<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: fuR Gaming</title>
    <description>The latest articles on DEV Community by fuR Gaming (fur-gaming).</description>
    <link>https://dev.to/fur-gaming</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F3167%2Fc98f3d1d-451c-4b82-b7ad-1fcdde165e9c.png</url>
      <title>DEV Community: fuR Gaming</title>
      <link>https://dev.to/fur-gaming</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fur-gaming"/>
    <language>en</language>
    <item>
      <title>Adopt the Brain, Defer the Bridge: Unity’s Agent Plugin and Free CLI, Three Weeks On</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 19 Sep 2026 08:23:57 +0000</pubDate>
      <link>https://dev.to/fur-gaming/adopt-the-brain-defer-the-bridge-unitys-agent-plugin-and-free-cli-three-weeks-on-1kei</link>
      <guid>https://dev.to/fur-gaming/adopt-the-brain-defer-the-bridge-unitys-agent-plugin-and-free-cli-three-weeks-on-1kei</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uzg7sv3jken4ub0wzvh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uzg7sv3jken4ub0wzvh.png" alt=" " width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Unity shipped an official Unity agent plugin — 31 skills for Claude Code and Codex — and a free Unity CLI, three weeks after I published a decision log that deliberately ended in triggers instead of a calendar reminder: &lt;em&gt;when X breaks, do Y.&lt;/em&gt; Every trigger fired, one conclusion broke outright, and the option I closed that post by saying nobody was selling turned up as a first-party product. This is what the frame got right, what it got wrong, and why “adopt the brain, defer the bridge” turns out to be the reusable part.&lt;/p&gt;

&lt;p&gt;Beat: a decision log is worth more than a recommendation &lt;em&gt;precisely because&lt;/em&gt; it can be graded later. This post is the grading.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What Unity shipped, in the frame’s own terms
&lt;/h2&gt;

&lt;p&gt;Recap brain/bridge/generator in two sentences, link back to the original post, then sort the news into the three buckets — the sorting is the argument:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What Unity shipped&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brain&lt;/td&gt;
&lt;td&gt;31 skills written by the teams that own URP, UI Toolkit, WebGL, IAP&lt;/td&gt;
&lt;td&gt;Free, additive, adopt now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bridge&lt;/td&gt;
&lt;td&gt;Unity CLI + experimental &lt;code&gt;com.unity.pipeline&lt;/code&gt;, free, no AI seat&lt;/td&gt;
&lt;td&gt;Defer — evaluate as fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generator&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Point: the frame’s value was never the taxonomy, it was that &lt;strong&gt;the three layers have independent adoption decisions&lt;/strong&gt;. One launch, three different answers. A single verdict on “the Unity plugin” would have been wrong whichever way it went.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this dates in the last post
&lt;/h3&gt;

&lt;p&gt;Fold the corrections in here rather than back-editing the original, and say why in one line: that post is a dated decision log, and a log that edits its own history is worth less than one that gets graded in public.&lt;/p&gt;

&lt;p&gt;Two things in it are now out of date, and both are worth stating in the open:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The entitlement claim was wrong within three weeks.&lt;/strong&gt; I wrote that on a Personal seat the first-party bridge is not a free fallback. The Unity CLI and &lt;code&gt;com.unity.pipeline&lt;/code&gt; are free, need no Unity AI entitlement, and run on Unity 6.0 LTS and newer. Worth noting the direction of my error: I understated a free option, so the cost of having read me is skipping something free rather than paying for something you didn’t need. I’d have corrected it in place if it ran the other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Three different things are called Unity MCP” is now four.&lt;/strong&gt; The CLI hosts a &lt;em&gt;second&lt;/em&gt; first-party MCP — by most accounts free — alongside the entitlement-gated one in Unity AI Assistant. Sources still disagree on where that line falls. I wrote the section to complain the name was ambiguous; the ambiguity has since gotten measurably worse, which I’m taking as vindication rather than as a reason to stop naming things carefully.&lt;/p&gt;

&lt;p&gt;Beat: this is the &lt;em&gt;cheap&lt;/em&gt; half of being wrong in public. A dated log absorbs a drifted price without collapsing. A confident recommendation doesn’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;strong&gt;The Unity agent plugin ships zero MCP servers, because Unity went CLI-first&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The hard, checkable finding, and the post’s spine. Unity’s launch post describes the plugin as bundling “skills, the Unity CLI, and Unity’s MCP server for live Editor control.” Installed, &lt;code&gt;claude plugin details unity&lt;/code&gt; reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Skills (31) ...
MCP servers (0)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8p2q557c4o7u4rso36xp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8p2q557c4o7u4rso36xp.png" alt="Unity agent plugin launch claim beside the installed inventory showing no MCP server" width="800" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Resist the urge to call this marketing dishonesty — the more interesting reading is architectural. &lt;strong&gt;&lt;code&gt;unity mcp&lt;/code&gt; lives inside the &lt;code&gt;unity&lt;/code&gt; binary.&lt;/strong&gt; It is a subcommand, not a separate product: start it and it exposes a connected Editor’s commands as MCP tools over stdio, and &lt;code&gt;unity mcp configure&lt;/code&gt; writes itself into sixteen AI clients. So the MCP server Unity mentions does exist. It just isn’t a thing the plugin installs, because the plugin installs no software at all.&lt;/p&gt;

&lt;p&gt;What you actually get is documentation. The &lt;code&gt;unity-cli&lt;/code&gt; skill &lt;em&gt;tells the agent how&lt;/em&gt; to drive the Editor; the CLI, the &lt;code&gt;com.unity.pipeline&lt;/code&gt; package and &lt;code&gt;unity mcp configure&lt;/code&gt; are three steps you take yourself, deliberately, afterwards.&lt;/p&gt;

&lt;p&gt;That gap — between “installs live Editor control” and “documents how you’d set it up” — is the entire reason adopt-the-brain-defer-the-bridge is available as a strategy. The knowledge layer and the control layer shipped in the same box and are not welded together. Had the plugin configured a bridge on install, adopting 31 skills would have meant swapping bridges underneath a working project, and I would have declined the whole thing.&lt;/p&gt;

&lt;p&gt;Beat: the thing worth checking was never the launch post, it was the inventory. And the thing worth noticing is that Unity built CLI-first and made MCP a mode of it — which tells you which one they expect to last.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. CLI or MCP? The token cost decides it, not the capability
&lt;/h2&gt;

&lt;p&gt;The question I kept expecting to be hard, and isn’t — because for Unity they are the same capability wearing two coats. Same binary, same commands, same Pipeline package underneath. Nothing is reachable through &lt;code&gt;unity mcp&lt;/code&gt; that isn’t reachable through &lt;code&gt;unity command&lt;/code&gt;. So this was never a power comparison; it is an ergonomics and context-economics one, and almost nobody frames it that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the CLI route costs and gives.&lt;/strong&gt; The &lt;code&gt;unity-cli&lt;/code&gt; skill sits at roughly 240 tokens always-on and only unpacks its ~13k when it actually fires. It runs in CI, in cron, in a plain shell, under any agent, or under my own hands. &lt;code&gt;unity command&lt;/code&gt; invocations go into scripts and get version-controlled; MCP calls don’t. And discovery is dynamic — the CLI lists whatever the connected Editor currently exposes, so a new command shows up without re-registering anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What MCP adds, honestly.&lt;/strong&gt; Typed tools the model sees without reading a skill first, which means fewer malformed invocations. Richer returns than stdout text — screenshots being the fair example, since an image coming back from a tool is natural over MCP and clumsy over a pipe. Per-tool permissioning in the harness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What decides it.&lt;/strong&gt; An MCP server’s tool schemas sit in the context window permanently, once per tool, whether or not you use them that session. A CLI’s cost is one skill description until the moment you need it. When your bridge exposes a couple of dozen tools this is a rounding error. When it exposes a hundred and eighty, it is the single largest fixed cost in your setup, and you are paying it on every trivial question you ask about the project.&lt;/p&gt;

&lt;p&gt;Beat, and it generalises past Unity: &lt;strong&gt;we are used to comparing tools on what they can do, and these do the same things.&lt;/strong&gt; The real question is what they cost you before you’ve done anything at all. Context is a budget now. Tool surfaces are rent.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The honest price of a free brain: what the Unity agent plugin costs in context
&lt;/h2&gt;

&lt;p&gt;Balance section — resist the free-is-free reflex.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~5,943 tokens always-on, every session, every repo.&lt;/li&gt;
&lt;li&gt;Of 31 skills, roughly 8 apply to my project. No IAP or LevelPlay (the web layer owns monetization), no tilemaps or sprite atlases (it’s 3D), no multiplayer (Photon is gone), no Unity-side localization — by design, since my Unity build renders no words at all.&lt;/li&gt;
&lt;li&gt;Enable/disable is plugin-level only. You cannot trim per-skill, so the 6k is take-it-or-leave-it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beat: “free” tooling bills you in context window. That is a real currency now, and nobody prices it on the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The trap that came with the brain
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;unity-cli&lt;/code&gt; skill instructs: &lt;em&gt;“when an Editor is available, drive it instead of hand-editing scene or asset files”&lt;/em&gt; — and documents &lt;code&gt;unity pipeline install&lt;/code&gt;, which would add a package to my manifest.&lt;/p&gt;

&lt;p&gt;So adopting the brain quietly imported an &lt;em&gt;advocate for the bridge I had just decided to defer&lt;/em&gt;. Not malice, not even a flaw — the skill is correct in general and wrong for my specific standing decision. But a future session acting on that instruction would silently reverse a deliberate choice.&lt;/p&gt;

&lt;p&gt;Beat, and the transferable one: &lt;strong&gt;skills carry opinions, and an opinion you did not choose is a decision made on your behalf.&lt;/strong&gt; The mitigation is boring — write the standing decision down where the agent reads it, not just in your own head. Generalizes to any team adopting any vendor’s skill pack.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The option nobody sells you got sold
&lt;/h2&gt;

&lt;p&gt;The punchline. I closed the last post recommending a batch-mode CLI entry point specifically &lt;em&gt;because no vendor was attached to it&lt;/em&gt; — arguing that the durable choice was the one that survived every acquisition scenario. Two weeks later Unity shipped precisely that: free, first-party, live-Editor control, &lt;code&gt;[CliCommand]&lt;/code&gt; custom tools, no AI entitlement.&lt;/p&gt;

&lt;p&gt;Two honest readings, give both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flattering:&lt;/strong&gt; if your reasoning lands on a capability the platform vendor ships weeks later, the reasoning was tracking something real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deflating:&lt;/strong&gt; I recommended it &lt;em&gt;because&lt;/em&gt; it was unowned, and it is now owned. The property I valued is the one that went away. A first-party CLI can be deprecated, entitlement-gated later, or folded into a paid tier — the vendor-free road now has a vendor on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I still think adopting it is right, and I notice the argument that got me there no longer applies. Say so plainly rather than claiming the prediction.&lt;/p&gt;

&lt;p&gt;Also: it is not polished. Bearer tokens regenerate on domain reload and strand clients on 401s. The Editor still wants foreground focus before refreshing assets — the single quirk that has cost me the most time. ~0.80s per call. First-party means someone is on the hook, not that the edges are gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The conclusion that broke: modality, not capability
&lt;/h2&gt;

&lt;p&gt;The honest failure. I wrote that generated meshes actively fight assets with radial mechanical symmetry, sharp edges, separable parts, and a pivot that must sit exactly on an axis — using my own spinning tops as the example.&lt;/p&gt;

&lt;p&gt;That was an argument against &lt;strong&gt;diffusion text-to-mesh&lt;/strong&gt; , and I wrote it as though it were an argument against &lt;em&gt;generated 3D&lt;/em&gt;. GPT-6 Astra (3 September 2026) is a different modality: an agent operating Blender, writing real Python, rendering, revising. It can name object hierarchies, set an origin exactly on an axis, assign per-part materials, export FBX. Every specific objection I raised is an objection to the technique I happened to have in mind.&lt;/p&gt;

&lt;p&gt;State the uncertainty properly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I have &lt;strong&gt;not&lt;/strong&gt; tested it on my own tops. Capability correction, not a result.&lt;/li&gt;
&lt;li&gt;Published weaknesses are organic shapes and topology-heavy work; strengths are hard-surface, naming, materials, export. My case sits in the strong half, which is &lt;em&gt;suggestive&lt;/em&gt;, not evidence.&lt;/li&gt;
&lt;li&gt;Access is the real blocker for a solo dev: on ChatGPT Plus, Astra is metered inside Work and Codex at 5–45 local messages per 5 hours. A multi-part hard-surface model with revision loops eats that. I deferred on cost, and I should be clear that I deferred on cost and not on capability, because those are different admissions.&lt;/li&gt;
&lt;li&gt;BenchCAD: 95.9% voxel IoU, against 84.3% for the model I actually use daily. Report it; don’t bury it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beat: the correction worth publishing isn’t “I was wrong about a product,” it’s &lt;strong&gt;“my rule was missing a qualifier I didn’t know it needed.”&lt;/strong&gt; Rules that name a &lt;em&gt;technique&lt;/em&gt; age; rules that name a &lt;em&gt;capability&lt;/em&gt; don’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. What I actually did
&lt;/h2&gt;

&lt;p&gt;Short and concrete, so the post lands as a log and not an essay:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Installed the skills plugin, user scope, all six repos. Free, additive, reversible.&lt;/li&gt;
&lt;li&gt;Left the bridge alone. No package added to any manifest.&lt;/li&gt;
&lt;li&gt;Promoted the Unity CLI to pre-chosen fallback, displacing the MIT community bridge in my trigger list.&lt;/li&gt;
&lt;li&gt;Recruited Codex as a &lt;em&gt;generator&lt;/em&gt;, not a replacement brain — image generation routed into my existing agent on subscription billing rather than API metering, because asset generation is out-of-band by nature and the filesystem was always the integration point.&lt;/li&gt;
&lt;li&gt;Declined Blender MCP on metered cost. Trigger: when a shipping asset actually needs it.&lt;/li&gt;
&lt;li&gt;Wrote the deferred-bridge decision down where the agent reads it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. The reusable heuristic
&lt;/h2&gt;

&lt;p&gt;When a platform vendor ships an AI tooling bundle, unbundle it before deciding, and give each layer its own answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Knowledge is nearly free to adopt&lt;/strong&gt; — additive, reversible, priced in context rather than money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control is expensive to switch&lt;/strong&gt; — the cost is never the tool, it’s the accumulated knowledge of one tool’s quirks, and you pay it again on every swap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation should be bought from whoever generates&lt;/strong&gt; — bundled generators are resellers, and the modality moves faster than any bundle tracks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adopt the brain. Defer the bridge. Buy the generator direct.&lt;/p&gt;

&lt;p&gt;Close on the thing that made the exercise worth it: the grading only worked because the original post committed to triggers rather than a verdict. A post that had said “here is what I recommend” would have been silently wrong for three weeks. One that said “here is when I will change my mind” got to be &lt;em&gt;checked&lt;/em&gt;. That is the argument for writing decision logs, and it has nothing to do with Unity.&lt;/p&gt;




&lt;h2&gt;
  
  
  References to collect before writing
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://unity.com/blog/unity-plugin-for-claude-code" rel="noopener noreferrer"&gt;Unity — official plugin for Claude Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://unity.com/blog/unity-plugin-codex" rel="noopener noreferrer"&gt;Unity — official plugin for Codex&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://unity.com/blog/meet-the-unity-cli" rel="noopener noreferrer"&gt;Unity — Meet the Unity CLI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.unity.com/en-us/ai/unity-plugin" rel="noopener noreferrer"&gt;Unity Docs — plugin for third-party agents&lt;/a&gt; (Claude Code, Codex, Grok)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.unity.com/en-us/unity-production-pipeline/local-tools-cli/unity-pipeline-package" rel="noopener noreferrer"&gt;Unity Docs — Pipeline package&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Unity-Technologies/unity-agent-plugin" rel="noopener noreferrer"&gt;GitHub — Unity-Technologies/unity-agent-plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vindler.solutions/blog/unity-cli-agent-automation" rel="noopener noreferrer"&gt;Vindler — the Unity CLI: what ships, what is broken&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.com/index/gpt-6-astra/" rel="noopener noreferrer"&gt;OpenAI — GPT-6 Astra&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Astra-in-Blender write-ups + BenchCAD numbers&lt;/li&gt;
&lt;li&gt;Astra access/limits by plan (Plus = Work/Codex only, metered)&lt;/li&gt;
&lt;li&gt;My own: &lt;a href="https://dev.to/furic/brain-bridge-generator-comparing-unity-mcp-options-after-the-coplay-aura-acquisition-1oc"&gt;Brain, Bridge, Generator: Comparing Unity MCP Options After the Coplay–Aura Acquisition&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Written September 2026. Same caveat as last time, and it earned its keep faster than I expected: prices, tiers and licence terms will drift, this is a decision log rather than a benchmark, and the Astra section is a corrected capability claim I have not personally tested.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/unity-agent-plugin-adopt-brain-defer-bridge/" rel="noopener noreferrer"&gt;Adopt the Brain, Defer the Bridge: Unity’s Agent Plugin and Free CLI, Three Weeks On&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gamedev</category>
      <category>unity3d</category>
      <category>aitooling</category>
    </item>
    <item>
      <title>Brain, Bridge, Generator: Comparing Unity MCP Options After the Coplay–Aura Acquisition</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:09:25 +0000</pubDate>
      <link>https://dev.to/fur-gaming/brain-bridge-generator-comparing-unity-mcp-options-after-the-coplay-aura-acquisition-1oc</link>
      <guid>https://dev.to/fur-gaming/brain-bridge-generator-comparing-unity-mcp-options-after-the-coplay-aura-acquisition-1oc</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtp6qkyebatepb9ryixq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdtp6qkyebatepb9ryixq.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My Unity AI setup cost $0 for months, and then the company behind half of it got acquired. That sent me through the Unity MCP alternatives I’d never seriously looked at — and the answer turned out to be less about picking a replacement than about noticing I’d been comparing the &lt;em&gt;wrong three things&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that was working fine
&lt;/h2&gt;

&lt;p&gt;I build a small Unity game on my own. My editor-AI setup arrived by accident rather than design: I started on the free MIT-licensed &lt;a href="https://github.com/CoplayDev/unity-mcp" rel="noopener noreferrer"&gt;CoplayDev/unity-mcp&lt;/a&gt; bridge, liked it, and after a while moved across to Coplay’s own MCP — the proprietary product from the same shop. That ran for months. It worked genuinely well, and it cost me nothing.&lt;/p&gt;

&lt;p&gt;My mental model of what I used it for was simple, and — I’d find out later — wrong. I thought of it as the thing that generated sound effects, plus the occasional texture. Nice to have. Replaceable.&lt;/p&gt;

&lt;p&gt;Then I read that &lt;a href="https://coplay.dev/blog/whats-next-for-coplay" rel="noopener noreferrer"&gt;Coplay had been acquired&lt;/a&gt;, and the two obvious questions arrived together. Is this getting sunset? And is there any actual benefit to moving to the acquirer’s product?&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;Ramen, the studio behind Aura — an AI assistant for Unreal Engine — &lt;a href="https://www.tryaura.dev/updates/ramen-acquires-coplay-making-aura-the-first-ai-agent-for-unity-and-unreal-engine" rel="noopener noreferrer"&gt;acquired Coplay in March 2026&lt;/a&gt;, making Aura the first assistant covering both engines. Aura for Unity launched on 14 May 2026. Coplay itself has no announced sunset date and, per the announcement, continues “as a legacy product.”&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you’re happy where you are, nothing needs to change. Unity MCP will also remain free, open source, and MIT-licensed.&lt;br&gt;&lt;br&gt;
— Coplay, &lt;em&gt;What’s next for Coplay&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is reassuring right up until you notice that the sentence contains a landmine of ambiguity. “Unity MCP” means at least three different things in this ecosystem, and the promise applies to exactly one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three different things are called Unity MCP
&lt;/h2&gt;

&lt;p&gt;Untangling this was the single most useful ten minutes of the whole exercise, because the free-forever guarantee attaches to the project I’d &lt;em&gt;left&lt;/em&gt;, not the one I was using.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Asset generation&lt;/th&gt;
&lt;th&gt;Fate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;CoplayDev/unity-mcp&lt;/strong&gt; (MIT)&lt;/td&gt;
&lt;td&gt;None, ever&lt;/td&gt;
&lt;td&gt;None — editor control only&lt;/td&gt;
&lt;td&gt;Explicitly guaranteed free and open source; Aura now sponsors it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Coplay MCP&lt;/strong&gt; (account)&lt;/td&gt;
&lt;td&gt;Free tier via a Coplay account&lt;/td&gt;
&lt;td&gt;Yes — sfx, music, images, 3D models&lt;/td&gt;
&lt;td&gt;Legacy. Running, undeveloped, no end date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Unity’s own MCP&lt;/strong&gt; (metered)&lt;/td&gt;
&lt;td&gt;Bundled on Pro; needs a paid Unity AI seat on Personal&lt;/td&gt;
&lt;td&gt;Textures, materials, skyboxes, 3D — &lt;strong&gt;no audio&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;First-party, actively developed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So “the MCP stays free” and “my setup stays free” were two different claims, and I’d been treating them as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe: three markets, not one
&lt;/h2&gt;

&lt;p&gt;Every comparison I read mushes these together, which is why the choice looks harder than it is. There are three separable products here, and most people already own one of them outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brain&lt;/strong&gt; — the agent that reasons and writes the code. If you drive Unity from a coding agent in your terminal or IDE, you already have this and it is the best part of your stack. Nothing below competes with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bridge&lt;/strong&gt; — the MCP server giving that brain hands inside the editor: read the hierarchy, check compile errors, tail the console, enter play mode, screenshot a scene. This is the part that quietly becomes load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generator&lt;/strong&gt; — text-to-texture, text-to-mesh, text-to-sound. A completely separate market with its own vendors, and the one where in-editor assistants are acting as resellers.&lt;/p&gt;

&lt;p&gt;Once split this way, the acquisition question shrinks. I don’t need a new brain. I don’t need a bundled generator. I need to know that a bridge will exist, and the Unity MCP alternatives turn out to be abundant and free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing the Unity MCP alternatives
&lt;/h2&gt;

&lt;p&gt;This is a crowded, healthy field — not a three-way vendor choice. Four credible free Unity MCP alternatives, plus the first-party one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Licence&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;What distinguishes it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/CoplayDev/unity-mcp" rel="noopener noreferrer"&gt;CoplayDev/unity-mcp&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;~47&lt;/td&gt;
&lt;td&gt;The default. Largest community by a wide margin, active releases through 2026, sponsored by Aura post-acquisition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/IvanMurzak/Unity-MCP" rel="noopener noreferrer"&gt;IvanMurzak/Unity-MCP&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Open&lt;/td&gt;
&lt;td&gt;70+&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Custom tools via C# attributes.&lt;/strong&gt; Also runs inside a compiled build, not just the editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/CoderGamester/mcp-unity" rel="noopener noreferrer"&gt;CoderGamester/mcp-unity&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Open&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Mature and broadly client-compatible across the major agents and IDEs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://foxsterdev.github.io/xuunity-mcp/alternatives.html" rel="noopener noreferrer"&gt;FoxsterDev/xuunity-mcp&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Validation-first.&lt;/strong&gt; Compile checks, test runs, scene assertions, deliberately small mutation surface, no external runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.unity3d.com/Packages/com.unity.ai.assistant@2.0/manual/unity-mcp-get-started.html" rel="noopener noreferrer"&gt;Unity official&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Seat&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;First-party and durable, but gated behind a Unity AI entitlement unless you’re on Pro or above&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Two of these are interesting for non-obvious reasons
&lt;/h3&gt;

&lt;p&gt;Tool counts are the least useful column in that table. The two entries worth actually thinking about earn it on posture, not breadth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation-first is aimed at the failure mode you actually hit.&lt;/strong&gt; I keep a running file of bridge quirks I’ve been bitten by, and reading it back was instructive: a save-scene tool that silently performed save-as, a script-execution tool that no-ops after a domain reload, a compile check that cheerfully reports a stale build as clean. Almost every entry is a &lt;em&gt;mutation&lt;/em&gt; tool lying to me. A bridge whose entire thesis is “small mutation surface, trustworthy compile and test and assert” is pointed directly at that, and nobody markets it that way because “fewer tools” is a terrible tagline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom tools beat vendor tools, and only one option offers them.&lt;/strong&gt; My real verification loop isn’t generic Unity work — it’s “run the batch harness across a range of inputs”, “replay a recorded line and diff the output against what it produced last time”, “assert the state machine actually reached its terminal state.” I currently reach all of that through fragile generic script-execution calls. Three or four purpose-built tools exposing my own harness would beat any vendor’s eighty-something generic ones, because they’d speak my domain instead of Unity’s. That capability is worth more than every paid feature I looked at, and it’s free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paid tier, honestly assessed
&lt;/h2&gt;

&lt;p&gt;I did the work of pricing both, so here it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unity AI&lt;/strong&gt; gives Personal-plan users a 14-day trial with 1,000 credits, then $10/month for 1,000 credits. Pro, Enterprise and Industry seats include credits and MCP access by default. That last detail matters more than the price: on a Personal seat, &lt;em&gt;the first-party bridge is not a free fallback&lt;/em&gt;. There’s an active community thread &lt;a href="https://discussions.unity.com/t/call-to-make-unity-mcp-usage-free/1724543" rel="noopener noreferrer"&gt;asking Unity to change this&lt;/a&gt;, and another &lt;a href="https://discussions.unity.com/t/request-for-official-clarification-unity-mcp-access-subscription-requirements-and-future-policy/1720323" rel="noopener noreferrer"&gt;asking for clarity on the policy at all&lt;/a&gt;, which tells you how settled it currently feels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aura&lt;/strong&gt; runs a two-week trial with $40 of credit, then Indie, Pro and Ultimate tiers. Its &lt;a href="https://www.tryaura.dev/documentation/pricing-explained/" rel="noopener noreferrer"&gt;pricing docs&lt;/a&gt; contain the detail that decided it for me: when you use its MCP tools from an external IDE, basic inspection is billed to your IDE subscription, but triggering its specialised subagents consumes Aura credits. That’s a fair model — and it means I’d be paying per-invocation for the loop I currently get free, in exchange for an in-editor agent I don’t need, because I already have a brain.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The reseller tell.&lt;/strong&gt; Coplay’s own &lt;a href="https://coplay.dev/blog/comparing-coplay-and-unity-mcp" rel="noopener noreferrer"&gt;comparison page&lt;/a&gt; lists 3D model generation “using your provider of choice (e.g. Meshy)” as a premium feature. That’s honest, and it’s also the whole story: the in-editor assistant is a convenience wrapper over generators you can subscribe to directly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  So buy generators from generator companies
&lt;/h2&gt;

&lt;p&gt;If you want generated meshes, the direct vendors are cheaper and better at it than any wrapper: &lt;a href="https://www.tripo3d.ai/compare/tripo-vs-meshy" rel="noopener noreferrer"&gt;Tripo&lt;/a&gt; from around $14–20/month, Meshy at $20, Hyper3D/Rodin with a free generation tier and the strongest reported quality for hero assets. &lt;a href="https://www.sloyd.ai/blog/3d-ai-price-comparison" rel="noopener noreferrer"&gt;Side-by-side credit pricing&lt;/a&gt; is worth a look before committing to any of them.&lt;/p&gt;

&lt;p&gt;Two things I’d flag that the marketing doesn’t.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;generated meshes suit some assets and actively fight others&lt;/strong&gt;. Anything with radial mechanical symmetry, sharp edges, parts that must stay separable, a pivot that has to sit exactly on an axis, and colliders keyed to geometry — you’ll pay for the generation and then spend the same hours you saved splitting a fused blob apart in Blender. Environment dressing, props, background silhouettes, skyboxes and textures are where this technology currently earns its keep.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;audio is the real gap&lt;/strong&gt;. Unity AI’s generators cover textures, materials, skyboxes and 3D. There is no audio generator at all. If in-editor sfx generation is what you value, no first-party or free-bridge option replaces it, and you’re going direct to an audio vendor whether you like it or not.&lt;/p&gt;

&lt;p&gt;On which — a licensing trap worth knowing, since it’s the one place my own “it’s free, it’s fine” reasoning was genuinely broken. &lt;a href="https://elevenlabs.io/pricing" rel="noopener noreferrer"&gt;ElevenLabs’ free tier&lt;/a&gt; is generous on capability: 10,000 credits a month, sound effects included, plenty for a small project. But it grants &lt;em&gt;no commercial licence&lt;/em&gt; — &lt;a href="https://elevenlabs.io/docs/help-center/legal/can-i-publish-the-content-i-generate-on-the-platform" rel="noopener noreferrer"&gt;free-tier output can’t be used commercially&lt;/a&gt;, and non-commercial publishing wants attribution in the title. Starter at $6/month is the cheapest tier that includes commercial rights, and rights persist for whatever you generated while paying. So the correct move isn’t a standing subscription: it’s one paid month covering the session that produces your shipping audio, then back down to free for experimentation. The trigger is “this ships”, not “the credits ran out.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The option nobody sells you
&lt;/h2&gt;

&lt;p&gt;Worth naming, because it never appears in these comparisons and it may be the most durable choice available: &lt;strong&gt;drop the bridge for anything that doesn’t need a live editor.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A Unity batch-mode entry point — &lt;code&gt;-batchmode -executeMethod&lt;/code&gt; pointed at your own harness — makes your verification runnable from a plain shell. No MCP server, no vendor, no port collision when you’ve got two editors open, no dependency on anybody’s acquisition. It survives every scenario in this post. The live bridge then stays for the things that genuinely need eyes: screenshots, poking around in play mode, inspecting a hierarchy you can’t picture.&lt;/p&gt;

&lt;p&gt;I’d been treating the bridge as the only road into my project from a terminal. It isn’t, and the CLI road doesn’t have a vendor attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I decided
&lt;/h2&gt;

&lt;p&gt;Nothing, immediately — which is a real decision rather than an absence of one. Coplay is up, there’s no sunset date, and I’m a light user of the part that’s actually at risk. Migrating on speculation costs me the accumulated knowledge of one tool’s quirks in exchange for learning another’s.&lt;/p&gt;

&lt;p&gt;What changed is that I now hold triggers instead of a diary date.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When the editor plugin breaks on a Unity upgrade&lt;/strong&gt; → move to one of the free MIT-licensed Unity MCP alternatives. Chosen in advance, no research under pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When generated audio stops being placeholder and ships&lt;/strong&gt; → one paid month at an audio vendor for the commercial licence, then downgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When props and environment need real volume&lt;/strong&gt; → subscribe to a 3D generator directly, never through a wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When I want to spend an hour improving tooling&lt;/strong&gt; → custom domain tools and a batch-mode entry point, not a bridge swap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the actual conclusion. Every one of the Unity MCP alternatives in this post, at best, restores what I already have for free. The only work that makes my setup &lt;em&gt;better&lt;/em&gt; is the work no vendor is selling: tools that speak my project’s own vocabulary, and a path into the project that doesn’t route through anybody’s product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that was worth the scare
&lt;/h2&gt;

&lt;p&gt;Legacy products rarely die by announcement. The realistic failure modes are quieter than a sunset notice: an engine upgrade breaks a plugin nobody is patching any more, or an upstream API key silently expires and a generation tool starts returning errors. Waiting for a formal end-of-life date means being surprised by neither of those.&lt;/p&gt;

&lt;p&gt;But the acquisition was worth more to me as a forcing function than as a decision. Asked cold what I used the tool for, I said sound effects and the odd texture. Then I actually looked — and the load-bearing dependency was the compile-check, console-tail, play-mode, screenshot loop that I lean on constantly and had stopped noticing precisely &lt;em&gt;because&lt;/em&gt; it works. The convenience I’d have named first was almost the only part I could lose without flinching.&lt;/p&gt;

&lt;p&gt;That’s the transferable bit, and it has nothing to do with Unity. When a tool in your stack changes hands, the useful response isn’t picking a replacement. It’s inventorying what’s actually load-bearing — because there’s a good chance, as there was for me, that you’d have named the wrong thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://coplay.dev/blog/whats-next-for-coplay" rel="noopener noreferrer"&gt;Coplay — What’s next for Coplay&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://coplay.dev/blog/ramen-acquires-coplay" rel="noopener noreferrer"&gt;Coplay — Ramen acquires Coplay&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.tryaura.dev/updates/ramen-acquires-coplay-making-aura-the-first-ai-agent-for-unity-and-unreal-engine" rel="noopener noreferrer"&gt;Aura — Ramen acquires Coplay&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.tryaura.dev/documentation/pricing-explained/" rel="noopener noreferrer"&gt;Aura — Pricing explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://coplay.dev/blog/comparing-coplay-and-unity-mcp" rel="noopener noreferrer"&gt;Coplay — Comparing Coplay and Unity MCP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CoplayDev/unity-mcp" rel="noopener noreferrer"&gt;GitHub — CoplayDev/unity-mcp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/IvanMurzak/Unity-MCP" rel="noopener noreferrer"&gt;GitHub — IvanMurzak/Unity-MCP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CoderGamester/mcp-unity" rel="noopener noreferrer"&gt;GitHub — CoderGamester/mcp-unity&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://foxsterdev.github.io/xuunity-mcp/alternatives.html" rel="noopener noreferrer"&gt;XUUnity MCP — Alternatives comparison&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.unity3d.com/Packages/com.unity.ai.assistant@2.0/manual/unity-mcp-get-started.html" rel="noopener noreferrer"&gt;Unity Docs — Get started with Unity MCP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://unity.com/blog/unity-ai-mcp-how-to-get-started" rel="noopener noreferrer"&gt;Unity — Connect Claude Code, Cursor and other agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://discussions.unity.com/t/request-for-official-clarification-unity-mcp-access-subscription-requirements-and-future-policy/1720323" rel="noopener noreferrer"&gt;Unity Discussions — Request for clarification on MCP access&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://discussions.unity.com/t/call-to-make-unity-mcp-usage-free/1724543" rel="noopener noreferrer"&gt;Unity Discussions — Call to make Unity MCP usage free&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://unity.com/products" rel="noopener noreferrer"&gt;Unity — Plans and pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://unity.com/pages/license-compliance" rel="noopener noreferrer"&gt;Unity — Licensing and compliance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vindler.solutions/blog/unity-ai-open-beta" rel="noopener noreferrer"&gt;Vindler — Unity AI open beta: what it ships with and costs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://elevenlabs.io/pricing" rel="noopener noreferrer"&gt;ElevenLabs — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://elevenlabs.io/docs/help-center/legal/can-i-publish-the-content-i-generate-on-the-platform" rel="noopener noreferrer"&gt;ElevenLabs — Can I publish what I generate?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.sloyd.ai/blog/3d-ai-price-comparison" rel="noopener noreferrer"&gt;Sloyd — 3D AI pricing and credits comparison&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.tripo3d.ai/compare/tripo-vs-meshy" rel="noopener noreferrer"&gt;Tripo — Tripo vs Meshy&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Prices, tiers and licence terms checked August 2026 and certain to drift. This is a decision log, not a benchmark — I compared these on documentation and posture, not by running each bridge through the same project, and I haven’t migrated off anything yet. Corrections welcome.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/unity-mcp-alternatives-coplay-aura/" rel="noopener noreferrer"&gt;Brain, Bridge, Generator: Comparing Unity MCP Options After the Coplay–Aura Acquisition&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gamedev</category>
      <category>unity3d</category>
      <category>aitooling</category>
    </item>
    <item>
      <title>Particle Trail Stuck at X=0: A Unity Update-Order Bug (and Six Wrong Fixes)</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:25:04 +0000</pubDate>
      <link>https://dev.to/fur-gaming/particle-trail-stuck-at-x0-a-unity-update-order-bug-and-six-wrong-fixes-3m5o</link>
      <guid>https://dev.to/fur-gaming/particle-trail-stuck-at-x0-a-unity-update-order-bug-and-six-wrong-fixes-3m5o</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkibd0nbu80t3mrilr9jq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkibd0nbu80t3mrilr9jq.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wanted a simple thing: a little particle trail that streams behind a fast-moving character as it runs down a lane and slides left/right. A few hours and &lt;strong&gt;six failed fixes&lt;/strong&gt; later, I finally understood why the particles kept piling up in the dead center of the lane while the character was clearly off to one side.&lt;/p&gt;

&lt;p&gt;The bug had one strange, very specific signature that I ignored for far too long:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The trail followed the character in &lt;strong&gt;Y and Z&lt;/strong&gt; , but its &lt;strong&gt;X was always pinned to 0.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That asymmetry was the whole answer. It took me most of the session to listen to it. This is the honest version — wrong turns first, then the root cause, then the fix and the lessons.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A Unity 6 / URP game. A character runs forward automatically and is nudged left/right between lanes. I attach a looping CPU particle trail (a standard Shuriken system from an art pack) to the character so it leaves a fun streak.&lt;/p&gt;

&lt;p&gt;In the editor, if I grab the character and drag it around by hand, the trail follows perfectly. In play mode, the streak renders in the &lt;strong&gt;center of the lane&lt;/strong&gt; no matter where the character actually is. New particles don’t appear at the character — they appear at world X ≈ 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #1 — “the spawn position is wrong”
&lt;/h2&gt;

&lt;p&gt;My first instinct: I’m instantiating the trail at the wrong place, or not parenting it. I “fixed” the parenting and zeroed the local position. No change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #2 — “stop re-instantiating; cache and reuse”
&lt;/h2&gt;

&lt;p&gt;I rewrote it to pre-instantiate one trail per type and just activate/deactivate them, reasoning that per-event &lt;code&gt;Instantiate&lt;/code&gt; was somehow mis-placing things. Cleaner code. Same bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #3 — “it’s World-space particle semantics + speed”
&lt;/h2&gt;

&lt;p&gt;I reasoned that with &lt;strong&gt;World simulation space&lt;/strong&gt; , particles are stamped into the world where they’re born and left behind, and with a long lifetime over a fast run they’d stretch back toward the start (≈ X=0). So I shortened particle lifetime to make a tight “comet tail.” Same bug — still centered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #4 — “use Local space, it’ll stick to the character”
&lt;/h2&gt;

