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.

Example: RPG Game

A complete example showing how to build server-authoritative game logic using endpoints, Game Values, workflows, and rate limits. This covers a full RPG with co...

# Example: RPG Game

A complete example showing how to build server-authoritative game logic using endpoints, Game Values, workflows, and rate limits. This covers a full RPG with combat, a shop, mining, and a daily login streak system.

YAML Source is the standard for endpoint, workflow, and collection definitions. All examples in this guide use YAML Source and the s&box Network Storage library.

## Prerequisites

Install the **Network Storage by sboxcool.com** library: [sbox.game/sboxcool/network-storage](https://sbox.game/sboxcool/network-storage). Source: [github.com/sbox-cool/sbox-network-storage](https://github.com/sbox-cool/sbox-network-storage)

See the [Library Setup Guide](/wiki/network-storage-v3/library-setup) for configuration instructions.

## Setup

### Collections

**player_data** (Per-Player, Endpoint controlled):

```yml
type: object
properties:
playerName:
type: string
gold:
type: number
min: 0
_ledger: true
xp:
type: number
min: 0
_ledger: true
ore:
type: object
properties:
copper_ore:
type: number
min: 0
iron_ore:
type: number
min: 0
gold_ore:
type: number
min: 0
inventory:
type: array
items:
type: object
dailyStreak:
type: number
min: 0
lastDailyClaim:
type: string
stats:
type: object
properties:
kills:
type: number
min: 0
deaths:
type: number
min: 0
playTime:
type: number
min: 0
```

There is no `level` field. Level is computed from `xp` using the `xp_per_level` Game Value: `floor(xp / xp_per_level)`.

### Game Values

**progression** (K,V Group):

| Key | Value |
|-----|-------|
| `xp_per_level` | `1000` |
| `max_level` | `100` |

**combat** (K,V Group):

| Key | Value |
|-----|-------|
| `xp_per_kill` | `50` |
| `xp_per_assist` | `15` |
| `gold_per_kill` | `10` |

**daily** (K,V Group):

| Key | Value |
|-----|-------|
| `base_gold_reward` | `100` |
| `streak_multiplier` | `10` |
| `max_streak_bonus` | `500` |

**shop_items** (Table):

| item_id | name | cost | xp_required | category |
|---------|------|------|-------------|----------|
| `sword_iron` | Iron Sword | 100 | 0 | weapon |
| `sword_gold` | Gold Sword | 500 | 10000 | weapon |
| `armor_plate` | Plate Armor | 1000 | 25000 | armor |
| `health_potion` | Health Potion | 25 | 0 | consumable |

**mining_nodes** (Table):

| node_id | name | ore_type | ore_amount | xp_reward | xp_required |
|---------|------|----------|------------|-----------|-------------|
| `copper` | Copper Node | copper_ore | 5 | 10 | 0 |
| `iron` | Iron Node | iron_ore | 3 | 25 | 10000 |
| `gold` | Gold Node | gold_ore | 1 | 100 | 25000 |

### Rate Limits

| Rule Name | Collection | Field | Scope | Window | Limit | Action |
|-----------|------------|-------|-------|--------|-------|--------|
| `xp_daily_cap` | player_data | xp | per_player | perDay | 10,000 | clamp |
| `gold_hourly_cap` | player_data | gold | per_player | perHour | 50,000 | clamp |
| `save_throttle` | * | * | per_player | perMinute | 30 | reject |

---

## Endpoint: report-kill

When a player kills something, the client sends the event. The server looks up the XP and gold reward from Game Values, computes the player's new level, and writes the result.

**Slug:** `report-kill`
**Method:** POST

**Input Schema:**

```yml
target:
type: string
required: true
```

**Steps:**

```yml
- id: player
type: read
collection: player_data
key: "{{steamId}}"
- id: new_xp
type: transform
expression: "{{player.xp}} + {{values.combat.xp_per_kill}}"
- id: new_level
type: transform
expression: floor({{new_xp}} / {{values.progression.xp_per_level}})
- id: award
type: write
collection: player_data
key: "{{steamId}}"
ops:
- op: inc
path: xp
value: "{{values.combat.xp_per_kill}}"
source: combat
reason: Killed {{input.target}}
- op: inc
path: gold
value: "{{values.combat.gold_per_kill}}"
source: combat
reason: Kill reward for {{input.target}}
- op: inc
path: stats.kills
value: 1
```

**Response:**

```yml
xp: "{{new_xp}}"
gold: "{{player.gold}}"
level: "{{new_level}}"
xpAwarded: "{{values.combat.xp_per_kill}}"
goldAwarded: "{{values.combat.gold_per_kill}}"
```

The XP daily cap (10,000/day) automatically applies. If the player has earned 9,990 XP today and gets 50 more, it clamps to 10 XP.

### C# Client

```csharp
var data = await NetworkStorage.CallEndpoint( "report-kill", new { target = "training_dummy" } );
if ( !data.HasValue )
{
Log.Warning( "Kill report failed." );
return;
}

var xp = data.Value.Int( "xp", 0 );
var gold = data.Value.Int( "gold", 0 );
var level = data.Value.Int( "level", 1 );
Log.Info( $"Kill rewarded! XP: {xp}, Gold: {gold}, Level: {level}" );
```

---

## Endpoint: purchase-item

Player buys an item. Server looks up cost from Game Values, validates gold and XP requirement (not level), deducts gold, and adds the item to inventory.

**Slug:** `purchase-item`
**Method:** POST

**Input Schema:**

```yml
itemId:
type: string
required: true
```

**Steps:**

```yml
- id: player
type: read
collection: player_data
key: "{{steamId}}"
- id: item
type: lookup
source: values
table: shop_items
where:
field: item_id
op: ==
value: "{{input.itemId}}"
- id: check_xp
type: condition
check:
left: "{{player.xp}}"
op: '>='
right: "{{item.xp_required}}"
onFail:
status: 403
error: XP_TOO_LOW
message: You need {{item.xp_required}} XP to buy {{item.name}}. You have {{player.xp}}.
- id: check_gold
type: condition
check:
left: "{{player.gold}}"
op: '>='
right: "{{item.cost}}"
onFail:
status: 403
error: NOT_ENOUGH_GOLD
message: You need {{item.cost}} gold but only have {{player.gold}}.
- id: buy
type: write
collection: player_data
key: "{{steamId}}"
ops:
- op: inc
path: gold
value: "{{-item.cost}}"
source: shop
reason: Bought {{item.name}}
- op: push
path: inventory
value:
itemId: "{{input.itemId}}"
name: "{{item.name}}"
category: "{{item.category}}"
```

**Response:**

```yml
purchased: "{{item.name}}"
cost: "{{item.cost}}"
remainingGold: "{{player.gold}}"
```

### C# Client

```csharp
var data = await NetworkStorage.CallEndpoint( "purchase-item", new { itemId = "sword_gold" } );
if ( !data.HasValue )
{
Log.Warning( "Purchase failed." );
return;
}

var itemName = data.Value.Str( "purchased", "item" );
var remaining = data.Value.Int( "remainingGold", 0 );
Log.Info( $"Purchased {itemName}! Gold remaining: {remaining}" );
```

---

## Endpoint: mine-node

Player mines a resource node. Server validates XP requirement (not level), looks up ore amounts from Game Values, and awards resources.

**Slug:** `mine-node`
**Method:** POST

**Input Schema:**

```yml
nodeId:
type: string
required: true
```

**Steps:**

```yml
- id: player
type: read
collection: player_data
key: "{{steamId}}"
- id: node
type: lookup
source: values
table: mining_nodes
where:
field: node_id
op: ==
value: "{{input.nodeId}}"
- id: check_xp
type: condition
check:
left: "{{player.xp}}"
op: '>='
right: "{{node.xp_required}}"
onFail:
status: 403
error: XP_TOO_LOW
message: You need {{node.xp_required}} XP to mine {{node.name}}. You have {{player.xp}}.
- id: new_xp
type: transform
expression: "{{player.xp}} + {{node.xp_reward}}"
- id: new_level
type: transform
expression: floor({{new_xp}} / {{values.progression.xp_per_level}})
- id: mine
type: write
collection: player_data
key: "{{steamId}}"
ops:
- op: inc
path: xp
value: "{{node.xp_reward}}"
source: mining
reason: Mined {{node.name}}
- op: inc
path: ore.{{node.ore_type}}
value: "{{node.ore_amount}}"
```

**Response:**

```yml
ore: "{{node.ore_type}}"
amount: "{{node.ore_amount}}"
xpAwarded: "{{node.xp_reward}}"
totalXp: "{{new_xp}}"
level: "{{new_level}}"
```

If a player with 5,000 XP tries to mine a gold node (requires 25,000 XP), the endpoint rejects with `XP_TOO_LOW`. The client never controls how much ore is awarded.

### C# Client

```csharp
var data = await NetworkStorage.CallEndpoint( "mine-node", new { nodeId = "iron" } );
if ( !data.HasValue )
{
Log.Info( "Cannot mine this node yet." );
return;
}

var ore = data.Value.Str( "ore", "ore" );
var amount = data.Value.Int( "amount", 0 );
var level = data.Value.Int( "level", 1 );
Log.Info( $"Mined {amount}x {ore}! Level: {level}" );
```

---

## Endpoint: claim-daily

Player claims a daily login reward. The reward scales with their login streak. Uses math functions to compute a bonus with a cap.

**Slug:** `claim-daily`
**Method:** POST

**Input Schema:**

```yml
# no input fields
```

No input needed -- the endpoint reads the player's streak and computes the reward.

**Steps:**

```yml
- id: player
type: read
collection: player_data
key: "{{steamId}}"
- id: streak_bonus
type: transform
expression: min(floor({{player.dailyStreak}} * {{values.daily.streak_multiplier}}),
{{values.daily.max_streak_bonus}})
- id: total_reward
type: transform
expression: "{{values.daily.base_gold_reward}} + {{streak_bonus}}"
- id: new_streak
type: transform
expression: "{{player.dailyStreak}} + 1"
- id: claim
type: write
collection: player_data
key: "{{steamId}}"
ops:
- op: inc
path: gold
value: "{{total_reward}}"
source: daily_reward
reason: Daily login day {{new_streak}}
- op: set
path: dailyStreak
value: "{{new_streak}}"
- op: set
path: lastDailyClaim
value: "{{now}}"
```

**Response:**

```yml
goldAwarded: "{{total_reward}}"
streakBonus: "{{streak_bonus}}"
currentStreak: "{{new_streak}}"
totalGold: "{{player.gold}}"
```

**How the math works:**

With `base_gold_reward = 100`, `streak_multiplier = 10`, and `max_streak_bonus = 500`:

| Day | Streak | Streak Bonus | Total Reward |
|-----|--------|-------------|--------------|
| 1 | 0 | `min(floor(0 * 10), 500)` = 0 | 100 |
| 2 | 1 | `min(floor(1 * 10), 500)` = 10 | 110 |
| 5 | 4 | `min(floor(4 * 10), 500)` = 40 | 140 |
| 10 | 9 | `min(floor(9 * 10), 500)` = 90 | 190 |
| 50 | 49 | `min(floor(49 * 10), 500)` = 490 | 590 |
| 60 | 59 | `min(floor(59 * 10), 500)` = 500 (capped) | 600 |

The `min()` function caps the streak bonus at 500 gold, preventing infinite scaling.

### C# Client

```csharp
var data = await NetworkStorage.CallEndpoint( "claim-daily" );
if ( !data.HasValue )
{
Log.Warning( "Daily claim failed." );
return;
}

var goldAwarded = data.Value.Int( "goldAwarded", 0 );
var streakBonus = data.Value.Int( "streakBonus", 0 );
var streak = data.Value.Int( "currentStreak", 0 );

Log.Info( $"Daily reward: {goldAwarded} gold (streak bonus: {streakBonus}, day {streak})" );
```

---

## Putting It All Together

Here is a complete C# helper class that wraps all four endpoints:

```csharp
public static class RPGEndpoints
{
public static async Task ReportKill( string target )
{
var data = await NetworkStorage.CallEndpoint( "report-kill", new { target } );
if ( !data.HasValue ) return;

var level = data.Value.Int( "level", 1 );
Log.Info( $"Kill: +{data.Value.Int( "xpAwarded", 0 )} XP (Level {level})" );
}

public static async Task PurchaseItem( string itemId )
{
var data = await NetworkStorage.CallEndpoint( "purchase-item", new { itemId } );
if ( !data.HasValue ) return;
Log.Info( $"Bought {data.GetProperty( "purchased" ).GetString()}!" );
}

public static async Task MineNode( string nodeId )
{
var data = await NetworkStorage.CallEndpoint( "mine-node", new { nodeId } );
if ( !data.HasValue ) return;

Log.Info( $"Mined {data.Value.Int( "amount", 0 )}x {data.Value.Str( "ore", "ore" )}" );
}

public static async Task ClaimDaily()
{
var data = await NetworkStorage.CallEndpoint( "claim-daily" );
if ( !data.HasValue ) return;

Log.Info( $"Daily: +{data.Value.Int( "goldAwarded", 0 )} gold (day {data.Value.Int( "currentStreak", 0 )})" );
}
}
```