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; zoneId: string; // home zone: spawn inside, stay inside count?: number; // initial population at game start (default 0) initialState?: Record<string, unknown>; speedMps?: number; // default 2 behavior?: BehaviorNode; tunable?: TunableParam[];}id— referenced by spawned bots (Player.botClassId) and by GM tunable calls.name— the prefix for spawned bot names ("Tag Bot 1","Tag Bot 2"); also shown in the GM’s bot listings.teamId— team to place bots on. Omit for free-for-all games.zoneId— the class’s home zone, a zone id from the game’s map. Bots spawn at a random point inside it and are confined to it (the engine clamps their movement at the edge and teleports escapees back inside). Game start refuses when the home zone is missing or hasn’t been placed — there is no implicit “arena”.count— initial population, spawned into the home zone at game start (default 0). Listcountintunableto give the GM a knob; the engine reconciles the live population to the knob, even mid-game.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.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 } zoneId: play_area count: 0 # human-first; the GM dials bots in via the knob speedMps: 2 tunable: - { key: speedMps, label: "Speed (m/s)", min: 0.5, max: 8, step: 0.5 } - { key: count, label: "Bots", min: 0, max: 12, step: 1 } 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: "chase_target"; idKey?: string } | { kind: "goto_zone"; zoneRef: string | { ofTeam: "same" | "other" } } | { kind: "collect_nearest_item"; itemKind?: string; where?: string } | { 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.chase_target { idKey? }— head toward one specific player named by the bot’s own state: the player whoseidequalsstate[idKey](idKeydefaults to"targetId"). Succeeds while steering toward that player; fails if the key is unset (or non-string), or the named player is gone, dead (state.alive === false), or has no position. Use it to pursue an assigned mark (e.g. an Assassin’s target chain) rather than the nearest opponent.goto_zone { zoneRef }— head toward a placed zone’s center.zoneRefis either a literalzoneIdor a team-relative reference{ ofTeam: "same" | "other" }, resolved each tick to the first placed, non-exclusion zone whoseteamIdmatches ("same") or differs ("other") from the bot’s own team. Succeeds while steering toward the zone; fails if no matching zone is placed (or, for anofTeamref, the bot has no team). The steering target is the center of the zone’s bounding box.collect_nearest_item { itemKind?, where? }— head toward the nearest un-held in-world item.itemKindfilters by the item’skind;whererestricts to items currently inside the named placed zone. Steering only — the engine’s worldItem pickup interaction does the actual collection on contact. Fails when no matching item exists (orwherenames a zone that isn’t placed).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. (collect_nearest_item’s where
is different — a plain zoneId string, not a state filter.)
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 knob. The class field is the
default; the GM’s override (per class, or per bot) wins at evaluation time.
count is special: it adjusts the class’s live population and is class-level
only (never a per-bot override).
tunable: - { key: speedMps, label: "Speed (m/s)", min: 0.5, max: 8, step: 0.5 } - { key: count, label: "Bots", min: 0, max: 12, step: 1 }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.