Truth stays server-side

Dark chess adds one hidden-information rule to chess: each side sees only the squares its own pieces reach. The implementation question is where that rule runs. On Mistboard, it runs on the server, so the browser receives a PlayerView, not a full board with fog painted over it.

Same position after 1.e4 e5 2.Nf3 Nc6 3.Bc4 Nf6. The center board is canonical server state; the side boards are the payloads sent to each player.

The triptych is the architecture in miniature. The center board exists only on the server. White and Black each receive a different projection, and neither projection contains the full truth with a visual layer hiding it.

The rule is simple: compute truth once, project the allowed view per seat, and keep the full event log private until the game is over.

That single boundary supports live PvP, engine games, calibration, tournaments, and review. This article stays focused on the player-facing live room: what each browser receives, who can receive it, and when the record becomes public.

How views are computed

For a player, the boundary is PlayerView: visible squares, visible pieces, legal moves, status, and clock for that seat. Opponent pieces outside the visibility set are not hidden fields. They are absent.

// packages/game/src/variants.ts (condensed)

// 1. Which squares can this player see?
function fogVisibleSquares(state, player) {
  // every square one of your own pieces stands on...
  const visible = new Set(ownPieceSquares(state.board, player));
  // ...plus every square one of your pieces could move to or capture on
  for (const move of getVisibilityMoves(state, player)) visible.add(move.to);
  return [...visible].sort();
}

// 2. Keep only the pieces standing on those squares.
function boardVisibleTo(board, visibleSquares) {
  const visible = new Set(visibleSquares);
  const playerBoard = {};
  for (const [square, piece] of Object.entries(board))
    if (piece && visible.has(square)) playerBoard[square] = piece;
  return playerBoard;
}

// 3. Assemble the view that gets sent.
getPlayerView(state, player) {
  const visibleSquares = fogVisibleSquares(state, player);
  const board = boardVisibleTo(state.board, visibleSquares);
  return {
    board,            // only the pieces kept by step 2
    visibleSquares,   // step 1: which squares render clear vs. fogged
    legalMoves: yourTurn(state, player) ? getFogMovesForPlayer(state, player) : [],
    status, perspective: player, moveNumber, clock,
    lastMove,         // your own last move; the opponent's is stripped
  };
}

The important part is the direction of dependency. The client can render fog because it receives a visibility mask, but it cannot remove fog to recover pieces it was never sent.

Sample data payload

The live move stream uses event-appended, a per-move frame. This is the white payload from the position above, shortened to the fields that matter:

{
  "type": "event-appended",
  "roomId": "mb-demo-room-001",
  "seat": "white",
  "seq": 6,
  "state": {
    "board": {
      "a1": { "color": "white", "role": "rook" },
      "e4": { "color": "white", "role": "pawn" },
      "e5": { "color": "black", "role": "pawn" },
      "f7": { "color": "black", "role": "pawn" }
    },
    "visibleSquares": ["a1", "a2", "a3", "..."],
    "legalMoves": [{ "from": "b1", "to": "a3" }, "..."],
    "status": { "type": "playing", "turn": "white" },
    "perspective": "white",
    "clock": { "...": "current clock state" }
  }
}
Representative steady-state frame. The real payload carries complete board, square, move, and clock values.

Core fields: seat identifies the recipient, seq orders the stream, state.board is the redacted board, state.visibleSquares is the clear-vs-fog mask, and state.status carries the canonical turn/result state.

If the appended event is visible to this seat, the frame includes one filtered event. If the move is hidden, event is omitted and the projected state still advances. The player knows a turn happened, not what happened in the fog.

Snapshots still exist for first connect, explicit recovery, and final resync. They carry the filtered event history needed to hydrate the client, so they are larger than per-move frames.

Player move

A move request is just coordinates:

// client -> server, sent on player's move
{ type: 'move', from: 'e2', to: 'e4' }

The server validates the request against canonical state, applies the move, appends an event, and projects the next view. The client never decides whether hidden information exists, whether an invisible move happened, or whether the game is over.

Seat-gated live rooms

During a live game, the server sends game data only to the two seats. After each move, it projects one view for White and one view for Black, then sends each view only to a socket that has proven it controls that seat.

// live room gate (condensed)
const seat = verifySeatClaim(socket, room);

if (!seat) {
  closeSocket(1008, 'private room');
  return;
}

send(projectPlayerView(room.gameState, seat));

Seat proof

A socket gets live room data only after it proves control of the white or black seat. Anonymous seats use random bearer tokens; the server stores a SHA-256 token hash and compares the presented token in constant time.

Account seats

Signed-in seats add the account session check on top of the seat claim. The token proves this browser can reclaim the seat; the session proves the account still matches the seat assignment.

No live spectator view

Non-players do not get a live spectator projection. A socket without a valid seat is rejected before room data is sent, and the live replay endpoint returns 403 until the game reaches a terminal state.

Postgame review

When the game becomes terminal, the privacy rule changes. The room no longer rejects non-players after the result, and the game page becomes the durable public review surface.

GET /room/abc123          active game, seat token required
GET /api/games/abc123/events  active game, 403
GET /game/abc123          finished game, public review
GET /room/abc123          finished game, opens without a seat

A spectator who opens the room during play gets no board. The same person can open the finished game page after the result and inspect the event log. That is the product rule: private while decisions are live, reviewable once the record is settled.

That split is important for rated play. A rated result can point at a public completed game without giving non-players access to live hidden information.

It also keeps reconnect and review on the same foundation. Live reconnect rebuilds a filtered player view from the event log. Postgame review uses the same log after the hidden-information constraint has expired.

Scope and verification

This is not a full anti-cheat claim. It is the narrower integrity claim this architecture can prove: during live play, hidden truth is not sent to unauthorized browser paths; after the game ends, the record is reviewable.

Anonymous casual seats are bearer-token seats, not account-grade identity, and there is no live spectator mode for hidden-information games.

Mistboard covers this boundary with WebSocket and payload regression tests that drive real moves and assert on the bytes each seat receives.

That is the line Mistboard defends: during play, there is no browser-side truth to unmask. After play, there is a public record to inspect.

接下来去哪

Play a dark chess game, or read the rules article for the player-facing version of the same visibility model.

来玩迷雾国际象棋Read Dark Chess RulesAll articles