Skip to content

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). List count in tunable to give the GM a knob; the engine reconciles the live population to the knob, even mid-game.
  • initialState — starting state for every bot of this class (the same bag humans get from playerJoinStates).
  • speedMps — movement speed, meters/second (default 2). GM-tunable when listed in tunable.
  • behavior — the behavior tree (below). If omitted, bots fall back to a legacy state.behavior lookup.
  • 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.

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" };
  • 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”).
  • state_equals { key, value } — succeeds iff the bot’s own state[key] strictly equals value. Used as a guard inside a sequence.

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 whose id equals state[idKey] (idKey defaults 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. zoneRef is either a literal zoneId or a team-relative reference { ofTeam: "same" | "other" }, resolved each tick to the first placed, non-exclusion zone whose teamId matches ("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 an ofTeam ref, 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. itemKind filters by the item’s kind; where restricts 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 (or where names 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.)

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 } # Fallback
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 }
{ 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: red

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