DEV Community

Cover image for The board came back. The highlights lied.
Michael Truong
Michael Truong

Posted on Edited on

The board came back. The highlights lied.

State persistence bugs across game mode switches

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 restored count is durable user work: the number you typed comes back. What does not come back is the live session flag that blocked auto-sync from targets. After reload, the first target selection re-derives count from the grid. That is the same interaction as changing targets in the same session without reloading (type 5 → change targets → derived count matches type 5 → reload → select a target → derived count). The override flag is transient drafting pose, not durable provenance like human-typed clue word vs unsubmitted AI clue.

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.
  • Count after reload: manually typed count survives reload; the first target selection afterward re-derives count from the grid (same as in-session target toggles).

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 (19)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

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.

Collapse
 
michaeltruong profile image
Michael Truong

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.

Collapse
 
izgorodin profile image
Edward Izgorodin

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.

Thread Thread
 
michaeltruong profile image
Michael Truong

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.

Thread Thread
 
izgorodin profile image
Edward Izgorodin

With provenance in, the last row of the table lands on the wrong side of its own line. The manual clue count override flag is unsubmitted, it is a standalone value rather than a reference into any particular grid, and a person sets it. Those are the same three answers the typed clue word gives, and the typed word is restored. The flag is cleared. All three distinctions predict restore for it, so either that row belongs on the other side of the table or there is a fourth line the thread has not named.

The cost is not only taxonomy. The post has targeting clicks sync the clue count, so the flag is what marks a count as set by hand instead of derived from the lit cards. Restore keeps the count and drops the flag, which leaves a hand-set count wearing a derived label on a board with nothing lit. The first targeting click of the next drafting session then re-derives the count and overwrites a value the table promises to restore. That is the original failure turned inward: a field holding a value whose origin the code has forgotten, except the forgetting now happens inside the restored state rather than across two grids.

The check has the same shape as the two just merged. Override the count by hand, restore, click one card on your own team, assert the restored count survives. If it does not, the restored row reads wider than it is, because the count comes back and then does not stay.

Thread Thread
 
michaeltruong profile image
Michael Truong

This sent me back through the implementation because the mismatch you pointed out is real at the state level: we persist clueCountText, but not the manual-override flag.

The important detail I’d missed in the post is what that flag actually means. It isn't durable provenance saying “this count was human-authored, so preserve it.” The count is normally derived from the current target selection, and manually typing a count temporarily overrides that derived value. We added this behavior deliberately during testing: the manual value remains until the target selection changes, at which point the new selection derives a new count and supersedes the override. We have a test pinning that behavior even without a reload.

That means type 5 → change targets → derived count and type 5 → reload → select a target → derived count are intentionally consistent. Persisting the override flag wouldn't preserve provenance that the product currently needs; it would change the interaction semantics.

I think you've found a gap in my framing, though. The table makes the override flag look like provenance that we're arbitrarily discarding, when it's really transient state describing whether the current count is temporarily overriding the value derived from the current target selection. The count itself is durable user work; that relationship to the current target selection isn't.

That's another distinction I hadn't made explicit in the post: persisting a value doesn't necessarily mean persisting the derivation state that governs how it changes. I'd noticed this behavior before, but it sat in the grey area of product taste and I didn't have a useful way to classify it.

Thread Thread
 
izgorodin profile image
Edward Izgorodin

Transient derivation state rather than durable provenance is the right correction, and it means the check I gave should fail by design. Settled. The line you drew in getting there has a case the thread has not touched: the count nobody typed.

The post has targeting clicks sync the clue count, and your reply has the count normally derived from the current target selection, so clueCountText usually holds a derived number rather than a typed one. Restore keeps clueCountText and clears the highlights unconditionally. That leaves the field holding a number derived from cards the restore just erased, with no override in force to explain it. In session that state cannot arise, because with nothing lit the count follows the grid. So the restored row reads as human typed clue word and count, while the value it restores can be the highlight in numeric form, which is the one thing the article says must not come back.

The check has the same shape as the three already merged. Click three cards on your own team, type nothing, reload, assert what the count field holds. If it comes back as three with nothing lit, the derivation state you called transient was carrying the meaning of the value and not only its update rule, and the flag was the only thing that could have separated the typed three from the clicked three, since they are the same string in the same field.

Thread Thread
 
michaeltruong profile image
Michael Truong

