Skip to main content

Payload types

One TypeScript declaration is authoritative for all four game payloads:

PayloadWorker uses it forFlutter uses it for
statepersisted authoritative statenever received
observationthe seat/public projectionrendering and preview
actionvalidating submitted movesconstructing/submitting moves
configvalidating creation settingscreation and rendering

The schemas must implement both Standard Schema validation and Standard JSON Schema emission. Zod 4 does:

import { z } from "zod";

const moveSchema = z.enum(["rock", "paper", "scissors"]).meta({ id: "Move" });
const stateSchema = z.object({ round: z.int(), commits: z.array(moveSchema.nullable()) })
.meta({ id: "State" });
const observationSchema = z.object({
round: z.int(),
yourMove: moveSchema.nullable().optional(),
}).meta({ id: "Observation" });
const actionSchema = z.object({ move: moveSchema }).meta({ id: "Action" });
const configSchema = z.object({ targetWins: z.int().min(1) }).meta({ id: "Config" });

export const rules: GameRules<State, Observation, Action, Config> = {
schemas: { state: stateSchema, observation: observationSchema, action: actionSchema, config: configSchema },
// hooks…
};

The engine requests the Standard JSON Schema draft-2020-12 target. It is the current JSON Schema meta-schema and one of the two targets Standard JSON Schema strongly recommends implementors support. Using one explicit modern dialect keeps $defs, nullable unions, arrays, and references deterministic across schema libraries instead of accepting library-specific output.

Give reusable/nested schemas stable meta({ id: "…" }) names. These names become stable Dart type names; wire keys themselves are preserved exactly.

The kernel validates state before commit and validates every observation after computeObservation, including public/replay views. A projection bug therefore fails at the source instead of becoming a Dart decoding mystery.

Emit and consume the contract

The Worker owns the deterministic artifact. Default-export its module from src/module/index.ts and declare its stable name:

{
"eigen": { "game": "Rps" },
"scripts": { "contract": "eigen-contract" }
}

Then run:

pnpm contract

@eigeninteractive/testkit owns the executable and its tsx loader. By convention it imports src/module/index.ts, reads fixtures from src/module/fixtures, and writes game-contract.json. Optional module, fixtures, and contract keys under eigen override those paths.

The artifact contains all versioned schemas and validated twin fixtures. The Flutter app consumes that file:

dart run eigen_flutter:generate_payloads \
--contract game-contract.json \
--output lib/game/generated/payloads.dart \
--fixtures-output test/fixtures

The contract's top-level game value supplies the Dart type prefix. For example, "game": "Example Game" emits ExampleGameV1Observation, ExampleGameV1Action, ExampleGameV1Config, and ExampleGameV1RulesBase. The scaffolder derives that value from its one lowercase kebab-case game slug. A hand-created project controls it through package.json's eigen.game.

For each version the generator emits immutable classes/enums and a typed abstract rules base containing all payload parsing and serialization. Extend that base in the Dart rules unit:

class RpsRulesV1 extends RpsV1RulesBase {
// legality, optional preview, and UI remain handwritten
}

Every version includes its number: version 1 uses RpsV1RulesBase, version 2 uses RpsV2RulesBase, and so on. The generated base is replaced whenever the contract is regenerated, while the subclass remains entirely game-owned. The generator and its code_builder/dart_style implementation dependencies ship inside eigen_flutter; the game app declares only eigen_flutter.

Unknown fields are ignored while known fields are decoded strictly. That is the useful read-side balance: additive object fields survive an older app, while a game-payload enum or incompatible shape still selects a new game schemaVersion. The engine API's generated transport enums separately carry an unknownDefaultOpenApi read fallback so additive engine enum values do not crash an installed app.

The fallback is read-only. Never serialize an unknown sentinel as an action; the generated action enum has no such member.

Modelling the observation

The client never receives state. computeObservation returns the exact audience-safe shape and the observation schema describes every allowed projection. For example, live RPS may include yourMove, while a finished public replay includes both commits. One observation schema can model that with optional audience-specific fields.

Nothing confidential should be removed by Flutter UI logic. If a value must be hidden, it must not appear in the observation bytes.

Drift policy

Commit the contract and generated Dart. CI regenerates both and fails on a diff. This catches schema changes, fixture drift, wire-key mismatches, and stale generated payloads without requiring the Worker and app to share a repository.