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:
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:
If you find yourself adding a new
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.