Skip to content

feat(lobby): give MatchData a status instead of inferring it from seat occupancy - #1332

Open
tbsvttr wants to merge 3 commits into
boardgameio:mainfrom
tbsvttr:tbsvttr/lobby-match-status
Open

feat(lobby): give MatchData a status instead of inferring it from seat occupancy#1332
tbsvttr wants to merge 3 commits into
boardgameio:mainfrom
tbsvttr:tbsvttr/lobby-match-status

Conversation

@tbsvttr

@tbsvttr tbsvttr commented Aug 18, 2026

Copy link
Copy Markdown

Closes #1328.

Server.MatchData now carries status: 'open' | 'running' and creator, plus
POST /games/{name}/{id}/start and LobbyClient.startMatch. The seat-occupancy rule
is kept wherever it still applies, so a match with a fixed number of seats behaves
exactly as before.

The lifecycle

status
created open
last free seat taken running, on its own
a seat freed again open, and creator passes to a player still seated
creator calls /start running

creator is the first player to sit down, and starting the match is theirs alone —
403 for anyone else or for bad credentials, 409 if it is already running.

Two decisions worth flagging

status is optional in storage, not required as the issue sketched it. Matches
written before this field existed do not have one, so a required field would be a
claim about stored data that isn't true. Everything reads it through getMatchStatus,
which falls back to the old occupancy rule for those rows — always the right answer,
since every match predating the field has fixed seats. What goes over the wire is not
optional: createClientMatchData resolves it, so LobbyAPI.Match['status'] is
required and the React lobby can rely on it.

Freeing a seat reopens the match. Otherwise a fixed-seat match that filled up and
then lost a player would sit at running with an empty seat, where the lobby used to
show it as open again. This is the piece #1327 will want to revisit: once a match can
be started deliberately, a leave should probably not undo that.

plugin-player

opponent came from ctx.numPlayers === 2, which reads the seat count the match was
created with — it stops describing the match as soon as players can come and go, and
leaves opponent pointing at a fixed '0' or '1' that may be nobody. It now follows
minPlayers and maxPlayers both being 2, as the issue asks.

Warning

This is a breaking change. A two-player game that declares neither bound no longer
gets opponent, and player.opponent.set(...) throws Cannot read properties of undefined. Declaring minPlayers: 2 and maxPlayers: 2 restores it. The plugin's
own test game needed exactly that change, which is a fair sign real games will too —
say the word if you would rather it fell back to ctx.numPlayers when a game
declares neither, and I will add that.

Not included

The plugin-player hook. G.players is still built from ctx.numPlayers at setup,
so a player seated after setup gets no entry. Fixing that needs a hook and a way to
be seated in the first place, and neither exists yet — that is #1327's to do.

Creating at minPlayers. The React create form already defaults numPlayers to
minPlayers, and re-defaults it when the selected game changes, so there was nothing
to change. It still lets you pick a larger number, which seemed worth keeping.

Testing

pnpm run lint, pnpm run ts, pnpm test (43 suites / 923 tests, 1 todo) and
pnpm run build all pass. Twenty new tests cover the start endpoint (creator, wrong
player, bad credentials, missing playerID, already running, unknown match, and a match
stored before status existed), the creator and auto-start on join, the reopen and
creator handover on leave, and LobbyClient.startMatch.

Server.MatchData had no field for "has this match started", so the lobby worked
it out from occupancy: a match is running once every seat has a name. That only
holds while the number of seats is fixed, and it is never true of a table you
can join, which is what blocks boardgameio#1327.

Matches now carry `status: 'open' | 'running'` and `creator`, the first player
to sit down and the only one who may start it. The seat-occupancy rule is kept
where it still applies, so nothing changes for fixed-seat matches:

  * joining the last free seat sets `running` by itself
  * freeing a seat sets `open` again, and hands `creator` to a player who is
    still seated if the one leaving held it

POST /games/:name/:id/start settles the seats for matches that can begin before
every seat is filled. It is the creator's to call — 403 for anyone else or for
bad credentials, 409 if the match is already running — and LobbyClient gets a
`startMatch` to match.

`status` is optional in storage rather than required, because matches written
before this field existed do not have one. Everything reads it through
getMatchStatus, which falls back to the old occupancy rule for those — always
the right answer, since every match predating the field has fixed seats. What
the API hands back is not optional: createClientMatchData resolves it, so
LobbyAPI.Match always carries a status.
The lobby decided whether to offer Play by looking for an unoccupied seat, which
is the same inference the server no longer needs to make. It reads `status`
instead. For a match with a fixed number of seats the two agree, so the buttons
and the OPEN/RUNNING column are unchanged; for one that can start with seats to
spare they no longer disagree.
The plugin exposed `player.opponent` when `ctx.numPlayers === 2`. That reads the
number of seats the match was created with, which stops describing the match as
soon as players can come and go, and leaves `opponent` pointing at a fixed '0'
or '1' that may be nobody.

What the game declares does hold for the life of the match, so `opponent` now
follows `minPlayers` and `maxPlayers` both being 2.

BREAKING: a two-player game that declares neither bound no longer gets
`opponent`, and `player.opponent.set(...)` throws "Cannot read properties of
undefined". Declaring `minPlayers: 2` and `maxPlayers: 2` restores it.

Not addressed here: G.players is still built from ctx.numPlayers at setup, so a
player seated after setup has no entry. That needs a hook and a way to be seated
in the first place, neither of which exists yet — see boardgameio#1327.
@tbsvttr
tbsvttr force-pushed the tbsvttr/lobby-match-status branch from 11a8275 to 15542d5 Compare August 19, 2026 13:04
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.

Lobby lifecycle: give MatchData a status instead of inferring it from seat occupancy

1 participant