The match session — from the start to the next round
What your game sees between "the host pressed start" and "everyone is back in the lobby", and what to show the player at each point. If your game calls gem.match.connect() at all, this is the contract that call lives inside. A game with no matches — no gem.rooms, no server — has nothing to do here.
Which SHAPE of room you want is decided on room-shapes.md; how a room becomes a match is rooms-and-matches.md. This page is the part after that: the session's phases, the events that move it, why a match ends, how a dropped link comes back, and how the next round starts.
1. One session per player, derived from the platform#
gem.rooms.matchSession is this player's view of the room's match: which match, what phase, and — once it is over — why. Read it whenever you render. It is kept current from the room poll, the platform's pushes and the transport, so it is right whether or not any push reached the tab.
none ──startMatch──▶ starting ──server up──▶ ready ──link up──▶ connected
│
link dropped
▼
ended ◀────── the platform ended the match ────────── suspended
│ │
└── roomReopened: the host starts again ──▶ starting └── link back ──▶ connectedphase | What it means | What to show |
|---|---|---|
none | the room has no match, and none has ended since you joined | the lobby |
starting | a match exists and its server is being brought up | "starting" — render matchStartWaiting if it fires |
ready | the match is up and this player holds a credential for it | a brief transition; connect() is about to resolve |
connected | this player's link to the match is up | the game |
suspended | the link dropped; the platform has not said the match is over | "reconnecting", with a way out that leaves the room |
ended | the match ran and is over; reason says why | match over, or the lobby again when roomReopened is true |
The union is open. A later release may add a phase, so switch on the values you handle and treat any phase you do not recognise as "a match is held" — never as none. The test to write is phase !== 'none' && phase !== 'ended'; the one that goes wrong is an allowlist of the phases you know, which reads a new phase as "no match" and lets the host allocate a second one over it. gem.rooms.matchConnectionState is the coarse view (disconnected, connecting, connected, suspended) and is fine for a spinner; branch on matchSession.phase when the distinction between ready and connected, or between none and ended, matters.
After a match ends the session keeps naming the match that ended and why, until the next one starts. A lobby that renders "last match: expired" from it is reading the right field.
2. The events, and the one thing never to key on#
Every transition below arrives on gem.rooms.on(...). The allocation events are the ones a game already handles; the session events are what this page adds.
| Event | Fires when | What to do |
|---|---|---|
matchAllocationStarted | a server is being brought up — every member, not just the host | show "starting", then serve() and connect() |
matchStartWaiting | the platform has no server free yet, inside the start budget | render the wait |
matchReady | the match is up (retained: a late listener still gets it) | nothing if you use connect(); it resolves from this |
matchFailed | the match never came up | offer another attempt — the room survives |
matchEnded | the match ran and is over | match-over screen, or the lobby when roomReopened |
matchSuspended | this player's link to a running match dropped | "reconnecting" — the SDK is already on it |
matchReconnecting | an attempt is about to be made — which of how many | update the spinner |
matchReconnected | the link is back | resync from the authority |
hostChanged | the room has a new host | move the start button to whoever it names |
matchEnded fires once per match, whether or not the platform's push arrived. The SDK derives it from the room and the match status it reads itself, so a phone that was locked when the match ended still gets it on the next read. After it, currentMatch is null and matchSession.phase is ended.
Never decide "the match is over" from the connection's onClose. It fires once, when the connection is over, and its reason string cannot tell the platform ending the match from the link dropping and the reconnect policy giving up. A game that tears down its match screen on onClose tears it down on a drop the SDK was about to recover from; a game that reconnects on onClose reconnects to a match that has ended. matchEnded is "over"; matchSuspended is "dropped". Wire those two, and treat onClose as cleanup.
connect({ onMatchEnded }) is the same fact as the matchEnded event, delivered on the connection instead of the room — called once, with the platform's reason, immediately before the connection closes, on every seat. Either is fine; the room event is the one that also reaches a game that has not connected yet.
3. Why a match ended#
matchEnded.reason and matchSession.reason carry the platform's word for it. Match the ones you handle and treat anything else as "the match is over":
reason | What happened | What the player should see |
|---|---|---|
explicit | the server, or the listen host, ended it — the ordinary end | your results screen |
expired | the match ran for the template's whole maximum duration | the results you have; nothing is wrong |
stale_cleanup | the server stopped reporting in and the platform reaped the match | "the match was lost"; offer another |
host_left | the hosting player left a peer-to-peer match and nobody could take over | "the host left"; back to the lobby |
all_left | everyone left | the lobby |
insufficient_players | too few players remained for the match to continue | "not enough players"; the lobby |
game_room_closed | the host closed the room | leave the room screen; closed follows |
game_room_gone | the room was deleted from under the match | the same |
replaced | the room started a new match while this one was held | nothing — the new match's events follow at once |
migration_failed | a peer-to-peer host migration did not complete | "the match was lost"; offer another |
migration_rate_exceeded | hosts changed too often for the platform to keep following | the same |
failed | the server reported that it failed after the match had started | "the match was lost"; offer another |
unknown | the platform no longer had the answer | the neutral "match over" |
The vocabulary is open: a later platform may add a word, and your default branch is what handles it.
Render your own copy, keyed by the reason. Never put a platform message into the page. The reason above is an enum you switch on. Other strings arrive beside it — matchFailed's reason, matchSuspended.cause, the message on an error — and those are diagnostics for your console and your bug-report context, not text to hand to innerHTML or even textContent as if it were yours. A player reads "the match was lost", not the server's own words for why.
4. A dropped link comes back on its own#
Auto-reconnect is on by default for every connection made through gem.match.connect(). When the link drops and the platform has not said the match is over, the SDK raises matchSuspended and then tries to get the link back itself: six attempts, the wait between them doubling from half a second to a ceiling of eight, each attempt one status read and — if the match is still up — one re-dial. Before the first attempt it reads the room and the match once, so a link that dropped because the match ended produces matchEnded and no attempt at all.
While the connection is suspended, send drops what you give it and onMessage stays attached. matchReconnecting fires before each attempt with attempt and attempts; matchReconnected fires when the link is back. Resync from the authority on matchReconnected. Nothing sent while the link was down was delivered or replayed, and a dedicated server sees the rejoin as a new join.
The loop never runs while the page is hidden. It parks, and the next visible edge — the phone unlocked, the tab foregrounded — is itself a read, so a match that ended while the player was away resolves to matchEnded and one that is still running resumes reconnecting. Nothing in your game has to notice that the page was hidden.
It stops on matchEnded, on your own close(), on gem.rooms.reconnectToMatch() (you took over), and when the attempts run out — the session then stays suspended and the connection's onClose fires once, so a manual connect() remains possible. A matchSuspended whose cause is kicked is not retried at all: the server closed the link on purpose and said so.
Turning it off. createGem({ autoReconnect: false }) — or { attempts: 0 } — and a connection then closes on a drop, onClose fires on every drop, and the session still moves to suspended. The decision is then yours: gem.match.connect() again rejoins the held match, and gem.rooms.reconnectToMatch() does the same at the room level. { attempts, maxDelayMs } tunes the policy without removing it.
What the policy does not cover. A game that dials its own transport from matchReady, or uses gem.dedicatedServer.connect() directly, reconnects on its own. It tells the session where it is with three calls: gem.rooms.notifyMatchConnected(matchId) when its link is up, gem.rooms.notifyMatchDisconnected(cause) when it drops — which raises matchSuspended and lets the rest of the room see this player as disconnected — and gem.rooms.notifyLeavingMatch() when the player leaves on purpose. Reconnecting is then gem.rooms.reconnectToMatch().
The cancel button. A "reconnecting" screen needs a way out, and the way out is leaving the room — gem.rooms.leave() — not closing the connection, which leaves the player in a room whose match they have abandoned.
5. Play again#
When matchEnded.roomReopened is true the room is open again and holds the same members. The next round is one call:
The host calls
gem.rooms.startMatch()again.gem.match.serve()followed bygem.match.connect()does the same thing on the host's seat — with the sessionended,connect()allocates the next match rather than handing back the one that finished.Members do nothing to start it. Every member receives
matchAllocationStartedfor the new match, and then serves and connects exactly as they did the first time.
A template's auto_start_host starts the first match, not the second. It fires when the room reaches min_players; a reopened room already has them, and nothing fires again. A shape whose first match started itself — a hub, a worker, a matchmade lobby — still needs a client to call startMatch() for the next one, and that client is whoever gem.rooms.isHost says. Watch hostChanged: if the host closed their tab during the match, the room has a new host, and the start belongs to them now.
Room args unlock when the match ends. They are write-locked for the match; the host changes them for the next round with gem.rooms.update({ roomArgs }) after matchEnded and before the next startMatch().
When roomReopened is false the room went away with the match — the host closed it, or the platform deleted it — and closed follows. There is nothing to start again; the lobby you return to is a new room.
Starting a match is bounded per player: an owner who starts round after round while earlier matches are still held open on the platform meets 429 too_many_concurrent_matches. A match your own server ends with gem.match.end() is released at once; one nobody ends is released when its maximum duration runs out.
6. Ending a match, from the server and from the listen host#
A dedicated server ends its match with gem.match.end(). The platform reopens the room and pushes matchEnded to every member with reason explicit; then your bundle's shutdown runs with info.reason match_ended and info.endedReason explicit. The platform's own ends — the duration limit, everyone leaving, a stale server — arrive at your bundle the same way, with the reason they carry on the client. When info.reason is idle_timeout, nobody was connected for the idle window and the match is still open on the platform; the SDK ends it for you after your shutdown returns, so the room reopens at once. When it is signal, the platform is reclaiming the machine while the match plays out — do not end the match there; your players are still in it.
async shutdown(info) {
await saveResults();
if (info?.reason !== 'signal') await gem.match.end();
}info is absent on older platform images, so default before use. The reasons are an open set: one you do not recognise means "the match is over".
A listen host ends its match with the handle gem.match.serve() returned. When mode is listen, hosting.end() tells the platform first — which ends the match for everyone, reopens the room and pushes matchEnded — and then runs the bundle's shutdown with match_ended / explicit. hosting.leave() leaves without ending: the platform is told so it can seat another host, and the bundle's shutdown runs with signal. The bare stop() on that handle is deprecated because it tells the platform nothing, so a host that meant to end the match starts a migration instead. Both calls are refused on a dedicated or peer seat — a server the platform runs ends its own match, and a peer is not the authority.
A listen host's bundle also hears two reasons a dedicated one never does: host_migrated (the platform moved the match to another host, and this seat no longer speaks for it) and stopped (the hosting game called the deprecated stop()).
7. Backgrounding, a locked phone, a closed lid#
Nothing here needs game code. The room manager listens for the page coming back — a visibility change, a focus, a page restored from the back-forward cache, a network that returned — and re-reads the room and the match on each. A match that ended while the phone was in a pocket raises matchEnded on that read; one still running resumes the reconnect loop that parked when the page went hidden. gem.rooms.reconcile() is the same read on demand, coalesced, for a moment your game knows about that the browser did not announce.
What the SDK does not do is guess that a hidden tab is a disconnected player: alt-tabbing to read a wiki keeps a live match live. Only the transport says a link dropped.
The two messages the arcade pushes about the page — onPause on both edges, and onWillTerminate with its grace budget — are the game's to honour and are on what-a-game-must-do.md. Neither ends a match.
8. When it goes wrong#
| What you see | What it is |
|---|---|
| Players stay on the game screen after the server ended the match | nothing is listening for matchEnded (or onMatchEnded); the game is keyed on onClose, which fires after teardown |
| "Reconnecting" never clears | the attempts ran out — onClose fired and the session is suspended; offer "try again" (connect() again) or leave |
| The second round never starts, though the host pressed start | the button is wired to a member, or to the host the room had — follow hostChanged; auto_start_host does not re-fire |
| The host closed their tab and everyone froze | peer-to-peer: a migration is in progress, or matchEnded with host_left is coming; render matchSuspended meanwhile |
| The game reconnected but the world is wrong | no resync on matchReconnected; nothing sent while suspended was delivered |
| The lobby shows the last match as still running | the lobby renders currentMatch from a cache; read matchSession at render time |
| A server pod outlives its match | the bundle never calls gem.match.end(); the duration limit ends it later with expired |
matchEnded arrives twice for one room | two matches: the first ended replaced when the host started again over a held session |