This doc is a map of the type chain a game definition travels through. It’s for contributors who maintain the libraries, not authors writing games. If you’re authoring a game, Mental model and Authoring with gamekit are what you want.Documentation Index
Fetch the complete documentation index at: https://openturn.io/docs/llms.txt
Use this file to discover all available pages before exploring further.
Why this doc exists
The type system has six well-defined transformation layers. When something breaks, it’s almost always inside one of them. Walking the chain top-down is faster than starting from the failing call site and tracing back. This page is the chain.The chain at a glance
Layer 1 — Author input
The literal you pass todefineGame({ maxPlayers, setup, moves, views, ... }) (or withPlugins({ ... }, plugins)).
What matters here:
maxPlayers: 2— TS sees the literal2, notnumber. The firstdefineGameoverload usesconst TMaxPlayers extends numberto capture that literal and deriveTPlayers = ["0", "1"].playerIDs: ["white", "black"] as const— alternative path. Second overload, captures the named-seat tuple.setup: () => TState— TState is whatever you return. View functions, move runners, computed selectors all see this.moves: ({ move }) => ({ ... })— the factory’s parametermoveis contextually typed by the gamekit definition shape somove<Args>({ run({G, args, player, move}) { ... } })infers everything.
withPlugins(...) as never pattern, which erased every generic before defineGame could reverse-infer.
Layer 2 — GamekitDefinition
packages/gamekit/src/index.ts — the typed input shape defineGame accepts. Eight generics:
| Generic | Source | Notes |
|---|---|---|
TState | setup return type | Must be JSON-compatible (checked via JsonCompatibilityChecks). |
TMoves | reverse-inferred from moves factory return | Each entry is GamekitMoveDefinition<...>. |
TPhase | union of initialPhase + phases keys + move-level phases | Defaults to "play". |
TPlayers | DefaultPlayerIDsBound<TMaxPlayers> or literal playerIDs tuple | The single most regression-prone slot. |
TComputed | computed map | Each computed function returns a JsonValue. |
TPublic / TPlayer | views.public/views.player return types | Default to TState if no views. |
TCoreNode | core.states keys | For gamekit-with-core compositions. |
TProfile | profile.default shape | ReplayValue constraint. |
withPlugins (packages/plugins/src/index.ts) mirrors defineGame’s overload structure on the input side and forwards through to defineGame internally — same generic surface, same contextual typing, plus a PluginsState<TPlugins> intersection on the result’s TPublic/TPlayer.
Layer 3 — CoreGameDefinitionFor
defineGame’s return type. Wraps TState in GamekitState<TState> (which adds the __gamekit internal slice), maps TMoves to a GamekitEventMap<TMoves> event map, and pins TResult to GamekitResultState.
typeof myGame rather than spelling out the full instantiation.
Layer 4 — GameDefinition
packages/core/src/types.ts — the underlying state-machine shape the engine actually executes. Key fields:
events: { readonly [TKind in keyof TEvents & string]: TEvents[TKind] }— the typed event payload map. Indexed by move name through gamekit; indexed by transitioneventthrough core.transitions: readonly GameTransitionConfig<...>[]— the graph edges.setup,views,legalActions— same intent as gamekit, but typed againstGamekitState<TState>(so__gamekitis visible onG).
AnyGame = GameDefinition<any, any, any, any, any, any, any, any, any> is the standard wildcard upper bound. The anys are slot wildcards: every concrete GameDefinition<...> extends AnyGame regardless of which generic slots vary. Variance forces any here — unknown doesn’t accept arbitrary callables in function-parameter positions like legalActions(context: GameRuleContext<any, ...>) => ....
Layer 5 — Wire types
packages/protocol/src/index.ts defines the over-the-wire shapes:
MatchSnapshot<TPublicState, TResult>— whatgetState()returns:G,position,derived,meta.log,result,revision. Validated byMatchSnapshotSchema(zod).PlayerViewSnapshot<TPlayerView, TResult>— the per-seat redacted variant.ProtocolActionRecord— one applied event in the log.BatchApplied<TPublicState, TResult>— diff applied between revisions.
G/result slots are generic; everything else is concrete and zod-validated. Schemas live alongside their types so Schema.parse(unknown) is the boundary check.
Layer 6 — React / hosted bindings
packages/react/src/index.tsx. Two top-level facades:
HostedRoomState<TGame>— the room-level state: phase (lobby/game/closed/…), invite URL, bridge handle, andgame: HostedMatchState<TGame> | null.HostedMatchState<TGame>— the per-match facade:snapshot,dispatch(one method per move),canDispatch,playerID,result,status, plus history.
HostedDispatchMap<TGame> is the key piece — it maps over keyof TGame["events"] & string to give one typed dispatcher per move. When a chat-app sees room.game.dispatch.placeMark typed as Record<string, unknown> instead of the typed function, the regression is upstream — Layer 1 or 2 lost the literal events shape.
Where the casts are, and why
Cross-layer transitions need a handful ofas unknown as casts. They are not hygiene failures; each is documented in source. A summary:
| Location | Why |
|---|---|
react/index.tsx: WeakMap<AnyGame, OpenturnBindings<AnyGame>> cache | TS has no key-dependent map type. Erased on insert, narrowed on read. |
react/index.tsx: Object.fromEntries(Object.keys(events).map(...)) for dispatch builders | Object.fromEntries widens to Record<string, ...>; TS cannot reconstruct literal keys from a runtime iteration. |
react/index.tsx: HostedMatchOverrideContext | Context is typed HostedMatchOverride<AnyGame> so any subtree can install one regardless of the consuming hook’s TGame. |
server/index.ts: parseRoomPersistenceRecord exit cast | zod’s .nonempty() returns mutable tuple; MatchInput.players is readonly tuple. Wire-equivalent shapes, different variance markers. |
protocol/index.ts: protocolizeGameStep/protocolizeGameSnapshot | Schema validates structure but cannot preserve the caller’s TPublicState/TResult generics. |
core/session.ts: cloneJsonValue(parseJsonValue(match)) as TMatch | Same pattern — runtime validates JSON, the caller’s generic is reasserted. |
as unknown as, check first whether one of these patterns covers it. If not, document the why in a comment so the next contributor doesn’t churn it.
Catching regressions
The*.test-d.ts files next to the major surfaces (packages/gamekit/src/types.test-d.ts, packages/plugins/src/types.test-d.ts, packages/react/src/types.test-d.ts) use expect-type to encode the contracts above. They are zero-cost: included in each package’s tsc -p tsconfig.json typecheck, not run by bun test. When a refactor regresses a layer-2 generic in a way that silently widens a downstream type, these tests fail at the package level instead of at a downstream example’s example app.
When adding a new public typed API, write the assertion before the implementation. The assertions are short — usually one or two expectTypeOf<...>().toEqualTypeOf<...>() per invariant — and they catch silent inference drift the runtime tests cannot.