Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

States

A machine holds a state that changes over time: a siege that waits, fights, breathes between waves and ends, or a round that counts down, plays and is won or lost. You declare it once with states(), and the engine keeps its snapshot in a trait, so a room dumps, streams, checkpoints and replays it as it does any trait. Every phase of a game, and the engine’s own round, is one.

states() takes XState’s config, so a machine reads as XState writes one: states, events, guards, nested states and history states. The engine runs XState’s pure transition on the snapshot in the trait and holds no actor of its own.

Unreal is the one engine of the others that ships a state machine for gameplay, StateTree. Roblox, Unity, Godot and PlayCanvas ship animation state machines and leave a game’s phases to an enum and a switch, which spreads one state across several fields. A machine here resumes an interrupted state where it left off, through a history state, and a room checkpoints it with the rest of the world.

Its API is in states.

Declare a machine beside the game’s traits. id is its name: the dump, the stream and the devtools list it by that name.

src/siege/machine.ts
import { states } from "@spawnite/engine";
export const Siege = states({
id: "siege",
initial: "waiting",
context: { wave: 0 },
states: {
waiting: { on: { START: "fight" } },
fight: { on: { WAVE_HELD: "breather", ALL_DOWN: "over" } },
breather: { wait: { seconds: 12, then: "fight" } },
over: {},
},
});

The machine types itself from the config: a state’s name and an event’s name are each written once. Sending an event that no state names, such as Siege.send(world, "HALT"), is a type error, and so is a target that names no state of the machine, in on, a wait’s then or a when rule. A target is a sibling’s key or its path, patrol.watch; a state of its own after a dot, .watch; or a state from the machine’s top by its id, #guard.patrol.watch. The machine’s own on, beside states, names a state after a dot, .answer, or by the id, as XState’s root does. The error sits on the states() call and lists the targets that name no state.

The context’s type comes from context too, so call states() with no type arguments: a type argument sets every other one to its default, which turns the checks above off and opens is to any key. Where a field holds more than its first value says, type the value itself, as context: { holder: null as number | null }.

Put the machine’s trait where it belongs. A game’s phases go on the world, with world.add(Siege.trait), or on an entity of their own in a room game: a room streams an entity’s traits, and the world’s own traits stay on the room. A machine for each thing, such as each guard’s patrol, goes on its entity: spawn the entity with Guard.trait. Pass the trait fields to start it with another context, as in Siege.trait({ wave: 3 }).

A machine on a character that is its player’s alone, such as a conversation’s, declares ownerOnly: true: a room streams its trait to that player and to nobody else, as it streams her bag, so another player’s page reads nothing of it. Unreal’s COND_OwnerOnly and Unity’s owner read permission on a NetworkVariable draw the same line. The dump shows it.

A system, a component or a test moves a machine with send and reads it with read. Each takes the world or an entity.

Siege.send(world, "WAVE_HELD"); // true: a state took it
Siege.read(world).value; // "breather"
Siege.read(world).secondsLeft; // 12, the seconds its wait has left
Siege.read(world).matches("breather"); // true

send returns whether a state took the event, including a transition that runs an action and moves nowhere. A state that names no such event, or whose guards all refuse it, leaves the machine as it was, and send returns false. A system that walks the machine’s trait with updateEach sends after the walk, or walks with readEach: updateEach writes its copy of the record back over the send, as the round’s system does. An event carries fields as XState’s do: Siege.send(world, { type: "WAVE_HELD", wave: 3 }), which a guard reads from event.

A page reads the top state a machine is in with useMachineState, which takes the entity that holds it, or the world for the world’s machine, and rerenders when that state changes, on a replica as the stream writes it, while a wait’s seconds leave the page as it is:

import { states, useMachineState } from "@spawnite/engine";
import { useQueryFirst } from "koota/react";
const Siege = states({
id: "siege",
initial: "fight",
states: { fight: { on: { WAVE_HELD: "breather" } }, breather: {} },
});
function PhaseBanner() {
const phase = useMachineState(Siege, useQueryFirst(Siege.trait));
return phase === "breather" ? <p>Catch your breath</p> : null;
}