&lt;p&gt;I forced &lt;strong&gt;Local simulation space&lt;/strong&gt;. Now the particles &lt;em&gt;did&lt;/em&gt; stick to the character… but they clustered on it with no trailing streak at all. I’d traded one wrong behavior for another, and — critically — I never confirmed Local had actually taken effect (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #5 — “add a world-space backward velocity to fake the streak”
&lt;/h2&gt;

&lt;p&gt;Local emission + a world-space backward velocity to make particles hang behind. This threw &lt;code&gt;Particle Velocity curves must all be in the same mode&lt;/code&gt; (you must set X/Y/Z velocity curves together), and when fixed, &lt;em&gt;re-introduced&lt;/em&gt; the X=0 behavior. I’d reintroduced a world-space transform path and the bug came back with it.&lt;/p&gt;

&lt;p&gt;At this point I had changed six things and understood nothing. That’s the signal to &lt;strong&gt;stop guessing and start measuring.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurements that cracked it
&lt;/h2&gt;

&lt;p&gt;Two probes finally produced facts instead of theories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe 1 — the emitter vs. the particles.&lt;/strong&gt; I logged, every frame, the emitter’s world position and the particle system’s &lt;strong&gt;render-bounds center&lt;/strong&gt; (where the particles actually are in the world):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
emitter world = (1.04, 0.69, 30.28)
particle bounds center = (-0.04, 0.69, 30.24)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The emitter was genuinely at X=1.04. The particles were at X≈0. &lt;strong&gt;Y and Z matched the emitter; only X was wrong.&lt;/strong&gt; So this was never a parenting bug — the GameObject &lt;em&gt;was&lt;/em&gt; on the character. The particles were being &lt;em&gt;born&lt;/em&gt; at the wrong X.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe 2 — what simulation space is actually running.&lt;/strong&gt; The same log printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
sim = World

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…even though my code now set the field default to &lt;code&gt;Local&lt;/code&gt;. That’s a Unity trap I’d walked straight into:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Changing a field’s default in code does not change the value already serialized on a component.&lt;/strong&gt; The component had been saved with &lt;code&gt;simulationSpace = World&lt;/code&gt; earlier, so every “Local” change in code was silently ignored.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So my “Wrong turn #4” had never even run as Local. Once I set the &lt;em&gt;serialized&lt;/em&gt; value to Local, the particles finally tracked the character’s X — confirming the bug was specific to &lt;strong&gt;World-space emission&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: particle trail emission timing
&lt;/h2&gt;

&lt;p&gt;Now the axis asymmetry made total sense. The character’s motion was split across two scripts on &lt;strong&gt;two different update steps&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forward (Z)&lt;/strong&gt; was applied in &lt;code&gt;Update&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lateral (X)&lt;/strong&gt; was applied as an &lt;em&gt;override&lt;/em&gt; in &lt;code&gt;LateUpdate&lt;/code&gt; — a second controller wrote &lt;code&gt;transform.position.x = laneX&lt;/code&gt; late in the frame.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most of the frame, the character’s X sat at ~0 (the forward controller pulls it toward a neutral target); only &lt;code&gt;LateUpdate&lt;/code&gt; slammed it back to the real lane value. A &lt;strong&gt;World-space particle system samples the emitter’s position when it emits — before that LateUpdate correction lands — so every particle was stamped at X≈0.&lt;/strong&gt; Z was never overridden late, so Z was always correct. Hence: &lt;strong&gt;follows Y and Z, stuck at X=0.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It was never the prefab, the parenting, the scale, the lifetime, or the simulation space &lt;em&gt;by itself&lt;/em&gt;. It was &lt;strong&gt;frame update order&lt;/strong&gt; colliding with how/when particle systems read the emitter transform. Any World-space particle trail attached to this character would break identically — so “try a different particle pack” would have wasted even more time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a Local-space particle trail
&lt;/h2&gt;

&lt;p&gt;I stopped trying to make World-space emission work and rebuilt the particle trail on &lt;strong&gt;Local&lt;/strong&gt; space, which is immune to the timing problem because it emits relative to the emitter no matter when the transform settles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local simulation space&lt;/strong&gt; — emission is always at the character (correct X). Confirmed by render-bounds tracking the emitter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A local −Z backward drift ≈ the run speed&lt;/strong&gt; — so particles, born at the character, drift backward in the character’s frame at roughly the speed the character moves forward. Net world motion ≈ zero, so they &lt;em&gt;hang on the ground and fade&lt;/em&gt; — a real leave-behind trail, but produced by velocity rather than by world-space emission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable the prefab’s Limit-Velocity (clamp) module&lt;/strong&gt; — it was capping speed to ~1 and damping the backward drift to nothing (particles clustered). This was the difference between “blob on the character” and “streak.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower the particles’ Start Speed&lt;/strong&gt; — the prefab fired them outward at random; with the clamp off, that randomness sprayed everywhere. A low start speed lets the drift line them up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spawn-per-event, self-despawning&lt;/strong&gt; — each pickup spawns its own short-lived instance that stops emitting after its duration and destroys itself once the tail fades; long-lived trails are stopped on a game event. Simple, no pooling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything became an inspector knob (drift speed, lifetime, start speed, emit height per trail type), because the “right” values are pure visual taste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls, collected
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Listen to axis asymmetry.&lt;/strong&gt; “Y and Z are fine, X is wrong” is not a particle problem — it’s a &lt;em&gt;which-thing-touches-X-differently&lt;/em&gt; problem. That should have pointed me at the lateral controller and update order in minute five.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the particles, not the emitter.&lt;/strong&gt; I kept confirming the emitter followed the character (it did) and concluding “so it should work.” The particles’ render-bounds center was the only thing that mattered, and I logged it last instead of first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t trust idle samples.&lt;/strong&gt; An early probe showed the character at &lt;code&gt;(0,0,0)&lt;/code&gt; and I briefly concluded “the world scrolls, the character is stationary.” It was just sampling before the run started. One bad sample sent me down a whole wrong model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serialized values silently override code defaults.&lt;/strong&gt; Changing a &lt;code&gt;public&lt;/code&gt; field’s initializer doesn’t touch components already saved in a scene/prefab. If a code change “does nothing,” verify the &lt;em&gt;actual runtime value&lt;/em&gt;, not the source default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Particle velocity curves are all-or-nothing per mode.&lt;/strong&gt; Set X/Y/Z together or Unity throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A “Limit Velocity over Lifetime” module will quietly eat any velocity you add.&lt;/strong&gt; If your forces seem to do nothing, check the clamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Six fixes without a measurement is a process failure, not bad luck.&lt;/strong&gt; The rule should kick in much earlier: after the &lt;em&gt;second&lt;/em&gt; failed fix, stop and instrument.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Self-reflection
&lt;/h2&gt;

&lt;p&gt;The technical lesson is real (update order vs. particle emission timing, and Local-space + drift as a robust workaround). But the meta-lesson is the one that actually cost the hours: I pattern-matched to plausible causes — parenting, instantiation, world-space semantics — and “fixed” each before I had any evidence it was the cause. Every one of those fixes was a guess wearing a confidence costume.&lt;/p&gt;

&lt;p&gt;The turning point wasn’t cleverness; it was switching from &lt;em&gt;explaining&lt;/em&gt; to &lt;em&gt;measuring&lt;/em&gt; — logging the emitter against the real particle positions, and printing the runtime simulation space instead of assuming my code default applied. Both took five minutes and immediately ended the guessing. The clue I needed (Y/Z follow, X doesn’t) was in the very first bug report. Next time: when a symptom is that specific, treat the specificity as the lead, and reach for the probe before the fix.&lt;/p&gt;

&lt;p&gt;And there was a second clue I held the whole time and failed to read. Early on I’d parented the trail under the character and, &lt;strong&gt;in play mode, dragged the character around by editing its Transform position by hand&lt;/strong&gt; — and the trail followed perfectly. I filed that under “so the parenting is fine” and moved on. But it was telling me something sharper: a manual Inspector edit sets the transform &lt;em&gt;directly and statically&lt;/em&gt; — it is &lt;strong&gt;not&lt;/strong&gt; re-applied every frame by a script, so it never participates in the Update→LateUpdate tug-of-war that the runtime controllers do. With the scripts off the hook, the emitter X just sat at the value I dragged it to, and World-space emission sampled it correctly. The only difference between “works” (manual drag) and “broken” (play mode) was &lt;strong&gt;whether scripted, frame-ordered position updates were involved at all.&lt;/strong&gt; That contrast points almost directly at update timing — if I’d asked “what is &lt;em&gt;different&lt;/em&gt; about how position gets set in the two cases?” instead of “is it parented?”, I’d have arrived at the LateUpdate override hours earlier. A test that works is not just reassurance; the &lt;em&gt;reason&lt;/em&gt; it works is often the diagnosis.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/particle-trail-stuck-at-x-zero/" rel="noopener noreferrer"&gt;Particle Trail Stuck at X=0: A Unity Update-Order Bug (and Six Wrong Fixes)&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>unity3d</category>
      <category>vfx</category>
      <category>updateorder</category>
    </item>
    <item>
      <title>Unity WebGL Particles Invisible? Causes and How to Fix It</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sun, 28 Jun 2026 15:39:18 +0000</pubDate>
      <link>https://dev.to/fur-gaming/unity-webgl-particles-invisible-causes-and-how-to-fix-it-358a</link>
      <guid>https://dev.to/fur-gaming/unity-webgl-particles-invisible-causes-and-how-to-fix-it-358a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgot7v0rjixfwixy9f2i1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgot7v0rjixfwixy9f2i1.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I spent the better part of a day chasing a celebration firework that played perfectly in the Unity editor and rendered &lt;strong&gt;absolutely nothing&lt;/strong&gt; in the WebGL build. No errors. The particles spawned, played, sat right in front of the camera — and stayed invisible.&lt;/p&gt;

&lt;p&gt;I diagnosed it confidently &lt;strong&gt;four times.&lt;/strong&gt; The first three were wrong. The only thing that ever found truth was a dumb A/B test &lt;strong&gt;in the actual build&lt;/strong&gt; — not in the editor, not by reading shaders, not by theorizing. That turned out to be the real lesson, so I’ll tell it honestly — wrong turns and all — and then map out what WebGL genuinely can and can’t do with particles.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The setup&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A Unity 6 / URP mobile-style game shipped to &lt;strong&gt;WebGL&lt;/strong&gt;. The win screen opens a treasure chest with a firework burst and a soft neon glow over the whole scene. Two things broke &lt;em&gt;only&lt;/em&gt; in the WebGL build:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;bloom/glow&lt;/strong&gt; was missing.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;firework&lt;/strong&gt; was completely invisible — despite the particle system clearly running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They turned out to be unrelated, which is part of why this took so long.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #1 — right, but only for the glow: shader variant stripping
&lt;/h2&gt;

&lt;p&gt;URP strips “unused” shader variants from builds to save size, and the editor never strips. So &lt;em&gt;editor-works / build-doesn’t&lt;/em&gt; should make you suspect stripping first.&lt;/p&gt;

&lt;p&gt;The missing &lt;strong&gt;bloom&lt;/strong&gt; was exactly that — URP’s Bloom post-processing variant was being stripped on WebGL (&lt;code&gt;Strip Unused Post Processing Variants&lt;/code&gt;). Disabling it brought the glow back (I later moved the glow to &lt;strong&gt;MK Glow&lt;/strong&gt; , a renderer feature — see the quality-tier trap below).&lt;/p&gt;

&lt;p&gt;Good fix — for the glow. Then I made the classic mistake: I assumed the firework had the &lt;em&gt;same&lt;/em&gt; villain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #2 — “the Shader Graph must render black on GLES3”
&lt;/h2&gt;

&lt;p&gt;The firework was from a popular Shader-Graph-based pack (Cartoon FX Remaster). I instrumented the celebration code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
firework diag @t=2.5s: totalParticles=164 anyPlaying=True anyVisible=True
shaders=[Cartoon FX/Remaster/Particle Procedural Glow, Particle Ubershader]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Particles &lt;strong&gt;exist&lt;/strong&gt; , system &lt;strong&gt;playing&lt;/strong&gt; , renderer &lt;strong&gt;on-screen&lt;/strong&gt; , &lt;strong&gt;real&lt;/strong&gt; shaders (not the magenta error shader). Yet no visible color — even when I cranked a glow pass to blow out the whole screen, the firework area simply never appeared.&lt;/p&gt;

&lt;p&gt;My conclusion sounded airtight: &lt;strong&gt;Unity WebGL = WebGL 2.0 = OpenGL ES 3.0&lt;/strong&gt; , and Unity cross-compiles its HLSL down to GLSL ES 3.00. For a big particle “ubershader,” I figured the cross-compiler was emitting GLSL that compiles but outputs black — a translation bug that bites only on the GLES3 path, never in the editor (Metal/D3D).&lt;/p&gt;

&lt;p&gt;Plausible. Confident. &lt;strong&gt;Wrong&lt;/strong&gt; — as I’d find out two diagnoses later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that worked — for a different reason than I thought
&lt;/h2&gt;

&lt;p&gt;I swapped the Shader-Graph firework for an &lt;strong&gt;Epic Toon FX&lt;/strong&gt; firework — CPU &lt;code&gt;ParticleSystem&lt;/code&gt; (Shuriken) + &lt;code&gt;URP/Particles/Unlit&lt;/code&gt;. Plain, old, boring tech. It rendered in the editor immediately.&lt;/p&gt;

&lt;p&gt;…and was &lt;strong&gt;invisible in the WebGL build.&lt;/strong&gt; Again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #3 → the first &lt;em&gt;confirmed&lt;/em&gt; truth: Soft Particles need a depth texture
&lt;/h2&gt;

&lt;p&gt;Same signature — plays in editor, audio + feedback fire (so it’s emitting), nothing on screen. But this time the cause was real and provable. I inspected the live materials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
shader: Universal Render Pipeline/Particles/Unlit
keywords: [..._SOFTPARTICLES_ON...] _SoftParticlesEnabled = 1
URP 'High' (WebGL tier): supportsCameraDepthTexture = False

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Soft Particles&lt;/strong&gt; — the fade where a particle meets opaque geometry — needs the &lt;strong&gt;camera depth texture&lt;/strong&gt; , and the WebGL tier wasn’t generating one. With no depth bound, the fade reads an unbound texture: desktop returns “far” (visible); &lt;strong&gt;WebGL GLES3 returns 0 → alpha ≈ 0 → fully transparent.&lt;/strong&gt; Same material, opposite result per platform. No shader error — it compiles fine and fades itself to nothing.&lt;/p&gt;

&lt;p&gt;Confirmed via the WebGL console (Playwright): &lt;strong&gt;zero shader errors&lt;/strong&gt; — a runtime fade, not a compile failure. The fix was one toggle: &lt;strong&gt;turn Soft Particles off&lt;/strong&gt; (an open-air firework never intersects geometry, so they did nothing anyway). The firework finally rendered on WebGL. 🎆 It’s also why the &lt;strong&gt;coins&lt;/strong&gt; were fine the whole time: opaque, no soft particles, no depth dependency.&lt;/p&gt;

&lt;p&gt;So I’d “solved” it — and, generalizing, told myself the &lt;em&gt;original&lt;/em&gt; CFXR firework must have been soft particles too. Tidy unified theory. Also wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reckoning: a 30-second A/B test demolishes my Shader-Graph theory
&lt;/h2&gt;

&lt;p&gt;The chests also carried a CFXR glow halo (&lt;code&gt;CFXR3 LightGlow A (Loop)&lt;/code&gt;). If “CFXR renders black on WebGL” were true, those would be invisible too. So I tested it the only way that matters — &lt;strong&gt;in the build&lt;/strong&gt; : two chests at the start line, one with the CFXR glow, one without.&lt;/p&gt;

&lt;p&gt;The one &lt;strong&gt;with&lt;/strong&gt; the CFXR glow &lt;strong&gt;glowed.&lt;/strong&gt; On WebGL. The pack I’d declared “renders black” was rendering just fine.&lt;/p&gt;

&lt;p&gt;So I pulled the shader &lt;strong&gt;keywords&lt;/strong&gt; of a CFXR material that &lt;em&gt;works&lt;/em&gt; (the glow) and one that &lt;em&gt;fails&lt;/em&gt; (the firework):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
WORKS "cfxr stretch ray blur add" → _CFXR_ADDITIVE, _FADING_ON
FAILS "cfxr stretch trait blur add" → _CFXR_ADDITIVE, _FADING_ON ← identical

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Identical shader, identical keywords, identical soft-particle settings — one renders, one doesn’t.&lt;/strong&gt; That kills every shader-level theory at once: not the Shader Graph, not the variant, not soft particles, not HDR. The &lt;em&gt;same compiled shader&lt;/em&gt; draws in the loop-glow and draws nothing in the firework burst.&lt;/p&gt;

&lt;p&gt;The real differentiator is &lt;strong&gt;at the ParticleSystem / effect level&lt;/strong&gt; — the failing effects are complex one-shot &lt;strong&gt;bursts&lt;/strong&gt; (sub-emitters, velocity, lifetime curves, the pack’s lifecycle script); the working one is a simple &lt;strong&gt;loop glow&lt;/strong&gt;. Something in the burst machinery doesn’t survive the WebGL runtime — particles spawn and the renderer reports on-screen, but they contribute no visible pixels. I never pinned the exact module, and didn’t need to: the game ships the working glow + the fixed CPU firework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest scoreboard:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;glow&lt;/strong&gt; was shader-variant stripping. ✅ correct&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;CPU firework&lt;/strong&gt; was Soft Particles + no depth. ✅ confirmed&lt;/li&gt;
&lt;li&gt;“CFXR renders black on GLES3”? ❌ &lt;strong&gt;wrong.&lt;/strong&gt; CFXR renders on WebGL; &lt;em&gt;some&lt;/em&gt; of its burst effects don’t, for a non-shader reason I never proved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three confident diagnoses, one survivor. The thing that exposed each wrong one was the &lt;strong&gt;build&lt;/strong&gt; , not the editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Unity WebGL particles actually render (a GPU-feature wall)
&lt;/h2&gt;

&lt;p&gt;The useful map. WebGL 2.0 / GLES3 supports modern &lt;em&gt;vertex/fragment&lt;/em&gt; shaders (GLSL ES 3.00) but has &lt;strong&gt;no compute, geometry, or tessellation shaders.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;th&gt;Runs on WebGL2?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Built-in / URP standard shaders&lt;/td&gt;
&lt;td&gt;✅ always&lt;/td&gt;
&lt;td&gt;simple frag/vert, cross-compiles cleanly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU &lt;code&gt;ParticleSystem&lt;/code&gt; (Shuriken)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;CPU sim + standard shaders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shader Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ usually&lt;/td&gt;
&lt;td&gt;generates a normal shader; mostly fine on GLES3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VFX Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ never&lt;/td&gt;
&lt;td&gt;GPU-simulated via &lt;strong&gt;compute shaders&lt;/strong&gt; — absent on WebGL2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geometry / tessellation&lt;/td&gt;
&lt;td&gt;❌ never&lt;/td&gt;
&lt;td&gt;absent on WebGL2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I deliberately &lt;em&gt;softened&lt;/em&gt; the Shader Graph row from my first draft — I’d written “can render black,” but my own A/B test later showed the Shader Graph renders fine and the failures were elsewhere. Shader Graph is an &lt;strong&gt;authoring tool&lt;/strong&gt; that emits an ordinary shader: treat it as “usually fine, verify in build,” not “cursed.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VFX Graph&lt;/strong&gt; is the categorical wall: it &lt;em&gt;simulates particles on the GPU via compute shaders&lt;/em&gt;, and WebGL2 has no compute, so it &lt;strong&gt;can’t run at all.&lt;/strong&gt; That’s why “Realistic Fireworks — VFX Graph, supports URP!” asset-store packs are a non-starter for WebGL.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“Supports URP” describes the render pipeline, not the platform.&lt;/strong&gt; Your constraint is WebGL, not URP.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A bonus trap: the editor lied via the quality tier
&lt;/h2&gt;

&lt;p&gt;When I added MK Glow it rendered in the WebGL build but did &lt;em&gt;nothing&lt;/em&gt; in the editor — sliders had no effect. I burned time on render-graph theories before introspecting the live editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
QualityLevel 2 (PC) → PC_Renderer (no MK Glow)
QualityLevel 3 (High)→ Default_Forward_Renderer (has MK Glow) ← WebGL uses this

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The editor was previewing on the &lt;strong&gt;PC&lt;/strong&gt; tier — a &lt;em&gt;different renderer&lt;/em&gt; than WebGL’s &lt;strong&gt;High&lt;/strong&gt; tier — so my feature wasn’t even in the active pipeline. Setting the editor to High gave instant preview. Bonus gotchas from the same hole: the &lt;strong&gt;Scene view doesn’t render custom renderer-feature passes&lt;/strong&gt; (only the Game view does), and &lt;strong&gt;Volume edits made during Play mode revert on Stop.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Unity ↔ three.js (because the compute wall is universal)
&lt;/h2&gt;

&lt;p&gt;The same wall shows up in three.js — with a different escape hatch.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Unity&lt;/th&gt;
&lt;th&gt;three.js&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shader authoring abstraction&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Shader Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;TSL&lt;/strong&gt; (Three.js Shading Language)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw shader code&lt;/td&gt;
&lt;td&gt;HLSL / ShaderLab&lt;/td&gt;
&lt;td&gt;GLSL / WGSL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU compute&lt;/td&gt;
&lt;td&gt;Compute Shaders (HLSL)&lt;/td&gt;
&lt;td&gt;WebGPU compute / &lt;strong&gt;TSL compute fns&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU particle sim (compute)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;VFX Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GPU-compute particles (TSL + WebGPU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU particle sim&lt;/td&gt;
&lt;td&gt;Shuriken &lt;code&gt;ParticleSystem&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;CPU-updated &lt;code&gt;Points&lt;/code&gt;/sprites&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things people (me included) get backwards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TSL ≈ Shader Graph, not VFX Graph.&lt;/strong&gt; It’s an &lt;em&gt;authoring layer&lt;/em&gt; — you write shading logic in a JS node API and TSL compiles it to &lt;strong&gt;WGSL&lt;/strong&gt; (WebGPU) or &lt;strong&gt;GLSL&lt;/strong&gt; (WebGL2 fallback). It can &lt;em&gt;also&lt;/em&gt; author compute, but that’s one capability, not its identity.&lt;/li&gt;
&lt;li&gt;The equivalent of &lt;strong&gt;VFX Graph&lt;/strong&gt; is &lt;strong&gt;GPU-compute particles built with TSL compute on the WebGPU renderer&lt;/strong&gt; — the technique, not the language.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The strategic difference today: &lt;strong&gt;three.js is well ahead on WebGPU&lt;/strong&gt; (its &lt;code&gt;WebGPURenderer&lt;/code&gt; + TSL are production-usable, and WebGPU browser support is real and growing), so you &lt;em&gt;can&lt;/em&gt; ship GPU-compute particles there if you target WebGPU. &lt;strong&gt;Unity’s web export is still WebGL2/GLES3&lt;/strong&gt; (its WebGPU backend is experimental), so for a shipping Unity web game you assume &lt;strong&gt;no compute → no VFX Graph&lt;/strong&gt; , and CPU particles + standard shaders are the lane. The “realistic GPU fireworks” I wanted would be on the table in three.js-on-WebGPU; on Unity-on-WebGL2, the boring CPU &lt;code&gt;ParticleSystem&lt;/code&gt; was the right — and only — answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test in the actual build — every time.&lt;/strong&gt; The editor renders through a different backend (Metal/D3D), often a different quality tier, and never strips shaders. &lt;em&gt;Every&lt;/em&gt; wrong diagnosis came from trusting the editor or my own reasoning; &lt;em&gt;every&lt;/em&gt; correction came from the build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invisible particles have several unrelated causes&lt;/strong&gt; — shader-variant stripping, Soft Particles (no depth texture), and particle-system quirks all produce the same “spawns, on-screen, invisible.” Don’t pattern-match the second case to the first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft Particles need the depth texture&lt;/strong&gt; the WebGL tier doesn’t provide → silent fade to invisible. Turn them off, or enable Depth Texture (at the cost of a pass).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VFX Graph never ships to WebGL&lt;/strong&gt; (needs compute). &lt;strong&gt;Shader Graph usually does&lt;/strong&gt; — verify in build; don’t assume it’s “black on GLES3.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Supports URP” ≠ “supports WebGL.”&lt;/strong&gt; Platform, not pipeline, is the constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know when to stop.&lt;/strong&gt; I never fully pinned &lt;em&gt;why&lt;/em&gt; the CFXR firework burst was invisible here (CFXR otherwise renders fine on WebGL — the chest glow proves it). Once a reliable alternative worked, the forensics weren’t worth it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stay humble about confident diagnoses.&lt;/strong&gt; I had three airtight ones. A 30-second in-build A/B test beat all of them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The firework glows now — a CPU particle system from decade-old tech, lit by a renderer-feature glow, rendering happily in a browser. Sometimes the boring tool is the right tool. And the fastest debugger is often two objects side by side in the actual build.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/unity-webgl-particles-invisible-casuses-and-how-to-fix-it/" rel="noopener noreferrer"&gt;Unity WebGL Particles Invisible? Causes and How to Fix It&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>shader</category>
      <category>webgl</category>
      <category>threejs</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Building MESH: A Civic Resilience Platform With AI Agents</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 23 May 2026 08:27:29 +0000</pubDate>
      <link>https://dev.to/fur-gaming/building-mesh-a-civic-resilience-platform-with-ai-agents-15jf</link>
      <guid>https://dev.to/fur-gaming/building-mesh-a-civic-resilience-platform-with-ai-agents-15jf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsnposaatcybyk1cjzfi7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsnposaatcybyk1cjzfi7.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A one-day hackathon project at the Claude Impact Lab (Melbourne, May 2026). The brief was open. I built MESH — &lt;em&gt;Melbourne Exchange &amp;amp; Solidarity Hub&lt;/em&gt; — a gamified civic platform where residents see their suburb as a node in a network and AI agents read live open data to suggest the most useful thing the community can do next.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9eu49lxaww7b3fm2hcnq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9eu49lxaww7b3fm2hcnq.jpg" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Why MESH
&lt;/h2&gt;

&lt;p&gt;Every Australian suburb is sitting on quiet abundance. Gardens that produce too much. Retirees who could teach welding. Neighbours who’d happily check on each other in a heatwave if anyone asked. The capacity exists. The wiring doesn’t.&lt;/p&gt;

&lt;p&gt;Three observations led me to MESH:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Civic capacity is fragmented.&lt;/strong&gt; There’s no shared map of who can teach what, who has spare zucchini, who’s ready in a heatwave. The information lives in group chats, in heads, in council PDFs nobody reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open data is inert.&lt;/strong&gt; &lt;code&gt;data.melbourne.vic.gov.au&lt;/code&gt; alone publishes 239 datasets. Almost none of them inform a decision a resident will make this week. The data isn’t missing — the &lt;em&gt;loop&lt;/em&gt; is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unit of resilience is the suburb.&lt;/strong&gt; “City-wide” is too coarse to coordinate. “Neighbourhood” is too granular to measure. The 3-km suburb — Carlton, Footscray, Brunswick — is the natural unit. So MESH scores, narrates, and gamifies at that scale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The vision: each Melbourne suburb gets a &lt;strong&gt;resilience index&lt;/strong&gt; (R-index) derived from five pillars — food security, skill density, resource sharing, social connectivity, emergency preparedness. AI agents read each suburb’s data and suggest the most impactful initiative residents could run &lt;em&gt;this week&lt;/em&gt;. Completing it earns XP. The suburb’s score moves. The board updates. Other residents see it. It is meant to feel like a slow-motion multiplayer game.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. What it does — a five-minute tour
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The map
&lt;/h3&gt;

&lt;p&gt;The home page is the centrepiece. A real Google Maps WebGL view of inner Melbourne with five suburb polygons (OSM-sourced) colour-coded by R-index. Clicking a suburb (or a sidebar row) tilts the camera to 2.5D, zooms in, and reveals 3D building extrusion native to the basemap. A top-r_index suburb gets a permanent pulsing golden ring; every selection fires a screen-projected burst animation; flowing dots travel along the inter-suburb lines as a stand-in for &lt;em&gt;potential exchange capacity&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A small &lt;strong&gt;(i) provenance icon&lt;/strong&gt; next to the suburb name expands a panel showing exactly where that suburb’s data came from — real / partial / SEIFA-approximated — with links to each underlying dataset. The detail card also embeds a live &lt;strong&gt;advisor chat&lt;/strong&gt; that streams Claude responses contextualised to the selected suburb.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuoi994dwipix01zg50bq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuoi994dwipix01zg50bq.jpg" alt="Streaming advisor chat for Carlton" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The quest board
&lt;/h3&gt;

&lt;p&gt;Five hand-curated &lt;strong&gt;EXAMPLE&lt;/strong&gt; quests load on first paint (one per suburb, each targeting that suburb’s weakest pillar). A single click — &lt;em&gt;“Regenerate with Claude →”&lt;/em&gt; — replaces the seed with a fresh AI-generated quest, marked &lt;strong&gt;AI&lt;/strong&gt; , that persists to &lt;code&gt;localStorage&lt;/code&gt; so it survives a refresh. Quests generated from the advisor chat appear here too — the two surfaces share state.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4qiapx606ababbik252s.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4qiapx606ababbik252s.jpg" alt="Quest board with seeded + AI-generated cards" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The pitch + the admin surface
&lt;/h3&gt;

&lt;p&gt;There’s also &lt;code&gt;/pitch&lt;/code&gt; (a long-form editorial walkthrough I built for the hackathon judging panel — Fraunces display type, scroll-paced narrative covering vision, pillars, the data → quest → XP loop, the six agents, honest data provenance, the stack, the roadmap) and &lt;code&gt;/admin&lt;/code&gt; (gated on &lt;code&gt;profile.is_admin === true&lt;/code&gt;, used to bulk-regenerate every quest with Claude in front of a live audience).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/login&lt;/code&gt; has demo personas — pick Maya from Carlton, Tom from Brunswick, Sofia from Footscray, the Admin operator, or play yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Real data, transparent AI
&lt;/h2&gt;

&lt;p&gt;This is the bit I’m most proud of.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real open data with honest provenance
&lt;/h3&gt;

&lt;p&gt;The five suburb pillar scores aren’t made up. They’re pulled by &lt;code&gt;scripts/seed-suburbs.ts&lt;/code&gt; from three live Melbourne open-data feeds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;social-indicators-for-city-of-melbourne-residents-2023&lt;/code&gt; (City of Melbourne resident survey)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;landmarks-and-places-of-interest&lt;/code&gt; (community-facility density within 1.5 km of each centroid)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pedestrian-counting-system-monthly-counts-per-hour&lt;/code&gt; (foot traffic → social-connectivity proxy)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus &lt;strong&gt;ABS 2021 SEIFA IRSD deciles&lt;/strong&gt; as the fallback for any pillar without a direct signal, and OSM via Nominatim for the boundary polygons.&lt;/p&gt;

&lt;p&gt;The catch: &lt;code&gt;data.melbourne.vic.gov.au&lt;/code&gt; only covers the City of Melbourne LGA. So of my five demo suburbs, only Carlton has full real data. Fitzroy gets partial coverage (sensors and landmarks reach across the LGA border). Brunswick, Footscray, and Richmond — different LGAs — fall back to SEIFA approximations.&lt;/p&gt;

&lt;p&gt;Rather than hide that, I made it the brand. Every suburb’s detail card shows a &lt;code&gt;real&lt;/code&gt; / &lt;code&gt;partial&lt;/code&gt; / &lt;code&gt;seifa&lt;/code&gt; chip with links to the exact datasets used. The &lt;code&gt;/pitch&lt;/code&gt; page has a full provenance table. The &lt;code&gt;mock-suburbs.ts&lt;/code&gt; file has a per-suburb comment header showing the derivation for the most recent seed run. The schema even has a &lt;code&gt;data_source&lt;/code&gt; column on the &lt;code&gt;suburbs&lt;/code&gt; table to enforce this at the database level.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The “we tell you what’s real” framing is more interesting than any made-up score.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Glass-box AI
&lt;/h3&gt;

&lt;p&gt;There are six agents in the design (see &lt;a href="https://github.com/furic/mesh/blob/main/AGENTS.md" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt;); three are wired end-to-end today: &lt;strong&gt;Quest Generator&lt;/strong&gt; (returns one structured JSON quest for a suburb), &lt;strong&gt;Initiative Advisor&lt;/strong&gt; (streaming SSE chat grounded in the selected suburb’s context), and &lt;strong&gt;Suburb Narrator&lt;/strong&gt; (weekly plain-English digest).&lt;/p&gt;

&lt;p&gt;Every AI quest card surfaces &lt;em&gt;why&lt;/em&gt; it exists, in two layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always visible — the signal strip&lt;/strong&gt; (amber left-border, between badges and description):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SIGNAL · Claude targeted &lt;strong&gt;food security&lt;/strong&gt; because Carlton scored &lt;strong&gt;27/100&lt;/strong&gt; — the lowest of its five pillars.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the smallest possible “data → AI choice” lineage: one sentence, one number. The audience sees in plain English which pillar was lowest and why the agent picked it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expandable — “How this was created”:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model name (&lt;code&gt;claude-sonnet-4-20250514&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Generation timestamp&lt;/li&gt;
&lt;li&gt;The full input the model saw (suburb · SEIFA · population · season)&lt;/li&gt;
&lt;li&gt;The weakest pillar + score (what triggered the choice)&lt;/li&gt;
&lt;li&gt;All five pillar scores at generation time as F/S/R/C/E chips&lt;/li&gt;
&lt;li&gt;A link to &lt;code&gt;/pitch#provenance&lt;/code&gt; for the underlying datasets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent endpoint at &lt;code&gt;src/routes/api/quests/+server.ts&lt;/code&gt; returns the &lt;code&gt;data_snapshot&lt;/code&gt; alongside the quest, the snapshot persists with the quest in &lt;code&gt;localStorage&lt;/code&gt;, so refreshing keeps the panel populated. The DB schema already has a &lt;code&gt;data_snapshot jsonb&lt;/code&gt; column on the &lt;code&gt;quests&lt;/code&gt; table — when Supabase comes online the persistence layer is already aligned.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No vibe-based moderation. Every AI decision is auditable in the UI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The output discipline is strict: all JSON-returning agents end their system prompt with &lt;code&gt;OUTPUT: Return ONLY valid JSON.&lt;/code&gt; and route raw output through a &lt;code&gt;parseAgentJSON()&lt;/code&gt; helper that strips accidental &lt;code&gt;json&lt;/code&gt; fences before &lt;code&gt;JSON.parse&lt;/code&gt;. The advisor is streamed without parsing — its SSE response body pipes straight to the browser. Cost guardrails baked in: max one quest generation per suburb per 24h, narratives cached for 7 days, Matchmaker skipped when the radius is too sparse.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The stack, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt; — SvelteKit 2 + Svelte 5 with runes mode + TypeScript strict. Svelte 5’s &lt;code&gt;$state&lt;/code&gt; / &lt;code&gt;$effect&lt;/code&gt; / &lt;code&gt;$derived&lt;/code&gt; runes made the reactive flow — store hydration from &lt;code&gt;localStorage&lt;/code&gt;, runtime feature-state toggles on the map, the XP-burst on level-up — read cleanly. No external state library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map&lt;/strong&gt; — Google Maps Platform’s WebGL vector renderer with a Map ID configured for tilt + rotation. Pitch 67.5° + heading 20° gives cohesive 2.5D with native 3D building extrusion in the CBD. Polygon overlays sit at zIndex 1000 above the fills; flowing dots are &lt;code&gt;google.maps.Polyline&lt;/code&gt; icons stepped via RAF. I tried MapLibre first; it works but the basemap stays flat and our 3D extrusions float — Google’s WebGL vector is the only path to coherent tilt-with-buildings without a custom tile pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI&lt;/strong&gt; — Anthropic Claude (&lt;code&gt;claude-sonnet-4-20250514&lt;/code&gt;) called directly from SvelteKit endpoints (streaming SSE for the advisor; JSON-mode for everything else). Cost-guarded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open data&lt;/strong&gt; — &lt;code&gt;data.melbourne.vic.gov.au&lt;/code&gt; (Opendatasoft v2.1, anonymous reads) + OpenStreetMap via Nominatim. A one-time fetch script (&lt;code&gt;pnpm fetch:suburb-geo&lt;/code&gt;) writes polygon boundaries to a committed JSON file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt; — Supabase Postgres 15 + PostGIS. Schema + RLS policies fully written (13 SQL migrations) and the typed &lt;code&gt;SupabaseClient&amp;lt;Database&amp;gt;&lt;/code&gt; is wired into a per-request server client in &lt;code&gt;hooks.server.ts&lt;/code&gt; with a typed-Proxy fallback when env is unconfigured. Provisioning a real Supabase project is one user action away; everything else is offline-complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting&lt;/strong&gt; — Vercel via &lt;code&gt;@sveltejs/adapter-vercel&lt;/code&gt;, auto-deploy on push to &lt;code&gt;main&lt;/code&gt;. Preview deploys per PR.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  5. What’s next
&lt;/h2&gt;

&lt;p&gt;MESH is a 10-sprint roadmap; this hackathon got me to roughly sprint 4. The remaining headline pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sprint 7&lt;/strong&gt; — Submission Verifier. The agent that closes the loop: residents submit photo + text evidence of a completed quest, Claude vision verifies, XP is awarded or routed to a moderation queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sprint 8&lt;/strong&gt; — Resource Matchmaker. The agent that powers the “Exchange” in the name. Posts of “I have 10 kg of zucchini” get semantically matched to “I need vegetables for a community kitchen” within a 10 km PostGIS &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; radius, and the mesh edges between matched suburbs light up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sprint 10&lt;/strong&gt; — Anomaly Watcher. Watches each open-data sync for deltas, classifies them as opportunity / risk / info, and spawns reactive quests or alerts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The architecture is already shaped to land them quickly once the Supabase project is provisioned. The data-snapshot + transparency pattern I built into the Quest Generator transfers directly to each new agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo →&lt;/strong&gt; &lt;a href="https://mesh-pi-topaz.vercel.app/" rel="noopener noreferrer"&gt;https://mesh-pi-topaz.vercel.app/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source →&lt;/strong&gt; &lt;a href="https://github.com/furic/mesh" rel="noopener noreferrer"&gt;https://github.com/furic/mesh&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pitch deck-as-a-page →&lt;/strong&gt; &lt;a href="https://mesh-pi-topaz.vercel.app/pitch" rel="noopener noreferrer"&gt;https://mesh-pi-topaz.vercel.app/pitch&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few things worth clicking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign in as &lt;strong&gt;Admin · Demo&lt;/strong&gt; on &lt;code&gt;/login&lt;/code&gt;, then hit &lt;strong&gt;“Generate all 5 with Claude →”&lt;/strong&gt; on &lt;code&gt;/admin&lt;/code&gt;. Watch the EXAMPLE chips flip to AI in real time.&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;/quests&lt;/code&gt;, expand any AI quest’s &lt;em&gt;“How this was created”&lt;/em&gt; — the full agent lineage is right there.&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;/&lt;/code&gt;, click the &lt;strong&gt;(i)&lt;/strong&gt; next to any suburb name to see exactly which datasets fed its scores.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Built in a day for the &lt;strong&gt;Claude Impact Lab&lt;/strong&gt; hackathon (Melbourne, 23 May 2026). Coffee was consumed. Polygons were fetched. AI rationales were strict-JSON-fenced. The streets of Carlton tilted on cue.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/mesh-civic-resilience-platform-claude-ai/" rel="noopener noreferrer"&gt;Building MESH: A Civic Resilience Platform With AI Agents&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>svelte</category>
      <category>webdev</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>Unity WebGL Safari Hang: The First-Draw Shader Stall</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 16 May 2026 07:55:26 +0000</pubDate>
      <link>https://dev.to/fur-gaming/unity-webgl-safari-hang-the-first-draw-shader-stall-43f2</link>
      <guid>https://dev.to/fur-gaming/unity-webgl-safari-hang-the-first-draw-shader-stall-43f2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1llimdqp4fr03qyf1hz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1llimdqp4fr03qyf1hz.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — This is a war story about a &lt;strong&gt;Unity WebGL Safari hang&lt;/strong&gt; : a game that ran beautifully on Chrome but froze for ~3 seconds on the first major animation event in iOS Safari. After a long detour through material instancing, shader keywords, animator transitions, and post-FX, the culprit was a single prefab nobody thought to warm up: an overlay spawned by an obscure event handler one frame before the animation started. &lt;strong&gt;Mid-game &lt;code&gt;Instantiate&lt;/code&gt; of a prefab with a custom shader triggers Safari’s &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; on first render — synchronous, main-thread, ~3ms per uniform lookup, ~200 uniforms across a complex prefab = 2.8s stall.&lt;/strong&gt; The fix is to render the prefab once during the splash so the cache is warm before gameplay needs it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reproducing the Unity WebGL Safari Hang
&lt;/h2&gt;

&lt;p&gt;I’m creating a Unity WebGL game with the Universal Render Pipeline (URP) and a lot of custom shaders — particle bursts, dissolve effects, glow overlays, 3D character animations, the works. The target audience is mobile; the majority of players are on iOS Safari, which is exactly where the Unity WebGL Safari hang showed up.&lt;/p&gt;

&lt;p&gt;Everything tested fine on desktop Chrome — silky 60fps, fast first interaction, no hitches.&lt;/p&gt;

&lt;p&gt;Then we tried Safari on a Mac (and later an iPhone 12). The &lt;strong&gt;first major animation event&lt;/strong&gt; of every session froze the entire game for ~3 seconds. The freeze happened &lt;em&gt;after&lt;/em&gt; the build-up animation completed but &lt;em&gt;before&lt;/em&gt; the payoff sequence visually started. Every subsequent occurrence of the same event was perfectly smooth.&lt;/p&gt;

&lt;p&gt;The pattern was textbook “first-use compile stall”:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;Hang on first event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome (normal)&lt;/td&gt;
&lt;td&gt;~0.1s (barely perceptible)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome (incognito, no GPU cache)&lt;/td&gt;
&lt;td&gt;~0.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;~2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari (macOS)&lt;/td&gt;
&lt;td&gt;~3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 12 Safari&lt;/td&gt;
&lt;td&gt;~3s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 25× delta between Chrome and Safari is the smoking gun. Both browsers run Metal under the hood on macOS/iOS — Chrome via ANGLE, Safari directly — but ANGLE aggressively caches shader compilation work that Safari’s WebGL→Metal translator does over and over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong rabbit holes
&lt;/h2&gt;

&lt;p&gt;Before finding the real cause, we eliminated a long list of suspects through targeted A/B tests. Each took a rebuild + Safari test cycle. Documenting them here in case anyone hits a similar pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A shader keyword toggle.&lt;/strong&gt; Our main mesh shader uses a &lt;code&gt;[Toggle(_USE_DISSOLVE)]&lt;/code&gt; property that activates a fragment-shader branch. Disabling the keyword → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;renderer.material = newInstance&lt;/code&gt; assignment.&lt;/strong&gt; Skipped it on 13 simultaneous objects → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An animator state transition.&lt;/strong&gt; Commented out the state change that occurred on the same frame → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The entire state handler for the suspect state.&lt;/strong&gt; Stubbed out the whole switch case → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MMFeedbacks / Feel post-FX (camera shake, vignette).&lt;/strong&gt; Unsubscribed the handler → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate fade-out coroutine on UI overlays.&lt;/strong&gt; Unsubscribed it → hang persisted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After all six A/Bs, we’d conclusively ruled out everything on the visible event path. The hang persisted even when the event handler did effectively nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic that cracked it
&lt;/h2&gt;

&lt;p&gt;Stopping the guess-and-check loop and capturing a &lt;strong&gt;Safari Web Inspector → Timelines&lt;/strong&gt; profile of the hang was the turning point.&lt;/p&gt;

&lt;p&gt;Inside the 3.1-second &lt;code&gt;Animation Frame Fired&lt;/code&gt; event, the breakdown was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
wasm-stub 1.94s
  _glGetUniformLocation 906ms (261 calls)
    webglPrepareUniformLocationsBeforeFi… 671ms (194 calls)
      getActiveUniform 444ms (128 calls)
      getProgramParameter 211ms (61 calls)
      getUniformLocation 201ms (58 calls)
  _glGetActiveUniform 423ms (122 calls)
  _glGetActiveUniformsiv 409ms (118 calls)
  _glGetActiveUniformBlockiv 191ms (55 calls)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 1.94s is &lt;strong&gt;Unity’s WebGL runtime asking the browser to look up uniform locations&lt;/strong&gt; for shader programs it had never drawn before. The remaining ~1.2s of the frame is the actual Metal pipeline-state-object (PSO) compile triggered by the first draw call.&lt;/p&gt;

&lt;p&gt;Most importantly: &lt;strong&gt;194 distinct shader programs went through &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; in a single frame.&lt;/strong&gt; That’s not “one or two new variants” — that’s almost the entire scene’s shader inventory being prepared at once.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; is WebKit-specific. On first draw of a program, it iterates every active uniform, queries the location for each, and caches the mapping. Each call into the WebGL JavaScript API costs ~3ms on Safari (vs ~0.1ms on Chrome’s ANGLE). 200 programs × ~10 calls each at 3ms ≈ 6 seconds of synchronous main-thread work. Spread that across however many programs need preparing on a given frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual root cause
&lt;/h2&gt;

&lt;p&gt;Once we knew it was first-draw uniform reflection, the question became &lt;em&gt;which prefab is first-drawn on that specific frame&lt;/em&gt;. The visible event handler had been stubbed out. Material instancing had been skipped. So what spawned at exactly the right moment?&lt;/p&gt;

&lt;p&gt;The game has a server-driven event sequence with several intermediate steps between the trigger and the visible animation. One of those intermediate steps was an “overlay reveal” — a manager that handles a separate server event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="n"&gt;GameObject&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Instantiate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overlayPrefab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localPosition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;overlay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="n"&gt;OverlayComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fresh &lt;code&gt;Instantiate&lt;/code&gt;, no pool. The overlay’s first draw call happens on the &lt;strong&gt;very next render frame&lt;/strong&gt; after spawn — which on this code path is &lt;strong&gt;frame #2 of the next animation’s wait window&lt;/strong&gt;. The hang at frame #2 matched perfectly. The overlay prefab uses a custom TMP text shader plus a particle/glow combo, with about 200 uniforms across its passes.&lt;/p&gt;

&lt;p&gt;Our shader warmup at boot covered the major VFX prefabs we knew about — the burst particles, the highlight glow, the popup labels, the dissolve variant of the main mesh shader. &lt;strong&gt;It did not cover this overlay&lt;/strong&gt; because the manager was an “old” engine system that pre-dated the warmup pattern. Nobody connected the dots: the overlay is spawned by a different event than the animation that hangs, so its first draw appears to be unrelated to the hang location.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the Unity WebGL Safari Hang
&lt;/h2&gt;

&lt;p&gt;A single line in the overlay manager’s &lt;code&gt;Awake&lt;/code&gt; resolved the Unity WebGL Safari hang completely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;MyGame.Rendering&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;virtual&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Awake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...existing setup...&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overlayPrefab&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ShaderWarmup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WarmupPrefab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overlayPrefab&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ShaderWarmup.WarmupPrefab&lt;/code&gt; does the bare minimum needed to make WebKit compile the program and cache its uniform locations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Instantiates one copy of the prefab as a child of a &lt;code&gt;WarmupParent&lt;/code&gt; transform (parked at the gameplay anchor — wherever the real prefab will spawn in-game, so lighting matches)&lt;/li&gt;
&lt;li&gt;Plays any &lt;code&gt;ParticleSystem&lt;/code&gt; components so their geometry actually emits this frame&lt;/li&gt;
&lt;li&gt;Lets the &lt;strong&gt;main camera&lt;/strong&gt; render the warmup parent for 3 frames during the splash screen — same camera, same lighting state, same render-pass configuration as gameplay, so the PSO key matches&lt;/li&gt;
&lt;li&gt;Destroys the throwaway instance&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The web front-end’s splash screen waits for both &lt;code&gt;loadProgress &amp;gt;= 1&lt;/code&gt; AND a Unity-side &lt;code&gt;gameReady&lt;/code&gt; signal that only fires after every warmup pass has flushed. So the 1-3 seconds of pre-compile work happens behind the loading bar where players already expect to wait, instead of during a critical animation later.&lt;/p&gt;

&lt;p&gt;Result on Safari: &lt;strong&gt;first event dropped from 3.1s to ~500ms&lt;/strong&gt; — identical to every subsequent occurrence.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need this — and when you don’t
&lt;/h2&gt;

&lt;p&gt;The combination of all three must be true:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Need warmup if…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lifecycle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Prefab is &lt;code&gt;Instantiate&lt;/code&gt;d at runtime (not present from scene load)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shader complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Uses non-trivial shader: custom &lt;code&gt;*.shader&lt;/code&gt;, &lt;code&gt;[Toggle(_KEYWORD)]&lt;/code&gt; properties, &lt;code&gt;multi_compile&lt;/code&gt; keywords, multi-pass shaders, particle shaders, or TMP shaders with &lt;code&gt;OUTLINE_ON&lt;/code&gt; / &lt;code&gt;UNDERLAY_ON&lt;/code&gt; / &lt;code&gt;BEVEL_ON&lt;/code&gt; variants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Timing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;First draw happens during gameplay, not during the loading splash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You &lt;strong&gt;don’t&lt;/strong&gt; need warmup for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Objects visible from scene load (Unity compiles their shaders during the splash render)&lt;/li&gt;
&lt;li&gt;Stock URP Lit/Unlit shaders with no keyword toggles&lt;/li&gt;
&lt;li&gt;UI Image components with the default UI shader&lt;/li&gt;
&lt;li&gt;Anything that doesn’t render (audio sources, logic GameObjects)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The diagnostic loop
&lt;/h2&gt;

&lt;p&gt;If you suspect this is happening in your game:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce on Safari&lt;/strong&gt; , ideally on real hardware. macOS Safari is usually enough — the WebGL-via-Metal path is the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Web Inspector → Timelines tab.&lt;/strong&gt; Hit record, trigger the event that hangs, stop recording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the long frame&lt;/strong&gt; in the JavaScript &amp;amp; Events row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look under &lt;code&gt;wasm-stub&lt;/code&gt;&lt;/strong&gt; for high counts of &lt;code&gt;_glGetUniformLocation&lt;/code&gt;, &lt;code&gt;_glGetActiveUniform&lt;/code&gt;, &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt;. If you see dozens or hundreds of these on one frame, you’ve found a first-use shader event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify what’s first-rendered.&lt;/strong&gt; What prefab spawned recently? What keyword was toggled? What material was instanced?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;ShaderWarmup.WarmupPrefab&lt;/code&gt; call&lt;/strong&gt; in that subsystem’s &lt;code&gt;Awake&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A subtle gotcha: the prefab that &lt;em&gt;causes&lt;/em&gt; the hang may not be the one whose animation is &lt;em&gt;running&lt;/em&gt; when the hang happens. In our case the overlay was spawned by a completely separate event handler one frame before the visible animation started, so the hang appeared to be inside the animation. Trace recent &lt;code&gt;Instantiate&lt;/code&gt; calls backward from the slow frame, not just what’s animating during it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related prior art
&lt;/h2&gt;

&lt;p&gt;The general “WebGL on Safari is slow at things ANGLE handles fast” story is well-documented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wonderlandengine.com/news/webgl-performance-safari-apple-vision-pro/" rel="noopener noreferrer"&gt;WebGL Performance on Safari and Apple Vision Pro — Wonderland Engine&lt;/a&gt; describes a ~150ms hitch from Safari’s Metal-emulated uniform buffer uploads. Same family of issues at a smaller scale.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=218949" rel="noopener noreferrer"&gt;WebKit Bug 218949&lt;/a&gt; — WebGL instanced draw calls slow on iPhone 12 Pro&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=230749" rel="noopener noreferrer"&gt;WebKit Bug 230749&lt;/a&gt; — WebGL performance regression on particles in Safari 15&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=239015" rel="noopener noreferrer"&gt;WebKit Bug 239015&lt;/a&gt; — Performance regression after uploading WebGL buffers (Safari 15.4)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.unity3d.com/Manual/shader-prewarm.html" rel="noopener noreferrer"&gt;Unity Manual: Prewarm shaders&lt;/a&gt; — official docs explicitly note that &lt;code&gt;Shader.WarmupAllShaders&lt;/code&gt; may not warm correctly on Metal/Vulkan because vertex layout differs from the actual draw call, which is exactly why we render through the main camera rather than calling &lt;code&gt;WarmupAllShaders&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://forum.unity.com/threads/shader-compile-frame-hitches-even-with-shader-warmupallshaders.465671/" rel="noopener noreferrer"&gt;Unity Forum: Shader Compile Frame Hitches even with &lt;code&gt;Shader.WarmupAllShaders&lt;/code&gt;&lt;/a&gt; — confirms the official API doesn’t fully solve the problem&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.blog.radiator.debacle.us/2023/01/unity-webgl-tips-advice-in-2023.html" rel="noopener noreferrer"&gt;Unity WebGL tips / advice in 2023 — Radiator Blog&lt;/a&gt; — general WebGL gotchas&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The specific case that bit us — &lt;strong&gt;a prefab &lt;code&gt;Instantiate&lt;/code&gt;d mid-game by an obscure event handler, whose first draw lands inside an unrelated critical animation one frame later&lt;/strong&gt; — isn’t covered in the existing material. The Safari Web Inspector &lt;code&gt;webglPrepareUniformLocations*&lt;/code&gt; markers as a diagnostic tool also seem underused; I didn’t find any reference to using them for Unity WebGL debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Shader.WarmupAllShaders&lt;/code&gt; is not enough on Metal/Vulkan.&lt;/strong&gt; You have to &lt;em&gt;actually render&lt;/em&gt; the geometry through the same camera pipeline that gameplay will use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first draw call of any custom-shader prefab is expensive on Safari.&lt;/strong&gt; Plan for it. Render once during the splash. Engineering effort: one line per missed prefab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-registering warmup is the right pattern.&lt;/strong&gt; Each subsystem that spawns prefabs at runtime should warm its own prefab in &lt;code&gt;Awake&lt;/code&gt; instead of relying on a central registry. New systems get coverage automatically without anyone remembering to wire them up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnose with the Safari profiler, not by guessing.&lt;/strong&gt; We burned a full day on A/B isolation before realizing the answer was sitting in the Web Inspector timeline the whole time. The &lt;code&gt;webglPrepareUniformLocations*&lt;/code&gt; markers tell you exactly when first-use uniform discovery is happening — search for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pool spawned prefabs aren’t a substitute for warmup.&lt;/strong&gt; Pooling avoids &lt;code&gt;Instantiate&lt;/code&gt; allocation cost (microseconds). Warmup avoids first-draw shader compile cost (seconds). They solve different problems and you typically want both.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your Unity WebGL game has a “first time something happens” hitch on Safari that doesn’t repeat, you’re almost certainly looking at a Unity WebGL Safari hang of the same family. The Safari profile + &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; markers are the fastest path to the answer. Hope this saves someone a long debugging session.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/unity-webgl-safari-hang-shader-warmup/" rel="noopener noreferrer"&gt;Unity WebGL Safari Hang: The First-Draw Shader Stall&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>shader</category>
      <category>webgl</category>
      <category>gamedev</category>
      <category>unity3d</category>
    </item>
    <item>
      <title>Re-skinning a 3D Character with AI Image Tools (Without Touching Blender)</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 02 May 2026 15:58:20 +0000</pubDate>
      <link>https://dev.to/fur-gaming/re-skinning-a-3d-character-with-ai-image-tools-without-touching-blender-4a14</link>
      <guid>https://dev.to/fur-gaming/re-skinning-a-3d-character-with-ai-image-tools-without-touching-blender-4a14</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76az1x1nik7cy1dzkhc2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76az1x1nik7cy1dzkhc2.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I bought a well-made chibi farmer model and wanted to reuse it as a zookeeper character — same mesh, same rig, same animations. Meshy.ai’s Retexture didn’t work for me, and naive cropping of the UV atlas pulled in neighbor pieces. The fix turned out to be embarrassingly simple: connected-component labeling to extract one UV island at a time, send each piece to ChatGPT with a tight prompt, then composite the AI result back with an intersection-of-silhouettes mask. I packaged the workflow as a Claude Code skill — &lt;a href="https://github.com/furic/texture-atlas-roundtrip" rel="noopener noreferrer"&gt;furic/texture-atlas-roundtrip&lt;/a&gt; — and ended up with a wildlife keeper that took a couple of evenings instead of a couple of weeks.&lt;/p&gt;

&lt;p&gt;&lt;span id="more-1017"&gt;&lt;/span&gt;&lt;/p&gt;





&lt;h2&gt;The problem&lt;/h2&gt;

&lt;p&gt;I’m a developer with no artist skills. For my causal game prototype, I needed a wildlife keeper character. I’d already paid for a beautifully animated chibi farmer (Suriyun’s Farmer SD pack), and the rig + animations were exactly what I wanted. The catch: the outfit was a plaid shirt + denim jeans + straw hat with a red band. Pure farmer.&lt;/p&gt;

&lt;p&gt;What I needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A khaki keeper shirt with chest pockets&lt;/li&gt;



&lt;li&gt;Khaki cargo pants/shorts&lt;/li&gt;



&lt;li&gt;A pith helmet instead of a straw hat&lt;/li&gt;



&lt;li&gt;Brown leather boots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I wanted to &lt;strong&gt;not&lt;/strong&gt; do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Touch the FBX, the rig, or any animation&lt;/li&gt;



&lt;li&gt;Open Blender to re-UV anything&lt;/li&gt;



&lt;li&gt;Hand-paint a 2048×2048 texture from scratch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The character has one big packed texture atlas — &lt;code&gt;Famer.png&lt;/code&gt; — with the shirt front, shirt back, sleeves, pants, belt, hat, leather accessories, and a few small bits all unwrapped onto a single image. That single PNG is what I needed to modify.&lt;/p&gt;





&lt;h2&gt;What didn’t work&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Meshy.ai’s retexture function.&lt;/strong&gt; I uploaded the FBX and tried both “text input” and “image input” prompts. The results were unrecognizable — colors smeared in the wrong places, details bleeding across UV seams. It felt like the model wasn’t understanding the UV mapping of the imported asset. Maybe there’s a config I missed, but I didn’t dig in: a one-click solution will probably exist in 12 months, but right now it doesn’t.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asking ChatGPT to redraw the whole atlas.&lt;/strong&gt; DALL-E doesn’t preserve UV island shapes pixel-precisely. Even with a strict “keep dimensions, keep silhouette” prompt, you get back a texture that &lt;em&gt;looks&lt;/em&gt; roughly right but seams shift, the hat brim rotates 5°, the collar moves. UV-bound textures are unforgiving — even a few-pixel shift causes visible artifacts on the 3D mesh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cropping a single piece out as a rectangle.&lt;/strong&gt; The atlas packs UV islands tightly to save memory, so the shirt-front bbox always includes slivers of the belt above and the suspender stripes that are technically in the same connected pixel region. Sending that to AI and pasting back contaminated the neighbor pieces.&lt;/p&gt;

&lt;p&gt;So the path forward needed to be: edit one piece at a time, keep its silhouette pixel-perfect, and protect everything else from collateral damage.&lt;/p&gt;





&lt;h2&gt;The solution: split → edit → composite&lt;/h2&gt;

&lt;p&gt;The technique has three parts, and only the splitting bit is non-obvious:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Split&lt;/strong&gt; the atlas into per-UV-island PNGs. Each piece is a 1024×1024 canvas with one island centered and everything else (including neighbor islands that share its bbox) masked to pure black.&lt;/li&gt;



&lt;li&gt;
&lt;strong&gt;Edit&lt;/strong&gt; each piece independently in any AI image tool. Because the background is solid black, the AI has a clean canvas and won’t draw beyond the island silhouette.&lt;/li&gt;



&lt;li&gt;
&lt;strong&gt;Composite&lt;/strong&gt; the AI’s result back into the atlas at the exact original pixel coordinates, using a mask that protects neighbor islands from accidental overwrites.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;Why naive cropping fails — and what to do instead&lt;/h3&gt;

&lt;p&gt;The atlas has roughly nine clearly visible UV islands, but they sit close together in the 2D image:&lt;/p&gt;

&lt;pre&gt;+-------------------------------+
|                               |
|   [BELT — wide horizontal]    |
|                               |
|  [SHIRT FRONT]   [SHIRT BACK] |
|                               |
|                  [SLEEVES][..]|
|                               |
|  [PANTS]        [STRAW HAT]   |
|                               |
+-------------------------------+
&lt;/pre&gt;

&lt;p&gt;If I rectangle-crop “shirt front”, I’m guaranteed to also catch a slice of the belt above it or the leather puffs on its right edge. Send that to AI, and the AI either repaints those slivers (corrupting the belt) or, more often, fills them with black — and “fills with black” is exactly what destroys the belt when I paste back.&lt;/p&gt;

&lt;p&gt;The fix is to extract the &lt;strong&gt;connected component&lt;/strong&gt; rather than a rectangle. &lt;code&gt;scipy.ndimage.label&lt;/code&gt; walks the image, assigns each contiguous non-black region its own integer ID, and gives you a per-pixel labels array. Then I crop the bbox AND zero every pixel whose label isn’t the one I want:&lt;/p&gt;

&lt;pre&gt;import numpy as np
from PIL import Image
from scipy import ndimage

img = np.array(Image.open("atlas.png").convert("RGBA"))
non_black = img[..., :3].sum(-1) &amp;gt; 30   # tolerate near-black gradients

labels, n = ndimage.label(non_black)
sizes = ndimage.sum(non_black, labels, range(1, n + 1))
slices = ndimage.find_objects(labels)

# Pick the island we want (e.g. by bbox heuristic or label_id)
target_label = 5
y0, y1 = slices[target_label - 1][0].start, slices[target_label - 1][0].stop
x0, x1 = slices[target_label - 1][1].start, slices[target_label - 1][1].stop

# Extract just this island — neighbors masked to black
piece = img[y0:y1, x0:x1].copy()
mask = labels[y0:y1, x0:x1] == target_label
piece[~mask] = [0, 0, 0, 255]
&lt;/pre&gt;

&lt;p&gt;Now I have a piece I can drop onto a 1024×1024 black canvas, save out as &lt;code&gt;shirt_front.png&lt;/code&gt;, and hand to any AI tool with a clean conscience.&lt;/p&gt;

&lt;p&gt;I also save a &lt;code&gt;manifest.json&lt;/code&gt; recording each piece’s original &lt;code&gt;src_bbox&lt;/code&gt;, &lt;code&gt;canvas_offset&lt;/code&gt;, &lt;code&gt;label_id&lt;/code&gt;, and (we’ll come back to this) &lt;code&gt;flip_y&lt;/code&gt;. The manifest is what makes the round trip work — without it, pasting back at the exact pixel position is guesswork.&lt;/p&gt;

&lt;h3&gt;The intersection-of-silhouettes paste-back&lt;/h3&gt;

&lt;p&gt;When the AI returns its result, the obvious composite is “paste the whole AI image into the original bbox.” But there are two failure modes hiding in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failure 1&lt;/strong&gt;: AI’s pure-black background covers neighbor islands that happen to fall inside the bbox. Belt fragment that lived in the corner of the shirt-front bbox? Now black.&lt;/li&gt;



&lt;li&gt;
&lt;strong&gt;Failure 2&lt;/strong&gt;: AI drew slightly outside the original silhouette (e.g. extended the shirt by 5px on the left). Those extra pixels land on parts of the texture that map to invisible mesh — but worse, they can land on a different UV island in the same bbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is the intersection of two silhouettes:&lt;/p&gt;

&lt;pre&gt;# AI silhouette: where the AI actually drew content
ai_silhouette = ai_result.sum(-1) &amp;gt; 30

# Original silhouette: where the target UV island lives in the atlas
src_labels, _ = ndimage.label(original_atlas.sum(-1) &amp;gt; 30)
orig_silhouette = src_labels[y0:y1, x0:x1] == target_label

# Paste only where both agree
mask = ai_silhouette &amp;amp; orig_silhouette
target[y0:y1, x0:x1][mask] = ai_resized[mask]
&lt;/pre&gt;

&lt;p&gt;Original-silhouette-only would let AI black overwrite neighbors. AI-silhouette-only would let AI bleed onto invisible mesh. The intersection covers both directions in one line.&lt;/p&gt;

&lt;p&gt;I add a tiny erosion + Gaussian blur on the mask to avoid hard edges where AI’s stroke meets the existing texture. That single trick is what makes the round trip clean enough to ship.&lt;/p&gt;





&lt;h2&gt;The Y-flip gotcha&lt;/h2&gt;

&lt;p&gt;I sent &lt;code&gt;shirt_front.png&lt;/code&gt; to ChatGPT with a careful prompt — “khaki shirt, two chest pockets, keep dimensions and silhouette” — and got back an excellent result. Pasted it back, opened Unity, and the pockets were on the keeper’s &lt;em&gt;waist&lt;/em&gt;. Upside down.&lt;/p&gt;

&lt;p&gt;Took me a minute to figure out why: the chibi character’s UV unwrap stores the shirt vertically flipped relative to mesh space. The V-notch at the bottom of the texture? That’s the &lt;strong&gt;collar&lt;/strong&gt; in 3D. When the AI looked at the piece, it saw a normal shirt right-way-up (because the collar happens to point down in the texture) and drew pockets in the upper half. Those pockets, mapped back to mesh, landed on the lower torso.&lt;/p&gt;

&lt;p&gt;The fix has two flavors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flavor A — don’t pre-flip; flip the AI result during composite.&lt;/strong&gt;&lt;br&gt;Pockets will still be in the wrong half of the canvas, but the second flip puts them on the chest. Works for symmetric content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flavor B — pre-flip the input so AI works in natural orientation.&lt;/strong&gt;&lt;br&gt;Send a Y-flipped piece to AI, AI draws on a “right-side-up” shirt, then composite flips the result back. Two flips total. Better for asymmetric content (name tags, logos).&lt;/p&gt;

&lt;p&gt;I went with Flavor B and added a &lt;code&gt;flip_y: true&lt;/code&gt; field to the manifest entry for each Y-flipped piece. The composite step honors it automatically:&lt;/p&gt;

&lt;pre&gt;if effective_flip_y:
    edited_pil = edited_pil.transpose(Image.FLIP_TOP_BOTTOM)
&lt;/pre&gt;

&lt;p&gt;Discovery flow: composite once, see that pockets are upside-down, set &lt;code&gt;flip_y: true&lt;/code&gt; in the manifest, re-composite. No re-prompting needed.&lt;/p&gt;





&lt;h2&gt;The workflow in practice&lt;/h2&gt;

&lt;p&gt;Here’s the actual loop I ran for the keeper conversion:&lt;/p&gt;

&lt;pre&gt;SKILL=~/.claude/skills/texture-atlas-roundtrip

# 1. Split the farmer atlas into per-UV-island PNGs
python3 $SKILL/split.py Famer.png ./pieces/

# 2. Rename pieces in manifest.json (island_2 → shirt_front, etc.)
#    Mark Y-flipped pieces: "flip_y": true

# 3. (For Y-flipped pieces) pre-flip so AI sees natural orientation
python3 $SKILL/flip_piece.py shirt_front.png  # → shirt_front_flipped.png

# 4. Send each piece to ChatGPT with a prompt like:
#    "Redraw this UV texture as a khaki keeper shirt with two chest pockets,
#     keep 1024×1024 dimensions, pure black background, exact silhouette..."
#    Save the result back to the same filename.

# 5. Composite each edited piece into the keeper atlas
python3 $SKILL/composite.py M07_Keeper.png shirt_front shirt_front_edited.png ./pieces/manifest.json
&lt;/pre&gt;

&lt;p&gt;For the hat I used a slightly different sub-flow: extract the hat region with the connected-component mask, send to ChatGPT with a pith-helmet reference photo and a “top-down circular UV” prompt, composite back. Since the existing straw hat already had the right round silhouette for a top-down dome+brim view, AI just had to repaint the texture inside that silhouette — no UV alignment risk.&lt;/p&gt;

&lt;p&gt;Pants had one extra wrinkle: the “pants” UV island is actually pants AND boots in one piece, with a visible curve dividing them. After the first AI pass turned the whole island into khaki, the boots came out as khaki socks. Second pass with a “modify only below the curve” prompt added brown leather boots without disturbing the pants area.&lt;/p&gt;





&lt;h2&gt;Results&lt;/h2&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Freskin-3d-character-result-before.png%3Fresize%3D300%252C300%26ssl%3D1" class="article-body-image-wrapper"&gt;&lt;img width="300" height="300" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Freskin-3d-character-result-before.png%3Fresize%3D300%252C300%26ssl%3D1" alt=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Freskin-3d-character-result-after.png%3Fresize%3D300%252C300%26ssl%3D1" class="article-body-image-wrapper"&gt;&lt;img width="300" height="300" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Freskin-3d-character-result-after.png%3Fresize%3D300%252C300%26ssl%3D1" alt=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model, rig, animations, and FBX are completely untouched. The keeper texture is a single recolored + AI-edited variant of &lt;code&gt;Famer.png&lt;/code&gt;, drop-in compatible with the original material.&lt;/p&gt;

&lt;p&gt;For comparison, here’s Meshy.ai’s attempt — which is what pushed me to build this in the first place:&lt;/p&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Fmesh-ai-failed-retexture.png%3Fresize%3D1024%252C816%26ssl%3D1" class="article-body-image-wrapper"&gt;&lt;img width="1024" height="816" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Fmesh-ai-failed-retexture.png%3Fresize%3D1024%252C816%26ssl%3D1" alt=""&gt;&lt;/a&gt;&lt;/p&gt;





&lt;h2&gt;Get the skill&lt;/h2&gt;

&lt;p&gt;I packaged the workflow as a Claude Code skill: &lt;strong&gt;&lt;a href="https://github.com/furic/texture-atlas-roundtrip" rel="noopener noreferrer"&gt;furic/texture-atlas-roundtrip&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;pre&gt;git clone https://github.com/furic/texture-atlas-roundtrip ~/.claude/skills/texture-atlas-roundtrip
pip install numpy Pillow scipy
&lt;/pre&gt;

&lt;p&gt;The repo includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SKILL.md&lt;/code&gt; — when to use, common mistakes, paste-back masking logic&lt;/li&gt;



&lt;li&gt;
&lt;code&gt;split.py&lt;/code&gt; — atlas → per-island PNGs + manifest&lt;/li&gt;



&lt;li&gt;
&lt;code&gt;composite.py&lt;/code&gt; — paste-back with intersection mask + Y-flip support&lt;/li&gt;



&lt;li&gt;
&lt;code&gt;flip_piece.py&lt;/code&gt; — vertical flip helper for Y-flipped UVs&lt;/li&gt;



&lt;li&gt;
&lt;code&gt;examples/&lt;/code&gt; — optional &lt;code&gt;/split-texture&lt;/code&gt; and &lt;code&gt;/merge-texture&lt;/code&gt; slash command shortcuts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roughly 200 lines of Python total. The whole thing exists because connected-component labeling solves an entire class of UV editing problems if you let it.&lt;/p&gt;





&lt;h2&gt;What I’d want next&lt;/h2&gt;

&lt;p&gt;A model-aware AI image tool that understands the UV → mesh mapping would make all of this obsolete. Meshy.ai’s retexture is the closest thing today and it didn’t work for my chibi character; presumably it’ll be solved in the next round. Until then, splitting an atlas and round-tripping pieces through ChatGPT is the most reliable workflow I’ve found, and it’s good enough that I’m planning to use it for the next four characters in this series.&lt;/p&gt;

&lt;p&gt;If you’ve been blocked on reskinning a 3D asset because you’re not an artist, give the skill a try and tell me where it falls down — issues and PRs welcome at the &lt;a href="https://github.com/furic/texture-atlas-roundtrip" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/re-skinning-a-3d-character-with-ai-image-tools-without-touching-blender/" rel="noopener noreferrer"&gt;Re-skinning a 3D Character with AI Image Tools (Without Touching Blender)&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>skill</category>
      <category>python</category>
      <category>modelling</category>
    </item>
    <item>
      <title>Building “Unmask the City” – A Solo Game Jam Journey with AI Pair Programming</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 31 Jan 2026 10:21:51 +0000</pubDate>
      <link>https://dev.to/fur-gaming/building-unmask-the-city-a-solo-game-jam-journey-with-ai-pair-programming-44b4</link>
      <guid>https://dev.to/fur-gaming/building-unmask-the-city-a-solo-game-jam-journey-with-ai-pair-programming-44b4</guid>
      <description>&lt;h2&gt;
  
  
  Another Year, Another Solo Jam
&lt;/h2&gt;

&lt;p&gt;It’s Global Game Jam season again, and once again I found myself diving in solo. But this year was different – I had a new coding partner: Claude Code.&lt;/p&gt;

&lt;p&gt;The theme for GGJ 2026 was &lt;strong&gt;&lt;a href="https://www.youtube.com/watch?v=hTePXmKUL3A" rel="noopener noreferrer"&gt;“Mask”&lt;/a&gt;&lt;/strong&gt;. While others might build games about literal masks – masquerades, disguises, hidden identities – I saw something different. What if the mask wasn’t on a person, but on an entire city? What if you had to “unmask” a fog-shrouded metropolis by exploring it?&lt;/p&gt;

&lt;p&gt;That’s how &lt;strong&gt;Unmask the City&lt;/strong&gt; was born.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenge: Vibe Coding from Zero
&lt;/h2&gt;

&lt;p&gt;I set myself an ambitious constraint: &lt;strong&gt;build everything from scratch&lt;/strong&gt;. No templates. No pre-made 3D models. No asset packs. Just code, procedural generation, and pure vibes.&lt;/p&gt;

&lt;p&gt;Why? Because I wanted to see how far modern web technologies could take me in a game jam timeframe when paired with AI assistance. Could we create something that feels complete, polished, and technically impressive using only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three.js primitives (boxes, cylinders, spheres)&lt;/li&gt;
&lt;li&gt;Procedural audio (Web Audio API)&lt;/li&gt;
&lt;li&gt;Custom shaders&lt;/li&gt;
&lt;li&gt;A lot of mathematical creativity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Spoiler:&lt;/strong&gt; We did. And it was wild.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Game: Exploring the Unknown
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Concept:&lt;/strong&gt; You’re dropped into a procedurally generated city consumed by a malevolent fog. Ancient fragments of light are scattered throughout – collect them all to unmask the city and reveal its secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core Mechanic:&lt;/strong&gt; Permanent fog clearing. Everywhere you walk, the fog disappears forever, creating a visual record of your exploration. It’s like drawing a map with your presence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Find all fragments in the shortest time while exploring as much of the city as possible. Three difficulty levels scale the challenge (5/7/10 fragments).&lt;/p&gt;

&lt;p&gt;Simple premise, but the execution is where things got interesting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8nc3icue9kbxkoi8e4is.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8nc3icue9kbxkoi8e4is.png" alt="Aerial view showing the procedurally generated city with fog-covered and revealed areas"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech Stack: Modern Web at Its Best
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Three.js – The Rendering Engine
&lt;/h3&gt;

&lt;p&gt;I chose Three.js because it’s the mature, battle-tested 3D engine for the web. Version 0.170.0 gave me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WebGL 2.0 rendering&lt;/li&gt;
&lt;li&gt;Built-in shadow mapping&lt;/li&gt;
&lt;li&gt;Excellent primitive geometries&lt;/li&gt;
&lt;li&gt;PointerLockControls for FPS gameplay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the real power move? &lt;strong&gt;InstancedMesh&lt;/strong&gt;. Instead of rendering 300 buildings with 300 draw calls, I render them all with 1-2 draw calls. That’s a 150x performance improvement right there.&lt;/p&gt;

&lt;h3&gt;
  
  
  TypeScript – Sanity in the Chaos
&lt;/h3&gt;

&lt;p&gt;Game jams are chaotic. Code gets messy fast. TypeScript was my safety net:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Catch bugs at compile time, not at 3 AM during playtesting&lt;/li&gt;
&lt;li&gt;IDE autocomplete saves so much time&lt;/li&gt;
&lt;li&gt;Interfaces make the codebase self-documenting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every game entity (Building, Tree, Fragment, Park) has a typed interface. When you’re iterating fast, this type safety prevents entire categories of bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vite – The Secret Weapon
&lt;/h3&gt;

&lt;p&gt;Vite’s Hot Module Replacement is &lt;em&gt;insane&lt;/em&gt; for game development:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Change shader code → see results in 100ms&lt;/li&gt;
&lt;li&gt;Adjust particle parameters → particles update live&lt;/li&gt;
&lt;li&gt;Modify audio synthesis → sounds regenerate instantly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No rebuild. No refresh. Just pure flow state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Web Audio API – Zero Asset Files
&lt;/h3&gt;

&lt;p&gt;Here’s where it gets interesting: &lt;strong&gt;every sound in the game is procedurally generated&lt;/strong&gt;. No MP3s. No WAV files. Pure synthesis:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Footsteps:&lt;/strong&gt; Filtered noise with different characteristics per surface (concrete, grass, water)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fragment collection:&lt;/strong&gt; Musical arpeggios (different chord progressions per fragment type)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambient sounds:&lt;/strong&gt; Wind, water, traffic, night creatures (crickets, owls)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spatial audio:&lt;/strong&gt; Echo and reverb that adapts to building proximity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why procedural? Because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Instant iteration (tweak a frequency, hear it immediately)&lt;/li&gt;
&lt;li&gt;Zero load times (no files to download)&lt;/li&gt;
&lt;li&gt;Infinite variation (sounds never repeat exactly)&lt;/li&gt;
&lt;li&gt;Smaller bundle size&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The entire audio system fits in one TypeScript file and sounds better than most asset-pack audio.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Build: From Void to Playable in Hours
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Day 1: Foundation &amp;amp; Core Loop
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Morning:&lt;/strong&gt; Set up the Three.js scene, camera, and basic player controls. Implemented the fog-of-war system using a DataTexture – a 512×512 texture that tracks which areas you’ve explored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why DataTexture?&lt;/strong&gt; Alternative approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Per-vertex fog checks: Too expensive&lt;/li&gt;
&lt;li&gt;Raymarching in shader: GPU bottleneck&lt;/li&gt;
&lt;li&gt;DataTexture: Paint on CPU, sample on GPU. Perfect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Afternoon:&lt;/strong&gt; Procedural city generation. Grid-based layout with randomized:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building dimensions (8-20 units wide, 15-100+ tall)&lt;/li&gt;
&lt;li&gt;Building types (box, cylinder, L-shaped)&lt;/li&gt;
&lt;li&gt;Rooftop details (antennas, water towers, helipads, gardens)&lt;/li&gt;
&lt;li&gt;Buildings get taller toward the center for visual interest&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Evening:&lt;/strong&gt; Collectibles and collision detection. Simple 2D AABB collision (buildings are axis-aligned, player is a capsule). Fragment spawning with validation to avoid placing them inside buildings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; Technically playable, but ugly and silent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Day 2: Polish, Polish, Polish
&lt;/h3&gt;

&lt;p&gt;This is where Claude Code really shined. Instead of spending hours debugging or looking up APIs, I could:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; “Add surface-specific footstep sounds”&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Claude:&lt;/strong&gt; &lt;em&gt;Generates complete Web Audio implementation with concrete, grass, and water variations&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; “The trees look static, add some life”&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Claude:&lt;/strong&gt; &lt;em&gt;Implements vertex shader wind animation with multi-frequency sine waves&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; “Fragments need more juice when collected”&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Claude:&lt;/strong&gt; &lt;em&gt;Adds particle burst, screen color tint, slow-motion effect, and camera shake&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;By afternoon, I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Four visual themes (Day → Dusk → Night → Neon) on auto-cycle&lt;/li&gt;
&lt;li&gt;Particle systems (birds, clouds, leaves, steam, embers)&lt;/li&gt;
&lt;li&gt;Spatial audio with echo effects&lt;/li&gt;
&lt;li&gt;A complete UI with loading screen, start menu, and game info modal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwms10gugeok7yd9jdydw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwms10gugeok7yd9jdydw.png" alt="Night theme with neon-lit buildings and atmospheric moon lighting"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By evening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Glowing breadcrumb trail showing your path&lt;/li&gt;
&lt;li&gt;Fireworks victory sequence&lt;/li&gt;
&lt;li&gt;Local leaderboard with multiple scoring bonuses&lt;/li&gt;
&lt;li&gt;Screenshot capture (P key)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxjh4trwagyt511kydepz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxjh4trwagyt511kydepz.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The game went from “functional prototype” to “feels AAA” in a single day.&lt;/p&gt;


&lt;h2&gt;
  
  
  Technical Deep Dives &amp;amp; Code Highlight
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Custom Shader Injection
&lt;/h3&gt;

&lt;p&gt;Buildings need to react to the fog of war texture, but I didn’t want to write a full custom shader (losing Three.js’s nice PBR lighting). Solution? &lt;code&gt;onBeforeCompile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="nx"&gt;material&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onBeforeCompile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Add custom uniforms&lt;/span&gt;
  &lt;span class="nx"&gt;shader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uniforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fogMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fogTexture&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// Inject custom fragment shader code&lt;/span&gt;
  &lt;span class="nx"&gt;shader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fragmentShader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fragmentShader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#include &amp;lt;fog_fragment&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`
    // Sample fog texture
    vec2 fogUV = (worldPos.xz + cityBounds.xy) / cityBounds.zw;
    float fogDensity = texture2D(fogMap, fogUV).r / 255.0;

    // Darken buildings in fogged areas
    gl_FragColor.rgb = mix(gl_FragColor.rgb, vec3(0.3), fogDensity * 0.9);

    #include &amp;lt;fog_fragment&amp;gt;
    `&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets me keep Three.js’s lighting while adding custom fog behavior. And it works with InstancedMesh!&lt;/p&gt;

&lt;h3&gt;
  
  
  Instanced Rendering – 150x Performance Boost
&lt;/h3&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// BAD: 300 draw calls&lt;/span&gt;
&lt;span class="nx"&gt;buildings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;building&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mesh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Mesh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;material&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;mesh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;building&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mesh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// GOOD: 1 draw call&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instancedMesh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InstancedMesh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;material&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;buildings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;building&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matrix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Matrix4&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;building&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;building&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;instancedMesh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMatrixAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;instancedMesh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setColorAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;building&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: Smooth 60 FPS with 300+ buildings, shadows, and particles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compass Logic – Finding Nearest Fragment
&lt;/h3&gt;

&lt;p&gt;The HUD compass always points to the nearest uncollected fragment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// Find nearest fragment&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;nearest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;uncollected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;minDist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;uncollected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fragment&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;playerPos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;distanceTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPosition&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;minDist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;minDist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;nearest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Calculate angle and rotate compass arrow&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nearest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPosition&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;playerPos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nearest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPosition&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;playerPos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;compassElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`rotate(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;rad)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No pathfinding needed – just point toward the goal. Players love it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Procedural Thunder – Layered Noise Synthesis
&lt;/h3&gt;

&lt;p&gt;Thunder isn’t just random noise. It’s carefully crafted with rumble + crack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="nf"&gt;playThunder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getChannelData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Envelope: quick attack, slow decay&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="c1"&gt;// Low-frequency rumble&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rumble&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// High-frequency crack&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Mix and apply envelope&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rumble&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;crack&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Low-pass filter for deep rumble&lt;/span&gt;
  &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lowpass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real thunder has that rumble + crack quality. This captures it with pure math.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimap – Coordinate Mapping
&lt;/h3&gt;

&lt;p&gt;Map world coordinates to minimap percentage for CSS positioning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;worldToMinimapPercent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worldPos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;citySize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// World spans -200 to +200&lt;/span&gt;

  &lt;span class="c1"&gt;// Normalize to 0-1, then convert to percentage&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalizedX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worldPos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;citySize&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;citySize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalizedZ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worldPos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;citySize&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;citySize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;normalizedX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;normalizedZ&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Position fragment dots&lt;/span&gt;
&lt;span class="nx"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same technique used for fog texture UVs. Master coordinate mapping once, use it everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fireworks Physics – Spherical Particle Distribution
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="nf"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;particleCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Random direction on sphere&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;phi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;multiplyScalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;particles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;life&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;particles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;multiplyScalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;9.8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Gravity&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;life&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Fade&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spherical distribution + gravity = convincing fireworks. No physics engine needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Challenges &amp;amp; Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Challenge 1: The Tree Gap Bug
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Trees had separate trunk and crown meshes. When I added wind animation to crowns (vertex shader), visible gaps appeared during sway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempts:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Increase overlap → Still visible&lt;/li&gt;
&lt;li&gt;Merge geometries with vertex attributes → Broke colors&lt;/li&gt;
&lt;li&gt;Reduce animation → Still janky&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Disable wind animation entirely. Static trees look fine and the gap is gone. Sometimes the best solution is the simplest one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Don’t over-engineer. If a feature causes more problems than it solves, cut it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenge 2: Fragment Spawning
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Fragments occasionally spawned inside buildings or in unreachable locations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evolution:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;v1: 2-unit clearance → Too close to buildings&lt;/li&gt;
&lt;li&gt;v2: 12-unit clearance → Better but still issues&lt;/li&gt;
&lt;li&gt;v3: 20-unit clearance + disabled problematic building types (pyramids, domes)&lt;/li&gt;
&lt;li&gt;v4: 30-unit clearance + increased collection radius&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Incremental fixes are okay. Don’t wait for the “perfect” solution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenge 3: Performance vs. Visual Fidelity
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Constraint:&lt;/strong&gt; Browser game running at 60 FPS with no stutters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decisions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instanced rendering for buildings (✓ massive win)&lt;/li&gt;
&lt;li&gt;Shadow map resolution: 2048×2048 (sweet spot)&lt;/li&gt;
&lt;li&gt;Particle count: Dynamic based on system type&lt;/li&gt;
&lt;li&gt;Fog texture: 512×512 (could go lower, but 256 KB is negligible)&lt;/li&gt;
&lt;li&gt;LOD system: Not needed (instancing is enough)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Profile before optimizing. Instancing alone solved 90% of performance concerns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Working with Claude Code
&lt;/h2&gt;

&lt;p&gt;This was my first game jam with AI pair programming. Here’s what that looked like:&lt;/p&gt;

&lt;h3&gt;
  
  
  What Worked Really Well
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Rapid prototyping&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Me: “Add thunder sounds for lightning”&lt;br&gt;&lt;br&gt;
Claude: &lt;em&gt;Complete Web Audio implementation with rumble, crack, and realistic delay&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Time saved: 30-60 minutes per feature&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Bug fixing&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Me: “Fireworks particles aren’t cleaning up”&lt;br&gt;&lt;br&gt;
Claude: &lt;em&gt;Identifies the issue, implements proper disposal in filter callback&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Polish iterations&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Me: “Make the breadcrumb trail look nicer”&lt;br&gt;&lt;br&gt;
Claude: &lt;em&gt;Converts from thin lines to glowing tube geometry with particles&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What Required Guidance
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Creative decisions&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Claude can implement, but vision is still human. I had to decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How fog should look (teal with corruption hints)&lt;/li&gt;
&lt;li&gt;What themes to include (day/dusk/night/neon)&lt;/li&gt;
&lt;li&gt;Which features to cut when time was tight&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Bug investigation&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Complex visual bugs (like the tree gap) required back-and-forth. Claude would suggest fixes, I’d test, we’d iterate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Performance tuning&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Deciding &lt;em&gt;what&lt;/em&gt; to optimize required understanding the bottleneck. Claude implemented solutions once I identified the problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Workflow
&lt;/h3&gt;

&lt;p&gt;Typical flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I describe what I want (feature or fix)&lt;/li&gt;
&lt;li&gt;Claude implements it&lt;/li&gt;
&lt;li&gt;I test in the browser (Vite HMR makes this instant)&lt;/li&gt;
&lt;li&gt;If issues, I describe what’s wrong&lt;/li&gt;
&lt;li&gt;Claude iterates&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It’s like having a senior developer who:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never gets tired&lt;/li&gt;
&lt;li&gt;Remembers every file in the codebase&lt;/li&gt;
&lt;li&gt;Writes clean, well-commented code&lt;/li&gt;
&lt;li&gt;Doesn’t argue about architecture decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But I’m still the director. The creative vision, gameplay feel, and final polish decisions are mine.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Development Stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Time:&lt;/strong&gt; 1.5 intensive days (+ polish sessions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; ~3,700 lines of TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commits:&lt;/strong&gt; 15+ (after cleaning up the messy ones)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External dependencies:&lt;/strong&gt; 1 (Three.js)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External assets:&lt;/strong&gt; 0 (everything procedural)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Technical Achievements:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;300+ procedurally generated buildings&lt;/li&gt;
&lt;li&gt;100% procedural audio (zero sound files)&lt;/li&gt;
&lt;li&gt;60 FPS on modern hardware&lt;/li&gt;
&lt;li&gt;~500 KB bundle size (gzipped)&lt;/li&gt;
&lt;li&gt;Zero load times (no assets to fetch)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Game Content:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 difficulty levels&lt;/li&gt;
&lt;li&gt;4 dynamic visual themes&lt;/li&gt;
&lt;li&gt;5 particle systems&lt;/li&gt;
&lt;li&gt;20+ game systems/classes&lt;/li&gt;
&lt;li&gt;Multiple scoring bonuses&lt;/li&gt;
&lt;li&gt;Local leaderboard&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Procedural Generation is Powerful
&lt;/h3&gt;

&lt;p&gt;No 3D models meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instant iteration (change a parameter, see results)&lt;/li&gt;
&lt;li&gt;Infinite variety (every city is unique)&lt;/li&gt;
&lt;li&gt;Tiny bundle size&lt;/li&gt;
&lt;li&gt;Creative constraints that forced innovation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The visual aesthetic emerged &lt;em&gt;from&lt;/em&gt; the limitations. Low-poly geometric buildings with procedural color variation created a distinctive look.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Web Audio API is Underrated
&lt;/h3&gt;

&lt;p&gt;Game devs sleep on Web Audio API. Yes, it’s more work than dropping in an MP3. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sounds can react to gameplay dynamically&lt;/li&gt;
&lt;li&gt;Zero licensing concerns&lt;/li&gt;
&lt;li&gt;No asset management overhead&lt;/li&gt;
&lt;li&gt;Perfect for game jams where time &amp;gt; polish&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Surface-specific footsteps and spatial echo effects make the world feel alive.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Vite’s HMR is a Game Changer
&lt;/h3&gt;

&lt;p&gt;The feedback loop is everything in game development. Vite made it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edit shader → 100ms to see change&lt;/li&gt;
&lt;li&gt;Adjust physics → instant update&lt;/li&gt;
&lt;li&gt;Modify UI → no page refresh&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traditional build tools would have killed my momentum.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. AI Pair Programming Accelerates Flow
&lt;/h3&gt;

&lt;p&gt;Claude Code didn’t &lt;em&gt;replace&lt;/em&gt; my skills – it &lt;strong&gt;amplified&lt;/strong&gt; them. I could:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stay in creative flow (no context switching to docs)&lt;/li&gt;
&lt;li&gt;Iterate faster (implement → test → refine)&lt;/li&gt;
&lt;li&gt;Tackle ambitious features (spatial audio, custom shaders)&lt;/li&gt;
&lt;li&gt;Focus on design while AI handles implementation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: A scope I would normally consider impossible for a solo jam.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Constraints Breed Creativity
&lt;/h3&gt;

&lt;p&gt;“No external assets” forced me to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Master procedural generation&lt;/li&gt;
&lt;li&gt;Learn Web Audio API deeply&lt;/li&gt;
&lt;li&gt;Think in primitives and compositions&lt;/li&gt;
&lt;li&gt;Build systems instead of placing objects&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These constraints made the game &lt;em&gt;more&lt;/em&gt; interesting, not less.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I’d Do Differently
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Test on Different Machines Sooner
&lt;/h3&gt;

&lt;p&gt;I developed on a beefy machine. Didn’t test on lower-end hardware until late. Luckily, instanced rendering meant performance was fine, but that was lucky – not planned.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Implement Settings Menu
&lt;/h3&gt;

&lt;p&gt;Audio volume, mouse sensitivity, graphics quality – these should have been in from the start. Players expect them. I shipped without them due to time constraints.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Better Spawn Validation from Day One
&lt;/h3&gt;

&lt;p&gt;The fragment spawning issues ate more time than they should have. A robust validation system upfront would have saved iterations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Unexpected Wins
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The Breadcrumb Trail
&lt;/h3&gt;

&lt;p&gt;Initially just a debug feature to see where I’d been. Players loved it so much I polished it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Thin lines → Glowing tube geometry&lt;/li&gt;
&lt;li&gt;Static → Particle sparkles&lt;/li&gt;
&lt;li&gt;Flat → Elevated with smooth curves&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Became one of the game’s signature visual elements.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Automatic Theme Cycling
&lt;/h3&gt;

&lt;p&gt;Originally, themes were manual (press T to cycle). Making it automatic with smooth cross-fades created this living, breathing atmosphere. The city &lt;em&gt;feels&lt;/em&gt; different every few minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Procedural Audio Reactions
&lt;/h3&gt;

&lt;p&gt;Making audio react to environment (echo in tight spaces, wind in open areas) was a last-minute addition. It’s subtle but makes the world feel responsive and real.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Verdict: Did Vibe Coding Work?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Yes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I shipped a complete 3D exploration game with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Procedurally generated world&lt;/li&gt;
&lt;li&gt;✅ Complete audio design (100% procedural)&lt;/li&gt;
&lt;li&gt;✅ Four visual themes with smooth transitions&lt;/li&gt;
&lt;li&gt;✅ Multiple particle systems&lt;/li&gt;
&lt;li&gt;✅ Professional UI with loading screens, menus, and HUD&lt;/li&gt;
&lt;li&gt;✅ Scoring system with bonuses&lt;/li&gt;
&lt;li&gt;✅ Local leaderboards&lt;/li&gt;
&lt;li&gt;✅ Victory celebration sequence&lt;/li&gt;
&lt;li&gt;✅ Comprehensive documentation&lt;/li&gt;
&lt;li&gt;✅ Clean codebase (~3,700 lines)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Zero&lt;/strong&gt; external assets. &lt;strong&gt;Zero&lt;/strong&gt; templates. Just code, creativity, and AI assistance.&lt;/p&gt;

&lt;p&gt;Would it have been possible solo without Claude? Sure – but it would have taken a week, not two days. And I would have cut half the features.&lt;/p&gt;




&lt;h2&gt;
  
  
  For Future Game Jammers
&lt;/h2&gt;

&lt;p&gt;If you’re considering AI-assisted development for your next jam:&lt;/p&gt;

&lt;h3&gt;
  
  
  Do This:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Use AI for implementation, not vision&lt;/li&gt;
&lt;li&gt;Iterate fast (test → feedback → refine)&lt;/li&gt;
&lt;li&gt;Let AI handle boilerplate and documentation&lt;/li&gt;
&lt;li&gt;Stay in flow state (avoid context switching)&lt;/li&gt;
&lt;li&gt;Focus on creative decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Don’t Do This:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Accept AI suggestions blindly&lt;/li&gt;
&lt;li&gt;Skip testing (“it compiled, ship it”)&lt;/li&gt;
&lt;li&gt;Outsource all problem-solving&lt;/li&gt;
&lt;li&gt;Forget that you’re still the designer&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Real Benefit
&lt;/h3&gt;

&lt;p&gt;It’s not that AI writes code faster (though it does). It’s that &lt;strong&gt;you can stay in creative flow&lt;/strong&gt;. No googling APIs. No context switching to documentation. No “how do I implement X” rabbit holes.&lt;/p&gt;

&lt;p&gt;You think of a feature, describe it, and &lt;em&gt;boom&lt;/em&gt; – it exists. Then you playtest, refine, polish.&lt;/p&gt;

&lt;p&gt;That’s the game jam superpower.&lt;/p&gt;




&lt;h2&gt;
  
  
  Play the Game
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://furic.github.io/unmask-the-city/" rel="noopener noreferrer"&gt;https://furic.github.io/unmask-the-city/&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;GGJ page:&lt;/strong&gt; &lt;a href="https://globalgamejam.org/games/2026/unmask-city-4" rel="noopener noreferrer"&gt;https://globalgamejam.org/games/2026/unmask-city-4&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/furic/unmask-the-city" rel="noopener noreferrer"&gt;https://github.com/furic/unmask-the-city&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Tech stack docs:&lt;/strong&gt; See &lt;a href="http://github.com/furic/unmask-the-city/blob/main/TECH_STACK.md" rel="noopener noreferrer"&gt;TECH_STACK.md&lt;/a&gt; in the repo&lt;/p&gt;

&lt;p&gt;Built for Global Game Jam 2026 | Theme: Mask&lt;br&gt;&lt;br&gt;
Developer: Richard Fu / fuR Gaming&lt;br&gt;&lt;br&gt;
AI Pair Programming: Claude Code&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Game jams are about constraints, creativity, and controlled chaos. This year, I added a new constraint: &lt;strong&gt;no external assets&lt;/strong&gt;. And a new tool: &lt;strong&gt;AI pair programming&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The result surprised me. Not just in scope (way bigger than I expected), but in polish. The game feels &lt;em&gt;complete&lt;/em&gt;. Menus, sound, particles, themes, documentation – all the things you usually sacrifice in a jam.&lt;/p&gt;

&lt;p&gt;Is this the future of solo game development? Maybe. At minimum, it’s a glimpse of how AI tools can amplify individual creativity instead of replacing it.&lt;/p&gt;

&lt;p&gt;Would I do it again? Absolutely.&lt;/p&gt;

&lt;p&gt;Next jam, I’m going even bigger. 🎮&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;P.S.&lt;/strong&gt; – If you’re curious about specific technical implementations (shader code, audio synthesis, procedural generation algorithms), check out the &lt;a href="//TECH_STACK.md"&gt;full technical documentation&lt;/a&gt; in the repo. It’s a deep dive into every technique used.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/building-unmask-the-city-a-solo-game-jam-journey-with-ai-pair-programming/" rel="noopener noreferrer"&gt;Building “Unmask the City” – A Solo Game Jam Journey with AI Pair Programming&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>typescript</category>
      <category>gamedev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Bubble 2048: A Technical Deep Dive</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 24 Jan 2026 14:20:13 +0000</pubDate>
      <link>https://dev.to/fur-gaming/building-bubble-2048-a-technical-deep-dive-52o9</link>
      <guid>https://dev.to/fur-gaming/building-bubble-2048-a-technical-deep-dive-52o9</guid>
      <description>&lt;p&gt;When I participated in Global Game Jam 2025 with the theme “Bubble,” I wanted to create something familiar yet innovative. The result? &lt;strong&gt;Bubble 2048&lt;/strong&gt; – a twist on the classic 2048 puzzle game where tiles don’t just slide… they bubble up.&lt;/p&gt;

&lt;p&gt;But here’s the thing: I didn’t finish it during the jam last year. Life got in the way, and the project sat incomplete for nearly a year. Then, just before Global Game Jam 2026 kicked off, I discovered Claude Code – and everything changed. What had been a frustrating tangle of animation bugs and timing issues became manageable. With Claude’s help debugging the animation system and refining the game logic, I finally completed what I’d started at GGJ 2025, just in time to approach this year’s jam with renewed confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://furic.github.io/bubble-2048/" rel="noopener noreferrer"&gt;Play the game here&lt;/a&gt;&lt;/strong&gt; | &lt;strong&gt;&lt;a href="https://github.com/furic/bubble-2048" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8me0eqvlwu7kn998bk7f.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8me0eqvlwu7kn998bk7f.gif" alt="Gameplay Demo" width="600" height="812"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Unique Mechanic: Double Movement System
&lt;/h2&gt;

&lt;p&gt;The game’s defining feature is its &lt;strong&gt;dual-movement mechanic&lt;/strong&gt;. Unlike classic 2048 where you make a move and wait for the next tile to spawn, Bubble 2048 adds a second layer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Player Move&lt;/strong&gt; : Swipe in any direction (up, down, left, or right) – tiles slide and merge as expected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bubble Shift&lt;/strong&gt; : After your move completes, ALL tiles automatically shift upward by one row (like bubbles rising in water), with another merge opportunity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spawn&lt;/strong&gt; : Only after both movements complete does a new tile appear&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This creates a fascinating strategic depth – you’re not just planning one move ahead, but considering how the automatic bubble shift will affect your board state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack: Simple but Effective
&lt;/h2&gt;

&lt;p&gt;I kept the technology stack deliberately minimal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React 19&lt;/strong&gt; with TypeScript for type safety and component structure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vite&lt;/strong&gt; for lightning-fast development and optimized builds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure CSS animations&lt;/strong&gt; for all visual effects (no animation libraries needed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero game engine dependencies&lt;/strong&gt; – all game logic written from scratch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entire project uses only 2 production dependencies: &lt;code&gt;react&lt;/code&gt; and &lt;code&gt;react-dom&lt;/code&gt;. Everything else is built in-house.&lt;/p&gt;

&lt;h2&gt;
  
  
  Game Logic Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Grid State Management
&lt;/h3&gt;

&lt;p&gt;The game state is built around a clean TypeScript interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
interface Tile {
  id: string; // Unique identifier
  value: number; // 2, 4, 8, 16, 32...
  position: Position; // Current { row, col }
  previousPosition?: Position; // For slide animations
  mergedFrom?: [Tile, Tile]; // Which two tiles merged
  isNew?: boolean; // For spawn animation
}

type Grid = (Tile | null)[][]; // 4x4 array

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This structure supports everything needed for animations: we track where tiles came from (&lt;code&gt;previousPosition&lt;/code&gt;), which tiles merged together (&lt;code&gt;mergedFrom&lt;/code&gt;), and which just spawned (&lt;code&gt;isNew&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Movement Algorithm
&lt;/h3&gt;

&lt;p&gt;The core movement logic (&lt;code&gt;moveTiles&lt;/code&gt;) processes tiles in the correct traversal order. For right/down movements, we reverse the iteration to prevent tiles from “leap-frogging” during merges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
function getTraversalOrder(direction: Direction) {
  const rows = [0, 1, 2, 3];
  const cols = [0, 1, 2, 3];

  if (direction === 'down') rows.reverse();
  if (direction === 'right') cols.reverse();

  return { rows, cols };
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The algorithm:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Iterate through tiles in traversal order&lt;/li&gt;
&lt;li&gt;For each tile, find the farthest position it can move to&lt;/li&gt;
&lt;li&gt;Check if the destination contains a tile with the same value&lt;/li&gt;
&lt;li&gt;If yes and it hasn’t merged yet → merge them (create new tile with doubled value)&lt;/li&gt;
&lt;li&gt;If no → just move the tile&lt;/li&gt;
&lt;li&gt;Track merged positions to prevent double-merging&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The crucial anti-double-merge logic uses a &lt;code&gt;Set&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const mergedTiles = new Set&amp;lt;string&amp;gt;();

// When checking if we can merge
if (next &amp;amp;&amp;amp; next.value === tile.value &amp;amp;&amp;amp; !mergedTiles.has(nextPosKey)) {
  // Merge and mark position
  mergedTiles.add(nextPosKey);
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Bubble Mechanic
&lt;/h3&gt;

&lt;p&gt;The bubble shift is simpler than player movement – it only moves upward and doesn’t cascade through multiple empty cells:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
function bubbleShiftUp(grid: Grid): MoveResult {
  // Process rows from top to bottom (skip row 0)
  for (let row = 1; row &amp;lt; GRID_SIZE; row++) {
    for (let col = 0; col &amp;lt; GRID_SIZE; col++) {
      const tile = grid[row][col];
      if (!tile) continue;

      const aboveRow = row - 1;
      const aboveTile = grid[aboveRow][col];

      if (!aboveTile) {
        // Move up to empty space
        moveTileToPosition(tile, aboveRow, col);
      } else if (aboveTile.value === tile.value) {
        // Merge with tile above
        mergeTiles(aboveTile, tile);
      }
    }
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This intentional simplicity prevents infinite cascade scenarios while still providing strategic depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Animation Challenges
&lt;/h2&gt;

&lt;p&gt;Getting smooth animations working was the most challenging part of the project – and honestly, what kept me from finishing during the jam. The tiles would jump around, animations wouldn’t trigger, and I couldn’t figure out why. This is where Claude Code became invaluable, helping me systematically debug and solve each issue. I documented the entire debugging process in &lt;a href="https://github.com/furic/bubble-2048/blob/main/docs/tile-animation-fix.md" rel="noopener noreferrer"&gt;docs/tile-animation-fix.md&lt;/a&gt;, but here are the key challenges:&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenge 1: CSS Transform Conflicts
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt; : The base &lt;code&gt;.tile&lt;/code&gt; class applied a transform to position tiles at their final location:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
.tile {
  transform: translate(calc(var(--tile-x) * ...));
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the animation class was added, the base transform took precedence, causing tiles to jump to their destination instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt; : Conditional base transform using &lt;code&gt;:not()&lt;/code&gt; selectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
.tile:not(.tile-moving):not(.tile-merged):not(.tile-new) {
  transform: translate(...);
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now animations have full control over the &lt;code&gt;transform&lt;/code&gt; property.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenge 2: Animation State Persistence
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt; : When cloning the grid between moves, animation state (&lt;code&gt;previousPosition&lt;/code&gt;, &lt;code&gt;mergedFrom&lt;/code&gt;) was being copied, causing stale animation data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt; : Modified &lt;code&gt;cloneGrid()&lt;/code&gt; to only copy core properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
export function cloneGrid(grid: Grid): Grid {
  return grid.map(row =&amp;gt;
    row.map(cell =&amp;gt;
      cell ? {
        id: cell.id,
        value: cell.value,
        position: { ...cell.position },
        // Don't copy animation state
      } : null
    )
  );
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Challenge 3: Animation Not Retriggering
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt; : CSS animations only start when the animation class is &lt;strong&gt;first added&lt;/strong&gt;. React reuses DOM elements with the same key, so changing CSS variables alone doesn’t restart animations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt; : Force DOM reflow with direct manipulation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
useEffect(() =&amp;gt; {
  if (shouldAnimate &amp;amp;&amp;amp; elementRef.current) {
    const element = elementRef.current;
    element.classList.remove('tile-moving');
    void element.offsetWidth; // Force reflow
    element.classList.add('tile-moving');
  }
}, [previousPosition?.row, previousPosition?.col, shouldAnimate]);

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;void element.offsetWidth&lt;/code&gt; forces the browser to process the class removal before re-adding it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenge 4: Bubble Wobble Effect
&lt;/h3&gt;

&lt;p&gt;To enhance the ocean theme, I added an idle “wobble” animation to make tiles look like floating bubbles. The trick was making them wobble at different times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const wobbleDelay = ((position.row * 4 + position.col) * 0.2) % 3;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This position-based calculation creates a natural staggered effect where each bubble has its own wobble phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Input Handling: Supporting All Devices
&lt;/h2&gt;

&lt;p&gt;The game supports three input methods:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard&lt;/strong&gt; : Arrow keys and WASD (case-insensitive)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Touch&lt;/strong&gt; : Swipe gestures with 30px minimum threshold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mouse&lt;/strong&gt; : Click-and-drag gestures&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The swipe detection logic calculates the primary axis to determine direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const deltaX = Math.abs(endX - startX);
const deltaY = Math.abs(endY - startY);

if (Math.max(deltaX, deltaY) &amp;lt; 30) return; // Too small

if (deltaX &amp;gt; deltaY) {
  // Horizontal swipe
  direction = endX &amp;gt; startX ? 'right' : 'left';
} else {
  // Vertical swipe
  direction = endY &amp;gt; startY ? 'down' : 'up';
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Move Sequence: Timing is Everything
&lt;/h2&gt;

&lt;p&gt;The game uses careful timing to coordinate both movements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const ANIMATION_DURATION = 150; // Tile slide duration
const BUBBLE_DELAY = 100; // Extra pause before bubble shift

// Player move
setGrid(playerResult.grid);
setIsAnimating(true);

// Wait for animation + delay
setTimeout(() =&amp;gt; {
  const cleanedGrid = clearAnimationState(playerResult.grid);
  const bubbleResult = bubbleShiftUp(cleanedGrid);

  if (bubbleResult.moved) {
    setGrid(bubbleResult.grid);

    // Wait for bubble animation
    setTimeout(() =&amp;gt; {
      spawnNewTile();
      checkWinLose();
      setIsAnimating(false);
    }, ANIMATION_DURATION);
  }
}, ANIMATION_DURATION + BUBBLE_DELAY);

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a smooth sequence: player swipe → tiles slide → pause → bubbles rise → new tile spawns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Optimizations
&lt;/h2&gt;

&lt;p&gt;Despite being built without a game engine, the game runs smoothly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React.memo&lt;/strong&gt; on the Tile component prevents unnecessary re-renders&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS animations&lt;/strong&gt; instead of JavaScript for 60fps performance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passive event listeners&lt;/strong&gt; for touch events to improve scroll performance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LocalStorage&lt;/strong&gt; for best score persistence (with graceful error handling)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deployment: GitHub Pages with Vite
&lt;/h2&gt;

&lt;p&gt;Deploying to GitHub Pages required configuring the base path in &lt;code&gt;vite.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
export default defineConfig({
  base: '/bubble-2048/',
  plugins: [react()],
});

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then build and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
npm run build
# Push dist/ folder to gh-pages branch

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key Learnings
&lt;/h2&gt;

&lt;p&gt;Building this game taught me several valuable lessons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CSS animations don’t restart automatically&lt;/strong&gt; – you need to remove and re-add classes, forcing a reflow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple state is better&lt;/strong&gt; – direct prop calculations beat complex useState/useEffect coordination&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animation timing is crucial&lt;/strong&gt; – the 250ms pause between movements makes both animations visible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean state between phases&lt;/strong&gt; – clearing animation properties prevents conflicts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Game jams force focus&lt;/strong&gt; – limited time means prioritizing core mechanics over feature creep&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Future Improvements
&lt;/h2&gt;

&lt;p&gt;If I continue developing this, potential additions include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Undo functionality&lt;/strong&gt; – storing move history for one-step-back&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily challenges&lt;/strong&gt; – seeded random number generation for reproducible puzzles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaderboards&lt;/strong&gt; – tracking and displaying high scores&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sound effects&lt;/strong&gt; – bubble pops, merge sounds, and ambient water audio&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progressive difficulty&lt;/strong&gt; – larger grids or modified bubble mechanics&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Play the game&lt;/strong&gt; : &lt;a href="https://furic.github.io/bubble-2048/" rel="noopener noreferrer"&gt;https://furic.github.io/bubble-2048/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Source code&lt;/strong&gt; : &lt;a href="https://github.com/furic/bubble-2048" rel="noopener noreferrer"&gt;https://github.com/furic/bubble-2048&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The entire codebase is MIT licensed and well-documented. Check out the animation system deep dive in the docs folder if you’re interested in the technical details.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;My best score is 3332, what’s yours?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
 &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5bv5xeqxp23nqxzrya93.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5bv5xeqxp23nqxzrya93.png" alt="Bubble 2048 - End game result" width="800" height="1180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/building-bubble-2048-a-technical-deep-dive/" rel="noopener noreferrer"&gt;Building Bubble 2048: A Technical Deep Dive&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>react</category>
      <category>globalgamejam</category>
      <category>gamedev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Unity WebGL Background Tabs: Autoplay &amp; Performance Fix</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Wed, 31 Dec 2025 06:45:13 +0000</pubDate>
      <link>https://dev.to/fur-gaming/unity-webgl-background-tabs-autoplay-performance-fix-1m9k</link>
      <guid>https://dev.to/fur-gaming/unity-webgl-background-tabs-autoplay-performance-fix-1m9k</guid>
      <description>&lt;p&gt;Unity WebGL games face severe performance degradation in inactive browser tabs due to aggressive &lt;code&gt;requestAnimationFrame&lt;/code&gt; throttling (~1 FPS). This creates race conditions and freezes when implementing autoplay features. We solved this by:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detecting tab visibility&lt;/strong&gt; and skipping Unity communication entirely during background autoplay&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracking Unity’s internal state&lt;/strong&gt; to detect and recover from desynchronization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding strategic delays&lt;/strong&gt; to accommodate Unity’s “wake-up” period when tabs become active&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping blocking UI modals&lt;/strong&gt; that wait for user interaction&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Understanding Unity WebGL background tabs behavior is essential for any casino game, idle game, or simulation that uses autoplay features. Native web technologies (Three.js, PixiJS) don’t face these issues due to their direct JavaScript integration, making this a Unity-specific challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: When Autoplay Meets Inactive Tabs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Background: Building a Game with Unity WebGL
&lt;/h3&gt;

&lt;p&gt;We were building a browser-based game using Unity WebGL for its 3D rendering capabilities. The game architecture follows a hybrid model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unity Layer&lt;/strong&gt; : Handles visual simulation, animations, and particle effects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Layer&lt;/strong&gt; : Manages server communication, authentication, game logic, and UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Communication between layers happens via Unity’s &lt;code&gt;SendMessage&lt;/code&gt; API (Web → Unity) and &lt;code&gt;Application.ExternalCall&lt;/code&gt; (Unity → Web).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Autoplay Feature
&lt;/h3&gt;

&lt;p&gt;Users requested an autoplay feature – click once, play multiple rounds automatically. Simple enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
class GameplayManager {
  async playRound() {
    // 1. Request round result from server
    const result = await api.requestRound();

    // 2. Send result to Unity for visual simulation
    unityInstance.SendMessage('GameController', 'StartSimulation', JSON.stringify(result));

    // 3. Wait for Unity to complete animation
    await waitForUnityCallback('SimulationComplete');

    // 4. If autoplay enabled, play next round
    if (this.autoplayMode) {
      this.playRound(); // Recursive
    }
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It worked perfectly… until users switched browser tabs.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mystery: Everything Breaks in Background Tabs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom 1: Autoplay Stops Completely
&lt;/h3&gt;

&lt;p&gt;When a user enabled autoplay and switched tabs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ First round completes normally&lt;/li&gt;
&lt;li&gt;❌ Second round starts, but never finishes&lt;/li&gt;
&lt;li&gt;❌ Game frozen when user returns to tab&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Console logs revealed:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
[Game] Sending simulation to Unity...
[Unity] Received simulation data
[Unity] Starting animation...
[Game] Waiting for Unity callback...
[... nothing ...]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unity never called back. The animation never completed. Autoplay stuck forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2: Seed Desynchronization
&lt;/h3&gt;

&lt;p&gt;When users returned to the tab during autoplay:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Unity showed the wrong level/seed&lt;/li&gt;
&lt;li&gt;❌ Visual simulation didn’t match server results&lt;/li&gt;
&lt;li&gt;❌ Player saw corrupted/glitched gameplay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Background: Played seed #1705 (skipped Unity)
Background: Started seed #839 (skipped Unity)
User returns: Unity still on seed #1705
Web sends: "Start simulation for seed #839"
Unity: Generates seed #839 on top of #1705's level
Result: Corrupted level geometry, wrong obstacles

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Symptom 3: Success Rounds Never Complete
&lt;/h3&gt;

&lt;p&gt;For rounds with positive results (score &amp;gt; 0):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Server response received&lt;/li&gt;
&lt;li&gt;✅ Score updated correctly&lt;/li&gt;
&lt;li&gt;❌ Game state stuck at “showing results” instead of “idle”&lt;/li&gt;
&lt;li&gt;❌ Next round never triggers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The logs showed:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
[Game] Round completed with score: 150
[ResultsUI] Showing results modal...
[Game] Waiting for user to close modal...
[... stuck forever ...]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results modal waited for a user click that would never come in a background tab.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understanding the Root Cause: Browser Tab Throttling
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How Browsers Throttle Inactive Tabs
&lt;/h3&gt;

&lt;p&gt;Modern browsers aggressively throttle inactive tabs to save CPU and battery:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Active Tab&lt;/th&gt;
&lt;th&gt;Inactive Tab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;requestAnimationFrame&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~60 FPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1 FPS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;setTimeout&lt;/code&gt; (&amp;lt; 4ms)&lt;/td&gt;
&lt;td&gt;As specified&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≥1000ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;setInterval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;As specified&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≥1000ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network requests&lt;/td&gt;
&lt;td&gt;Normal&lt;/td&gt;
&lt;td&gt;✅ Normal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;Normal&lt;/td&gt;
&lt;td&gt;✅ Normal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; Network APIs continue working normally, but anything timing-related gets severely throttled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Unity WebGL Suffers More Than Native Code
&lt;/h3&gt;

&lt;p&gt;Unity WebGL’s architecture makes it particularly vulnerable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Separate Runtime Sandbox&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Browser JS ←→ Message Queue ←→ Unity WebAssembly Runtime
   (60 FPS) (throttled!) (~1 FPS in background)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Message Queue Delays&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Web sends message to Unity
unityInstance.SendMessage('GameObject', 'Method', data);
// ↓
// Message enters Unity's internal queue
// ↓
// Unity processes queue on next Update() call (every ~1000ms in background)
// ↓
// Unity executes method
// ↓
// Unity sends callback via Application.ExternalCall()
// ↓
// Callback enters browser's task queue
// ↓
// Browser processes callback on next frame (~1000ms later)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total latency in background: &lt;strong&gt;~2-3 seconds per message&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Animation Completion Detection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unity animations rely on frame updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
void Update() {
    animationTimer += Time.deltaTime; // Only updates at ~1 FPS
    if (animationTimer &amp;gt;= duration) {
        SendCompletionCallback();
    }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 1 FPS, a 3-second animation takes &lt;strong&gt;3 minutes&lt;/strong&gt; to complete.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failed Solutions: What Didn’t Work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Attempt 1: Shorter Timeouts
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis:&lt;/strong&gt; Maybe 100ms isn’t enough. Try longer delays.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
setTimeout(() =&amp;gt; {
  if (unityResponded) {
    continueAutoplay();
  }
}, 5000); // Wait 5 seconds instead of 100ms

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; ❌ Still frozen. Unity simply doesn’t respond at reasonable speeds in background tabs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2: Promise.race with Timeout
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis:&lt;/strong&gt; Use timeout as fallback if Unity takes too long.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
await Promise.race([
  waitForUnityCallback('complete'),
  new Promise(resolve =&amp;gt; setTimeout(resolve, 10000))
]);

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; ❌ Timeout fires, but game state is now inconsistent. Unity still processing old simulation when we start new one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3: Visibility Change Detection
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis:&lt;/strong&gt; Pause autoplay when tab becomes inactive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
document.addEventListener('visibilitychange', () =&amp;gt; {
  if (document.hidden) {
    pauseAutoplay();
  }
});

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; ❌ Defeats the purpose. Users &lt;em&gt;want&lt;/em&gt; autoplay to continue in background tabs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: Skip Unity Entirely in Background
&lt;/h2&gt;

&lt;p&gt;The breakthrough realization: &lt;strong&gt;Unity is only needed for visual feedback. Server responses contain all game logic.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture Shift
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Server → Web → Unity → Web → Next Round
         ↓ ↓ ↓
      Request Render Callback

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Active Tab: Server → Web → Unity → Web → Next Round
Background: Server → Web → [Skip Unity] → Next Round

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Implementation: Four-Layer Solution
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Layer 1: Tab Visibility Detection
&lt;/h4&gt;

&lt;p&gt;Skip Unity communication when tab is hidden AND autoplay is active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
protected async onRoundStart(): Promise&amp;lt;void&amp;gt; {
  const isTabHidden = document.hidden;
  const isAutoplay = getUIManager()?.autoplayMode || false;

  if (!isTabHidden || !isAutoplay) {
    // Normal flow: send to Unity for visual animation
    await unityInterface.startRound();
    this.lastUnitySeed = null; // Reset seed tracker
  } else {
    // Background autoplay: skip Unity entirely
    console.log('[Background] Skipping Unity round start');
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the same pattern to all Unity communication points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;startRound()&lt;/code&gt; – Game initialization&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;startSimulation()&lt;/code&gt; – Simulation trigger&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;showResults()&lt;/code&gt; – Success animation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;completeRound()&lt;/code&gt; – Cleanup&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Layer 2: Seed State Tracking
&lt;/h4&gt;

&lt;p&gt;Track which seed Unity currently has loaded to detect desynchronization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
export abstract class SeedGameplayManager extends BaseGameplayManager {
  private lastUnitySeed: string | number | null = null;

  protected async onSimulationStart(roundResponse: RoundResponse): Promise&amp;lt;void&amp;gt; {
    const currentSeed = roundResponse.events[0].seed;

    if (isTabHidden &amp;amp;&amp;amp; isAutoplay) {
      // Background: don't update lastUnitySeed
      // This flags a mismatch when user returns
      skipUnityAndContinue();
    } else {
      // Check for mismatch from previous background play
      if (this.lastUnitySeed !== null &amp;amp;&amp;amp; this.lastUnitySeed !== currentSeed) {
        console.log(`Seed mismatch: Unity=${this.lastUnitySeed}, need=${currentSeed}`);
        await unityInterface.startRound(); // Reset Unity
        await sleep(500); // Wait for Unity to wake up
      }

      unityInterface.startSimulation(stateJson);
      this.lastUnitySeed = currentSeed; // Update tracker
    }
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Background spins don’t update &lt;code&gt;lastUnitySeed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;When user returns, we detect mismatch immediately&lt;/li&gt;
&lt;li&gt;We reset Unity before sending new seed&lt;/li&gt;
&lt;li&gt;This prevents level corruption&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Layer 3: Smart Recovery with Wake-Up Delay
&lt;/h4&gt;

&lt;p&gt;When tabs become active, Unity needs time to recover from low FPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
if (this.lastUnitySeed !== null &amp;amp;&amp;amp; this.lastUnitySeed !== currentSeed) {
  // Step 1: Reset Unity
  await unityInterface.startRound();

  // Step 2: CRITICAL - Wait for Unity to wake up
  // Tab just became visible, Unity transitioning from 1 FPS → 60 FPS
  console.log('[Recovery] Waiting 500ms for Unity wake-up...');
  await new Promise(resolve =&amp;gt; setTimeout(resolve, 500));

  // Step 3: Now Unity is ready for new commands
  unityInterface.startSimulation(stateJson);
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Timeline:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
0ms: Tab becomes visible
0ms: Send startRound() to Unity (reset command)
0-100ms: Unity at ~1-10 FPS, processing reset slowly
100-300ms: Unity FPS ramping up (10→30→60 FPS)
300-500ms: Unity stabilizes at 60 FPS, reset complete
500ms: Send startSimulation() - Unity processes immediately ✅

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the delay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
0ms: Send startRound() - Unity queues it
0ms: Send startSimulation() - Unity queues it
100ms: Unity processes startRound() slowly
200ms: Unity tries to process startSimulation() but internal state not ready
Result: Command lost or processed incorrectly ❌

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Layer 4: Skip Blocking UI
&lt;/h4&gt;

&lt;p&gt;Modal dialogs that wait for user interaction block the entire async flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
public async showResultsModal(): Promise&amp;lt;void&amp;gt; {
  if (this.latestScore === 0) return;

  // CRITICAL: Skip modal in background tabs
  // Modal waits for user click which never comes
  if (document.hidden) {
    console.log('[ResultsUI] Background - skipping modal');
    return; // Resolve immediately
  }

  // Normal flow: show modal and wait for user interaction
  return new Promise((resolve) =&amp;gt; {
    const modal = createModal({
      content: resultsContent,
      onClose: () =&amp;gt; resolve() // Waits for click
    });
  });
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The async blocking chain:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// gameplayManager.ts
protected async transitionToComplete(): Promise&amp;lt;void&amp;gt; {
  if (this.lastScore &amp;gt; 0) {
    await Promise.all([
      this.sendEndRoundRequest(),
      this.emit('roundComplete') // ← Waits for listeners
    ]);
  }
  this.completeRound();
}

// ResultsUI.ts
eventEmitter.on('roundComplete', async () =&amp;gt; {
  await this.showResultsModal(); // ← Blocks if waiting for click
});

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the skip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Background: Results modal created
Background: Promise waiting for click
Background: No user present to click
Background: Promise never resolves
Background: transitionToComplete() never completes
Background: completeRound() never called
Background: Game stuck in "showing results" state
Background: Autoplay frozen ❌

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the skip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Background: Detect document.hidden
Background: Skip modal, return immediately
Background: transitionToComplete() completes
Background: completeRound() → state = "idle"
Background: Autoplay continues ✅

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Results: Smooth Background Autoplay
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Performance Metrics
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before fixes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Background autoplay: ❌ Frozen after 1 round&lt;/li&gt;
&lt;li&gt;Tab switch recovery: ❌ Corrupted visuals&lt;/li&gt;
&lt;li&gt;Success animation completion: ❌ Stuck forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After fixes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Background autoplay: ✅ Continuous at server speed (~300ms/round)&lt;/li&gt;
&lt;li&gt;Tab switch recovery: ✅ Automatic reset + sync (~500ms)&lt;/li&gt;
&lt;li&gt;Success animation completion: ✅ Instant in background&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Flow Comparison
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Scenario: User enables autoplay, switches tabs for 30 seconds&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
0s: Round 1 starts, completes (Unity active)
3s: Round 2 starts
3.1s: User switches tab
3.1s: Unity receives simulation command (queued)
5s: Unity still processing at 1 FPS
10s: Unity still processing at 1 FPS
30s: User returns - game frozen on Round 2

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
0s: Round 1 starts, completes (Unity active)
3s: Round 2 starts
3.1s: User switches tab
3.1s: Detect hidden tab → skip Unity
3.4s: Round 2 completes (server-side only)
3.7s: Round 3 starts → skip Unity
4.0s: Round 3 completes
... 25 more rounds in 30 seconds ...
30s: User returns - seed mismatch detected
30s: Reset Unity + 500ms delay
30.5s: Round 28 plays with correct visuals ✅

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why Three.js and PixiJS Don’t Have This Problem
&lt;/h2&gt;

&lt;p&gt;The issues we faced are &lt;strong&gt;Unity WebGL-specific&lt;/strong&gt;. Native web rendering libraries handle background tabs gracefully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture Comparison
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Unity WebGL&lt;/th&gt;
&lt;th&gt;Three.js / PixiJS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Separate WebAssembly sandbox&lt;/td&gt;
&lt;td&gt;Native JavaScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Communication&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Message queue (async, throttled)&lt;/td&gt;
&lt;td&gt;Direct function calls (instant)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Main Loop&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unity’s Update() via RAF&lt;/td&gt;
&lt;td&gt;Your render loop via RAF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State Management&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Isolated in Unity C# code&lt;/td&gt;
&lt;td&gt;Shared JavaScript context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Background Impact&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Complete freeze (~1 FPS)&lt;/td&gt;
&lt;td&gt;Rendering paused, logic continues&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Three.js Example
&lt;/h3&gt;

&lt;p&gt;With Three.js, background autoplay is trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
class ThreeJSGame {
  async playRound() {
    // 1. Server request - works normally in background
    const result = await api.requestRound();

    // 2. Update game state - pure JavaScript, instant
    this.updateGameLogic(result);

    // 3. Conditionally render visuals
    if (!document.hidden) {
      await this.animateScene(result); // Skip in background
    }

    // 4. Complete round - instant
    this.onRoundComplete();

    // 5. Continue autoplay - no blocking!
    if (this.autoplayMode) {
      this.playRound();
    }
  }

  private async animateScene(result: GameResult) {
    // Three.js rendering - skipped when document.hidden
    return new Promise(resolve =&amp;gt; {
      const animate = () =&amp;gt; {
        if (this.animationComplete) {
          resolve();
          return;
        }

        // Update camera, objects, etc.
        this.camera.position.lerp(targetPos, 0.1);
        this.mesh.rotation.y += 0.01;

        // Render frame
        this.renderer.render(this.scene, this.camera);

        requestAnimationFrame(animate);
      };
      animate();
    });
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No need for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Message queue synchronization&lt;/li&gt;
&lt;li&gt;❌ Seed state tracking&lt;/li&gt;
&lt;li&gt;❌ Wake-up delays&lt;/li&gt;
&lt;li&gt;❌ Manual desync detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Just:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Skip rendering loop if &lt;code&gt;document.hidden&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;✅ Continue game logic at full speed&lt;/li&gt;
&lt;li&gt;✅ Everything stays synchronized automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  PixiJS Example
&lt;/h3&gt;

&lt;p&gt;PixiJS follows the same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
class PixiGame {
  private animationLoop(): void {
    // Game logic - always runs
    this.updatePhysics(deltaTime);
    this.checkCollisions();
    this.updateScore();

    // Rendering - conditional
    if (!document.hidden) {
      this.renderer.render(this.stage);
    }

    requestAnimationFrame(() =&amp;gt; this.animationLoop());
  }

  async playRound() {
    const result = await api.requestRound();

    // Update sprites, positions, etc. - instant
    this.updateGameObjects(result);

    // Wait for animation if visible
    if (!document.hidden) {
      await this.waitForAnimation(3000);
    }

    // Continue - no blocking
    if (this.autoplayMode) this.playRound();
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key Differences Explained
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Synchronous State Access&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unity WebGL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Must send message and wait for callback
unityInstance.SendMessage('GameObject', 'GetScore', '');
// ... wait for Unity to process ...
// ... wait for callback ...
const score = await waitForCallback();

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three.js/PixiJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Direct access - instant
const score = this.gameState.score;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. No Communication Overhead&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unity WebGL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Every operation requires:
1. Serialize data to JSON
2. Send via SendMessage
3. Unity deserialize JSON
4. Unity process in Update()
5. Unity serialize response
6. Send via ExternalCall
7. Browser deserialize response

Total: ~2-3 seconds in background tab

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three.js/PixiJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Every operation is:
1. Direct function call

Total: &amp;lt;1ms

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Shared Context&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unity WebGL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Two separate worlds
const webState = { round: 5, score: 100 };
const unityState = { round: 3, score: 0 }; // Out of sync!
// Must manually synchronize

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three.js/PixiJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Single source of truth
class Game {
  state = { round: 5, score: 100 };

  updateLogic() { this.state.score += 10; }
  renderVisuals() { this.scoreText.text = this.state.score; }
  // Always in sync
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Selective Rendering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unity WebGL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Unity's Update() always tries to run everything
void Update() {
    UpdatePhysics(); // Throttled to 1 FPS
    UpdateAnimation(); // Throttled to 1 FPS
    UpdateAI(); // Throttled to 1 FPS
    Render(); // Throttled to 1 FPS
    // Can't separate logic from rendering
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three.js/PixiJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
function gameLoop() {
  // Logic - always runs at full speed
  updatePhysics(deltaTime); // 60 FPS even in background
  updateAI(); // 60 FPS even in background

  // Rendering - skip in background
  if (!document.hidden) {
    renderer.render(scene); // 0 FPS in background
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  When to Use Each Technology
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Choose Unity WebGL When:
&lt;/h3&gt;

&lt;p&gt;✅ You need a full 3D engine with physics, lighting, particles&lt;br&gt;&lt;br&gt;
✅ You have existing Unity assets/expertise&lt;br&gt;&lt;br&gt;
✅ You’re targeting multiple platforms (desktop, mobile, console, web)&lt;br&gt;&lt;br&gt;
✅ Visual fidelity is critical&lt;br&gt;&lt;br&gt;
✅ Background tab performance is not a priority&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Larger bundle size (~10-50 MB)&lt;/li&gt;
&lt;li&gt;Compilation required&lt;/li&gt;
&lt;li&gt;Background tab challenges (as discussed)&lt;/li&gt;
&lt;li&gt;Less direct browser API access&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Choose Three.js When:
&lt;/h3&gt;

&lt;p&gt;✅ You need custom 3D rendering with full control&lt;br&gt;&lt;br&gt;
✅ Background tab performance matters&lt;br&gt;&lt;br&gt;
✅ You want smaller bundle sizes&lt;br&gt;&lt;br&gt;
✅ You need direct browser API integration&lt;br&gt;&lt;br&gt;
✅ You’re comfortable with JavaScript/TypeScript&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More manual setup (physics, lighting, etc.)&lt;/li&gt;
&lt;li&gt;Steeper learning curve for 3D graphics&lt;/li&gt;
&lt;li&gt;No visual editor&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Choose PixiJS When:
&lt;/h3&gt;

&lt;p&gt;✅ You’re building 2D games&lt;br&gt;&lt;br&gt;
✅ Performance is critical&lt;br&gt;&lt;br&gt;
✅ Background tab support is required&lt;br&gt;&lt;br&gt;
✅ You want the smallest bundle size&lt;br&gt;&lt;br&gt;
✅ You need maximum browser compatibility&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Limited to 2D (no 3D capabilities)&lt;/li&gt;
&lt;li&gt;Manual sprite management&lt;/li&gt;
&lt;li&gt;No built-in physics engine&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Browser Tab Throttling is Aggressive
&lt;/h3&gt;

&lt;p&gt;Don’t assume &lt;code&gt;requestAnimationFrame&lt;/code&gt;, &lt;code&gt;setTimeout&lt;/code&gt;, or any timing APIs work normally in background tabs. &lt;strong&gt;They don’t.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Network APIs Are Reliable
&lt;/h3&gt;

&lt;p&gt;Fetch, WebSocket, and other network APIs continue working at full speed regardless of tab visibility. Build your architecture around this.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Unity WebGL Needs Special Handling
&lt;/h3&gt;

&lt;p&gt;Unity WebGL’s sandboxed runtime creates unique challenges. Budget extra development time for cross-context communication and state synchronization.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Always Have a Fallback
&lt;/h3&gt;

&lt;p&gt;When integrating external runtimes (Unity, iframes, Web Workers), always implement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timeout detection&lt;/li&gt;
&lt;li&gt;State synchronization&lt;/li&gt;
&lt;li&gt;Recovery mechanisms&lt;/li&gt;
&lt;li&gt;Graceful degradation&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Test in Real Conditions
&lt;/h3&gt;

&lt;p&gt;Background tab behavior varies by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browser (Chrome, Firefox, Safari)&lt;/li&gt;
&lt;li&gt;Device (desktop, mobile, tablet)&lt;/li&gt;
&lt;li&gt;Battery state (plugged in vs. battery)&lt;/li&gt;
&lt;li&gt;System load (other tabs, apps)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Always test with actual tab switching, not just &lt;code&gt;document.hidden = true&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Unity WebGL is a powerful tool for bringing 3D games to the browser, but its sandboxed architecture creates unique challenges for features that must work in background tabs. By understanding browser throttling behavior and implementing strategic workarounds—skipping Unity during background operations, tracking synchronization state, adding recovery delays, and removing blocking UI—we achieved smooth autoplay functionality that works reliably regardless of tab visibility.&lt;/p&gt;

&lt;p&gt;For new projects, consider whether Unity’s benefits (full 3D engine, cross-platform support, visual editor) outweigh its limitations (background tab challenges, large bundle size, communication overhead). Native web technologies like Three.js and PixiJS offer simpler architectures with better background tab support, at the cost of requiring more manual setup.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;Unity is for visual feedback. Keep your game logic in JavaScript, and treat Unity as a pure rendering layer.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API" rel="noopener noreferrer"&gt;Browser Tab Throttling (MDN)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.unity3d.com/Manual/webgl-interactingwithbrowserscripting.html" rel="noopener noreferrer"&gt;Unity WebGL Communication&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://threejs.org/docs/" rel="noopener noreferrer"&gt;Three.js Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pixijs.com/guides" rel="noopener noreferrer"&gt;PixiJS Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.chrome.com/blog/page-lifecycle-api/" rel="noopener noreferrer"&gt;requestAnimationFrame Throttling&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/unity-webgl-background-tabs-autoplay-performance-ix/" rel="noopener noreferrer"&gt;Unity WebGL Background Tabs: Autoplay &amp;amp; Performance Fix&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>threejs</category>
      <category>typescript</category>
      <category>pixijs</category>
    </item>
    <item>
      <title>Building a Web-Unity WebGL Bridge: A Practical Guide</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 13 Dec 2025 15:22:32 +0000</pubDate>
      <link>https://dev.to/fur-gaming/building-a-web-unity-webgl-bridge-a-practical-guide-3nbe</link>
      <guid>https://dev.to/fur-gaming/building-a-web-unity-webgl-bridge-a-practical-guide-3nbe</guid>
      <description>&lt;p&gt;When you’re building games that need both the power of Unity’s 3D engine and the flexibility of modern web technologies, you quickly discover that making them communicate isn’t as straightforward as it seems. After building several hybrid web-Unity applications, I’ve learned quite a few lessons about what works, what doesn’t, and what will make you want to throw your keyboard out the window.&lt;/p&gt;

&lt;p&gt;While the well-established &lt;a href="https://react-unity-webgl.dev/" rel="noopener noreferrer"&gt;React Unity WebGL&lt;/a&gt; library already does a great job, this guide walks you through building a robust communication bridge from scratch between a TypeScript/JavaScript web frontend and Unity WebGL builds. I’ll share the architecture we settled on, the mistakes we made along the way, and the solutions we found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Build a Hybrid Architecture?
&lt;/h2&gt;

&lt;p&gt;Before diving into the technical details, let’s address the “why.” You might be wondering: if Unity can build for WebGL, why not just use Unity for everything?&lt;/p&gt;

&lt;p&gt;In our case, we were building multiple games that shared common functionality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentication &amp;amp; session management&lt;/strong&gt; – handled by our backend SDK&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internationalization&lt;/strong&gt; – 16+ languages with dynamic switching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI framework&lt;/strong&gt; – consistent menus, settings panels, modals across games&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio system&lt;/strong&gt; – web audio API with Howler.js for UI sounds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State persistence&lt;/strong&gt; – localStorage, session handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each game had unique 3D gameplay, but 80% of the surrounding infrastructure was identical. Building all of this in Unity for each game would mean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Duplicating shared code across projects&lt;/li&gt;
&lt;li&gt;Longer iteration cycles (Unity builds take time)&lt;/li&gt;
&lt;li&gt;Larger bundle sizes (Unity UI is heavy)&lt;/li&gt;
&lt;li&gt;Less flexibility in web-specific features&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our solution: a shared web engine that handles the “chrome” around the game, while Unity handles the 3D gameplay. The web layer sends commands to Unity (“start the game”, “play this seed”), and Unity sends results back (“game complete”, “animation finished”).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture at a Glance
&lt;/h2&gt;

&lt;p&gt;Here’s the high-level flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Web (TypeScript) Unity WebGL
----------------- -----------
GameplayManager GameController.cs
      | ^
      v |
UnityInterface -------SendMessage-------&amp;gt; WebInterface.cs
      | |
      | &amp;lt;------ExternalCall-------- |
      v v
   GameUI Game Scene
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The communication is bidirectional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web to Unity&lt;/strong&gt; : Uses Unity’s &lt;code&gt;SendMessage()&lt;/code&gt; API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unity to Web&lt;/strong&gt; : Uses &lt;code&gt;Application.ExternalCall()&lt;/code&gt; (or the modern &lt;code&gt;jslib&lt;/code&gt; approach)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Messages are JSON strings in both directions, giving us type safety and flexibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Key Mistakes We Made (So You Don’t Have To)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mistake #1: Inconsistent GameObject Naming
&lt;/h3&gt;

&lt;p&gt;Unity’s &lt;code&gt;SendMessage()&lt;/code&gt; API requires you to specify a GameObject name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unityInstance.SendMessage('WebInterface', 'StartGame', data);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sounds simple, right? Here’s where we messed up: different developers named the receiving GameObject differently across projects. One called it “WebInterface”, another “WebBridge”, another “GameManager”.&lt;/p&gt;

&lt;p&gt;When we tried to create a reusable engine, nothing worked because each game expected a different name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Establish a &lt;em&gt;strict naming convention&lt;/em&gt; and stick to it. We settled on pattern-based names:&lt;br&gt;&lt;br&gt;
 – &lt;code&gt;WebSeedInterfaces&lt;/code&gt; – for seed-based games&lt;br&gt;&lt;br&gt;
 – &lt;code&gt;WebRoundInterfaces&lt;/code&gt; – for round-based games&lt;br&gt;&lt;br&gt;
 – &lt;code&gt;WebSettingsInterface&lt;/code&gt; – for audio/language/settings&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The “Interfaces” suffix (plural) reminds us it’s a collection of interface methods, not a single one.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h3&gt;
  
  
  Mistake #2: Complex JSON Message Structures
&lt;/h3&gt;

&lt;p&gt;Our first implementation sent messages like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Web side - sending
unityInstance.SendMessage('WebInterface', 'ReceiveMessage', JSON.stringify({
    action: 'setDifficulty',
    data: {
        difficulty: 'hard',
        timestamp: Date.now()
    }
}));

// Unity side - receiving
public void ReceiveMessage(string json) {
    var msg = JsonUtility.FromJson&amp;lt;WebMessage&amp;gt;(json);
    switch(msg.action) {
        case "setDifficulty":
            var data = JsonUtility.FromJson&amp;lt;DifficultyData&amp;gt;(msg.data);
            SetDifficulty(data.difficulty);
            break;
        // ... 20 more cases
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This meant:&lt;br&gt;&lt;br&gt;
 – Unity needed to parse JSON twice (outer message + inner data)&lt;br&gt;&lt;br&gt;
 – Giant switch statements that grew with every feature&lt;br&gt;&lt;br&gt;
 – Type definitions on both sides had to stay in sync&lt;br&gt;&lt;br&gt;
 – Debugging was painful&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Use direct method calls with simple values. Unity’s &lt;code&gt;SendMessage()&lt;/code&gt; can call any public method directly:&lt;br&gt;
&lt;/p&gt;


&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Web side - much simpler!
unityInstance.SendMessage('WebRoundInterfaces', 'WebSetDifficulty', 'hard');
unityInstance.SendMessage('WebRoundInterfaces', 'WebStartRound', '');
unityInstance.SendMessage('WebSettingsInterface', 'WebSetLanguage', 'en'); 

// Unity side - clean methods
public void WebSetDifficulty(string difficulty) {
    _difficulty = difficulty;
    OnDifficultyChanged?.Invoke(difficulty);
}

public void WebStartRound(string _unused) {
    StartRound();
}

public void WebSetLanguage(string languageCode) {
    SetLanguage(languageCode);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We adopted a &lt;code&gt;Web[Verb][Noun]&lt;/code&gt; naming pattern for all web-callable methods. This makes it immediately clear which methods are called from the web side.&lt;/p&gt;




&lt;h3&gt;
  
  
  Mistake #3: Not Handling the “Unity Not Ready” State
&lt;/h3&gt;

&lt;p&gt;Unity WebGL takes time to load. If your web code tries to send messages before Unity is ready, they simply disappear into the void.&lt;/p&gt;

&lt;p&gt;Our first “fix” was to add arbitrary delays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Don't do this
setTimeout(() =&amp;gt; {
    unityInstance.SendMessage('WebInterface', 'Initialize', '');
}, 3000); // Hope 3 seconds is enough...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spoiler: it wasn’t always enough. And sometimes it was too much.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Implement a message queue that holds messages until Unity signals it’s ready:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class UnityManager {
    private messageQueue: Array&amp;lt;{methodName: string; value?: any}&amp;gt; = [];
    private connectionState: 'disconnected' | 'loading' | 'ready' = 'disconnected';

    async sendMessage(methodName: string, value?: any): Promise&amp;lt;void&amp;gt; {
        if (this.connectionState !== 'ready') {
            // Queue message for later
            this.messageQueue.push({ methodName, value });
            return;
        }
        await this.sendMessageToUnity(methodName, value);
    }

    // Called when Unity sends 'gameReady' message
    private onUnityReady(): void {
        this.connectionState = 'ready';
        // Process queued messages
        this.processMessageQueue();
    }

    private async processMessageQueue(): Promise&amp;lt;void&amp;gt; {
        while (this.messageQueue.length &amp;gt; 0) {
            const message = this.messageQueue.shift();
            await this.sendMessageToUnity(message.methodName, message.value);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Unity side, send a “ready” signal when the game is initialized:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;void Start() {
    // Send ready signal to web
    SendToWeb("gameReady", null);
}

public void SendToWeb(string action, object data) {
    var message = JsonUtility.ToJson(new { action, data });
    Application.ExternalCall("UnityMessageHandler", message);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Mistake #4: Forgetting That SendMessage Only Takes Strings
&lt;/h3&gt;

&lt;p&gt;This one bit us multiple times. Unity’s &lt;code&gt;SendMessage()&lt;/code&gt; can only pass string, int, or float parameters. No objects, no booleans, no arrays.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// This silently fails or behaves unexpectedly
unityInstance.SendMessage('WebInterface', 'SetEnabled', true);
unityInstance.SendMessage('WebInterface', 'SetConfig', { foo: 'bar' });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Always convert to strings on the web side, parse on the Unity side:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Web side
private async sendMessageToUnity(methodName: string, value?: any): Promise&amp;lt;void&amp;gt; {
    // Convert value to string - Unity SendMessage always expects string
    const messageData = value !== undefined ? String(value) : '';
    this.unityInstance.SendMessage(this.gameObjectName, methodName, messageData);
}

// Unity side - parse boolean from string
public void WebSetTurbo(string boolValue) {
    bool enabled = bool.Parse(boolValue); // "true" -&amp;gt; true
    SetTurboMode(enabled);
}

// Unity side - parse number from string
public void WebSetRound(string roundNumber) {
    int round = int.Parse(roundNumber); // "5" -&amp;gt; 5
    SetRound(round);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Unity Build Settings That Matter
&lt;/h2&gt;

&lt;p&gt;Your Unity WebGL build settings significantly impact how well the bridge works. Here’s what we learned:&lt;/p&gt;

&lt;h3&gt;
  
  
  Player Settings
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Edit &amp;gt; Project Settings &amp;gt; Player &amp;gt; WebGL Settings

Product Name: Game // Use a generic name for reusability
Compression Format: Gzip // Best balance of size and compatibility
Decompression Fallback: Yes // For older browsers
Run In Background: Yes // Keep running when tab loses focus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Product Name&lt;/code&gt; becomes part of your build filenames (&lt;code&gt;Game.wasm&lt;/code&gt;, &lt;code&gt;Game.framework.js&lt;/code&gt;, etc.). Using a generic name like “Game” means your web engine doesn’t need per-game configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;File &amp;gt; Build Settings &amp;gt; WebGL

Development Build: [checked for dev, unchecked for prod]
Code Optimization: Speed (for production)
Enable Exceptions: Explicitly Thrown Only
Strip Engine Code: Yes (reduces file size)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Vite Configuration for Unity WebGL
&lt;/h3&gt;

&lt;p&gt;If you’re using Vite (or similar bundler), you need specific configuration to handle Unity files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// vite.config.ts
export default defineConfig({
    server: {
        headers: {
            // Required for Unity WebGL SharedArrayBuffer support
            'Cross-Origin-Embedder-Policy': 'require-corp',
            'Cross-Origin-Opener-Policy': 'same-origin'
        }
    },
    build: {
        assetsInlineLimit: 0, // Don't inline Unity assets
        chunkSizeWarningLimit: 10000, // Unity files can be large
        rollupOptions: {
            output: {
                assetFileNames: (assetInfo) =&amp;gt; {
                    // Keep Unity files with their original names
                    if (assetInfo.name?.endsWith('.data') ||
                        assetInfo.name?.endsWith('.wasm') ||
                        assetInfo.name?.endsWith('.framework.js')) {
                        return '[name][extname]';
                    }
                    return 'assets/[name]-[hash][extname]';
                }
            }
        }
    }
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Silencing Unity’s Console Noise
&lt;/h2&gt;

&lt;p&gt;Unity WebGL builds are &lt;em&gt;chatty&lt;/em&gt;. Like, really chatty. Open your browser console and you’ll see hundreds of messages about memory allocation, WebGL state, physics initialization, and more.&lt;/p&gt;

&lt;p&gt;This isn’t just annoying – it can hide actual errors and slow down the browser’s developer tools.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Console Filter Approach
&lt;/h3&gt;

&lt;p&gt;We intercept console methods &lt;em&gt;before&lt;/em&gt; Unity loads and filter out the noise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;!-- index.html - MUST be before any Unity scripts --&amp;gt;
&amp;lt;script&amp;gt;
(function() {
    // Store original console methods
    const originalConsole = {
        log: console.log.bind(console),
        warn: console.warn.bind(console),
        error: console.error.bind(console)
    };

    // Detect build mode (replaced by build tool)
    const isProduction = __VITE_BUILD_MODE__ ;

    // Unity internal patterns to suppress
    const unityPatterns = [
        /\[UnityMemory\]/, /\[Physics::Module\]/, /memorysetup-/,
        /Loading player data/, /Initialize engine version/, /Creating WebGL/,
        /^Renderer:/, /^Vendor:/, /^GLES:/, /OPENGL LOG:/,
        /UnloadTime:/, /JS_FileSystem_Sync/, /Configuration Parameters/,
        /\$func\d+ @ Game\.wasm/, /Module\._main @ Game\.framework\.js/
    ];

    // Custom Debug.Log patterns to KEEP (your game's logs)
    const customPatterns = [
        /\[WebRoundInterface\]/, /\[GameplayController\]/,
        /\[[A-Z][A-Za-z]*(?:Interface|Controller|Manager)\]/
    ];

    function shouldSuppress(message) {
        // Production: suppress everything
        if (isProduction) return true;

        // Development: suppress Unity internal, keep custom
        const isUnityInternal = unityPatterns.some(p =&amp;gt; p.test(message));
        if (isUnityInternal) {
            const isCustomLog = customPatterns.some(p =&amp;gt; p.test(message));
            return !isCustomLog;
        }
        return false;
    }

    // Override console methods
    ['log', 'warn', 'error'].forEach(level =&amp;gt; {
        console[level] = function(...args) {
            const message = args.map(String).join(' ');
            if (!shouldSuppress(message)) {
                originalConsole[level](...args);
            }
        };
    });

    // Store original for emergency access
    window.__originalConsole = originalConsole;
})();
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Suppressing WebGL Warnings at the Source
&lt;/h3&gt;

&lt;p&gt;Some WebGL warnings happen &lt;em&gt;during&lt;/em&gt; API calls, before any console filtering can catch them. Unity queries texture formats that may not be supported, and Chrome helpfully logs &lt;code&gt;WebGL: INVALID_ENUM&lt;/code&gt; warnings.&lt;/p&gt;

&lt;p&gt;The nuclear option: patch the WebGL API itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (typeof WebGL2RenderingContext !== 'undefined') {
    const original = WebGL2RenderingContext.prototype.getInternalformatParameter;

    // Known invalid formats Unity queries
    const invalidFormats = new Set([
        36756, 36757, 36759, 36760, 36761, 36763 // Compressed texture formats
    ]);

    WebGL2RenderingContext.prototype.getInternalformatParameter = function(
        target, internalformat, pname
    ) {
        // Block Unity's known invalid queries before they trigger warnings
        if (invalidFormats.has(internalformat)) {
            return null;
        }
        return original.call(this, target, internalformat, pname);
    };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Post-Build Processing
&lt;/h3&gt;

&lt;p&gt;For production builds, we also modify Unity’s generated files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// post-unity-build.js - Run after Unity build
import fs from 'fs';

const frameworkFile = './public/unity/Build/Game.framework.js';
const loaderFile = './public/unity/Build/Game.loader.js';

// Read Unity framework file
let framework = fs.readFileSync(frameworkFile, 'utf8');

// Prepend WebGL fix
const webglFix = `
(function () {
    const original = WebGL2RenderingContext.prototype.getInternalformatParameter;
    const invalid = new Set([36756, 36757, 36759, 36760, 36761, 36763]);
    WebGL2RenderingContext.prototype.getInternalformatParameter = function(t, i, p) {
        if (invalid.has(i)) return null;
        return original.call(this, t, i, p);
    };
})();
`;

// Replace console.log/warn with void 0
framework = webglFix + framework.replace(/(\W)console\.(log|warn)\([^)]*\);/g, '$1void 0;');

fs.writeFileSync(frameworkFile, framework);

// Suppress Unity Analytics in loader
let loader = fs.readFileSync(loaderFile, 'utf8');

const analyticsFix = `
(function() {
    const originalFetch = window.fetch;
    window.fetch = function(...args) {
        if (typeof args[0] === 'string' &amp;amp;&amp;amp; args[0].includes('unity3d.com')) {
            return Promise.reject(new Error('Analytics disabled'));
        }
        return originalFetch.apply(this, args);
    };
})();
`;

loader = analyticsFix + loader.replace(/console\.(log|warn)\([^)]*\)/g, 'void 0');
fs.writeFileSync(loaderFile, loader);

console.log('Unity build post-processed successfully');
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Final Architecture
&lt;/h2&gt;

&lt;p&gt;After all these iterations, here’s what our architecture looks like:&lt;/p&gt;

&lt;h3&gt;
  
  
  Web Side (TypeScript)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// UnityManager - Low-level communication
class UnityManager {
    private messageQueue: Message[] = [];
    private connectionState: ConnectionState = 'disconnected';

    async initialize(config: UnityConfig): Promise&amp;lt;void&amp;gt; { /* ... */ }
    async loadUnityGame(canvas?: HTMLCanvasElement): Promise&amp;lt;void&amp;gt; { /* ... */ }
    async sendMessage(methodName: string, value?: any): Promise&amp;lt;void&amp;gt; { /* ... */ }
}

// Domain-specific interfaces
class UnitySeedInterface {
    async startSpin(): Promise&amp;lt;void&amp;gt; { /* WebStartSpin */ }
    async startRevealing(seed: string): Promise&amp;lt;void&amp;gt; { /* WebStartRevealing */ }
    async startPayout(amount: string): Promise&amp;lt;void&amp;gt; { /* WebStartPayout */ }
    async completeSpin(): Promise&amp;lt;void&amp;gt; { /* WebCompleteSpin */ }
}

class UnitySettingsInterface {
    async setSound(enabled: boolean): Promise&amp;lt;void&amp;gt; { /* WebToggleSound */ }
    async setLanguage(lang: string): Promise&amp;lt;void&amp;gt; { /* WebSetLanguage */ }
    async setTurbo(enabled: boolean): Promise&amp;lt;void&amp;gt; { /* WebSetTurbo */ }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Unity Side (C#)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public class WebSeedInterface : MonoBehaviour
{
    public UnityEvent OnSpinStarted;
    public UnityEvent&amp;lt;string&amp;gt; OnRevealing;
    public UnityEvent&amp;lt;string&amp;gt; OnPayout;
    public UnityEvent OnSpinCompleted;

    void Start() =&amp;gt; SendToWeb("gameReady", null);

    public void WebStartSpin(string _) =&amp;gt; OnSpinStarted?.Invoke();
    public void WebStartRevealing(string seed) =&amp;gt; OnRevealing?.Invoke(seed);
    public void WebStartPayout(string amount) =&amp;gt; OnPayout?.Invoke(amount);
    public void WebCompleteSpin(string _) =&amp;gt; OnSpinCompleted?.Invoke();

    public void SendToWeb(string action, object data) {
        var json = JsonUtility.ToJson(new { action, data });
        Application.ExternalCall("UnityMessageHandler", json);
    }
}

// WebSettingsInterface.cs - Audio, language, turbo
public class WebSettingsInterface : MonoBehaviour
{
    public static event Action&amp;lt;bool&amp;gt; OnSoundToggled;
    public static event Action&amp;lt;string&amp;gt; OnLanguageChanged;
    public static event Action&amp;lt;bool&amp;gt; OnTurboModeToggled;

    public void WebToggleSound(string val) =&amp;gt; OnSoundToggled?.Invoke(bool.Parse(val));
    public void WebSetLanguage(string lang) =&amp;gt; OnLanguageChanged?.Invoke(lang);
    public void WebSetTurbo(string val) =&amp;gt; OnTurboModeToggled?.Invoke(bool.Parse(val));
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Standardize GameObject names&lt;/strong&gt; – Pick a naming convention and enforce it across all projects.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use direct method calls&lt;/strong&gt; – Skip the JSON wrapper for simple values. &lt;code&gt;WebSetDifficulty('hard')&lt;/code&gt; beats &lt;code&gt;ReceiveMessage('{"action":"setDifficulty","data":"hard"}')&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Queue messages until ready&lt;/strong&gt; – Never assume Unity is loaded. Always queue messages and process them when Unity signals readiness.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Everything is a string&lt;/strong&gt; – Remember that &lt;code&gt;SendMessage()&lt;/code&gt; can only pass strings. Convert on web, parse on Unity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Filter console noise early&lt;/strong&gt; – Set up console filtering before Unity loads, and patch WebGL APIs if necessary.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Post-process Unity builds&lt;/strong&gt; – Remove console calls and Unity analytics from production builds.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Building a web-Unity bridge isn’t rocket science, but the devil is in the details. These patterns have served us well across multiple games, and I hope they save you some of the headaches we experienced along the way.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have questions or improvements? Feel free to reach out. Happy coding!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/building-a-web-unity-webgl-bridge-a-practical-guide/" rel="noopener noreferrer"&gt;Building a Web-Unity WebGL Bridge: A Practical Guide&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>typescript</category>
      <category>gamedev</category>
      <category>unity3d</category>
    </item>
    <item>
      <title>Introducing Blocky UI Lite: A 3D Blocky-Themed Component Library 🎮</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 18 Oct 2025 10:20:09 +0000</pubDate>
      <link>https://dev.to/fur-gaming/introducing-blocky-ui-lite-a-3d-blocky-themed-component-library-58k3</link>
      <guid>https://dev.to/fur-gaming/introducing-blocky-ui-lite-a-3d-blocky-themed-component-library-58k3</guid>
      <description>&lt;p&gt;Ever wanted to give your web apps that distinctive game aesthetic? Meet &lt;strong&gt;Blocky UI Lite&lt;/strong&gt; – a lightweight TypeScript component library that brings 3D blocky styling to your projects with zero dependencies and pure CSS magic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp5sgem1lavvq7468hbnj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp5sgem1lavvq7468hbnj.jpg" alt="Blocky UI components with 3D depth effects" width="800" height="774"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Blocky UI components with 3D depth effects&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  ✨ What Makes It Special?
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Pure CSS 3D Effects
&lt;/h3&gt;

&lt;p&gt;No SVG generation, no JavaScript-based styling, no runtime overhead. Every 3D effect is achieved through carefully crafted CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;
&lt;span class="c"&gt;/* Multi-layer box shadows create depth */&lt;/span&gt;
&lt;span class="nt"&gt;box-shadow&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="err"&gt;4&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="c"&gt;/* Base shadow */&lt;/span&gt;
    &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt; &lt;span class="err"&gt;16&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;4&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="c"&gt;/* Far shadow */&lt;/span&gt;
    &lt;span class="nt"&gt;inset&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="err"&gt;2&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;2&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="c"&gt;/* Top highlight */&lt;/span&gt;
    &lt;span class="nt"&gt;inset&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="nt"&gt;-2px&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c"&gt;/* Bottom shadow */&lt;/span&gt;

&lt;span class="c"&gt;/* Gradient backgrounds with transparency */&lt;/span&gt;
&lt;span class="nt"&gt;background&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;linear-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="err"&gt;180&lt;/span&gt;&lt;span class="nt"&gt;deg&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;85&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;223&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;95&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt;
    &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;85&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;223&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;7&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt;
    &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;85&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;223&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;5&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="c"&gt;/* Radial overlay for extra depth */&lt;/span&gt;
&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;::before&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;circle&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;70%&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Zero Dependencies
&lt;/h3&gt;

&lt;p&gt;The entire library weighs in at just &lt;strong&gt;~15KB gzipped&lt;/strong&gt; with absolutely zero runtime dependencies. It’s pure TypeScript + CSS, making it incredibly portable and performant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full TypeScript Support
&lt;/h3&gt;

&lt;p&gt;Every component comes with complete type definitions, making development a breeze with full autocomplete and type checking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ComponentVariant&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// TypeScript knows all available options&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Click Me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'default' | 'primary' | 'secondary' | 'danger'&lt;/span&gt;
    &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Clicked!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🎨 Component Variants System
&lt;/h2&gt;

&lt;p&gt;One of the newest features is the &lt;strong&gt;unified variant system&lt;/strong&gt;. Buttons, Cards, and Tags all support the same four color variants:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;default&lt;/strong&gt; – Neutral gray for standard elements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;primary&lt;/strong&gt; – Vibrant blue for primary actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;secondary&lt;/strong&gt; – Bright cyan for secondary actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;danger&lt;/strong&gt; – Bold red for destructive actions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Ffuric%2Fblocky-ui%2Fmain%2Fdocs%2Fvariants-demo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Ffuric%2Fblocky-ui%2Fmain%2Fdocs%2Fvariants-demo.png" alt="Component Variants" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;All components support consistent color variants&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  🚀 Quick Start Example
&lt;/h2&gt;

&lt;p&gt;Getting started is incredibly simple. Here’s a complete example creating an interactive game UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- CSS --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/blocky-ui-lite@latest/dist/blocky-ui.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"game-ui"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- JavaScript --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/blocky-ui-lite@latest/dist/index.umd.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BlockyUILite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Create a game stats card&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statsCard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Player Stats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
                &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Level:&amp;lt;/strong&amp;gt; 42&amp;lt;/p&amp;gt;
                &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Score:&amp;lt;/strong&amp;gt; 9,850&amp;lt;/p&amp;gt;
                &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Coins:&amp;lt;/strong&amp;gt; 1,234&amp;lt;/p&amp;gt;
            `&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Create multiplier tags&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;multiplierTag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTag&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;×5.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;secondary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Create action buttons&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;playButton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PLAY NOW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Game Started!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Good luck and have fun!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
                &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Add to page&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game-ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;statsCard&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playButton&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🏗️ Technical Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Component Architecture
&lt;/h3&gt;

&lt;p&gt;Each component follows a consistent &lt;strong&gt;static factory pattern&lt;/strong&gt; that returns instances with methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// Factory method creates instance&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createModal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Confirm Action&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Are you sure?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;buttons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cancel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Confirm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Instance methods for control&lt;/span&gt;
&lt;span class="nx"&gt;modal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Display the modal&lt;/span&gt;
&lt;span class="nx"&gt;modal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Close programmatically&lt;/span&gt;

&lt;span class="c1"&gt;// Or use convenience methods (auto-shown)&lt;/span&gt;
&lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Success!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Operation completed.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Something went wrong.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showConfirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Delete?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This cannot be undone.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onConfirm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onCancel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CSS Architecture
&lt;/h3&gt;

&lt;p&gt;The library uses CSS custom properties for easy theming and consistency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;/* Colors */&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#55dfff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-danger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ff4444&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c"&gt;/* 3D Effects */&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-shadow-base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-shadow-far&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c"&gt;/* Spacing */&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-padding-md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c"&gt;/* Z-Index Layers */&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-z-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-z-dropdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--blocky-z-overlay-modal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;900&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Build Pipeline
&lt;/h3&gt;

&lt;p&gt;Blocky UI uses &lt;strong&gt;Rollup&lt;/strong&gt; to generate multiple module formats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ESM&lt;/strong&gt; (&lt;code&gt;index.esm.js&lt;/code&gt;) – For modern bundlers (Vite, Webpack, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CJS&lt;/strong&gt; (&lt;code&gt;index.cjs.js&lt;/code&gt;) – For Node.js environments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UMD&lt;/strong&gt; (&lt;code&gt;index.umd.js&lt;/code&gt;) – For direct browser usage via CDN&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt; (&lt;code&gt;index.d.ts&lt;/code&gt;) – Full type definitions&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📦 Available Components
&lt;/h2&gt;

&lt;h3&gt;
  
  
  BlockyButton
&lt;/h3&gt;

&lt;p&gt;Interactive buttons with 4 color variants and 3D hover effects. Perfect for CTAs and game actions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;START GAME&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;startGame&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BlockyModal
&lt;/h3&gt;

&lt;p&gt;Overlay dialogs with backdrop blur and smooth animations. Returns an instance for manual control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createModal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Game Over&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You scored 1,234 points!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;showCloseButton&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;buttons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Play Again&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;restart&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;modal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Display when ready&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BlockyCard
&lt;/h3&gt;

&lt;p&gt;Content containers with 3D styling and optional headers. Now with variant support!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Daily Rewards&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;secondary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Come back tomorrow for more coins!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BlockyTag
&lt;/h3&gt;

&lt;p&gt;Compact labels perfect for multipliers and status indicators.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTag&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;×2.5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// Red for high multipliers!&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BlockyInfo
&lt;/h3&gt;

&lt;p&gt;Temporary notifications with auto-dismiss and 5 color themes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createInfo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Achievement Unlocked!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;titleColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yellow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You reached level 10!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BlockyPage
&lt;/h3&gt;

&lt;p&gt;Full-screen scrollable overlays with animated gradient borders.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
        &amp;lt;h1&amp;gt;Game Rules&amp;lt;/h1&amp;gt;
        &amp;lt;p&amp;gt;Here are the complete game instructions...&amp;lt;/p&amp;gt;
    `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;onClose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rules closed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🎮 Real-World Use Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Casino Game Interfaces
&lt;/h3&gt;

&lt;p&gt;Perfect for slots, roulette, poker interfaces – anywhere you need that “blocky casino” aesthetic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gaming Dashboards
&lt;/h3&gt;

&lt;p&gt;Player stats, leaderboards, achievement systems, inventory screens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Interactive Web Apps
&lt;/h3&gt;

&lt;p&gt;Any application that wants to stand out with a unique, game-inspired design language.&lt;/p&gt;

&lt;h3&gt;
  
  
  Educational Platforms
&lt;/h3&gt;

&lt;p&gt;Gamified learning interfaces, quiz applications, progress tracking systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 Installation &amp;amp; Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Via npm/yarn/pnpm
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;npm&lt;/span&gt;
&lt;span class="nx"&gt;npm&lt;/span&gt; &lt;span class="nx"&gt;install&lt;/span&gt; &lt;span class="nx"&gt;blocky&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;lite&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;yarn&lt;/span&gt;
&lt;span class="nx"&gt;yarn&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="nx"&gt;blocky&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;lite&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;pnpm&lt;/span&gt;
&lt;span class="nx"&gt;pnpm&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="nx"&gt;blocky&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;lite&lt;/span&gt;



&lt;span class="c1"&gt;// Import CSS (required)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite/styles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Import components&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Use in your app&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Click Me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Via CDN (No Build Step)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;
&lt;span class="c"&gt;&amp;lt;!-- CSS --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/blocky-ui-lite@latest/dist/blocky-ui.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- JavaScript (UMD) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/blocky-ui-lite@latest/dist/index.umd.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BlockyUILite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Use BlockyUI here&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🎨 Framework Integration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  React
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite/styles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GameButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;containerRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PLAY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Game started!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Vue 3
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;
&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"ts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite/styles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;containerRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PLAY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Game started!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"containerRef"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Svelte
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"ts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onMount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocky-ui-lite/styles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;onMount&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlockyUI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PLAY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Game started!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;bind:this=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🚀 Performance Considerations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Bundle Size
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS&lt;/strong&gt; : ~8KB minified, ~2KB gzipped&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript&lt;/strong&gt; : ~12KB minified, ~4KB gzipped&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total&lt;/strong&gt; : ~20KB minified, ~6KB gzipped&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Runtime Performance
&lt;/h3&gt;

&lt;p&gt;All animations are CSS-based using &lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt;, ensuring smooth 60fps animations with GPU acceleration. No JavaScript animation loops means zero CPU overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Load Time Optimization
&lt;/h3&gt;

&lt;p&gt;When using via CDN, both unpkg.com and jsdelivr.net offer automatic minification, compression, and edge caching for blazing-fast delivery worldwide.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔮 Future Roadmap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;⏳ &lt;strong&gt;More Components&lt;/strong&gt; : Tabs, Tooltips, Dropdowns, Sliders&lt;/li&gt;
&lt;li&gt;⏳ &lt;strong&gt;Theming API&lt;/strong&gt; : Runtime theme switching&lt;/li&gt;
&lt;li&gt;⏳ &lt;strong&gt;Animation Library&lt;/strong&gt; : Pre-built entrance/exit animations&lt;/li&gt;
&lt;li&gt;⏳ &lt;strong&gt;Form Components&lt;/strong&gt; : Inputs, Checkboxes, Radio buttons with 3D styling&lt;/li&gt;
&lt;li&gt;⏳ &lt;strong&gt;Icon System&lt;/strong&gt; : Optional built-in icon support&lt;/li&gt;
&lt;li&gt;⏳ &lt;strong&gt;Enhanced Accessibility&lt;/strong&gt; : ARIA labels, keyboard navigation improvements&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📚 Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;Live Demo&lt;/strong&gt; : &lt;a href="https://fur-gaming.github.io/blocky-ui/" rel="noopener noreferrer"&gt;https://furic.github.io/blocky-ui/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;npm Package&lt;/strong&gt; : &lt;a href="https://www.npmjs.com/package/blocky-ui-lite" rel="noopener noreferrer"&gt;blocky-ui-lite&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐙 &lt;strong&gt;GitHub&lt;/strong&gt; : &lt;a href="https://github.com/furic/blocky-ui" rel="noopener noreferrer"&gt;fuR-Gaming/blocky-ui&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 &lt;strong&gt;Documentation Wiki&lt;/strong&gt; : &lt;a href="https://github.com/furic/blocky-ui/wiki" rel="noopener noreferrer"&gt;GitHub Wiki&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🤝 Contributing
&lt;/h2&gt;

&lt;p&gt;Blocky UI Lite is open source and welcomes contributions! Whether it’s bug reports, feature requests, or pull requests – all contributions are appreciated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clone the repository&lt;/span&gt;
git clone https://github.com/fuR-Gaming/blocky-ui.git

&lt;span class="c"&gt;# Install dependencies&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# Start development server&lt;/span&gt;
npm run dev

&lt;span class="c"&gt;# Build the library&lt;/span&gt;
npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  📄 License
&lt;/h2&gt;

&lt;p&gt;MIT License – Free to use in personal and commercial projects. No attribution required (but always appreciated! 🙏)&lt;/p&gt;

&lt;h2&gt;
  
  
  🎉 Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Blocky UI Lite&lt;/strong&gt; brings a unique aesthetic to web development – one that’s perfect for gaming interfaces, casino applications, and any project that wants to stand out with bold, 3D styling. With zero dependencies, full TypeScript support, and pure CSS effects, it’s both powerful and lightweight.&lt;/p&gt;

&lt;p&gt;Give it a try in your next project, and let us know what you build with it!&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with ❤️ by fuR Gaming | Powered by Claude Code 🤖&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/introducing-blocky-ui-lite-a-3d-blocky-themed-component-library/" rel="noopener noreferrer"&gt;Introducing Blocky UI Lite: A 3D Blocky-Themed Component Library 🎮&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>zerodependencies</category>
      <category>3deffects</category>
      <category>componentlibrary</category>
    </item>
  </channel>
</rss>
