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.

Two layers decide whether a dispatched move is allowed to run: the engine’s activePlayers set, and your move’s own run body.

activePlayers (engine layer)

Each phase derives an activePlayers list. Core’s dispatch gate rejects any event whose playerID is not in that list with the reason inactive_player. This is automatic — you don’t write it. By default, with a round-robin turn policy, activePlayers is [currentPlayer]. That alone is enough turn-gating for a standard alternating game; no per-move check is required.
import { defineGame, move, turn } from "@openturn/gamekit";

defineGame({
  maxPlayers: 2,
  setup: () => ({ value: 0 }),
  turn: turn.roundRobin(),
  moves: {
    increment: move({
      run({ G, move }) {
        return move.endTurn({ value: G.value + 1 });
      },
    }),
  },
  // ...
});
If a player whose turn it isn’t tries to dispatch increment, core rejects with inactive_player before run is called.

phases on a move

If a move is only valid in some phases, list them:
moves: ({ move }) => ({
  fire: move<FireArgs>({
    phases: ["battle"],
    run: /* ... */,
  }),
  placeShip: move<PlaceShipArgs>({
    phases: ["planning"],
    run: /* ... */,
  }),
}),
If a move omits phases, it is valid in every phase.

Custom rules in run (game layer)

When the engine layer is too coarse — phases that allow multiple seats, custom validity rules, “the player must own this card” — your run decides and returns move.invalid(reason).
move({
  run({ G, args, move: m, player, turn: t }) {
    if (player.id !== t.currentPlayer) return m.invalid("not_your_turn");
    if (G.hasVoted[player.id]) return m.invalid("already_voted");
    return m.endTurn({ /* ... */ });
  },
})
The reason string surfaces to the client in the dispatch result, so the UI can render a specific message rather than a generic rejection.

Active players (per phase)

Override activePlayers for a phase when more than one seat may act in it — typically simultaneous phases:
phases: {
  planning: {
    activePlayers: ({ G }) => PLAYERS.filter((id) => !G.players[id].ready),
    label: "Place your fleet",
  },
  battle: {
    label: "Fire!",
    // no override; round-robin gives a single currentPlayer
  },
},
When activePlayers returns a list, every seat on that list can dispatch moves. The move’s run decides what’s actually valid for the dispatching seat.

Custom turn policies

Today gamekit ships one turn policy: turn.roundRobin(). If your game is strictly alternating, use it. If not, rely on activePlayers and inline move.invalid(...) checks to enforce ordering, and leave turn off. For fully custom progression (skip turns, reversed order, elimination), author the game with @openturn/core directly. See how-to: author with core.