Follow-on from #1326, which gives ctx an explicit players list — who is in the match.
Once that list exists it can change. Would close #884.
Proposal
events.seat(id) and events.unseat(id), which add to and remove from ctx.players.
A game with no seating key behaves exactly as today.
Seating stays with the game, because the details vary: some games let a player pick from
the empty seats, others assign them. So the framework says who is waiting, and the game
decides what to do about it.
const blackjack = {
minPlayers: 1,
maxPlayers: 7,
seating: { // names provisional
onWaiting: ({ events }, waiting) => { // blackjack seats anyone, next round
for (const id of waiting) events.seat(id);
},
onSit: ({ G }, playerID) => { G.chips[playerID] = 100; },
onUnseat: ({ G }, playerID) => { delete G.chips[playerID]; },
},
};
The waiting list is metadata.players minus ctx.players. Passing it as an argument
keeps it out of ctx.
Handing a seat over is unseat plus seat plus a game-supplied migration — G is
keyed by player ID, so only the game knows what moves across.
Open
- When is
onWaiting called? Every admission, every phase boundary, or both. A game
that declines someone mid-hand has to be asked again, or every game ends up keeping
its own waiting list.
- Letting the player pick a seat needs a route that is not a move. A waiting player
is not in ctx.players, so isPlayerActive rejects anything they send
(master.ts:267). Either seat choice arrives with the join and stays a lobby concern,
or waiting players get some narrow way to act.
What it costs
- Admitting a player becomes a state change, so join needs a reducer round-trip
through the master, as leaveGame already does. Today it writes metadata only — no
reducer, no lock, no _stateID bump. This is the one worth agreeing before any code.
maxPlayers is only enforced when a match is created (api.ts:256). The seat path
needs it too.
getFirstAvailablePlayerID iterates indices (util.ts:80), and IDs stop being dense.
- Join stops returning
409 once the seats are full (api.ts:393).
- Seat changes must reuse
rebaseUndoRedoState (reducer.ts:104), or undo brings a
player back.
Follow-on from #1326, which gives
ctxan explicitplayerslist — who is in the match.Once that list exists it can change. Would close #884.
Proposal
events.seat(id)andevents.unseat(id), which add to and remove fromctx.players.A game with no
seatingkey behaves exactly as today.Seating stays with the game, because the details vary: some games let a player pick from
the empty seats, others assign them. So the framework says who is waiting, and the game
decides what to do about it.
The waiting list is
metadata.playersminusctx.players. Passing it as an argumentkeeps it out of
ctx.Handing a seat over is
unseatplusseatplus a game-supplied migration —Giskeyed by player ID, so only the game knows what moves across.
Open
onWaitingcalled? Every admission, every phase boundary, or both. A gamethat declines someone mid-hand has to be asked again, or every game ends up keeping
its own waiting list.
is not in
ctx.players, soisPlayerActiverejects anything they send(
master.ts:267). Either seat choice arrives with the join and stays a lobby concern,or waiting players get some narrow way to act.
What it costs
through the master, as
leaveGamealready does. Today it writes metadata only — noreducer, no lock, no
_stateIDbump. This is the one worth agreeing before any code.maxPlayersis only enforced when a match is created (api.ts:256). The seat pathneeds it too.
getFirstAvailablePlayerIDiterates indices (util.ts:80), and IDs stop being dense.409once the seats are full (api.ts:393).rebaseUndoRedoState(reducer.ts:104), or undo brings aplayer back.