See `requirements.md`; requirement ids (R1.1, …) are referenced below. Builds on `game-clock` (the end of a game),
`game-history` (saved games, replays), `game-bot` ("Play again") and `web-redesign` (the game over card, whose design
already has a rematch button).
## Decisions
| Decision | Reason |
|---|---|
| The server owns series and rematch requests; clients only ask, answer and show | Both players must see the same series, and the server already decides who moves first (R2). |
| A series is carried by its live game (`BackendGame.series`): its id and the results of the games before | No new store to keep in step: the series lives and is snapshotted with the game, so it survives restarts (R5.2), and is dropped with it. |
| Rematch requests are kept in memory only, keyed by the finished game's id | They last at most 10 minutes; losing them in a restart costs a click (R5.2). Valkey would add writes for little gain. |
| Series entries name seats (`player1`/`player2`), not user ids | The strip needs no ids, and nothing new can leak a legacy user's secret id (`PublicIds`). Seats stay the same through a series (R2.3). |
| A rematch starts with the usual `on_game_start`; the game page follows it to the new game | Clients already handle a game start; a new route per game keeps reloads, links and replays as they are. |
| "Play again" against the bot becomes a rematch the bot accepts at once | One flow for both kinds of games; bot series get the strip and alternating first moves (R4). |
| Every new game gets a series id, the first game too; rematches keep it | A game alone is a series of one, so grouping later needs no special case (R5.1). Games saved before have none. |
| No fixed series length | The user chose open series; "best of N" can be added on top (out of scope). |
## Backend
### Model
```java
// One finished game of a series, as the strip shows it.
record SeriesGame(String gameId, Seat firstSeat, GameResult result, EndReason endReason,
int player1Store, int player2Store) {}
// The series a live game belongs to: its id and the games played before this one, oldest first.
record Series(String id, List<SeriesGame> games) {
Series next(SeriesGame finished); // with `finished` appended
}
```
-`BackendGame` gains `series` (final; never null) and `firstSeat()`, the seat that moved first: the seat of the first
move's player, or of the player to move before any move. Derived rather than stored, so restored games and tests that
set the player to move need nothing more.
-`ClientGame` gains `series` (`@JsonInclude(NON_NULL)`, the games before this one) and `firstSeat`.
-`GameEndMessage` gains `series` (`NON_NULL`) with the ended game appended, and `rematch` (`NON_NULL`): `"available"`
when a rematch can be asked for, left out after a game a player left (R1.1).
-`LiveGameSnapshot` gains `series`, optional: snapshots without it restore as a new series of one game.
`createMancalaGame(userId1, userId2, bot, series, firstSeat)`: `firstSeat` null draws at random (R2.1), as now. A
rematch passes the seat opposite to the previous game's first seat (R2.2) and the same user order, so the seats stay
(R2.3).
### Rematch requests (`RematchOffers`, on the game loop)
`RematchOffers` is built next to `GameManager`, like `BotPlayer`, and hooks into it: `onGameEnded` (every ending
except a crash, with the end message) and `onPlayerSearching` (`new_game` and `new_bot_game`, before they are handled).
It listens to its own channels and to connection changes, and starts the next game with `createMancalaGame` and
`startGame`. When a game ends, it opens an entry unless a player left (R1.1). An entry keeps what the next game needs: the players in seat order, the bot settings' level, the
series with the ended game appended and the next first seat; plus who has asked, and two timers.
rematch from A ─► A asked; send on_rematch_request to B; 60 s timer
├─ rematch from B ─► start the next game; entry gone (R1.4)
├─ decline_rematch from B ─► on_rematch_ended(declined); entry gone (R1.6)
├─ decline_rematch from A ─► on_rematch_ended(withdrawn); entry stays open
├─ 60 s ─► on_rematch_ended(expired); entry stays open
├─ A or B offline ─► on_rematch_ended(offline); entry stays open
└─ A or B sends new_game / new_bot_game ─► on_rematch_ended(left); entry gone (R1.6)
```
-`on_rematch_ended` goes to both players: each client knows whether it asked and words the reason from that.
- A request to an opponent who is offline ends at once with `offline` (R1.5).
-`rematch` for a game without an entry (over 10 minutes, or the series ended) answers `on_rematch_ended(closed)`.
- Against the bot, `rematch` starts the next game at once (R4.1, R4.2); the bot takes a new strength as in
`onNewBotGame`.
- The players' user ids map to the entry (`entryByUser`), so `new_game` and going offline find it; a player is in at
most one entry, the one of their last game.
- Before starting the next game, the manager checks that neither player has a game in progress (they cannot, since
matchmaking removes the entry, but a restored or crashed state should not give a player two games).
### Channels (`Channels`, `channel_names.ts`)
| Channel | Direction | Message |
|---|---|---|
| `rematch` | client → server | `{ gameId }`: ask for a rematch of that ended game, or accept the opponent's request |
| `decline_rematch` | client → server | `{ gameId }`: say no, or withdraw one's own request |
| `on_rematch_request` | server → client | `{ gameId }`: the opponent asked; the client knows who that is |
| `on_rematch_ended` | server → client | `{ gameId, reason }`, reason one of `declined`, `withdrawn`, `expired`, `offline`, `left`, `closed` (10 minutes, or a new game) |
The next game reaches both players as `on_game_start`, like a game from the lobby.