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.

Inventory System for s&box Games

Build a server-authoritative stackable inventory with Network Storage. This example is designed for survival games, fishing games, RPGs, and shop-driven sandbox...

# Inventory System for s&box Games

Build a server-authoritative stackable inventory with Network Storage. This example is designed for survival games, fishing games, RPGs, and shop-driven sandbox games where clients must not be trusted to add items or spend currency.

The pattern is simple: store item quantities on the player row, store item definitions in Game Values, validate purchases in a workflow, then mutate gold and inventory in one endpoint.

## What this system gives you

- Stackable item quantities keyed by item id.
- Item catalog in Game Values so prices and stack sizes can be rebalanced without shipping a new build.
- A reusable `inventory_validate_purchase` workflow.
- `buy-item` and `consume-item` endpoints that never trust client prices.
- Ledger metadata on gold changes for auditing.

## Collection: `player_inventory`

Create `collections/player_inventory.collection.yml`:

```yml
sourceVersion: "1"
kind: collection
name: player_inventory
description: Player-owned currency and stackable inventory items.
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
items:
type: object
additionalProperties:
type: number
lifetimeItemsBought:
type: number
min: 0
```

## Game Values: item catalog

Create `collections/game_values.collection.yml` or merge this table into your existing Game Values source:

```yml
sourceVersion: "1"
kind: collection
name: game_values
description: Server-authored item catalog for inventory examples.
collectionType: game_values
accessMode: endpoint
schema: {}
tables:
- id: item_catalog
name: Item Catalog
columns:
- key: id
type: string
- key: displayName
type: string
- key: price
type: number
- key: maxStack
type: number
- key: consumable
type: boolean
rows:
- id: health_potion
displayName: Health Potion
price: 25
maxStack: 20
consumable: true
- id: iron_ore
displayName: Iron Ore
price: 5
maxStack: 999
consumable: false
- id: repair_kit
displayName: Repair Kit
price: 150
maxStack: 5
consumable: true
```

## Workflow: validate a purchase

Create `workflows/inventory_validate_purchase.workflow.yml`:

```yml
sourceVersion: "1"
kind: workflow
id: inventory_validate_purchase
name: Inventory Validate Purchase
description: Checks item existence, requested quantity, stack room, and player gold.
params:
player:
type: object
item:
type: object
quantity:
type: number
steps:
- id: item_exists
type: condition
check:
field: "{{item.id}}"
op: exists
onFail:
status: 404
errorCode: ITEM_NOT_FOUND
message: Unknown item id.
- id: quantity_valid
type: condition
check:
field: "{{quantity}}"
op: ">"
value: 0
onFail:
status: 400
errorCode: INVALID_QUANTITY
message: Quantity must be greater than zero.
- id: current_qty
type: transform
expression: "max(0, {{num(player.items.{{item.id}}, 0)}})"
- id: next_qty
type: transform
expression: "{{current_qty}} + floor({{quantity}})"
- id: stack_room
type: condition
check:
field: "{{next_qty}}"
op: "<="
value: "{{item.maxStack}}"
onFail:
status: 409
errorCode: STACK_FULL
message: "{{item.displayName}} can only stack to {{item.maxStack}}."
- id: total_cost
type: transform
expression: "floor({{item.price}} * {{quantity}})"
- id: can_afford
type: condition
check:
field: "{{player.gold}}"
op: ">="
value: "{{total_cost}}"
onFail:
status: 402
errorCode: NOT_ENOUGH_GOLD
message: "Need {{total_cost}} gold."
returns:
currentQty: "{{current_qty}}"
nextQty: "{{next_qty}}"
totalCost: "{{total_cost}}"
```

## Endpoint: `buy-item`

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

```yml
sourceVersion: "1"
kind: endpoint
name: Buy Item
slug: buy-item
method: POST
enabled: true
input:
type: object
properties:
itemId:
type: string
quantity:
type: number
required:
- itemId
- quantity
steps:
- id: player
type: read
collection: player_inventory
key: "{{playerKey}}"
- id: item
type: lookup
source: values
table: item_catalog
where:
field: id
op: "=="
value: "{{input.itemId}}"
- id: purchase
type: workflow
workflow: inventory_validate_purchase
params:
player: "{{player}}"
item: "{{item}}"
quantity: "{{input.quantity}}"
- id: spend
type: transform
expression: "0 - {{purchase.totalCost}}"
- id: apply
type: write
collection: player_inventory
key: "{{playerKey}}"
ops:
- op: inc
path: gold
value: "{{spend}}"
source: shop
reason: "Bought {{input.quantity}}x {{item.displayName}}"
- op: set
path: "items.{{item.id}}"
value: "{{purchase.nextQty}}"
- op: inc
path: lifetimeItemsBought
value: "{{input.quantity}}"
response:
status: 200
body:
ok: true
itemId: "{{item.id}}"
quantity: "{{purchase.nextQty}}"
goldSpent: "{{purchase.totalCost}}"
```

## Endpoint: `consume-item`

Create `endpoints/consume-item.endpoint.yml`:

```yml
sourceVersion: "1"
kind: endpoint
name: Consume Item
slug: consume-item
method: POST
enabled: true
input:
type: object
properties:
itemId:
type: string
quantity:
type: number
required:
- itemId
- quantity
steps:
- id: player
type: read
collection: player_inventory
key: "{{playerKey}}"
- id: item
type: lookup
source: values
table: item_catalog
where:
field: id
op: "=="
value: "{{input.itemId}}"
- id: consumable
type: condition
check:
field: "{{item.consumable}}"
op: "=="
value: true
onFail:
status: 400
errorCode: ITEM_NOT_CONSUMABLE
message: This item cannot be consumed.
- id: current_qty
type: transform
expression: "max(0, {{num(player.items.{{input.itemId}}, 0)}})"
- id: enough_items
type: condition
check:
field: "{{current_qty}}"
op: ">="
value: "{{input.quantity}}"
onFail:
status: 409
errorCode: NOT_ENOUGH_ITEMS
message: Not enough items.
- id: next_qty
type: transform
expression: "max(0, {{current_qty}} - floor({{input.quantity}}))"
- id: apply
type: write
collection: player_inventory
key: "{{playerKey}}"
ops:
- op: set
path: "items.{{input.itemId}}"
value: "{{next_qty}}"
response:
status: 200
body:
ok: true
itemId: "{{input.itemId}}"
quantity: "{{next_qty}}"
```

## C# call from s&box

```csharp
var result = await NetworkStorage.CallEndpoint( "buy-item", new
{
itemId = "health_potion",
quantity = 3
} );

if ( result.HasValue )
{
Log.Info( $"Bought potions. New stack: {result.Value.Int( "quantity" )}" );
}
```

## Recommended rate limits

| Rule | Collection | Field | Scope | Window | Limit | Action |
|------|------------|-------|-------|--------|-------|--------|
| `inventory_buy_throttle` | `player_inventory` | `gold` | per_player | perMinute | 60 | reject |
| `gold_daily_spend_watch` | `player_inventory` | `gold` | per_player | perDay | 100000 | flag |

This endpoint is safe for public client calls because the item price, stack size, and final mutation are all server-side.