Math Expressions
Transform steps support safe math expressions for computing derived values server-side.
# Math Expressions
Transform steps support safe math expressions for computing derived values server-side.
## Syntax
Use the `expression` field instead of `value` on a transform step:
```yml
id: player_level
type: transform
expression: floor({{player.xp}} / {{values.progression.xp_per_level}})
```
All `{{...}}` template variables are resolved first, then the resulting numeric expression is evaluated using a safe parser (no eval). Dynamic path segments are supported inside templates:
```yml
id: fish_value
type: transform
expression: "{{player.inventory.{{input.fishIndex}}.value}}"
```
The inner `{{input.fishIndex}}` resolves first, then the runner reads that inventory row.
## Operators
| Operator | Example | Result |
|----------|---------|--------|
| `+` | `10 + 5` | `15` |
| `-` | `10 - 3` | `7` |
| `*` | `4 * 5` | `20` |
| `/` | `10 / 3` | `3.333...` |
| `%` | `2750 % 1000` | `750` (modulus/remainder) |
Operator precedence follows standard math: `*`, `/`, `%` before `+`, `-`. Use parentheses to override.
## Functions
| Function | Description | Example | Result |
|----------|-------------|---------|--------|
| `floor(x)` | Round down | `floor(3.7)` | `3` |
| `ceil(x)` | Round up | `ceil(3.2)` | `4` |
| `round(x)` | Round to nearest | `round(3.5)` | `4` |
| `min(a, b, ...)` | Smallest value (2+ args) | `min(5, 3)` | `3` |
| `max(a, b, ...)` | Largest value (2+ args) | `max(5, 3)` | `5` |
| `sum(collection, path)` | Sum array/object items, optionally by field path | `sum({{player.inventory}}, Value)` | Total item value |
| `avg(collection, path)` | Average array/object items, optionally by field path | `avg({{fish}}, WeightKg)` | Average weight |
| `count(collection)` | Count array items or object values | `count({{player.inventory}})` | Inventory count |
| `count(collection, predicate)` | Count matching items using a lambda | `count({{player.inventory}}, item => item.sold == false)` | Unsold count |
| `any(collection, predicate)` | True when at least one item matches | `any({{quests}}, item => item.done == true)` | `true`/`false` |
| `all(collection, predicate)` | True when every item matches | `all({{quests}}, item => item.done == true)` | `true`/`false` |
| `first(collection, predicate)` | First item, optionally matching a predicate | `first({{inventory}}, item => item.type == "rod")` | Object/null |
| `find(collection, predicate)` | Alias for first matching item | `find({{inventory}}, item => item.id == "rod_01")` | Object/null |
| `pluck(collection, path)` | Select a field from each item | `pluck({{inventory}}, Value)` | Array |
| `abs(x)` | Absolute value | `abs(-5)` | `5` |
| `random(min, max)` | Random integer (inclusive) | `random(1, 10)` | `1`--`10` |
| `pow(base, exp)` | Exponentiation | `pow(2, 3)` | `8` |
| `clamp(val, min, max)` | Clamp value to range | `clamp(15, 0, 10)` | `10` |
| `now()` | Current Unix timestamp (ms) | `now()` | `1711612800000` |
Functions can be nested: `floor(max(0, {{a}} - {{b}}))`
Aggregate functions accept arrays or objects. When you pass a field path, each item is read by that path and missing values count as `0`; non-numeric selected values fail loudly instead of being silently ignored. Use the implicit `item` name or your own lambda parameter for predicates and computed selectors:
```yml
# Array of fish objects: [{ Value: 10 }, { Value: 25 }]
inventory_value: "sum({{player.inventory}}, Value)"
# Object/map of bins: { raw: { totalValue: 20 }, cooked: { totalValue: 7 } }
bin_value: "sum({{player.bins}}, totalValue)"
# Predicate count / validation helpers
unsold_count: "count({{player.inventory}}, item => item.sold == false)"
has_trophy: "any({{player.inventory}}, item => item.rarity == \"trophy\")"
all_claimed: "all({{player.dailyRewards}}, item => item.claimed == true)"
# Pull values or one matching object for response payloads / later checks
values: { value: "{{pluck(player.inventory, Value)}}" }
first_rod: { value: "{{first(player.inventory, item => item.type == \"rod\")}}" }
```
## Common Patterns
### Compute Level from XP
Store XP in the collection, compute level on the fly:
```yml
id: level
type: transform
expression: floor({{player.xp}} / {{values.progression.xp_per_level}})
```
With `xp = 2750` and `xp_per_level = 1000`: level = 2
### XP Remaining to Next Level
```yml
id: xp_remaining
type: transform
expression: "{{values.progression.xp_per_level}} - ({{player.xp}} % {{values.progression.xp_per_level}})"
```
With `xp = 2750` and `xp_per_level = 1000`: remaining = 250
### XP Progress Percentage
```yml
id: progress_pct
type: transform
expression: floor(({{player.xp}} % {{values.progression.xp_per_level}}) * 100 / {{values.progression.xp_per_level}})
```
With `xp = 2750` and `xp_per_level = 1000`: progress = 75%
### Tax Calculation (5% with minimum of 1)
```yml
id: tax
type: transform
expression: max(1, floor({{item.cost}} * 0.05))
```
### Sum Inventory Value
Use `sum()` instead of hard-coding every possible inventory index:
```yml
id: inventory_totals
type: compute
values:
total_value: "sum({{player.inventory}}, Value)"
unsold_count: "count({{player.inventory}}, item => item.sold == false)"
has_legendary: "any({{player.inventory}}, item => item.rarity == \"legendary\")"
```
This works for both arrays and object maps.
### VIP Discount (20% off)
```yml
id: discounted
type: transform
expression: floor({{item.cost}} * (1 - {{values.shop.vip_discount}}))
```
### Streak Bonus (capped multiplier)
```yml
id: reward
type: transform
expression: floor({{values.daily.base_reward}} * min({{values.daily.max_multiplier}},
1 + {{player.login_streak}} * {{values.daily.streak_multiplier}}))
```
### K/D Ratio (prevent division by zero)
```yml
id: kd_ratio
type: transform
expression: round({{player.stats.kills}} * 100 / max(1, {{player.stats.deaths}}))
/ 100
```
### ELO Rating Change
```yml
id: elo_change
type: transform
expression: round(32 * ({{match_result}} - {{expected_score}}))
```
### Random Loot Amount
```yml
id: loot_amount
type: transform
expression: random({{values.loot.min_drop}}, {{values.loot.max_drop}})
```
### Exponential XP Curve
```yml
id: xp_for_next
type: transform
expression: floor(pow({{level}} + 1, 2) * {{values.progression.xp_base}})
```
### Clamped Damage (bounded range)
```yml
id: final_damage
type: transform
expression: clamp({{raw_damage}} - {{target.armor}}, {{values.combat.min_damage}},
{{values.combat.max_damage}})
```
### Time-Based Cooldown Check
```yml
id: time_since_last
type: transform
expression: now() - {{player.lastClaimTimestamp}}
```
## Using Computed Values
Transform results are stored in the context and available to all subsequent steps:
```yml
- id: level
type: transform
expression: floor({{player.xp}} / {{values.progression.xp_per_level}})
- id: check_level
type: condition
check:
field: level
op: '>='
value: "{{node.xp_required}}"
```
## Expression Checks
`assert` and `condition` checks can use `check.expression` when the predicate is just a math expression plus an optional comparison. This removes one-off transform steps used only for validation.
```yml
- id: rod_owned
type: assert
check:
expression: "max(0, {{num(player.itemInventory.{{rod.id}}, 0)}}) > 0"
status: 409
errorCode: EQUIPPED_ROD_NOT_OWNED
```
Comparison operators supported in expression checks are `>`, `<`, `>=`, `<=`, `==`, and `!=`. If no comparison appears, the numeric result is treated as truthy: non-zero passes, zero fails.
You can also compute the right-hand side of a regular field comparison:
```yml
check:
field: player.gold
op: ">="
expression: "{{input.quantity}} * {{item.price}}"
```
## Returning Computed Values
Include computed values in the response so the game client can display them:
```yml
response:
status: 200
body:
xp: "{{player.xp}}"
level: "{{level}}"
xp_remaining: "{{xp_remaining}}"
progress_pct: "{{progress_pct}}"
```
## Security
- Only numbers, operators, functions, parentheses, commas, and whitespace are allowed
- No string operations, variable assignment, or arbitrary code
- Maximum 1000 characters after template resolution
- Most `{{...}}` variables in math must resolve to numeric values; aggregate inputs like `sum({{player.inventory}}, Value)` may resolve to arrays or objects
- Unresolved variables throw an error (not silently treated as 0)