A guess is two options

Three bug reports I could not reproduce, a spec that turned out to be the actual bug, and what the guess tree looks like now.

Kludoku's what-if tree is the feature I am proudest of and the one that has generated the most "this is broken" reports. This week I finally understood why: it was never broken in the way the reports said. It was built on a different idea of what a guess is from the one every player brings to it, including me when I play.

The reports

Three of them, over one day, all mine, all from real play:

None of them reproduced. I built the exact shapes described, in the game's own store, headless, with the real side strings and the real column presses, and every time the placements unwound correctly and the claims unwound correctly. I did it in a browser, as a premium account, at two levels of depth, on a phone viewport, with forced clicks and without. Clean, every time.

When a bug will not reproduce after that much effort, the description is wrong — not the reporter, the description. So I wrote down what I actually do, in a notation precise enough to argue with.

The spec

Four suspects: Hector, Maria, Julius, Peter. X(a, b, c) is person X at option c, reached through option a of the first fork and option b of the second. "Guess as second position" means: they are on a square, and I want to try another.

1.  Place H(1), guess second position H(2)
2.  Place J(2,1) in a column
3.  Switch to H(1), J(2,1) disappears
4.  Place J(1,1), guess J(1,2)
5.  Place P(1,J2,1), guess P(1,J2,2)
6.  Place M(1,J2,P2,1)
7.  Switch to P(1,J2,1), M(1,J2,P2,1) disappears
8.  Place M(1,J2,P1,1) in the same column as J(2,1)
9.  Switch to H(2): everything else disappears, J(2,1) claims the column
10. Switch to H(1): the previous H1 tree reappears, up to M(1,J2,P1,1)
11. Switch to P(1,J2,P2): M(1,J2,P2,1) appears

Then I ran it, step by step, against the store. This is what the tree that shipped actually did:

stepbeforeafter
3. Switch to H(1): J(2,1) disappearspasspass
7. Switch to P(1,J2,1): M(1,J2,P2,1) disappearspasspass
7. …and no mini portrait for M eitherfailpass
9. Switch to H(2): M and P disappear, J(2,1) claims the columnpasspass
9. …and J(1,1) is gone toofailpass
10. Switch to H(1): the H1 tree reappearsfailpass
11. Switch to P(1,J2,P2): M(1,J2,P2,1) appearspasspass

Three failures. None of them was a placement leaking through a switch — the thing all three reports described. They were three places where the tree's model and mine disagreed, and once I could see the disagreement, every report made sense.

Option 1 was not a node

Here is the shape the tree had. You place Hector. You open a guess and put Hector somewhere else. The guess is a branch, forked from the board you were on, and that board — the committed board — is the base of every branch.

committed board H at square 1 · J at square 3 H(2) — the guess H at square 2 · inherits J "H(1)" is not a node. It is the base of everything — including H(2).
Before. Only the guess is a node. Whatever you place "under H(1)" lands on the committed board and shows inside H(2).

That explains step 9. Julius placed "under H(1)" was placed on the committed board, and the committed board is the floor of every branch, so Julius showed inside H(2). And it explains step 10: "switch to H(1)" meant "show the committed board", which is not where the work was — the work was in branches below it, which are correctly off the path from there. There was nothing to remember a leaf against, because the thing I called H(1) did not exist.

It also explains the reports. If you want two alternatives and you press + inside a branch, you get a deeper guess, never a sibling — there was no control that made one. And if you press + right after placing a suspect, nothing happens at all, because placing clears the selection and a guess needs an anchor. So the sequence a player gets when they want two options is: nothing, then a chain. A chain inherits. From the board, that is indistinguishable from "the placements did not disappear".

The fix is the diagram everybody already had in their head:

committed board H at square 1 H(1) — kept H at square 1 · J at square 3 H(2) — the guess H at square 2 · J on a column Two nodes. Each owns what is placed under it. Neither sees the other.
After. A guess about a placed suspect opens a pair: a kept option holding them where they were, and the guess. Both are forked from where you stood; that node is left untouched.

The other choices disappear

Step 7's mini portrait was a different fault with a longer history. The mini portraits are the tree's whole visible surface: the other side of every fork you are standing on, drawn on the square it would take. Months ago a player — me again — picked a portrait at depth two, dropped to the committed board, and found the branch just left had no portrait leading back to it. The fix put the whole tree on the board from everywhere: every branch, at any depth, one press away. It was even pinned by a test with that name.

And it was wrong, in the way that fixes for navigation problems often are: it solved getting back by making it impossible to see where you are. Standing on H(1), a guess made under H(2) — an option I had not taken — sat on my board as if it were mine. My own design note for the tree, written before any of it existed, says what should happen: "the tree swaps, my previously selected Henri becomes a mini head and the other choices disappear."

So the rule is now the one the code's own comment had always claimed and never enforced: a branch off your path is offered only if it was forked from a node on your path. Getting back to a deep branch is done by the thing that should have done it all along — selecting an option puts you back on the leaf you were last on beneath it.

The code

Four changes, all in the store, none in the renderer — the board draws what the store says, and the store was the thing with the wrong idea.

A guess about a placed suspect opens a split. The kept twin carries the suspect where they were; the guess starts empty; both are children of where the player stands, and the player lands in the guess. A second guess about the same suspect from the same node finds the twin already there and becomes option 3 — H(1), H(2), H(3) are one fork, not two.

const at = placedEffective()[anchor];
const twin = at != null ? state.hypos.find(x => x.kept && (x.parent ?? null) === parent
  && x.anchor === anchor && x.placed[anchor] === at) : null;
const kept = at != null && !twin
  ? { id: id++, anchor, parent, kept: true, placed: { [anchor]: at }, order: [anchor] }
  : null;
const guess = { id, anchor, parent, placed: {}, order: [] };
const hypos = [...state.hypos, ...(kept ? [kept] : []), guess];

Selecting an option lands where you left off — unless you are walking up. Re-entering a subtree from outside restores its last leaf. Choosing an ancestor of where you stand lands on that ancestor, or an intermediate node could never be looked at once anything was forked beneath it.

const landing = (id) => {
  if (id == null) return null;
  const cur = state.activeHypo;
  if (cur != null && cur !== id && lineageOf(cur).some(b => b.id === id)) return id;
  const r = state.hypoLast[id];
  if (r == null || r === id || !branchById(r)) return id;
  return lineageOf(r).some(b => b.id === id) ? r : id;
};

The other choices disappear. One line, and the line the comment above it had described for months.

if (b.parent != null && !onPath.has(b.parent)) continue;

The numerals are distances. The other side of your own fork has no number; the other option of the fork one level up reads −1; a deeper guess you made from here reads +1. The off-path portraits had been counting from one step too far back, so a fresh fork labelled its other option −1 and a deeper guess got no label at all — which is why the "+1"s and "−2"s I had designed for seemed to have vanished. They had not; they started one level late.

Two smaller things. The cap counts guesses, not nodes, because a kept twin is bookkeeping and a free account should not go from two ideas to one the day this shipped. And refuting a guess folds a twin nobody built on, while a twin with work under it stays.

What it looks like

Standing in option 2: the guess is ringed in blue on square 7, the kept option waits as a mini portrait on square 1, and a second suspect's column claim is hatched down column 4
Standing in option 2. Two chips above the board, numbered 1 and 2 in the fork's own colours — the lit one is the guess. The other option waits as a mini portrait on square 1, with no number, because it is the other side of the fork I am standing on. The column claim belongs to this option.
Standing in option 1: the suspect is back on square 1, the guess waits as a mini portrait, the column claim is gone and a second suspect is placed on the board instead
Standing in option 1. The column claim is gone; the second suspect is placed here instead. Switch back and this placement leaves and the claim returns. Nothing is lost either way.
The whole sequence, twelve seconds, on the phone layout. Watch it on its own page.

The numbers

Two things I would not have found without the reports being wrong

The first probe I wrote to reproduce the column claim reported "no leak" from a tree that had never held a claim, because a column press that misses the strip by four pixels is silent, and so is one made with nobody selected, and so is one on a strip somebody already occupies. Three silent exits, one symptom. The store now records which exit each edge press took, so the next report of "it did nothing" is one look instead of a day.

The second: the video pipeline's verifier checked that the clip's file returned 200. It did — the single-page app's fallback returns 200 for anything, with three kilobytes of HTML. The embed sat there with no source and no error. A status is not a file; it asks for the content type now. Both are the same lesson the whole week was: measure the thing you are actually asking about, not the thing next to it.

What the design review sent back

Nothing visual ships here without a hostile review of screenshots — light and dark, desktop and phone — and this one came back with numbers rather than opinions. The two chips of a split read "1" and "1": placement counts, on chips the guide called option 1 and option 2. They now carry the option number, in the fork's own colour, and the colour stays on the chip at rest instead of appearing only on the lit one. On a phone the chips measured 30px tall — a rule meant to make them 34px on touch was inside a 30px box — and at two levels of depth the row was 410px wide on a 390px screen, with the + chip cut in half and the 👁 chip off the screen. The chips are 40px on touch now and the row wraps. The column-claim hatch measured 1.35:1 against a green floor in dark. It could not simply be made heavier — an older complaint, "it is too much", caps the band's ink under the checkerboard's and a test holds it there — so each stripe got a dark edge inside the same budget: on the same pixels the hatch now touches roughly twice the square (24–49% of it, from 4–31%) and its strongest tenth reads 1.9–2.1:1 where it read 1.3–1.6:1. That is a hatch you can see on every biome I tried; it is still not 3:1, and on a same-hue floor the typical stripe pixel stays under 2:1. The devlog itself was 664px wide on a 390px phone, because two 640px diagrams had no rule capping them, and the clip was re-shot on the phone layout: in a desktop frame its subject, a chip, was 8px tall at the size the guide embeds it on a phone.

One thing the review could not settle on its own: the −1 numeral on a mini portrait was 4.7px tall on a medium board on a phone, and a badge big enough to read, clamped inside a 36px square, landed on the face it belongs to. The call was that the face expands when you press it, so covering a little at rest is fine — and the badge now holds a floor of 8px, growing up to 1.8× on small squares, and on those squares it sits beside the head along the tile's edge rather than riding its corner. Measured after: 9px of digit on the phone, the face still showing; unchanged on the desktop, where 13 units was already 9px.

Published · More from the devlog

Play free — no download