Skip to content

Take turns ​

Goal ​

Two seats take one counter per turn from a shared pile. The authority rejects out-of-turn actions and passes the turn after 30 seconds, including room sleep.

Files you will edit ​

  • src/game.ts

Steps ​

  1. In a template shell, use this minimal board definition. For rooms, keep your existing multiplayer configuration; allow seats 0 and 1 only. Space asks to take a counter. The sender comes from the message envelope.

    ts
    import { createTurns, defineGame, defineMessage, type GameContext } from 'gameable';
    
    const Take = defineMessage('take-counter', (p): p is null => p === null);
    let pile = 6;
    let timeouts = 0;
    const model = { text: { seat: 0, pile: 6, timeouts: 0 } };
    const turns = createTurns({
      seats: 2,
      turnSeconds: 30,
      onTimeout() {
        timeouts++;
      },
    });
    function take(ctx: GameContext, seat: number): void {
      if (seat < 0 || seat >= 2 || !Number.isInteger(seat)) return;
      if (!turns.isTurn(seat) || pile === 0) return;
      pile--;
      turns.endTurn(ctx);
    }
    function ask(ctx: GameContext): void {
      if (ctx.input.pressed('Space')) ctx.net.send(Take, null);
    }
    function board(ctx: GameContext): void {
      turns.step(ctx);
      const requests = ctx.net.messages(Take);
      for (let i = 0; i < requests.length; i++) take(ctx, requests[i].player);
      if (ctx.net.role === 'solo' && ctx.input.pressed('Space')) {
        take(ctx, turns.current());
      }
      model.text.seat = turns.current();
      model.text.pile = pile;
      model.text.timeouts = timeouts;
      if (ctx.net.role === 'solo') ctx.hud.set(model);
      const players = ctx.players.list;
      for (let i = 0; i < players.length; i++) players[i].hud.set(model);
    }
    function snapshotBoardDocument(): unknown {
      return { turns: turns.snapshot(), pile, timeouts };
    }
    function restoreBoardDocument(state: unknown): void {
      const saved = state as { turns: unknown; pile: number; timeouts: number };
      turns.restore(saved.turns);
      pile = saved.pile;
      timeouts = saved.timeouts;
    }
    export function wakeBoard(ctx: GameContext, doc: unknown, sleptSeconds: number): void {
      restoreBoardDocument(doc);
      turns.elapse(ctx, sleptSeconds);
    }
    export default defineGame({
      init(ctx) {
        turns.reset();
        pile = 6;
        timeouts = 0;
        if (ctx.net.role !== 'client') turns.start(ctx);
      },
      systems: [
        { run: ask, on: 'client' },
        { run: board, on: 'authority' },
      ],
      snapshot: snapshotBoardDocument,
      restore: restoreBoardDocument,
    });
  2. For a room that sleeps between sessions, keep snapshotBoardDocument() in the room's own document and, when the room wakes, call wakeBoard from the authority with that document and the seconds it slept, before ordinary steps resume. Where that document lives and how the wake reaches the game is the multiplayer bundle's job (llms-multiplayer.txt); this recipe only needs the two functions above. The game works without persistence.

  3. Save after wake too. elapse emits timeout callbacks before passing each expired turn; it returns the number consumed. It caps catch-up at 2000 timeouts for two seats, then restarts a full clock. Do not read Date.now in the guest or snapshot from a system. Validate move legality alongside isTurn when replacing the counter pile with a real board.

Verify ​

sh
npm run dev

Solo: each Space changes pile 6 → 5 → 4 and seat 0 → 1 → 0. In two room tabs, seat 1 pressing first leaves pile at 6; seat 0 pressing changes it to 5. Wait 30 seconds: seat changes and timeouts rises by one without reducing pile. For the wake integration, save a fresh 30-second turn and wake after 35 seconds: one timeout fires, seat is 1, and turns.remainingSeconds() is 25 at 60 Hz.

See also ​