Skip to main content

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.

Use @openturn/gamekit when your game is naturally “a player picks one of a few moves on their turn.” It is the shortest path from design to a playable reducer.

Skeleton

import { defineGame, move, permissions, turn } from "@openturn/gamekit";

export const game = defineGame({
  maxPlayers: 2,                  // pool size — generates IDs "0",..,"N-1"
  // minPlayers: 2,                // optional; defaults to maxPlayers
  // playerIDs: ["white","black"] as const,  // opt-in for named seats instead
  setup: () => ({ /* initial G */ }),
  turn: turn.roundRobin(),
  initialPhase: "play",       // optional; defaults to "play"
  phases: {                    // optional; auto-derived from moves otherwise
    play: { label: "Take your turn" },
  },
  computed: {                  // optional selectors
    // ...
  },
  moves: ({ move }) => ({
    // ...
  }),
  views: {                     // optional but recommended
    public: ({ G, turn }) => ({ /* ... */ }),
  },
});

Declare moves

moves: ({ move, queue }) => ({
  attack: move<{ target: PlayerID }>({
    phases: ["combat"],            // only valid in this phase
    run({ G, C, args, move, player, rng }) {
      if (G.players[args.target].hp <= 0) return move.invalid("already_defeated");
      const damage = rng.int(6) + 1;
      const nextHp = G.players[args.target].hp - damage;
      if (nextHp <= 0 && allDefeated(G)) return move.finish({ winner: player.id }, { ... });
      return move.endTurn({ players: { ...G.players, [args.target]: { hp: nextHp } } });
    },
  }),
}),
Each move declares:
  • args? — the payload type. Use move<T> to type it explicitly.
  • phases? — restrict the move to specific phases.
  • run — the pure state transition. Must return an outcome from move.stay | endTurn | goto | finish | invalid.
queue(kind, payload?) is a helper for building internal events to enqueue from run.

Turn gating

With a round-robin turn policy, only the current player is in activePlayers, and core’s dispatch gate handles “wrong seat” rejections automatically — no per-move predicate required. When more than one seat is allowed in a phase (simultaneous play, plugin moves), enforce game-specific rules inline in run and reject with a reason:
run({ G, move: m, player, turn: t }) {
  if (player.id !== t.currentPlayer) return m.invalid("not_your_turn");
  if (!G.hasPaid[player.id]) return m.invalid("must_pay_first");
  // ...
},
See gamekit turn gating for the full pattern.

Phases

Phases group moves by game stage. Each phase can override activePlayers (for simultaneous play) and label:
phases: {
  planning: {
    activePlayers: ({ G }) => PLAYERS.filter((id) => !G.ready[id]),
    label: "Place your units",
  },
  combat: {
    label: ({ G }) => `Round ${G.round}`,
  },
},
initialPhase: "planning",
Transition phases with move.goto("phaseName").

Computed values

Expose derived facts as selectors. They are available as C inside moves, permissions, and views, and also show up in snapshot.derived.selectors:
computed: {
  aliveCount: ({ G }) => PLAYERS.filter((id) => G.players[id].hp > 0).length,
},

Views

views: {
  public: ({ G, turn }) => ({
    currentPlayer: turn.currentPlayer,
    board: G.board,              // public info
  }),
  player: ({ G, turn }, player) => ({
    currentPlayer: turn.currentPlayer,
    board: G.board,
    myHand: G.hands[player.id],  // private info
  }),
},
views.public is the default; views.player runs once per seat. Both must return JSON.

When to reach for core

  • You need more than one event per move (rare).
  • You want states that are not phase-shaped (e.g. “waiting for reconnect”).
  • You need custom state labels or control metadata that doesn’t fit phases.
Drop to core via the core field or rewrite the game with @openturn/core directly. See how-to: author with core.