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 structured design 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.
flowchart TD
accTitle: GameRecap component merging EndGameDialog and AlreadyPlayedView
accDescr: The two game-over screens merge into a single GameRecap component with an isRevisit flag. EndGameDialog (shown immediately after game ends with live board and submission form) and AlreadyPlayedView (shown on revisit with countdown) become different views of the same component controlled by isRevisit. Both use the same board renderer (live GameState or reconstructed from snapshot) and both lead to the submission form.
subgraph game["Immediate End of Game"]
direction LR
endDialog["EndGameDialog<br/>(live game)"]
endDialog -->|isRevisit = false| gameRecap["GameRecap<br/>component"]
end
subgraph revisit["Revisited Game"]
direction LR
alreadyPlayed["AlreadyPlayedView<br/>(countdown)"]
alreadyPlayed -->|isRevisit = true| gameRecap2["GameRecap<br/>component"]
end
gameRecap --> board["Render board<br/>(live or snapshot)"]
gameRecap2 --> board
board --> state["State: submitted<br/>or skipped?"]
state -->|Neither| form["Show initials<br/>form"]
state -->|Submitted/Skipped| results["Show results<br/>view"]
form --> results
class gameRecap,gameRecap2,board,results accentThe 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.
sequenceDiagram
accTitle: Server-side Daily replay validation
accDescr: On submission, the server replays the word history through the engine. Each word is looked up in the database, validated, and fed to the game engine. The resulting score is authoritative; the client's score is logged only on disagreement but never trusted.
participant Client
participant Server as Leaderboard<br/>Endpoint
participant DB as Word<br/>Database
participant Engine as Game<br/>Engine
Client->>Server: Submit challenge, wordHistory, clientScore
Note over Client: Hours later, from old localStorage
Server->>Server: Get challenge seed
loop For each word in history
Server->>DB: Look up word metadata<br/>(is valid, letter rarity)
DB-->>Server: Metadata
Server->>Engine: Apply word move
Note over Engine: Deterministic replay
Engine-->>Server: Next board state
end
Server->>Server: Compute final score
alt Score matches client
Server->>DB: Store with score
Server-->>Client: 200 OK
else Score differs
Server->>DB: Log disagreement
Server->>DB: Store authoritative score
Server-->>Client: 200 OK
endreplayDailyChallenge 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.