It returns one of the machine’s top state names, typed from the config, so phase === "breathr" is a type error, and undefined while the entity is undefined or holds no record. A state that holds states reads as its own name, "patrol" while the guard is in patrol.watch, and so does a parallel state while its regions move. Read a state inside another with its tag, useHas(entity, Guard.is.patrol.watch). XState’s React binding reads a state the same way, with useSelector(actor, (snapshot) => snapshot.value).

The machine decides and the systems act. Its context fields sit on its trait beside the state, so a system reads and writes them as it writes any trait, and a guard reads them to choose a way out. An action on a transition, an entry or an exit runs once the new state is written. A machine starts in its initial state as its trait is added, so that state’s entry actions run the first time a transition enters it; what a game wants done at the start goes in the system that adds the trait.

Each state is a tag trait, Machine.is.<state>, and a state inside another is its parent’s tag’s key: Guard.is.patrol.watch. The tags follow the record: a send, a wait and a when move them as they move it, a spawn, the stream and a restore write them with it, and a parent’s tag stays while its child’s changes. A record spawned in a state holds that state’s tag from its spawn: RoundMachine.is.playing matches a round spawned in play before any step. The engine’s world writes the tags: a <Game>, createHeadlessGame, and a test’s world or createWorld fixture from @spawnite/engine/testing. A world made with koota’s own createWorld holds the record and writes no tag, so a test there reads every is as false. To put a machine in a state by hand, set its record’s state, as entity.set(Guard.trait, { state: "answer" }) or spawnite play set <entity> guard.state answer; its tags move at the start of the next step, before any system reads them, and a tool that names a tag rather than the state is refused. Find the entities in a state with a query, as you find DownedTrait ones:

world.query(Guard.is.answer);
world.query(TransformTrait, Not(Guard.is.patrol));
entity.has(RoundMachine.is.playing);

The dump names each tag <id>.<state>, such as "guard.patrol.watch": true. A room streams the record alone, and each replica writes the tags from the record it receives. The stream sends a record’s changed fields, and a replica reads a changed state as the machine holds it: a move to another child of a state replaces the child it left, and a region of a parallel state that moves leaves the other regions as they stand. A page that joins mid-run, or reloads into a room, receives the record whole and holds the state it names, with none of the machine’s initial state, as Unity’s Netcode and Unreal write a replicated value whole on a client’s first receipt. A checkpoint keeps the record alone too, and a restore writes the tags from it. The keys type the tags, so a misspelt state is a type error.

Each tag is a koota trait, and the dump tests every entity for every tag. A machine that many kinds of entity each declare their own of, such as an NPC’s routines, declares tags: false: its is is empty, it writes its record alone, and its systems read the state with read. Fifty kinds of NPC, a machine each of 36 states, make 1,800 tags, which make a dump of 1,000 NPCs about seven times slower. The tags cost the step little: a machine’s move writes them with its record.

A state’s when lists tests and the state each moves to. Each step, after the waits, the machine runs the tests of the state an entity is in, a child state’s before its parent’s, and moves to the target of the first that holds. A test takes the entity, the world’s own for the world’s machine, and the world:

const isSlow = (rider: Entity) =>
(rider.get(TrackMoverTrait)?.speed ?? 0) < stallSpeed;
sliding: { when: [[isSlow, "stalling"]] },
stalling: {
when: [[(rider) => !isSlow(rider), "sliding"]],
wait: { seconds: stallSeconds, then: "stalled" },
},

A wait that when enters counts that step’s seconds, as a wait a system’s send enters earlier in the step does. Use when for a condition that holds over time, such as a speed under a line, and send for what happens once, such as a hit. The step runs the machines in the order they were declared.

A state with wait holds for its seconds and then goes to then, a state or a transition: then: { target: "watch", reenter: true } on the watch state starts it over, entry actions and all. The fixed step counts the seconds, so a wait holds while the game pauses, and runs the same in a headless test. seconds may be a function of the context, as the round’s countdown is:

