The score that almost got away

A tab switch could strand a finished Daily Challenge with no way to submit it. The fix merges two screens into one and stops trusting the client for the score.

A player who finished a Daily Challenge without typing initials right away could lose the entry for good. Switch tabs, or reload, before submitting, and the game swapped in a countdown to tomorrow with no way back to the form. The score stayed in the browser for the player’s own reference, but the leaderboard never saw it. Fixing that turned into merging the two screens that handled game-over into one, and closing a trust gap that postponed submission made worse.

The two screens had grown apart because they were built for different moments. EndGameDialog appeared the instant a live game ended, with the board still on screen and the submission form right there. AlreadyPlayedView appeared on every later visit, and it only knew how to render a countdown, because by the time it existed the leaderboard write was assumed to have already happened. Wiring a submission path onto a dead-end view built to explain why there was nothing to do was the wrong shape for the fix. A wayfinder map locked three decisions before any implementation started: guarantee a working submission path exists until the next challenge, decide how the two views relate instead of patching around their split, and close the server-side validation gap a postponed submission would otherwise open. Implementation followed as its own issue, the map’s decisions treated as settled rather than renegotiated mid-build.

The two views became one GameRecap component, switched by an isRevisit flag rather than by which route rendered it. Underneath that flag sits a second, smaller state machine: submitted || skipped decides whether the player sees the initials form or the results view, so pressing Skip and actually submitting land in the same place afterward.

The other half of the fix had nothing to do with the screens. Once a score could be submitted an unknown amount of time after the game ended, from data sitting in localStorage the whole time, the server could no longer take the client’s word for what that score was. The leaderboard endpoint now replays the submitted word history through the real engine before writing anything:

const replayResult = await replayDailyChallenge(challengeId, language, wordHistory);
if (!replayResult.ok) {
  throw error(400, 'Invalid submission');
}
// replayResult.score is what gets stored; the client's own score is
// logged only on disagreement, never trusted.

replayDailyChallenge is modeled directly on PvP’s turn processor: word in, database-backed metadata lookup, a pure engine call, next state out, looped once per word instead of once per turn. The daily game already had a deterministic seed and a fixed apple-spawn queue, both needed for the replay to reproduce the original board exactly; server-side validation turned out to be a smaller addition than the screen merge that motivated it.

The client’s score is a claim now, not a fact.

Two smaller things rode along, both signs of what two separate screens had let slip: a code-review pass caught that dropping the dialog role also dropped its implicit announcement, so the merged component’s title and subtitle now sit in an aria-live="polite" region, and a stale /duel link, pointing the practice action home instead of into an AI duel, turned out to be duplicated in both EndGameDialog and AlreadyPlayedView, unnoticed because neither got much testing attention on its own. The map’s decision to merge outright, rather than keep the two views distinct but consistent, was partly an argument against exactly this kind of drift: one component can’t disagree with itself about where the practice link goes.