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.

Hidden information — cards in your hand, a fog of war, a secret fleet placement — lives in G, the authoritative state. The engine decides who sees what when it computes player views. The hosted server sends each client only their view, so opponents never receive the secret bytes.

The rule

  • Store everything in G. The server needs the full state to validate moves.
  • Never expose secrets in views.public. Spectators and the default path read this.
  • Filter secrets in views.player. Each seat gets its own projection.

A battleship sketch

From examples/games/battleship:
interface PlayerGameData {
  board: readonly (readonly BoardCell[])[];  // placement + hits
  fleet: FleetMap;                             // ship positions
  ready: boolean;
  shotsReceived: readonly Shot[];
}

interface BattleshipState {
  players: PlayerRecord<typeof PLAYERS, PlayerGameData>;
  lastShot: Shot | null;
}
The authoritative state contains every player’s fleet positions. The server needs them to decide “hit” or “miss” when someone fires.

Public view: everyone can see this

views: {
  public: ({ G }) => ({
    players: Object.fromEntries(
      PLAYERS.map((id) => [id, {
        ready: G.players[id].ready,
        shotsReceived: G.players[id].shotsReceived,   // visible: opponents already know where they fired
      }]),
    ),
    lastShot: G.lastShot,
  }),
},
No fleet positions, no unfired cells. Just what any observer can legitimately know (that a player is ready, and which shots have landed).

Player view: you see your own fleet

views: {
  player: ({ G }, self) => ({
    myFleet: G.players[self.id].fleet,                        // private: my ships
    myBoard: G.players[self.id].board,
    opponents: Object.fromEntries(
      PLAYERS.filter((id) => id !== self.id).map((id) => [id, {
        ready: G.players[id].ready,
        shotsReceived: G.players[id].shotsReceived,           // public
        // no fleet, no board
      }]),
    ),
    lastShot: G.lastShot,
  }),
},
views.player is called once per seat. Each player’s response only includes their own fleet. The hosted server sends the right response to each client.

Rules of thumb

  • If in doubt, leave it out of views.public. Adding something later is easier than noticing it leaked.
  • Use computed for counts. Show “opponent has 3 cards” via a computed value (opponentHandSize), not the hand itself.
  • Beware of array references. Passing G.players[id].fleet into the public view exposes it. Destructure first.

Another worked example: Splendor reserved cards

examples/games/splendor applies the same pattern at production polish. Each merchant’s reserved cards live in G.players[id].reserved (full card IDs the server needs to validate buyCard). views.player projects opponents’ reserved arrays down to a count — opponentReservedCount — so the table UI can render face-down placeholders without the IDs ever leaving the worker. The game-package test suite asserts this invariant: opponent reserved IDs never appear in any player’s view, regardless of game state. Use it as the reference implementation when designing your own per-player projections.

Local testing

Because local sessions run the same reducer, you can unit-test the player view:
import { createLocalSession } from "@openturn/core";
import { battleship } from "./battleship";

const session = createLocalSession(battleship, {
  match: { players: battleship.playerIDs },
  seed: "test",
});
// ...place ships...
const viewForZero = session.getPlayerView("0");
const viewForOne = session.getPlayerView("1");

expect(viewForZero).not.toHaveProperty("opponents.1.fleet");
expect(viewForOne).not.toHaveProperty("opponents.0.fleet");