One recap, two modes

A blocking modal in PvE and an inline overlay in PvP get replaced by one shared recap component, ported wholesale from the Daily Challenge pattern.

Both duel modes still ended a game the old way: PvE popped a blocking modal over the finished board, PvP swapped in an inline overlay with its own separate markup, and neither looked like the inline recap the Daily Challenge had just gotten. A wayfinder map, duel-mode end-of-game recap, charted the fix from research through a three-way prototype before any real component got touched.

Three variants went up behind a dev-only harness that could flip between PvE and PvP mock contexts, and between a normal collision ending and a PvP forfeit: an image-first inline card, a scoreboard-first layout with no image, and a compact version of today’s blocking overlay restyled to match. Image-first won outright, reviewed across both modes and both ending types. The blocking overlay went away entirely; a duel game now ends the same way the daily one does, an aria-live header replacing the board and controls in place rather than covering them.

One DuelRecap.svelte serves both modes, taking the viewer-perspective game state plus a small envelope describing what differs between them:

{ mode: 'pve' | 'pvp', playerName?, opponentName?, outcome, reasonText?, language, forfeit }

PvE keeps its familiar You/Them labels until the player submits initials, then the image itself re-renders with the submitted name in place of You. PvP already has real names on both sides, so it skips the initials form entirely and shows a rematch hint and leaderboard link instead; a forfeit ending draws the board with its own caption rather than pretending a collision happened, and the winner is always read from the server-authoritative viewer state, never recomputed from the engine.

The share image is a sibling to the daily one rather than a rewrite: renderDuelShareImage draws from the same board-painting primitives screenshot.ts already had, extended to place both snakes side by side with a two-player score panel. Pulling those primitives out surfaced a second bug along the way, the player and opponent snake colours in every share image had drifted from the live board’s actual palette:

export const SNAKE_PALETTES: Record<'player' | 'opponent', SnakePalette> = {
  player:   { head: '#C8203C', body: '#1B7D92' },
  opponent: { head: '#CC6000', body: '#5A3000' }
};

Re-syncing it here fixed the daily share image too, since both renderers now read from the one constant. The share button itself picked up a ref parameter, duel-pve-score and duel-pvp-score alongside the daily image’s own value, so a later look at referral traffic can tell which mode actually brought a new player in.