The hooks
The six hooks are the deciding half of a game, and they all live in TypeScript. Each section below ends with what reaches the client — because a hook's real output is not its return value, it is what a player ends up looking at.
Everything returns an Envelope<State>: the new state, the
pendingPlayers who may act next (empty ⇒ game over), an optional outcome
(present only when the game ends), and an optional turnSeconds override
for this one action. See the Envelope reference.
initialState({ config, rng, playerCount }) → Envelope
The starting position. Draw any setup randomness (shuffle, first player) from
rng. Set pendingPlayers to whoever moves first.
On the client: the first frame of the game, projected through
computeObservation with cause: null. There is no predecessor, so a game
should render it as a static opening position rather than animating into it.
applyAction({ state, pending, data, playerIndex, config, rng }) → Envelope
A player's move. The engine has already confirmed it is this seat's turn at the
expected version — do not re-check turn order. Validate move legality only;
if it fails, throw new IllegalMoveError("…") and the engine renders it as the
caller's error. Any other throw is treated as a game bug (a server 500). Return
the next envelope: advance the state, set the next pendingPlayers, and include
outcome if this move ended the game.
On the client: the legality check you write here is transcribed into the Dart
isValidAction, which greys out the illegal tap before it is ever sent. The two
are compared by twin fixtures. The acting seat also gets an
ActionSubmitResult telling it whether a confirming frame is coming — see
Rendering.
applyLifecycle({ state, pending, type, data, rng }) → Envelope
Resolve an out-of-rules event. Unlike applyAction it can never be "illegal" —
it always resolves. Three triggers:
timeout— the seats inpendingran out of time. Resolve the whole set in one envelope (you decide the consequence — often a loss for the idle seat, or a draw if everyone stalled).forfeit— a voluntary resign; the seat is indata.playerIndex.autoForfeit— the engine-driven variant (an account was deleted). Same shape as forfeit; you may choose a gentler consequence (a draw rather than a loss) since the seat did not choose to quit.
On the client: an ordinary frame, arriving unprompted. Nothing in the game
screen needs to know it came from a lifecycle event — gameStatus flips to
finished and outcomes populates like any other ending.
computeObservation({ state, pending, playerIndex, cause, isReplay, … }) → ObservationSlice
Project the state into one seat's view — this is where hidden information
lives, and it is the hook with the most leverage in the whole contract. Return
{ data, pendingPlayers }:
datais exactly what this seat may see. Strip anything hidden (opponents' hands, face-down cards, un-revealed simultaneous commits).pendingPlayersmay be narrowed from the true set to avoid leaking information — for example hiding that an opponent has secretly moved — but it must stay truthful about the seat itself, and the engine enforces that.playerIndexisnullfor a public viewer (only ever withisReplay: true, a finished public game), where you can reveal everything.causetells the seat what just happened;isReplayis true only for finished-game replay.
For a perfect-information game, use the shipped passthroughObservation
helper — every seat sees the full state and the true pending set.
On the client: this hook's return value is the only game data that exists.
parseObservation consumes it, buildContent draws it, and anything you did not
project simply is not on the device. Note that the shape may differ between live
play and replay — see Payload types.
What you reveal here decides which concurrent submissions the engine accepts. See Hidden information.
ratingPool({ access, turnSeconds, budgetSeconds, config, … }) → string | null
Decide whether — and in which pool — a game with these settings is rated. Return
a pool name ("standard", "rapid", …) or null for unrated. The engine
computes canBeRated = pool !== null && !guest and validates the client's
concrete rated flag against it.
On the client: the Dart twin of this function decides whether the create
dialog shows a Rated toggle at all. It is display-only — but the client sends a
concrete rated value and the server rejects a mismatch with a 422 rather
than coercing, so a drifted twin is a visible bug, not a silent one. See
Creation UI.
botSeatable({ gameConfig, botConfig }) → boolean
Whether a bot's declared capabilities support this game config. Return true to
allow the seating.
On the client: the Dart twin filters the bot picker locally, with no network call. Also display-only; the server enforces the same rule before seating. See Bots.