When helpful validation becomes a cheat: Sudoku notes that leaked the answer
A validation that rejects 'invalid' input sounds responsible. In a puzzle game it can quietly hand the player the solution, one rejected tap at a time.
In Daily Sudoku: Offline Puzzle you can pencil in candidate notes: small numbers in the corners of an empty cell to track what might go there. It is a basic paper-Sudoku gesture, and for a while our digital version had a bug that looked exactly like a feature.
When you tried to add a note that already appeared in the same row, column, or box, the app refused. The tap did nothing. On the surface that reads as a tidy little guardrail: why let someone pencil in a 7 when there is already a 7 in the row? It cannot be the answer, so blocking it feels helpful.
It is not helping. It is playing on the player’s behalf, and worse, it leaks the solution one rejected tap at a time.
Why blocking “impossible” notes is an exploit
A big part of solving Sudoku by hand is figuring out which candidates are actually possible in each cell. You scan the row, the column, the box, and you eliminate. That elimination is the puzzle. It is the work the player is supposed to do.
Now imagine the app silently does that scan for you every time you try to write a note. You tap 7 in a cell and nothing happens. You just learned, for free, that 7 is already used somewhere in that cell’s row, column, or box. You did not scan anything. The rejection told you.
Multiply that across the board. A player who does not actually know the rules can brute-force information by tapping every digit into every cell and watching which ones “stick.” The set of accepted notes is exactly the set of legal candidates, a large part of the deductive work. The block turned the notes tool into an on-demand candidate calculator, available even to someone who never learned to solve.
That is the exploit: a mechanic that lets a player skip the intended challenge through a side effect the designer did not intend. The uncomfortable part is that it hid inside a check that, in code review, looks like defensive programming.
The code that looked correct
The offending logic lived in the note-toggle function. Simplified:
export function toggleNote(state, index, value) {
// ...guards for fixed/filled cells...
const cellNotes = new Set(state.notes[index]);
if (cellNotes.has(value)) {
cellNotes.delete(value);
} else {
if (!canPlaceCandidate(state.boardCurrent, index, value)) {
return { ok: false, reason: "invalid_candidate", state };
}
cellNotes.add(value);
}
// ...
}
canPlaceCandidate checks whether value conflicts with any peer already on the board. It is a perfectly good function. The problem is not the function, it is where it was called. Gating a manual note behind it means the player’s own pencil marks are silently curated by a solver.
The fix is a deletion:
} else {
// Manual notes are free-form: the player may pencil in any 1-9, even a value
// that conflicts with a peer. Filtering to legal candidates here would leak
// solving information (which numbers are still possible) and is an exploit.
cellNotes.add(value);
}
Now any digit 1 through 9 goes in as a note, exactly like a real pencil on real paper. If you want to write a 7 next to a cell that already has a 7 in its row, you can. Maybe you are wrong, maybe you are mid-deduction, maybe you just want it there. The game does not judge, because judging is leaking.
The subtle part: the same check is correct elsewhere
What makes this bug useful to study is that the identical candidate filter is still in the codebase, still called, and still right, in the auto-notes feature:
export function applyAutoNotes(state) {
const notes = createEmptyNotesBoard();
for (let index = 0; index < 81; index += 1) {
if (isFixedOrFilled(state, index)) continue;
notes[index] = NOTE_VALUES.filter((value) =>
canPlaceCandidate(state.boardCurrent, index, value),
);
}
return { ...state, notes };
}
Auto-notes is an explicit, opt-in button. When you press it, you are asking the game to fill in the legal candidates for you. That is an assist the player chooses, knowing it is an assist, the same way many Sudoku apps offer it. Filtering to legal candidates there is the whole point.
So the exact same helper, canPlaceCandidate, is an exploit in one call site and a feature in another. The difference is consent and intent. In auto-notes the player asked for computed candidates. In manual notes the player asked to write a number, and the computation happened behind their back. Same code, opposite ethics, decided entirely by who initiated it and whether they know what they are getting.
This is worth keeping as a design rule: assistance is fine when it is requested and legible, and it is a cheat when it is silent and automatic. The line is not “does the app know the answer.” The app always knows the answer. The line is “did the player ask the app to use what it knows.”
Guarding it with a test
The old unit test actually asserted the bug. It was named blocks notes that conflict with row, column, or block candidates and it checked that conflicting notes returned invalid_candidate. That is the trap of tests written from implementation behavior instead of product intent: they lock in whatever the code did, including the wrong thing, and then defend it during future refactors.
We inverted it to assert the intended behavior:
it("allows manual notes that conflict with row, column, or block peers", () => {
const state = createState();
const noteSeven = toggleNote(state, asCellIndex(0), 7);
expect(noteSeven.ok).toBe(true);
if (!noteSeven.ok) return;
expect(noteSeven.state.notes[0]).toEqual([7]);
});
Now the regression net catches the exploit coming back, not the fix. The invalid_candidate result type was removed entirely, because nothing produces it anymore, which is a small win against dead branches lingering in the state machine.
How to spot this class of bug in your own game
The tricky thing is that an accidental oracle almost never shows up as a crash, a failing test, or a red line in review. It shows up as a feature that is too convenient. A few questions flush most of them out.
First, list everything the game rejects and ask what each rejection teaches. Not “is the rejection correct,” but “if I were trying to win without playing, would watching this rejection help me?” If the honest answer is yes, you have found an oracle. In our case, “notes reject illegal candidates” instantly fails that test: watching the rejections reconstructs the candidate grid.
Second, separate automatic behavior from requested behavior. Anything the game computes on the player’s behalf should be traceable to a button they pressed or a setting they turned on. If a computation happens as a side effect of an unrelated action, like writing a note, treat it as suspicious by default.
Third, try to cheat on purpose. The fastest way we could have caught this earlier was to sit down with the intent of a lazy player: select a cell, tap all nine digits, and see what the app tells you. That thirty-second probe reveals the leak immediately, while a hundred honest playtests never trigger it, because honest players do not tap digits they already know are wrong.
Fourth, watch for validation that references hidden state. canPlaceCandidate reads the board to answer a question about the solution space. Any validator that consults the very thing the player is trying to deduce deserves a second look at whether its “no” is doing more than protecting data integrity.
The broader lesson: validation is a channel
Input validation is usually framed as pure defense: reject bad data, protect the system. But every rejection is also a message to whoever sent the input. In a form, “email already taken” can help a real user and still leak account existence. In a puzzle, “you cannot pencil that here” can feel convenient and still leak the solution.
At any validation boundary, also ask what the error message reveals. Most of the time the answer is harmless. Sometimes, as here, the rejection is the payload. When the thing you are validating against is exactly the secret the user is trying to discover, validation stops being a guard and becomes an oracle.
For a Sudoku that is candidates and the solution. For a login it is account existence. For a game economy it might be whether an item is craftable with hidden ingredients. The pattern generalizes: if a “helpful” no leaks a fact the user was supposed to earn, the help is the exploit.
Our notes take any number now, the auto-notes button still calculates legal candidates when the player asks, and the only thing we lost was a guardrail that quietly solved part of the puzzle for anyone who tapped fast enough.