Bots & behavior trees
Bots are engine-controlled players. A ruleset declares one or more bot classes; each has movement, state, GM-tunable knobs, and a behavior tree re-evaluated every tick.
interface BotClass { id: string; name: string; description?: string; teamId?: string; initialState?: Record<string, unknown>; speedMps?: number; // default 2 minCount?: number; // pre-staged; can't remove below this maxCount?: number; // adds clamp to headroom behavior?: BehaviorNode; tunable?: TunableParam[];}id— referenced by spawned bots (Player.botClassId) and by GM tunable calls.name— shown on the GM’s bot card; also the prefix for spawned bot names ("Tag Bot 1","Tag Bot 2").teamId— team to place bots on. Omit for free-for-all games.initialState— startingstatefor every bot of this class (the same bag humans get fromplayerJoinStates).speedMps— movement speed, meters/second (default 2). GM-tunable when listed intunable.minCount/maxCount— population floor/ceiling.minCountbots are pre-staged at session creation; the engine refuses to drop below it. Omit for none.behavior— the behavior tree (below). If omitted, bots fall back to a legacystate.behaviorlookup.tunable— numeric knobs exposed to the GM.
bots: - id: tag-bot name: "Tag Bot" description: "Chases when it's 'it'; flees otherwise." initialState: { it: false } minCount: 0 maxCount: 12 speedMps: 2 tunable: - { key: speedMps, label: "Speed (m/s)", min: 0.5, max: 8, step: 0.5 } behavior: { kind: selector, children: [ /* … */ ] }Source: apps/wage-engine/src/games/tag/game.yaml.
BehaviorNode
Section titled “BehaviorNode”The tree uses the same nested kind/children grammar as conditions, so authors
learn one shape. It’s evaluated top-down every tick and the chosen action sets the
bot’s heading for that tick.
type BehaviorNode = | { kind: "selector"; children: BehaviorNode[] } | { kind: "sequence"; children: BehaviorNode[] } | { kind: "state_equals"; key: string; value: unknown } | { kind: "chase_nearest"; where?: { key: string; value: unknown } } | { kind: "flee_nearest"; where?: { key: string; value: unknown } } | { kind: "wander" };Composites
Section titled “Composites”selector— try children left-to-right; succeed at the first that succeeds (logical OR — “first applicable behavior wins”).sequence— run children left-to-right; succeed only if all succeed (logical AND — “guard, then act”).
Condition leaf
Section titled “Condition leaf”state_equals { key, value }— succeeds iff the bot’s ownstate[key]strictly equalsvalue. Used as a guard inside asequence.
Action leaves
Section titled “Action leaves”Each sets the bot’s intended heading and reports success/failure:
chase_nearest { where? }— head toward the nearest matching player; fails if no match exists.flee_nearest { where? }— head away from the nearest matching player; fails if no match.wander— hold the current bearing (or pick a random one); always succeeds — the natural fallback at the end of a selector.
where: { key, value } filters candidate target players by a single state
equality. Omit it to match any other player.
Example
Section titled “Example”behavior: kind: selector children: - kind: sequence # If it → chase a non-it player children: - { kind: state_equals, key: it, value: true } - { kind: chase_nearest, where: { key: it, value: false } } - kind: sequence # Else if not it → flee the it player children: - { kind: state_equals, key: it, value: false } - { kind: flee_nearest, where: { key: it, value: true } } - { kind: wander } # FallbackTunable parameters
Section titled “Tunable parameters”interface TunableParam { key: string; // a field name on BotClass, e.g. "speedMps" label?: string; // GM-facing name; defaults to key min?: number; max?: number; step?: number; // default 1}Listing a field in tunable gives the GM a slider. The class field is the
default; the GM’s override (per class, or per bot) wins at evaluation time.
tunable: - { key: speedMps, label: "Speed (m/s)", min: 0.5, max: 8, step: 0.5 }teamAssignment
Section titled “teamAssignment”{ humanTeamId: string; botTeamId?: string }A top-level ruleset field (not part of a bot class). It pins human WebSocket
joiners to humanTeamId instead of auto-balancing across all teams — required
for asymmetric “humans vs bots” games so players never get sorted onto the bot
team.
teamAssignment: humanTeamId: blue botTeamId: redSource: apps/wage-engine/src/games/bots_vs_humans/game.json.