countdown: {
wait: { seconds: ({ context }) => context.countdown, then: "playing" },
},

A timer of the game’s own, such as a wave of enemies every 30 seconds, is a wait like these, or a system that counts the step’s ticks, as the spawner on the Health page does. Keep the wave count in the machine’s context, as the siege’s wave is, so every page, the dump and a save read it, and a Counter draws it.

A wait keeps its seconds on the trait, so a checkpoint restores it mid-way. A state left before its wait ends keeps the seconds it waited, and a history state that brings the state back resumes the wait from there. Entered any other way, or brought back by a history state that names another of its states, the wait starts from zero.

import { HistoryDepth, StateType, states } from "@spawnite/engine";
export const Guard = states({
id: "guard",
initial: "patrol",
states: {
patrol: {
initial: "watch",
states: {
watch: { wait: { seconds: 4, then: "rest" } },
rest: { wait: { seconds: 10, then: "watch" } },
resumed: {
type: StateType.History,
history: HistoryDepth.Deep,
},
},
on: { BELL: "answer" },
},
answer: { on: { RESUME: "patrol.resumed", RESTART: "patrol" } },
},
});

A state’s type and a history state’s history take the StateType and HistoryDepth enums, exported beside states. A guard three seconds into his watch hears the bell. RESUME brings him back to the watch with one second left; RESTART starts the patrol over.

Add the MachinePausedTrait trait to an entity to hold every machine on it where it is: until the trait comes off, a wait counts no seconds and a when goes untested, and an event sent to the machine still moves it, as Godot’s process_mode pauses a node. A game holds a character through a cutscene this way.

To hold one machine and leave the others running, name the traits that hold it in its heldBy: states({ id: "patrol", heldBy: [Stunned], ... }) holds the patrol’s wait and its when rules while the entity has Stunned, and its other machines run on. An NPC’s routine names the TalkingTrait trait, which a conversation adds, so a talking NPC holds its routine and every other machine on it keeps running.

A wait sits on a state with no states of its own, and a machine counts one wait at a time. XState’s own after delays fall outside that: they need an actor’s clock, and a snapshot does not keep them.

The trait holds the context’s fields and four of its own:

  • state: the state it is in, as XState writes a value, such as "breather" or { patrol: "watch" }.
  • waited: the seconds spent in the current wait.
  • paused: the seconds each wait was left at, by its state’s id, for a history state to bring back.
  • history: where each history state goes back to.

spawnite play dump shows a machine under its name, as it shows the round under round. The step runs every machine’s wait after the engine’s rules, so a wait that a rule enters in a step counts that step’s seconds.

A step reads each machine’s wait without calling XState, so a wait costs a sum a step. Only a transition calls XState, about 11 µs each, and it makes about 1.5 kB of garbage. On a laptop, the engine’s own bench measures the machines’ part of a step as follows:

Case Mean ms a step Worst 1% ms
1,000 machines, none waiting 0.10 0.25
1,000 machines in a long wait 0.16 0.41
100 machines, each moving on every 5 seconds 0.02 0.07
1,000 machines, each moving on once a second 0.50 11

The last case makes a thousand transitions a second, and its worst steps are the garbage collector’s pauses. Keep a machine’s transitions to what changes the game: a routine’s next step, not every frame.

The engine’s own page machines, the room connection and the play menu, run on XState’s actor, createActor, with its clock and its after delays, and show their state through a store: the connection’s through useRoom’s status, and the menu’s through useMenu and useCursor. A page machine belongs to one page and answers the browser, so no room steps it and no replay has to land it where it was: the actor’s timers on the wall’s clock serve it, and a store is what a HUD reads.

A game’s own machine uses states(): the room steps it on the fixed step, and a checkpoint or a replay brings it back mid-wait, which a timer on the wall’s clock cannot.

  • The inspector shows each machine an entity holds: its state, the seconds its wait has left, and its states.
  • The Wiki tab lists every machine under machine, with its states, the entities that hold it, and the state the world’s is in.
  • The AI tree says the state as a fact, such as “siege: breather, 8 s left”, and names an entity that nothing else names by its machine.