YAML Source is the Network Storage authoring standard.
Use the s&box Library Manager install for auto-updates. GitHub is best for agents, source review, contributions, and manual installs.

Upgrade Shop System for s&box Games

Build a server-authoritative upgrade shop with exponential pricing, max-level checks, and atomic currency spending. This pattern is based on real tycoon, fishin...

# Upgrade Shop System for s&box Games

Build a server-authoritative upgrade shop with exponential pricing, max-level checks, and atomic currency spending. This pattern is based on real tycoon, fishing, and vehicle-upgrade flows: the client asks to buy an upgrade id, while Network Storage computes the price and level on the server.

## What this system gives you

- Upgrade definitions in Game Values.
- Dynamic price formula: `baseCost * growth^currentLevel`.
- Reusable `upgrade_quote` workflow.
- One `buy-upgrade` endpoint for every upgrade row.
- No client-trusted price, current level, or max level.

## Collection: `player_upgrades`

Create `collections/player_upgrades.collection.yml`:

```yml
sourceVersion: "1"
kind: collection
name: player_upgrades
description: Player currency and purchased upgrade levels.
collectionType: per-steamid
accessMode: endpoint
maxRecords: 1
allowRecordDelete: false
requireSaveVersion: true
rateLimits:
mode: none
rateLimitAction: reject
schema:
type: object
properties:
gold:
type: number
min: 0
_ledger: true
upgrades:
type: object
additionalProperties:
type: number
lifetimeGoldSpent:
type: number
min: 0
_ledger: true
```

## Game Values: upgrade catalog

Create `collections/game_values.collection.yml`:

```yml
sourceVersion: "1"
kind: collection
name: game_values
description: Upgrade definitions and balance values.
collectionType: game_values
accessMode: endpoint
schema: {}
tables:
- id: upgrades
name: Upgrades
columns:
- key: id
type: string
- key: displayName
type: string
- key: baseCost
type: number
- key: growth
type: number
- key: maxLevel
type: number
- key: effectPerLevel
type: number
rows:
- id: rod_power
displayName: Rod Power
baseCost: 100
growth: 1.45
maxLevel: 50
effectPerLevel: 0.05
- id: boat_speed
displayName: Boat Speed
baseCost: 500
growth: 1.65
maxLevel: 25
effectPerLevel: 0.03
- id: inventory_value
displayName: Sell Value
baseCost: 250
growth: 1.55
maxLevel: 40
effectPerLevel: 0.04
```

## Workflow: `upgrade_quote`

Create `workflows/upgrade_quote.workflow.yml`:

```yml
sourceVersion: "1"
kind: workflow
id: upgrade_quote
name: Upgrade Quote
description: Computes current level, next level, cost, and effect for one upgrade.
params:
player:
type: object
upgrade:
type: object
steps:
- id: upgrade_exists
type: condition
check:
field: "{{upgrade.id}}"
op: exists
onFail:
status: 404
errorCode: UPGRADE_NOT_FOUND
message: Unknown upgrade.
- id: current_level
type: transform
expression: "max(0, floor({{num(player.upgrades.{{upgrade.id}}, 0)}}))"
- id: below_max
type: condition
check:
field: "{{current_level}}"
op: "<"
value: "{{upgrade.maxLevel}}"
onFail:
status: 409
errorCode: UPGRADE_MAXED
message: "{{upgrade.displayName}} is already max level."
- id: cost
type: transform
expression: "max(1, round({{upgrade.baseCost}} * pow({{upgrade.growth}}, {{current_level}})))"
- id: next_level
type: transform
expression: "{{current_level}} + 1"
- id: next_effect
type: transform
expression: "{{next_level}} * {{upgrade.effectPerLevel}}"
returns:
currentLevel: "{{current_level}}"
nextLevel: "{{next_level}}"
cost: "{{cost}}"
nextEffect: "{{next_effect}}"
```

## Endpoint: `buy-upgrade`

Create `endpoints/buy-upgrade.endpoint.yml`:

```yml
sourceVersion: "1"
kind: endpoint
name: Buy Upgrade
slug: buy-upgrade
method: POST
enabled: true
input:
type: object
properties:
upgradeId:
type: string
required:
- upgradeId
steps:
- id: player
type: read
collection: player_upgrades
key: "{{playerKey}}"
- id: upgrade
type: lookup
source: values
table: upgrades
where:
field: id
op: "=="
value: "{{input.upgradeId}}"
- id: quote
type: workflow
workflow: upgrade_quote
params:
player: "{{player}}"
upgrade: "{{upgrade}}"
- id: can_afford
type: condition
check:
field: "{{player.gold}}"
op: ">="
value: "{{quote.cost}}"
onFail:
status: 402
errorCode: NOT_ENOUGH_GOLD
message: "Need {{quote.cost}} gold."
- id: spend
type: transform
expression: "0 - {{quote.cost}}"
- id: apply
type: write
collection: player_upgrades
key: "{{playerKey}}"
ops:
- op: inc
path: gold
value: "{{spend}}"
source: upgrade_shop
reason: "Bought {{upgrade.displayName}} level {{quote.nextLevel}}"
- op: set
path: "upgrades.{{upgrade.id}}"
value: "{{quote.nextLevel}}"
- op: inc
path: lifetimeGoldSpent
value: "{{quote.cost}}"
source: upgrade_shop
reason: "Bought {{upgrade.id}}"
response:
status: 200
body:
ok: true
upgradeId: "{{upgrade.id}}"
level: "{{quote.nextLevel}}"
goldSpent: "{{quote.cost}}"
nextEffect: "{{quote.nextEffect}}"
```

## Endpoint: `get-upgrade-quote`

Create `endpoints/get-upgrade-quote.endpoint.yml` so UI can show exact server prices before purchase:

```yml
sourceVersion: "1"
kind: endpoint
name: Get Upgrade Quote
slug: get-upgrade-quote
method: POST
enabled: true
input:
type: object
properties:
upgradeId:
type: string
required:
- upgradeId
steps:
- id: player
type: read
collection: player_upgrades
key: "{{playerKey}}"
- id: upgrade
type: lookup
source: values
table: upgrades
where:
field: id
op: "=="
value: "{{input.upgradeId}}"
- id: quote
type: workflow
workflow: upgrade_quote
params:
player: "{{player}}"
upgrade: "{{upgrade}}"
response:
status: 200
body:
ok: true
upgradeId: "{{upgrade.id}}"
currentLevel: "{{quote.currentLevel}}"
nextLevel: "{{quote.nextLevel}}"
cost: "{{quote.cost}}"
nextEffect: "{{quote.nextEffect}}"
```

## C# call from s&box

```csharp
var quote = await NetworkStorage.CallEndpoint( "get-upgrade-quote", new { upgradeId = "rod_power" } );
var buy = await NetworkStorage.CallEndpoint( "buy-upgrade", new { upgradeId = "rod_power" } );

if ( buy.HasValue )
{
Log.Info( $"Rod Power is now level {buy.Value.Int( "level" )}" );
}
```

## Recommended rate limits

| Rule | Collection | Field | Scope | Window | Limit | Action |
|------|------------|-------|-------|--------|-------|--------|
| `upgrade_spend_hourly` | `player_upgrades` | `gold` | per_player | perHour | 500000 | flag |
| `upgrade_buy_throttle` | `player_upgrades` | `lifetimeGoldSpent` | per_player | perMinute | 50 | reject |

This structure scales well: adding a new upgrade is just a new Game Values row, not a new endpoint.