Skip to content

events.endTurn() is silently discarded when called inside a phase's onBegin hook #1237

@Rupesh-ark

Description

@Rupesh-ark

Calling events.endTurn({ next: playerID }) inside a phase's onBegin hook has no effect. The turn is not redirected. The call is silently discarded with no error or warning.

Proof

const game = {
  phases: {
    main: {
      start: true,
      turn: { order: TurnOrder.DEFAULT },
      onBegin: ({ events }) => {
        events.endTurn({ next: "1" });  // <-- silently discarded
      },
      moves: { ... },
    },
  },
};

// After phase starts:
// ctx.currentPlayer === "0"   (should be "1")
// ctx.turn === 1

The same call in turn.onBegin works correctly:

turn: {
  onBegin: ({ ctx, events }) => {
    if (ctx.turn === 1) {
      events.endTurn({ next: "1" });  // <-- works
    }
  },
},

// After phase starts:
// ctx.currentPlayer === "1"   (correct)
// ctx.turn === 2

Tested with boardgame.io v0.50.2, Local() multiplayer, Node.js v22.22.0.

Reproduction Script

Run from packages/game/ with node:

const { Client } = require('boardgame.io/client');
const { Local } = require('boardgame.io/multiplayer');
const { TurnOrder } = require('boardgame.io/core');

// Test 1: endTurn({ next }) in phase onBegin — FAILS
const game1 = {
  setup: () => ({ target: '1' }),
  phases: {
    main: {
      start: true,
      turn: { order: TurnOrder.DEFAULT },
      onBegin: ({ events }) => {
        events.endTurn({ next: '1' });
      },
      moves: {
        doSomething: ({ G }) => { G.done = true; },
      },
    },
  },
};

const clients1 = [];
for (let i = 0; i < 3; i++) {
  const c = Client({ game: game1, numPlayers: 3, multiplayer: Local(), playerID: String(i) });
  c.start();
  clients1.push(c);
}

setTimeout(() => {
  const state1 = clients1[0].getState();
  console.log('TEST 1 — endTurn in phase.onBegin:');
  console.log('  currentPlayer:', state1.ctx.currentPlayer, '(expected: 1, got:', state1.ctx.currentPlayer + ')');
  console.log('  PASS:', state1.ctx.currentPlayer === '1' ? 'YES' : 'NO — endTurn was discarded');
  clients1.forEach(c => c.stop());

  // Test 2: endTurn({ next }) in turn.onBegin — WORKS
  const game2 = {
    setup: () => ({ target: '1' }),
    phases: {
      main: {
        start: true,
        turn: {
          order: TurnOrder.DEFAULT,
          onBegin: ({ ctx, events }) => {
            if (ctx.turn === 1) {
              events.endTurn({ next: '1' });
            }
          },
        },
        moves: {
          doSomething: ({ G }) => { G.done = true; },
        },
      },
    },
  };

  const clients2 = [];
  for (let i = 0; i < 3; i++) {
    const c = Client({ game: game2, numPlayers: 3, multiplayer: Local(), playerID: String(i) });
    c.start();
    clients2.push(c);
  }

  setTimeout(() => {
    const state2 = clients2[0].getState();
    console.log('\nTEST 2 — endTurn in turn.onBegin:');
    console.log('  currentPlayer:', state2.ctx.currentPlayer, '(expected: 1, got:', state2.ctx.currentPlayer + ')');
    console.log('  PASS:', state2.ctx.currentPlayer === '1' ? 'YES' : 'NO');
    clients2.forEach(c => c.stop());
  }, 100);
}, 100);

Expected output:

TEST 1 — endTurn in phase.onBegin:
  currentPlayer: 0 (expected: 1, got: 0)
  PASS: NO — endTurn was discarded

TEST 2 — endTurn in turn.onBegin:
  currentPlayer: 1 (expected: 1, got: 1)
  PASS: YES

Why It Happens

boardgame.io processes a phase start in two sequential steps inside flow.ts:

Step 1: StartPhase() — line 301
  ├── Run phase.onBegin(state)
  │     Our code calls events.endTurn({ next: "1" })
  │     This QUEUES the event: { type: "endTurn", turn: 0 }
  │
  └── Queue StartTurn for next step

Step 2: StartTurn() — line 313
  ├── InitTurnOrderState() sets ctx.currentPlayer = playOrder[0]
  ├── ctx.turn = ctx.turn + 1    (turn becomes 1)
  └── Run turn.onBegin(state)

After both steps, the Events plugin processes the dispatch queue (Events.update(), line 124). It finds our queued endTurn event:

// events.ts line 139
const turnHasEnded = event.turn !== state.ctx.turn;
// event.turn = 0 (when it was dispatched)
// state.ctx.turn = 1 (incremented by StartTurn)
// turnHasEnded = true

// events.ts line 187
if (turnHasEnded) continue EventQueue;  // <-- SKIPPED

The event was dispatched during turn 0, but StartTurn already advanced to turn 1. boardgame.io's stale-event protection sees the turn has changed and skips the event. This is intentional behavior to prevent processing events from previous turns — but it incorrectly catches events dispatched in phase onBegin because the turn counter advances between dispatch and processing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions