From the second game of a series, a strip shows the series score and a box per game in the winner's colour, with the stores, who moved first and how it ended; a finished game opens its replay in a new tab. The game over card shows the series score too. Spectators see names.
9.9 KiB
Rematch and series: design
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
// 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
}
BackendGamegainsseries(final; never null) andfirstSeat(), 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.ClientGamegainsseries(@JsonInclude(NON_NULL), the games before this one) andfirstSeat.GameEndMessagegainsseries(NON_NULL) with the ended game appended, andrematch(NON_NULL):"available"when a rematch can be asked for, left out after a game a player left (R1.1).LiveGameSnapshotgainsseries, optional: snapshots without it restore as a new series of one game.GameRecordgainsseriesId(NON_NULL); migration004_seriesaddsgames.series_id uuid(NULL for older games) and an index on it.
Who moves first (R2)
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.
on_game_end ─► entry (open, 10 min) ── 10 min ──► gone (R1.7)
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_endedgoes 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). rematchfor a game without an entry (over 10 minutes, or the series ended) answerson_rematch_ended(closed).- Against the bot,
rematchstarts the next game at once (R4.1, R4.2); the bot takes a new strength as inonNewBotGame. - The players' user ids map to the entry (
entryByUser), sonew_gameand 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.
Core (packages/core)
- Channel names; types
Series,SeriesGame,RematchRequest,RematchEnded;Game.series,Game.firstSeat,GameEndMessage.seriesandrematch. getSeriesScore(series, seat): wins of the seat and of the other seat, draws apart.- Texts:
Rematch,RematchWaiting("Waiting for {{name}}…"),RematchRequested("{{name}} wants a rematch"),RematchDeclined,RematchExpired,RematchOpponentGone,No,Series,SeriesScore,MovedFirst,GamesNotShown("+{{count}}"), inen.tsandtr.ts.
Web app
- Following a rematch:
GamePagelistens toon_game_startwhile a game of the user is over and navigates to/game/<new id>. The route rendersGamePagewithkey={gameId}, so the page starts afresh for the new game (its load effect runs once per mount). - Game over card (
GameOverCard) gets arematchstate from the page:available: "Rematch" (primary), "New Game", "Home".waiting: "Waiting for Ahmet…" with "Cancel" (decline_rematch).requested: "Ahmet wants a rematch" with "Rematch" (primary) and "No". A request reopens a closed card (R1.3).ended: the reason as a short line, then "New Game" (and "Rematch" again unless the series is over, R1.6).- Bot games: "Play again" sends
rematch(R4.1); the lobby's?bot=stays for the home page and the lobby offer. - The final score as two columns, the bottom player's first: the name (small, muted) above the store count (large), the winner's count in their colour (R3.8).
- The series score line once the series has more than one game (R3.6).
- Series strip (
SeriesStrip, R3): the first row of the game grid, above the players and the board, shown from the second game of a series. The score, then one box per game: the winner's colour (--accentfor the bottom player, the opponent's colour for the top one, neutral for a draw), "30–18" from the bottom player's side, a small dot for the player who moved first, a "time" or "left" after the game's number. The current game's box is outlined and says "now". Finished boxes are links to/game/<id>withtarget="_blank"(R3.4). At most 8 boxes; earlier games collapse into a "+N" box (R3.5). Below 600 px the strip scrolls sideways inside itself, never the page. - Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons.
Mobile app
Unchanged (R6). New fields in on_game_start, on_game_update and on_game_end are ignored by the mobile app; it
has no listeners for the new channels.
Testing
- Backend unit tests (fakes,
ManualScheduler): both ask → a game with the same seats, the other first seat and the series appended; the third game alternates again; no rematch after a player left; decline, withdraw, 60 s expiry, offline,new_gameandnew_bot_gameeach end the request with the right reason and to the right player; asking again after withdraw and expiry; the 10 minute limit; the bot accepts at once at the same level; a player in a new game cannot be pulled into a rematch. - Serialisation:
seriesandfirstSeatround-trip through snapshots; old snapshots without them restore; the JSON ofClientGameandGameEndMessagewithout a series is unchanged. - Storage (
@QuarkusTest):series_idsaved and read; migration on a database with games from before. - Core:
getSeriesScore, text keys in both languages. - Web: build and the temporary type check.
- End to end in Chrome, two browsers: a game ends, A asks, B sees the request and accepts, both land on the new game with the other player moving first and the strip showing game 1; three games in a row alternate; B declines and A is told; A asks and B does not answer within 60 s; a player who left gets no rematch; "Play again" against the bot continues the series; a strip box opens the replay in a new tab; the strip at phone width.