mancala/docs/specs/rematch/design.md
Halit Aksoy 79f68416ee feat(frontend): show the series above the game
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.
2026-10-17 02:15:53 +03:00

9.9 KiB
Raw Permalink Blame History

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
}
  • 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.
  • GameRecord gains seriesId (NON_NULL); migration 004_series adds games.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_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.

Core (packages/core)

  • Channel names; types Series, SeriesGame, RematchRequest, RematchEnded; Game.series, Game.firstSeat, GameEndMessage.series and rematch.
  • 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}}"), in en.ts and tr.ts.

Web app

  • Following a rematch: GamePage listens to on_game_start while a game of the user is over and navigates to /game/<new id>. The route renders GamePage with key={gameId}, so the page starts afresh for the new game (its load effect runs once per mount).
  • Game over card (GameOverCard) gets a rematch state 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 (--accent for 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> with target="_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_game and new_bot_game each 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: series and firstSeat round-trip through snapshots; old snapshots without them restore; the JSON of ClientGame and GameEndMessage without a series is unchanged.
  • Storage (@QuarkusTest): series_id saved 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.