Rematching with the same opponent in real-time PvP

Starting another match against the same opponent previously required sharing a new room link. Token-chained authorization and a six-state UI matrix bring instant same-opponent rematches.

When a 1v1 real-time word match concluded in pvp-duel, playing another round required closing the game, generating a new invite link or QR code, and sending it to the same player over an external chat channel. This post-game friction interrupted the competitive flow between friends. Bringing instant same-opponent rematches directly into the endgame card required extending server-side game state, establishing live presence detection, and handling UI transitions cleanly.

Instead of requiring players to generate a new room link, the rematch system authorizes a fresh match room by chaining the session tokens of the completed game. Three state fields were added to the primary match database record to track which player requested a rematch, whether a request was declined, and the identifier of the new match room once created.

export interface PvpRematchState {
  requestedBy: 'host' | 'guest' | null;
  declined: boolean;
  newGameId: string | null;
  opponentPresent: boolean;
}

When both players tap rematch, the server creates a new game record using atomic compare-and-set guards, linking the previous session’s tokens to the host and guest slots of the new match automatically.

Zero-layout-shift UI matrix#

The endgame recap card supports a six-state interaction matrix covering idle, request pending, opponent requested, both accepted, opponent declined, and opponent departed.

stateDiagram-v2
  accTitle: Same-opponent rematch UI states
  accDescr: The endgame recap card moves through six states after a match ends, from one player's perspective. From idle, this player can request a rematch, moving to request pending, or the opponent can request first, moving to opponent requested. From either pending state, an accept moves both players to both accepted and a new game room; a decline moves to opponent declined. Any state can move to opponent departed if the opponent's connection drops entirely.
  [*] --> Idle
  Idle --> RequestPending: I request a rematch
  Idle --> OpponentRequested: opponent requests first
  RequestPending --> BothAccepted: opponent accepts
  OpponentRequested --> BothAccepted: I accept
  RequestPending --> OpponentDeclined: opponent declines
  OpponentRequested --> OpponentDeclined: I decline
  Idle --> OpponentDeparted: opponent departs
  RequestPending --> OpponentDeparted: opponent departs
  OpponentRequested --> OpponentDeparted: opponent departs
  BothAccepted --> [*]: new game room created
The endgame recap card's six-state rematch matrix, seen from one player's perspective. The action button row stays structurally fixed across every state.

Keeping action button positions locked in place across every rematch state eliminates layout jitter during rapid post-game taps.

Endgame UI design

To prevent UI elements from jumping as states transition, the action button row remains structurally fixed in place. Labels and disabled states update inline, while status explanations render inside an absolutely positioned callout box above the controls.

Catching connection cleanup edge cases#

An automated code review pass caught a subtle event bus bug during development. The initial Server-Sent Events (SSE) disconnect handler was clearing pending rematch requests on any connection teardown. In practice, routine network hiccups or browser tab switching cause transient SSE auto-reconnects, which erroneously cancelled active rematch requests.

// Ensure cleanup fires only when all active connections for a role drop
if (eventBus.getRoleConnectionCount(gameId, role) === 0) {
  await pvpService.clearRematchOnDeparture(gameId, role);
}

Restricting departure cleanup to fire only when a role’s connection count hits zero ensures that transient network reconnects do not disrupt pending rematch offers.

With same-opponent rematches in place, players can flow seamlessly into consecutive duels without leaving the game screen.