<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://sir-teo.github.io/blogs/feed.xml" rel="self" type="application/atom+xml" /><link href="https://sir-teo.github.io/blogs/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-20T13:11:56-04:00</updated><id>https://sir-teo.github.io/blogs/feed.xml</id><title type="html">Teo’s blog</title><subtitle>Things I build, explained with figures you can play with.</subtitle><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><entry><title type="html">Drawing 6,684 ideas in one dependency graph</title><link href="https://sir-teo.github.io/blogs/2026/10/18/drawing-six-thousand-ideas-in-one-dependency-graph.html" rel="alternate" type="text/html" title="Drawing 6,684 ideas in one dependency graph" /><published>2026-10-18T18:00:00-04:00</published><updated>2026-10-18T18:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/2026/10/18/drawing-six-thousand-ideas-in-one-dependency-graph</id><content type="html" xml:base="https://sir-teo.github.io/blogs/2026/10/18/drawing-six-thousand-ideas-in-one-dependency-graph.html"><![CDATA[<p><strong>Ideas, visualized</strong> started as an interactive companion to every numbered result in Walter Rudin’s three analysis books (<code class="language-plaintext highlighter-rouge">1,062</code> theorems, lemmas, corollaries, propositions, and definitions) and expanded into a single directed acyclic graph of <code class="language-plaintext highlighter-rouge">6,684</code> ideas (<code class="language-plaintext highlighter-rouge">6,621</code> interactive canvas simulations plus <code class="language-plaintext highlighter-rouge">6,890</code> planned syllabus nodes) across <code class="language-plaintext highlighter-rouge">85</code> collections and <code class="language-plaintext highlighter-rouge">13</code> fields. Every interactive figure below runs the atlas’s own zero-build canvas runtime (<code class="language-plaintext highlighter-rouge">assets/js/viz/core.js</code>, <code class="language-plaintext highlighter-rouge">math.js</code>, and its 17 domain kits) directly on the compiled graph.</p>

<h2 id="zero-build-in-the-browser-one-pass-on-disk">Zero build in the browser, one pass on disk</h2>

