import Figure from '../components/Figure.astro';

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](/posts/the-score-that-almost-got-away) the Daily Challenge had just gotten. A dedicated design map for the 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:

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

<Figure caption="DuelRecap.svelte receives the game state and a mode-describing envelope, branching its layout and interactivity for PvE or PvP — replacing two separate implementations with one shared component.">

```mermaid
graph LR
  accTitle: Shared recap architecture
  accDescr: Both PvE and PvP game-over flows feed the viewer-perspective game state and a small mode envelope to DuelRecap.svelte. The component branches its layout, labels, and interactions based on the envelope to serve both modes from one implementation.
  pve["PvE<br/>Game Over"] --> state["Viewer-perspective<br/>Game State"]
  pvp["PvP<br/>Game Over"] --> state
  state --> recap["DuelRecap.svelte"]
  envelope["Mode Envelope<br/>(pve or pvp, names,<br/>outcome, etc.)"] --> recap
  recap --> pveOut["PvE Layout<br/>(You/Them,<br/>initials form)"]
  recap --> pvpOut["PvP Layout<br/>(real names,<br/>rematch hint)"]
```

</Figure>

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:

```typescript
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.