<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Geek Life</title>
        <link>https://alexblog.net</link>
        <description>A literary, self-ironic dev blog</description>
        <lastBuildDate>Thu, 16 Jul 2026 08:44:41 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>Alex Kucherenko</copyright>
        <item>
            <title><![CDATA[Take Three: How This Blog Actually Works]]></title>
            <link>https://alexblog.net/how-this-blog-works/</link>
            <guid isPermaLink="false">https://alexblog.net/how-this-blog-works/</guid>
            <pubDate>Sun, 14 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[My third blog. Maybe fourth — I lost count. Before it came a WordPress that broke itself and a Next.js that never clicked. This one I finally built on my own framework, and here's how it works under the hood.]]></description>
            <content:encoded><![CDATA[<p>The work is done. Let's call this the official relaunch of the blog — the one you're reading right now. It took far longer than I'd have liked. But it proved the thing I needed it to prove: Moku Core actually works. I built Moku Web on top of it, and this blog on top of that, with exactly the API and the attention to detail I like.</p>
<p>Are there bugs? Undeniably. But I'm fine with where they sit. Sometimes you just have to stop and tell yourself: good enough.</p>
<h2>The Two Blogs I Buried</h2>
<p>First there was WordPress. I hated it from start to finish. Bots came at it around the clock trying to break in, though honestly they needn't have bothered: it broke itself just fine. The database went down for reasons I never did pin down. Had it not kept falling over, I'm fairly sure it would have turned into a virus hub: holes on top of holes, patch constantly or pay for it. And all that joy ran me seven dollars a month. Short version: I'm happy it's gone.</p>
<p>Then came a run at Next.js. I didn't write that one myself — I asked my brother and his girlfriend. A school project for them, a blog for me. The result was meh. I just didn't like how Next does routing, or how big and ungainly the whole thing is. <a href="https://github.com/AlexTiTanium/geek-life">The repo's still around</a>, gathering dust since late 2023.</p>
<p>And yet the direction was exactly right: static generation, articles in Markdown, components in React. MDX, if we're being precise. It was just miserable to actually build. Server components were their own saga; the thing still dragged all of React onto the client, and a heap of code with it. But that was the moment I first saw my own architecture clearly. The idea of the ideal blog that eventually turned into this one.</p>
<h2>First the Engine, Then the Blog</h2>
<p>I've <a href="https://alexblog.net/birth-of-moku-core/">written about Moku Core</a> already: a month spent not over code but over the kernel's spec. That post ended on a glum note — no second layer yet, no third, all of it pure theory, and me just hoping they'd show up.</p>
<p>They showed up. Moku Web is the second layer, the framework. The blog is the third, the app itself. The three-storey tower from that post is fully built now, and you're reading its top floor.</p>
<p>The entire blog is a single <code>createApp</code> call. No globals stashed in corners: you register plugins, hand them config, and that's the end of it.</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// src/app.ts — the whole blog is one createApp() call.</span></span>
<span class="line"><span style="color:#F97316">const</span><span style="color:#D4C8B8"> app </span><span style="color:#F97316">=</span><span style="color:#FDE68A"> createApp</span><span style="color:#968B80">({</span></span>
<span class="line"><span style="color:#D4C8B8">  plugins</span><span style="color:#968B80">:</span><span style="color:#968B80"> [</span><span style="color:#D4C8B8">contentPlugin</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> buildPlugin</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> deployPlugin</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> dataPlugin</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> cliPlugin</span><span style="color:#968B80">],</span></span>
<span class="line"><span style="color:#D4C8B8">  config</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> mode</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">hybrid</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> stage</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">production</span><span style="color:#968B80">"</span><span style="color:#968B80"> },</span></span>
<span class="line"><span style="color:#D4C8B8">  pluginConfigs</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">    site</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> SITE</span><span style="color:#968B80">,</span><span style="color:#F59E0B;font-style:italic">            // name, url, author — one source of truth</span></span>
<span class="line"><span style="color:#D4C8B8">    i18n</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> i18nConfig</span><span style="color:#968B80">,</span><span style="color:#F59E0B;font-style:italic">      // en · uk · ru · es</span></span>
<span class="line"><span style="color:#D4C8B8">    content</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">      providers</span><span style="color:#968B80">:</span><span style="color:#968B80"> [</span></span>
<span class="line"><span style="color:#FDE68A">        fileSystemContent</span><span style="color:#968B80">({</span></span>
<span class="line"><span style="color:#D4C8B8">          contentDir</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">./content</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span></span>
<span class="line"><span style="color:#D4C8B8">          shikiTheme</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> warmSyntaxTheme</span><span style="color:#968B80">,</span><span style="color:#F59E0B;font-style:italic">                  // code blocks</span></span>
<span class="line"><span style="color:#D4C8B8">          mermaid</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> mermaidConfig</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> warmMermaidTheme </span><span style="color:#968B80">},</span><span style="color:#F59E0B;font-style:italic"> // ```mermaid fences</span></span>
<span class="line"><span style="color:#D4C8B8">          embed</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> facade</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> EmbedFacade </span><span style="color:#968B80">},</span><span style="color:#F59E0B;font-style:italic">               // ::embed{...}</span></span>
<span class="line"><span style="color:#D4C8B8">          gallery</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> component</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> Gallery </span><span style="color:#968B80">}</span><span style="color:#F59E0B;font-style:italic">               // ::gallery{...}</span></span>
<span class="line"><span style="color:#968B80">        })</span></span>
<span class="line"><span style="color:#968B80">      ]</span></span>
<span class="line"><span style="color:#968B80">    },</span></span>
<span class="line"><span style="color:#D4C8B8">    router</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> routes </span><span style="color:#968B80">},</span></span>
<span class="line"><span style="color:#D4C8B8">    deploy</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> target</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">cloudflare-pages</span><span style="color:#968B80">"</span><span style="color:#968B80"> }</span></span>
<span class="line"><span style="color:#968B80">  }</span></span>
<span class="line"><span style="color:#968B80">});</span></span></code></pre>
<p>One file, and you can see what the blog is made of.</p>
<h2>Where Everything Lives</h2>
<p>The rest of the project is just as flat. No magic folders, no hidden wiring — you open a directory and it does what it says on the door:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span>blog/</span></span>
<span class="line"><span>  src/</span></span>
<span class="line"><span>    app.ts        # Node-side composition — the createApp() from above</span></span>
<span class="line"><span>    spa.tsx       # browser entry — same routes, none of the Node plugins</span></span>
<span class="line"><span>    routes.tsx    # THE route table — one source of truth (next section)</span></span>
<span class="line"><span>    config.ts     # SITE identity: name, url, author</span></span>
<span class="line"><span>    i18n/         # en · uk · ru · es — UI strings + locale config</span></span>
<span class="line"><span>    pages/        # one Preact page per route (home, article, archive…)</span></span>
<span class="line"><span>    components/   # SSR'd UI: nav, gallery, the embed facade</span></span>
<span class="line"><span>    islands/      # the tiny client scripts, hydrated on demand</span></span>
<span class="line"><span>    lib/          # pure helpers: articles, head, urls, dates</span></span>
<span class="line"><span>    styles/       # CSS: tokens, fonts, article typography</span></span>
<span class="line"><span>    og/           # build-time OG-image cards (Satori)</span></span>
<span class="line"><span>  content/        # the articles — one folder per post (see above)</span></span>
<span class="line"><span>  public/         # served as-is: fonts, _headers, favicons</span></span>
<span class="line"><span>  scripts/        # one-liners: build / serve / preview / deploy</span></span>
<span class="line"><span>  tests/          # unit · integration · e2e (the paranoia, below)</span></span></code></pre>
<p>Two files carry the weight: <code>app.ts</code>, the one you just saw, and <code>routes.tsx</code> — which is the part I actually care about.</p>
<h2>The Router Is the Heart of It</h2>
<p>If the blog has a heart, it's <code>routes.tsx</code>. One table, and three different things read from it: the static build (which pages to generate), the in-browser navigation (what to render on a click), and the link builder (every <code>href</code> on the site). Define a page once, in one place, and all three agree forever.</p>
<p>A route is a little chain of declarations:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// src/routes.tsx — define a page once; the build, the SPA, and every link obey it.</span></span>
<span class="line"><span style="color:#D4C8B8">article</span><span style="color:#968B80">:</span><span style="color:#FDE68A"> route</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">/{lang:?}/{slug}/</span><span style="color:#968B80">"</span><span style="color:#968B80">)</span><span style="color:#F59E0B;font-style:italic">          // {lang:?}: bare "/slug/" for en, "/ru/slug/" for the rest</span></span>
<span class="line"><span style="color:#968B80">  .</span><span style="color:#FDE68A">generate</span><span style="color:#968B80">(</span><span style="color:#F97316">async</span><span style="color:#D4C8B8"> ctx </span><span style="color:#F97316">=></span><span style="color:#F59E0B;font-style:italic">                       // which static pages to emit at build time</span></span>
<span class="line"><span style="color:#968B80">    (</span><span style="color:#F97316">await</span><span style="color:#FDE68A"> allArticles</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">ctx</span><span style="color:#968B80">)).</span><span style="color:#FDE68A">map</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">a </span><span style="color:#F97316">=></span><span style="color:#968B80"> ({</span><span style="color:#D4C8B8"> lang</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> ctx</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">locale</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> slug</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> a</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">computed</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">slug </span><span style="color:#968B80">}))</span></span>
<span class="line"><span style="color:#968B80">  )</span></span>
<span class="line"><span style="color:#968B80">  .</span><span style="color:#FDE68A">load</span><span style="color:#968B80">(</span><span style="color:#F97316">async</span><span style="color:#D4C8B8"> ctx </span><span style="color:#F97316">=></span><span style="color:#968B80"> {</span><span style="color:#F59E0B;font-style:italic">                         // fetch the data — runs at build, persisted as JSON</span></span>
<span class="line"><span style="color:#F97316">    const</span><span style="color:#D4C8B8"> article </span><span style="color:#F97316">=</span><span style="color:#F97316"> await</span><span style="color:#FDE68A"> articleBySlug</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">ctx</span><span style="color:#968B80">);</span></span>
<span class="line"><span style="color:#F97316">    const</span><span style="color:#D4C8B8"> all </span><span style="color:#F97316">=</span><span style="color:#F97316"> await</span><span style="color:#FDE68A"> allArticles</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">ctx</span><span style="color:#968B80">);</span></span>
<span class="line"><span style="color:#F97316">    return</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> article</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> related</span><span style="color:#968B80">:</span><span style="color:#FDE68A"> relatedArticles</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">all</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> article</span><span style="color:#968B80">,</span><span style="color:#FDA4AF"> 5</span><span style="color:#968B80">)</span><span style="color:#968B80"> };</span></span>
<span class="line"><span style="color:#968B80">  })</span></span>
<span class="line"><span style="color:#968B80">  .</span><span style="color:#FDE68A">render</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">ctx </span><span style="color:#F97316">=></span><span style="color:#968B80"> &#x3C;</span><span style="color:#FDBA74">ArticlePage</span><span style="color:#FDBA74"> article</span><span style="color:#D4C8B8">=</span><span style="color:#968B80">{</span><span style="color:#FDBA74">ctx</span><span style="color:#968B80">.</span><span style="color:#FDBA74">data</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">article</span><span style="color:#968B80">}</span><span style="color:#FDBA74"> related</span><span style="color:#D4C8B8">=</span><span style="color:#968B80">{</span><span style="color:#FDBA74">ctx</span><span style="color:#968B80">.</span><span style="color:#FDBA74">data</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">related</span><span style="color:#968B80">}</span><span style="color:#D4C8B8"> /</span><span style="color:#968B80">>)</span></span>
<span class="line"><span style="color:#968B80">  .</span><span style="color:#FDE68A">head</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">ctx </span><span style="color:#F97316">=></span><span style="color:#FDE68A"> articleHead</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">ctx</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> ctx</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">data</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">article</span><span style="color:#968B80">))</span><span style="color:#F59E0B;font-style:italic">   // &#x3C;title>, OG, canonical, hreflang</span></span></code></pre>
<p><code>.generate</code> says which pages to stamp out — one per article, per locale. <code>.load</code> fetches the data. <code>.render</code> is the Preact component. <code>.head</code> is the SEO. That's the whole contract.</p>
<p>Here's the trick that turns it into a single-page app without me writing any of the plumbing. At build time <code>.load</code> runs, and its result is baked into the HTML <em>and</em> dropped next door as <code>_data/&#x3C;lang>/&#x3C;slug>/index.json</code>. Click a link and the browser doesn't reload — it fetches that little JSON file and runs the same <code>.render</code>. One piece of code, two moments: the build on my machine, the click in your tab.</p>
<p>And because the links come out of the same table, they can't rot:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// links come from the SAME table — a typed builder, so a link can't drift from a route:</span></span>
<span class="line"><span style="color:#D4C8B8">urls</span><span style="color:#968B80">.</span><span style="color:#FDE68A">toUrl</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">article</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> lang</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">en</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> slug</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">spark</span><span style="color:#968B80">"</span><span style="color:#968B80"> });</span><span style="color:#F59E0B;font-style:italic"> // "/spark/"     — en is bare</span></span>
<span class="line"><span style="color:#D4C8B8">urls</span><span style="color:#968B80">.</span><span style="color:#FDE68A">toUrl</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">article</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> lang</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">ru</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> slug</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">spark</span><span style="color:#968B80">"</span><span style="color:#968B80"> });</span><span style="color:#F59E0B;font-style:italic"> // "/ru/spark/"</span></span></code></pre>
<p>I never type a URL by hand. I ask the table for one, and the day I rename a route, every link moves with it. That's why the router sits in the middle and everything else hangs off it.</p>
<h2>A Post Is Just a Folder</h2>
<p>To write a post I don't open an admin panel (hi, WordPress). I make a <code>content/&#x3C;slug>/</code> folder and drop one file per language into it:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span>content/how-this-blog-works/</span></span>
<span class="line"><span>  en.md</span></span>
<span class="line"><span>  uk.md</span></span>
<span class="line"><span>  ru.md</span></span>
<span class="line"><span>  es.md</span></span>
<span class="line"><span>  images/   # images, if you need them</span></span></code></pre>
<p>Up top, each file has frontmatter — plain YAML:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#D4C8B8">---</span></span>
<span class="line"><span style="color:#F97316">title</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">Take Three: How This Blog Actually Works</span><span style="color:#968B80">"</span></span>
<span class="line"><span style="color:#F97316">date</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">2026-06-14</span><span style="color:#968B80">"</span></span>
<span class="line"><span style="color:#F97316">description</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">One hook of a sentence — it doubles as the OG card and the archive teaser.</span><span style="color:#968B80">"</span></span>
<span class="line"><span style="color:#F97316">tags</span><span style="color:#968B80">:</span></span>
<span class="line"><span style="color:#968B80">  -</span><span style="color:#84CC16"> moku</span></span>
<span class="line"><span style="color:#968B80">  -</span><span style="color:#84CC16"> programming</span></span>
<span class="line"><span style="color:#F97316">language</span><span style="color:#968B80">:</span><span style="color:#84CC16"> en</span></span>
<span class="line"><span style="color:#F97316">draft</span><span style="color:#968B80">:</span><span style="color:#FDA4AF"> false</span></span>
<span class="line"><span style="color:#D4C8B8">---</span></span></code></pre>
<p><code>draft: false</code> and the post ships to production. Set it to <code>true</code> and it's visible only locally, while you're still writing. No Publish button, no database. Git is my CMS.</p>
<h2>The Parts I Actually Wanted</h2>
<p>This is the part I built the thing for. You like technical details? I do.</p>
<p><strong>Syntax highlighting.</strong> Every code block in this post is coloured by <a href="https://shiki.style">Shiki</a>, at build time, not in the browser. The theme is warm, tuned to the blog; the actual token colours live in inline styles.</p>
<p><strong>Diagrams.</strong> Drop a <code>```mermaid</code> block and the build turns it into a static SVG. Right below this paragraph is a live one, the very feature I'm describing. It's the path text takes before it becomes a website:</p>
<figure class="mermaid-diagram"><svg id="mermaid-0" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 1275.671875px;" viewBox="0 0 1275.671875 103.5999984741211" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#mermaid-0{font-family:"Fira Code","IBM Plex Mono",monospace;font-size:13px;fill:#d4c8b8;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-0 .error-icon{fill:#1c1917;}#mermaid-0 .error-text{fill:#d4c8b8;stroke:#d4c8b8;}#mermaid-0 .edge-thickness-normal{stroke-width:1px;}#mermaid-0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-0 .marker{fill:#968b80;stroke:#968b80;}#mermaid-0 .marker.cross{stroke:#968b80;}#mermaid-0 svg{font-family:"Fira Code","IBM Plex Mono",monospace;font-size:13px;}#mermaid-0 p{margin:0;}#mermaid-0 .label{font-family:"Fira Code","IBM Plex Mono",monospace;color:#d4c8b8;}#mermaid-0 .cluster-label text{fill:#d4c8b8;}#mermaid-0 .cluster-label span{color:#d4c8b8;}#mermaid-0 .cluster-label span p{background-color:transparent;}#mermaid-0 .label text,#mermaid-0 span{fill:#d4c8b8;color:#d4c8b8;}#mermaid-0 .node rect,#mermaid-0 .node circle,#mermaid-0 .node ellipse,#mermaid-0 .node polygon,#mermaid-0 .node path{fill:#231f1b;stroke:#3d3530;stroke-width:1px;}#mermaid-0 .rough-node .label text,#mermaid-0 .node .label text,#mermaid-0 .image-shape .label,#mermaid-0 .icon-shape .label{text-anchor:middle;}#mermaid-0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-0 .rough-node .label,#mermaid-0 .node .label,#mermaid-0 .image-shape .label,#mermaid-0 .icon-shape .label{text-align:center;}#mermaid-0 .node.clickable{cursor:pointer;}#mermaid-0 .root .anchor path{fill:#968b80!important;stroke-width:0;stroke:#968b80;}#mermaid-0 .arrowheadPath{fill:#e7ebed;}#mermaid-0 .edgePath .path{stroke:#968b80;stroke-width:1px;}#mermaid-0 .flowchart-link{stroke:#968b80;fill:none;}#mermaid-0 .edgeLabel{background-color:#292420;text-align:center;}#mermaid-0 .edgeLabel p{background-color:#292420;}#mermaid-0 .edgeLabel rect{opacity:0.5;background-color:#292420;fill:#292420;}#mermaid-0 .labelBkg{background-color:rgba(41, 36, 32, 0.5);}#mermaid-0 .cluster rect{fill:#1c1917;stroke:#3d3530;stroke-width:1px;}#mermaid-0 .cluster text{fill:#d4c8b8;}#mermaid-0 .cluster span{color:#d4c8b8;}#mermaid-0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"Fira Code","IBM Plex Mono",monospace;font-size:12px;background:#1c1917;border:1px solid #3d3530;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#d4c8b8;}#mermaid-0 rect.text{fill:none;stroke-width:0;}#mermaid-0 .icon-shape,#mermaid-0 .image-shape{background-color:#292420;text-align:center;}#mermaid-0 .icon-shape p,#mermaid-0 .image-shape p{background-color:#292420;padding:2px;}#mermaid-0 .icon-shape .label rect,#mermaid-0 .image-shape .label rect{opacity:0.5;background-color:#292420;fill:#292420;}#mermaid-0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-0 .node .neo-node{stroke:#3d3530;}#mermaid-0 [data-look="neo"].node rect,#mermaid-0 [data-look="neo"].cluster rect,#mermaid-0 [data-look="neo"].node polygon{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node path{stroke:url(#mermaid-0-gradient);stroke-width:1px;}#mermaid-0 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node .neo-line path{stroke:#3d3530;filter:none;}#mermaid-0 [data-look="neo"].node circle{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node circle .state-start{fill:#000000;}#mermaid-0 [data-look="neo"].icon-shape .icon{fill:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 :root{--mermaid-font-family:arial,sans-serif;}#mermaid-0 .src rect{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .src polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .src ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .src circle{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .src path{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .src tspan{fill:rgb(253, 186, 116)!important;}#mermaid-0 .mid rect{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .mid polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .mid ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .mid circle{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .mid path{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .mid tspan{fill:rgb(253, 230, 138)!important;}#mermaid-0 .ship rect{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .ship polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .ship ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .ship circle{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .ship path{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .ship tspan{fill:rgb(217, 249, 157)!important;}</style><g><marker id="mermaid-0_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></polygon></marker><marker id="mermaid-0_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"></path></marker><marker id="mermaid-0_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"><path d="M228.656,51.8L241.193,51.8C253.729,51.8,278.802,51.8,303.208,51.8C327.615,51.8,351.354,51.8,363.224,51.8L375.094,51.8" id="mermaid-0-L_md_build_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_md_build_0" data-points="W3sieCI6MjI4LjY1NjI1LCJ5Ijo1MS43OTk5OTkyMzcwNjA1NX0seyJ4IjozMDMuODc1LCJ5Ijo1MS43OTk5OTkyMzcwNjA1NX0seyJ4IjozNzkuMDkzNzUsInkiOjUxLjc5OTk5OTIzNzA2MDU1fV0=" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M662.172,51.8L665.922,51.8C669.672,51.8,677.172,51.8,684.005,51.8C690.839,51.8,697.005,51.8,700.089,51.8L703.172,51.8" id="mermaid-0-L_build_out_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_build_out_0" data-points="W3sieCI6NjYyLjE3MTg3NSwieSI6NTEuNzk5OTk5MjM3MDYwNTV9LHsieCI6Njg0LjY3MTg3NSwieSI6NTEuNzk5OTk5MjM3MDYwNTV9LHsieCI6NzA3LjE3MTg3NSwieSI6NTEuNzk5OTk5MjM3MDYwNTV9XQ==" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M943.422,51.8L952.707,51.8C961.992,51.8,980.563,51.8,998.466,51.8C1016.37,51.8,1033.607,51.8,1042.225,51.8L1050.844,51.8" id="mermaid-0-L_out_cf_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_out_cf_0" data-points="W3sieCI6OTQzLjQyMTg3NSwieSI6NTEuNzk5OTk5MjM3MDYwNTV9LHsieCI6OTk5LjEzMjgxMjUsInkiOjUxLjc5OTk5OTIzNzA2MDU1fSx7IngiOjEwNTQuODQzNzUsInkiOjUxLjc5OTk5OTIzNzA2MDU1fV0=" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(303.875, 51.79999923706055)"><g class="label" data-id="L_md_build_0" transform="translate(0, -8.5)"><g><rect class="background" style="" x="-52.71875" y="-1" width="105.4375" height="19"></rect><text y="-10.1" text-anchor="middle" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">bun</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> run</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> build</tspan></tspan></text></g></g></g><g class="edgeLabel"><g class="label" data-id="L_build_out_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel" transform="translate(999.1328125, 51.79999923706055)"><g class="label" data-id="L_out_cf_0" transform="translate(0, -8.5)"><g><rect class="background" style="" x="-33.2109375" y="-1" width="66.421875" height="19"></rect><text y="-10.1" text-anchor="middle" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">git</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> push</tspan></tspan></text></g></g></g></g><g class="nodes"><g class="node default src" id="mermaid-0-flowchart-md-0" data-look="classic" transform="translate(118.328125, 51.79999923706055)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important" x="-110.328125" y="-36.64999961853027" width="220.65625" height="73.29999923706055"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -14.649999618530273)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">content/*.md</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">en</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> uk</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ru</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> es</tspan></tspan></text></g></g></g><g class="node default mid" id="mermaid-0-flowchart-build-1" data-look="classic" transform="translate(520.6328125, 51.79999923706055)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important" x="-141.5390625" y="-43.79999923706055" width="283.078125" height="87.5999984741211"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -21.799999237060547)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">@moku-labs/web</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Shiki</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Mermaid</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ::embed</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ::gallery</tspan></tspan></text></g></g></g><g class="node default mid" id="mermaid-0-flowchart-out-2" data-look="classic" transform="translate(825.296875, 51.79999923706055)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important" x="-118.125" y="-36.64999961853027" width="236.25" height="73.29999923706055"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -14.649999618530273)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">dist/</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> static</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> HTML</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">+</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> tiny</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> islands</tspan></tspan></text></g></g></g><g class="node default ship" id="mermaid-0-flowchart-cf-3" data-look="classic" transform="translate(1161.2578125, 51.79999923706055)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#84cc16 !important" x="-106.4140625" y="-36.64999961853027" width="212.828125" height="73.29999923706055"></rect><g class="label" style="color:#d9f99d !important" transform="translate(0, -14.649999618530273)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#d9f99d !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Cloudflare</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Pages</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">$0</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> /</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> month</tspan></tspan></text></g></g></g></g></g></g><defs><filter id="mermaid-0-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><defs><filter id="mermaid-0-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><linearGradient id="mermaid-0-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3d3530" stop-opacity="1"></stop><stop offset="100%" stop-color="#3d3530" stop-opacity="1"></stop></linearGradient></svg></figure>
<p>I draw the same diagrams in the posts on <a href="https://alexblog.net/birth-of-moku-core/">Moku Core</a> and <a href="https://alexblog.net/spark/">Spark</a>.</p>
<p><strong>Galleries.</strong> <code>::gallery{src="./images/board/"}</code> turns a folder of images into a gallery with a lightbox. That's how I showed off board-game boxes in <a href="https://alexblog.net/best-board-games/">the best board games</a> and in <a href="https://alexblog.net/descent-journeys-in-the-dark/">Descent</a>.</p>
<p><strong>Embeds.</strong> <code>::embed{src="…" title="…"}</code> is a lazy iframe: nothing loads until you click. In the <a href="https://alexblog.net/screw-master/">Screw Master</a> post you can play the game I made right inside the article.</p>
<p>All of these are directives — a single line of Markdown each.</p>
<h2>Four Kinds of Paranoia</h2>
<p>I don't trust myself, and I trust the AI that wrote half of this even less. So there are several layers of tests:</p>
<ul>
<li><strong>Unit tests</strong> for pure logic: pagination, date formatting, link building. Fast, no browser.</li>
<li><strong>Integration tests</strong> build the real content, every article in full, and check that nothing fell off. Including the rendering of those mermaid diagrams.</li>
<li><strong>E2E on <a href="https://playwright.dev">Playwright</a></strong> run the live, built site across three engines: Chromium, WebKit, and Firefox. Because Safari has broken on me on its own before, and I've learned.</li>
<li><strong>Visual baselines</strong>: golden screenshots of the home page, the archive, an article. If code shifts the layout, the screenshot won't match and the test fails.</li>
</ul>
<p>One subtlety: e2e runs against a frozen set of fixtures, not the live articles. So I can publish whatever I want to <code>content/</code> and the tests don't flinch. A new post needs zero changes to the tests.</p>
<h2>Hosting for the Price of Nothing</h2>
<p>And now the money. Remember the seven dollars a month for WordPress? Here it's zero.</p>
<p>The blog is just a pile of static files. I run <code>git push</code>, CI runs the lint and the tests, and if everything's green it ships itself to <a href="https://pages.cloudflare.com">Cloudflare Pages</a>. Static files on the free tier cost nothing a month. No server to patch, and update, and bring the database back up every week. The page arrives as finished HTML; the interactivity comes in as tiny islands (small JS scripts that hook onto the content, or just run on the client).</p>
<p>From seven dollars a month to nothing. I didn't get rich, but it's nice to save.</p>
<div class="section-divider"><span class="section-divider-ornament">***</span></div>
<p>So that's the beginning. Or the end, depending which way you look at it. The blog I rewrote three times (maybe four — I lost count) finally stands on the foundation I wanted from the start.</p>
<p>And I've got a new toy now, plus one more hobby: <a href="https://alexblog.net/spark/">Spark</a>, a game engine in Rust I've already started writing about. So the story doesn't end. It just moves to a different repository.</p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[Monaco 2026: One Overtake, All the Drama in the World]]></title>
            <link>https://alexblog.net/monaco-2026-drama/</link>
            <guid isPermaLink="false">https://alexblog.net/monaco-2026-drama/</guid>
            <pubDate>Sun, 07 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[One overtake in two hours, six pit-lane penalties, two heroes in the same wall, and a broken car on the podium. Everyone says Monaco is boring. My wife and I had the time of our lives.]]></description>
            <content:encoded><![CDATA[<p>One overtake. In two hours of Monaco, a car passed another car on track exactly once — and that was Arvid Lindblad, surfing a wagon-load of pure luck into the points. One move, a fistful of points by the end, and if the red flag hadn't fallen exactly his way it would have been a far less cheerful afternoon for him. By every metric the purists love, this was a dead race.</p>
<p>It was also the most fun my wife and I have had in front of a screen all season.</p>
<p>Kimi Antonelli won it, by the way — lights to flag, utterly in control. I'd genuinely forgotten he won, and honestly nobody gave a damn — and that tells you everything about where the story actually lived.</p>
<h2>A race with one overtake and a hundred plot twists</h2>
<p>Monaco isn't a race you win. It's a race you survive. And this year the survival rate was comedy.</p>
<h2>Hadjar drove a wreck onto the podium</h2>
<p>Isack Hadjar spent what felt like sixty laps nursing a broken Red Bull around the houses, collecting what felt like every penalty in the world on the way. Then the stewards quietly dropped the one that mattered — the team had started illegal work on his car under the red flag and then thought better of it — and he climbed out of that mess onto his first podium for Red Bull. P3. Not because he was fast, but because everyone ahead of him found a more creative way to lose.</p>
<p>George Russell got the opposite gift. A five-second pit-lane penalty he couldn't even serve properly — because under the late safety car one half of the pit wall thought they had to sit out the five seconds and the other half thought they didn't, so the crew just went ahead and changed the tyres while George sat there asking the radio whether he was even stopping. The FIA was not amused, and the five seconds became a drive-through that folded his afternoon shut like a cheap deck chair. Two drivers, the same circuit, opposite endings. That's the whole sport in one stop.</p>
<h2>Stroll started it, Leclerc had to one-up him</h2>
<p>Lance Stroll planted his Aston in the barriers first — first to get bored, evidently, first to decide he'd seen quite enough and head home early. Then Charles Leclerc, apparently deciding the drama wasn't quite sharp enough, repeated the trick almost note for note at the restart — and binned a podium doing it. The FIA pointed at the track surface breaking up at exactly the spot where both of them lost the car. Both drivers shrugged and blamed their own machinery — engine braking, brakes, take your pick. Nobody agreed on anything, which is the most Monaco outcome imaginable.</p>
<p>And the Ferrari heartbreak underneath it all: Leclerc, at home, throwing away a podium car. Rage? An honest mistake? He came out of it sad and furious in equal measure, and you genuinely couldn't tell which one put him in the wall. Piastri, for the record, dragged his McLaren home a frustrated fourth — a podium that was right there and simply never arrived.</p>
<h2>Six speeding tickets in one afternoon</h2>
<p>And the penalties. Oh, the penalties. Six drivers — <em>six</em> — got done for speeding in the pit lane. Half the grid, near enough, all caught doing the one thing every team has a literal button to prevent.</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span>THE PENALTY BOX · Monaco 2026</span></span>
<span class="line"><span>Gasly       P3 on the road  ->  P7   (two separate 5s pit-lane penalties)</span></span>
<span class="line"><span>Russell     5s pit-lane     ->  drive-through, afternoon over</span></span>
<span class="line"><span>Hulkenberg  10s             ->  for collecting Sainz</span></span>
<span class="line"><span>Perez       false start     ->  P10 stripped, sent to the back</span></span>
<span class="line"><span>... and two more for the road. Six speeding tickets in total.</span></span></code></pre>
<p>So which is it? Did half the paddock set their limiters wrong on the same day, or did something go sideways on the FIA's side of the measurement? They're usually surgical about this stuff. This time something smells off. Gasly is the one I ache for — he crossed the line third for Alpine and got demoted to seventh by two separate five-second penalties. A Monaco podium, gone, over a handful of km/h.</p>
<p>I haven't even named everyone who got burned. Carlos Sainz — forgot him completely, poor guy, just unlucky from lights to flag. Hülkenberg picked up a penalty for tipping Carlos into trouble. The casualty list reads like a phone book.</p>
<h2>The footnotes nobody clapped for</h2>
<p>Nearly forgot the best little story of all: the final point. Fernando Alonso, P10 — Aston Martin's first point and first top-10 of the entire 2026 season. Let that land. Their year is so grim that one point, handed to them by someone else's penalty, counts as a party. They will not get this lucky twice.</p>
<p>Then there's Pérez, in a league of his own — only the league turned out to be the losers' one. How do you line up in the wrong grid box, <em>and then</em> fumble the restart, knowing full well you've already been warned? He was genuinely quick — close to winning the loser's cup outright — and then a false start dropped him to the back and stripped his P10, denying Cadillac their first-ever point. Second place in the championship nobody wants.</p>
<p>But honestly? Cadillac keep getting better and the Astons keep getting worse, so my prediction is they level it out at the bottom. And as much as Alonso charms me, it's no comfort — I reckon he and Stroll finish dead last in this loser's championship, together.</p>
<div class="section-divider"><span class="section-divider-ornament">***</span></div>
<h2>What Monaco actually needs</h2>
<blockquote>
<p>Everyone's begging for more overtakes at Monaco. Wrong fix. We don't want more passing — we want more carnage. And this year Monaco delivered.</p>
</blockquote>
<p>Here's my unpopular take, and my wife co-signs it. People moan that Monaco needs eight overtakes, ten, fifteen, whatever number makes a spreadsheet smile. Others want the V8s screaming back, as if engine noise ever won anyone a race. I could not care less about any of it. Give us drama. Give us a broken car on the podium, two heroes in the same wall, six speeding tickets, and a first point that feels like a trophy.</p>
<p>That is what makes this thing watchable for hamsters like us.</p>
<p>More drama. More spectacle. That's the whole review.</p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[Spark: ECS — It Works but It's Slow (Which Is Fine)]]></title>
            <link>https://alexblog.net/spark-ecs/</link>
            <guid isPermaLink="false">https://alexblog.net/spark-ecs/</guid>
            <pubDate>Sun, 31 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Stage one of Spark's from-scratch Rust ECS is done: a stable API, a naive-but-working sparse set, and change detection that took two tries. Then I benchmarked it against five real engines and came out a few times slower — here's why that's not the disaster it sounds like.]]></description>
            <content:encoded><![CDATA[<p>My last Spark post ended on a question: where would I fold this time. Well — I haven't folded yet. Stage one of the ECS is behind me: there's a stable API and a naive-but-working implementation. The engine still draws nothing on screen, but the heart is already beating.</p>
<p>Quick recap for anyone who skipped <a href="https://alexblog.net/spark/">part one</a>: Spark is my game engine in Rust where everything is built around a single ECS. I steal the system syntax from Bevy, workloads from Shipyard, and the storage I built my own way.</p>
<h2>A sparse set, not archetypes</h2>
<p>The most important architectural decision is the storage, and there are two big camps here. Bevy (and hecs, and flax) use archetypes: entities with the same set of components sit together in dense tables. Shipyard uses a sparse set: every component type gets its own separate storage.</p>
<p>I went with a sparse set, like Shipyard, rather than archetypes, like Bevy. The reason is embarrassingly simple: it's easier. Archetypes are faster on pure traversal, but writing them from scratch is its own genre of pain — entities migrating between tables, fragmentation, all of it. And I cared more about understanding every line than about squeezing out the last microsecond. Besides, the API is designed so I can move to archetypes later without breaking anything on the outside. But more on that at the very end.</p>
<h2>How it sits in memory</h2>
<p>The storage for one component <code>T</code> is three parallel arrays:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span>sparse:        [ Some(0), None, Some(1), None, Some(2) ]</span></span>
<span class="line"><span>                   E0            E2             E4</span></span>
<span class="line"><span>dense:         [ Pos0,          Pos2,          Pos4 ]     &#x3C;- packed back to back</span></span>
<span class="line"><span>entity_index:  [ E0,            E2,            E4   ]</span></span></code></pre>
<p><code>sparse</code> is indexed by the entity's number and tells you where its data lives in <code>dense</code> (or that there's none at all). <code>dense</code> is the components themselves, packed back to back, no holes. <code>entity_index</code> answers the reverse question: whose row is this in <code>dense</code>. Insert, remove, lookup — all O(1). Removal is a swap-remove: plug the hole in <code>dense</code> with the last element and fix a single pointer in <code>sparse</code>. The array stays dense.</p>
<p>And here's the answer to "why is it even fast." When a system walks every <code>Position</code>, it reads <code>dense</code> straight through, byte after byte. The CPU adores this: the prefetcher guesses what's coming next, and the cache lines are stuffed with useful data instead of pointers. Compare that with classic OOP, where you have an array of pointers to objects scattered across the heap — there, every step of the loop is a trip to memory and a cache miss. Data-oriented design basically says: lay your data out the way the processor will read it, not the way that's comfortable for a human. The ECS takes that idea to its logical extreme.</p>
<h2>Workloads: a real example</h2>
<p>A system in Spark is a plain function, and its parameters declare what it reads and writes. A workload is a named batch of systems that belong together:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// A workload is a named batch of systems. Each system's parameter types</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// declare what it reads and writes; the scheduler uses those access sets to</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// decide what may share a parallel batch and what must run in order.</span></span>
<span class="line"><span style="color:#D4C8B8">app</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add_workload</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">Workload</span><span style="color:#F97316">::</span><span style="color:#FDBA74">PowerGrid</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> Stage</span><span style="color:#F97316">::</span><span style="color:#FDBA74">FixedUpdate</span><span style="color:#968B80">,</span><span style="color:#F97316"> |</span><span style="color:#D4C8B8">w</span><span style="color:#F97316">|</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">    // Both write the grid, so they can't share a batch — and an *undeclared*</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">    // order between two writers is a registration error, not a guess.</span></span>
<span class="line"><span style="color:#F97316">    let</span><span style="color:#D4C8B8"> supply </span><span style="color:#F97316">=</span><span style="color:#D4C8B8"> w</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add_system</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">collect_supply</span><span style="color:#968B80">);</span></span>
<span class="line"><span style="color:#F97316">    let</span><span style="color:#D4C8B8"> demand </span><span style="color:#F97316">=</span><span style="color:#D4C8B8"> w</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add_system</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">compute_demand</span><span style="color:#968B80">)</span><span style="color:#F97316">.</span><span style="color:#FDE68A">after</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">supply</span><span style="color:#968B80">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">    // Reads the finished grid, so it runs last.</span></span>
<span class="line"><span style="color:#D4C8B8">    w</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add_system</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">distribute_power</span><span style="color:#968B80">)</span><span style="color:#F97316">.</span><span style="color:#FDE68A">after</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">demand</span><span style="color:#968B80">);</span></span>
<span class="line"><span style="color:#968B80">})</span></span>
<span class="line"><span style="color:#F97316">.</span><span style="color:#FDE68A">after</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">Workload</span><span style="color:#F97316">::</span><span style="color:#FDBA74">Simulation</span><span style="color:#968B80">);</span><span style="color:#F59E0B;font-style:italic"> // whole workloads order by label, same .after / .before</span></span></code></pre>
<p>The scheduler reads the access sets (the parameter types declare them) and works out for itself what conflicts with what. If two systems both write the same <code>PowerNetwork</code> and no order between them is declared, that's a registration error — not "eh, it'll sort itself out." But systems that touch disjoint data, it's free to pack into one batch and run in parallel.</p>
<p>Free to — but not doing it yet. The parallel executor is M4, still ahead of me, and today everything runs sequentially. The access model is already in place, though, and that's deliberate: when I get to Rayon, it'll be swapping a <code>RefCell</code> for an <code>UnsafeCell</code> behind an already-proven-correct scheduler, not a rewrite.</p>
<h2>How the files are laid out</h2>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span>lib/ecs/</span></span>
<span class="line"><span>├─ src/</span></span>
<span class="line"><span>│  ├─ entity.rs       # Entity = (index, generation) + allocator with a free list</span></span>
<span class="line"><span>│  ├─ storage.rs      # ComponentStorage&#x3C;T> — sparse set + the change counter</span></span>
<span class="line"><span>│  ├─ world.rs        # World: HashMap&#x3C;TypeId, Box&#x3C;dyn AnyStorage>></span></span>
<span class="line"><span>│  ├─ query/          # Query&#x3C;D, F>: data, filters, joins, driver selection</span></span>
<span class="line"><span>│  ├─ system/         # SystemParam + IntoSystem — a function becomes a system</span></span>
<span class="line"><span>│  ├─ workload.rs     # workloads: labels, builder, topo-sort</span></span>
<span class="line"><span>│  ├─ scheduler.rs    # runs stages → workloads → systems</span></span>
<span class="line"><span>│  ├─ commands.rs     # deferred spawn / despawn / insert / remove</span></span>
<span class="line"><span>│  ├─ events.rs       # Events&#x3C;T> + Reader / Writer (double-buffered)</span></span>
<span class="line"><span>│  └─ access.rs       # access sets + conflict detection</span></span>
<span class="line"><span>└─ macros/            # #[derive(Component / Resource / Event / WorkloadLabel)]</span></span></code></pre>
<h2>How a frame runs</h2>
<figure class="mermaid-diagram"><svg id="mermaid-0" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 374.578125px;" viewBox="0 0 374.578125 815.2412719726562" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#mermaid-0{font-family:"Fira Code","IBM Plex Mono",monospace;font-size:13px;fill:#d4c8b8;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-0 .error-icon{fill:#1c1917;}#mermaid-0 .error-text{fill:#d4c8b8;stroke:#d4c8b8;}#mermaid-0 .edge-thickness-normal{stroke-width:1px;}#mermaid-0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-0 .marker{fill:#968b80;stroke:#968b80;}#mermaid-0 .marker.cross{stroke:#968b80;}#mermaid-0 svg{font-family:"Fira Code","IBM Plex Mono",monospace;font-size:13px;}#mermaid-0 p{margin:0;}#mermaid-0 .label{font-family:"Fira Code","IBM Plex Mono",monospace;color:#d4c8b8;}#mermaid-0 .cluster-label text{fill:#d4c8b8;}#mermaid-0 .cluster-label span{color:#d4c8b8;}#mermaid-0 .cluster-label span p{background-color:transparent;}#mermaid-0 .label text,#mermaid-0 span{fill:#d4c8b8;color:#d4c8b8;}#mermaid-0 .node rect,#mermaid-0 .node circle,#mermaid-0 .node ellipse,#mermaid-0 .node polygon,#mermaid-0 .node path{fill:#231f1b;stroke:#3d3530;stroke-width:1px;}#mermaid-0 .rough-node .label text,#mermaid-0 .node .label text,#mermaid-0 .image-shape .label,#mermaid-0 .icon-shape .label{text-anchor:middle;}#mermaid-0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-0 .rough-node .label,#mermaid-0 .node .label,#mermaid-0 .image-shape .label,#mermaid-0 .icon-shape .label{text-align:center;}#mermaid-0 .node.clickable{cursor:pointer;}#mermaid-0 .root .anchor path{fill:#968b80!important;stroke-width:0;stroke:#968b80;}#mermaid-0 .arrowheadPath{fill:#e7ebed;}#mermaid-0 .edgePath .path{stroke:#968b80;stroke-width:1px;}#mermaid-0 .flowchart-link{stroke:#968b80;fill:none;}#mermaid-0 .edgeLabel{background-color:#292420;text-align:center;}#mermaid-0 .edgeLabel p{background-color:#292420;}#mermaid-0 .edgeLabel rect{opacity:0.5;background-color:#292420;fill:#292420;}#mermaid-0 .labelBkg{background-color:rgba(41, 36, 32, 0.5);}#mermaid-0 .cluster rect{fill:#1c1917;stroke:#3d3530;stroke-width:1px;}#mermaid-0 .cluster text{fill:#d4c8b8;}#mermaid-0 .cluster span{color:#d4c8b8;}#mermaid-0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"Fira Code","IBM Plex Mono",monospace;font-size:12px;background:#1c1917;border:1px solid #3d3530;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#d4c8b8;}#mermaid-0 rect.text{fill:none;stroke-width:0;}#mermaid-0 .icon-shape,#mermaid-0 .image-shape{background-color:#292420;text-align:center;}#mermaid-0 .icon-shape p,#mermaid-0 .image-shape p{background-color:#292420;padding:2px;}#mermaid-0 .icon-shape .label rect,#mermaid-0 .image-shape .label rect{opacity:0.5;background-color:#292420;fill:#292420;}#mermaid-0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-0 .node .neo-node{stroke:#3d3530;}#mermaid-0 [data-look="neo"].node rect,#mermaid-0 [data-look="neo"].cluster rect,#mermaid-0 [data-look="neo"].node polygon{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node path{stroke:url(#mermaid-0-gradient);stroke-width:1px;}#mermaid-0 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node .neo-line path{stroke:#3d3530;filter:none;}#mermaid-0 [data-look="neo"].node circle{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node circle .state-start{fill:#000000;}#mermaid-0 [data-look="neo"].icon-shape .icon{fill:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 :root{--mermaid-font-family:arial,sans-serif;}#mermaid-0 .step rect{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;stroke-width:1.5px!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .step polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;stroke-width:1.5px!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .step ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;stroke-width:1.5px!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .step circle{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;stroke-width:1.5px!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .step path{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;stroke-width:1.5px!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .step tspan{fill:rgb(253, 230, 138)!important;}#mermaid-0 .store rect{fill:rgb(28, 25, 23)!important;stroke:rgb(132, 204, 22)!important;stroke-width:2px!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .store polygon{fill:rgb(28, 25, 23)!important;stroke:rgb(132, 204, 22)!important;stroke-width:2px!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .store ellipse{fill:rgb(28, 25, 23)!important;stroke:rgb(132, 204, 22)!important;stroke-width:2px!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .store circle{fill:rgb(28, 25, 23)!important;stroke:rgb(132, 204, 22)!important;stroke-width:2px!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .store path{fill:rgb(28, 25, 23)!important;stroke:rgb(132, 204, 22)!important;stroke-width:2px!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .store tspan{fill:rgb(217, 249, 157)!important;}#mermaid-0 .defer rect{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;stroke-width:1.5px!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .defer polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;stroke-width:1.5px!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .defer ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;stroke-width:1.5px!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .defer circle{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;stroke-width:1.5px!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .defer path{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;stroke-width:1.5px!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .defer tspan{fill:rgb(253, 186, 116)!important;}</style><g><marker id="mermaid-0_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></polygon></marker><marker id="mermaid-0_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"></path></marker><marker id="mermaid-0_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"><path d="M154.59,81.3L154.59,85.05C154.59,88.8,154.59,96.3,154.59,103.133C154.59,109.967,154.59,116.133,154.59,119.217L154.59,122.3" id="mermaid-0-L_frame_stage_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_frame_stage_0" data-points="W3sieCI6MTU0LjU4OTg0Mzc1LCJ5Ijo4MS4yOTk5OTU0MjIzNjMyOH0seyJ4IjoxNTQuNTg5ODQzNzUsInkiOjEwMy43OTk5OTU0MjIzNjMyOH0seyJ4IjoxNTQuNTg5ODQzNzUsInkiOjEyNi4yOTk5OTU0MjIzNjMyOH1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M154.59,213.9L154.59,217.65C154.59,221.4,154.59,228.9,154.59,235.733C154.59,242.567,154.59,248.733,154.59,251.817L154.59,254.9" id="mermaid-0-L_stage_wl_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_stage_wl_0" data-points="W3sieCI6MTU0LjU4OTg0Mzc1LCJ5IjoyMTMuODk5OTkzODk2NDg0Mzh9LHsieCI6MTU0LjU4OTg0Mzc1LCJ5IjoyMzYuMzk5OTkzODk2NDg0Mzh9LHsieCI6MTU0LjU4OTg0Mzc1LCJ5IjoyNTguODk5OTkzODk2NDg0NH1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M154.59,346.5L154.59,350.25C154.59,354,154.59,361.5,154.59,368.333C154.59,375.167,154.59,381.333,154.59,384.417L154.59,387.5" id="mermaid-0-L_wl_batch_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_wl_batch_0" data-points="W3sieCI6MTU0LjU4OTg0Mzc1LCJ5IjozNDYuNDk5OTkyMzcwNjA1NDd9LHsieCI6MTU0LjU4OTg0Mzc1LCJ5IjozNjguOTk5OTkyMzcwNjA1NDd9LHsieCI6MTU0LjU4OTg0Mzc1LCJ5IjozOTEuNDk5OTkyMzcwNjA1NDd9XQ==" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M98.095,479.1L91.216,484.433C84.337,489.767,70.579,500.433,63.699,517.208C56.82,533.983,56.82,556.867,56.82,580.942C56.82,605.017,56.82,630.283,63.044,649.393C69.268,668.502,81.715,681.454,87.939,687.931L94.162,694.407" id="mermaid-0-L_batch_world_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_batch_world_0" data-points="W3sieCI6OTguMDk1MDQ5MDYzMTk0MjcsInkiOjQ3OS4wOTk5OTA4NDQ3MjY1Nn0seyJ4Ijo1Ni44MjAzMTI1LCJ5Ijo1MTEuMDk5OTkwODQ0NzI2NTZ9LHsieCI6NTYuODIwMzEyNSwieSI6NTc5Ljc0OTk4ODU1NTkwODJ9LHsieCI6NTYuODIwMzEyNSwieSI6NjU1LjU0OTk4NTg4NTYyMDF9LHsieCI6OTYuOTM0MDI2OTc2MzI0MDYsInkiOjY5Ny4yOTA4MzExMzM4NjQ3fV0=" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M211.085,479.1L217.964,484.433C224.843,489.767,238.601,500.433,245.48,510.433C252.359,520.433,252.359,529.767,252.359,534.433L252.359,539.1" id="mermaid-0-L_batch_cmd_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_batch_cmd_0" data-points="W3sieCI6MjExLjA4NDYzODQzNjgwNTczLCJ5Ijo0NzkuMDk5OTkwODQ0NzI2NTZ9LHsieCI6MjUyLjM1OTM3NSwieSI6NTExLjA5OTk5MDg0NDcyNjU2fSx7IngiOjI1Mi4zNTkzNzUsInkiOjU0My4wOTk5OTA4NDQ3MjY2fV0=" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M252.359,616.4L252.359,622.925C252.359,629.45,252.359,642.5,246.136,655.501C239.912,668.502,227.465,681.454,221.241,687.931L215.017,694.407" id="mermaid-0-L_cmd_world_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_cmd_world_0" data-points="W3sieCI6MjUyLjM1OTM3NSwieSI6NjE2LjM5OTk4NjI2NzA4OTh9LHsieCI6MjUyLjM1OTM3NSwieSI6NjU1LjU0OTk4NTg4NTYyMDF9LHsieCI6MjEyLjI0NTY2MDUyMzY3NTk1LCJ5Ijo2OTcuMjkwODMxMTMzODY0N31d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path></g><g class="edgeLabels"><g class="edgeLabel"><g class="label" data-id="L_frame_stage_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_stage_wl_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_wl_batch_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel" transform="translate(56.8203125, 579.7499885559082)"><g class="label" data-id="L_batch_world_0" transform="translate(0, -8.5)"><g><rect class="background" style="" x="-48.8203125" y="-1" width="97.640625" height="19"></rect><text y="-10.1" text-anchor="middle" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">read</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> /</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> write</tspan></tspan></text></g></g></g><g class="edgeLabel" transform="translate(252.359375, 511.09999084472656)"><g class="label" data-id="L_batch_cmd_0" transform="translate(0, -8.5)"><g><rect class="background" style="" x="-21.5078125" y="-1" width="43.015625" height="19"></rect><text y="-10.1" text-anchor="middle" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">queue</tspan></tspan></text></g></g></g><g class="edgeLabel" transform="translate(252.359375, 655.5499858856201)"><g class="label" data-id="L_cmd_world_0" transform="translate(0, -15.649999618530273)"><g><rect class="background" style="" x="-68.3203125" y="-1" width="136.640625" height="33.29999923706055"></rect><text y="-10.1" text-anchor="middle" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">flush</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> at</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> workload</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">boundary</tspan></tspan></text></g></g></g></g><g class="nodes"><g class="node default step" id="mermaid-0-flowchart-frame-0" data-look="classic" transform="translate(154.58984375, 44.64999771118164)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important;stroke-width:1.5px !important" x="-98.625" y="-36.64999961853027" width="197.25" height="73.29999923706055"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -14.649999618530273)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">App.frame()</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">once</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> per</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> frame</tspan></tspan></text></g></g></g><g class="node default step" id="mermaid-0-flowchart-stage-1" data-look="classic" transform="translate(154.58984375, 170.09999465942383)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important;stroke-width:1.5px !important" x="-125.9375" y="-43.79999923706055" width="251.875" height="87.5999984741211"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -21.799999237060547)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Stages,</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> fixed</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> order</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">First</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Input</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ...</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Render</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Last</tspan></tspan></text></g></g></g><g class="node default step" id="mermaid-0-flowchart-wl-2" data-look="classic" transform="translate(154.58984375, 302.6999931335449)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important;stroke-width:1.5px !important" x="-122.0234375" y="-43.79999923706055" width="244.046875" height="87.5999984741211"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -21.799999237060547)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Workloads</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> in</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> a</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> stage</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">ordered</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> by</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> .after</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> /</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">.before</tspan></tspan></text></g></g></g><g class="node default step" id="mermaid-0-flowchart-batch-3" data-look="classic" transform="translate(154.58984375, 435.299991607666)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important;stroke-width:1.5px !important" x="-122.0234375" y="-43.79999923706055" width="244.046875" height="87.5999984741211"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -21.799999237060547)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">System</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> batches</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">disjoint</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> access</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> runs</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">together</tspan></tspan></text></g></g></g><g class="node default store" id="mermaid-0-flowchart-world-4" data-look="classic" transform="translate(154.58984375, 750.970645904541)"><path d="M0,15.650710545998505 a104.625,15.650710545998505 0,0,0 209.25,0 a104.625,15.650710545998505 0,0,0 -209.25,0 l0,81.2507090201196 a104.625,15.650710545998505 0,0,0 209.25,0 l0,-81.2507090201196" class="basic label-container outer-path" style="fill:#1c1917 !important;stroke:#84cc16 !important;stroke-width:2px !important" transform="translate(-104.625, -56.27606505605831)"></path><g class="label" style="color:#d9f99d !important" transform="translate(0, -8.13333257039388)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#d9f99d !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">World</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">components</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> resources</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">events</tspan></tspan></text></g></g></g><g class="node default defer" id="mermaid-0-flowchart-cmd-5" data-look="classic" transform="translate(252.359375, 579.7499885559082)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important;stroke-width:1.5px !important" x="-114.21875" y="-36.64999961853027" width="228.4375" height="73.29999923706055"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -14.649999618530273)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Commands</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">deferred</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> mutations</tspan></tspan></text></g></g></g></g></g></g><defs><filter id="mermaid-0-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><defs><filter id="mermaid-0-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><linearGradient id="mermaid-0-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3d3530" stop-opacity="1"></stop><stop offset="100%" stop-color="#3d3530" stop-opacity="1"></stop></linearGradient></svg></figure>
<p>Once per frame, <code>App</code> pokes the scheduler. It walks the stages strictly in order; inside each stage, the workloads run in their <code>.after</code> / <code>.before</code> order; inside each workload, the systems run in batches by access. Commands (<code>spawn</code>, <code>despawn</code>, <code>insert</code>) aren't applied right away — they pile up and flush at the workload boundary. Events live in a double buffer and swap once at the top of the frame, so a reader always sees exactly the previous frame, and the order of systems within a frame stops mattering. Boring and predictable, which is what a simulation wants.</p>
<h2>Change detection: on the second attempt</h2>
<p>The piece I'm happiest with is change detection. The <code>Changed&#x3C;T></code> and <code>Added&#x3C;T></code> filters let a system process only the entities whose component changed (or first appeared) since its last run. For a simulation where three of ten thousand entities actually move in a given tick, that's the difference between "recompute everything" and "recompute three."</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// `Query&#x3C;&#x26;mut T>` hands back a `Mut&#x3C;T>`, not a bare `&#x26;mut T`. Taking the</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// mutable borrow *is* the change signal: write through it and this entity's</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// `changed_tick` moves; read through it and nothing is marked.</span></span>
<span class="line"><span style="color:#F97316">fn</span><span style="color:#FDE68A"> fluctuate</span><span style="color:#968B80">(</span><span style="color:#F97316">mut</span><span style="color:#D4C8B8"> q</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> Query</span><span style="color:#968B80">&#x3C;</span><span style="color:#F97316">&#x26;mut</span><span style="color:#FDBA74"> BusVoltage</span><span style="color:#968B80">>)</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#F97316">    for</span><span style="color:#F97316"> mut</span><span style="color:#D4C8B8"> v </span><span style="color:#F97316">in</span><span style="color:#D4C8B8"> q</span><span style="color:#F97316">.</span><span style="color:#FDE68A">iter_mut</span><span style="color:#968B80">()</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">        v</span><span style="color:#F97316">.</span><span style="color:#FDA4AF">0</span><span style="color:#F97316"> =</span><span style="color:#D4C8B8"> v</span><span style="color:#F97316">.</span><span style="color:#FDA4AF">0</span><span style="color:#968B80">.</span><span style="color:#FDE68A">wrapping_add</span><span style="color:#968B80">(</span><span style="color:#FDA4AF">1</span><span style="color:#968B80">);</span><span style="color:#F59E0B;font-style:italic"> // DerefMut here -> this bus is "changed"</span></span>
<span class="line"><span style="color:#968B80">    }</span></span>
<span class="line"><span style="color:#968B80">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// Re-solve only the substations whose voltage actually moved this tick.</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// `Changed&#x3C;BusVoltage>` filters to those; the three-component shape already</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// drops bare buses before the filter even matters.</span></span>
<span class="line"><span style="color:#F97316">fn</span><span style="color:#FDE68A"> grid_solver</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">q</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> Query</span><span style="color:#968B80">&#x3C;(</span><span style="color:#F97316">&#x26;</span><span style="color:#FDBA74">BusVoltage</span><span style="color:#968B80">,</span><span style="color:#F97316"> &#x26;</span><span style="color:#FDBA74">Transformer</span><span style="color:#968B80">,</span><span style="color:#F97316"> &#x26;</span><span style="color:#FDBA74">Feeder</span><span style="color:#968B80">),</span><span style="color:#FDBA74"> Changed</span><span style="color:#968B80">&#x3C;</span><span style="color:#FDBA74">BusVoltage</span><span style="color:#968B80">>>)</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#F97316">    for</span><span style="color:#968B80"> (</span><span style="color:#D4C8B8">_v</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> _t</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> _f</span><span style="color:#968B80">)</span><span style="color:#F97316"> in</span><span style="color:#F97316"> &#x26;</span><span style="color:#D4C8B8">q </span><span style="color:#968B80">{</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">        // ... re-solve this substation</span></span>
<span class="line"><span style="color:#968B80">    }</span></span>
<span class="line"><span style="color:#968B80">}</span></span></code></pre>
<p>The precision rides on a small trick. <code>Query&#x3C;&#x26;mut T></code> hands you a <code>Mut&#x3C;T></code> wrapper, not a bare <code>&#x26;mut T</code>, and the "changed" signal is the very act of taking the mutable borrow through <code>DerefMut</code>. Walk a thousand entities, write to three, and exactly three marks move. Read through <code>Deref</code> only, and nothing moves.</p>
<p>The decision I'm proudest of cost me two implementations. The change counter can be done two ways: one global counter for the whole <code>World</code>, or a counter per component type. The original plan had the global one. The AI and I wrote both and compared them head to head. The per-component counters won — they solve three problems the global model could only paper over with warnings:</p>
<ul>
<li>components attached before any system runs are visible on a system's very first pass (counters start at 1, the reader's baseline at 0);</li>
<li>a tuple-join driver doesn't mark the extra entities that never made it into the join;</li>
<li>entities spawned through <code>Commands</code> reach an <code>Added</code> reaction the next frame.</li>
</ul>
<p>The global version didn't get thrown out — it sits on a branch as a monument. And somewhere along the way it turned out the <code>u32</code> counter will eventually overflow, and the naive "tick greater than baseline" check quietly breaks at the wrap. The cure is comparing relative age with the wrap in mind (<code>current - tick &#x3C; current - baseline</code>). A genre classic: the feature works, and then you spend half a day thinking about what happens four billion ticks from now.</p>
<h2>And how badly we lose</h2>
<p>With stage one wrapped up, I put together a benchmark harness: spark-ecs against five live ECS on one machine. Here's the gist (10k entities, single-threaded, Apple M4 Pro; lower is better):</p>













































