# 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](room-shapes.md); how a room becomes a
match is [rooms-and-matches.md](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 ──▶ connected
```

| `phase`     | 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 by
  `gem.match.connect()` does the same thing on the host's seat — with the session `ended`,
  `connect()` allocates the next match rather than handing back the one that finished.
- **Members do nothing to start it.** Every member receives `matchAllocationStarted` for 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.

```js
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](what-a-game-must-do.md#what-the-parent-says-about-the-session-prefs-pause-and-terminate).
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                                  |
