Turning an AI search into a tree you can page through

Visualising a search is not mainly about drawing the tree. The data has to be real, the panel must not shove the board around, and it must load only for people who click the button.

The AIs in gomoku and xiangqi search several plies. I added a “tool tree” button that opens up what each depth found, which move it chose, and how sure it is.

The feature is not complicated, but it has four traps worth writing down.

Trap one: displayed scores must be real

The first version showed eight root candidates all at -4517.7. It looked broken. It was not — those are alpha-beta bounds.

Pruning works by stopping as soon as a candidate is known to be no better than the current best and returning a bound instead of a true score. So in the root loop, everything after the first candidate usually gets a bound.

The fix: for a search the user is going to look at, search every root candidate with a full window (-Infinity, Infinity). At the root, pruning barely saves anything anyway, and correctness is worth the cost.

const value =
  atRoot && rootDepth > 1
    ? negamax(ctx, depth - 1, -Infinity, Infinity, !isMax, ai, rootDepth)
    : negamax(ctx, depth - 1, alpha, beta, !isMax, ai, rootDepth);

Numbers shown to a user must be real numbers. A “process” assembled from bounds is worse than no process at all.

Trap two: confidence cannot be a constant

My first confidence was the score gap between best and second best, normalised by the value of an open four. It came out between 0.1 and 0.5, always.

The units were wrong: an open four is in the hundreds of thousands, while a typical gap is ten or twenty thousand, so the ratio is about 0.06. Confidence sat permanently low — a constant, and worse than nothing.

I switched the unit to a closed four (twenty-six thousand) and added a small depth term:

const gap = sorted.length > 1 ? Math.abs(top.score - sorted[1].score) : PATTERN.FOUR * 2;
const depthPart = Math.min(1, d / 6);
const gapPart = Math.min(1, gap / PATTERN.FOUR);
const confidence = Math.max(decided, Math.min(1, depthPart * 0.35 + gapPart * 0.65));

Now an open fight where everything is roughly equal lands around seventy percent, while a forced win pins at one hundred. The number finally discriminates.

Trap three: the panel must not shove the board

This one came from the user: the taunt used to sit in normal flow above the board, so every appearance and disappearance bounced the board up and down. While you are reading the board, that is maddening.

Two changes together:

  1. Fixed height. .notes is pinned at height: 5.5rem; no amount of content may stretch it. That is a component contract, not a styling detail.
  2. Pageable queue. Comments no longer appear for a few seconds and vanish; they enter a queue you page through with arrows. That also answers “which move triggered this one”.

The cursor state machine is pure logic (logic/notes.ts), because the error-prone part is where the cursor goes when a new entry arrives:

User is After a new entry
On the newest page Follow to the new entry
Paging back through history Stay put
Entries were evicted Shift left by the number dropped

My first version applied the third rule unconditionally, so without any eviction the cursor moved one page — you were reading entry one, a new one arrived, and the text changed to entry two. The self-test assertion for that case caught exactly this.

Trap four: load on demand, do not bill everyone

The tool tree carries SVG layout code. Players who never press the button should not download it. Wrapping it in defineAsyncComponent makes it a separate chunk:

const ToolTree = defineAsyncComponent(() => import('@/games/gomoku/ToolTree.vue'));

Note you must not add client:* here. Hydration directives are a compile-time transform and silently fail on dynamic components — a trap already recorded in this project. The app pages mount through a static shell plus createApp, so an async component can just load itself at runtime.

The build output confirms it: _astro/ToolTree.xxxx.js is its own file.

It has to actually look like a tree

Calling it a tree rules out a list. I hand-drew three layers in SVG — root, candidates, replies:

  • Nodes are hard-edged boxes (matching the site’s all-square look), monospaced, showing the point and its score
  • Edges are cubic beziers; the best line is thick orange, the rest thin ink
  • Pruned nodes get dashed borders, stating plainly that this branch was not finished
  • By default only the top five candidates and their best replies are drawn; “expand full search” draws everything, otherwise the lines smudge together

Hand-rolled layout rather than d3: the structure is a fixed three layers, pulling in a graphics library for that is not worth it, and it would add another chunk.

One more: what if they open it afterwards

A trace only exists if it was collected during the search. Players usually get curious after the AI has already moved, when there is none.

So the panel re-runs a pure analysis of the current position (without touching the board) instead of showing an empty box. You pay once, only when you ask, and the panel says “replaying the search for the current position…”.

A panel that only ever says “no data” is a feature that does not exist.

← Back to all posts

Comments

…