Skip to content

Rework leave-game around an explicit player roster in ctx - #1326

Draft
devill wants to merge 3 commits into
mainfrom
devill/simplify-player-removal
Draft

Rework leave-game around an explicit player roster in ctx#1326
devill wants to merge 3 commits into
mainfrom
devill/simplify-player-removal

Conversation

@devill

@devill devill commented Aug 17, 2026

Copy link
Copy Markdown
Member

Draft, and deliberately so — this changes public API, so I'd like to agree the shape
before polishing it. It also reworks part of #1258, which only just landed, so I'd
very much like @Rupesh-ark's read on it.

The filtering mechanism doesn't sit well with the existing design

ctx has never held a list of players. It holds numPlayers, and playOrder is
rebuilt from numPlayers at the start of every phase:

// InitTurnOrderState
let playOrder = Array.from({ length: numPlayers }).map((_, i) => i + '');

So a player taken out of playOrder is back at the next phase change. #1258 handles
that with a ctx._removedPlayers tombstone that every consumer subtracts again:

flowchart LR
  N["ctx.numPlayers<br/>frozen at create"] -->|rebuilt every phase| P["ctx.playOrder"]
  R["ctx._removedPlayers"] -.->|subtract| P
  R -.->|subtract| A["SetActivePlayers<br/>4 branches"]
  R -.->|subtract| I["IsPlayerActive"]
  R -.->|deep scrub| H["_prevActivePlayers<br/>_nextActivePlayers"]
Loading

A tombstone is a negative list, so it is only correct while every reader remembers to
subtract it. Nothing in the type system says a new reader of playOrder must.

It also blocks things we might want next

Each of these needs to change who is playing:

  • handing a seat over to another player mid-match
  • adding a seat during a match, in a game that supports it
  • creating a match before you know how many people will join

With a tombstone there is nothing to change — only a list of who has been struck out.

The alternative

The first commit reverts the turn-order half of #1258. The rest replaces it with one
new field, ctx.players — the players in this match, in seat order:

layer field changed
who is admitted to the lobby metadata.players no
who is in the match ctx.players new
who plays this phase ctx.playOrder no

playOrder is seeded from the roster instead of from a count, and constrained to it:

// InitTurnOrderState
const players = GetPlayers(ctx);
let playOrder = [...players];
if (order.playOrder !== undefined) {
  playOrder = order.playOrder(context);
}
// invariant: ctx.playOrder is always a subset of ctx.players
playOrder = playOrder
  .map((playerID) => playerID + '')
  .filter((playerID) => players.includes(playerID));
flowchart LR
  N["ctx.numPlayers<br/>seats created"] -->|at setup| PL["ctx.players<br/>who is in the match"]
  PL -->|seeds, every phase| PO["ctx.playOrder<br/>who plays this phase"]
  L["leaveGame"] -->|removes from| PL
Loading

Removing a player is then a removal rather than a filter, and RemovePlayer drops from
65 lines to 30 — the deep scrub of _prevActivePlayers / _nextActivePlayers goes away,
because both are made inert where they are read instead.

GetPlayers() falls back to deriving the roster from numPlayers, so matches persisted
by 0.50.x keep working.

Commits

commit diff
revert(core) drop the turn-order half of the leave-game feature +56 −511
refactor(core) give ctx an explicit player list +78 −11
feat(core) remove a leaving player from the match +506 −23

The middle commit is pure structure: I diffed its behaviour against main across six
scenarios and the resulting states are identical.

Two things I left alone on purpose

  • endTurn({ next }) and the array form of setActivePlayers still set whatever player
    ID you name, including one who has left. Without a tombstone the framework can't tell
    "has left" from "was never in the roster", and boardgame.io tolerates the latter today.
    Documented rather than changed.
  • flow.ts EndTurn returns the wrong state in the arg.remove branch, so removing
    the last player silently does nothing. Pre-existing since Add pass event #492 (2018), out of scope here.

Where this goes next

