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.
Calling
events.endTurn({ next: playerID })inside a phase'sonBeginhook has no effect. The turn is not redirected. The call is silently discarded with no error or warning.Proof
The same call in
turn.onBeginworks correctly:Tested with boardgame.io v0.50.2, Local() multiplayer, Node.js v22.22.0.
Reproduction Script
Run from
packages/game/withnode:Expected output:
Why It Happens
boardgame.io processes a phase start in two sequential steps inside
flow.ts:After both steps, the Events plugin processes the dispatch queue (
Events.update(), line 124). It finds our queued endTurn event: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.