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.

Move table presentation (getMoveTableRounds)

Gamerecord export stays on getRounds() / getMoveList() — do not change those shapes for UI convenience.

For round-grid move trees (sequenced, simultaneous, skip-turn), clients call:

API Role
getMoveTableRounds({ density?, pathLength? }) Rows for the UI; density is compact (default) or sparse
pathIndexForMoveTableCell({ density, model, useRoundGrid, … }) Map a grid cell to exploration path index

Defaults on GameBase:

Helpers (packPliesForMoveTable, moveTableRoundsFromExplorationPath, resolveMoveTableRounds) live in _turn-move-table.ts. Override getMoveTableRounds only when QA shows the generic branch is wrong for a specific game.

State shape (IAPGameState)

{
  game: string;        // uid
  numplayers: number;
  variants: string[];  // active variant uids — see [Variants](/gameslib/variants/)
  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().

Flags: getFlags() returns effective flags for this session (variant and player-count aware). Override static resolveFlags(context) on the game class when flags depend on context. See Flags. Session capability (getFlags() includes pie / pie-even) is separate from turn phase (isPieTurn(), isKomiTurn() on individual games).

UI: handleClick(), moves(), validateMove(), sidebarStatuses(), sidebarScores(), getButtons() (when flagged). Sidebar status/score labels use structured RenderLabel objects (seatStatusValue(), neutralAreaLabel()); front resolves them at display time. See Structured render labels.

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

Turn model and record export

There are two related layers:

  1. Canonical turn structure — getPlies() / getRounds() walk the stack with ply-correct round boundaries. Slots include full _results (no export filtering).
  2. Published gamerecord — genRecord().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.

Move table presentation (getMoveTableRounds)

Gamerecord export uses getRounds() (for GameBaseSequenced, sparse one row per ply). The live / playground move tree may use compact rows (one seating-indexed row per logical round when each seat acts at most once per round).

When a stack frame expands to multiple plies (simultaneous wire, Thricewise select, …), compact density still uses getRounds() (stack-indexed rows).

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 (legacy single uid), altDisplays (active toggle uids), hideLayer. Use hasDisplay(opts, uid) inside render() instead of branching on altDisplay alone. See Alternative displays. Games with stacking-expanding pass click coordinates through render options.

Render labels

Area and board label fields may be plain strings or structured RenderLabel objects (textKey, optional textParams, optional actor). Use seatAreaLabel(seat, textKey) on GameBase for player-owned pieces areas; front resolves usernames and i18n at draw time. Full guide: Structured render labels.

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

Move-handler rules (live exploration)

On /move/, exploration uses emulation: true for preview plies. Games with no-explore never hit this path — see Exploration and emulation.

Explore-allowed references: WaldMeister and Frogger (persist stack on emulated complete moves without hidden scoring/refill tails). Do not use Canoe or Emu as templates (no-explore on live play).

genRecord() solo header (additive)

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

Structured move log (chatLogEntries)

Structured move logs separate who spoke (ChatActorRef) from what was said (i18n textKey / textParams), so consumers substitute display names and style automated actors at render time. Full guide: Structured move log.

Export Role
ChatActorRef, ChatLogLine, ChatLogEntry, ChatLogCollectContext 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

Override chatLogEntries for simultaneous indexing or aggregated frames — see the guide.

Consumer integration (playground / front)

  1. Call formatChatLogEntryNodes(game.chatLogEntries(playerNames), 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. Optional: render line.actor.kind in the UI for label/neutral styling.