Game object

Every game is a class extending one of the GameBase hierarchy:

Base class turnModel() When to use
GameBase "sequential" Default — one stack entry per ply; turns advance in seat order
GameBaseSimultaneous "simultaneous" One stack entry per round; lastmove is comma-split per seat (Pigs, Entropy, …)
GameBaseSkipTurn "skip-turn" Inactive seats skipped in turn order; null in export for eliminated players (Armadas, Homeworlds)
GameBaseSequenced "sequenced" Seat may act multiple times before the cycle completes; sparse one-row-per-ply export (optional sugar — see mixin hooks)

See Creating games — Choosing a base class for a decision guide and worked examples.

Required abstract methods

Method Purpose
move(m, opts?) Apply a move string; return updated game
render(opts?) Return APRenderRep (or array) for the renderer
state(opts?) Return IAPGameState snapshot
load(idx) Load stack position (default: latest)
clone() Deep copy
moveState() Snapshot for pushing onto stack (protected)

GameBaseSkipTurn also requires isSeatActive(seat, stackIndex) — whether a 1-based seat may act at the pre-move state for stack[stackIndex].

Mixin hooks (plyActor, shouldCloseRound)

All bases inherit overridable hooks from GameBase (implemented in _turn-plies.ts and base getRounds()). Override these when turn structure is not strict round-robin but you are not using skip-turn nulls or simultaneous comma-moves:

Hook Default behaviour
plyActor(stackIndex) Actor = stack[stackIndex - 1].currplayer
shouldCloseRound(roundPlies, stackIndex) Close every numplayers plies (every ply when numplayers === 1)
getRounds() Pack plies into numplayers-wide rows via buildRoundRow
compactExportRounds() Trim trailing null seats per row (sequential export)

GameBaseSequenced sets turnModel()"sequenced", uses seat-cycle shouldCloseRound, sparse getRounds() (one row per ply), and skips trailing-null compaction. See Sequenced turn model for a full example (duplicate actions per round, round-close rules, Frogger refills).

Frogger (refills variant) is migrating to this model; shipped code still uses legacy stack skipto via _turn-sequenced-skipto.ts. See Sequenced turn model for the target refactor shape.

Do not override moveHistory() for export fixes — bots and legacy tests depend on the frozen stride shape.

State shape (IAPGameState)

{
  game: string;        // uid
  numplayers: number;
  variants: string[];
  gameover: boolean;
  winner: number[];
  stack: IIndividualState[];
}

You typically don't need to alter IAPGameState, but if there is game-wide information you need to store (information that doesn't change move to move), this is the most efficient place to store it.

Each IIndividualState requires _version, _results, _timestamp. The rest is up to the game itself. It's really up to the developer how they want to structure things. As long as the game code will correctly hydrate a saved state, you're good.

Provided by GameBase

Serialization: serialize(), undo(), resign(), timeout(), draw(), abandoned().

UI: handleClick(), moves(), validateMove(), sidebarStatuses(), getButtons() (when flagged).

History and records: moveHistory(), getPlies(), getRounds(), recordExportExclude(), resultsHistory(), chatLog(), chat(), genRecord().

Turn model and record export

There are two related layers:

  1. Canonical turn structuregetPlies() / getRounds() walk the stack with ply-correct round boundaries. Slots include full _results (no export filtering).
  2. Published gamerecordgenRecord().moves comes from the export pipeline below.
flowchart LR
  GP[getPlies]
  GR[getRounds]
  RE[recordExportExclude]
  FR[filterRoundsForRecord]
  CE[compactExportRounds]
  GML[getMoveList / genRecord.moves]
  GP --> GR
  GR --> FR
  RE --> FR
  FR --> CE
  CE --> GML

getPlies() and getRounds()

Simultaneous and skip-turn games keep full row width (including null columns); sequential games may trim trailing null seats in export only.

recordExportExclude()

Override this protected hook to control which _results.type values are stripped from published move slots in genRecord().moves. It does not change getRounds() — only the export copy.

Default (no override needed for most games):

protected recordExportExclude(): string[] {
    return ["eog", "winners"];
}

eog and winners are already in the gamerecord header; omitting them from per-move result arrays avoids duplication.

When to override: your game previously used getMoveList()getMovesAndResults([...exclude]) to hide annotation types from the published record (e.g. per-move move, place, capture objects that duplicate the move string). Copy the same type list into recordExportExclude() — do not override getMoveList().

Example (Volcano — omits move annotations from export):

protected recordExportExclude(): string[] {
    return ["move", "eog", "winners"];
}

Example (Tablero — several annotation types):

protected recordExportExclude(): string[] {
    return ["place", "take", "pass", "eog", "winners"];
}

Do not override getMoveList() for export filtering. The default implementation is:

protected getMoveList(): any[] {
    return this.compactExportRounds(
        this.filterRoundsForRecord(this.getRounds(), this.recordExportExclude()),
    );
}

Only override getMoveList() if export row shape must differ from getRounds() after filtering (rare; Armadas 3+ used to be an example — now handled by GameBaseSkipTurn + buildRoundRow).

genRecord() header

genRecord() sets header["turn-model"] from turnModel() (Phase 4). Values: sequential, simultaneous, sequenced, skip-turn. Recranks and stats consumers use this to replay sequenced/skip-turn rounds and count null-aware move totals; legacy records without the header keep stride replay and rec.moves.length.

moveHistory() (frozen)

Legacy stride-based grouping (i += numplayers). Still used by bots, AiAi, and some golden tests. Not the source for genRecord().moves after the Phase 1b export pipeline.

Do not override moveHistory() to fix record export — use getRounds() / recordExportExclude() instead.

getMovesAndResults() (deprecated for export)

Frozen stride shim for old code paths. New games should not call it. Migrating games: replace getMoveList() { return this.getMovesAndResults([...]); } with recordExportExclude() only.

IRenderOpts

Optional render() arguments: perspective, altDisplay, hideLayer. Games with stacking-expanding pass click coordinates through render options.

IClickResult

Returned by handleClick: valid, message, move, optional complete and canrender.

Example games

Solo play (numplayers === 1)

Solo titles use ordinary GameBase with turnModel: "sequential" — there is no SoloGameBase. A class may serve both solo and multiplayer (playercounts: [1, 2, …]); solo-only paths activate when numplayers === 1.

Outcome types

Declare the outcome model via getSoloOutcomeMeta():

outcome-type Record fields Score source
binary passed (boolean), optional score getPlayerScore() + getBinaryPassed()
graded grade (tier id), score getPlayerScore() + getGradeTiers()
score score only getPlayerScore()
timed score = elapsed ms getPlayerElapsedMs() (stack timestamps)

Games must declare score-direction (higher or lower) for binary, graded, and score. timed always archives score-direction: lower (faster is better).

Types and helpers live in _solo-outcome.ts: evaluateGrade(), computeElapsedMs(), soloScoreDirection().

Seeded RNG (GameRng)

New solo games that need deterministic puzzles use GameRng (seedrandom.alea) — not unseeded Math.random().

Step Contract
Create challengeSeed optional; resolveChallengeSeed() / generateChallengeSeed() assign one before play
Setup initRng(seed) before any random event
Play Pass this.rng to randomInt() / shuffle() / Deck.shuffle(rng)
Save saveState() snapshots rngCounter on each stack entry (via attachSoloStateFields)
Load restoreSoloRngFromEntry(stack[idx]) after copying board state
Archive genRecord() writes header["challenge-seed"] when present

Strategy A (persist outcomes): store rolls, draw-pile order (drawPile / serializeDrawOrder()), etc. on stack entries — do not re-roll on load().

Strategy B (RNG stream): rngCounter on stack entries enables catch-up without replaying all prior draws.

randomInt and shuffle accept an optional GameRng; default remains Math.random() so shipped multiplayer games need no changes.

Replay helpers

replay.ts: replayToStackIndex() and assertReplayMatches() for tests and recranks validation.

Move-handler rules (solo)

Rule Why
Never call randomInt / shuffle when emulation: true Preview must not advance RNG
Never reroll if stack[idx].roll already set Dice games
Never shuffle() in load() if draw order on stack Deck games
Consume RNG only on committed moves Optional rolls / stymie

Canoe syncFromStackEntry() + emulation tests are the reference pattern for dice — inspiration only; do not retrofit shipped multiplayer titles.

genRecord() solo header (additive)

Solo EOG uses {type: "eog"} plus outcome fields — not {type: "winners"} as the primary narrative.

Structured chat log (chatLogEntries)

Some solo games expose an opt-in structured move log for correct attribution of automated opponents (e.g. El Oso’s bear on seat 2).

Export Role
ChatActorRef, ChatLogLine, ChatLogEntry Types in chat-log.ts
formatChatLogEntries, formatChatLogEntryNodes Resolve textKey / label actors at display time
chatPlayerToken, applyChatPlayerNames Seat lines embed Player N; only seat actors get display-name substitution

Game hooks

Consumer integration (playground / front)

  1. If typeof game.chatLogEntries === "function", call formatChatLogEntryNodes(entries, playerNames, t) (or formatChatLogEntries for a flat list).
  2. For solo (numplayers === 1), pass one human display name — do not map seat 2 to a second player name.
  3. Otherwise fall back to chatLog(playerNames) and existing replace logic.
  4. Optional: render line.actor.kind === "label" with t(actor.key) for styling; formatChatLogEntries substitutes the label into textParams.player when present.

El Oso is the first consumer; other games stay on chatLog() until migrated in a follow-up audit.