<table><thead><tr><th>metric</th><th>spark</th><th>hecs</th><th>bevy</th><th>shipyard</th><th>flax</th></tr></thead><tbody><tr><td>iter, µs (read)</td><td>18.5</td><td>6.5</td><td>6.3</td><td><strong>5.0</strong></td><td>6.1</td></tr><tr><td>iter_mut, µs (write)</td><td>56.4</td><td>19.5</td><td>10.2</td><td>11.0</td><td><strong>10.0</strong></td></tr><tr><td>memory, B / entity</td><td>126</td><td><strong>66</strong></td><td>145</td><td>96</td><td>93</td></tr><tr><td>dependencies, crates</td><td>6</td><td><strong>4</strong></td><td>59</td><td>17</td><td>21</td></tr></tbody></table>
<p>In short: on reads we're about 3–4× slower than the leaders, on writes about five. Sounds like a death sentence. But before I start the sackcloth and ashes, three caveats:</p>
<ol>
<li><strong>This is single-threaded.</strong> Spark has no parallel scheduler yet (that's M4), and Bevy's defining superpower — its parallel scheduler — is switched off here too. So it's an honest snapshot of "where I am right now," but it is not the real gap to Bevy. That one opens up under multithreading, which nobody runs here.</li>
<li><strong>This is a pure-traversal test — archetypes' best friend and a sparse set's worst enemy.</strong> The scenario a sparse set is built for in the first place (cheap insert / remove with no migrations between tables) isn't measured here at all. So my architecture hasn't even played its own home game yet.</li>
<li><strong>Writes are slower for a reason</strong> — every <code>&#x26;mut</code> goes through that <code>changed_tick</code> stamp. That's the price of change detection, which half the rivals don't even do by default.</li>
</ol>
<p>The bright spot is the dependencies. Spark drags in 6 crates. Bevy, 59. I'm second only to hecs, and that's with everything written on the bare standard library. On memory, a solid mid-table finish. So for a naive, from-scratch sparse set, three-to-four times slower on someone else's turf is, honestly, not that bad.</p>
<h2>What's next</h2>
<p>The benchmark itself is the plan. It pins down the "today" point so future work can be measured as a delta on the same machine, not on vibes.</p>
<p>Next up is a detailed performance inventory: run not just <code>iter</code> and <code>spawn</code> but every slice of the API, and dump it all into a report with numbers. After that, an AI optimization pass — this time with those numbers in hand.</p>
<p>The one lever I'm sure of is M4: Rayon and the parallel executor. But Stage 24, the move to archetypes, I haven't decided. I kept the API stable from day one precisely to keep that option open — but it's an option, not a promise. Shipyard, on the same sparse set, posts very respectable numbers (on reads it's faster than every archetype engine in the field). So my deficit is the naive implementation, not the architecture, which means maybe I don't need archetypes at all — just a sparse set polished to a shine. Do I even need archetypes? I don't know yet — but now I can answer that with numbers and an AI, not a guess.</p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[Spark: I Watched Chernobyl and Sat Down to Write a Game Engine]]></title>
            <link>https://alexblog.net/spark/</link>
            <guid isPermaLink="false">https://alexblog.net/spark/</guid>
            <pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[I rewatched Chernobyl with my son, fell down a rabbit hole about power grids, and somehow climbed out the other side writing my own ECS in Rust. This is the part where I have a plan — which means everything is under control.]]></description>
            <content:encoded><![CDATA[<p>My son and I rewatched HBO's <em>Chernobyl</em> a while back. The show's dramatised to the hilt, of course, and plenty of it is flat-out invented (the heroic divers, the rest of the tall tales). But even through all the drama, you can see how fiendishly complicated the whole thing is. It got me curious, so I went further: a couple of YouTube videos on how power grids are actually run and balanced. And it hit me: damn, this is hard — keeping that whole machine in balance. Properly hard. To us, electricity is a switch on the wall, the most ordinary thing there is. Getting anything to happen behind that switch took decades of evolution, just so the lights stay on and the house stays warm.</p>
<p>I wanted to climb into the skin of the people who live inside all that and actually understand it. So an idea was born: make a game about power grids. I called it <a href="https://github.com/AlexTiTanium/spark">Spark</a>.</p>
<h2>But just making games is boring</h2>
<p>Just grinding out a game bores me. I want to experiment with AI on the side and learn something myself in the process. I've taken runs at this before (a game engine in Rust), so why not resurrect it. My first project bored me at the exact moment I reached the renderer: I slammed into a wall of problems with the Shipyard ECS. It was never built to be used like that.</p>
<h2>My brilliantly dumb idea</h2>
<p>And here comes my favourite genre of idea: brilliantly dumb, as usual. What if I threw out almost the whole engine, every last bit of its API, and replaced it with one single ECS? Sounds unhinged, I know.</p>
<p>Let everything in the engine be one of three things. There are components, and there are loads of them. A resource is the thing there's exactly one of. Systems are where all the logic lives. And then the fun part: the renderer, the asset manager, the camera, the audio are just resources too. Any system that needs one simply asks for it by name. One world, one set of rules for everything.</p>
<p>Since every system says up front what it reads and writes, I can build a scheduler that scatters the non-overlapping tasks across threads, and the whole thing runs in parallel on its own. Which is exactly why the ECS ended up the heart of the engine rather than just one of its parts.</p>
<p>In code it looks roughly like this. No "engine object" with methods — there's a <code>World</code>, and everything lives in it:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// In Spark there is no "engine object" with methods. There is a World, and</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// everything lives inside it — as a Resource (one of a kind) or an Entity</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// (many of a kind). The renderer, the GPU, the input, the power grid: all</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// just Resources. Nothing hidden away in global statics.</span></span>
<span class="line"></span>
<span class="line"><span style="color:#968B80">#[</span><span style="color:#D4C8B8">derive</span><span style="color:#968B80">(</span><span style="color:#FDBA74">Resource</span><span style="color:#968B80">)]</span></span>
<span class="line"><span style="color:#F97316">struct</span><span style="color:#FDBA74"> RenderContext</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">    device</span><span style="color:#F97316">:</span><span style="color:#D4C8B8"> wgpu</span><span style="color:#F97316">::</span><span style="color:#FDBA74">Device</span><span style="color:#968B80">,</span></span>
<span class="line"><span style="color:#D4C8B8">    queue</span><span style="color:#F97316">:</span><span style="color:#D4C8B8"> wgpu</span><span style="color:#F97316">::</span><span style="color:#FDBA74">Queue</span><span style="color:#968B80">,</span></span>
<span class="line"><span style="color:#D4C8B8">    surface</span><span style="color:#F97316">:</span><span style="color:#D4C8B8"> wgpu</span><span style="color:#F97316">::</span><span style="color:#FDBA74">Surface</span><span style="color:#968B80">&#x3C;'</span><span style="color:#FDBA74">static</span><span style="color:#968B80">>,</span></span>
<span class="line"><span style="color:#968B80">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#968B80">#[</span><span style="color:#D4C8B8">derive</span><span style="color:#968B80">(</span><span style="color:#FDBA74">Resource</span><span style="color:#968B80">,</span><span style="color:#FDBA74"> Default</span><span style="color:#968B80">)]</span></span>
<span class="line"><span style="color:#F97316">struct</span><span style="color:#FDBA74"> PowerNetwork</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">    supply</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> f32</span><span style="color:#968B80">,</span></span>
<span class="line"><span style="color:#D4C8B8">    demand</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> f32</span><span style="color:#968B80">,</span></span>
<span class="line"><span style="color:#D4C8B8">    ratio</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> f32</span><span style="color:#968B80">,</span></span>
<span class="line"><span style="color:#968B80">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// A system is just a function. Its parameters declare what it touches —</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// and the scheduler hands it exactly that, nothing more.</span></span>
<span class="line"><span style="color:#F97316">fn</span><span style="color:#FDE68A"> balance_grid</span><span style="color:#968B80">(</span><span style="color:#F97316">mut</span><span style="color:#D4C8B8"> grid</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> ResMut</span><span style="color:#968B80">&#x3C;</span><span style="color:#FDBA74">PowerNetwork</span><span style="color:#968B80">>)</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">    grid</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">ratio </span><span style="color:#F97316">=</span><span style="color:#D4C8B8"> grid</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">supply </span><span style="color:#F97316">/</span><span style="color:#D4C8B8"> grid</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">demand</span><span style="color:#F97316">.</span><span style="color:#FDE68A">max</span><span style="color:#968B80">(</span><span style="color:#FDA4AF">1</span><span style="color:#968B80">.</span><span style="color:#FDA4AF">0</span><span style="color:#968B80">);</span></span>
<span class="line"><span style="color:#968B80">}</span></span></code></pre>
<h2>Why not just take Bevy or Shipyard</h2>
<p>Fair question: why build my own when <a href="https://github.com/bevyengine/bevy/tree/main/crates/bevy_ecs">Bevy</a> and <a href="https://github.com/leudz/shipyard">Shipyard</a> exist. I love Bevy's syntax — it's noticeably more logical than Shipyard's. But Shipyard has workloads, and those are done beautifully. And I plain cannot choose.</p>
<p>Bevy's also a monster. And Shipyard's no picnic either. And the day I need something that isn't there and isn't supported, no AI is going to pull it out for me — because I don't even roughly know how these things work on the inside. Which is the real reason for the whole exercise: I want to understand. At least at the level of the data structures and the decisions the authors of such libraries make. Why it runs so fast. What compromises they made to get there.</p>
<p>Here's the same thing in both. Bevy:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// Bevy — a system is a plain function; you ask for data by its type.</span></span>
<span class="line"><span style="color:#F97316">fn</span><span style="color:#FDE68A"> movement</span><span style="color:#968B80">(</span><span style="color:#F97316">mut</span><span style="color:#D4C8B8"> query</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> Query</span><span style="color:#968B80">&#x3C;(</span><span style="color:#F97316">&#x26;mut</span><span style="color:#FDBA74"> Position</span><span style="color:#968B80">,</span><span style="color:#F97316"> &#x26;</span><span style="color:#FDBA74">Velocity</span><span style="color:#968B80">)>)</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#F97316">    for</span><span style="color:#968B80"> (</span><span style="color:#F97316">mut</span><span style="color:#D4C8B8"> pos</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> vel</span><span style="color:#968B80">)</span><span style="color:#F97316"> in</span><span style="color:#F97316"> &#x26;mut</span><span style="color:#D4C8B8"> query </span><span style="color:#968B80">{</span></span>
<span class="line"><span style="color:#D4C8B8">        pos</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">x </span><span style="color:#F97316">+=</span><span style="color:#D4C8B8"> vel</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">x</span><span style="color:#968B80">;</span></span>
<span class="line"><span style="color:#D4C8B8">        pos</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">y </span><span style="color:#F97316">+=</span><span style="color:#D4C8B8"> vel</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">y</span><span style="color:#968B80">;</span></span>
<span class="line"><span style="color:#968B80">    }</span></span>
<span class="line"><span style="color:#968B80">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97316">let</span><span style="color:#F97316"> mut</span><span style="color:#D4C8B8"> schedule </span><span style="color:#F97316">=</span><span style="color:#FDBA74"> Schedule</span><span style="color:#F97316">::</span><span style="color:#FDE68A">default</span><span style="color:#968B80">();</span></span>
<span class="line"><span style="color:#D4C8B8">schedule</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add_systems</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">movement</span><span style="color:#968B80">);</span></span></code></pre>
<p>Shipyard:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// Shipyard — a system takes "views" into storages, then iterates them.</span></span>
<span class="line"><span style="color:#F97316">fn</span><span style="color:#FDE68A"> movement</span><span style="color:#968B80">(</span><span style="color:#F97316">mut</span><span style="color:#D4C8B8"> positions</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> ViewMut</span><span style="color:#968B80">&#x3C;</span><span style="color:#FDBA74">Position</span><span style="color:#968B80">>,</span><span style="color:#D4C8B8"> velocities</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> View</span><span style="color:#968B80">&#x3C;</span><span style="color:#FDBA74">Velocity</span><span style="color:#968B80">>)</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#F97316">    for</span><span style="color:#968B80"> (</span><span style="color:#F97316">mut</span><span style="color:#D4C8B8"> pos</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> vel</span><span style="color:#968B80">)</span><span style="color:#F97316"> in</span><span style="color:#968B80"> (</span><span style="color:#F97316">&#x26;mut</span><span style="color:#D4C8B8"> positions</span><span style="color:#968B80">,</span><span style="color:#F97316"> &#x26;</span><span style="color:#D4C8B8">velocities</span><span style="color:#968B80">)</span><span style="color:#F97316">.</span><span style="color:#FDE68A">iter</span><span style="color:#968B80">()</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">        pos</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">x </span><span style="color:#F97316">+=</span><span style="color:#D4C8B8"> vel</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">x</span><span style="color:#968B80">;</span></span>
<span class="line"><span style="color:#D4C8B8">        pos</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">y </span><span style="color:#F97316">+=</span><span style="color:#D4C8B8"> vel</span><span style="color:#F97316">.</span><span style="color:#D4C8B8">y</span><span style="color:#968B80">;</span></span>
<span class="line"><span style="color:#968B80">    }</span></span>
<span class="line"><span style="color:#968B80">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4C8B8">world</span><span style="color:#F97316">.</span><span style="color:#FDE68A">run</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">movement</span><span style="color:#968B80">);</span></span></code></pre>
<p>And here are the workloads from Shipyard I want to steal. You name a batch of systems, hand it to the world, and it works out from the "views" which systems can run in parallel:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// Shipyard workloads — name a batch of systems, add it to the world, and it</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// works out which ones can run in parallel from the views they borrow.</span></span>
<span class="line"><span style="color:#FDBA74">Workload</span><span style="color:#F97316">::</span><span style="color:#FDE68A">new</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">simulation</span><span style="color:#968B80">"</span><span style="color:#968B80">)</span></span>
<span class="line"><span style="color:#F97316">    .</span><span style="color:#FDE68A">with_system</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">movement</span><span style="color:#968B80">)</span></span>
<span class="line"><span style="color:#F97316">    .</span><span style="color:#FDE68A">with_system</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">collide</span><span style="color:#968B80">)</span></span>
<span class="line"><span style="color:#F97316">    .</span><span style="color:#FDE68A">add_to_world</span><span style="color:#968B80">(</span><span style="color:#F97316">&#x26;</span><span style="color:#D4C8B8">world</span><span style="color:#968B80">)</span></span>
<span class="line"><span style="color:#F97316">    .</span><span style="color:#FDE68A">unwrap</span><span style="color:#968B80">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4C8B8">world</span><span style="color:#F97316">.</span><span style="color:#FDE68A">run_workload</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">simulation</span><span style="color:#968B80">"</span><span style="color:#968B80">)</span><span style="color:#F97316">.</span><span style="color:#FDE68A">unwrap</span><span style="color:#968B80">();</span></span></code></pre>
<p>So Spark robs them both. Bevy's function-systems, Shipyard's named workloads:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// Spark steals from both: Bevy's function-systems, Shipyard's named workloads.</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// Because every system spells out what it reads and writes, the scheduler can</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// batch the ones that don't collide and run them on separate threads.</span></span>
<span class="line"><span style="color:#D4C8B8">app</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add_workload</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">Workload</span><span style="color:#F97316">::</span><span style="color:#FDBA74">PowerGrid</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> Schedule</span><span style="color:#F97316">::</span><span style="color:#FDBA74">FixedUpdate</span><span style="color:#968B80">,</span><span style="color:#F97316"> |</span><span style="color:#D4C8B8">w</span><span style="color:#F97316">|</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">    w</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">collect_supply</span><span style="color:#968B80">);</span><span style="color:#F59E0B;font-style:italic">                      // reads plants</span></span>
<span class="line"><span style="color:#D4C8B8">    w</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">compute_demand</span><span style="color:#968B80">);</span><span style="color:#F59E0B;font-style:italic">                      // reads cities — runs in parallel</span></span>
<span class="line"><span style="color:#D4C8B8">    w</span><span style="color:#F97316">.</span><span style="color:#FDE68A">add</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">distribute_power</span><span style="color:#968B80">)</span><span style="color:#F97316">.</span><span style="color:#FDE68A">after_all_prior</span><span style="color:#968B80">();</span><span style="color:#F59E0B;font-style:italic">  // needs both, so it waits</span></span>
<span class="line"><span style="color:#968B80">});</span></span></code></pre>
<h2>A different approach than Moku</h2>
<p>My son showed an interest too — he might end up taking part in something. And I want to come at this project from a different angle. If <a href="https://github.com/moku-labs/core">Moku</a> is the story where code generation sits at the centre (fire off a prompt, go watch a show), here I want the exact opposite. To climb into the code it generates. To understand, at least in part, why it picks one solution over another. How it sits in memory. And to design an API I personally consider right. Most likely that same mix of Bevy and Shipyard.</p>
<p>I'm also curious to see how the models write Rust. At TypeScript, frankly, they're so-so.</p>
<h2>How the development actually works</h2>
<p>I'm doing it in stages, and there's one principle the whole thing rests on. First we kick the general idea around — goals, a rough API. That conversation turns into a design document: ECS, renderer, asset server, the lot. Then a roadmap, sliced into stages. A task gets filed, and the AI takes on the implementation. When the PR is ready I look it over, try to understand it, talk it through with an AI to make sure I really get how and why it works (or doesn't). I propose edits. I accept, we merge, on to the next task. A plan is good. A false sense of control.</p>
<p>Codex or Claude Code only ever touch the code, nothing else. All the discussion happens with ordinary, non-code agents. Why it matters: I don't want the agent I'm hashing decisions out with to know anything about the code, or to dig into it and clog its context. Let it talk things through with me, not reach in to "fix" things and, as usual, break them.</p>
<figure class="mermaid-diagram"><svg id="mermaid-0" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 205.4375px;" viewBox="0 0 205.4375 907" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#mermaid-0{font-family:"Fira Code","IBM Plex Mono",monospace;font-size:13px;fill:#d4c8b8;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-0 .error-icon{fill:#1c1917;}#mermaid-0 .error-text{fill:#d4c8b8;stroke:#d4c8b8;}#mermaid-0 .edge-thickness-normal{stroke-width:1px;}#mermaid-0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-0 .marker{fill:#968b80;stroke:#968b80;}#mermaid-0 .marker.cross{stroke:#968b80;}#mermaid-0 svg{font-family:"Fira Code","IBM Plex Mono",monospace;font-size:13px;}#mermaid-0 p{margin:0;}#mermaid-0 .label{font-family:"Fira Code","IBM Plex Mono",monospace;color:#d4c8b8;}#mermaid-0 .cluster-label text{fill:#d4c8b8;}#mermaid-0 .cluster-label span{color:#d4c8b8;}#mermaid-0 .cluster-label span p{background-color:transparent;}#mermaid-0 .label text,#mermaid-0 span{fill:#d4c8b8;color:#d4c8b8;}#mermaid-0 .node rect,#mermaid-0 .node circle,#mermaid-0 .node ellipse,#mermaid-0 .node polygon,#mermaid-0 .node path{fill:#231f1b;stroke:#3d3530;stroke-width:1px;}#mermaid-0 .rough-node .label text,#mermaid-0 .node .label text,#mermaid-0 .image-shape .label,#mermaid-0 .icon-shape .label{text-anchor:middle;}#mermaid-0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-0 .rough-node .label,#mermaid-0 .node .label,#mermaid-0 .image-shape .label,#mermaid-0 .icon-shape .label{text-align:center;}#mermaid-0 .node.clickable{cursor:pointer;}#mermaid-0 .root .anchor path{fill:#968b80!important;stroke-width:0;stroke:#968b80;}#mermaid-0 .arrowheadPath{fill:#e7ebed;}#mermaid-0 .edgePath .path{stroke:#968b80;stroke-width:1px;}#mermaid-0 .flowchart-link{stroke:#968b80;fill:none;}#mermaid-0 .edgeLabel{background-color:#292420;text-align:center;}#mermaid-0 .edgeLabel p{background-color:#292420;}#mermaid-0 .edgeLabel rect{opacity:0.5;background-color:#292420;fill:#292420;}#mermaid-0 .labelBkg{background-color:rgba(41, 36, 32, 0.5);}#mermaid-0 .cluster rect{fill:#1c1917;stroke:#3d3530;stroke-width:1px;}#mermaid-0 .cluster text{fill:#d4c8b8;}#mermaid-0 .cluster span{color:#d4c8b8;}#mermaid-0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"Fira Code","IBM Plex Mono",monospace;font-size:12px;background:#1c1917;border:1px solid #3d3530;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#d4c8b8;}#mermaid-0 rect.text{fill:none;stroke-width:0;}#mermaid-0 .icon-shape,#mermaid-0 .image-shape{background-color:#292420;text-align:center;}#mermaid-0 .icon-shape p,#mermaid-0 .image-shape p{background-color:#292420;padding:2px;}#mermaid-0 .icon-shape .label rect,#mermaid-0 .image-shape .label rect{opacity:0.5;background-color:#292420;fill:#292420;}#mermaid-0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-0 .node .neo-node{stroke:#3d3530;}#mermaid-0 [data-look="neo"].node rect,#mermaid-0 [data-look="neo"].cluster rect,#mermaid-0 [data-look="neo"].node polygon{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node path{stroke:url(#mermaid-0-gradient);stroke-width:1px;}#mermaid-0 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node .neo-line path{stroke:#3d3530;filter:none;}#mermaid-0 [data-look="neo"].node circle{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node circle .state-start{fill:#000000;}#mermaid-0 [data-look="neo"].icon-shape .icon{fill:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 :root{--mermaid-font-family:arial,sans-serif;}#mermaid-0 .talk rect{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .talk polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .talk ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .talk circle{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .talk path{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .talk tspan{fill:rgb(253, 230, 138)!important;}#mermaid-0 .code rect{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .code polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .code ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .code circle{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .code path{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .code tspan{fill:rgb(217, 249, 157)!important;}#mermaid-0 .human rect{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .human polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .human ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .human circle{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .human path{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .human tspan{fill:rgb(253, 186, 116)!important;}</style><g><marker id="mermaid-0_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></polygon></marker><marker id="mermaid-0_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"></path></marker><marker id="mermaid-0_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"><path d="M102.719,67L102.719,70.75C102.719,74.5,102.719,82,102.719,88.833C102.719,95.667,102.719,101.833,102.719,104.917L102.719,108" id="mermaid-0-L_idea_talk_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_idea_talk_0" data-points="W3sieCI6MTAyLjcxODc1LCJ5Ijo2N30seyJ4IjoxMDIuNzE4NzUsInkiOjg5LjV9LHsieCI6MTAyLjcxODc1LCJ5IjoxMTJ9XQ==" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M102.719,171L102.719,174.75C102.719,178.5,102.719,186,102.719,192.833C102.719,199.667,102.719,205.833,102.719,208.917L102.719,212" id="mermaid-0-L_talk_doc_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_talk_doc_0" data-points="W3sieCI6MTAyLjcxODc1LCJ5IjoxNzF9LHsieCI6MTAyLjcxODc1LCJ5IjoxOTMuNX0seyJ4IjoxMDIuNzE4NzUsInkiOjIxNn1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M102.719,275L102.719,278.75C102.719,282.5,102.719,290,102.719,296.833C102.719,303.667,102.719,309.833,102.719,312.917L102.719,316" id="mermaid-0-L_doc_road_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_doc_road_0" data-points="W3sieCI6MTAyLjcxODc1LCJ5IjoyNzV9LHsieCI6MTAyLjcxODc1LCJ5IjoyOTcuNX0seyJ4IjoxMDIuNzE4NzUsInkiOjMyMH1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M102.719,379L102.719,382.75C102.719,386.5,102.719,394,102.719,400.833C102.719,407.667,102.719,413.833,102.719,416.917L102.719,420" id="mermaid-0-L_road_task_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_road_task_0" data-points="W3sieCI6MTAyLjcxODc1LCJ5IjozNzl9LHsieCI6MTAyLjcxODc1LCJ5Ijo0MDEuNX0seyJ4IjoxMDIuNzE4NzUsInkiOjQyNH1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M102.719,483L102.719,486.75C102.719,490.5,102.719,498,102.719,504.833C102.719,511.667,102.719,517.833,102.719,520.917L102.719,524" id="mermaid-0-L_task_coder_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_task_coder_0" data-points="W3sieCI6MTAyLjcxODc1LCJ5Ijo0ODN9LHsieCI6MTAyLjcxODc1LCJ5Ijo1MDUuNX0seyJ4IjoxMDIuNzE4NzUsInkiOjUyOH1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M102.719,587L102.719,590.75C102.719,594.5,102.719,602,102.719,608.833C102.719,615.667,102.719,621.833,102.719,624.917L102.719,628" id="mermaid-0-L_coder_pr_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_coder_pr_0" data-points="W3sieCI6MTAyLjcxODc1LCJ5Ijo1ODd9LHsieCI6MTAyLjcxODc1LCJ5Ijo2MDkuNX0seyJ4IjoxMDIuNzE4NzUsInkiOjYzMn1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M102.719,691L102.719,694.75C102.719,698.5,102.719,706,102.719,712.833C102.719,719.667,102.719,725.833,102.719,728.917L102.719,732" id="mermaid-0-L_pr_review_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_pr_review_0" data-points="W3sieCI6MTAyLjcxODc1LCJ5Ijo2OTF9LHsieCI6MTAyLjcxODc1LCJ5Ijo3MTMuNX0seyJ4IjoxMDIuNzE4NzUsInkiOjczNn1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M102.719,795L102.719,798.75C102.719,802.5,102.719,810,102.719,816.833C102.719,823.667,102.719,829.833,102.719,832.917L102.719,836" id="mermaid-0-L_review_merge_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_review_merge_0" data-points="W3sieCI6MTAyLjcxODc1LCJ5Ijo3OTV9LHsieCI6MTAyLjcxODc1LCJ5Ijo4MTcuNX0seyJ4IjoxMDIuNzE4NzUsInkiOjg0MH1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path></g><g class="edgeLabels"><g class="edgeLabel"><g class="label" data-id="L_idea_talk_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_talk_doc_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_doc_road_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_road_task_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_task_coder_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_coder_pr_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_pr_review_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_review_merge_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g></g><g class="nodes"><g class="node default human" id="mermaid-0-flowchart-idea-0" data-look="classic" transform="translate(102.71875, 37.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important" x="-83.0234375" y="-29.5" width="166.046875" height="59"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Idea</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> +</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> API</tspan></tspan></text></g></g></g><g class="node default talk" id="mermaid-0-flowchart-talk-1" data-look="classic" transform="translate(102.71875, 141.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important" x="-94.71875" y="-29.5" width="189.4375" height="59"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Talk</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> to</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> an</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> AI</tspan></tspan></text></g></g></g><g class="node default human" id="mermaid-0-flowchart-doc-2" data-look="classic" transform="translate(102.71875, 245.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important" x="-83.015625" y="-29.5" width="166.03125" height="59"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Design</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> doc</tspan></tspan></text></g></g></g><g class="node default human" id="mermaid-0-flowchart-road-3" data-look="classic" transform="translate(102.71875, 349.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important" x="-71.3046875" y="-29.5" width="142.609375" height="59"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Roadmap</tspan></tspan></text></g></g></g><g class="node default human" id="mermaid-0-flowchart-task-4" data-look="classic" transform="translate(102.71875, 453.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important" x="-75.2109375" y="-29.5" width="150.421875" height="59"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">New</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> task</tspan></tspan></text></g></g></g><g class="node default code" id="mermaid-0-flowchart-coder-5" data-look="classic" transform="translate(102.71875, 557.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#84cc16 !important" x="-75.2109375" y="-29.5" width="150.421875" height="59"></rect><g class="label" style="color:#d9f99d !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#d9f99d !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Coder-AI</tspan></tspan></text></g></g></g><g class="node default human" id="mermaid-0-flowchart-pr-6" data-look="classic" transform="translate(102.71875, 661.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important" x="-90.8203125" y="-29.5" width="181.640625" height="59"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Pull</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Request</tspan></tspan></text></g></g></g><g class="node default talk" id="mermaid-0-flowchart-review-7" data-look="classic" transform="translate(102.71875, 765.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important" x="-86.9140625" y="-29.5" width="173.828125" height="59"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Review</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> +</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> AI</tspan></tspan></text></g></g></g><g class="node default human" id="mermaid-0-flowchart-merge-8" data-look="classic" transform="translate(102.71875, 869.5)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important" x="-94.71875" y="-29.5" width="189.4375" height="59"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -7.5)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Merge</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> to</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> main</tspan></tspan></text></g></g></g></g></g></g><defs><filter id="mermaid-0-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><defs><filter id="mermaid-0-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><linearGradient id="mermaid-0-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3d3530" stop-opacity="1"></stop><stop offset="100%" stop-color="#3d3530" stop-opacity="1"></stop></linearGradient></svg></figure>
<div class="section-divider"><span class="section-divider-ornament">***</span></div>
<h2>Let's see where I tap out</h2>
<p>So, an ambitious plan. Let's see where I fold this time. Last time it was the second implementation of a WebGPU renderer. Will I get any further?</p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[The Birth of Moku Core]]></title>
            <link>https://alexblog.net/birth-of-moku-core/</link>
            <guid isPermaLink="false">https://alexblog.net/birth-of-moku-core/</guid>
            <pubDate>Wed, 18 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Smarter models promised everyone a blog engine in three hours for twenty bucks. And they deliver — beautiful, working-ish spaghetti that has no idea what it's doing. So I sat down and spent a month writing a spec instead of code.]]></description>
            <content:encoded><![CDATA[<p>The moment the models got even slightly smarter, everyone had the same thought at once: what if I could just generate the software I need and not pay a single cent to those programmers of yours? You tell the model: I want a blog engine. Make it beautiful, the fastest, the best, not a single bug — or you're going to jail. And three hours later, for a twenty-dollar subscription, you have everything you asked for. With zero mental effort on your end. You fire off a prompt and go re-watch old seasons of <em>House</em> — it feels new, because nothing interesting has shipped in about ten years anyway.</p>
<p>And of course, the moment that idea landed, everyone started bolting on clever little plugins, skills, call them what you like, anything to make the prompt behave, and behave long enough to deliver. There will be stages. There will be a review of every stage of the project. GSD was the first of these I tried, and I was genuinely stunned: it created the appearance of serious engineering. Then came a thousand more equally clever schemes for manufacturing activity and the feeling that the software you ordered is <em>almost</em> here. You have a plan. You have a spec. Everything is under control. The thing is so easy to drive that I sat my wife down in front of it, and she had no trouble at all; she only called me over to answer the technical questions. Tell me that's not a fairy tale.</p>
<h2>Not quite a fairy tale</h2>
<p>The idea is genius from every angle: pennies in, everything you ever wanted out, all rosy.</p>
<p>...Well. Not quite. And not so rosy. What you actually get goes more like this: it'll probably even boot, but the bug count will be spectacular. You'll debug till you're blue in the face, fixing it through the AI, naturally, and what you end up with is a hundred spaghetti functions. It sort of works, but at any given moment something somewhere is broken, visually or invisibly, and working out what's actually going on is no longer humanly possible.</p>
<p>And none of the generated stuff, I noticed, has a unifying concept. No idea of how the whole thing hangs together. Everything touches everything, every API gets yanked from everywhere, state gets dumped wherever it happened to land. That is not how you build software. Okay, fine, it is, people do. But if you want something that works <em>and</em> can be maintained and extended later, you need an idea of the architecture. A simple one, like a tree. Because the AI isn't fond of following instructions, so the architecture has to be obvious. To the human and to the machine.</p>
<h2>So Moku Core was born</h2>
<p>A system of plugins that together assemble an application. Each piece lives isolated inside its own plugin. What goes in and how it's configured is the entry point's call. How a plugin works on the inside is nobody's business, as long as it works and honours its contract.</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// A plugin is one self-contained contract: its config, its state, and the API it hands out.</span></span>
<span class="line"><span style="color:#F97316">export</span><span style="color:#F97316"> const</span><span style="color:#D4C8B8"> routerPlugin </span><span style="color:#F97316">=</span><span style="color:#FDE68A"> createPlugin</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">router</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#968B80"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">  // config — the defaults; every key becomes optional for whoever uses the plugin.</span></span>
<span class="line"><span style="color:#D4C8B8">  config</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> basePath</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">/</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> notFoundRedirect</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">/404</span><span style="color:#968B80">"</span><span style="color:#968B80"> },</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">  // createState — private mutable state, owned by this plugin and nobody else.</span></span>
<span class="line"><span style="color:#FDE68A">  createState</span><span style="color:#968B80">:</span><span style="color:#968B80"> ()</span><span style="color:#F97316"> =></span><span style="color:#968B80"> ({</span><span style="color:#D4C8B8"> currentPath</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">/</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> history</span><span style="color:#968B80">:</span><span style="color:#968B80"> []</span><span style="color:#F97316"> as</span><span style="color:#FDBA74"> string</span><span style="color:#968B80">[]</span><span style="color:#968B80"> }),</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">  // events — declare what this plugin emits, with typed payloads.</span></span>
<span class="line"><span style="color:#FDE68A">  events</span><span style="color:#968B80">:</span><span style="color:#968B80"> (</span><span style="color:#D4C8B8">register</span><span style="color:#968B80">)</span><span style="color:#F97316"> =></span><span style="color:#968B80"> ({</span></span>
<span class="line"><span style="color:#968B80">    "</span><span style="color:#84CC16">router:navigate</span><span style="color:#968B80">"</span><span style="color:#968B80">:</span><span style="color:#FDE68A"> register</span><span style="color:#968B80">&#x3C;{</span><span style="color:#D4C8B8"> from</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> string</span><span style="color:#968B80">;</span><span style="color:#D4C8B8"> to</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> string</span><span style="color:#968B80"> }>(</span><span style="color:#968B80">"</span><span style="color:#84CC16">Fired after navigation</span><span style="color:#968B80">"</span><span style="color:#968B80">)</span></span>
<span class="line"><span style="color:#968B80">  }),</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">  // api — the public surface, mounted on `app.router`.</span></span>
<span class="line"><span style="color:#FDE68A">  api</span><span style="color:#968B80">:</span><span style="color:#968B80"> (</span><span style="color:#D4C8B8">ctx</span><span style="color:#968B80">)</span><span style="color:#F97316"> =></span><span style="color:#968B80"> ({</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">    // Become: app.router.navigate("/about");</span></span>
<span class="line"><span style="color:#FDE68A">    navigate</span><span style="color:#968B80">:</span><span style="color:#968B80"> (</span><span style="color:#D4C8B8">path</span><span style="color:#F97316">:</span><span style="color:#FDBA74"> string</span><span style="color:#968B80">)</span><span style="color:#F97316"> =></span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">      ctx</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">state</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">history</span><span style="color:#968B80">.</span><span style="color:#FDE68A">push</span><span style="color:#968B80">(</span><span style="color:#D4C8B8">ctx</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">state</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">currentPath</span><span style="color:#968B80">);</span><span style="color:#F59E0B;font-style:italic"> // remember where we were</span></span>
<span class="line"><span style="color:#D4C8B8">      ctx</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">state</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">currentPath </span><span style="color:#F97316">=</span><span style="color:#D4C8B8"> path</span><span style="color:#968B80">;</span><span style="color:#F59E0B;font-style:italic"> // move to the new path</span></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">      // emit — announce it so any plugin listening to "router:navigate" can react</span></span>
<span class="line"><span style="color:#D4C8B8">      ctx</span><span style="color:#968B80">.</span><span style="color:#FDE68A">emit</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">router:navigate</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> from</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> ctx</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">state</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">history</span><span style="color:#968B80">.</span><span style="color:#FDE68A">at</span><span style="color:#968B80">(</span><span style="color:#F97316">-</span><span style="color:#FDA4AF">1</span><span style="color:#968B80">)</span><span style="color:#F97316">!</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> to</span><span style="color:#968B80">:</span><span style="color:#D4C8B8"> path </span><span style="color:#968B80">});</span></span>
<span class="line"><span style="color:#968B80">    },</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">    // Become: app.router.current();</span></span>
<span class="line"><span style="color:#FDE68A">    current</span><span style="color:#968B80">:</span><span style="color:#968B80"> ()</span><span style="color:#F97316"> =></span><span style="color:#D4C8B8"> ctx</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">state</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">currentPath </span><span style="color:#F59E0B;font-style:italic">// read the current path</span></span>
<span class="line"><span style="color:#968B80">  })</span></span>
<span class="line"><span style="color:#968B80">});</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// Subscribing — another plugin depends on router and reacts to its events:</span></span>
<span class="line"><span style="color:#F97316">export</span><span style="color:#F97316"> const</span><span style="color:#D4C8B8"> analyticsPlugin </span><span style="color:#F97316">=</span><span style="color:#FDE68A"> createPlugin</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">analytics</span><span style="color:#968B80">"</span><span style="color:#968B80">,</span><span style="color:#968B80"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">  // defaults again — the entry point overrides this below</span></span>
<span class="line"><span style="color:#D4C8B8">  config</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> trackingId</span><span style="color:#968B80">:</span><span style="color:#968B80"> ""</span><span style="color:#968B80"> },</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">  // unlocks the typed "router:*" events below</span></span>
<span class="line"><span style="color:#D4C8B8">  depends</span><span style="color:#968B80">:</span><span style="color:#968B80"> [</span><span style="color:#D4C8B8">routerPlugin</span><span style="color:#968B80">],</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">  // runs on every "router:navigate" — the payload type comes from the declaration</span></span>
<span class="line"><span style="color:#FDE68A">  hooks</span><span style="color:#968B80">:</span><span style="color:#968B80"> (</span><span style="color:#D4C8B8">ctx</span><span style="color:#968B80">)</span><span style="color:#F97316"> =></span><span style="color:#968B80"> ({</span></span>
<span class="line"><span style="color:#968B80">    "</span><span style="color:#84CC16">router:navigate</span><span style="color:#968B80">"</span><span style="color:#968B80">:</span><span style="color:#968B80"> ({</span><span style="color:#D4C8B8"> from</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> to </span><span style="color:#968B80">})</span><span style="color:#F97316"> =></span></span>
<span class="line"><span style="color:#D4C8B8">      console</span><span style="color:#968B80">.</span><span style="color:#FDE68A">log</span><span style="color:#968B80">(</span><span style="color:#968B80">`</span><span style="color:#84CC16">[</span><span style="color:#968B80">${</span><span style="color:#D4C8B8">ctx</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">config</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">trackingId</span><span style="color:#968B80">}</span><span style="color:#84CC16">] page view: </span><span style="color:#968B80">${</span><span style="color:#D4C8B8">from</span><span style="color:#968B80">}</span><span style="color:#84CC16"> -> </span><span style="color:#968B80">${</span><span style="color:#D4C8B8">to</span><span style="color:#968B80">}`</span><span style="color:#968B80">)</span></span>
<span class="line"><span style="color:#968B80">  })</span></span>
<span class="line"><span style="color:#968B80">});</span></span></code></pre>
<p>Every plugin declares its contract: its state, the events it subscribes to, the API it exposes, and the helpers it throws out into the open. Which means that by reading one file, you can tell exactly how badly the AI botched the design of that plugin. And the entry point just gathers them up:</p>
<pre class="shiki warm-syntax" style="background-color:#181412;color:#d4c8b8" tabindex="0"><code><span class="line"><span style="color:#F59E0B;font-style:italic">// The entry point decides what goes in and how it's configured:</span></span>
<span class="line"><span style="color:#F97316">const</span><span style="color:#D4C8B8"> app </span><span style="color:#F97316">=</span><span style="color:#FDE68A"> createApp</span><span style="color:#968B80">({</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">  // order matters — analytics depends on router, so router comes first</span></span>
<span class="line"><span style="color:#D4C8B8">  plugins</span><span style="color:#968B80">:</span><span style="color:#968B80"> [</span><span style="color:#D4C8B8">routerPlugin</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> analyticsPlugin</span><span style="color:#968B80">,</span><span style="color:#D4C8B8"> blogPlugin</span><span style="color:#968B80">],</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4C8B8">  pluginConfigs</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span></span>
<span class="line"><span style="color:#D4C8B8">    router</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> basePath</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">/blog</span><span style="color:#968B80">"</span><span style="color:#968B80"> },</span><span style="color:#F59E0B;font-style:italic"> // overrides the "/" default declared by the plugin</span></span>
<span class="line"><span style="color:#D4C8B8">    analytics</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> trackingId</span><span style="color:#968B80">:</span><span style="color:#968B80"> "</span><span style="color:#84CC16">G-XXXXX</span><span style="color:#968B80">"</span><span style="color:#968B80"> },</span></span>
<span class="line"><span style="color:#D4C8B8">    blog</span><span style="color:#968B80">:</span><span style="color:#968B80"> {</span><span style="color:#D4C8B8"> postsPerPage</span><span style="color:#968B80">:</span><span style="color:#FDA4AF"> 5</span><span style="color:#968B80"> }</span></span>
<span class="line"><span style="color:#968B80">  }</span></span>
<span class="line"><span style="color:#968B80">});</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F59E0B;font-style:italic">// In client code you just call the typed API — autocompleted, no imports, no globals:</span></span>
<span class="line"><span style="color:#D4C8B8">app</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">router</span><span style="color:#968B80">.</span><span style="color:#FDE68A">navigate</span><span style="color:#968B80">(</span><span style="color:#968B80">"</span><span style="color:#84CC16">/about</span><span style="color:#968B80">"</span><span style="color:#968B80">);</span><span style="color:#F59E0B;font-style:italic"> // analytics logs: [G-XXXXX] page view: / -> /about</span></span>
<span class="line"><span style="color:#D4C8B8">app</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">router</span><span style="color:#968B80">.</span><span style="color:#FDE68A">current</span><span style="color:#968B80">();</span><span style="color:#F59E0B;font-style:italic"> // "/about"</span></span>
<span class="line"><span style="color:#D4C8B8">app</span><span style="color:#968B80">.</span><span style="color:#D4C8B8">blog</span><span style="color:#968B80">.</span><span style="color:#FDE68A">listPosts</span><span style="color:#968B80">();</span><span style="color:#F59E0B;font-style:italic"> // 5 per page — straight from the config above</span></span></code></pre>
<p>You can extend them, complicate them. There's a way to test them deterministically. And the whole thing is packed as minimally as it goes, plus it hands you the freshest guarantees there are, like type safety — the kind TypeScript can actually prove. The point is to shrink the room for error as hard as the language will let you.</p>
<h2>A month on a spec, not on code</h2>
<p>I sat with this idea for about a month, not over code, over the spec. Mostly I had the AI model different situations against my API. I've tried to build a plugin system like this many times before, at work and in my own game engines; the idea keeps resurfacing. Case in point: Beavy, my dream project — a game engine in Rust. I think it's genuinely brilliant, and I draw inspiration from it (lying) at every opportunity.</p>
<p>The spec took a month. I had to work through a pile of variants: how to run it in the browser, how from the console, how on Node, how to make things isomorphic, how to drive coupling down to nothing. I have never in my life sweated so hard over documentation and the endless debugging of this stuff. The AI writes docs beautifully. Coding, though, is not the AI's strong suit.</p>
<h2>Three layers</h2>
<p>I also arrived at the idea that the structure has to be three layers deep.</p>
<figure class="mermaid-diagram"><svg id="mermaid-0" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 299.0625px;" viewBox="0 0 299.0625 368.8000183105469" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#mermaid-0{font-family:"Fira Code","IBM Plex Mono",monospace;font-size:13px;fill:#d4c8b8;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-0 .error-icon{fill:#1c1917;}#mermaid-0 .error-text{fill:#d4c8b8;stroke:#d4c8b8;}#mermaid-0 .edge-thickness-normal{stroke-width:1px;}#mermaid-0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-0 .marker{fill:#968b80;stroke:#968b80;}#mermaid-0 .marker.cross{stroke:#968b80;}#mermaid-0 svg{font-family:"Fira Code","IBM Plex Mono",monospace;font-size:13px;}#mermaid-0 p{margin:0;}#mermaid-0 .label{font-family:"Fira Code","IBM Plex Mono",monospace;color:#d4c8b8;}#mermaid-0 .cluster-label text{fill:#d4c8b8;}#mermaid-0 .cluster-label span{color:#d4c8b8;}#mermaid-0 .cluster-label span p{background-color:transparent;}#mermaid-0 .label text,#mermaid-0 span{fill:#d4c8b8;color:#d4c8b8;}#mermaid-0 .node rect,#mermaid-0 .node circle,#mermaid-0 .node ellipse,#mermaid-0 .node polygon,#mermaid-0 .node path{fill:#231f1b;stroke:#3d3530;stroke-width:1px;}#mermaid-0 .rough-node .label text,#mermaid-0 .node .label text,#mermaid-0 .image-shape .label,#mermaid-0 .icon-shape .label{text-anchor:middle;}#mermaid-0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-0 .rough-node .label,#mermaid-0 .node .label,#mermaid-0 .image-shape .label,#mermaid-0 .icon-shape .label{text-align:center;}#mermaid-0 .node.clickable{cursor:pointer;}#mermaid-0 .root .anchor path{fill:#968b80!important;stroke-width:0;stroke:#968b80;}#mermaid-0 .arrowheadPath{fill:#e7ebed;}#mermaid-0 .edgePath .path{stroke:#968b80;stroke-width:1px;}#mermaid-0 .flowchart-link{stroke:#968b80;fill:none;}#mermaid-0 .edgeLabel{background-color:#292420;text-align:center;}#mermaid-0 .edgeLabel p{background-color:#292420;}#mermaid-0 .edgeLabel rect{opacity:0.5;background-color:#292420;fill:#292420;}#mermaid-0 .labelBkg{background-color:rgba(41, 36, 32, 0.5);}#mermaid-0 .cluster rect{fill:#1c1917;stroke:#3d3530;stroke-width:1px;}#mermaid-0 .cluster text{fill:#d4c8b8;}#mermaid-0 .cluster span{color:#d4c8b8;}#mermaid-0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"Fira Code","IBM Plex Mono",monospace;font-size:12px;background:#1c1917;border:1px solid #3d3530;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#d4c8b8;}#mermaid-0 rect.text{fill:none;stroke-width:0;}#mermaid-0 .icon-shape,#mermaid-0 .image-shape{background-color:#292420;text-align:center;}#mermaid-0 .icon-shape p,#mermaid-0 .image-shape p{background-color:#292420;padding:2px;}#mermaid-0 .icon-shape .label rect,#mermaid-0 .image-shape .label rect{opacity:0.5;background-color:#292420;fill:#292420;}#mermaid-0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-0 .node .neo-node{stroke:#3d3530;}#mermaid-0 [data-look="neo"].node rect,#mermaid-0 [data-look="neo"].cluster rect,#mermaid-0 [data-look="neo"].node polygon{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node path{stroke:url(#mermaid-0-gradient);stroke-width:1px;}#mermaid-0 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node .neo-line path{stroke:#3d3530;filter:none;}#mermaid-0 [data-look="neo"].node circle{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].node circle .state-start{fill:#000000;}#mermaid-0 [data-look="neo"].icon-shape .icon{fill:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mermaid-0 :root{--mermaid-font-family:arial,sans-serif;}#mermaid-0 .l1 rect{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .l1 polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .l1 ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .l1 circle{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .l1 path{fill:rgb(35, 31, 27)!important;stroke:rgb(249, 115, 22)!important;color:rgb(253, 186, 116)!important;}#mermaid-0 .l1 tspan{fill:rgb(253, 186, 116)!important;}#mermaid-0 .l2 rect{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .l2 polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .l2 ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .l2 circle{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .l2 path{fill:rgb(35, 31, 27)!important;stroke:rgb(245, 158, 11)!important;color:rgb(253, 230, 138)!important;}#mermaid-0 .l2 tspan{fill:rgb(253, 230, 138)!important;}#mermaid-0 .l3 rect{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .l3 polygon{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .l3 ellipse{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .l3 circle{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .l3 path{fill:rgb(35, 31, 27)!important;stroke:rgb(132, 204, 22)!important;color:rgb(217, 249, 157)!important;}#mermaid-0 .l3 tspan{fill:rgb(217, 249, 157)!important;}</style><g><marker id="mermaid-0_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></polygon></marker><marker id="mermaid-0_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mermaid-0_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mermaid-0_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"></path></marker><marker id="mermaid-0_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"><path d="M149.531,273.2L149.531,269.45C149.531,265.7,149.531,258.2,149.531,251.367C149.531,244.533,149.531,238.367,149.531,235.283L149.531,232.2" id="mermaid-0-L_core_fw_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_core_fw_0" data-points="W3sieCI6MTQ5LjUzMTI1LCJ5IjoyNzMuMTk5OTk2OTQ4MjQyMn0seyJ4IjoxNDkuNTMxMjUsInkiOjI1MC42OTk5OTY5NDgyNDIyfSx7IngiOjE0OS41MzEyNSwieSI6MjI4LjE5OTk5Njk0ODI0MjJ9XQ==" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path><path d="M149.531,140.6L149.531,136.85C149.531,133.1,149.531,125.6,149.531,118.767C149.531,111.933,149.531,105.767,149.531,102.683L149.531,99.6" id="mermaid-0-L_fw_app_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_fw_app_0" data-points="W3sieCI6MTQ5LjUzMTI1LCJ5IjoxNDAuNTk5OTk4NDc0MTIxMX0seyJ4IjoxNDkuNTMxMjUsInkiOjExOC4wOTk5OTg0NzQxMjExfSx7IngiOjE0OS41MzEyNSwieSI6OTUuNTk5OTk4NDc0MTIxMX1d" data-look="classic" marker-end="url(#mermaid-0_flowchart-v2-pointEnd)"></path></g><g class="edgeLabels"><g class="edgeLabel"><g class="label" data-id="L_core_fw_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g><g class="edgeLabel"><g class="label" data-id="L_fw_app_0" transform="translate(0, 0)"><text y="-10.1" text-anchor="middle"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"></tspan></text></g></g><g><rect class="background" style="stroke: none"></rect></g></g><g class="nodes"><g class="node default l1" id="mermaid-0-flowchart-core-0" data-look="classic" transform="translate(149.53125, 316.99999618530273)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f97316 !important" x="-141.53125" y="-43.79999923706055" width="283.0625" height="87.5999984741211"></rect><g class="label" style="color:#fdba74 !important" transform="translate(0, -21.799999237060547)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fdba74 !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Layer</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 1</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> @moku-labs/core</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">the</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> kernel.</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Ascetic.</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Pure</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">machinery.</tspan></tspan></text></g></g></g><g class="node default l2" id="mermaid-0-flowchart-fw-1" data-look="classic" transform="translate(149.53125, 184.39999771118164)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#f59e0b !important" x="-129.8916015625" y="-43.79999923706055" width="259.783203125" height="87.5999984741211"></rect><g class="label" style="color:#fde68a !important" transform="translate(0, -21.799999237060547)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#fde68a !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Layer</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 2</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Framework</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">web,</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> client,</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> whatever.</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Patterns</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> live</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> here.</tspan></tspan></text></g></g></g><g class="node default l3" id="mermaid-0-flowchart-app-2" data-look="classic" transform="translate(149.53125, 51.79999923706055)"><rect class="basic label-container" style="fill:#231f1b !important;stroke:#84cc16 !important" x="-137.6328125" y="-43.79999923706055" width="275.265625" height="87.5999984741211"></rect><g class="label" style="color:#d9f99d !important" transform="translate(0, -21.799999237060547)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style="fill:#d9f99d !important"><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Layer</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 3</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Application</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">the</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> app</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> itself.</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Minimal.</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Just</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> the</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> visuals.</tspan></tspan></text></g></g></g></g></g></g><defs><filter id="mermaid-0-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><defs><filter id="mermaid-0-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><linearGradient id="mermaid-0-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3d3530" stop-opacity="1"></stop><stop offset="100%" stop-color="#3d3530" stop-opacity="1"></stop></linearGradient></svg></figure>
<p>The kernel stays ascetic. The framework on top of it exists to hoard all the patterns of one specific kind of software (web, a client app, whatever), purely to spare you the endless AI-debugging when you get to the app. And the app on top of that just uses the framework. So the bottom is a licked-clean kernel; one floor up we relax a little and generate a ton of code, sorted neatly by plugin; and at the top sits client code that answers for the visual part alone. The interface, roughly speaking.</p>
<h2>For now, it's all theory</h2>
<p>And that is how the project was born.</p>
<p>One thing to keep in mind: there is no second layer yet, and no third. It's all pure theory. I'm hoping they show up soon, so I can finally test the architecture I've been dreaming about.</p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[Eight Days of Unscrewing a Game Out of an AI]]></title>
            <link>https://alexblog.net/screw-master/</link>
            <guid isPermaLink="false">https://alexblog.net/screw-master/</guid>
            <pubDate>Wed, 07 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[My company announced an AI week. My wife, at that exact moment, was unscrewing little bolts on her phone. I put the two together and gave myself exactly eight days: all code and all art by AI, me just conducting.]]></description>
            <content:encoded><![CDATA[<p>My company announced an AI week: every developer could try AI for any purpose they wanted. Subscriptions, any tools we asked for — within reason, obviously. And my wife, at that exact moment, was playing a game where you unscrew little bolts. I don't know what the genre is called. Some kind of puzzle: you unscrew bolts in the right order, sort them into colored trays, and basically win by unscrewing bolts.</p>
<p>I studied the mechanic and set myself a task. I had exactly eight days — no more, no less. I had to make the game with AI as fast as possible, at the best quality possible. All the code, all the graphics, the concept art, everything — AI. Me, I only orchestrate: I organize the gameplay and decide what gets done and how. That's how Screw Master happened: my first attempt at making a game without making it with my own hands.</p>
<h2>I'm Not an Animal — I Don't Poke Buttons</h2>
<p>I started with the code: ODIE (one of our internal engines, a classic ECS) with PixiJS 8 doing the rendering. The first thing I did was tighten every TypeScript validation as strictly as it would go, and I decided up front: testing would be automatic, and the game would be built from day one with self-verification in mind. I'm not an animal — I don't poke buttons by hand.</p>
<p>The idea was this: Playwright plays the game. It gets the whole harness and the instrumentation to do it: access to input, access to the render graph, access to the ECS. Every feature is proven by video: the AI builds something, opens a PR, attaches a recording — I watch it, decide whether I like what I see, and give feedback. No "trust me, it works." Here's the footage, see for yourself.</p>
<p>And here, in fact, is what came out of it. You can unscrew a few bolts without leaving the article:</p>
<figure class="lazy-embed" data-island="lazy-embed" data-embed-src="https://screw-master.kucherenko-email.workers.dev/" data-embed-title="Screw Master" data-embed-width="450" data-embed-height="800" style="aspect-ratio: 450 / 800; max-width: 450px;"><button type="button" data-launch="true" aria-label="Load embed: Screw Master"><span data-ring="true" aria-hidden="true"><svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M4.5 2.5v11l9-5.5z"></path></svg></span><span data-label="true">Screw Master</span><span data-cmd="true" aria-hidden="true">$ run --interactive</span><span data-hint="true" aria-hidden="true">[click to load]</span></button></figure>
<p>All the code is on GitHub, PRs and video proofs and all: <a href="https://github.com/AlexTiTanium/screw-master">AlexTiTanium/screw-master</a>.</p>
<h2>Figma, MCP, and the Battle for Sanity</h2>
<p>The entire interface went through Figma. I assembled the scenes the way they were supposed to look, then the Figma MCP gave the model access to the data and the assets — and the UI got built from that.</p>
<p>A whole separate problem was teaching that MCP and the model to be friends and not build nonsense. I had to go deep into Figma: structure every component with ruthless logic, so that what came out the other end was something sane instead of gibberish. I spent ages picking components so they'd all sit in more or less one style. Generating identical models in a different color — same suffering.</p>
<h2>Bugs Are What AI Generates Best</h2>
<p>And now for the biggest problem of all.</p>
<blockquote>
<p>Bugs are what AI generates best. And what bugs — honestly, you just stand there marveling.</p>
</blockquote>
<p>The real challenge was even <em>explaining</em> what the bug was. Especially when the AI had to do something concrete: say, several animations have to drop bolts onto their slots <em>at the same time</em>. It simply lost its mind over that. No, you can't do it the pretty way, with a queue or a stack — you need exactly this weird contraption and nothing else: cancelling promises, pausing them. Basically the most direct way to pile up problems for yourself. Hundreds of prompts. Pure pain.</p>
<h2>So, Can You Make Games This Way?</h2>
<p>I'd say the experiment ended on a positive note: I took it all the way to the finish. What I learned for myself: building a prototype with AI is completely fine. Building a product… that I honestly don't know. You'd have to be cocky. Very, <em>very</em> cocky.</p>
<p>Image generation wasn't great either: you'd order red bolts and get mushrooms. Why? No idea. Maybe the shape, maybe the color. But like everything in this world, enough work, nerves, and swearing gets you something more or less not-terrible.</p>
<p>Is it faster to just do it by hand? For code — the other way around, actually: by hand is faster. But only if you don't count the tests, of which there are more than the code itself — so, nuances. For the art — I could never have drawn it myself, but any artist would have done it better. So draw your own conclusions. The bar will probably rise, and projects like this will become genuinely possible. Right now, the level of quality AI can carry is an MVP or a prototype. Past that, it's a lottery.</p>
<p>But I learned a lot for myself: Figma, how to do e2e tests — and how not to do them. So I count the experience as a win. I'd love to have more time for things like this, but alas: a day has 24 hours, food doesn't earn itself, and the kid needs to get to school. So I had my fun. That'll do.</p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[What's the Best Board Game?]]></title>
            <link>https://alexblog.net/best-board-games/</link>
            <guid isPermaLink="false">https://alexblog.net/best-board-games/</guid>
            <pubDate>Sat, 26 Aug 2023 00:00:00 GMT</pubDate>
            <description><![CDATA[Three hundred-odd boxes later, people still ask me what to play. Fine. Here's a pick of games across different categories that won't let you down.]]></description>
            <content:encoded><![CDATA[<p>People ask me this all the time. At its peak my collection ran to <a href="https://boardgamegeek.com/collection/user/AlexKucherenko">three hundred-something boxes</a>, I've played through a lot of them, trust me, and the question "so what should we play, what do you recommend?" has been chasing me for years. So I decided to put together a few games, across different categories, that won't let you down.</p>
<h2>Gloomhaven: Jaws of the Lion</h2>
<p><a href="https://boardgamegeek.com/boardgame/291457/gloomhaven-jaws-of-the-lion">A masterpiece of a dungeon crawler</a>. On paper it's a fairly brainy game and you need to know how to play it, but god, the balance, the synergy between the cards and your character. I don't know how they do it, but you win on your last card, on your last hit point. And when you lose, you always know exactly what you did wrong. Tons of scenarios, four characters in the box with real progression, a quick and painless setup, and the best part — the price: fifty bucks for a good fifty hours of content! I don't recommend its big brother with a 120-hour campaign, or the new Frosthaven, which runs god knows how long, because realistically you don't have that much life left, and your friends certainly don't. Components, gameplay, replayability, and that price — I don't think anything has topped this little box. An absolutely brilliant game, can't recommend it enough. My wife and I run it as a two-player game; getting a bigger party together is the hard part.</p>
<p><img src="https://alexblog.net/best-board-games/images/gloomhaven-jotl.webp" alt="Official spread of Gloomhaven: Jaws of the Lion: the box surrounded by the scenario book, character mats, cards, standees and miniatures" loading="lazy"></p>
<h2>Terraforming Mars</h2>
<p><a href="https://boardgamegeek.com/boardgame/167791/terraforming-mars">Terraforming Mars</a> is something else. Over two hundred cards, unbelievable combinations of things you can build, and no two games ever look alike. I don't know what you call the genre where you get a huge pile of cards and try to squeeze the maximum out of a random deal every time, but every game is a battle with my wife and every game is a new experience. Fair warning: the standard edition has some issues with component quality and the player boards, so buy the organizer and the proper dual-layer boards right away. I also picked up metal cubes, but that's pure indulgence. As a two-player game it's an absolute hit in our family.</p>
<div class="gallery" data-island="gallery"><div data-gallery-viewport="true"><div data-gallery-track="true"><img src="https://alexblog.net/best-board-games/images/terraforming-mars/01-board-and-trays.webp" alt="Our Terraforming Mars game in progress: the board, wooden organizer trays and player mats · 1" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/best-board-games/images/terraforming-mars/02-cards-and-cubes.webp" alt="Our Terraforming Mars game in progress: the board, wooden organizer trays and player mats · 2" loading="lazy" data-gallery-slide="true"></div><button type="button" data-gallery-nav="prev" aria-label="Previous photo"><span>&#x3C;</span></button><button type="button" data-gallery-nav="next" aria-label="Next photo"><span>></span></button></div><div data-gallery-dots="true"><button type="button" data-gallery-dot="true" aria-label="Photo 1"></button><button type="button" data-gallery-dot="true" aria-label="Photo 2"></button></div></div>
<h2>Clank!</h2>
<p>The <a href="https://boardgamegeek.com/boardgame/201808/clank-a-deck-building-adventure">Clank!</a> series, from the regular one to the legacy version: grab any of them, you can't go wrong. Very simple, very fun, best with three to five players. It doesn't run long, if you don't drag it out, and there are plenty of ways to throw a friend under the bus. What else do you need.</p>
<p><img src="https://alexblog.net/best-board-games/images/clank.webp" alt="Clank! box with the dungeon board, meeples and dragon silhouette in the publisher&#x27;s render" loading="lazy"></p>
<h2>Detective</h2>
<p>My wife and I adore detective shows: Castle, The Mentalist, Bones, Columbo (if you're old enough) — all rewatched twenty times over. And there are board games that let you step into those detectives' shoes and try to crack a case yourself. There are quite a few, some harder than others, but the one that stuck with me is <a href="https://boardgamegeek.com/boardgame/223321/detective-a-modern-crime-board-game">Detective: A Modern Crime Board Game</a>. You'll need a computer. The game essentially hands you a simulated police database and leans heavily on the actual internet for solving its puzzles. I can't spoil any more than that, sadly. You have to play it yourself and enjoy the process. Not all the cases are equally good, but some are genuinely excellent.</p>
<p><img src="https://alexblog.net/best-board-games/images/detective.webp" alt="Detective board close-up: the city map with the time track, the Case 1 &#x22;A Man with a Golden Watch&#x22; case book and stacks of clue tokens" loading="lazy"></p>
<h2>Unlock!</h2>
<p>Escape rooms: you can play these at the table instead of grimy basements and secret rooms in apartment blocks (those who've been know exactly what I mean). Sure, the weird rooms and flats have their charm, all those physical gimmicks. But if you really want to play at home, the <a href="https://boardgamegeek.com/boardgame/213460/unlock-escape-adventures">Unlock!</a> series is our favorite: clever, logical escape quests (hi, Exit), an app that gives you hints and makes the whole thing more interactive, and a different style and theme every time — you genuinely feel the atmosphere of each adventure. The downside, same as every game of this kind: they're one-shot. Once you've solved it, sell it, gift it, or shelve it until you forget what was inside.</p>
<p><img src="https://alexblog.net/best-board-games/images/unlock.webp" alt="Four players leaning over Unlock! cards, with the box and the companion-app tablet nearby" loading="lazy"></p>
<h2>Brass: Birmingham</h2>
<p>I love economic games: money, income, all of that. But I despise Monopoly — a game where you literally decide nothing and nothing depends on you. It's nonsense; how people keep fussing over it, and why, is beyond me. Thank god there are proper games where your decisions matter and decide the outcome, and most importantly, shape the outcome for your opponents. The <a href="https://boardgamegeek.com/boardgame/224517/brass-birmingham">Brass</a> series by Martin Wallace is my favorite. The components are superb: the deluxe version has literal poker chips, the board looks downright gorgeous, and the whole art direction is stylish as hell. Very unusual mechanics (this isn't your dice-chucking) and the best interaction model I've seen in this kind of game: everyone at the table depends on everyone else and on each other's decisions. I've seen proper backstabbing there, and I've seen genius moves where you literally force your opponent to play the way you need them to. Nobody here gets to quietly tend their own garden: it's direct competition inside one interconnected system. Highly recommended. Two to four players, but four is where it gets really interesting.</p>
<p><img src="https://alexblog.net/best-board-games/images/brass-birmingham.webp" alt="Brass: Birmingham mid-game: the dark canal-era board with industry tiles, coins and player mats" loading="lazy"></p>
<h2>London</h2>
<p>My little love, and Martin Wallace again, see above. <a href="https://boardgamegeek.com/boardgame/236191/london-second-edition">London</a> is a small box with a ridiculous number of clever mechanics inside. It's strictly a card game: there is a board, but it's nominal. There's a caveat, of course. Know the deck well and some cards are clearly better than others. I honestly can't explain why I love it so much, but I do, for the life of me. It's a balancing act between wealth and poverty, where you rake in a pile of money and then blow it all on other cards, where you live on the edge between failure and success. One wrong decision and you're simply done; one right one, plus some luck with the cards, and you're riding high. That emotional ride is probably why I love it. Strictly two players. And I won't even recommend it: it's my game, and good luck finding a copy these days anyway. But if you ever see one — take it, you won't regret it.</p>
<p><img src="https://alexblog.net/best-board-games/images/london.webp" alt="First-edition London mid-game: the city map with wooden buildings, rows of cards and loan tokens" loading="lazy"></p>
<h2>Through the Ages</h2>
<p>The monster civilization game, the mother and father of all civilization games. <a href="https://boardgamegeek.com/boardgame/182028/through-the-ages-a-new-story-of-civilization">Through the Ages</a> is a minimum, again, a <em>minimum</em> of eight hours of uninterrupted play with two players. Don't even attempt it with three or four: you will never, ever finish. The game has three small boards, no world map at all, really just mats for holding playing cards. And hundreds of cards: war is in there, peace, science, culture. How any of this works, I have no idea, but it works. You sit down intending to quietly build a civilization with your wife and see who scores more points, and it all rapidly turns into a bloodbath: the constant threat of war, resource shortages, the fight for colonies. And all of it on ordinary playing cards. How, tell me, how does this all work?! I can only recommend it if you're a proper geek. Not just any geek — that exact breed of nerd who's ready to fight with a friend or a spouse for eight hours straight — and not fall out, and not get divorced, when that snake declares war on you out of nowhere.</p>
<div class="gallery" data-island="gallery"><div data-gallery-viewport="true"><div data-gallery-track="true"><img src="https://alexblog.net/best-board-games/images/through-the-ages/01-in-progress.webp" alt="Our Through the Ages session: card rows, a civilization board and resource cubes · 1" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/best-board-games/images/through-the-ages/02-full-table.webp" alt="Our Through the Ages session: card rows, a civilization board and resource cubes · 2" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/best-board-games/images/through-the-ages/03-moses-card.webp" alt="Our Through the Ages session: card rows, a civilization board and resource cubes · 3" loading="lazy" data-gallery-slide="true"></div><button type="button" data-gallery-nav="prev" aria-label="Previous photo"><span>&#x3C;</span></button><button type="button" data-gallery-nav="next" aria-label="Next photo"><span>></span></button></div><div data-gallery-dots="true"><button type="button" data-gallery-dot="true" aria-label="Photo 1"></button><button type="button" data-gallery-dot="true" aria-label="Photo 2"></button><button type="button" data-gallery-dot="true" aria-label="Photo 3"></button></div></div>
<h2>Mage Knight</h2>
<p>Some games have mechanics better than the game itself. The deck-building in <a href="https://boardgamegeek.com/boardgame/96848/mage-knight-board-game">Mage Knight</a> is absolutely brilliant, but everything around it is so convoluted that I'm not prepared to actually play it. I just sit down sometimes purely for the deck-building system itself, for that card synergy: chaining combo after combo, wriggling like an eel in a frying pan to squeeze out one more play and deal damage to some nameless monster. At its core it's tabletop Heroes of Might and Magic: you move, you flip a tile, you hire units, you kill monsters — that's it, there's nothing else. I could spend hours criticizing this game: how tangled it is, how dull the missions are, how arbitrary the random monsters feel. But god, the card mechanics in there are gorgeous. Oh, if only there were an option to get this engine in some other wrapper.</p>
<div class="gallery" data-island="gallery"><div data-gallery-viewport="true"><div data-gallery-track="true"><img src="https://alexblog.net/best-board-games/images/mage-knight/01-hex-map.webp" alt="Our Mage Knight session: the hex map with hero miniatures and enemy tokens · 1" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/best-board-games/images/mage-knight/02-fame-board.webp" alt="Our Mage Knight session: the hex map with hero miniatures and enemy tokens · 2" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/best-board-games/images/mage-knight/03-full-table.webp" alt="Our Mage Knight session: the hex map with hero miniatures and enemy tokens · 3" loading="lazy" data-gallery-slide="true"></div><button type="button" data-gallery-nav="prev" aria-label="Previous photo"><span>&#x3C;</span></button><button type="button" data-gallery-nav="next" aria-label="Next photo"><span>></span></button></div><div data-gallery-dots="true"><button type="button" data-gallery-dot="true" aria-label="Photo 1"></button><button type="button" data-gallery-dot="true" aria-label="Photo 2"></button><button type="button" data-gallery-dot="true" aria-label="Photo 3"></button></div></div>
<h2>Alchemists</h2>
<p>There's this thing called a puzzle. Now imagine solving a puzzle while playing a board game: that's <a href="https://boardgamegeek.com/boardgame/161970/alchemists">Alchemists</a>. I haven't played a harder game — and not because the rules are complicated. The point is that you have to crack something like a sudoku puzzle and somehow play the game at the same time. Your solution, even a partial one, has to be woven into your play: publishing theories, debunking your rival's publications and even your own, taking risks, guessing, exploiting your opponent to extract more information for the puzzle. Only to find out later that you got it wrong, solved the whole thing incorrectly — and won anyway. That's Alchemists for you.</p>
<div class="gallery" data-island="gallery"><div data-gallery-viewport="true"><div data-gallery-track="true"><img src="https://alexblog.net/best-board-games/images/alchemists/01-lab-screen.webp" alt="Our Alchemists game: the main board, a lab screen with the potion grid and the ingredient triangle · 1" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/best-board-games/images/alchemists/02-overhead.webp" alt="Our Alchemists game: the main board, a lab screen with the potion grid and the ingredient triangle · 2" loading="lazy" data-gallery-slide="true"></div><button type="button" data-gallery-nav="prev" aria-label="Previous photo"><span>&#x3C;</span></button><button type="button" data-gallery-nav="next" aria-label="Next photo"><span>></span></button></div><div data-gallery-dots="true"><button type="button" data-gallery-dot="true" aria-label="Photo 1"></button><button type="button" data-gallery-dot="true" aria-label="Photo 2"></button></div></div>
<h2>Cosmic Encounter</h2>
<p>There's a pile of social games out there: all the Mafias, Dixits, Imaginariums, Codenames. They're fun for a couple of plays, then it all starts wearing thin: you get the emotions, but mechanically it's, well, whatever. And there's one game that simply tears those Mafias and Dixits apart in a single breath. Betrayal on the level of <a href="https://boardgamegeek.com/boardgame/39463/cosmic-encounter">Cosmic Encounter</a> you have never seen or experienced in your life. The box holds fifty aliens, broken from start to finish. Some swap cards with you. Some get to peek at the combat card the whole battle hinges on. There's even a Bride character who picks herself a husband and can look at his cards, the husband can't attack her, and when she divorces him, she takes half his cards. Can you picture the level of madness at a table with characters like these? If you haven't played it you won't get it, and if you have, you already know exactly what I mean. This game destroyed every social game for me. The fun bar is set so high that nothing else even comes close.</p>
<p><img src="https://alexblog.net/best-board-games/images/cosmic-encounter.webp" alt="Cosmic Encounter in progress: planet discs with stacked plastic ships and the warp in the middle of the table" loading="lazy"></p>
<div class="section-divider"><span class="section-divider-ornament">***</span></div>
<p>So that's my little selection. Have fun out there.</p>
<p><em>Photos: <a href="https://commons.wikimedia.org/wiki/File:Playing_board_game_-_Play_1048_1724352635173.jpg">El Pantera</a>, <a href="https://www.flickr.com/photos/wetwebwork/14291336656">wetwebwork</a>, <a href="https://commons.wikimedia.org/wiki/File:Cosmic_Encounter.jpg">JIP</a> · CC BY-SA / CC BY. Press photos: © <a href="https://cephalofair.com/products/gloomhaven-jaws-of-the-lion">Cephalofair Games</a>, <a href="https://www.direwolfdigital.com/clank/">Dire Wolf Digital</a>, <a href="https://en.portalgames.pl/detective-photo-gallery/">Portal Games</a>, <a href="https://www.spacecowboys-games.com/game/unlock/">Space Cowboys</a>. The rest of the photos are mine.</em></p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[New in the Collection: Descent Journeys in the Dark]]></title>
            <link>https://alexblog.net/descent-journeys-in-the-dark/</link>
            <guid isPermaLink="false">https://alexblog.net/descent-journeys-in-the-dark/</guid>
            <pubDate>Sat, 27 Feb 2016 00:00:00 GMT</pubDate>
            <description><![CDATA[It finally arrived -- the long-awaited and controversial purchase. I really wanted it, but the price and reviews about its balance kept me from pulling the trigger. A bit of spontaneity and here it is.]]></description>
            <content:encoded><![CDATA[<p>It finally arrived -- the long-awaited and controversial purchase. I really wanted it, but the price and reviews about its balance kept me from pulling the trigger. A bit of spontaneity and here it is.</p>
<blockquote>
<p>In the darkest depths of Terrinoth, an ambitious overlord gathers his minions to lay siege on the world above. Only a small band of heroes, gifted with courage and power, will be able to save the land from the cold grip of domination. Now is the time to venture into the dark and unravel the overlord's plot before it's too late...</p>
</blockquote>
<p><img src="https://alexblog.net/descent-journeys-in-the-dark/images/dj-1.webp" alt="Descent board game box contents: rulebooks, campaign map, cards, dice, tokens, and miniatures laid out" loading="lazy"></p>
<h2>What the game is about</h2>
<p>The game is about the epic adventure of heroes in the dungeons of a Dark Evil Overlord who won't miss a chance to show the heroes who's boss. It's a game for 2--5 players -- where am I supposed to find that many friends? One player takes on the role of universal evil and becomes the Overlord. The rest become regular heroes, divided into classes and archetypes. Heroes roam dungeons, search for treasure, fight the Overlord's minions, buy equipment... in short, they go all out.</p>
<p>All combat is dice-rolling -- there are both melee and ranged attacks. And of course spells, lots of spells. Can't do without them -- it's fantasy, after all.</p>
<p>The game features both standalone missions and campaigns. A campaign is a set of individual missions tied together by an overarching story. Heroes can carry their hard-earned (read: looted) gear into the next mission, accumulating a small fortune by the end of the campaign. You won't finish a campaign in a single day -- it takes dozens of hours, and the game thoughtfully provides a save system: a notebook and pencil.</p>
<div class="gallery" data-island="gallery"><div data-gallery-viewport="true"><div data-gallery-track="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/board/01-campaign-board.jpg" alt="Descent campaign board and rulebooks spread out on the table · 1" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/board/02-rulebooks.webp" alt="Descent campaign board and rulebooks spread out on the table · 2" loading="lazy" data-gallery-slide="true"></div><button type="button" data-gallery-nav="prev" aria-label="Previous photo"><span>&#x3C;</span></button><button type="button" data-gallery-nav="next" aria-label="Next photo"><span>></span></button></div><div data-gallery-dots="true"><button type="button" data-gallery-dot="true" aria-label="Photo 1"></button><button type="button" data-gallery-dot="true" aria-label="Photo 2"></button></div></div>
<h2>Impressions</h2>
<p>Haven't actually played yet -- haven't even finished reading the rules (a 20-page booklet). But I'll definitely report back once I get the chance to try it.</p>
<p>The miniatures are impressive -- they're quite detailed and genuinely menacing. I liked the tile system: you lay out the dungeon map from tiles, and since there are so many of them, it gives the game great replayability and variety.</p>
<div class="gallery" data-island="gallery"><div data-gallery-viewport="true"><div data-gallery-track="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/miniatures/01-heroes-and-monsters.webp" alt="Descent hero and monster miniatures: heroes, cream creatures and red demons with a dragon · 1" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/miniatures/02-overlord-monster.webp" alt="Descent hero and monster miniatures: heroes, cream creatures and red demons with a dragon · 2" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/miniatures/03-dragons.webp" alt="Descent hero and monster miniatures: heroes, cream creatures and red demons with a dragon · 3" loading="lazy" data-gallery-slide="true"></div><button type="button" data-gallery-nav="prev" aria-label="Previous photo"><span>&#x3C;</span></button><button type="button" data-gallery-nav="next" aria-label="Next photo"><span>></span></button></div><div data-gallery-dots="true"><button type="button" data-gallery-dot="true" aria-label="Photo 1"></button><button type="button" data-gallery-dot="true" aria-label="Photo 2"></button><button type="button" data-gallery-dot="true" aria-label="Photo 3"></button></div></div>
<p>On the downside, there's the balance issue. From what I've read in the rules, playing the Overlord seems kind of dull, and the heroes almost always have the upper hand -- they're essentially immortal.</p>
<div class="gallery" data-island="gallery"><div data-gallery-viewport="true"><div data-gallery-track="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/closeups/01-all-minis.webp" alt="Descent miniatures lined up, with close-ups of the monster figures · 1" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/closeups/02-winged-demon.webp" alt="Descent miniatures lined up, with close-ups of the monster figures · 2" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/closeups/03-tentacled-monster.webp" alt="Descent miniatures lined up, with close-ups of the monster figures · 3" loading="lazy" data-gallery-slide="true"></div><button type="button" data-gallery-nav="prev" aria-label="Previous photo"><span>&#x3C;</span></button><button type="button" data-gallery-nav="next" aria-label="Next photo"><span>></span></button></div><div data-gallery-dots="true"><button type="button" data-gallery-dot="true" aria-label="Photo 1"></button><button type="button" data-gallery-dot="true" aria-label="Photo 2"></button><button type="button" data-gallery-dot="true" aria-label="Photo 3"></button></div></div>
<div class="gallery" data-island="gallery"><div data-gallery-viewport="true"><div data-gallery-track="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/tiles/01-caverns.webp" alt="Descent dungeon tiles: fiery caverns, icy corridors and a full assembled map layout · 1" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/tiles/02-icy-caves.webp" alt="Descent dungeon tiles: fiery caverns, icy corridors and a full assembled map layout · 2" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/tiles/03-treasure-tile.webp" alt="Descent dungeon tiles: fiery caverns, icy corridors and a full assembled map layout · 3" loading="lazy" data-gallery-slide="true"><img src="https://alexblog.net/descent-journeys-in-the-dark/images/tiles/04-full-layout.webp" alt="Descent dungeon tiles: fiery caverns, icy corridors and a full assembled map layout · 4" loading="lazy" data-gallery-slide="true"></div><button type="button" data-gallery-nav="prev" aria-label="Previous photo"><span>&#x3C;</span></button><button type="button" data-gallery-nav="next" aria-label="Next photo"><span>></span></button></div><div data-gallery-dots="true"><button type="button" data-gallery-dot="true" aria-label="Photo 1"></button><button type="button" data-gallery-dot="true" aria-label="Photo 2"></button><button type="button" data-gallery-dot="true" aria-label="Photo 3"></button><button type="button" data-gallery-dot="true" aria-label="Photo 4"></button></div></div>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[Starting Development of STDS]]></title>
            <link>https://alexblog.net/stds/</link>
            <guid isPermaLink="false">https://alexblog.net/stds/</guid>
            <pubDate>Sat, 20 Feb 2016 00:00:00 GMT</pubDate>
            <description><![CDATA[Spring is coming and I'm getting restless. An idea was born: build a multiplayer top-down shooter in the browser with JavaScript, set in space. Working title: STDS (Space Top Down Shooter).]]></description>
            <content:encoded><![CDATA[<p>Spring is coming and I'm getting restless. An idea was born: build a multiplayer top-down shooter in the browser with JavaScript, set in space. Working title: STDS (Space Top Down Shooter).</p>
<h2>Main stack</h2>
<p>Plain JavaScript ES5</p>
<p>JS modules in CommonJS format (like Node.js, but on the frontend)</p>
<p><a href="https://webpack.github.io/">Webpack 1.x</a> -- not sure 2.x is solid yet; even the first branch is still rough (IMHO). We'll see, maybe we'll migrate.</p>
<p>Rendering by <a href="http://www.pixijs.com/">PIXI JS</a></p>
<p>Using <a href="http://phaser.io/">PHASER IO</a> as the game engine</p>
<p>CSS generated from <a href="http://lesscss.org/">Less</a> -- I just like it</p>
<p>You can follow the project's progress on GitHub: <a href="https://github.com/AlexTiTanium/Space-Top-Down-Shooter">STDS</a>. Too early to think about the server side, but it'll most likely be Node.js.</p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[Bad Monday]]></title>
            <link>https://alexblog.net/bad-monday/</link>
            <guid isPermaLink="false">https://alexblog.net/bad-monday/</guid>
            <pubDate>Sat, 05 Dec 2015 00:00:00 GMT</pubDate>
            <description><![CDATA[It's 4 AM, insomnia got me again. Usually I go work. For me this is a blessed time of peace and quiet -- what more do you need to write beautiful, maintainable code?]]></description>
            <content:encoded><![CDATA[<p>It's 4 AM, insomnia got me again. Usually I go work. For me this is a blessed time of peace and quiet -- what more do you need to write beautiful, maintainable code?</p>
<p>Beautiful code... let's talk about that, shall we?</p>
<p>Recently I was listening to Radio-T (a popular Russian tech podcast) -- don't remember which episode, one of the recent ones -- where they discussed an article about outsourcing.</p>
<p>Yet another client decided to save money and ordered iOS app development from the cheapest company they could find on the internet. The result is obvious: a complete disaster. And he goes point by point explaining why, in his opinion, everything went wrong.</p>
<p>Of course nobody's willing to admit the correlation between price and quality. It must be something else, right? You want to believe you can get the best for five bucks an hour. Though you should understand that a big price tag doesn't guarantee anything either.</p>
<p>There was a lot of back-and-forth on this topic. Umputun's brief take was that there's a difference in mentality: for Americans, the result is everything, the process is nothing. For Russians, the process is everything, and the result is a side effect. For Indians, one thing's rubbish, another thing's rubbish -- life's too short to sweat it.</p>
<p>So I'm sitting there thinking: damn, there's something to this. Should you even bother with the process?</p>
<p>After all, the client needs results, and what am I doing? I'm fussing over the process. Sure, I fuss over it to produce the best result I'm capable of, but you could get results faster.</p>
<p>The task: write a simple autocomplete for a service. Just shove everything into the damn controller that renders the list of results and call it a day.</p>
<p>But no, I have to extract it into a separate controller, refactor it -- because someone will come along to maintain this, and what will they think of me?</p>
<p>And what about the server side? A normal, efficient programmer would just regex through the database and be done with it. Result achieved.</p>
<p>But no, we're "special." Everything has to be fast, and the autocomplete has to actually help you type something. Instead of regex search, let's build a prefix tree? And let's add tag weights to surface the most popular ones first. And of course we need tests and documentation.</p>
<p>And just like that, I'm deep in the weeds of process.</p>
<p>This is not results. The client doesn't need any of this. Not only doesn't need it, but it's actively harmful -- financially and deadline-wise. And in a year they'll rewrite the whole thing anyway, if anyone still cares.</p>
<p>Maybe subconsciously I realize I'm paid by the hour, and if I do it in one hour instead of ten, I'll earn less? No, that's not it -- I did the same thing back when I worked for a fixed price.</p>
<p>What's bad is that for me this is a conscious choice. I don't want to change, to adapt to the market. (Probably why the last time I looked for a job it took me about three months.)</p>
<p>Kill me, but I want to write proper code. And proper code takes time, and time is money. The conclusion: by all rights, I should be fired. In this unequal battle between process and results, I'm the broken gear.</p>
<p>Beautiful code to you all, friends!</p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[The Making of Fun Da Vinci]]></title>
            <link>https://alexblog.net/fun-da-vinci/</link>
            <guid isPermaLink="false">https://alexblog.net/fun-da-vinci/</guid>
            <pubDate>Wed, 04 May 2011 00:00:00 GMT</pubDate>
            <description><![CDATA[Here's where it all started, and here's what we ended up with. We changed a lot, assembled a solid team, and gained experience selling a game. We'll share all of it with you.]]></description>
            <content:encoded><![CDATA[<p><a href="https://geeklife.in.ua/2010/07/27/make-game-in-nine-days/">Here's</a> where it all started, and <a href="http://armorgames.com/play/10964/fun-da-vinci">here's</a> what we ended up with. We changed a lot, assembled a solid team, and gained experience selling a game -- we'll share all of it with you, dear reader. Interested? Welcome aboard.</p>
<h2>Programming</h2>
<p>This part's simple -- me and Nikita Sidorenko (Division). People say two programmers on one simple Flash game is too many. I categorically disagree. In practice, if you write a game solo, you eventually give up. Maybe it's just us, but trust me, we tried -- working as a pair is both more fun and more productive. I explain it through guilt: if one person is working on something, the other feels ashamed for slacking and starts pitching in.</p>
<p><img src="https://alexblog.net/fun-da-vinci/images/fdv-1.webp" alt="Four art style iterations of the game: early cartoon prototype, item sprites, neon concept, and final Leonardo manuscript style" loading="lazy"></p>
<h2>Design</h2>
<p>Everything starts with design, and in a quality game it's front and center. As you know, I was the one who drew (read: borrowed) the art for the previous version. That flies for a contest, but for a real product you need proper, and most importantly, original art. Kherson is a small city -- everyone knows everyone -- and there aren't many good designers around, so we didn't have to search long for <a href="https://www.youtube.com/watch?v=K7r9dbqYrHs">Semyon Khramtsov, the "freelancer from the provinces"</a>.</p>
<p><em>"Even though I didn't particularly like puzzles, the guys' enthusiasm was infectious. First I proposed several art styles, including a stylization of Leonardo's manuscripts. That's the one everyone fell for. Sure, it's a pretty mainstream trick in graphic design, but the allure of synthesizing an interactive environment with that famous sketch style of the Renaissance mega-designer was too strong. I re-read his biography, googled all his works to mine as much flavor for the game as possible -- basically, immersed myself in the right atmosphere as deeply as I could. Of course, I consider the result an affront to his image and inventions, but who are we compared to the Master? Thank God I didn't repaint the game in bright 'casual' colors, as one of the judges suggested at the game-lynch -- we'd definitely be burning in hell for that. My biggest achievement in this project was diving into level design and combining the artist's grim self-portrait with the Mona Lisa's smile :)" -- Semyon Khramtsov</em></p>
<p><img src="https://alexblog.net/fun-da-vinci/images/fdv-2.jpg" alt="Da Vinci self-portrait evolution: original sketch, grayscale adaptation, and final game version with Mona Lisa smile" loading="lazy"></p>
<h2>Sound and Music</h2>
<p>The last but by no means least area -- the musical and sound design of the game. It doesn't just bring the game to life; it keeps the player from getting bored -- at least until it starts to annoy them. Semyon happened to know a very talented composer he'd been working with on small Flash projects for a while -- <a href="http://requix.promodj.ru/">Vladimir Marinichev</a> -- which was perfect timing.</p>
<p><em>"First things first, to immerse myself in the Renaissance atmosphere, I re-watched a biographical film about Leonardo da Vinci and listened to works by composers of that era. For the menu theme, I based it on a piece by Claude Gervaise that would convey the mood of a thoughtful Creator and transport the player to the Renaissance. I tried to keep the arrangement and instrumentation true to the style of that period.
For the gameplay sound design, many sounds were recorded using everyday objects. We decided to leave the levels without a musical score and instead convey the atmosphere of a workshop. During gameplay you can hear Leonardo constantly scribbling notes and humming something to himself, engrossed in yet another experiment.
Unfortunately, in trying to reduce the game's file size, we had to degrade the quality of all sounds and music, which of course made it harder to realistically convey all the nuances and details. But overall, many people were happy with the result, and it was very gratifying to receive a high rating from FGL." -- Vladimir Marinichev</em></p>
<p>And so, with the team assembled, off we went...</p>
<h2>The Beginning</h2>
<p>The first commit was on August 4, 2010 -- that's when you can mark the start of development. We slowly began fixing bugs, and since we all had day jobs, progress was very slow. We set up a task manager, created a pile of tasks, had lots of discussions -- all about nothing, really. In its final form, if you took all the tasks into account, the game should have looked like a mini Starcraft 2 with its own BattleNet (which had just come out at the time -- very distracting). The unrealistic scope of our plans was our first mistake. Don't build enormous castles. You'll abandon most of it once you realize your plans would have surprised even Napoleon. That's exactly what happened to us. We came to our senses when we decided to participate in FlashGamm KYIV 2010. We needed something to show, and we had nothing -- not even a working prototype. That's when the real work began. First we axed most of the tasks, minimized and optimized everything we could. And that produced results.</p>
<p><img src="https://alexblog.net/fun-da-vinci/images/fdv-4.webp" alt="Task manager screenshot: a to-do list including &#x22;Stop playing Starcraft 2&#x22; with humorous entries" loading="lazy"></p>
<h2>FlashGamm KYIV 2010</h2>
<p>By the start of the conference we'd managed to cobble together something resembling a prototype and build a few lackluster levels. With that, Semyon and Nikita headed to Kyiv. We competed in the Indie category, plus we signed up for the game-lynch (a public critique session) -- oh boy, did they tear us apart... But the game-lynch itself was incredibly useful. They pointed out our game's shortcomings, our mistakes, and gave us advice on where to go next. The encouraging part was that we'd already anticipated many of the flaws they called out. During the audience vote for best game-lynch game, ours scored exactly 0 points (zen mode) -- and after that, none of us expected to win anything at all. But out of nowhere, we won the "Future Hit" nomination! The joy was immense. I think this event had a really positive effect on the team's morale -- we realized there was something to this game and we needed to finish it no matter what.</p>
<p><img src="https://alexblog.net/fun-da-vinci/images/fdv-5.webp" alt="The Fun Da Vinci team receiving the Future Hit award at FlashGamm KYIV 2010 conference" loading="lazy"></p>
<h2>Wrapping Up</h2>
<p>After the conference, development settled into a steady rhythm and everything ran like clockwork. But there were annoying bugs. The first was objects falling through other objects -- a ghost that had haunted us since Ball Factory (the first prototype). We solved it pragmatically, by trial and error. I started tinkering with Box2D settings and found a quirk: changing <code>b2_aabbMultiplier</code> from 0.2 to 0.1 magically fixed the fall-through issue. The second bug crept up from where we least expected. Everywhere we needed a button, we used <code>SimpleButton</code>, and it never even crossed our minds that in newer Flash Player versions, button states would start "sticking" -- and there was no way to fix it no matter what hacks we tried. We had to build our own solution. Discussion on <a href="http://www.flasher.ru/forum/showthread.php?t=148860">flasher.ru</a>.</p>
<h2>Selling the Game</h2>
<p>Another thing happened at FlashGamm -- we met Stefan Keisch. We had no idea how to sell a game. There was some information online about FGL (Flash Game License), but it was all vague, and you needed to attract sponsors. That's exactly what Stefan does -- not for free, of course: 30% of the game's sale price. After some thought and deliberation, we accepted the offer. Stefan also gave recommendations on how to make the game more attractive to sponsors. The first thing he pointed out was the name. Our working title was "Balls Da Vinci" -- that's the name we competed under at FlashGamm. Unfortunately, the name triggered too many unwanted associations with Leonardo's manhood (which we found hilarious, admittedly), so we rebranded to "Fun Da Vinci" (maybe it's only a puzzle for us -- for da Vinci's brain, it's child's play).</p>
<p>The listing went up on FGL on January 14, 2011. FGL reviews every game submitted.</p>
<p><em>Review Information:</em></p>
<p>Intuitiveness: 7 Good
Fun: 6 Average
Graphics: 7 Good
Sound: 8 Great
Quality: 7 Good
Overall: 7 Good</p>
<p>Comments:
Standard execution of a traditional physics game with a Leonardo da Vinci theme
Creative level design, familiar game mechanics, polished interface
Lacks depth beyond first playthrough. Consider adding achievements or a scoring mechanism of some sort</p>
<p>Right away someone offered us $1,000, but we wanted more -- at least $3K. That bid sat there for about a month. We were starting to lose hope when suddenly several major portals began competing for the game (I suspect Stefan had something to do with it). Bids rose and fell. We noticed that sponsor activity picked up toward the end of each week. When the bid hit $3,000, we nearly caved and accepted, but a certain bald someone suggested we hold out -- right at that moment, we'd received an offer from <a href="http://jayisgames.com/">jayisgames.com</a> to write a <a href="http://jayisgames.com/archives/2011/03/fun_da_vinci.php">review</a> of the game. We were thrilled -- besides the "street cred" from a well-known portal, it could trigger a new wave of bids. And it did... After another round of sponsor bidding wars, the price settled at $5,600. We strangled our inner cheapskate and accepted. That was another victory. From listing on FGL to accepting the bid: exactly 2 months.</p>
<p>As you've probably guessed, Armor Games won. Sponsors usually ask you to brand the game for their portal -- add an intro, integrate their API, and so on. All easy enough, but we ran into problems with the Armor Games API, which absolutely refused to connect. We made a blank project -- worked perfectly there. In the actual game project -- nope. By some miracle, Nikita figured out that if you initialized the API in the preloader, everything was fine. Weird, but whatever.</p>
<h2>Splitting the Money</h2>
<p>So, you all want to know how much we made? Here's the breakdown:
FGL takes 10% for its services (half of which Stefan pays by agreement);
10% of 5,600 = 560 (FGL's cut);
560 / 2 = 280 (our share of FGL's fee);
30% of 5,600 = 1,680 (Stefan's cut);
Bottom line: 5,600 -- 280 -- 1,680 = 3,640.</p>
<p>Three and a half grand split four ways over four months -- not a bad result, for Nigerian guest workers. How to make a living from games alone? -- a question that puzzled us. But our enthusiasm only grew, and we're not giving up this hobby. We'll figure out the answer eventually -- maybe even in the comments to this post ;)</p>
<h2>Future Plans</h2>
<p>We're already working on a second version with new items, levels, and features. We have ideas for interesting game mechanics and unexplored themes. On the technical side, we're looking toward the App Store and Android Market, with Unity3D to help us get there.</p>
<p><img src="https://alexblog.net/fun-da-vinci/images/fdv-3.webp" alt="The Fun Da Vinci development team: Division (Lead Programmer), Titanum (Programmer), Xsem (Art and Design), Requix (Sound)" loading="lazy"></p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
        <item>
            <title><![CDATA[Making a Game in Nine Days -- Is That Even Possible?!]]></title>
            <link>https://alexblog.net/ball-factory/</link>
            <guid isPermaLink="false">https://alexblog.net/ball-factory/</guid>
            <pubDate>Tue, 27 Jul 2010 00:00:00 GMT</pubDate>
            <description><![CDATA[I'm not really the adventurous type, but sometimes it happens. And ten days ago, it happened. A buddy suggested I enter an IGDC game jam. Here are my impressions.]]></description>
            <content:encoded><![CDATA[<p>I'm not really the adventurous type, but sometimes it happens. And ten days ago, it happened. Exactly ten days ago, my buddy Nikita (who goes by Division) suggested I enter an IGDC game jam.
Here are my impressions...</p>
<h2>The Contest</h2>
<p>For those who don't know, IGDC is a community of people who enjoy making games. A contest is announced, rules are set, and off you go -- anyone can participate. The theme for this particular contest was genuinely interesting: Indirect Control. The idea is that the player has no direct control over the game process. You can only influence things indirectly.</p>
<p>I'd never entered one of these contests before, unlike Nikita, who had even placed in the top ranks. We decided to write in ActionScript 3, which I knew only vaguely. Why AS? Simply because Division had an engine built on AS that used the well-known Decorator pattern -- used it so heavily, in fact, that the engine itself was called Decorator.</p>
<p>When I heard "indirect," the famous puzzle game "The Incredible Machine" came to mind. I got the idea to make something similar but change the goal. In The Incredible Machine, the objective is to get a mechanism working. In our game, you'd make all the balls roll into a specific pipe using various boxes, planks, and -- of course -- physics. Luckily, the Decorator engine supported Box2D, though looking at the relative sizes of the engine and Box2D, it's hard to tell who's supporting whom. Anyway, physics sorted.</p>
<p>Design, hmm... neither I nor Division could draw at all. We decided to offer the honorary position of designer to an acquaintance of mine. He drew a couple of scenes, but then something came up, and we had to draw everything ourselves -- by "ourselves" I mean me.</p>
<p><img src="https://alexblog.net/ball-factory/images/bf-1.webp" alt="Ball Factory main menu with various sports balls and options: Start Game, Editor, About Us" loading="lazy"></p>
<p><img src="https://alexblog.net/ball-factory/images/bf-2.webp" alt="Ball Factory gameplay on level 2: wooden crates, planks, and pipes on a colorful hillside" loading="lazy"></p>
<h2>Development</h2>
<p>One key rule of the contest: you have to make the game in 9 days. So work kicked off at a lively pace. I was coming up with the concept, sketching out what everything should look like, while Division was preparing the engine. We used Dropbox for file sharing -- once again convinced that app is indispensable.</p>
<p>The engine prep took about three days and fell squarely on Division's shoulders. But for collaborative work we needed version control. The choice was between Git and SVN. We chose SVN, which we would later regret.</p>
<p>We got into a rhythm pretty quickly. Development happened at night. Since my AS experience left much to be desired, Division would explain over Skype what I was doing wrong. Skype was a huge help on this project, actually -- first, it made things more fun, and second, we could quickly coordinate our tasks and actions.</p>
<p>I fondly remember those sleepless nights. It was genuinely exciting to learn something new (AS) by doing it. I used to think you had to read a thick book before you could start writing in a new language -- how wrong I was. In those 4 days of coding, I learned more than I would from a month of reading a smart book. Sure, I lacked theoretical knowledge, but that was quickly fixed by the school of hard knocks...</p>
<p><img src="https://alexblog.net/ball-factory/images/bf-3.webp" alt="Ball Factory level 4 gameplay: triangular ramps, pipes, and ball counter showing caught and total balls" loading="lazy"></p>
<p><img src="https://alexblog.net/ball-factory/images/bf-4.webp" alt="Ball Factory level editor showing XML level data with entity definitions and coordinates" loading="lazy"></p>
<h2>The SVN Disaster</h2>
<p>Things went smoothly for about four days, but then Saturday came -- the turning point. We wrecked SVN. Here's how it went down... I pushed a new commit, Division was supposed to update his repo, but there was a conflict that needed manual resolution. Out of inexperience, Division clicked the wrong thing or pressed the wrong button, and wrecked one file. All his changes in that file were lost. And of course, stupidity never comes alone... In a move that was more dumb than inexperienced, I advised him to roll back to the previous revision, assuming he'd committed before that. Naturally, the previous revision wiped out an entire day's work... We were livid. The submission deadline was the next day, and we hadn't even built the levels yet -- and as if by Murphy's Law, the most complex and tangled part got erased.</p>
<p>Nothing to do about it -- Division had to restore everything. Morale was dented, but the payoff was worth it: we ended up with a solid level editor and a pretty good-looking game (you're welcome, that was my design work).</p>
<h2>The Disqualification and Redemption</h2>
<p>Murphy's Law, act two... According to contest rules, you could be a day late with a 30% penalty, but you had to notify the organizer. Of course we weren't going to make it, and we'd known it beforehand. In the relevant IGDC forum thread, Nikita had been voicing concerns and guesses that we wouldn't finish on time. We weren't sure yet, so we warned them more explicitly once we realized our earlier posts hadn't been taken as an official late notice. The organizer said "why so late?" but nothing more. We were drawing levels until about 3 AM (with work in 4 hours). We'd wanted to submit earlier, but as always, things never go the way you want. Realizing we were looking at the full 30% penalty, we went to sleep. During the day, in breaks between my day job, I kept tweaking levels, fixing issues, preparing the game for release. And then, like a bolt from the blue -- we get disqualified. For being late. I was honestly in shock. So much effort and soul poured in, it was really upsetting. But what can you do -- we packed the archive and sent it off.</p>
<p>Dragging myself home, I bought a bunch of junk food -- chips and cookies -- to at least console myself somehow. Got home and crashed without even reaching the cookies or chips. Around 10 PM, my wife wakes me up with great news -- the admin reversed the disqualification! The joy was beyond words. Special thanks to the admin! The world was right again... We're in the fight... We're competing... And we wait for the scores...</p>
<h2>Lessons Learned</h2>
<p>Lessons I took away:
-- Learn by doing.
-- Making games is fun.
-- To hell with SVN. Next project, we're using Git.</p>
<p><img src="https://alexblog.net/ball-factory/images/bf-5.webp" alt="Ball Factory level 3 gameplay: balls bouncing along a circular path with pipes and ramps" loading="lazy"></p>
<p><img src="https://alexblog.net/ball-factory/images/bf-6.webp" alt="Ball Factory level selection screen with 16 numbered levels in a green grid" loading="lazy"></p>]]></content:encoded>
            <author>Alex Kucherenko</author>
        </item>
    </channel>
</rss>