Skip to content

Demo players

Demo players are engine-driven, simulated players that occupy player slots, follow a game’s objectives, and trigger its real rules — so a designer can watch a freshly authored ruleset play out, and a prospective player can preview what gameplay looks like, without real phones in real geography.

They are deliberately distinct from bots. A bot is a first-class game entity the design depends on; a demo player is a debugging/preview tool that lives outside the game design, works for any ruleset that opts in, and is visibly labeled as a simulation. A ruleset opts in by declaring an optional top-level demo block.

The rules engine treats every player the same — proximity, state, and zone conditions evaluate identically regardless of kind. The kind only changes how positions update each tick and how the GM console labels the marker.

KindPosition sourcePart of game design?Counts for minPlayers?Declared where
humanPhone GPSn/ayesjoins via the four-letter code
botEngine (behavior tree)yesnoRuleset.bots: BotClass[]
demoEngine (behavior tree)nonoRuleset.demo?: DemoPolicy
interface DemoPolicy {
count: number;
zoneId: string; // home zone: spawn inside, teleport home
initialState?: Record<string, unknown>;
balanceTeams?: boolean; // default true
behavior: BehaviorNode;
}
  • count — default roster size when the GM hits Fill to N in the Demo players card.
  • zoneId — the demo roster’s home zone, a zone id from the game’s map (mirrors BotClass.zoneId). Demo players spawn at a random point inside it. Because they simulate humans, their movement roams the union of all placed zones — the home zone is the spawn point and the teleport-home target if one ends up outside every zone. Game start refuses when this zone is missing or unplaced.
  • initialState — starting state for each demo player. Optional. When omitted, demo players seed from playerJoinStates in join order (see Seeding below); supply initialState only when you want every demo player to start identically regardless of join order.
  • balanceTeams — alternate demo joiners between teams in team games. Default true. Ignored for games with no teams.
  • behavior — the behavior tree driving every demo player each tick. It uses the same BehaviorNode grammar as BotClass.behavior, so there is one vocabulary to learn — see Bots & behavior trees for the full node vocabulary.

From Pirate’s Booty (apps/wage-engine/src/games/pirates_booty/game.yaml):

# Demo players for previewing a 2v2 raid without four phones. Three branches:
# carrying a chest → run it home to my crew's ship; empty-handed → go grab the
# nearest chest on Treasure Island; otherwise wander. balanceTeams (default)
# splits demo joiners across the two crews so deposits happen on both ships.
demo:
count: 4
zoneId: treasure_island
behavior:
kind: selector
children:
- kind: sequence
children:
- { kind: state_equals, key: carrying, value: true }
- { kind: goto_zone, zoneRef: { ofTeam: same } }
- { kind: collect_nearest_item, itemKind: treasure_chest }
- { kind: wander }

Read top to bottom: a carrier runs the chest home to its own crew’s ship; an empty-handed pirate steers toward the nearest treasure chest; everyone else wanders. balanceTeams is left at its default, so demo joiners alternate between Pearl and Revenge Crew and deposits happen on both ships.

Demo players reuse the shared behavior-tree grammar, but three nodes were added with simulated objective-following in mind. They work for bots too — see Bots & behavior trees for the authoritative definitions:

  • goto_zone — steer toward a placed zone’s center. zoneRef is either a literal zone id or { ofTeam: "same" | "other" }, resolved against the acting player’s team at evaluation time. Pirate’s Booty uses { ofTeam: same } so a carrier heads for its own ship.
  • collect_nearest_item — steer toward the nearest un-held world item, optionally filtered by itemKind and confined to a zone (where). It only steers; the engine’s normal pickup handling claims the item on contact. Fails when no matching item exists, so a sibling wander can take over.
  • chase_target — head toward one specific player named by the actor’s own state (default key targetId). Useful where a game assigns each player an individual mark to pursue.

Demo players draw from playerJoinStates in join order, the same way humans do as they arrive. This reproduces a game’s asymmetric roles across a demo-only roster without any extra rule: the first “it”, the patient-zero zombie, the lone shark, or the single bomb-holder all fall out of the join order naturally. When you set demo.initialState, it overrides this and every demo player starts from that bag instead.

When balanceTeams is true (the default) and the ruleset has teams, each new demo joiner alternates onto the smaller team so a team game previews with both sides populated.

  • minPlayers. Demo players don’t count toward minPlayers. Their mere presence puts the session in preview mode, which waives the floor — so a demo-only roster can start a game that would otherwise require, say, four humans.
  • maxPlayers. Still bounds humans only. Adding demo players never eats into the human cap.
  • Demo players card. When a ruleset declares a demo block, the GM console shows a Demo players card in the Players panel with Add, Fill to N (fills the roster up to demo.count), and Clear. The card is hidden for rulesets with no demo block.
  • Dashed markers. On the GM map, demo players render with a dashed marker outline and a “Demo” label, so they are never mistaken for humans or game bots.
  • Dashboards. GM dashboard metrics aggregate over all players — human, bot, and demo — so a demo-only preview reads correctly.

Demo mode is entirely opt-in and game-agnostic: any ruleset can support it by adding a demo block whose behavior expresses how a simulated player should pursue that game’s objectives. Rulesets without a demo block simply don’t show the Demo players card and reject demo-roster API calls.