I ship Codenames AI, a web game. Each game mode keeps its own save in localStorage. Reload the tab, switch to a different mode, come back later: the board, turn, clue history, and in-progress results all come back. That felt like a win until I added spymaster clue targeting.
While drafting a clue, I can click cards on my team. Those clicks sync the clue count and show which words I had in mind. They are visual intent only. They are not part of the clue submission payload.
I assumed that if I reloaded the same game, the UI could restore those highlights too. Same session, same cards, same mental model. On a single-mode refresh that was mostly harmless.
Switching game modes was not. Each mode loads its own 25-card board. Leftover targeting clicks stayed in memory. If a word on the new board matched a card I had highlighted in the previous mode, that new card lit up. I had never clicked it. The board was truthful. The UI was lying about what I was drafting.
Clearing those highlights when the board changed would have stopped the lie. That cheaper fix was not enough. I had already assumed a same-game reload should bring the clicks back. Keeping that assumption and stopping the leak meant persisting the clicks per mode, the same way we persist the board. That would have treated a thinking aid like a move. The collision forced the real question: should coming back restore those clicks at all?
What coming back restores
People now expect drafts to survive a reload. Google Docs made that the default: leave, come back, the paragraph is still there. Autosave is already on in this game. A lot of players never press Save. They just come back.
A saved snapshot carries the board, the turn, typed clue fields, history, and any in-flight result. It omits targeting clicks on purpose. The snapshot type documents that omission in gamePersistence.ts.
A clue you typed already works the Docs way. An unsubmitted AI-generated clue does not. Refresh asks the model again. The clue word and the highlighted targets can change (INSECT and one card can come back as BODYPART and two different cards). That generation has not crossed into accepted game state. A useful consequence is that a model experiment, upgrade, swap, or config change can take effect on the next reload instead of replaying the last output.
Human targeting clicks are the same shape: a thinking aid around a later submit, not a move on the board.
| Restored from snapshot | Cleared or regenerated on restore |
|---|---|
| Board, revealed cards, team, outcome, and pending guess result | Human-intended target highlights |
| Human-typed clue word and count | Unsubmitted AI clue and targets |
| Submitted clue history | Manual clue-count override flag |
That is why "persist domain state, discard UI state" is the wrong summary. Some UI state should persist (the typed clue). Some AI-generated state should not, even though it occupies the same fields. React state versus a domain object does not tell you enough. The snapshot has to encode what the product has accepted as true, not whatever happened to exist in the UI, and not because anyone pressed Save.
What restore actually does
Switching modes and reloading the tab both restore the saved game, then clear the drafting pose. Targets, the manual count override, and any cached AI overlay go with it. The same clear runs on new game and after a successful submit, so a leftover thinking aid cannot leak into the next turn.
So restore is not "rehydrate everything the component used to know." It is "restore the game, then clear the drafting pose."
The regression tests I care about are behavioral:
- Grid reference: highlight on board A, switch to board B that shares the word, assert nothing is lit.
- Provenance: after reload, a human-typed clue survives while an unsubmitted model-generated clue is regenerated. The test controls the model's second answer so replaying the saved output fails deterministically.
Highlights only reflect choices made in the current drafting session on the current grid.
Same problem, different surfaces
The leftover highlight was not a styling bug. Dropping the clicks from the snapshot fixed the category: a thinking aid is not current truth.
If you are building agent UIs with drafts, wizards, or "thinking aloud" interactions, coming back poses the same question. Autosave does not settle what on the screen is current truth. Keep proposals and thinking-aloud hints ephemeral, even when they sit in the same inputs as accepted work. Document that acceptance boundary in the snapshot type so the next contributor does not "helpfully" persist whatever the component last held.
Takeaway: Coming back should restore what the product treats as current truth, not whatever happened to be on screen. Accepted work stays. Proposals and thinking-aloud hints do not, even when they share the same fields. The persistence boundary is semantic, not architectural. Test what coming back looks like, not whether the save still loads.
If you'd like to see the project that inspired these lessons, you can try Codenames AI.

