0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Before every move the computer plays out every game that could still follow from the board in front of it — 549,946 positions from an empty grid — and then picks a square that cannot lose. The point of this one is not the game, it is the panel beside it: a score for every square it could still play, the branch it actually chose, and three plies of the real tree, with the boards alpha-beta pruning threw away drawn faded and dashed. It opens mid-opening with a corner already played, so the first thing you see is the computer's reply and the reasoning underneath it — eight squares available, exactly one of them holding the draw. Flip to computer first and all nine of its opening replies tie at zero, which is the whole game in one picture: perfect play on both sides is a draw, so holding it to one is the best result on offer.
Source — the code you see is the code that just ran 1089 lines 50.3 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Unbeatable Tic-Tac-Toe — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---- extra palette ----
--you/--you-soft: the human player (no green — green stays earned).
--accent-ink/--run-ink: text-only variants. On light paper the display accent
(#C05600) lands at 4.2:1 and --run at 4.4:1 on their own tints, under AA for the
small mono labels that use them; these are the same hues pushed past 4.5:1.
In dark they are the display colours unchanged. */
:root {
--you: #2C6EBB; --you-soft: #E8F0FA; --danger-soft: #FBEAE8;
--accent-ink: #9A4500; --run-ink: #14682C;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--you: #7FB6F0; --you-soft: #16293D; --danger-soft: #38201E;
--accent-ink: #FFB454; --run-ink: #3FB950;
}
}
:root[data-theme="dark"] {
--you: #7FB6F0; --you-soft: #16293D; --danger-soft: #38201E;
--accent-ink: #FFB454; --run-ink: #3FB950;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
.wrap { max-width: 1160px; margin: 0 auto; padding: 22px 16px 48px; }
/* ---- header ---- */
header { margin-bottom: 18px; }
h1 { font-size: 1.35rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.lede { margin: 0 0 10px; color: var(--muted); max-width: 68ch; font-size: .95rem; }
.spec { display: flex; flex-wrap: wrap; gap: 6px; }
.spec b {
font-family: var(--font-mono); font-size: .7rem; font-weight: 500; letter-spacing: .02em;
padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px;
color: var(--muted); background: var(--surface); white-space: nowrap;
}
.spec b.warn { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); }
/* ---- layout ---- */
.grid { display: grid; gap: 14px; grid-template-columns: minmax(0, 1fr); align-items: start; }
.grid > * { min-width: 0; }
.col-left { display: contents; } /* narrow: game, search panel, then the notes */
.col-b { order: 2; }
.col-c { order: 3; }
@media (min-width: 920px) {
.grid { grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); }
.col-left { display: block; min-width: 0; }
}
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
.card + .card { margin-top: 14px; }
.card h2 {
font-family: var(--font-mono); font-size: .72rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); margin: 0 0 10px;
}
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.spread { justify-content: space-between; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---- controls ---- */
button { font: inherit; color: inherit; }
.btn {
font-family: var(--font-mono); font-size: .74rem; padding: 5px 9px;
background: var(--surface); color: var(--muted);
border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
}
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--ink); }
.btn[aria-pressed="true"], .btn.on {
background: var(--accent-soft); border-color: var(--accent); color: var(--ink); font-weight: 600;
}
.btn:disabled { opacity: .45; cursor: default; }
.seg { display: inline-flex; gap: 0; }
.seg .btn { border-radius: 0; margin-left: -1px; }
.seg .btn:first-child { border-radius: 7px 0 0 7px; margin-left: 0; }
.seg .btn:last-child { border-radius: 0 7px 7px 0; }
.seg .btn[aria-pressed="true"] { position: relative; z-index: 1; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- board ---- */
.board-wrap { position: relative; width: 100%; max-width: 316px; margin: 2px auto 0; }
.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.cell {
position: relative; aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--line); border-radius: 10px; padding: 0; cursor: pointer;
}
.cell[aria-disabled="true"] { cursor: default; }
.cell.free:hover { border-color: var(--accent); background: var(--accent-soft); }
.cell.hint { border-color: var(--accent); border-style: dashed; box-shadow: inset 0 0 0 3px var(--accent-soft); }
.cell.wincell.wy { background: var(--you-soft); border-color: var(--you); }
.cell.wincell.wa { background: var(--accent-soft); border-color: var(--accent); }
.cell .coord {
position: absolute; top: 4px; left: 6px; font-family: var(--font-mono);
font-size: .56rem; color: var(--muted); letter-spacing: .04em;
}
.mark { width: 60%; height: 60%; display: block; overflow: visible; }
.mark line, .mark circle { fill: none; stroke-width: 9; stroke-linecap: round; }
.mark.mx line { stroke: var(--you); }
.mark.mo circle { stroke: var(--accent); }
.winline { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.winline line { stroke-width: 7; stroke-linecap: round; stroke: currentColor; }
.winline.wy { color: var(--you); }
.winline.wa { color: var(--accent); }
.turnbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.who { font-family: var(--font-mono); font-size: .7rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
.who .g { width: 13px; height: 13px; }
.status { font-family: var(--font-mono); font-size: .74rem; color: var(--muted); min-height: 1.4em; }
.status.ok { color: var(--run-ink); }
.status.bad { color: var(--danger); }
/* hint line: ink text (accent on paper is only 4.2:1 at this size), accent rule for identity */
.status.hintline { color: var(--ink); border-left: 2px solid var(--accent); padding-left: 8px; }
.status.hintline.blank { border-left-color: transparent; }
.hide { display: none !important; }
@media (prefers-reduced-motion: no-preference) {
.status.dots { animation: pulse 1.15s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .48; } }
.mark.fresh { animation: pop .18s ease-out both; }
@keyframes pop { from { opacity: 0; transform: scale(.72); } to { opacity: 1; transform: scale(1); } }
.winline.draw line { animation: draw .34s ease-out both; }
@keyframes draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
.btn, .cell, .node, .mapcell { transition: background-color .12s ease, border-color .12s ease, color .12s ease; }
}
.winline.draw line { stroke-dasharray: 1; }
/* ---- stat strips ---- */
.stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
}
.stat { background: var(--surface); padding: 8px 10px; }
.stat .k { font-family: var(--font-mono); font-size: .6rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.stat .v { font-family: var(--font-mono); font-size: 1.02rem; font-weight: 600; letter-spacing: -0.02em; }
.stat .v small { font-size: .62rem; font-weight: 500; color: var(--muted); display: block; letter-spacing: 0; }
@media (max-width: 560px) { .stats { grid-template-columns: repeat(2, 1fr); } }
.stat .v.you { color: var(--you); }
.stat .v.ai { color: var(--accent-ink); }
/* the +/0/- colour code, applied everywhere a score is printed */
.stat .v.pos { color: var(--danger); }
.stat .v.neg { color: var(--you); }
.node .sc.pos { color: var(--danger); }
.node .sc.neg { color: var(--you); }
.node .sc.zero { color: var(--muted); }
.inspect .zero { color: var(--ink); }
.log {
font-family: var(--font-mono); font-size: .68rem; color: var(--muted);
display: flex; flex-wrap: wrap; gap: 4px 6px; margin-top: 10px;
}
.log i { font-style: normal; border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; background: var(--bg); }
.log i b { font-weight: 600; }
.log i b.x { color: var(--you); } .log i b.o { color: var(--accent-ink); }
.log .sc { color: var(--muted); }
.note { font-size: .78rem; color: var(--muted); margin: 9px 0 0; }
.note code, .note b.m { font-family: var(--font-mono); font-size: .95em; font-weight: 600; }
.badge {
font-family: var(--font-mono); font-size: .66rem; padding: 2px 7px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg); white-space: nowrap;
}
.badge.ok { color: var(--run-ink); border-color: var(--run); background: var(--run-soft); }
/* ---- score map ---- */
.maprow { display: grid; grid-template-columns: minmax(0, 176px) minmax(0, 1fr); gap: 14px; align-items: start; }
@media (max-width: 460px) { .maprow { grid-template-columns: 1fr; } }
.map { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
.mapcell {
position: relative; aspect-ratio: 1 / 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 1px; padding: 2px;
border: 1px solid var(--line); border-radius: 7px; background: var(--bg); cursor: pointer;
}
.mapcell.occ { cursor: default; background: var(--surface); }
.mapcell.pos { background: var(--danger-soft); border-color: var(--danger); }
.mapcell.neg { background: var(--you-soft); border-color: var(--you); }
.mapcell:hover:not(.occ) { border-color: var(--accent); }
.mapcell.opened { border-color: var(--ink); }
.mapcell.pick { box-shadow: 0 0 0 2px var(--accent); border-color: var(--accent); }
.mapcell .n { font-family: var(--font-mono); font-size: 1.2rem; font-weight: 700; letter-spacing: -.02em; }
.mapcell .w { font-family: var(--font-mono); font-size: .52rem; color: var(--muted); letter-spacing: .02em; }
.mapcell .tagpick {
position: absolute; top: -7px; right: -3px; font-family: var(--font-mono); font-size: .52rem;
background: var(--accent); color: var(--surface); border-radius: 999px; padding: 0 5px; letter-spacing: .03em;
}
.mapcell .g { width: 20px; height: 20px; opacity: .75; }
.pos .n, .n.pos { color: var(--danger); }
.neg .n, .n.neg { color: var(--you); }
.n.zero { color: var(--muted); }
.legend { display: flex; flex-wrap: wrap; gap: 4px 8px; font-family: var(--font-mono); font-size: .64rem; color: var(--muted); }
.legend > span + span::before { content: "\00b7"; color: var(--muted); opacity: .55; margin-right: 7px; }
.legend span b { font-weight: 700; }
.legend .pos { color: var(--danger); } .legend .neg { color: var(--you); }
/* ---- tree ---- */
.tree { position: relative; padding-left: 16px; }
.tree::before { content: ""; position: absolute; left: 4px; top: 8px; bottom: 20px; width: 1px; background: var(--line); }
.lvl + .lvl { margin-top: 10px; }
.lvl-head { position: relative; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; margin-bottom: 5px; }
.lvl-head .dot {
position: absolute; left: -15px; top: 6px; width: 7px; height: 7px; border-radius: 50%;
background: var(--accent); border: 1px solid var(--surface);
}
.lvl-head .lbl { font-family: var(--font-mono); font-size: .66rem; color: var(--ink); letter-spacing: .02em; }
.lvl-head .sub { font-family: var(--font-mono); font-size: .62rem; color: var(--muted); }
/* padding leaves room for the focus ring, which the scroll container would otherwise clip */
.lvl-row { display: flex; gap: 7px; overflow-x: auto; padding: 5px 4px 9px; scrollbar-width: thin; }
.node {
flex: 0 0 auto; width: 62px; padding: 5px 3px 4px; background: var(--surface);
border: 1px solid var(--line); border-radius: 8px; cursor: pointer;
display: flex; flex-direction: column; align-items: center; gap: 2px;
}
.node:hover { border-color: var(--accent); }
.node .g { width: 38px; height: 38px; }
.node .co { font-family: var(--font-mono); font-size: .6rem; color: var(--muted); letter-spacing: .04em; }
.node .sc { font-family: var(--font-mono); font-size: .78rem; font-weight: 700; letter-spacing: -.02em; }
.node .tag { font-family: var(--font-mono); font-size: .52rem; letter-spacing: .04em; color: var(--muted); min-height: .9em; }
.node .tag.hid { visibility: hidden; }
.node .tag.best { color: var(--accent-ink); font-weight: 700; }
.node .tag.cut { color: var(--muted); }
.node.open { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.node.sel { border-color: var(--ink); }
.node.cut { border-style: dashed; background: var(--bg); }
.node.cut .mini { opacity: .45; }
.node.cut:hover .mini { opacity: 1; }
.mini .mgrid { stroke: var(--line); stroke-width: .6; fill: none; }
.mini .last { fill: var(--accent-soft); stroke: var(--accent); stroke-width: .45; opacity: .9; }
.mini .mx { stroke: var(--you); }
.mini .mo { stroke: var(--accent); }
.mini .mx, .mini .mo { stroke-width: 1.5; stroke-linecap: round; fill: none; }
.inspect {
font-family: var(--font-mono); font-size: .68rem; color: var(--muted);
background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
padding: 7px 9px; margin-top: 4px; overflow-wrap: anywhere;
}
.inspect b { color: var(--ink); font-weight: 600; }
.inspect .pos { color: var(--danger); } .inspect .neg { color: var(--you); }
.deadend { font-family: var(--font-mono); font-size: .66rem; color: var(--muted); padding: 6px 2px; }
/* ---- details ---- */
details {
margin-top: 14px; background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 10px 14px;
}
summary { cursor: pointer; font-family: var(--font-mono); font-size: .72rem; color: var(--muted); list-style: none; }
summary::-webkit-details-marker { display: none; }
details[open] summary { color: var(--accent-ink); margin-bottom: 6px; }
summary .tri { display: inline-block; }
details[open] summary .tri { transform: rotate(90deg); }
@media (prefers-reduced-motion: no-preference) { summary .tri { transition: transform .15s ease; } }
details .body { font-size: .85rem; color: var(--muted); max-width: 74ch; }
details .body p { margin: 8px 0; }
details .body b { color: var(--ink); }
details .body code { font-family: var(--font-mono); font-size: .93em; }
footer { margin-top: 26px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Unbeatable Tic-Tac-Toe</h1>
<p class="lede">Before every move the computer plays out <em>every</em> game that could still happen, then picks a
square that cannot lose. The search panel shows that work in full: how many positions it looked at,
what it thought each square was worth, and the line it expects you to play.</p>
<div class="spec">
<b>minimax + alpha-beta</b>
<b>549,946 positions in the full tree</b>
<b>plain JS, nothing loaded</b>
<b class="warn">the best you can get is a draw</b>
</div>
</header>
<div class="grid">
<!-- LEFT: the game, with the notes stacked under it -->
<div class="col-left">
<div class="col-a">
<section class="card">
<div class="row spread" style="margin-bottom:9px">
<h2 style="margin:0">The game</h2>
<span class="badge" id="plyBadge">move 1</span>
</div>
<div class="turnbar">
<span class="who"><svg class="g mark mx" viewBox="0 0 100 100" aria-hidden="true"><line x1="22" y1="22" x2="78" y2="78"/><line x1="78" y1="22" x2="22" y2="78"/></svg> you</span>
<span class="who"><svg class="g mark mo" viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="29"/></svg> computer</span>
</div>
<div class="board-wrap">
<div class="board" id="board" role="group" aria-label="Tic-tac-toe board, three by three. Use arrow keys to move, Enter to play."></div>
<svg class="winline" id="winline" viewBox="0 0 300 300" aria-hidden="true" hidden><line id="wl" x1="0" y1="0" x2="0" y2="0" pathLength="1"/></svg>
</div>
<p class="status" id="status" role="status" aria-live="polite" style="margin:12px 0 2px"> </p>
<p class="status hintline blank" id="hintline" style="margin:0 0 10px"> </p>
<div class="row" style="margin-bottom:10px">
<button class="btn" type="button" id="btnNew">new game</button>
<span class="seg" role="group" aria-label="Who moves first">
<button class="btn on" type="button" id="firstYou" aria-pressed="true">you first</button>
<button class="btn" type="button" id="firstAi" aria-pressed="false">computer first</button>
</span>
<button class="btn" type="button" id="btnHint">hint</button>
</div>
<div class="stats">
<div class="stat"><div class="k">your wins</div><div class="v you" id="tYou">0</div></div>
<div class="stat"><div class="k">draws</div><div class="v" id="tDraw">0</div></div>
<div class="stat"><div class="k">its wins</div><div class="v ai" id="tAi">0</div></div>
<div class="stat"><div class="k">games</div><div class="v" id="tGames">0</div></div>
</div>
<div class="log" id="log"></div>
<p class="note">Squares are named like a map: <b class="m">a1</b> top-left, <b class="m">b2</b> the middle,
<b class="m">c3</b> bottom-right. Click a square, or tab to the board and use the arrow keys.</p>
</section>
</div>
<!-- NOTES: tucked under the game at wide widths, last on narrow -->
<div class="col-c">
<details>
<summary><span class="tri">▸</span> how the search actually works</summary>
<div class="body">
<p><b>Minimax.</b> The computer walks the whole game forward, one square at a time, until every line ends in
a win, a loss or a full board. On its own turns it assumes it will pick the best square available; on your
turns it assumes you will pick the worst square for it. That is the whole idea: take the maximum of your
minimums.</p>
<p><b>The scores.</b> A finished line is worth <code>+1</code> if the computer won, <code>-1</code> if you
won, <code>0</code> if the board filled up. To stop it from dawdling, the score shrinks the longer a win
takes: a win on the very next move scores <code>+9</code>, a win four moves out scores <code>+6</code>, and a
loss it cannot avoid is pushed as far away as possible. That is why it blocks you immediately instead of
wandering — and why the numbers in the score map differ even when several squares all end in a draw.</p>
<p><b>Alpha-beta pruning.</b> While searching, it keeps track of the best score it has already locked in.
The moment a branch is proven worse than that, it stops reading the rest of that branch — nothing there
can change the answer. It reaches exactly the same move having looked at a fraction of the positions,
which is the second number in the stats strip.</p>
<p><b>Ties.</b> When several squares score the same, the computer picks one of them at random, so games do
not repeat. Every tied square is equally good, so this costs it nothing.</p>
<p><b>What is drawn.</b> The tree panel shows the first three half-moves of the real search. Deeper plies are
searched exactly the same way, there are simply far too many boards to draw — from an empty board the
full tree is 549,946 positions.</p>
</div>
</details>
<details>
<summary><span class="tri">▸</span> why you cannot win</summary>
<div class="body">
<p>Tic-tac-toe is solved: with both sides playing perfectly, every game is a draw. The computer searches to
the end of the game every single move, so it never plays a second-best square. It cannot be tricked, and it
has no opening book to catch it out — it re-derives everything from the position in front of it.</p>
<p><b>So a draw is a win for you.</b> Try the pre-played opening: after you take a corner, the score map
shows that exactly one of the computer's eight replies holds the draw. Every other square hands you the game.
Take the middle yourself and watch the same thing happen from the other side.</p>
<p><b>Where people slip.</b> Answer a corner opening with an edge instead of the middle, or chase your own
line while the computer builds two at once, and the score map turns red one move before you feel it.</p>
</div>
</details>
</div>
</div>
<!-- RIGHT: the search -->
<div class="col-b">
<section class="card">
<div class="row spread" style="margin-bottom:10px">
<h2 style="margin:0">What it worked out</h2>
<span class="badge" id="pBadge"> </span>
</div>
<div class="stats" id="pStats"></div>
<div class="maprow" style="margin-top:12px">
<div>
<div class="map" id="map" role="group" aria-label="Score for each square the search considered"></div>
</div>
<div>
<div class="legend" style="margin-bottom:8px">
<span class="pos"><b>+</b> computer wins</span>
<span><b>0</b> draw</span>
<span class="neg"><b>−</b> you win</span>
<span>bigger number = happens sooner</span>
</div>
<p class="note" id="mapNote" style="margin:0"></p>
</div>
</div>
</section>
<section class="card">
<div class="row spread" style="margin-bottom:10px">
<h2 style="margin:0">The tree it searched</h2>
<span class="badge" id="treeBadge"> </span>
</div>
<div class="tree" id="tree"></div>
<div class="inspect" id="inspect"> </div>
<p class="note">Click any board in the tree to follow that branch. Faded, dashed boards are the ones
alpha-beta pruning threw away without looking — once one reply is known to be good enough,
the rest of that branch cannot change the answer.</p>
</section>
</div>
</div>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
/* ---------- constants ---------- */
var YOU = 1, AI = 2;
var LF = new Int8Array([0,1,2, 3,4,5, 6,7,8, 0,3,6, 1,4,7, 2,5,8, 0,4,8, 2,4,6]);
var COORD = ["a1","b1","c1","a2","b2","c2","a3","b3","c3"];
var SPOKEN = ["top left","top middle","top right","middle left","center","middle right",
"bottom left","bottom middle","bottom right"];
var RECD = 3; /* plies kept for drawing */
var RM = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function $(id) { return document.getElementById(id); }
function other(p) { return p === AI ? YOU : AI; }
function now() { return (window.performance && performance.now) ? performance.now() : Date.now(); }
function fmt(n) { return n.toLocaleString("en-US"); }
function fmtMs(ms) {
if (!(ms > 0.05)) return "<0.1 ms"; /* clock resolution, not a real zero */
if (ms < 10) return ms.toFixed(1) + " ms";
return Math.round(ms) + " ms";
}
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
/* ---------- rules ---------- */
function winFast(b) {
for (var k = 0; k < 24; k += 3) {
var a = b[LF[k]];
if (a !== 0 && a === b[LF[k + 1]] && a === b[LF[k + 2]]) return a;
}
return 0;
}
function winLineOf(b) {
for (var k = 0; k < 24; k += 3) {
var a = b[LF[k]];
if (a !== 0 && a === b[LF[k + 1]] && a === b[LF[k + 2]]) return [LF[k], LF[k + 1], LF[k + 2]];
}
return null;
}
function emptyCount(b) { var n = 0; for (var i = 0; i < 9; i++) if (b[i] === 0) n++; return n; }
function isTerminal(b) { return winFast(b) !== 0 || emptyCount(b) === 0; }
/* ---------- exact minimax, recording the first RECD plies ----------
Scores are always from the computer's point of view:
+(10-ply) computer wins on that ply, -(10-ply) you win, 0 draw. */
var gN = 0, gByPath = null;
function ex(b, turn, depth, path, parentNode) {
gN++;
var w = winFast(b);
if (w === AI) return 10 - depth;
if (w === YOU) return depth - 10;
var rec = depth < RECD;
var best = turn === AI ? -100 : 100, any = false, i;
for (i = 0; i < 9; i++) {
if (b[i] !== 0) continue;
any = true;
b[i] = turn;
var nd = null, cp = path;
if (rec) {
cp = path === "" ? String(i) : path + "-" + i;
nd = { path: cp, move: i, by: turn, depth: depth + 1, score: 0,
board: Int8Array.from(b), children: [], terminal: false, cut: false };
gByPath[cp] = nd;
parentNode.children.push(nd);
}
var s = ex(b, other(turn), depth + 1, cp, nd || parentNode);
b[i] = 0;
if (nd) { nd.score = s; nd.terminal = isTerminal(nd.board); }
if (turn === AI) { if (s > best) best = s; } else { if (s < best) best = s; }
}
return any ? best : 0;
}
/* ---------- same search with alpha-beta, only counting + marking visits ---------- */
var gAB = 0, gSeen = null;
function ab(b, turn, depth, alpha, beta, path) {
gAB++;
if (path !== null) gSeen[path] = 1;
var w = winFast(b);
if (w === AI) return 10 - depth;
if (w === YOU) return depth - 10;
var any = false, i, s, cp, best;
if (turn === AI) {
best = -100;
for (i = 0; i < 9; i++) {
if (b[i] !== 0) continue;
any = true; b[i] = AI;
cp = (path !== null && depth < RECD) ? (path === "" ? String(i) : path + "-" + i) : null;
s = ab(b, YOU, depth + 1, alpha, beta, cp);
b[i] = 0;
if (s > best) best = s;
if (best > alpha) alpha = best;
if (alpha >= beta) break;
}
return any ? best : 0;
}
best = 100;
for (i = 0; i < 9; i++) {
if (b[i] !== 0) continue;
any = true; b[i] = YOU;
cp = (path !== null && depth < RECD) ? (path === "" ? String(i) : path + "-" + i) : null;
s = ab(b, AI, depth + 1, alpha, beta, cp);
b[i] = 0;
if (s < best) best = s;
if (best < beta) beta = best;
if (alpha >= beta) break;
}
return any ? best : 0;
}
function runSearch(board, turn) {
var root = { path: "", move: -1, by: 0, depth: 0, score: 0,
board: Int8Array.from(board), children: [], terminal: false, cut: false };
gByPath = { "": root };
gN = 0;
var b = Int8Array.from(board);
var t0 = now();
root.score = ex(b, turn, 0, "", root);
var t1 = now();
gAB = 0; gSeen = {};
var b2 = Int8Array.from(board);
var t2 = now();
ab(b2, turn, 0, -100, 100, "");
var t3 = now();
var byPath = gByPath, p;
for (p in byPath) if (p !== "" && !gSeen[p]) byPath[p].cut = true;
var ties = [], i;
for (i = 0; i < root.children.length; i++) if (root.children[i].score === root.score) ties.push(root.children[i]);
var chosen = ties[Math.floor(Math.random() * ties.length)];
return { who: turn, root: root, byPath: byPath, nodes: gN, abNodes: gAB,
msFull: t1 - t0, msAb: t3 - t2, ties: ties.length, chosen: chosen };
}
function cutKids(node) {
var n = 0, i;
for (i = 0; i < node.children.length; i++) if (node.children[i].cut) n++;
return n;
}
/* Best reply for whoever moves next. Replies that tie on score are genuinely equally
good, so among those prefer the one whose own row shows some pruning — that way the
line opened by default demonstrates what the faded boards mean. */
function bestChildPath(node) {
if (!node || !node.children.length) return null;
var mover = other(node.by), best = null, bestCuts = -1, i, c, cuts;
for (i = 0; i < node.children.length; i++) {
c = node.children[i]; cuts = cutKids(c);
if (best === null || (mover === AI ? c.score > best.score : c.score < best.score)) {
best = c; bestCuts = cuts; continue;
}
if (c.score === best.score && cuts > bestCuts) { best = c; bestCuts = cuts; }
}
return best.path;
}
function makePanel(res) {
var p = {
who: res.who, root: res.root, byPath: res.byPath, nodes: res.nodes, abNodes: res.abNodes,
msFull: res.msFull, msAb: res.msAb, ties: res.ties, chosen: res.chosen,
chosenPath: res.chosen ? res.chosen.path : null, open1: null, open2: null, sel: null
};
p.open1 = p.chosenPath;
p.open2 = p.open1 ? bestChildPath(p.byPath[p.open1]) : null;
p.sel = p.open1;
return p;
}
/* ---------- drawing helpers ---------- */
function markSVG(p, fresh) {
var c = "mark " + (p === YOU ? "mx" : "mo") + (fresh ? " fresh" : "");
if (p === YOU) {
return '<svg class="' + c + '" viewBox="0 0 100 100" aria-hidden="true">' +
'<line x1="24" y1="24" x2="76" y2="76"/><line x1="76" y1="24" x2="24" y2="76"/></svg>';
}
return '<svg class="' + c + '" viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="27"/></svg>';
}
function miniSVG(b, last) {
var s = '<svg class="g mini" viewBox="0 0 30 30" aria-hidden="true">';
if (last !== null && last >= 0) {
s += '<rect class="last" x="' + (last % 3) * 10 + '" y="' + (((last / 3) | 0) * 10) + '" width="10" height="10"/>';
}
s += '<path class="mgrid" d="M10 1V29M20 1V29M1 10H29M1 20H29"/>';
for (var i = 0; i < 9; i++) {
if (!b[i]) continue;
var x = (i % 3) * 10, y = ((i / 3) | 0) * 10;
if (b[i] === YOU) {
s += '<path class="mx" d="M' + (x + 2.9) + ' ' + (y + 2.9) + 'L' + (x + 7.1) + ' ' + (y + 7.1) +
'M' + (x + 7.1) + ' ' + (y + 2.9) + 'L' + (x + 2.9) + ' ' + (y + 7.1) + '"/>';
} else {
s += '<circle class="mo" cx="' + (x + 5) + '" cy="' + (y + 5) + '" r="2.4"/>';
}
}
return s + "</svg>";
}
function scoreStr(s) { return (s > 0 ? "+" : "") + s; }
function scoreCls(s) { return s > 0 ? "pos" : (s < 0 ? "neg" : "zero"); }
function endPly(s) { return 10 - Math.abs(s); }
function outcomeShort(s) { return s === 0 ? "draw" : (s > 0 ? "it wins" : "you win"); }
function outcomeLong(s) {
if (s === 0) return "this line ends in a draw";
return (s > 0 ? "the computer wins" : "you win") + " on ply " + endPly(s) + " of the search";
}
/* ---------- game state ---------- */
var st = {
board: new Int8Array(9), turn: YOU, first: YOU, over: false, winner: 0, winLine: null,
log: [], hint: null, hintShown: false, thinking: false, intro: false, preplayed: false, lastIdx: -1,
tally: { you: 0, draw: 0, ai: 0 }, games: 0, gen: 0
};
var panel = null;
var cells = [];
/* ---------- board ---------- */
function buildBoard() {
var wrap = $("board"), i, html = "";
for (i = 0; i < 9; i++) {
html += '<button class="cell" type="button" id="cell' + i + '" data-i="' + i + '"></button>';
}
wrap.innerHTML = html;
for (i = 0; i < 9; i++) cells.push($("cell" + i));
}
function renderBoard() {
for (var i = 0; i < 9; i++) {
var v = st.board[i];
var free = v === 0 && !st.over && !st.thinking && st.turn === YOU;
var cls = "cell" + (free ? " free" : "");
if (st.hint && st.hint.move === i && free) cls += " hint";
if (st.winLine && st.winLine.indexOf(i) >= 0) cls += " wincell " + (st.winner === YOU ? "wy" : "wa");
var el = cells[i];
if (el.className !== cls) el.className = cls;
el.setAttribute("aria-disabled", free ? "false" : "true");
el.setAttribute("aria-label", COORD[i] + ", " + SPOKEN[i] + ", " +
(v === 0 ? (free ? "empty, play here" : "empty") : (v === YOU ? "your X" : "the computer's O")) +
(st.hint && st.hint.move === i && free ? ", suggested" : ""));
var html = '<span class="coord">' + COORD[i] + "</span>" + (v ? markSVG(v, i === st.lastIdx) : "");
if (el.innerHTML !== html) el.innerHTML = html;
}
renderWinLine();
}
/* The board is a 3x3 grid with an 8px gap, so cell centres are NOT at 1/6, 1/2, 5/6
of the box. Measure the real cells and match the viewBox to the real pixel box. */
function renderWinLine() {
var svg = $("winline"), ln = $("wl");
if (!st.winLine) { svg.setAttribute("class", "winline hide"); svg.setAttribute("hidden", "hidden"); return; }
var a = st.winLine[0], c = st.winLine[2];
var br = $("board").getBoundingClientRect();
var ra = cells[a].getBoundingClientRect(), rc = cells[c].getBoundingClientRect();
var W, H, x1, y1, x2, y2, ext;
if (br.width > 0 && br.height > 0 && ra.width > 0) {
W = br.width; H = br.height; ext = ra.width * 0.22;
x1 = ra.left - br.left + ra.width / 2; y1 = ra.top - br.top + ra.height / 2;
x2 = rc.left - br.left + rc.width / 2; y2 = rc.top - br.top + rc.height / 2;
} else { /* not laid out yet: nominal 100px cells, 8px gaps */
W = H = 316; ext = 22;
x1 = (a % 3) * 108 + 50; y1 = ((a / 3) | 0) * 108 + 50;
x2 = (c % 3) * 108 + 50; y2 = ((c / 3) | 0) * 108 + 50;
}
var dx = x2 - x1, dy = y2 - y1, len = Math.sqrt(dx * dx + dy * dy) || 1;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
ln.setAttribute("x1", x1 - (dx / len) * ext); ln.setAttribute("y1", y1 - (dy / len) * ext);
ln.setAttribute("x2", x2 + (dx / len) * ext); ln.setAttribute("y2", y2 + (dy / len) * ext);
svg.removeAttribute("hidden");
svg.setAttribute("class", "winline draw " + (st.winner === YOU ? "wy" : "wa"));
}
/* ---------- status, tally, log ---------- */
function renderStatus() {
var el = $("status"), cls = "status", txt;
if (st.thinking) {
cls += " dots";
txt = (st.intro ? "Opening pre-played: you took the corner a1. Searching every reply"
: "Searching every follow-up") + "\u2026";
} else if (st.over) {
if (st.winner === 0) { cls += " ok"; txt = "✓ Draw. You held a perfect player to a tie — that is the ceiling here."; }
else if (st.winner === AI) { cls += " bad"; txt = "The computer got there first. The score map shows where it turned."; }
else { cls += " ok"; txt = "✓ You win."; }
} else if (st.turn === YOU) {
txt = st.preplayed ? "Your move — the opening a1 / b2 was played for you to get things going."
: "Your move — you play X.";
} else { txt = " "; }
el.className = cls;
el.textContent = txt;
var h = $("hintline");
if (st.hint && !st.over && st.turn === YOU) {
var s = st.hint.score, m = COORD[st.hint.move], t;
if (s < 0) t = "Hint: play " + m + " — that line wins for you.";
else if (s === 0) t = "Hint: play " + m + " — it holds the draw" +
(st.hint.ties > 1 ? " (" + (st.hint.ties - 1) + (st.hint.ties > 2 ? " other squares also hold" : " other square also holds") + ")" : ", and it is the only square that does") + ".";
else t = "Hint: play " + m + " — every square loses from here; this one holds out longest.";
h.className = "status hintline";
h.textContent = t;
} else { h.className = "status hintline blank"; h.textContent = " "; }
$("plyBadge").textContent = st.over ? "final position" : "move " + Math.min(st.log.length + 1, 9) + " of 9";
$("btnHint").disabled = st.over || st.thinking || st.turn !== YOU;
}
function renderTally() {
$("tYou").textContent = st.tally.you;
$("tDraw").textContent = st.tally.draw;
$("tAi").textContent = st.tally.ai;
$("tGames").textContent = st.games;
}
function renderLog() {
var h = "", i, e;
for (i = 0; i < st.log.length; i++) {
e = st.log[i];
h += '<i>' + (i + 1) + " " + '<b class="' + (e.who === YOU ? "x" : "o") + '">' + (e.who === YOU ? "X" : "O") + "</b> " +
COORD[e.idx] + (e.score === null || e.score === undefined ? "" : ' <span class="sc">' + scoreStr(e.score) + "</span>") + "</i>";
}
$("log").innerHTML = h || '<i>no moves yet</i>';
}
/* ---------- search panel ---------- */
function renderPanel() {
var bad = $("pBadge"), stats = $("pStats"), map = $("map"), note = $("mapNote");
if (!panel) {
var busy = st.thinking;
bad.className = "badge"; bad.textContent = busy ? "searching\u2026" : "no search yet";
stats.innerHTML = "";
map.innerHTML = "";
note.textContent = busy
? "Reading every game that can still follow from this board\u2026"
: "Make a move, or press hint, and this panel fills with the search behind it.";
$("treeBadge").textContent = " ";
$("tree").innerHTML = '<div class="deadend">' + (busy ? "building the tree\u2026" : "nothing searched yet") + "</div>";
$("inspect").innerHTML = " ";
return;
}
var mine = panel.who === AI;
/* while a fresh search is running the numbers below are the PREVIOUS one — say so */
if (st.thinking) { bad.className = "badge"; bad.textContent = "searching…"; }
else { bad.className = "badge ok"; bad.textContent = "✓ searched in " + fmtMs(panel.msFull); }
var pct = panel.nodes > 0 ? (100 - (panel.abNodes / panel.nodes) * 100) : 0;
var sc = panel.root.score;
stats.innerHTML =
'<div class="stat"><div class="k">positions</div><div class="v">' + fmt(panel.nodes) +
"<small>every board to the end</small></div></div>" +
'<div class="stat"><div class="k">with alpha-beta</div><div class="v">' + fmt(panel.abNodes) +
"<small>" + pct.toFixed(1) + "% never opened</small></div></div>" +
'<div class="stat"><div class="k">search time</div><div class="v">' + esc(fmtMs(panel.msFull)) +
"<small>pruned run " + esc(fmtMs(panel.msAb)) + "</small></div></div>" +
'<div class="stat"><div class="k">verdict</div><div class="v ' + scoreCls(sc) + '">' + scoreStr(sc) +
"<small>" + (sc === 0 ? "draw with best play" : (sc > 0 ? "computer wins on ply " + endPly(sc) : "you win on ply " + endPly(sc))) +
"</small></div></div>";
/* score map */
var kids = {}, i, c;
for (i = 0; i < panel.root.children.length; i++) { c = panel.root.children[i]; kids[c.move] = c; }
var h = "", nWin = 0, nDraw = 0, nLose = 0;
for (i = 0; i < 9; i++) {
c = kids[i];
if (!c) {
var v = panel.root.board[i];
h += '<div class="mapcell occ"><span class="sr">' +
esc(COORD[i] + ", already taken by " + (v === YOU ? "you" : "the computer")) + "</span>" +
(v ? markSVG(v, false).replace('class="mark', 'class="g mark') : '<span class="w">—</span>') + "</div>";
continue;
}
var mv = mine ? c.score : -c.score;
if (mv > 0) nWin++; else if (mv === 0) nDraw++; else nLose++;
var kls = "mapcell " + scoreCls(c.score) + (c.path === panel.chosenPath ? " pick" : "");
h += '<button class="' + kls + '" type="button" data-map="' + c.path + '" aria-label="' +
esc(COORD[i] + ", score " + scoreStr(c.score) + ", " + outcomeLong(c.score) + ". Show this branch in the tree.") + '">' +
(c.path === panel.chosenPath ? '<span class="tagpick">' + (mine ? "played" : "hint") + "</span>" : "") +
'<span class="n ' + scoreCls(c.score) + '">' + scoreStr(c.score) + "</span>" +
'<span class="w">' + outcomeShort(c.score) + "</span></button>";
}
map.innerHTML = h;
markMapOpen();
var open = panel.root.children.length;
note.innerHTML = (mine ? "The computer had " : "You have ") + "<b class=\"m\">" + open + "</b> square" +
(open === 1 ? "" : "s") + " to choose from: " +
"<b class=\"m\">" + nWin + "</b> win, <b class=\"m\">" + nDraw + "</b> draw, <b class=\"m\">" + nLose + "</b> lose. " +
(panel.chosen
? (mine ? "It played <b class=\"m\">" + COORD[panel.chosen.move] + "</b>"
: "Best for you is <b class=\"m\">" + COORD[panel.chosen.move] + "</b>") +
(panel.ties > 1 ? ", one of <b class=\"m\">" + panel.ties + "</b> squares tied at <b class=\"m\">" + scoreStr(panel.root.score) + "</b>." : ".")
: "");
renderTree();
}
/* Which square's branch the tree is currently showing. */
function markMapOpen() {
var all = $("map").querySelectorAll("[data-map]"), i, on;
for (i = 0; i < all.length; i++) {
on = panel && all[i].getAttribute("data-map") === panel.open1;
all[i].classList.toggle("opened", !!on);
if (on) all[i].setAttribute("aria-current", "true");
else all[i].removeAttribute("aria-current");
}
}
/* ---------- tree ---------- */
function rowHTML(nodes, mover, openPath, depth, fromNode) {
var i, c, h = "", bestScore = null;
for (i = 0; i < nodes.length; i++) {
c = nodes[i];
if (bestScore === null) bestScore = c.score;
else if (mover === AI ? c.score > bestScore : c.score < bestScore) bestScore = c.score;
}
var chain = fromNode ? fromNode.path.split("-").map(function (x) { return COORD[+x]; }).join(" → ") : null;
var dead = fromNode && fromNode.terminal;
var nBest = 0;
for (i = 0; i < nodes.length; i++) if (nodes[i].score === bestScore) nBest++;
var allTied = nodes.length > 1 && nBest === nodes.length;
h += '<div class="lvl"><div class="lvl-head"><span class="dot"></span>' +
'<span class="lbl">' + (dead ? "the line ends" : "ply " + depth + " · " + (mover === AI ? "computer" : "you") + " to move") + "</span>" +
'<span class="sub">' + (dead ? "nothing left to search" : (mover === AI ? "takes the highest" : "takes the lowest")) +
(chain ? " · after " + chain : "") +
(dead ? "" : " · " + nodes.length + " option" + (nodes.length === 1 ? "" : "s") +
(allTied ? " · all tie at " + scoreStr(bestScore) : "")) +
"</span></div>";
if (!nodes.length) {
h += '<div class="deadend">' + (fromNode ? "this line is already over — " + outcomeLong(fromNode.score) : "no moves left") + "</div></div>";
return h;
}
h += '<div class="lvl-row">';
for (i = 0; i < nodes.length; i++) {
c = nodes[i];
var isOpen = c.path === openPath;
var tag = '<span class="tag hid"> </span>';
if (depth === 1 && c.path === panel.chosenPath) tag = '<span class="tag best">' + (panel.who === AI ? "played" : "hint") + "</span>";
else if (c.score === bestScore && !allTied) tag = '<span class="tag best">' + (depth === 1 ? "tied" : "best") + "</span>";
else if (c.cut) tag = '<span class="tag cut">cut</span>';
h += '<button class="node' + (isOpen ? " open" : "") + (c.cut ? " cut" : "") + (c.path === panel.sel ? " sel" : "") +
'" type="button" data-path="' + c.path + '" aria-label="' +
esc(COORD[c.move] + ", score " + scoreStr(c.score) + ", " + outcomeLong(c.score) +
(c.cut ? ", skipped by alpha-beta pruning" : "") + ". Inspect this branch.") + '">' +
miniSVG(c.board, c.move) +
'<span class="co">' + COORD[c.move] + "</span>" +
'<span class="sc ' + scoreCls(c.score) + '">' + scoreStr(c.score) + "</span>" +
tag + "</button>";
}
h += "</div></div>";
return h;
}
function renderTree() {
var t = $("tree");
if (!panel) { t.innerHTML = '<div class="deadend">nothing searched yet</div>'; return; }
var h = "";
h += rowHTML(panel.root.children, panel.who, panel.open1, 1, null);
var n1 = panel.open1 ? panel.byPath[panel.open1] : null;
if (n1) {
h += rowHTML(n1.children, other(panel.who), panel.open2, 2, n1);
var n2 = panel.open2 ? panel.byPath[panel.open2] : null;
if (n2) h += rowHTML(n2.children, panel.who, null, 3, n2);
}
t.innerHTML = h;
var drawn = 0, pruned = 0, p;
for (p in panel.byPath) { if (p === "") continue; drawn++; if (panel.byPath[p].cut) pruned++; }
$("treeBadge").textContent = "3 plies · " + fmt(drawn) + " boards · " + fmt(pruned) + " pruned";
renderInspect();
}
function renderInspect() {
var el = $("inspect");
if (!panel || !panel.sel || !panel.byPath[panel.sel]) { el.innerHTML = " "; return; }
var nd = panel.byPath[panel.sel];
var chain = nd.path.split("-").map(function (x) { return COORD[+x]; }).join(" → ");
el.innerHTML = "line <b>" + chain + "</b> · score <span class=\"" + scoreCls(nd.score) + "\">" +
scoreStr(nd.score) + "</span> · " + outcomeLong(nd.score) +
(nd.terminal ? " · board finished here" : "") +
(nd.cut ? " · alpha-beta skipped this branch" : "");
}
function renderAll() { renderBoard(); renderStatus(); renderTally(); renderLog(); renderPanel(); }
/* ---------- game flow ---------- */
function checkEnd() {
var wl = winLineOf(st.board);
if (wl) {
st.over = true; st.winner = st.board[wl[0]]; st.winLine = wl;
st.tally[st.winner === YOU ? "you" : "ai"]++; st.games++;
return true;
}
if (emptyCount(st.board) === 0) {
st.over = true; st.winner = 0; st.winLine = null; st.tally.draw++; st.games++;
return true;
}
return false;
}
function applyMove(i, who, score) {
st.board[i] = who; st.lastIdx = i;
st.log.push({ who: who, idx: i, score: (score === undefined ? null : score) });
if (!checkEnd()) st.turn = other(who);
}
function aiTurn() {
if (st.over || st.turn !== AI) return;
st.thinking = true; st.hint = null;
renderBoard(); renderStatus(); renderPanel();
var gen = st.gen;
/* the pre-played opening resolves almost immediately: the search panel is the whole
point of the demo, so it must be full on first paint, not blank for a third of a second */
var wait = st.intro ? 60 : (RM ? 50 : 360);
window.setTimeout(function () {
if (gen !== st.gen || st.over || st.turn !== AI) return; /* game was restarted mid-search */
var res = runSearch(st.board, AI);
panel = makePanel(res);
st.thinking = false; st.intro = false;
applyMove(res.chosen.move, AI, res.chosen.score);
renderAll();
if (!st.over) autoHint(false);
}, wait);
}
function doHint(showPanel) {
if (st.over || st.thinking || st.turn !== YOU) return;
var res = runSearch(st.board, YOU);
st.hint = { move: res.chosen.move, score: res.chosen.score, ties: res.ties };
st.hintShown = true;
if (showPanel) panel = makePanel(res);
renderAll();
}
function autoHint(showPanel) {
if (st.hintShown || st.over || st.turn !== YOU) return;
st.hintShown = true;
var gen = st.gen;
window.setTimeout(function () {
if (gen !== st.gen || st.over || st.turn !== YOU) return;
var res = runSearch(st.board, YOU);
st.hint = { move: res.chosen.move, score: res.chosen.score, ties: res.ties };
if (showPanel || !panel) panel = makePanel(res);
renderAll();
}, 20);
}
function resetState() {
st.gen++; /* invalidates any pending search timer */
st.board = new Int8Array(9); st.over = false; st.winner = 0; st.winLine = null;
st.log = []; st.hint = null; st.hintShown = false; st.thinking = false; st.lastIdx = -1;
st.turn = st.first; st.preplayed = false;
}
function newGame() {
resetState();
st.intro = false;
panel = null;
renderAll();
if (st.first === AI) aiTurn();
else autoHint(true);
}
/* ---------- events ---------- */
function onCell(i) {
if (st.over || st.thinking || st.turn !== YOU || st.board[i] !== 0) return;
applyMove(i, YOU, null);
st.hint = null; st.preplayed = false;
renderAll();
if (!st.over) aiTurn();
}
function bindEvents() {
$("board").addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest(".cell") : null;
if (b) onCell(+b.getAttribute("data-i"));
});
$("board").addEventListener("keydown", function (e) {
var b = e.target.closest ? e.target.closest(".cell") : null;
if (!b) return;
var i = +b.getAttribute("data-i"), r = (i / 3) | 0, c = i % 3;
if (e.key === "ArrowRight") c = Math.min(2, c + 1);
else if (e.key === "ArrowLeft") c = Math.max(0, c - 1);
else if (e.key === "ArrowDown") r = Math.min(2, r + 1);
else if (e.key === "ArrowUp") r = Math.max(0, r - 1);
else return;
e.preventDefault();
cells[r * 3 + c].focus();
});
$("btnNew").addEventListener("click", newGame);
$("btnHint").addEventListener("click", function () { doHint(true); });
$("firstYou").addEventListener("click", function () { setFirst(YOU); });
$("firstAi").addEventListener("click", function () { setFirst(AI); });
$("map").addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("[data-map]") : null;
if (!b || !panel) return;
var p = b.getAttribute("data-map");
panel.open1 = p; panel.sel = p;
panel.open2 = bestChildPath(panel.byPath[p]);
markMapOpen();
renderTree();
});
$("tree").addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("[data-path]") : null;
if (!b || !panel) return;
var p = b.getAttribute("data-path");
var depth = p.split("-").length;
panel.sel = p;
if (depth === 1) { panel.open1 = p; panel.open2 = bestChildPath(panel.byPath[p]); markMapOpen(); }
else if (depth === 2) { panel.open2 = p; }
renderTree();
var again = $("tree").querySelector('[data-path="' + p + '"]');
if (again) again.focus();
});
}
function setFirst(who) {
st.first = who;
$("firstYou").setAttribute("aria-pressed", String(who === YOU));
$("firstAi").setAttribute("aria-pressed", String(who === AI));
$("firstYou").classList.toggle("on", who === YOU);
$("firstAi").classList.toggle("on", who === AI);
newGame();
}
/* the win stroke is measured from the live layout, so redraw it if the board resizes */
window.addEventListener("resize", function () { if (st.winLine) renderWinLine(); });
/* ---------- platform theme ---------- */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* ---------- boot: a corner opening already on the board ---------- */
buildBoard();
bindEvents();
resetState();
st.intro = true; st.preplayed = true;
st.board[0] = YOU; st.lastIdx = 0;
st.log.push({ who: YOU, idx: 0, score: null });
st.turn = AI;
renderAll();
aiTurn();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.