Skip to content

Rules: conditions

A rule’s when is a condition — the trigger. When it’s satisfied, the rule’s actions run. This page documents every condition kind in the Condition union from packages/wage-protocol/src/rules.ts.

Conditions also bind the players involved so actions know who to act on: same is the primary bound player, other is the partner. Each entry below notes what it binds. See Selectors & values for the binding model.

{ kind: "entered_zone"; who: PlayerSelector; zoneId: string }

Fires once when a player matching who crosses into the placed zone zoneId. Binds that player as same. Edge-triggered.

- id: enter_hill
when: { kind: entered_zone, who: any, zoneId: hill }
do:
- { kind: set_state, target: same, key: inHill, value: true }
{ kind: "left_zone"; who: PlayerSelector; zoneId: string }

Fires once when a matching player crosses out of zoneId. Binds that player as same. Edge-triggered. (Players in an exclusion cutout count as outside.)

- id: out_of_bounds
when: { kind: left_zone, who: any, zoneId: play_area }
do:
- { kind: send_event, to: same, event: { type: toast, text: "Out of bounds." } }
{ kind: "proximity"; a: PlayerSelector; b: PlayerSelector; meters: number }

Fires when two players (one matching a, one matching b) come within meters (haversine distance). Binds a as same and b as other. Edge-triggered per pair — it won’t re-fire while they stay close; they must separate and re-approach.

- id: tackle
when:
kind: and
children:
- { kind: proximity, a: any, b: any, meters: 3 }
- { kind: state_equals, who: same, key: carrying, value: true }
- { kind: compare, op: ne, left: { ref: same.teamId }, right: { ref: other.teamId } }
do:
- { kind: set_state, target: same, key: carrying, value: false }

Source: apps/wage-engine/src/games/pirates_booty/game.yaml.

{ kind: "tick"; everyMs: number }

Fires periodically, every everyMs milliseconds. Binds no one on its own — use it to gate how often a rule fires, and put player-binding conditions before it in an and.

- id: score_tick
when:
kind: and
children:
- { kind: state_equals, who: any, key: inHill, value: true } # binds same
- { kind: count_state, who: any, key: inHill, value: true, n: 1 }
- { kind: tick, everyMs: 1000 }
do:
- { kind: increment_state, target: same, key: score, by: 1 }
{ kind: "state_equals"; who: PlayerSelector; key: string; value: unknown }

Matches each player (in who) whose state[key] strictly equals value, binding them as same. Used both as a top-level trigger and (very commonly) as a filter inside an and.

- { kind: state_equals, who: same, key: it, value: true }
{ kind: "count_state"; who: PlayerSelector; key: string; value: unknown; n: number }

Fires while exactly n players in who have state[key] === value. Binds the matching players. Two classic uses:

  • n: 0 — “nobody is it anymore” (self-heal; see ensure a role exists).
  • n: 1 — “exactly one player on the hill” / “last one standing.”
# Win when only one player remains alive.
- id: winner
when:
kind: and
children:
- { kind: count_state, who: any, key: alive, value: true, n: 1 }
- { kind: state_equals, who: same, key: alive, value: true }
do:
- { kind: send_event, to: same, event: { type: toast, text: "You win." } }

Source: apps/wage-engine/src/games/assassin/game.json.

{ kind: "compare"; op: CompareOp; left: Value; right: Value; who?: PlayerSelector }
type CompareOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "contains";

The general two-value comparison. Iterates who (default same) and fires for each bound player whose left/right satisfy op. Subsumes state_equals (it’s compare(eq, { ref: "same.state.K" }, V)), but is far more flexible because either side can be a { ref }.

# Anyone whose score reaches 30.
- { kind: compare, who: any, op: gte, left: { ref: same.state.score }, right: 30 }
# A blue carrier vs. a red tagger (cross-team check).
- { kind: compare, op: ne, left: { ref: same.teamId }, right: { ref: other.teamId } }
# Match a player against their own target id.
- { kind: compare, op: eq, left: { ref: other.id }, right: { ref: same.state.targetId } }

The operators: eq (=), ne (≠), gt/gte/lt/lte (numeric ordering), contains (membership / substring).

{ kind: "interaction"; verb: string; a?: PlayerSelector; b?: PlayerSelector; itemKind?: string }

Fires when a player-initiated interaction of the given verb resolves (immediately for confirm: none, on confirmation for confirm: mutual). Binding depends on the interaction’s target:

  • Player interactions — binds initiator as same, target as other. a constrains the initiator (default any), b the target.
  • Item interactions (worldItem/heldItem) — binds the initiator as same and the item as other (read other.kind, other.id, other.position.lat). itemKind optionally filters by the item’s kind.

Range and eligibility are enforced server-side by the interaction’s own rangeMeters/filters; this condition only matches the verb and the a/b selectors.

- id: tag
when:
kind: and
children:
- { kind: interaction, verb: tag }
- { kind: state_equals, who: same, key: it, value: true }
- { kind: state_equals, who: other, key: it, value: false }
do:
- { kind: set_state, target: same, key: it, value: false }
- { kind: set_state, target: other, key: it, value: true }

Source: apps/wage-engine/src/games/tag/game.yaml.

{ kind: "and"; children: Condition[] }
{ kind: "or"; children: Condition[] }
{ kind: "not"; child: Condition }
  • and — all children must hold. Order matters: put a player-binding condition first so later children (state_equals who: same, compare) have a same/other to filter, and put tick last as a frequency gate.
  • or — any child holds.
  • not — the child does not hold.
when:
kind: and
children:
- { kind: proximity, a: any, b: any, meters: 5 } # binds same/other
- { kind: state_equals, who: same, key: alive, value: true }
- { kind: state_equals, who: other, key: alive, value: true }