Top comments (9)
The regression test as written cannot fail on this incident. It uses one board - highlight, leave, come back to the saved board - while the collision needed two boards that share a word, since the card lit up only because a word you had highlighted in the previous mode also appeared on the new 25-card grid. A clear that ran on restore but not on board change would pass that assertion and still reproduce the original lie on the next mode switch, so the case worth pinning is switching into a mode whose grid shares a word and finding nothing lit. That is also why acceptance is not the line your table actually draws: the human-typed clue word is unsubmitted too and it is restored, so what separates it from the highlights is that a highlight is a reference into one particular grid while the clue word is a standalone string no board change can invalidate.
You’re right, thank you. The restore-only test used one board, so it could pass even if highlights were cleared on restore but not on board change. The collision needed two grids sharing a word.
I’ve tightened the regression to highlight on board A, switch to board B that still contains the same word, and assert nothing is lit. That case now fails if the board-change clear is removed. I’ve updated the post to reflect the stronger regression case.
Your point about the highlight being tied to a particular grid is useful too. It strengthens the persistence boundary: the typed clue is durable user work, while the highlight is transient intent whose meaning depends on the board it came from.
Appreciate the careful read.
The grid test you just adopted covers two of the three distinctions the table needs. Acceptance separates submitted history from everything unsubmitted. Grid reference separates a highlight, a reference into one particular board, from the typed clue, a standalone string no board change can invalidate. The unsubmitted AI clue has two parts, and only its targets are grid references. Its word is a standalone string, unsubmitted, in the same field as the typed one, and the table regenerates it while the typed word is restored. Neither line separates those two cells. What is left is who produced the value, and the post says why that matters: a model swap or config change can take effect on the next reload instead of replaying the last output.
That third line deserves the regression the collision got, in two cases, since the two words need not share the field at once. First, type a clue, reload, assert the word is still there. Second, not a same-config reload: under one config your own refresh can already turn INSECT into BODYPART, so asserting the old proposal is gone passes or fails on whatever the model said the second time, flaky rather than blind. Anchor it to the config sentence instead: let the model propose, switch to a config whose answer you control, a stub that always returns one word, reload, and assert the field holds that word and not the saved one. A replay from cache fails every run, an honest re-ask passes every run.
If the snapshot type is where the acceptance boundary gets documented, that second test keeps it honest about the boundary the type does not name: two values in one field, the same acceptance state, and only one of them typed by a person.
Good catch on provenance as the third distinction. The grid test pins acceptance and grid reference; it did not pin who produced the value.
We’ve added reload regressions for that case: a human-typed clue survives reload, while an unsubmitted model-generated clue is regenerated. The test controls the model’s second answer so replaying the saved output fails deterministically.
I’ve merged that coverage and updated the post to reflect the stronger regression tests. Thanks for pushing on it.
One thing worth pinning down before treating the same-board test as redundant: the post now says switching modes and reloading the tab both restore the saved game and then clear the drafting pose, which reads as a single clear site on the restore path, and if that is accurate then removing it fails the one-board test too, so the two-grid case pins the same line rather than a separate board-change clear. If there really are two clear sites, the two-grid test only exercises the switch path and the one-board reload test is the only thing covering the other one, which makes them disjoint coverage rather than a weak and a strong version of the same check. Either way it is the number of clear sites that decides whether the older test can go, and that is cheaper to read off the code than to infer from which assertion fails.
You were right to question that. I checked the paths, and the reload and mode-switch restore reach the same visible result through different mechanisms.
On reload, the highlight intent was never persisted, so there’s nothing to restore. On a mode switch back to a saved game,
applySavedSnapshotclears it explicitly.I’ve tightened the regression coverage around both paths: one test now directly pins the
applySavedSnapshotclear, and another verifies highlights don’t survive a tab reload. The old round-trip test was weaker than it looked because the intermediate mode hop had already cleared the intent.Thanks for pushing on the test structure rather than just the behavior. That was a useful distinction.
I have a small suggestion.
My English is not very good.
Would this game support multi‑language selection🤣
Codenames itself has official editions in multiple languages, so supporting additional language decks is definitely something I could explore.
The main work would be adding language-specific word lists and making sure the AI gives good clues and guesses in each language.
I’ll add it to the ideas list 🙂
Looking forward if you get round to it. Thanks for considering my suggestion😝