<figure class="fig wide" id="tv-arch">
<svg class="arch" viewBox="0 0 760 254" role="img" aria-label="Architecture: content/&lt;col&gt;/chNN.js modules, syllabus.js inventories, and bridges/*.js walks compile through scripts/build.mjs into data/index.json, 85 collection shards, and OKLCH CSS variables; in the browser, app.js and viz/core.js + math.js + 17 kits mount live canvas simulations; scripts/check.mjs and 161 verification suites audit every item in headless Chromium and Node.">
<defs>
<marker id="tv-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head-strong" d="M0,1 L9,5 L0,9 z" /></marker>
</defs>
<rect class="box" x="12" y="12" width="236" height="54" rx="6" />
<text class="t" x="130" y="30">content/&lt;col&gt;/chNN.js</text>
<text class="st" x="130" y="48">85 collections · 6,684 items · viz(v)</text>
<rect class="box" x="264" y="12" width="232" height="54" rx="6" />
<text class="t" x="380" y="30">content/&lt;col&gt;/syllabus.js</text>
<text class="st" x="380" y="48">6,890 planned · 450 landmark papers</text>
<rect class="box" x="512" y="12" width="236" height="54" rx="6" />
<text class="t" x="630" y="30">content/bridges/*.js</text>
<text class="st" x="630" y="48">8 cross-field walks · 104 steps</text>
<path class="edge-strong" d="M130 68 V96" marker-end="url(#tv-arrow)" />
<path class="edge-strong" d="M380 68 V96" marker-end="url(#tv-arrow)" />
<path class="edge-strong" d="M630 68 V96" marker-end="url(#tv-arrow)" />
<rect class="box em" x="12" y="98" width="736" height="56" rx="6" />
<text class="t" x="380" y="116">scripts/build.mjs (DAG validation · transitive closure · 8-sweep barycentre layout · OKLCH palette)</text>
<text class="st" x="380" y="135">data/index.json · data/planned.json · data/col/&lt;id&gt;.json (85 shards) · data/atlas.css</text>
<path class="edge-strong" d="M210 156 V184" marker-end="url(#tv-arrow)" />
<text class="el" x="268" y="173">dynamic import()</text>
<path class="edge-strong" d="M566 156 V184" marker-end="url(#tv-arrow)" />
<text class="el" x="626" y="173">Playwright + Node</text>
<rect class="box" x="12" y="186" width="396" height="56" rx="6" />
<text class="t" x="210" y="204">assets/js/viz/core.js + math.js + 17 domain kits</text>
<text class="st" x="210" y="223">2D/3D stages · handles · RK45 / FFT / SVD / quantum · KaTeX</text>
<rect class="box" x="424" y="186" width="324" height="56" rx="6" />
<text class="t" x="586" y="204">scripts/check.mjs + 161 test suites</text>
<text class="st" x="586" y="223">headless Chromium · 6,906 numerical assertions</text>
</svg>
<figcaption>Each chapter is a plain ES module under <code>content/&lt;col&gt;/</code>. <code>scripts/build.mjs</code> resolves the global dependency DAG and writes static JSON shards; the browser imports chapter modules directly with no bundler.</figcaption>
</figure>

<p>Adding a collection requires only a folder with a <code class="language-plaintext highlighter-rouge">collection.js</code> descriptor and chapter modules exporting <code class="language-plaintext highlighter-rouge">statement</code>, <code class="language-plaintext highlighter-rouge">idea</code>, <code class="language-plaintext highlighter-rouge">deps</code>, and <code class="language-plaintext highlighter-rouge">viz(v)</code>. On build, <code class="language-plaintext highlighter-rouge">scripts/build.mjs</code> verifies that all <code class="language-plaintext highlighter-rouge">11,541</code> dependency edges form a cycle-free DAG, computes each item’s transitive ancestor and descendant closures, packs chapters into <code class="language-plaintext highlighter-rouge">88</code> shelf rows using eight forward-and-backward barycentre sweeps, and derives light and dark collection hues in perceptual OKLCH space.</p>

<h2 id="6684-ideas-in-one-dependency-graph">6,684 ideas in one dependency graph</h2>

<figure class="fig wide" id="tv-map" style="--reserve: 740px" data-src="/blogs/assets/data/theorems-visualizations-demo.json">
<figcaption>All 6,684 written ideas and 11,541 dependency edges across 85 collections and 13 fields. <span class="only-hover">Click or drag</span><span class="only-touch">Tap or drag</span> on the atlas to pan and zoom, select any node to light up its full transitive prerequisite and descendant cones, switch to the 13-field chord matrix, or scrub the 77-step longest reasoning chain.</figcaption>
</figure>

<p>Because <code class="language-plaintext highlighter-rouge">deps</code> cross chapter, book, and discipline boundaries, a single click traces how foundational definitions propagate across the entire graph: <a class="act" href="#tv-map" data-view="atlas" data-node="pma-1.5">an order on a set (<code class="language-plaintext highlighter-rouge">pma-1.5</code>)</a> sits at depth <code class="language-plaintext highlighter-rouge">0</code> with <code class="language-plaintext highlighter-rouge">3,286</code> downstream descendants; <a class="act" href="#tv-map" data-view="atlas" data-node="pma-4.19">uniform continuity on compact sets (<code class="language-plaintext highlighter-rouge">pma-4.19</code>)</a> bridges metric topology into integration and Fourier analysis; <a class="act" href="#tv-map" data-view="atlas" data-node="ladr-7.29">the real spectral theorem (<code class="language-plaintext highlighter-rouge">ladr-7.29</code>)</a> feeds <code class="language-plaintext highlighter-rouge">664</code> results across mechanics, quantum theory, and statistics; <a class="act" href="#tv-map" data-view="flows" data-field="math">the 1,989 cross-field links</a> carry mathematical structure into physics (<code class="language-plaintext highlighter-rouge">446</code>), engineering (<code class="language-plaintext highlighter-rouge">116</code>), statistics (<code class="language-plaintext highlighter-rouge">110</code>), computer science (<code class="language-plaintext highlighter-rouge">104</code>), and economics (<code class="language-plaintext highlighter-rouge">93</code>); and <a class="act" href="#tv-map" data-view="chain" data-step="76">the 77-step longest chain</a> runs unbroken from <code class="language-plaintext highlighter-rouge">pma-1.5</code> through complex and functional analysis, wave mechanics, and quantum gates to Grover’s search algorithm (<code class="language-plaintext highlighter-rouge">cplx-13.9</code>).</p>

<h2 id="thirteen-fields-one-canvas-runtime">Thirteen fields, one canvas runtime</h2>

<figure class="fig wide" id="tv-workbench" style="--reserve: 680px" data-src="/blogs/assets/data/theorems-visualizations-demo.json">
<figcaption>Thirteen live simulations—one from each field—mounted directly by <code>assets/js/viz/core.js</code>. Switch fields, drag handles and sliders on the canvas, flip hypotheses, or open the <code>viz(v)</code> source drawer to see the declarative stage calls behind each picture.</figcaption>
</figure>

<p>Every simulation in the repository is written against the same <code class="language-plaintext highlighter-rouge">3,531</code>-line runtime (<code class="language-plaintext highlighter-rouge">core.js</code> + <code class="language-plaintext highlighter-rouge">math.js</code>) and <code class="language-plaintext highlighter-rouge">17</code> domain kits. Instead of static diagrams, each <code class="language-plaintext highlighter-rouge">viz(v)</code> exposes the quantity the proof or mechanism controls: you can <a class="act" href="#tv-workbench" data-item="pma-4.19" data-seg="2">break compactness on (0, 1] in <code class="language-plaintext highlighter-rouge">pma-4.19</code></a> and watch the greatest admissible δ(p) collapse to zero near the missing endpoint, <a class="act" href="#tv-workbench" data-item="mech-8.22">drag the launch velocity in <code class="language-plaintext highlighter-rouge">mech-8.22</code></a> as Keplerian velocities trace an exact circle in hodograph space, <a class="act" href="#tv-workbench" data-item="geog-4.4" data-seg="0">drag Greenland to the equator in <code class="language-plaintext highlighter-rouge">geog-4.4</code></a> to shed its 14× sec²φ Mercator inflation, <a class="act" href="#tv-workbench" data-item="imaging-3.3">reconstruct a CT phantom from its Radon sinogram in <code class="language-plaintext highlighter-rouge">imaging-3.3</code></a>, <a class="act" href="#tv-workbench" data-item="soc-1.2">tip an integrated city into segregation in <code class="language-plaintext highlighter-rouge">soc-1.2</code></a>, or <a class="act" href="#tv-workbench" data-item="tech-2.9" data-toggle="0">snap one tooth off the Antikythera <code class="language-plaintext highlighter-rouge">b1</code> gear in <code class="language-plaintext highlighter-rouge">tech-2.9</code></a> and watch the Metonic pointer drift.</p>

<h2 id="one-mathematical-spine-many-disguises">One mathematical spine, many disguises</h2>

<figure class="fig wide" id="tv-bridges" style="--reserve: 690px" data-src="/blogs/assets/data/theorems-visualizations-demo.json">
<figcaption>Eight cross-field bridges (104 steps) and 450 landmark papers (350 BCE – 2020 CE) across the 13 field lanes. Select a bridge and click any step along its track to run that step's interactive simulation live below the swimlane.</figcaption>
</figure>

<p>The same mathematical skeleton repeatedly reappears under different physical or social names. Following <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="spectral" data-step="2">the spectral theorem bridge</a> walks from self-adjoint matrices (<code class="language-plaintext highlighter-rouge">ladr-7.29</code>) and SVD (<code class="language-plaintext highlighter-rouge">ladr-7.70</code>) to <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="spectral" data-step="7">coupled normal modes (<code class="language-plaintext highlighter-rouge">mech-11.4</code>)</a> and <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="spectral" data-step="8">standing waves on a string (<code class="language-plaintext highlighter-rouge">music-2.3</code>)</a>; <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="entropy" data-step="2">the entropy bridge</a> links Boltzmann microstates (<code class="language-plaintext highlighter-rouge">thermo-5.6</code>) to <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="entropy" data-step="5">Shannon information (<code class="language-plaintext highlighter-rouge">info-1.2</code>)</a> and <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="entropy" data-step="8">Landauer’s k<sub>B</sub>T ln 2 erasure bound (<code class="language-plaintext highlighter-rouge">thermo-5.17</code>)</a>; <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="fixed-points" data-step="1">the fixed-point bridge</a> connects Banach contraction (<code class="language-plaintext highlighter-rouge">pma-9.23</code>) to <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="fixed-points" data-step="4">Nash equilibria (<code class="language-plaintext highlighter-rouge">game-2.3</code>)</a> and <a class="act" href="#tv-bridges" data-mode="bridges" data-bridge="fixed-points" data-step="7">PageRank (<code class="language-plaintext highlighter-rouge">stoch-4.15</code>)</a>; and switching to <a class="act" href="#tv-bridges" data-mode="papers">the 450 landmark papers</a> plots each primary source on the field timeline alongside the graph nodes it introduced.</p>

<h2 id="auditing-6621-canvases-without-eyes">Auditing 6,621 canvases without eyes</h2>

<figure class="fig wide" id="tv-audit" style="--reserve: 580px" data-src="/blogs/assets/data/theorems-visualizations-demo.json">
<figcaption>Coverage across all 85 collections and live numerical audits from <code>scripts/check.mjs</code> and the 161 <code>tests/verification-*.test.mjs</code> suites (1,058 tests, 6,906 assertions).</figcaption>
</figure>

<p>Keeping <code class="language-plaintext highlighter-rouge">6,621</code> interactive canvases honest requires automated checks at two levels. In headless Chromium, <code class="language-plaintext highlighter-rouge">scripts/check.mjs --modes</code> mounts every item in <code class="language-plaintext highlighter-rouge">tools/harness.html</code>, clicks through every segmented control and toggle, verifies that every visible canvas paints non-transparent pixels, and flags raw-string TeX backslash escapes or missing live readouts. In Node, <code class="language-plaintext highlighter-rouge">161</code> primary-source verification suites test the underlying solvers against independent quadrature and published datasets—catching <a class="act" href="#tv-audit" data-case="radon">discarded negative-k<sub>x</sub> imaginary residuals and sub-Nyquist detector aliasing in Fourier CT (<code class="language-plaintext highlighter-rouge">imaging-3.4</code>)</a>, separating <a class="act" href="#tv-audit" data-case="popgen">exact discrete log-odds selection ln(1 + s) from the weak-selection s approximation (<code class="language-plaintext highlighter-rouge">popgen-4.2</code>)</a>, and auditing <a class="act" href="#tv-audit" data-case="harness">every control mode and primitive family across the 85 collections</a>.</p>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><summary type="html"><![CDATA[How theorems-visualizations wires 6,684 theorems, laws, models, and mechanisms across 85 collections and 13 fields into a single directed acyclic graph—running 6,621 interactive canvas simulations on one zero-build runtime and verifying every frame headlessly.]]></summary></entry><entry><title type="html">Rendering twenty-two hours of math from code</title><link href="https://sir-teo.github.io/blogs/2026/10/18/rendering-twenty-two-hours-of-math-from-code.html" rel="alternate" type="text/html" title="Rendering twenty-two hours of math from code" /><published>2026-10-18T08:00:00-04:00</published><updated>2026-10-18T08:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/2026/10/18/rendering-twenty-two-hours-of-math-from-code</id><content type="html" xml:base="https://sir-teo.github.io/blogs/2026/10/18/rendering-twenty-two-hours-of-math-from-code.html"><![CDATA[<p><strong>explainer-videos</strong> builds feature-length mathematics, physics, machine learning, and markets films entirely from Python with <a href="https://www.manim.community/">Manim Community</a> and local neural text-to-speech. No video files or timeline projects live in git: every frame, equation, simulation, and subtitle track compiles from source.</p>

<h2 id="a-film-is-a-package">A film is a package</h2>

<figure class="fig" id="ev-arch">
<svg class="arch" viewBox="0 0 680 272" role="img" aria-label="Architecture: compute.py writes simulation checkpoints, sNN_*.py defines VoiceoverScene classes with semantic colors, and tts.py synthesizes cached WAVs with word timestamps through a 513-rule IPA lexicon; manim render runs scenes in parallel with isolated TeX caches; tools/build.py normalizes audio to ALAC, runs two-pass loudnorm, embeds subtitles and chapters, and hands the master to dead_air.py and contact_sheet.py.">
<defs>
<marker id="ev-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head-strong" d="M0,1 L9,5 L0,9 z" /></marker>
</defs>
<rect class="box" x="16" y="16" width="202" height="54" rx="6" />
<text class="t" x="117" y="34">compute.py</text>
<text class="st" x="117" y="51">NumPy · SciPy · mpmath → .npz</text>
<rect class="box" x="239" y="16" width="202" height="54" rx="6" />
<text class="t" x="340" y="34">sNN_*.py + style.py</text>
<text class="st" x="340" y="51">VoiceoverScene · 222 C.* colors</text>
<rect class="box" x="462" y="16" width="202" height="54" rx="6" />
<text class="t" x="563" y="34">tts.py + narration_zh.json</text>
<text class="st" x="563" y="51">Kokoro · Qwen3-TTS · 513 IPA rules</text>
<path class="edge-strong" d="M117 72 V100" marker-end="url(#ev-arrow)" />
<path class="edge-strong" d="M340 72 V100" marker-end="url(#ev-arrow)" />
<path class="edge-strong" d="M563 72 V100" marker-end="url(#ev-arrow)" />
<rect class="box em" x="16" y="102" width="648" height="56" rx="6" />
<text class="t" x="340" y="120">manim render (parallel scene workers, longest scene first)</text>
<text class="st" x="340" y="138">isolated TeX dir per worker · 400-SVG LRU cache · up to 2,000 cached segments per scene</text>
<path class="edge-strong" d="M340 160 V188" marker-end="url(#ev-arrow)" />
<text class="el" x="350" y="178" style="text-anchor:start">.mp4 + .wav + .narration.json + .srt</text>
<rect class="box" x="16" y="190" width="396" height="56" rx="6" />
<text class="t" x="214" y="208">tools/build.py</text>
<text class="st" x="214" y="226">soundtrack check · ALAC pad · 2-pass EBU R128 loudnorm · chapters</text>
<rect class="box" x="434" y="190" width="230" height="56" rx="6" />
<text class="t" x="549" y="208">dead_air.py · contact_sheet.py</text>
<text class="st" x="549" y="226">320×180 pixel scan vs #0E1117</text>
<path class="edge-strong" d="M414 218 H432" marker-end="url(#ev-arrow)" />
</svg>
<figcaption>Each video lives in <code>videos/&lt;series&gt;/&lt;name&gt;/</code> and compiles from numerical checkpoints, scene scripts, and cached neural speech into a chaptered master.</figcaption>
</figure>

<p>Rendering a sixty-minute film in parallel exposes two bottlenecks in stock Manim: concurrent scene processes race on shared LaTeX files, and frame-by-frame numeric readouts fill <code class="language-plaintext highlighter-rouge">SVG_HASH_TO_MOB_MAP</code> past 6 GB of RAM. <code class="language-plaintext highlighter-rouge">tools/build.py</code> gives every scene worker its own TeX directory, orders jobs longest-first so a short scene never waits behind a long tail, and replaces Manim’s unbounded SVG dictionary with a 400-entry LRU cache.</p>

<h2 id="twenty-four-films-frame-by-frame">Twenty-four films, frame by frame</h2>

<figure class="fig wide" id="ev-atlas" style="--reserve: 760px" data-src="/blogs/assets/data/explainer-videos-demo.json">
<figcaption>All 24 completed films in the repository (16.4 hours in English, plus 5.6 hours of Chinese masters for <em>Analysis, Built</em>). Every horizontal ribbon is the film's vertical color profile over time; <span class="only-hover">click or drag</span><span class="only-touch">tap or drag</span> across any ribbon to inspect its rendered frames, subtitle track, and active-pixel density.</figcaption>
</figure>

<p>Each barcode strip compresses a full render into 300 vertical slices against the <code class="language-plaintext highlighter-rouge">#0E1117</code> background, making a film’s structure visible before pressing play. You can spot <a class="act" href="#ev-atlas" data-film="navier_stokes" data-sample="4">the Kármán vortex street</a> in <em>Navier–Stokes</em> where blue velocity gives way to orange vorticity, <a class="act" href="#ev-atlas" data-film="riemann" data-sample="1">the rainbow phase portrait of ζ(s)</a> in <em>The Riemann Hypothesis</em>, <a class="act" href="#ev-atlas" data-film="llm" data-sample="4">the orange attention matrices and purple MLP blocks</a> in <em>How LLMs Work</em>, and <a class="act" href="#ev-atlas" data-film="analysis7" data-sample="0">the domain-colored complex discs</a> opening <em>Analysis, Built</em> Part 7.</p>

<h2 id="the-voice-is-the-clock">The voice is the clock</h2>

<figure class="fig wide" id="ev-score" style="--reserve: 670px" data-src="/blogs/assets/data/explainer-videos-demo.json">
<figcaption><code>Contraction</code> (<code>videos/analysis/analysis4/s05_contraction.py</code>) rendered in English (144.8 s) and Chinese (151.3 s) from the same scene class. The 57 <code>self.play()</code> segments run for the exact same 58.6 s in both languages; the 25 <code>vo.wait_until()</code> holds stretch or contract around each language's word timestamps.</figcaption>
</figure>

<p>Scenes never hard-code pause durations. Instead, narration strings carry inline <code class="language-plaintext highlighter-rouge">&lt;bookmark mark='...'/&gt;</code> tags that <code class="language-plaintext highlighter-rouge">VoiceoverScene</code> strips before synthesis and maps to the exact start timestamp of the following word:</p>

<div class="language-py highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">with</span> <span class="bp">self</span><span class="p">.</span><span class="n">voiceover</span><span class="p">(</span>
    <span class="s">"&lt;bookmark mark='a'/&gt; To solve cosine of x equals x, just iterate ... "</span>
    <span class="s">"&lt;bookmark mark='b'/&gt; On the graph, bounce between the curve and the diagonal ..."</span>
<span class="p">)</span> <span class="k">as</span> <span class="n">vo</span><span class="p">:</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">play</span><span class="p">(</span><span class="n">FadeIn</span><span class="p">(</span><span class="n">head</span><span class="p">[</span><span class="mi">0</span><span class="p">]),</span> <span class="n">Create</span><span class="p">(</span><span class="n">ax</span><span class="p">),</span> <span class="n">Create</span><span class="p">(</span><span class="n">dg</span><span class="p">),</span> <span class="n">FadeIn</span><span class="p">(</span><span class="n">dl</span><span class="p">))</span>
    <span class="n">vo</span><span class="p">.</span><span class="n">wait_until</span><span class="p">(</span><span class="s">"a"</span><span class="p">)</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">play</span><span class="p">(</span><span class="n">Create</span><span class="p">(</span><span class="n">cg</span><span class="p">),</span> <span class="n">FadeIn</span><span class="p">(</span><span class="n">cl</span><span class="p">))</span>
    <span class="n">vo</span><span class="p">.</span><span class="n">wait_until</span><span class="p">(</span><span class="s">"b"</span><span class="p">)</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">play</span><span class="p">(</span><span class="n">Create</span><span class="p">(</span><span class="n">web</span><span class="p">),</span> <span class="n">run_time</span><span class="o">=</span><span class="mf">4.0</span><span class="p">,</span> <span class="n">rate_func</span><span class="o">=</span><span class="n">linear</span><span class="p">)</span>
</code></pre></div></div>

<p>Because <code class="language-plaintext highlighter-rouge">self.play()</code> durations are fixed while <code class="language-plaintext highlighter-rouge">vo.wait_until()</code> absorbs the difference in speech cadence, translating a film requires zero timeline editing. Switching from <a class="act" href="#ev-score" data-beat="1" data-lang="en">the English cobweb</a> to <a class="act" href="#ev-score" data-beat="1" data-lang="zh">the Chinese render</a>, jumping to <a class="act" href="#ev-score" data-beat="2" data-lang="en">the shrinking Cauchy discs in Rudin 9.23</a>, or zooming ×127 into <a class="act" href="#ev-score" data-beat="3" data-lang="en">the √2 ∉ ℚ hole</a> runs the exact same 82 Manim segments in lockstep with each voice.</p>

<h2 id="one-color-per-concept-one-ipa-rule-per-term">One color per concept, one IPA rule per term</h2>

<figure class="fig wide" id="ev-palette" style="--reserve: 520px" data-src="/blogs/assets/data/explainer-videos-demo.json">
<figcaption>The 222 semantic color tokens in <code>explainer/style.py</code> (referenced 11,085 times across 705 scene files) and the 513 regex-to-IPA pronunciation rules in <code>explainer/tts.py</code> (firing 2,808 times across the narration corpus).</figcaption>
</figure>

<p>Across all 705 scene files, mobjects never pass raw hex strings; they import semantic roles from <code class="language-plaintext highlighter-rouge">explainer/style.py</code>—<a class="act" href="#ev-palette" data-mode="palette" data-group="Analysis, Built">tolerance bands in gold (<code class="language-plaintext highlighter-rouge">C.EPS</code>) and holes in coral (<code class="language-plaintext highlighter-rouge">C.HOLE</code>)</a> across <em>Analysis, Built</em>, or <a class="act" href="#ev-palette" data-mode="palette" data-group="Language Models &amp; RL">queries, keys, values, and attention</a> across the ML films. On the audio side, <a class="act" href="#ev-palette" data-mode="lexicon" data-rule="Poisson">a 513-rule pronunciation lexicon</a> injects inline IPA into Kokoro’s G2P front-end so <code class="language-plaintext highlighter-rouge">Poisson</code> is never read as <em>“poison”</em>, <a class="act" href="#ev-palette" data-mode="lexicon" data-rule="dy">the differential <code class="language-plaintext highlighter-rouge">dy</code></a> never becomes <em>“die”</em>, and <a class="act" href="#ev-palette" data-mode="lexicon" data-rule="F = m a"><code class="language-plaintext highlighter-rouge">F = m a</code></a> stays two letters instead of <em>“Emma”</em>.</p>

<h2 id="hunting-dead-air">Hunting dead air</h2>

<figure class="fig wide" id="ev-qa" style="--reserve: 580px" data-src="/blogs/assets/data/explainer-videos-demo.json">
<figcaption><code>tools/dead_air.py</code> downscales every frame to <code>320×180</code> and flags runs lasting at least 1.5 s where fewer than 0.30% of pixels differ from <code>#0E1117</code>—catching scenes where a <code>vo.wait_until()</code> hold sat before the drawing call instead of after it.</figcaption>
</figure>

<p>Watching twenty hours of 4K renders at real-time speed to check whether an equation appeared on time does not scale. <code class="language-plaintext highlighter-rouge">tools/dead_air.py</code> streams a render through <code class="language-plaintext highlighter-rouge">ffmpeg</code> at <code class="language-plaintext highlighter-rouge">320×180</code> and measures the fraction of pixels brighter than the <code class="language-plaintext highlighter-rouge">#0E1117</code> background, flagging any stretch of 1.5 seconds or longer below <code class="language-plaintext highlighter-rouge">0.30%</code> (a lone header or bare axes). Running it across the series caught places where a <code class="language-plaintext highlighter-rouge">vo.wait_until()</code> call sat <em>before</em> <code class="language-plaintext highlighter-rouge">self.play()</code>—leaving <a class="act" href="#ev-qa" data-case="balance">12.4 seconds of bare title before the router load replay</a>, <a class="act" href="#ev-qa" data-case="width">14.5 seconds before the μP scaling card</a>, or <a class="act" href="#ev-qa" data-case="steering">8.0 seconds before the chain-of-thought faithfulness schematic</a>—and cut sparse stretches across the eleven series films by 91%.</p>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><summary type="html"><![CDATA[How explainer-videos compiles twenty-four feature-length math, physics, ML, and markets films from Python—synchronizing Manim geometry to word-level TTS bookmarks in English and Chinese, enforcing a 222-token semantic color palette, and scanning every frame for dead air.]]></summary></entry><entry><title type="html">Putting a trading desk behind a dashboard</title><link href="https://sir-teo.github.io/blogs/2026/10/10/putting-a-trading-desk-behind-a-dashboard.html" rel="alternate" type="text/html" title="Putting a trading desk behind a dashboard" /><published>2026-10-10T08:00:00-04:00</published><updated>2026-10-10T08:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/2026/10/10/putting-a-trading-desk-behind-a-dashboard</id><content type="html" xml:base="https://sir-teo.github.io/blogs/2026/10/10/putting-a-trading-desk-behind-a-dashboard.html"><![CDATA[<p><strong>FinOS-AgentLab</strong> is a self-hosted financial research workspace that pairs a page-aware copilot on every screen with five specialist sub-agents and a multi-agent <code class="language-plaintext highlighter-rouge">TradingAgents</code> desk behind it. Every interactive figure below either runs the backend’s own Python routing, prompt-assembly, and scheduler code offline, or replays the 22 desk transcripts and scripted evaluation passes recorded in the repository.</p>

<h2 id="four-processes">Four processes</h2>

<figure class="fig" id="fa-arch">
<svg class="arch" viewBox="0 0 680 318" role="img" aria-label="Architecture: a Next.js app talks to a FastAPI backend over REST, server-sent events and a WebSocket; the backend starts a TradingAgents subprocess per desk run and keeps one warm quant worker, both speaking JSON lines; SQLite and provider caches sit underneath.">
<defs>
<marker id="fa-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head-strong" d="M0,1 L9,5 L0,9 z" /></marker>
</defs>
<rect class="box" x="24" y="24" width="632" height="42" rx="6" />
<text class="t" x="340" y="38">Next.js app</text>
<text class="st" x="340" y="55">agent chat, home, dashboards, markets, stock pages, portfolio, watchlists, world, data</text>
<path class="edge-strong" data-flow="both" d="M340 68 V98" marker-start="url(#fa-arrow)" marker-end="url(#fa-arrow)" />
<text class="el" x="430" y="87">REST · SSE · WebSocket</text>
<rect class="box em" x="24" y="100" width="632" height="60" rx="6" />
<text class="t" x="340" y="116">FastAPI backend</text>
<text class="st" x="340" y="134">543 routes · 454 service modules · one scheduler</text>
<text class="st" x="340" y="150">copilot: 138 tools, 21 of them core · 5 specialists · 150 shipped commands</text>
<path class="edge-strong" data-flow="up" d="M176 162 V190" marker-start="url(#fa-arrow)" />
<text class="el" x="252" y="181" style="font-size:9.5px">JSONL, one event per line</text>
<path class="edge-strong" data-flow="both" d="M504 162 V190" marker-start="url(#fa-arrow)" marker-end="url(#fa-arrow)" />
<text class="el" x="582" y="181" style="font-size:9.5px">JSONL, request and reply</text>
<rect class="box" x="24" y="192" width="304" height="58" rx="6" />
<text class="t" x="176" y="208">agents-runtime</text>
<text class="st" x="176" y="225">TradingAgents 0.6.0 on LangGraph, its own venv</text>
<text class="st" x="176" y="240">one subprocess per run, at most five at once</text>
<rect class="box" x="352" y="192" width="304" height="58" rx="6" />
<text class="t" x="504" y="208">quant-runtime</text>
<text class="st" x="504" y="225">vectorbt and numba, its own venv</text>
<text class="st" x="504" y="240">one warm worker, respawned when it stalls</text>
<rect class="box" x="24" y="274" width="632" height="38" rx="6" />
<text class="t" x="340" y="287">On disk</text>
<text class="st" x="340" y="303">SQLite: dashboards, watchlists, runs, chats, specialists, settings · provider caches · the desk’s memory log</text>
</svg>
<figcaption>The desk and the quant engine run in their own virtual environments and talk to the backend one JSON line at a time.</figcaption>
</figure>

<p>Splitting a self-hosted workspace across four processes and three Python virtual environments was a truce with dependency management. <code class="language-plaintext highlighter-rouge">TradingAgents</code> pulls in the LangChain and LangGraph stack, while the quantitative backtesting engine relies on <code class="language-plaintext highlighter-rouge">vectorbt</code> and <code class="language-plaintext highlighter-rouge">numba</code>—and both pin transitive packages that clash directly with the FastAPI backend’s own dependencies.</p>

<p>Rather than forcing three incompatible dependency trees into a single environment, the backend isolates both runtimes behind newline-delimited JSON (<code class="language-plaintext highlighter-rouge">JSONL</code>) pipes over standard input and output. Each desk run spawns a fresh <code class="language-plaintext highlighter-rouge">agents-runtime</code> subprocess and relays its <code class="language-plaintext highlighter-rouge">stdout</code> lines to the browser as server-sent events; because each LangGraph process claims a few hundred megabytes of memory, a semaphore caps concurrent runs at five and queues the rest. The <code class="language-plaintext highlighter-rouge">quant-runtime</code> takes the opposite lifecycle: importing <code class="language-plaintext highlighter-rouge">numba</code> and <code class="language-plaintext highlighter-rouge">vectorbt</code> is slow, so the backend starts one worker at boot, keeps it warm across requests, and respawns it automatically if a calculation stalls.</p>

<h2 id="the-desk">The desk</h2>

<figure class="fig wide" id="fa-firm" style="--reserve: 900px" data-src="/blogs/assets/data/finos-agentlab-demo.json">
<figcaption>TradingAgents 0.6.0's graph as the runner builds it, and the 22 desk runs recorded in the project's static export, each event a block sized by its bytes.</figcaption>
</figure>

<p>Convening the trading desk used to be a tool the chat copilot could invoke on its own, which meant a casual question could accidentally launch a multi-minute, twelve-node LangGraph debate. Now a desk run is always deliberate: typing <code class="language-plaintext highlighter-rouge">@TradingAgents NVDA</code> in chat starts the graph directly and streams every analyst report, bull-versus-bear rebuttal, and risk-committee turn into the thread without a model middleman deciding whether to.</p>

<p>Ten of the 22 recorded runs reached a final portfolio verdict (3 <code class="language-plaintext highlighter-rouge">BUY</code>, 5 <code class="language-plaintext highlighter-rouge">SELL</code>, 2 <code class="language-plaintext highlighter-rouge">HOLD</code>), and the runner’s <code class="language-plaintext highlighter-rouge">parse_action</code> extracts the exact same call from all ten. Replaying those transcripts surfaced two bugs that only appear when real model routers and LangGraph state dictionaries meet. In <a class="act" href="#fa-firm" data-run="e35b83">one NVDA run</a> and <a class="act" href="#fa-firm" data-run="a730e0">one MU run</a>, a free upstream model router intercepted the Bull Researcher’s turn and returned a bare content-safety classifier label (<code class="language-plaintext highlighter-rouge">safety: safe</code>) instead of a bull case; with nobody arguing for the stock, both desks marched straight to <code class="language-plaintext highlighter-rouge">SELL</code>. Since September, the runner checks every turn against a classifier pattern and redraws the reply up to three times before any downstream agent reads it:</p>

<div class="language-py highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">_CLASSIFIER_REPLY</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span>
    <span class="sa">r</span><span class="s">"^\s*(?:(?:user|response|prompt|agent)\s+)?safety\s*:\s*(?:un)?safe\b"</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="n">IGNORECASE</span>
<span class="p">)</span>
</code></pre></div></div>

<p>A quieter bug hid in the risk committee at the end of the graph. Every finished run in the snapshot streamed only the <code class="language-plaintext highlighter-rouge">Neutral</code> risk analyst’s turn. All three risk voices had actually run, but the runner looked up <code class="language-plaintext highlighter-rouge">risky</code> and <code class="language-plaintext highlighter-rouge">safe</code> in the state dictionary where <code class="language-plaintext highlighter-rouge">TradingAgents 0.6.0</code> writes <code class="language-plaintext highlighter-rouge">aggressive</code> and <code class="language-plaintext highlighter-rouge">conservative</code>—silently dropping two of the three risk perspectives until a contract test pinned the state keys.</p>

<h2 id="what-a-turn-carries">What a turn carries</h2>

<figure class="fig wide" id="fa-pay" style="--reserve: 505px" data-src="/blogs/assets/data/finos-agentlab-demo.json">
<figcaption>Prompt and tool schemas routed for one question on each of the app's pages, each tool a block, measured with the backend's <code>tools_for_request</code> against the ceilings its tests pin.</figcaption>
</figure>

<p>Because an LLM API call pays for every tool schema on every round, shipping the full registry of 138 tools on every turn would burn tens of thousands of prompt characters before the user asked a question. Instead, <code class="language-plaintext highlighter-rouge">tools_for_request</code> routes a slice of the catalogue per turn: the 21 core tools are always included, the current page adds its own domain group, and keywords in the user’s question trip any matching intent groups.</p>

<p>To keep schema bloat from creeping back in, a test pins a strict character ceiling for a plain question on each route and requires a written note in the test file whenever a ceiling is raised. The <code class="language-plaintext highlighter-rouge">/dashboards</code> page already sits at 28,923 characters against a 28,950 ceiling before any question keywords fire, and asking <a class="act" href="#fa-pay" data-q="1">a question about the book</a> (<code class="language-plaintext highlighter-rouge">"how is my book doing?"</code>) pulls in enough portfolio and risk tools to push almost every page past its baseline cap.</p>

<h2 id="ordered-by-how-often-it-changes">Ordered by how often it changes</h2>

<figure class="fig" id="fa-rungs" style="--reserve: 420px" data-src="/blogs/assets/data/finos-agentlab-demo.json">
<figcaption>Six turns of one conversation as the loop assembles them, measured with the backend's own prompt, command, policy and context builders, with turn sizes from its scripted scenarios.</figcaption>
</figure>

<p>Routing fewer tools shrinks each request; ordering the prompt blocks keeps the provider’s prefix cache warm across turns. On every turn, the loop sends up to five system messages arranged strictly by how often each one changes: the base system prompt (<code class="language-plaintext highlighter-rouge">4,998</code> chars), the listing of 150 slash commands (<code class="language-plaintext highlighter-rouge">15,962</code> chars), and the mode policy (<code class="language-plaintext highlighter-rouge">1,073</code> chars) sit at the very top, followed by the growing conversation history.</p>

<p>The page context block (<code class="language-plaintext highlighter-rouge">687</code> chars) carries a wall-clock timestamp that changes every minute, so it is placed last, immediately before the newest user question. <a class="act" href="#fa-rungs" data-order="old">It used to sit before the history</a>—which meant that every time the clock ticked over a minute, that single changing line invalidated the provider’s prefix cache for the entire conversation history behind it.</p>

<h2 id="building-an-agent">Building an agent</h2>

<figure class="fig wide" id="fa-spec" style="--reserve: 450px" data-src="/blogs/assets/data/finos-agentlab-demo.json">
<figcaption>The copilot's 138 tools, one cell each, and what the loop's <code>plan_tools</code> hands the parent turn and each shipped specialist for the same question.</figcaption>
</figure>

<p>When a question needs several rounds of data gathering, the copilot can delegate a sub-question to a specialist: a smaller child loop with its own instructions and a narrower slice of the tool catalogue, which runs the lookups in isolation and returns one synthesized paragraph. Five specialists ship with the app (<code class="language-plaintext highlighter-rouge">general</code>, <code class="language-plaintext highlighter-rouge">market</code>, <code class="language-plaintext highlighter-rouge">filings</code>, <code class="language-plaintext highlighter-rouge">web</code>, and <code class="language-plaintext highlighter-rouge">portfolio</code>), and a user can declare up to eight custom specialists in settings—each with a <code class="language-plaintext highlighter-rouge">@name</code> handle, up to 4,000 characters of instructions, a tool allowlist, a model choice, and a cap of eight rounds.</p>

<p>What a custom declaration cannot override is fixed in the loop itself: every child runs in <code class="language-plaintext highlighter-rouge">ask</code> mode with mutating actions disabled, its tool list can only ever narrow the parent’s read-only tools, and a specialist cannot delegate again. Because <a class="act" href="#fa-spec" data-agent="general">the general specialist</a> declares no tool filter of its own, <code class="language-plaintext highlighter-rouge">plan_tools</code> hands it the full read-only slice routed by the question—<code class="language-plaintext highlighter-rouge">research_subagent</code> included—and the loop refuses the call at depth one if it tries to recurse.</p>

<h2 id="one-fire-per-slot">One fire per slot</h2>

<figure class="fig wide" id="fa-jobs" style="--reserve: 680px" data-src="/blogs/assets/data/finos-agentlab-demo.json">
<figcaption>A week of the ten system jobs on the backend's own scheduler, stepped a minute at a time, with one window of it drawn minute by minute.</figcaption>
</figure>

<p>A single scheduler drives the backend’s ten recurring system jobs—market snapshots, nightly syncs, thesis scans, and morning or evening briefs—stepping once a minute. Each job is keyed to a slot (a local calendar date or an hour bucket) and may succeed at most once per slot; during an <a class="act" href="#fa-jobs" data-scenario="outage">upstream outage</a>, a failing pass retries up to three times in that slot, one minute apart, before waiting for the next window.</p>

<p>Because a self-hosted box can reboot or sleep at any hour, a <a class="act" href="#fa-jobs" data-scenario="restart">restart</a> treats any pass left marked <code class="language-plaintext highlighter-rouge">running</code> in SQLite as failed and re-fires it immediately, while skipping any morning brief whose catch-up window has already closed rather than delivering a pre-market summary at noon.</p>

<h2 id="evals-without-a-key">Evals without a key</h2>

<figure class="fig wide" id="fa-evals" style="--reserve: 680px" data-src="/blogs/assets/data/finos-agentlab-demo.json">
<figcaption>The core suite run scripted on this machine and replayed at its recorded pace: the real loop, catalogue and checks against a model that plays a fixed script, with no network.</figcaption>
</figure>

<p>Half of the core evaluation suite (17 of 34 scenarios, 144 checks in all) runs in five seconds without an API key and with outbound network connections blocked, because what it guards is the loop rather than the model. A scripted fake model replays predetermined tool calls and failure modes against the real <code class="language-plaintext highlighter-rouge">CopilotLoop</code>, tool registry, and permission checks: verifying that <a class="act" href="#fa-evals" data-id="permission.attempt_is_refused">an action attempted in ask mode is stopped</a> before touching disk, that <a class="act" href="#fa-evals" data-id="leaked_call.retried_not_shipped">a tool call written as prose is handed back</a> for a retry rather than shown to the user, and that <a class="act" href="#fa-evals" data-id="horizon.early_lookup_survives">the sixteen-lookup turn</a> compacts older tool results out of its context window as it works and still answers using the very first lookup.</p>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><summary type="html"><![CDATA[How FinOS-AgentLab isolates a LangGraph trading desk and a vectorbt quant worker behind FastAPI, routes 138 copilot tools under strict token ceilings, caches prompt prefixes, and tests its agent loop offline.]]></summary></entry><entry><title type="html">Porting San Andreas to a browser tab</title><link href="https://sir-teo.github.io/blogs/2026/10/09/porting-san-andreas-to-a-browser-tab.html" rel="alternate" type="text/html" title="Porting San Andreas to a browser tab" /><published>2026-10-09T14:00:00-04:00</published><updated>2026-10-09T14:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/2026/10/09/porting-san-andreas-to-a-browser-tab</id><content type="html" xml:base="https://sir-teo.github.io/blogs/2026/10/09/porting-san-andreas-to-a-browser-tab.html"><![CDATA[<p><strong>web-gta</strong> recreates <em>Grand Theft Auto: San Andreas</em> inside a browser tab, streaming the original 900 MB world as you drive and executing the original mission scripts. Because the game’s map, models, scripts, and audio belong to Rockstar (“Native assets retain their original ownership,” the project’s notes state), none of that proprietary content appears on this page. Instead, every interactive figure below runs the port’s own TypeScript subsystems live on synthetic inputs built for this post.</p>

<h2 id="what-runs-where">What runs where</h2>

<figure class="fig">
<svg class="arch" viewBox="0 0 680 300" role="img" aria-label="Architecture: on the main thread, a fixed 60 Hz simulation, Rapier physics in WebAssembly, a three.js renderer and the region follower; in workers, the world streamer and the chunk fetcher; underneath, Cache Storage and a service worker.">
<defs>
<marker id="gta-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head-strong" d="M0,1 L9,5 L0,9 z" /></marker>
</defs>
<text class="lbl" x="24" y="13">Main thread</text>
<text class="lbl" x="400" y="13">Workers</text>
<rect class="box" x="24" y="26" width="320" height="48" rx="6" />
<text class="t" x="184" y="42">Simulation, a fixed 60 Hz</text>
<text class="st" x="184" y="59">CJ, traffic, peds, police, the script interpreter</text>
<rect class="box" x="24" y="88" width="152" height="48" rx="6" />
<text class="t" x="100" y="104">Rapier</text>
<text class="st" x="100" y="121">physics, WebAssembly</text>
<rect class="box" x="192" y="88" width="152" height="48" rx="6" />
<text class="t" x="268" y="104">three.js</text>
<text class="st" x="268" y="121">WebGL, at most 30 fps</text>
<rect class="box" x="24" y="150" width="320" height="48" rx="6" />
<text class="t" x="184" y="166">Region follower</text>
<text class="st" x="184" y="183">asks for the window ahead, holds play if it is late</text>
<rect class="box" x="400" y="26" width="256" height="48" rx="6" />
<text class="t" x="528" y="42">World streamer</text>
<text class="st" x="528" y="59">placements, models, collision per window</text>
<rect class="box" x="400" y="88" width="256" height="48" rx="6" />
<text class="t" x="528" y="104">Chunk fetcher</text>
<text class="st" x="528" y="121">fetch, check, keep, inflate</text>
<path class="edge-strong" d="M346 174 H372 V50 H398" marker-end="url(#gta-arrow)" />
<text class="el" x="372" y="190">request</text>
<path class="edge" d="M528 76 V86" marker-end="url(#gta-arrow)" />
<rect class="box" x="24" y="222" width="632" height="62" rx="6" />
<text class="t" x="340" y="240">The browser's storage</text>
<text class="st" x="340" y="258">Cache Storage: 1,943 world chunks, each named by its hash</text>
<text class="st" x="340" y="274">Service worker: the app, the start package and scripts</text>
<path class="edge" d="M528 138 V220" marker-end="url(#gta-arrow)" />
</svg>
<figcaption>Everything a frame needs runs on the main thread; everything that touches the world's 900 MB runs in workers.</figcaption>
</figure>

<p>Keeping an open-world game smooth in a browser means keeping the 900 MB map off the thread that draws frames. On the main thread, the simulation steps at a fixed 60 Hz—updating CJ, ambient traffic, pedestrians, police, and the mission script interpreter—alongside Rapier physics in WebAssembly and a <code class="language-plaintext highlighter-rouge">three.js</code> renderer capped at 30 fps. Everything that touches storage or network lives in background workers: a chunk fetcher downloads, verifies, caches, and inflates raw world archives across 1,943 hash-addressed chunks, while a world streamer unpacks building placements, geometry, and collision meshes for the active region.</p>

<p>Gameplay systems are ported from <code class="language-plaintext highlighter-rouge">gta-reversed</code>, the community’s C++ reconstruction of the PC game; wherever <code class="language-plaintext highlighter-rouge">gta-reversed</code> has only a stub, the port cross-references the disassembled iOS executable to recover the missing logic.</p>

<h2 id="streaming-a-state">Streaming a state</h2>

<figure class="fig" id="gta-stream">
<figcaption>The port's region follower on a made-up road, one minute of driving with each window taking a fixed time to load. The strip at the bottom is the same follower in the real game, on a scripted 90-second drive, before and after the streaming worker stopped re-decoding models it already had.</figcaption>
</figure>

<p>The world arrives in 180-unit high-detail windows surrounded by low-detail LODs out to 1,000 units. A small state machine, the region follower, tracks a look-ahead point in front of the car: once that point drifts 30% of a radius from the current window’s center, it asks the worker for the next window; if the car travels 85% of the way to the edge before the new window arrives, the game pauses on a streaming hold rather than letting CJ drive off the end of the collision mesh.</p>

<p>On the synthetic straight road above, <a class="act" href="#gta-stream" data-speed="70" data-latency="2" data-lead="fixed">at 70 m/s, 2-second windows</a> land just in time and never stop the car. <a class="act" href="#gta-stream" data-speed="70" data-latency="2.1" data-lead="fixed">At 2.1 seconds</a>, that extra tenth of a second pushes the car past the 85% threshold and triggers repeated holds.</p>

<p>That exact cliff showed up in the real game. When players reported freezes on fast highways, a scripted route probe drove a 90-second diagonal across the map at 70 m/s and logged 53 streaming holds totaling 28.6 seconds—and along the way uncovered a Rapier physics body leak that no unit test had caught. The streaming worker had been decoding every model in each new window before discarding the ones already resident in memory. Skipping already-loaded models before decoding and caching collision data cut window load times from 1.3–1.9 seconds down to 60–350 ms, reducing the same 90-second drive to a single 1.4-second hold at the start.</p>

<h2 id="running-the-original-scripts">Running the original scripts</h2>

<figure class="fig wide" id="gta-vm">
<figcaption>The port's script decoder and interpreter on a six-instruction script assembled for this page. Below, the port's census of the 1,570 commands the original scripts use.</figcaption>
</figure>

<p>San Andreas’s story, side jobs, cutscene timing, and ambient triggers are all compiled into Rockstar’s SCM bytecode (<code class="language-plaintext highlighter-rouge">main.scm</code> plus roughly two hundred streamed mission scripts), and web-gta executes that original bytecode directly. Each cooperative script thread steps through instructions—a two-byte opcode followed by type-tagged arguments—until it reaches a <code class="language-plaintext highlighter-rouge">WAIT</code>, then sleeps until the game clock catches up. (<a class="act" href="#gta-vm" data-wait="1000" data-play="1">Set the wait to a full second</a> and the thread spends almost all its time asleep, which is what most of the game’s background threads are doing at any moment.)</p>

<p>Writing the bytecode interpreter is the small part; the real work is the opcode catalogue behind it. The original scripts call 1,570 distinct commands across 416,669 places in the bytecode, and each one needs an engine subsystem behind it to spawn a car, assign a pedestrian task, or start a phone call. When a thread hits one of the 127 commands that aren’t ported yet, the interpreter logs the skip, reports “nothing found” for its outputs, and lets the mission carry on rather than crashing the script.</p>

<h2 id="traffic-from-the-original-arithmetic">Traffic from the original arithmetic</h2>

<figure class="fig" id="gta-traffic">
<figcaption>Two made-up cars. The blue one brakes with the port of <code>CCarCtrl::SlowCarDownForOtherCar</code> for whatever gap the grey one leaves.</figcaption>
</figure>

<p>Ambient traffic runs a direct port of <code class="language-plaintext highlighter-rouge">CCarCtrl::SlowCarDownForOtherCar</code>. When another vehicle enters the scan zone ahead, the follower caps its target speed at roughly 62% of cruise and slows down in proportion to the closing gap.</p>

<p>Right behind a bumper, however, three consecutive branches in the original formula produce a quirk you can watch live above. <a class="act" href="#gta-traffic" data-place="8.5">Stopped 8.5 units behind</a> the lead car, the follower stays put in the outer stop zone. Place it <a class="act" href="#gta-traffic" data-place="7.5">one unit closer</a> and it lands in a middle branch that commands a crawl speed of <code class="language-plaintext highlighter-rouge">1</code>, creeping forward until it hits the inner stop zone at 5.9 units—closer than the spot where it was already stopped, it starts moving again. Both zones come straight out of the branching arithmetic ported from <code class="language-plaintext highlighter-rouge">reVC</code>.</p>

<h2 id="fitting-on-a-phone">Fitting on a phone</h2>

<figure class="fig" id="gta-res">
<figcaption>The port's adaptive-resolution controller on three made-up phones. Grey is frame time, blue the resolution scale.</figcaption>
</figure>

<p>Running a desktop open-world game inside a mobile browser hits three bottlenecks at once: fill-rate limits, thermal throttling, and tight memory ceilings. To protect frame rate, the adaptive-resolution controller watches the median frame time over a three-second window and drops a step down the resolution ladder (<code class="language-plaintext highlighter-rouge">100% → 85% → 72% → 60%</code>) whenever rendering stays slower than 26 fps. On <a class="act" href="#gta-res" data-kind="gpu" data-base="45">a GPU-bound phone</a>, stepping down to 85% immediately restores smooth frames; on <a class="act" href="#gta-res" data-kind="cpu" data-base="45">a CPU-bound phone</a>, fewer pixels make no difference, so the controller undoes the step after a single window and backs off—waiting twice as long before each retry—rather than blurring the screen for nothing.</p>

<figure class="fig" id="gta-dxt">
<figcaption>One 8-byte DXT1 block through the port's decoder, to 32-bit colour and to the 16 bits a phone gets. The two endpoint colours and the sixteen indices are the whole block.</figcaption>
</figure>

<p>Because few mobile GPUs can sample the PC release’s DXT-compressed textures directly, the port decodes them on the CPU as chunks stream in. Decoding DXT1 blocks into 16-bit colour (<code class="language-plaintext highlighter-rouge">RGB565</code>, or <code class="language-plaintext highlighter-rouge">RGB5A1</code> when a block uses DXT1’s transparency mode—<a class="act" href="#gta-dxt" data-swap="1">swap the two endpoints</a> to see <code class="language-plaintext highlighter-rouge">c0 &lt;= c1</code> turn index <code class="language-plaintext highlighter-rouge">3</code> clear) instead of 32-bit RGBA cut texture memory around Grove Street in half, from 93 MB to 46 MB.</p>

<figure class="fig" id="gta-steer">
<figcaption>The port's touch-stick steering against a stick that steers one for one. The stick springs back when let go.</figcaption>
</figure>

<p>Finally, because a thumb on glass has no physical resistance, the touch thumbstick uses the iOS port’s non-linear steering curve: the first third of the stick’s travel turns the wheels only a fifth of the way so you can make fine lane corrections at speed, while the outer range opens up to full lock.</p>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><summary type="html"><![CDATA[How web-gta streams San Andreas into a browser tab, executes the original SCM mission bytecode, ports CCarCtrl traffic arithmetic, and fits a 900 MB world onto a phone.]]></summary></entry><entry><title type="html">Replaying a village raid</title><link href="https://sir-teo.github.io/blogs/2026/10/09/replaying-a-village-raid.html" rel="alternate" type="text/html" title="Replaying a village raid" /><published>2026-10-09T13:00:00-04:00</published><updated>2026-10-09T13:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/2026/10/09/replaying-a-village-raid</id><content type="html" xml:base="https://sir-teo.github.io/blogs/2026/10/09/replaying-a-village-raid.html"><![CDATA[<p><strong>Crown &amp; Clan</strong> is a village-building and raiding game in the mould of Clash of Clans, written in TypeScript and Phaser with no server behind it. Because it is an independent fan project (“Clash of Clans assets and trademarks belong to Supercell,” its README notes), the figures here draw plain geometric shapes instead of sprites. Every battle in them was simulated by the game’s own engine in Node across the twelve campaign villages the project designed itself.</p>

<h2 id="a-simulation-with-no-screen">A simulation with no screen</h2>

<figure class="fig">
<svg class="arch" viewBox="0 0 680 262" role="img" aria-label="Taps and recorded actions go into GameModel, a pure TypeScript simulation stepped at 20 Hz; a Phaser scene and a DOM HUD read it; saves and recordings go to the browser's storage.">
<defs>
<marker id="coc-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head-strong" d="M0,1 L9,5 L0,9 z" /></marker>
</defs>
<rect class="box" x="24" y="30" width="170" height="52" rx="6" />
<text class="t" x="109" y="48">Your taps</text>
<text class="st" x="109" y="65">deploy, spell, end</text>
<rect class="box" x="24" y="104" width="170" height="52" rx="6" />
<text class="t" x="109" y="122">A recording</text>
<text class="st" x="109" y="139">the same actions, replayed</text>
<rect class="box em" x="252" y="56" width="176" height="76" rx="6" />
<text class="t" x="340" y="80">GameModel</text>
<text class="st" x="340" y="98">pure TypeScript, no Phaser</text>
<text class="st" x="340" y="114">step(0.05), twenty times a second</text>
<rect class="box" x="486" y="30" width="170" height="52" rx="6" />
<text class="t" x="571" y="48">Phaser scene</text>
<text class="st" x="571" y="65">draws between two steps</text>
<rect class="box" x="486" y="104" width="170" height="52" rx="6" />
<text class="t" x="571" y="122">HUD</text>
<text class="st" x="571" y="139">plain DOM, reads the model</text>
<path class="edge-strong" d="M194 56 H222 V84 H250" marker-end="url(#coc-arrow)" />
<path class="edge-strong" d="M194 130 H222 V104 H250" marker-end="url(#coc-arrow)" />
<path class="edge-strong" d="M430 84 H458 V56 H484" marker-end="url(#coc-arrow)" />
<path class="edge-strong" d="M430 104 H458 V130 H484" marker-end="url(#coc-arrow)" />
<rect class="box" x="24" y="190" width="632" height="52" rx="6" />
<text class="t" x="340" y="208">Saves</text>
<text class="st" x="340" y="225">IndexedDB with a localStorage backup · each raid kept as its setup, every step size and every action</text>
<path class="edge" d="M340 134 V188" marker-end="url(#coc-arrow)" />
</svg>
<figcaption>The simulation never imports Phaser, so the same code runs a battle on screen, in a replay, in a test or in Node.</figcaption>
</figure>

<p>One architectural rule governs the entire 66,000-line codebase: <code class="language-plaintext highlighter-rouge">GameModel</code> may never import Phaser, a scene class, or a single byte of rendering data. Fifty-three files in the repository touch Phaser, and none of them is reachable from the simulation. During a live battle, the Phaser scene steps <code class="language-plaintext highlighter-rouge">GameModel</code> twenty times a second (<code class="language-plaintext highlighter-rouge">step(0.05)</code>) and interpolates unit positions between ticks so movement looks smooth at any display refresh rate without the model knowing frames exist. Because the simulation is pure TypeScript, the exact same class runs live raids in the browser, verifies deterministic replays in CI, and generated every figure on this page from a Node script.</p>

<h2 id="where-you-attack-from">Where you attack from</h2>

<figure class="fig wide" id="coc-raid" style="--reserve: 560px" data-src="/blogs/assets/data/web-coc-demo.json">
<figcaption>One army, twelve landing points around Ember Keep, each raid run by the game's engine. Troops can't land in the shaded zone.</figcaption>
</figure>

<p>Once deployed outside the shaded exclusion zone, every unit follows a greedy target rule: Barbarians and Archers march toward the nearest building of any kind, while Giants ignore resource huts and head straight for the nearest defense. That makes your landing point decisive, because it determines which defenses your Giants engage first and how long your damage dealers survive behind them.</p>

<p>Deploy the exact same 42-troop army (6 Giants, 4 Wall Breakers, 16 Barbarians, 16 Archers) from twelve points around Ember Keep and the outcomes split completely. <a class="act" href="#coc-raid" data-point="3">From the northeast</a>, the army strings out into the village’s defenses one at a time and dies at 37% destruction with zero stars. <a class="act" href="#coc-raid" data-point="5">From lower down the same side</a>, four of the six Giants survive into the core and the raid clears the village for all three stars in 42 seconds:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">b</span><span class="p">.</span><span class="nx">stars</span> <span class="o">=</span> <span class="nb">Number</span><span class="p">(</span><span class="nx">b</span><span class="p">.</span><span class="nx">destruction</span> <span class="o">&gt;=</span> <span class="mi">50</span><span class="p">)</span>
  <span class="o">+</span> <span class="nb">Number</span><span class="p">(</span><span class="nx">townHallDead</span><span class="p">)</span>
  <span class="o">+</span> <span class="nb">Number</span><span class="p">(</span><span class="nx">b</span><span class="p">.</span><span class="nx">destruction</span> <span class="o">===</span> <span class="mi">100</span><span class="p">);</span>
</code></pre></div></div>

<h2 id="routes-through-walls">Routes through walls</h2>

<figure class="fig wide" id="coc-paths" style="--reserve: 480px" data-src="/blogs/assets/data/web-coc-demo.json">
<figcaption>Routes from 64 points around the village, from the game's own <code>findPath</code>. Red: walls a route breaks through.</figcaption>
</figure>

<p>Every ground unit finds its way across the 48×48 tile grid with four-directional A*. Buildings are solid obstacles, while walls are passable at a price: stepping onto open grass costs <code class="language-plaintext highlighter-rouge">1</code>, and stepping through a wall tile costs <code class="language-plaintext highlighter-rouge">7</code>. A troop will detour up to six extra tiles around a wall before deciding to stop and batter through it, recomputing its path whenever a building or wall segment falls.</p>

<p>Because <a class="act" href="#coc-paths" data-troop="giant">Giants</a> only target defenses—and Ember Keep’s defenses sit inside stone compartments—20 of the 64 perimeter starting points send them straight through a wall. <a class="act" href="#coc-paths" data-troop="barbarian">Barbarians</a> settle for the closest building of any kind, which from the perimeter is always an outside building, so their initial paths never break a wall at all.</p>

<h2 id="one-army-twelve-villages">One army, twelve villages</h2>

<figure class="fig" id="coc-sweep" style="--reserve: 420px" data-src="/blogs/assets/data/web-coc-demo.json">
<figcaption>Destruction on each of the campaign's twelve villages, for three armies deployed from the west.</figcaption>
</figure>

<p>Because <code class="language-plaintext highlighter-rouge">GameModel</code> runs without a screen, balancing the twelve-stage single-player campaign means sweeping test armies across every village in Node. Deployed from the west, the small starter army fades after the fourth village, while the medium ground force holds three stars through village eight. <a class="act" href="#coc-sweep" data-village="8">Air</a> (Balloons and Dragons) flies over every wall to clear the first eight villages just as easily as the ground army, then falls off sharply on the Expert stages as soon as villages gain a second Air Defense.</p>

<h2 id="every-step-recorded">Every step, recorded</h2>

<figure class="fig wide" id="coc-det" style="--reserve: 520px" data-src="/blogs/assets/data/web-coc-demo.json">
<figcaption>The same raid with the same taps, stepped every 0.05 s and every 0.1 s. Lines trace each troop.</figcaption>
</figure>

<p>Crown &amp; Clan does not record a battle as video or as a stream of unit positions. A replay saves only the initial setup and your timestamped taps, then simulates the battle again from scratch—45 actions and 317 step sizes for the 16-second raid above, under 10 KB in all.</p>

<p>That only works if simulating the raid a second time produces the exact same battle to the bit. Combat uses no ambient random number generator, hashing a fixed battle seed with a counter whenever a rule needs chance. Distances are computed with explicit arithmetic because browser implementations of <code class="language-plaintext highlighter-rouge">Math.hypot</code> can disagree by a few least-significant bits—enough for a projectile in Safari to arrive one tick later than in Chrome and fork the rest of the raid. And because a different tick rate produces a different battle—as the two boards above show <a class="act" href="#coc-det" data-time="8">halfway through the raid</a>—the recording logs every step size alongside every tap.</p>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><summary type="html"><![CDATA[How Crown & Clan keeps its TypeScript battle simulation decoupled from Phaser, deterministic across browsers, and replayable from a 10 KB log of taps and step sizes.]]></summary></entry><entry><title type="html">Compiling a xiangqi engine for the browser</title><link href="https://sir-teo.github.io/blogs/2026/10/09/compiling-a-xiangqi-engine-for-the-browser.html" rel="alternate" type="text/html" title="Compiling a xiangqi engine for the browser" /><published>2026-10-09T12:00:00-04:00</published><updated>2026-10-09T12:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/2026/10/09/compiling-a-xiangqi-engine-for-the-browser</id><content type="html" xml:base="https://sir-teo.github.io/blogs/2026/10/09/compiling-a-xiangqi-engine-for-the-browser.html"><![CDATA[<p>Pikafish is the strongest open-source engine for xiangqi (Chinese chess)—a C++ fork of Stockfish built for a nine-by-ten grid divided by a river, palatial fortresses, and tactical rules that behave nothing like Western chess. <strong>web-xiangqi</strong> compiles Pikafish to WebAssembly, serves its 53.7 MB neural network from a static host, and wraps an analysis and game-review studio around it. Every interactive board below runs the app’s own rule and notation code directly in the page, paired with evaluations from the Pikafish build the app ships.</p>

<h2 id="the-rules">The rules</h2>

<figure class="fig" id="xq-rules">
<figcaption>web-xiangqi's own move validation for the piece picked up. Dots are legal; crosses are moves a rule refuses, and the piece in the way is ringed.</figcaption>
</figure>

<p>Even if you play Western chess every day, four xiangqi rules will catch you off guard: <a class="act" href="#xq-rules" data-preset="horse">a horse’s leg can be blocked</a> on the orthogonal step before its diagonal turn; <a class="act" href="#xq-rules" data-preset="elephant">an elephant’s eye too</a> at the midpoint of its two-point diagonal jump (and elephants may never cross the river); <a class="act" href="#xq-rules" data-preset="cannon">a cannon captures over exactly one screen</a> while sliding like a rook when moving to an empty point; and <a class="act" href="#xq-rules" data-preset="general">the generals may never face each other</a> along an open file with no piece between them.</p>

<p>Before the UI ever sends a position to the engine, its own TypeScript move generator has to agree with Pikafish on every legal move. A <code class="language-plaintext highlighter-rouge">perft</code> test suite pins the two together to the leaf: 44 moves from the starting board, 1,920 two-move sequences, and 79,666 three-move sequences.</p>

<h2 id="writing-a-move-down">Writing a move down</h2>

<figure class="fig">
<svg class="anatomy" viewBox="0 0 640 150" role="img" aria-label="The move 炮二平五 split into its parts: piece 炮 (cannon), file 二 (two, counted from the mover's right), action 平 (across), target 五 (file five). In WXF letters: C2=5.">
<g class="cell"><rect x="20" y="20" width="110" height="64" rx="8" /><text class="big cjk" x="75" y="56">炮</text><text class="cap" x="75" y="108">piece</text><text class="sub" x="75" y="126">cannon · C</text></g>
<g class="cell"><rect x="150" y="20" width="110" height="64" rx="8" /><text class="big cjk" x="205" y="56">二</text><text class="cap" x="205" y="108">file</text><text class="sub" x="205" y="126">2, from your right</text></g>
<g class="cell em"><rect x="280" y="20" width="110" height="64" rx="8" /><text class="big cjk" x="335" y="56">平</text><text class="cap" x="335" y="108">direction</text><text class="sub" x="335" y="126">across · =</text></g>
<g class="cell"><rect x="410" y="20" width="110" height="64" rx="8" /><text class="big cjk" x="465" y="56">五</text><text class="cap" x="465" y="108">to</text><text class="sub" x="465" y="126">file 5</text></g>
<text class="wxf" x="545" y="52">= C2=5</text>
</svg>
<figcaption>The most common first move, in Chinese and in WXF letters. 進 (+) is forward and 退 (−) back; moving straight forward or back, the last number counts steps, otherwise it names the file.</figcaption>
</figure>

<p>Xiangqi notation reads like a four-character sentence rather than an algebraic square, with files numbered 1 to 9 from each player’s own right-hand side. In the classic central-cannon opening <code class="language-plaintext highlighter-rouge">炮二平五</code> (<code class="language-plaintext highlighter-rouge">C2=5</code> in World Xiangqi Federation letters), Red takes the cannon (<code class="language-plaintext highlighter-rouge">炮</code>) on file two (<code class="language-plaintext highlighter-rouge">二</code>) and slides it horizontally (<code class="language-plaintext highlighter-rouge">平</code>) to the fifth file (<code class="language-plaintext highlighter-rouge">五</code>). When a piece advances (<code class="language-plaintext highlighter-rouge">進</code>, <code class="language-plaintext highlighter-rouge">+</code>) or retreats (<code class="language-plaintext highlighter-rouge">退</code>, <code class="language-plaintext highlighter-rouge">−</code>), the final character changes role depending on the piece: for pieces that travel straight along a file, it counts the steps taken; for diagonal pieces like horses, elephants, and advisors, it names the file they land on.</p>

<p>You can <a class="act" href="#xq-rules" data-preset="start" data-play="h2e2">play <code class="language-plaintext highlighter-rouge">炮二平五</code> on the board above</a>, or <a class="act" href="#xq-rules" data-pieces="letters">switch the pieces to letters</a> if you are still learning the characters (Red and Black even use different characters for their own counterparts, such as <code class="language-plaintext highlighter-rouge">仕</code> and <code class="language-plaintext highlighter-rouge">士</code> for advisors). To parse a move a user types—whether in Chinese, WXF letters, or UCI coordinates—the app simply renders every legal move in the current position across all three notations and keeps the one that matches.</p>

<h2 id="compiling-pikafish">Compiling Pikafish</h2>

<figure class="fig">
<svg class="arch" viewBox="0 0 680 372" role="img" aria-label="The build: Pikafish's C++ source at a pinned commit and a 340-line patch go through Emscripten 6.0.1 to four WebAssembly cores of about 0.7 MB each; the 53.7 MB network ships beside them and is 97.6% of a first visit's bytes. With the isolation headers, the threaded core searches 3.7 million nodes a second on eight threads against 414,000 on one.">
<defs>
<marker id="xq-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head" d="M0,1 L9,5 L0,9 z" /></marker>
</defs>
<rect class="box" x="24" y="26" width="196" height="48" rx="6" />
<text class="t" x="122" y="42">Pikafish</text>
<text class="st" x="122" y="59">C++, pinned to one commit</text>
<rect class="box" x="24" y="90" width="196" height="48" rx="6" />
<text class="t" x="122" y="106">pikafish-wasm.patch</text>
<text class="st" x="122" y="123">340 lines; command() for stdin</text>
<rect class="box" x="262" y="58" width="164" height="48" rx="6" />
<text class="t" x="344" y="74">Emscripten 6.0.1</text>
<text class="st" x="344" y="91">pinned; 6.0.2 broke threads</text>
<path class="edge" d="M220 50 H240 V82 H260" marker-end="url(#xq-arrow)" />
<path class="edge" d="M220 114 H240 V82" />
<rect class="box" x="468" y="22" width="188" height="26" rx="5" />
<text class="st" x="562" y="35">threads + SIMD · 710 KB</text>
<rect class="box" x="468" y="54" width="188" height="26" rx="5" />
<text class="st" x="562" y="67">threads · 705 KB</text>
<rect class="box" x="468" y="86" width="188" height="26" rx="5" />
<text class="st" x="562" y="99">one thread + SIMD · 659 KB</text>
<rect class="box" x="468" y="118" width="188" height="26" rx="5" />
<text class="st" x="562" y="131">one thread · 654 KB</text>
<path class="edge" d="M426 82 H446 V35 H466 M446 67 H466 M446 99 H466 M446 131 H466 M446 35 V131" marker-end="url(#xq-arrow)" />
<text class="lbl" x="24" y="190">What a first visit to the engine downloads: 55.1 MB</text>
<rect class="bar" x="24" y="202" width="4" height="22" />
<rect class="bar" x="30" y="202" width="11" height="22" style="fill-opacity:0.45" />
<rect class="bar net" x="43" y="202" width="613" height="22" />
<text class="bl" x="24" y="244">app 0.7%</text>
<text class="bl" x="92" y="244">engine code 1.7%</text>
<text class="bl r" x="656" y="244">pikafish.nnue, 53.7 MB, 97.6%</text>
<text class="st" x="340" y="276" style="text-anchor:middle">The network is already Zstandard-compressed: served with gzip it grows by 8.8 KB.</text>
<text class="lbl" x="24" y="316">Nodes searched a second, with the isolation headers</text>
<rect class="bar" x="118" y="326" width="60" height="14" />
<text class="bl mid" x="24" y="333">one thread</text>
<text class="bl mid" x="186" y="333">414,000</text>
<rect class="bar net" x="118" y="346" width="538" height="14" />
<text class="bl mid" x="24" y="353">eight threads</text>
<text class="bl r in" x="650" y="353">3.7 million</text>
</svg>
<figcaption>One patched source tree, four cores chosen at load time by what the browser supports, a network that is almost all of the download, and what the threads buy.</figcaption>
</figure>

<p>Pikafish is written as a command-line program that blocks on <code class="language-plaintext highlighter-rouge">stdin</code> waiting for UCI strings. A browser worker has no <code class="language-plaintext highlighter-rouge">stdin</code> to block on, so the heart of the 340-line patch turns <code class="language-plaintext highlighter-rouge">main()</code> into initialization and exposes a single C function that dispatches one command at a time:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">extern</span> <span class="s">"C"</span> <span class="kt">void</span> <span class="nf">command</span><span class="p">(</span><span class="k">const</span> <span class="kt">char</span><span class="o">*</span> <span class="n">cmd</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">uciP</span><span class="o">-&gt;</span><span class="n">process_command</span><span class="p">(</span><span class="n">cmd</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The rest of the patch stubs out native threads for the single-threaded builds and removes NUMA node binding, producing four compact WASM binaries (~700 KB each) that the loader chooses between at startup. The compiler itself had to be pinned as tightly as the C++ source: Emscripten <code class="language-plaintext highlighter-rouge">6.0.2</code> quietly dropped a flag that told pthread workers where the engine’s script lived, turning every spawned thread into <code class="language-plaintext highlighter-rouge">new Worker(undefined)</code> so the engine answered <code class="language-plaintext highlighter-rouge">uci</code> and then hung forever before <code class="language-plaintext highlighter-rouge">readyok</code>.</p>

<p>Beside the 700 KB core sits <code class="language-plaintext highlighter-rouge">pikafish.nnue</code>, a 53.7 MB weight file that accounts for 97.6% of a first visit’s download (and is already Zstandard-compressed, so gzipping it on the server actually makes it 8.8 KB larger). Because browsers refuse to store a 53 MB response in the standard HTTP cache, the app’s service worker stores the network in Cache Storage itself—and attaches the two cross-origin isolation headers that GitHub Pages cannot send, taking an eight-thread search from 414,000 nodes a second to 3.7 million.</p>

<h2 id="the-halfmove-clock">The halfmove clock</h2>

<figure class="fig" id="xq-clock" style="--reserve: 360px" data-src="/blogs/assets/data/web-xiangqi-demo.json">
<figcaption>One endgame, searched by Pikafish at depth 14 for every value of the FEN's halfmove clock. Blue is a forced mate.</figcaption>
</figure>

<p>Every FEN string ends with a halfmove clock counting plies since the last capture. Where chess draws after 50 moves (100 plies), xiangqi enforces a 60-move rule (120 plies), and Pikafish reads that counter directly from the FEN when searching.</p>

<p>In the horse-and-advisor endgame above, forcing mate in three requires five plies. That means the forced mate is still there <a class="act" href="#xq-clock" data-position="0" data-clock="115">at a clock of 115</a> (<code class="language-plaintext highlighter-rouge">115 + 5 = 120</code>, delivering mate on the final legal ply) and vanishes into a <code class="language-plaintext highlighter-rouge">0.00</code> draw <a class="act" href="#xq-clock" data-position="0" data-clock="116">at 116</a>. Early versions of web-xiangqi wrote a hardcoded <code class="language-plaintext highlighter-rouge">0</code> into the clock field of every FEN and ignored the clock on pasted positions—which caused endgames pasted near the 60-move limit to promise forced mates in positions the engine itself would have declared drawn.</p>

<h2 id="repetition">Repetition</h2>

<figure class="fig" id="xq-repetition" style="--reserve: 400px" data-src="/blogs/assets/data/web-xiangqi-demo.json">
<figcaption>Two repeating lines, judged after each move by web-xiangqi's own check and by Pikafish's rule judge.</figcaption>
</figure>

<p>Repetition is where xiangqi breaks hardest from Western chess. Repeating a board position three times is not an automatic draw; the outcome depends on <em>who is forcing the loop</em>. A player who checks on every move of a cycle loses unless they deviate, and so does a player who perpetually chases an unprotected piece.</p>

<p>The app’s own TypeScript detector catches <a class="act" href="#xq-repetition" data-line="check" data-move="5">perpetual check</a> as soon as the cycle forms, since every recorded move already carries a check flag. It deliberately does not guess at <a class="act" href="#xq-repetition" data-line="chase" data-move="11">perpetual chase</a>: deciding whether a move legally “chases” requires knowing which piece became newly attacked, whether it is defended, and a tangle of exceptions for pinned pieces, generals, and soldiers. As the source comments put it, guessing at that would be worse than saying nothing—so the UI flags an unverified loop as a draw <em>unless a chase is involved</em>, while the WASM patch exposes Pikafish’s internal <code class="language-plaintext highlighter-rouge">rulejudge</code> command, which adjudicates both lines above.</p>

<h2 id="reviewing-a-game">Reviewing a game</h2>

<figure class="fig wide" id="xq-review" style="--reserve: 640px" data-src="/blogs/assets/data/web-xiangqi-demo.json">
<figcaption>A game between the app's Easy and Medium levels, reviewed by Pikafish at depth 16 with the app's review code, opened on the move that decided it.</figcaption>
</figure>

<p>The review pipeline scores every position at depth 16, maps centipawns onto a win-probability curve, and grades each move by the milder of its centipawn and winning-chance losses. In the game above, play stays roughly level through a pair of mutual stumbles around <a class="act" href="#xq-review" data-move="64">move 32</a> until Red steps its general off the back rank with <a class="act" href="#xq-review" data-move="89">45.帥五進一</a>, dropping from 43% winning chances to 17% and walking into mate eleven plies later.</p>

<p>That showcase game was played between two of the app’s own AI levels (<code class="language-plaintext highlighter-rouge">Easy</code> and <code class="language-plaintext highlighter-rouge">Medium</code>) rather than lifted from a book, and for good reason. web-xiangqi once shipped ten “classic grandmaster games” complete with player names, tournament titles, and dates. None of them were real: seven shared the exact same eight-move opening block, and two contained illegal moves that had been loading silently truncated. What gave the fabrication away was pointing the app’s own review engine at them—the supposed grandmasters scored 20% to 28% accuracy with seven to ten blunders apiece, barely ahead of the 7% scored by random legal moves.</p>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><summary type="html"><![CDATA[How web-xiangqi compiles Pikafish to WebAssembly, streams its 53.7 MB neural network from a static host, and implements xiangqi's blocking rules, notation, halfmove clock, and repetition laws in the browser.]]></summary></entry><entry><title type="html">Grading chess moves in a browser tab</title><link href="https://sir-teo.github.io/blogs/2026/10/09/grading-chess-moves-in-a-browser-tab.html" rel="alternate" type="text/html" title="Grading chess moves in a browser tab" /><published>2026-10-09T11:00:00-04:00</published><updated>2026-10-09T11:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/2026/10/09/grading-chess-moves-in-a-browser-tab</id><content type="html" xml:base="https://sir-teo.github.io/blogs/2026/10/09/grading-chess-moves-in-a-browser-tab.html"><![CDATA[<p>Putting a chess engine in a Web Worker is the easy half of building a browser chess app. The harder half starts once the engine speaks: turning raw centipawn numbers into move grades that match how a human experiences a game, and weakening a 3,500-Elo monster into a beginner opponent that makes natural mistakes without randomly giving away its queen. <strong>web-chess</strong> runs Stockfish 18 directly in the browser tab for playing, importing, and reviewing games. Every interactive figure below runs the app’s own sizing and grading code in the page, using evaluations recorded from the same Stockfish build in Node.</p>

<h2 id="three-engines-in-one-tab">Three engines in one tab</h2>

<figure class="fig">
<svg class="arch" viewBox="0 0 680 362" role="img" aria-label="Architecture: on the main thread, the board and panels, play mode and clock, and the review session; in Web Workers, an analysis engine, an opponent engine and a review pool of two to four engines; a service worker beneath everything adds isolation headers and caches the app. Below: reviewing the Opera Game took 16.5 seconds with one engine and 8.2 with four.">
<defs>
<marker id="wc-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head-strong" d="M0,1 L9,5 L0,9 z" /></marker>
</defs>
<text class="lbl" x="24" y="13">Main thread</text>
<text class="lbl" x="386" y="13">Web Workers, one Stockfish each</text>
<rect class="box" x="24" y="26" width="270" height="48" rx="6" />
<text class="t" x="159" y="42">Board and panels</text>
<text class="st" x="159" y="59">React; the live analysis view</text>
<rect class="box" x="24" y="92" width="270" height="48" rx="6" />
<text class="t" x="159" y="108">Play mode and clock</text>
<text class="st" x="159" y="125">your moves, premoves, takebacks</text>
<rect class="box" x="24" y="158" width="270" height="48" rx="6" />
<text class="t" x="159" y="174">Review session</text>
<text class="st" x="159" y="191">queue, reuse, grading, accuracy</text>
<rect class="box" x="386" y="26" width="270" height="48" rx="6" />
<text class="t" x="521" y="42">Analysis engine</text>
<text class="st" x="521" y="59">follows the board; MultiPV, WDL</text>
<rect class="box" x="386" y="92" width="270" height="48" rx="6" />
<text class="t" x="521" y="108">Opponent engine</text>
<text class="st" x="521" y="125">Elo limit, its own options</text>
<rect class="box dash" x="394" y="166" width="270" height="48" rx="6" />
<rect class="box dash" x="390" y="162" width="270" height="48" rx="6" />
<rect class="box" x="386" y="158" width="270" height="48" rx="6" />
<text class="t" x="521" y="174">Review pool</text>
<text class="st" x="521" y="191">2 to 4 engines, for one review</text>
<path class="edge-strong" d="M296 50 H384" marker-start="url(#wc-arrow)" marker-end="url(#wc-arrow)" />
<path class="edge-strong" d="M296 116 H384" marker-start="url(#wc-arrow)" marker-end="url(#wc-arrow)" />
<path class="edge-strong" d="M296 182 H384" marker-start="url(#wc-arrow)" marker-end="url(#wc-arrow)" />
<text class="el" x="340" y="43">UCI</text>
<text class="el" x="340" y="109">UCI</text>
<text class="el" x="340" y="175">UCI</text>
<rect class="box" x="24" y="236" width="640" height="46" rx="6" />
<text class="t" x="344" y="251">Service worker</text>
<text class="st" x="344" y="268">adds the two isolation headers to every response · keeps the app and engine for offline use</text>
<text class="lbl" x="24" y="308">Reviewing the Opera Game, depth 18</text>
<rect class="bar" x="130" y="316" width="526" height="14" />
<text class="bl mid" x="24" y="323">one engine</text>
<text class="bl r in" x="650" y="323">16.5 s</text>
<rect class="bar" x="130" y="336" width="261" height="14" />
<text class="bl mid" x="24" y="343">four engines</text>
<text class="bl r in" x="385" y="343">8.2 s</text>
</svg>
<figcaption>Three kinds of Stockfish, each in its own worker. The review pool exists only while a review runs, and splits the game into one contiguous run of moves per engine.</figcaption>
</figure>

<p>Instead of multiplexing a single Stockfish instance across every task, web-chess keeps separate engines in separate Web Workers. Live analysis wants <code class="language-plaintext highlighter-rouge">MultiPV</code> and win-draw-loss statistics; the AI opponent wants <code class="language-plaintext highlighter-rouge">UCI_LimitStrength</code> and an Elo cap; and re-sending UCI options like <code class="language-plaintext highlighter-rouge">Hash</code> between turns silently flushes the engine’s transposition table even when the hash size hasn’t changed. When you ask for a full game review, the app spins up a temporary pool of two to four workers and hands each one a contiguous slice of the game so its hash table stays warm from move to move. (An earlier experiment tried to speed reviews up further and clocked a 37% win on a single run—until a five-run benchmark showed it was actually slower.)</p>

<figure class="fig" id="wc-device">
<figcaption>What web-chess sets up on a device like this one, from its own sizing code. Numbered cells are review engines.</figcaption>
</figure>

<p>How many workers and threads the app spawns depends on what the browser reports. <a class="act" href="#wc-device" data-device="phone">A phone</a> gets one small, single-threaded engine, a 16 MB hash table, and a single principal variation, since a second line costs two to four times the search effort. <a class="act" href="#wc-device" data-device="desktop">An 18-core desktop</a> spins up four review engines with two threads each. <a class="act" href="#wc-device" data-device="pages">Without cross-origin isolation</a>—which GitHub Pages cannot grant unless the app’s service worker intercepts the page—shared memory disappears and each engine drops to one thread, but the four-worker review pool still runs at full width because separate workers share no memory. For a long time, a missing bootstrap wrapper meant the multithreaded build died at startup and silently fell back to single-threaded WASM; fixing that one silent fallback took a depth-16 search from 443,000 to 3.5 million nodes a second.</p>

<h2 id="from-a-score-to-a-verdict">From a score to a verdict</h2>

<figure class="fig" id="wc-grade" style="--reserve: 330px" data-src="/blogs/assets/data/web-chess-demo.json">
<figcaption>One move graded on both ladders, by web-chess's own functions, for any <em>before</em> and <em>after</em> score.</figcaption>
</figure>

<p>Stockfish measures positions in <em>centipawns</em> (hundredths of a pawn), which is the right unit for searching a tree and the wrong unit for judging a person. Losing a pawn in a level endgame can cost the game; losing a pawn when you are already a rook ahead changes nothing.</p>

<p>To judge moves in context, web-chess converts centipawn evaluations into winning chances using Lichess’s logistic curve, grades every move on both centipawns lost and winning chances lost, and reports the milder of the two verdicts. <a class="act" href="#wc-grade" data-before="0" data-after="-300">Three pawns from a level position</a> cuts your winning chances from 50% to 25% and registers as a Blunder on both ladders; <a class="act" href="#wc-grade" data-before="1000" data-after="700">the same three pawns at +10</a> nudges your winning chances from 97% to 93% and is graded Good. Even <a class="act" href="#wc-grade" data-game="opera" data-ply="30">walking into mate when already lost</a> (<code class="language-plaintext highlighter-rouge">15…Nxd7</code> in the Opera Game) comes out Good: Stockfish encodes forced mate with a 100-pawn sentinel, which looks like a 90-pawn catastrophe on the raw scale—and once inflated the Opera Game’s average centipawn loss to 316 beside zero mistakes—whereas the winning-chance curve simply sees a lost position ending a move sooner.</p>

<h2 id="one-number-for-a-game">One number for a game</h2>

<figure class="fig" id="wc-accuracy" style="--reserve: 300px" data-src="/blogs/assets/data/web-chess-demo.json">
<figcaption>Per-move accuracy for one player, each move's weight in grey below it, and four ways to sum it up. Every bar can be moved.</figcaption>
</figure>

<p>Turning forty per-move scores into a single game accuracy looks like a job for a plain average, and for its first seven months web-chess used one. The trouble is that <a class="act" href="#wc-accuracy" data-set="one">one blunder in forty moves</a> barely dents an arithmetic mean: thirty-nine solid moves at 95 and a single game-throwing blunder at 9.5 still average 93, which reads like a near-flawless game.</p>

<p>Following Lichess, web-chess now averages two different summaries instead. The first weights each move by how volatile the game was around it—the standard deviation of winning chances over a local window—so moves played while the balance is swinging count far more than routine moves in a dead ending. The second is the harmonic mean, which is dominated by the worst move on the board. Averaging the two pulls that one-blunder game down from 93 to 85, while dragging the blunder bar back up shows the penalty disappearing as soon as the mistake is no longer fatal.</p>

<h2 id="reviewing-a-game">Reviewing a game</h2>

<figure class="fig wide" id="wc-review" style="--reserve: 640px" data-src="/blogs/assets/data/web-chess-demo.json">
<figcaption>Two games as web-chess reviews them, Stockfish 18 at depth 16. The badge grades the move just played; the green arrow is the engine's choice.</figcaption>
</figure>

<p>Putting the worker pool, the dual grading ladders, and the opening book together produces the full review above, searched at depth 16. The default game was played between two of web-chess’s own levels, Club as White and Intermediate as Black. Black was winning for most of the afternoon until White played <code class="language-plaintext highlighter-rouge">19.Nxf6+</code>; instead of taking the knight back, Black stepped away with <a class="act" href="#wc-review" data-game="club" data-move="38">19…Kf8</a>, surrendering 8.6 pawns and 66 points of winning chances in a single move—the only Blunder in either game. Switch to <a class="act" href="#wc-review" data-game="opera" data-move="20">the Opera Game</a> and Black’s worst slips (<code class="language-plaintext highlighter-rouge">6…Nf6</code> and <code class="language-plaintext highlighter-rouge">10…cxb5</code>) are only Inaccuracies: after <code class="language-plaintext highlighter-rouge">10…cxb5</code> the position is already four pawns down in the engine’s eyes, so none of the famous fireworks that follow cost Black many winning chances it still had.</p>

<h2 id="playing-badly-on-purpose">Playing badly on purpose</h2>

<figure class="fig" id="wc-variety" style="--reserve: 420px" data-src="/blogs/assets/data/web-chess-demo.json">
<figcaption>Every legal move in a position, by what it costs (full-strength search), and where each rule's choices land.</figcaption>
</figure>

<p>Stockfish’s built-in <code class="language-plaintext highlighter-rouge">UCI_LimitStrength</code> option only goes down to 1,320 Elo, which is still strong enough to overwhelm a casual player. To soften the two weakest levels (<code class="language-plaintext highlighter-rouge">Beginner</code> and <code class="language-plaintext highlighter-rouge">Novice</code>), web-chess originally injected variety by picking a uniformly random legal move 38% and 20% of the time.</p>

<p>No human beginner plays chess by rolling a die across every legal move on the board. <a class="act" href="#wc-variety" data-level="1" data-position="8">After 4…Bxf3</a> in the Opera Game, White has to recapture the bishop; 33 of White’s 35 legal moves leave the piece hanging and immediately lose three pawns or more. Under the old rule, Beginner wandered into one of those unforced blunders more than a third of the time. Now the opponent asks Stockfish for its top four candidate lines and only varies among moves that stay within 1.8 pawns of its first choice (0.9 for Novice)—which here means choosing between the two sensible recaptures, <code class="language-plaintext highlighter-rouge">Qxf3</code> and <code class="language-plaintext highlighter-rouge">gxf3</code>. Across all 90 positions in both games, Beginner went from throwing away three or more pawns on one move in six to never doing so at all.</p>

<p class="credit">Piece artwork: Colin M.L. Burnett's set, as drawn by react-chessboard in the app.</p>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><summary type="html"><![CDATA[How web-chess runs Stockfish 18 across a pool of Web Workers, turns raw centipawn scores into human move grades, and weakens its beginner opponent without letting it randomly hang its queen.]]></summary></entry><entry><title type="html">Putting KataGo in a browser tab</title><link href="https://sir-teo.github.io/blogs/2026/10/09/putting-katago-in-a-browser-tab.html" rel="alternate" type="text/html" title="Putting KataGo in a browser tab" /><published>2026-10-09T10:00:00-04:00</published><updated>2026-10-09T10:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/2026/10/09/putting-katago-in-a-browser-tab</id><content type="html" xml:base="https://sir-teo.github.io/blogs/2026/10/09/putting-katago-in-a-browser-tab.html"><![CDATA[<p>Reviewing a game of Go with an engine usually starts with system administration: downloading KataGo, matching an OpenCL or CUDA binary to your GPU, and wiring a desktop client like KaTrain or Lizzie to a local process. <strong>web-katrain</strong> puts that entire loop—neural network inference, Monte Carlo tree search, and KaTrain-style game review—inside a single browser tab, with no server behind it. Every number in the interactive figures below comes straight out of its engine code, evaluated with the weights the app bundles.</p>

<h2 id="main-thread-and-worker">Main thread and worker</h2>

<figure class="fig">
<svg class="arch" viewBox="0 0 680 296" role="img" aria-label="Architecture: the main thread holds the React components, game store, analysis queue and engine client; a Web Worker holds the message handler, MCTS search, input features and the TensorFlow.js network; the two exchange messages with postMessage.">
<defs>
<marker id="arch-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head" d="M0,1 L9,5 L0,9 z" /></marker>
<marker id="arch-arrow-strong" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="head-strong" d="M0,1 L9,5 L0,9 z" /></marker>
</defs>
<text class="lbl" x="24" y="13">Main thread</text>
<text class="lbl" x="376" y="13">Web Worker</text>
<rect class="box" x="24" y="26" width="280" height="42" rx="6" />
<text class="t" x="164" y="40">React components</text>
<text class="st" x="164" y="56">board, move tree, graph, panels</text>
<rect class="box" x="24" y="82" width="280" height="42" rx="6" />
<text class="t" x="164" y="96">Game store (Zustand)</text>
<text class="st" x="164" y="112">game tree, settings, analysis per node</text>
<rect class="box" x="24" y="138" width="280" height="42" rx="6" />
<text class="t" x="164" y="152">Analysis queue</text>
<text class="st" x="164" y="168">priority, cancellation, stale check, cache</text>
<rect class="box" x="24" y="194" width="280" height="42" rx="6" />
<text class="t" x="164" y="208">Engine client</text>
<text class="st" x="164" y="224">promises over messages</text>
<rect class="box" x="376" y="26" width="280" height="42" rx="6" />
<text class="t" x="516" y="40">TensorFlow.js network</text>
<text class="st" x="516" y="56">WebGPU, then WASM, then CPU</text>
<rect class="box" x="376" y="82" width="280" height="42" rx="6" />
<text class="t" x="516" y="96">Input features</text>
<text class="st" x="516" y="112">22 board planes and 19 globals</text>
<rect class="box" x="376" y="138" width="280" height="42" rx="6" />
<text class="t" x="516" y="152">MCTS search</text>
<text class="st" x="516" y="168">KataGo-style PUCT, batched leaves</text>
<rect class="box" x="376" y="194" width="280" height="42" rx="6" />
<text class="t" x="516" y="208">Message handler</text>
<text class="st" x="516" y="224">serial request queue, model cache</text>
<path class="edge" d="M164 68 V80" marker-end="url(#arch-arrow)" />
<path class="edge" d="M164 124 V136" marker-end="url(#arch-arrow)" />
<path class="edge" d="M164 180 V192" marker-end="url(#arch-arrow)" />
<path class="edge" d="M516 194 V182" marker-end="url(#arch-arrow)" />
<path class="edge" d="M516 138 V126" marker-end="url(#arch-arrow)" />
<path class="edge" d="M516 82 V70" marker-end="url(#arch-arrow)" />
<path class="edge-strong" d="M306 215 H374" marker-start="url(#arch-arrow-strong)" marker-end="url(#arch-arrow-strong)" />
<text class="el" x="340" y="207" style="font-size:9.5px">postMessage</text>
<rect class="box" x="24" y="252" width="632" height="42" rx="6" />
<text class="t" x="340" y="266">Browser storage only</text>
<text class="st" x="340" y="282">localStorage: settings, auto-save · IndexedDB: game library, uploaded models · Cache Storage: offline app</text>
</svg>
<figcaption>Everything slow lives in the worker. Components only talk to the store, and only the store knows an engine exists.</figcaption>
</figure>

<p>The most important architectural line in the codebase separates the UI from the engine. A 200-visit search can take a couple of seconds; if tensor allocations or tree walks happened on the main thread, stones would stutter under your cursor. Instead, the main thread owns only what you see and touch—the React views and a single Zustand store holding the game tree—while a dedicated Web Worker owns model parsing, feature encoding, neural network inference, and tree search.</p>

<p>Keeping that boundary clean comes down to the analysis queue in the middle. When you scrub rapidly through a game, you do not want forty stale board positions piling up in the worker. The queue orders every request by priority—the position currently on screen comes first, then the AI opponent’s next reply, and finally any background full-game review. As soon as you step to a new move, in-flight requests for the old position are cancelled, and completed evaluations are cached directly on their <code class="language-plaintext highlighter-rouge">GameNode</code> in the tree so revisiting a branch is instant.</p>

<h2 id="which-backend">Which backend</h2>

<figure class="fig" id="wk-env">
<figcaption>The backend web-katrain would pick in this browser, from the same feature tests the app runs.</figcaption>
</figure>

<p>A browser cannot execute KataGo’s native binary directly, so web-katrain reads KataGo’s raw <code class="language-plaintext highlighter-rouge">.bin</code> weight files (format versions 8 through 16), reconstructs the residual network out of TensorFlow.js operations, and feeds it the exact same inputs: 22 spatial planes over the board (stones, liberties, ko bans, ladders, and the last five moves) plus 19 global scalars for komi and rules.</p>

<p>At startup, the app probes your browser to pick the fastest execution path: WebGPU first, then WebAssembly, and finally plain JavaScript on the CPU. Every fallback exists because browsers fail in quiet, specific ways. Multithreaded WASM requires <code class="language-plaintext highlighter-rouge">SharedArrayBuffer</code>, which browsers only expose when a host sends cross-origin isolation headers—headers that GitHub Pages cannot attach on its own, forcing WASM onto a single thread there. WebGPU needs no special headers and handles most modern desktops, but its shader warm-up can still fail on quirky drivers, and browser storage throws in private windows. Each failure degrades to a slower path rather than breaking the app.</p>

<h2 id="checking-against-katago">Checking against KataGo</h2>

<figure class="fig" id="wk-golden" style="--reserve: 330px" data-src="/blogs/assets/data/web-katrain-demo.json">
<figcaption>KataGo's golden test position (5×5, White to play, komi 7.5), read by the KataGo binary and by web-katrain with the same network. The board opens on the difference between the two.</figcaption>
</figure>

<p>The bugs to fear in a neural-network port never crash the page. A transposed feature plane, a flipped sign on komi, or an off-by-one liberty count simply makes the engine play slightly worse Go while every number on screen still looks plausible.</p>

<p>To catch silent drift, web-katrain tests itself against KataGo’s own golden fixture, which records what the native C++ binary outputs on a 5×5 position using <code class="language-plaintext highlighter-rouge">g170-b6c96</code>, the compact 3.6 MB network bundled with the app. Pushing that exact position through web-katrain’s binary parser, feature encoder, TensorFlow.js graph, and post-processing produces an almost identical reading: across the board, <a class="act" href="#wk-golden" data-layer="ownership" data-source="gap">only four ownership values</a> differ, each by a single permille (<code class="language-plaintext highlighter-rouge">0.001</code>), while <a class="act" href="#wk-golden" data-layer="policy" data-source="gap">the policy priors</a> match to the printed digit.</p>

<p>Because a single fixture only pins down one board, a companion test suite checks invariants that must hold on <em>any</em> position: swapping Black and White stones while negating komi must yield a bit-identical input tensor; raising komi must monotonically lower Black’s lead; and under area scoring, summing the ownership map across all intersections must reconcile with the value head’s reported score lead. Even board symmetry needed care: KataGo normally evaluates random rotations and reflections to decorrelate errors deep in the search tree, but at the root node that randomness caused the territory overlay to flicker by up to a quarter point from run to run, so root evaluations now lock to a canonical orientation.</p>

<h2 id="where-the-next-playout-goes">Where the next playout goes</h2>

<figure class="fig" id="wk-puct" style="--reserve: 400px" data-src="/blogs/assets/data/web-katrain-demo.json">
<figcaption>KataGo's selection rule on the six candidates after move 20 of the game below, with their real network priors. The blue ticks are the full 200-visit search.</figcaption>
</figure>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">explore</span> <span class="o">=</span> <span class="p">(</span><span class="nx">scaling</span> <span class="o">*</span> <span class="nx">prior</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="mf">1.0</span> <span class="o">+</span> <span class="nx">childWeight</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">score</span> <span class="o">=</span> <span class="nx">explore</span> <span class="o">+</span> <span class="nx">sign</span> <span class="o">*</span> <span class="nx">childUtility</span><span class="p">;</span> <span class="c1">// the best score gets the playout</span>
</code></pre></div></div>

<p>A single network evaluation gives only a first impression: a <em>policy</em> prior (where a strong player would look first) and a <em>value</em> estimate (who appears to be ahead). Search turns that intuition into calculation by spending <em>playouts</em>, walking down the tree one evaluation at a time. At every node, KataGo’s PUCT formula adds an exploration bonus—scaled by the network’s prior and divided by the weight already spent on that child—to the move’s observed utility.</p>

<p>You can watch that balance shift after move 20 of the self-play game below. <a class="act" href="#wk-puct" data-c="1" data-playouts="100">At the default constant</a>, the network’s initial favourite, F6, gets the first playout, comes back worse than its prior promised, and quickly cedes the search to H4, which takes 89 of the first 100 playouts. <a class="act" href="#wk-puct" data-c="0.1" data-playouts="100">With less exploration</a>, the search locks onto the early leader even harder; <a class="act" href="#wk-puct" data-c="4" data-playouts="100">with more</a>, the prior keeps pulling visits back toward F6, which climbs to 26. Around this core rule, the port brings over the rest of KataGo’s search details: first-play urgency for unvisited moves, lower-confidence-bound move selection, root symmetry deduplication, subtree value-bias correction, and uncertainty-weighted visits.</p>

<h2 id="reviewing-a-game">Reviewing a game</h2>

<figure class="fig wide" id="wk-review" style="--reserve: 640px" data-src="/blogs/assets/data/web-katrain-demo.json">
<figcaption>A 9×9 game the bundled network played against itself, every position searched at 200 visits. It opens on move 37 with the swing overlay: ownership after the move minus ownership before, for changes of a quarter point or more.</figcaption>
</figure>

<p>Once every position in a game has been searched, each move is graded by the points of score lead it gave up, following KaTrain’s six classes from <span class="swatch" style="--c: rgb(114, 33, 107)"></span>blunder (12 or more points lost) down to <span class="swatch" style="--c: rgb(30, 150, 0)"></span>best (under 0.5). To give the review above genuine mistakes to catch, the self-play generator occasionally planted a lower-ranked candidate instead of the search’s top choice; the figure labels those slips on the graph.</p>

<p>A candidate table can tell you <em>that</em> a move lost fourteen points, but not <em>where</em> on the board those points went. That is what the swing overlay solves. Because the network predicts ownership for every intersection, subtracting the ownership map before a move from the map after it highlights the exact territory that changed hands. On <a class="act" href="#wk-review" data-move="37" data-overlay="swing">Move 37</a>, Black’s planted slip at J3 drops 14 points: the swing map immediately shows 15 points in the top right passing to White while 12 points on the left solidify for Black.</p>

<p>Two small constraints make that overlay readable. Subtracting two whole-board estimates leaves a faint haze of tiny deltas across three-quarters of the board, so only intersections that shifted by at least a quarter point (<code class="language-plaintext highlighter-rouge">0.25</code>) are painted. And the overlay deliberately prints no point total of its own: ownership and score lead come from different heads of the network, and on this small bundled net their changes disagree by 8 to 25 points. The score readout tells you <em>how much</em> a move cost; the swing map shows you <em>where</em>.</p>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><summary type="html"><![CDATA[How web-katrain runs KataGo's neural network and Monte Carlo tree search inside a browser tab, verified against the native C++ engine and explored through interactive figures built from its own code.]]></summary></entry></feed>