Silence was two different bugs

One missing leaderboard entry exposed both silent server rejections and a client race that could erase a finished game without making a request.

A player finished the Daily Challenge and could not submit the score. There was no error, no explanation, and no useful record of what the server had decided. An incognito window worked, which made several causes plausible but proved none of them. The fix had to answer two questions separately: what should a player see when a submission is rejected, and was a rejection even what happened here?

Make every rejection visible#

The first pass mapped every non-success response from the Daily and AI Duel leaderboard endpoints. The previous policy gave one validation failure specific treatment and left the rest silent, on the reasoning that retyping a name could not fix a rate limit, an expired session, or a server error. That reasoning still held, but silence did not follow from it. The replacement has four outcomes: blocked text, rate limited, session expired, or a generic failure. Each gets an honest message, and each rejected request writes a small structured reason to the server log so the next report has evidence behind it.

The implementation also corrected its own map while it was being built. AI Duel had two nearby endpoints, and the first logging pass covered the background end-game call rather than the leaderboard call the client actually awaited. Following the real client path exposed the mismatch. The logging and message work then landed together, with 1,698 unit tests passing and three end-to-end cases driving rate-limited, expired-session, and generic failures through the recap.

The request that never happened#

The original incident still could not be explained from production telemetry, because the telemetry did not exist when it happened. Code-level investigation ruled out the obvious theories and found a different silent path. The Daily page reloads its challenge when the tab becomes visible. If that happened after the game ended but before the completion effect saved the result, this condition treated game-over as permission to start again:

!game.isStarted ||
game.state.phase === 'game-over' ||
game.state.challengeId !== challenge.challengeId
sequenceDiagram
  accTitle: Tab visibility race condition
  accDescr: Two effects racing after game-over. The visibility-triggered reload queries a new challenge, replacing the finished game. The completion-save effect saves to localStorage. If reload wins before save runs, the finished game is erased and no submission can happen.
  participant User
  participant Page as Daily Page
  participant Store as Store
  participant LocalStorage
  
  User->>Page: Finishes game
  Page->>Store: phase='game-over'
  Note over Page: Effect 1: visibility check<br/>Effect 2: save to localStorage
  Page->>User: Switch to other tab
  User->>Page: Tab becomes visible
  
  par Reload Effect (wins)
    Page->>Page: Detect tab visible
    Page->>Page: Query new challenge
    Page->>Store: Replace game state
    Note over Store: game is now empty
  and Save Effect (loses)
    Page->>Page: Complete effect fires
    Page->>LocalStorage: Save finished game
  end
  
  Note over Page,LocalStorage: Finished game never submitted
The tab-visibility race: the reload-on-visibility effect and the completion-save effect race after game-over. If reload wins, the finished game is replaced with an empty challenge before the save can run, erasing the score.

The middle clause was redundant for every intended flow. A new day is already identified by a different challenge, and the same Daily Challenge cannot be replayed. Removing it made the finished in-memory game authoritative, rather than asking a local-storage effect to win a timing race. A small pure decision function now carries that rule, with deterministic tests for the race, the day rollover, the first start, and an older game still in progress.

The two fixes meet at the same promise: a completed game should not disappear into silence. If the server rejects a submission, the player gets a useful answer and the logs retain a reason. If no request was sent because the client replaced the finished game first, that state is preserved instead. The remaining unknown is whether the original report took exactly that race path, but the system no longer needs a guess before it can explain the next failure.