This PR stands on its own — it adds no features and leaveGame ends up behaving exactly
as it does today. What the roster makes possible afterwards is written up separately, so
none of it has to be bought into here: #1327 (seating), #1328 (lobby lifecycle),
#1329 (tidy-up).

pnpm test and pnpm run lint are green — 43 suites, 911 tests, 100% statements and
branches.

devill added 3 commits August 16, 2026 19:38
Removal was implemented by scrubbing nine fields of ctx and keeping a
_removedPlayers tombstone, because playOrder is regenerated from
ctx.numPlayers at every phase start. Seven further sites had to re-subtract
that list whenever a player set was rebuilt.

A following commit makes playOrder carry its own membership forward, which
removes the need for the tombstone entirely. Strip the old mechanism first
so the replacement lands against a clean slate.

leaveGame is unreleased, so no published behaviour changes. The lobby
endpoints, the onPlayerLeave hook and the PLAYER_LEAVE action are untouched;
only their effect on turn order is removed. events.removePlayer(playerID)
and its documentation section are dropped for good, not restored by a
following commit.

Three robustness fixes #1258 made inside InitTurnOrderState are deliberately
kept: the play-order stringify, the playOrderPos out-of-range clamp, and the
empty-play-order currentPlayer fallback. All three are reachable without the
removal feature.
playOrder served two purposes — the set of players in the match and the
order they take turns in this phase — and TurnOrder.CUSTOM writes a
phase-scoped subset into it, so it cannot carry membership. ctx.players
now holds the membership and playOrder defaults to it, keeping its
existing meaning. No behaviour change: ctx.players is fixed at creation
in this commit, so every game sees exactly what it saw before. A
following commit makes player removal update ctx.players.
PLAYER_LEAVE now removes the player from ctx.players. Because
InitTurnOrderState re-seeds playOrder from ctx.players at the start of every
phase, that is what makes a removal persist -- which is why no _removedPlayers
tombstone is needed.

ctx.playOrder is now always a subset of ctx.players. A turn.order.playOrder
that names a departed player would otherwise put them back in the rotation at
the next phase boundary, and TurnOrder.CUSTOM takes a static literal, so the
game has no way to drop them itself. The same applies to the value and
currentPlayer forms of turn.activePlayers, which are re-applied at every
StartTurn. This narrows one undocumented tolerance: a custom play order naming
an ID that was never in the roster is now dropped rather than passed through.

ctx.numPlayers deliberately keeps its creation-time value. Read
ctx.players.length for the live roster.

Stale ctx._prevActivePlayers and ctx._nextActivePlayers entries are made inert
where they are consumed rather than scrubbed on removal, which is where most of
the previous implementation's bulk went.

Two paths stay outside the contract, because without a tombstone the framework
cannot tell "has left" from "was never in the roster", and a game may name an
ID outside the roster today: endTurn({ next }) and the array form of
setActivePlayers both set the players they are given. A game that names an ID
explicitly owns that ID. Documented in Lobby.md.

GetPlayers falls back to deriving the roster from ctx.numPlayers, so a match
persisted before ctx.players existed still loads.
@Rupesh-ark

Copy link
Copy Markdown
Member

Yeah, I read though all this.

This is right, we should have an overall list, but we should keep track of the original list of players to compare against with cause the information is lost as who left.

The overall Shape's right, ship the revert.

One blocker: turn-order.ts:396 filters the custom play order against the roster, so a game whose turn.order.playOrder returns non-seat IDs loses all of them. playOrder: () => ['alice','bob'] with numPlayers: 2 gives ["alice","bob"] on base and [] here, empty rotation, unplayable, silent, and it hits games that never call leaveGame.

The filter's reasoning is right, it just can't tell a departure from an ID that was never here. Suggestion: keep _removedPlayers as private bookkeeping next to ctx.players, and filter on !IsPlayerRemoved(ctx, playerID) — the predicate that was there before. The roster stays authoritative; the departed list is only consulted where something wants a finer answer than "who's in the match", so forgetting it costs today's behaviour rather than correctness. It'd settle #1327's waiting list too.

One decision: types.ts:90 says players is required, GetPlayers() says it might not be, we should decide on one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants