XP and Leveling System for s&box Games
Create a progression system that stores `xp`, not `level`. Level is derived from XP using Game Values, so you can rebalance progression without migrating player...
# XP and Leveling System for s&box Games
Create a progression system that stores `xp`, not `level`. Level is derived from XP using Game Values, so you can rebalance progression without migrating player rows or trusting client math.
This is the right pattern for RPGs, shooters, tycoons, fishing games, and any s&box game where XP unlocks content.
## Why store XP instead of level?
If you store `level`, a balance change creates data drift: old players keep old levels while new players use the new curve. If you store only `xp`, the server can compute level from the current Game Values every time.
## Collection: `player_progress`
Create `collections/player_progress.collection.yml`:
```yml
sourceVersion: "1"
kind: collection
name: player_progress
description: Persistent player XP. Level is computed from XP and never stored.
collectionType: per-steamid
accessMode: endpoint
maxRecords: 1
allowRecordDelete: false
requireSaveVersion: true
rateLimits:
mode: none
rateLimitAction: reject
schema:
type: object
properties:
xp:
type: number
min: 0
_ledger: true
lifetimeXp:
type: number
min: 0
_ledger: true
gold:
type: number
min: 0
_ledger: true
rewardClaims:
type: object
additionalProperties:
type: boolean
```
## Game Values: progression curve and rewards
Create `collections/game_values.collection.yml` or merge this into your existing source:
```yml
sourceVersion: "1"
kind: collection
name: game_values
description: Progression tunables for XP and level rewards.
collectionType: game_values
accessMode: endpoint
schema: {}
constants:
- id: progression
name: Progression
entries:
xp_per_level: 1000
max_level: 100
match_xp_cap: 250
tables:
- id: level_rewards
name: Level Rewards
columns:
- key: id
type: string
- key: requiredLevel
type: number
- key: goldReward
type: number
rows:
- id: level_5
requiredLevel: 5
goldReward: 500
- id: level_10
requiredLevel: 10
goldReward: 1500
- id: level_25
requiredLevel: 25
goldReward: 7500
```
## Workflow: calculate level from XP
Create `workflows/progression_from_xp.workflow.yml`:
```yml
sourceVersion: "1"
kind: workflow
id: progression_from_xp
name: Progression From XP
description: Computes level information from raw XP and current Game Values.
params:
xp:
type: number
steps:
- id: safe_xp
type: transform
expression: "max(0, {{xp}})"
- id: level
type: transform
expression: "min({{values.progression.max_level}}, floor({{safe_xp}} / {{values.progression.xp_per_level}}))"
- id: next_level_xp
type: transform
expression: "min({{values.progression.max_level}} * {{values.progression.xp_per_level}}, ({{level}} + 1) * {{values.progression.xp_per_level}})"
- id: xp_into_level
type: transform
expression: "{{safe_xp}} - ({{level}} * {{values.progression.xp_per_level}})"
returns:
xp: "{{safe_xp}}"
level: "{{level}}"
nextLevelXp: "{{next_level_xp}}"
xpIntoLevel: "{{xp_into_level}}"
```
## Endpoint: `award-match-xp`
Use this from P2P/client-hosted games to award XP to the authenticated caller. The server clamps each award and rate limits total XP gain; dedicated servers can make a separate secret-key variant that accepts `targetSteamId`.
Create `endpoints/award-match-xp.endpoint.yml`:
```yml
sourceVersion: "1"
kind: endpoint
name: Award Match XP
slug: award-match-xp
method: POST
enabled: true
input:
type: object
properties:
matchId:
type: string
xp:
type: number
required:
- matchId
- xp
steps:
- id: player
type: read
collection: player_progress
key: "{{playerKey}}"
- id: old_xp
type: transform
expression: "max(0, {{num(player.xp, 0)}})"
- id: award_xp
type: transform
expression: "clamp(floor({{input.xp}}), 0, {{values.progression.match_xp_cap}})"
- id: new_xp
type: transform
expression: "{{old_xp}} + {{award_xp}}"
- id: progress
type: workflow
workflow: progression_from_xp
params:
xp: "{{new_xp}}"
- id: apply
type: write
collection: player_progress
key: "{{playerKey}}"
ops:
- op: set
path: xp
value: "{{new_xp}}"
source: match
reason: "Match {{input.matchId}}"
- op: inc
path: lifetimeXp
value: "{{award_xp}}"
source: match
reason: "Match {{input.matchId}}"
response:
status: 200
body:
ok: true
xpAwarded: "{{award_xp}}"
xp: "{{progress.xp}}"
level: "{{progress.level}}"
nextLevelXp: "{{progress.nextLevelXp}}"
xpIntoLevel: "{{progress.xpIntoLevel}}"
```
## Endpoint: `get-progression`
Create `endpoints/get-progression.endpoint.yml`:
```yml
sourceVersion: "1"
kind: endpoint
name: Get Progression
slug: get-progression
method: POST
enabled: true
input:
type: object
properties: {}
steps:
- id: player
type: read
collection: player_progress
key: "{{playerKey}}"
- id: xp
type: transform
expression: "max(0, {{num(player.xp, 0)}})"
- id: progress
type: workflow
workflow: progression_from_xp
params:
xp: "{{xp}}"
response:
status: 200
body:
ok: true
xp: "{{progress.xp}}"
level: "{{progress.level}}"
nextLevelXp: "{{progress.nextLevelXp}}"
xpIntoLevel: "{{progress.xpIntoLevel}}"
```
## Optional endpoint: claim a level reward
Create `endpoints/claim-level-reward.endpoint.yml`:
```yml
sourceVersion: "1"
kind: endpoint
name: Claim Level Reward
slug: claim-level-reward
method: POST
enabled: true
input:
type: object
properties:
rewardId:
type: string
required:
- rewardId
steps:
- id: player
type: read
collection: player_progress
key: "{{playerKey}}"
- id: reward
type: lookup
source: values
table: level_rewards
where:
field: id
op: "=="
value: "{{input.rewardId}}"
- id: reward_exists
type: condition
check:
field: "{{reward.id}}"
op: exists
onFail:
status: 404
errorCode: REWARD_NOT_FOUND
- id: progress
type: workflow
workflow: progression_from_xp
params:
xp: "{{num(player.xp, 0)}}"
- id: high_enough
type: condition
check:
field: "{{progress.level}}"
op: ">="
value: "{{reward.requiredLevel}}"
onFail:
status: 403
errorCode: LEVEL_TOO_LOW
message: "Reach level {{reward.requiredLevel}} first."
- id: not_claimed
type: condition
check:
field: "{{player.rewardClaims.{{reward.id}}}}"
op: "!="
value: true
onFail:
status: 409
errorCode: REWARD_ALREADY_CLAIMED
- id: apply
type: write
collection: player_progress
key: "{{playerKey}}"
ops:
- op: set
path: "rewardClaims.{{reward.id}}"
value: true
- op: inc
path: gold
value: "{{reward.goldReward}}"
source: level_reward
reason: "Claimed {{reward.id}}"
response:
status: 200
body:
ok: true
rewardId: "{{reward.id}}"
goldReward: "{{reward.goldReward}}"
level: "{{progress.level}}"
```
## Sync the same formula into C#
Use Game Values as the single source of truth. Your UI can render the same level as the endpoint:
```csharp
var data = await NetworkStorage.CallEndpoint( "get-progression" );
if ( data.HasValue )
{
int xp = data.Value.Int( "xp" );
int level = data.Value.Int( "level" );
int nextLevelXp = data.Value.Int( "nextLevelXp" );
Log.Info( $"Level {level} ({xp}/{nextLevelXp} XP)" );
}
```
## Recommended rate limits
| Rule | Collection | Field | Scope | Window | Limit | Action |
|------|------------|-------|-------|--------|-------|--------|
| `xp_daily_cap` | `player_progress` | `xp` | per_player | perDay | 25000 | clamp |
| `xp_award_throttle` | `player_progress` | `lifetimeXp` | per_player | perMinute | 3000 | reject |
The endpoint clamps each match award and the rate limit caps total daily XP, giving you two layers of anti-cheat protection.