You were right about the “count nobody typed” case. I went back through this again, and it exposed a place where my previous answer was only correct under the old persistence rule.

Before this change, we persisted whatever happened to be in clueCountText, whether it had been typed or derived from target selection. Under that model, the manual-override flag genuinely did not need to survive reload. Its job was only to describe the live interaction: typing a count temporarily supersedes the target-derived count, and the next target change derives a fresh value again.

Your three-card case showed the problem with that persistence rule itself. select 3 targets → count = 3 → reload restored the 3 while deliberately clearing the three highlights that produced it. So the snapshot was preserving a derived value after discarding its derivation inputs. That doesn't fit the boundary the post claims: a human-typed count is durable user work; a target-derived count belongs with the transient targeting state.

I've changed that behavior. A count derived purely from target selection is no longer persisted, while a manually typed count is. Because only manually typed counts can now appear in the snapshot, a restored non-empty count tells us it was manually authored. We can reconstruct the existing manual-override state from that, while the next target change still derives a fresh count as before.

So the resulting behavior is:

select 3 targets → save → reload → no highlights, no count
type 5 → save → reload5 survives
type 5 → reload → change targets → fresh derived count

The distinction I was missing earlier was that it wasn't enough to ask whether the override flag itself should be persisted. Under the old rule it didn't need to be, because every count was persisted anyway. Once derived counts stopped being durable, we needed to preserve the distinction between authored and derived values across the persistence boundary. We can do that without persisting another flag because the new snapshot rule makes that distinction recoverable from the count itself.

This is a much cleaner boundary: a derived value shouldn't outlive the transient state it was derived from.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

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.

Thread Thread
 
michaeltruong profile image
Michael Truong

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, applySavedSnapshot clears it explicitly.

I’ve tightened the regression coverage around both paths: one test now directly pins the applySavedSnapshot clear, 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.

Collapse
 
xulingfeng profile image
xulingfeng

I have a small suggestion.
My English is not very good.
Would this game support multi‑language selection🤣

Collapse
 
michaeltruong profile image
Michael Truong

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 🙂

Collapse
 
xulingfeng profile image
xulingfeng

Looking forward if you get round to it. Thanks for considering my suggestion😝

Thread Thread
 
michaeltruong profile image
Michael Truong

A little update on this. I ended up building it 😄

Chinese is now available as the first additional language. You can select it when starting a game, and the word list and AI clues and guesses will use Chinese too.

I ended up doing a bit more than just adding a Chinese word list. The game now has proper support for different word packs, so Chinese is the first one using that structure rather than being a one-off special case. That should make it much easier to add more languages and other word packs in the future.

It turned out to be a useful improvement to the game architecture, so your suggestion gave me a good reason to build it properly 🙂

I can't judge the Chinese word choices and gameplay as well as a native speaker can, so if you get a chance to try it I'd be interested to hear how it feels.

Thread Thread
 
xulingfeng profile image
xulingfeng

Haha, thank you so much! You’re incredibly efficient. I’ll give it a try right after I publish Stratagems #29. I’ll point out any grammatical issues I come across.👏

Collapse
 
thebitforge profile image
TheBitForge

That distinction between accepted state and thinking aloud state is something I have run into without ever naming it properly. Always treated persistence as one bucket, restore everything or restore nothing, and never stopped to ask whether two values sitting in the same field actually deserve the same treatment. The highlight tied to a specific grid versus the typed clue being a standalone string is a small detail but it explains a lot of weird bugs I have shipped in the past. Good writeup, saving this for the next time I touch anything with autosave.

Collapse
 
xulingfeng profile image
xulingfeng

I’ve tried a few rounds, but I still haven’t really gotten the core of the game. Maybe tabletop games just aren’t for me, hahaha 😂

Collapse
 
michaeltruong profile image
Michael Truong

Haha, fair enough 😄 Codenames can take a little while to click if you haven't played it before. The core idea is really just trying to find one clue that connects several of your team's words without leading the guessers toward the wrong words.

Thanks for giving the Chinese version a few rounds though. Your suggestion ended up pushing me to build the broader word-pack support, so it was still a really useful addition to the game 🙂

Collapse
 
xulingfeng profile image
xulingfeng

That’s awesome to hear! Happy my small suggestion helped the project. I’ll keep trying to get the hang of Codenames😂