<?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: Richard Fu</title>
    <description>The latest articles on DEV Community by Richard Fu (@furic).</description>
    <link>https://dev.to/furic</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F496204%2F16f7c90a-db34-4f71-aec8-bd491e334b4d.jpg</url>
      <title>DEV Community: Richard Fu</title>
      <link>https://dev.to/furic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/furic"/>
    <language>en</language>
    <item>
      <title>The FTX Collapse Had Warnings. An LLM Could Have Caught Them.</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Thu, 05 Mar 2026 12:58:41 +0000</pubDate>
      <link>https://dev.to/furic/the-ftx-collapse-had-warnings-an-llm-could-have-caught-them-hop</link>
      <guid>https://dev.to/furic/the-ftx-collapse-had-warnings-an-llm-could-have-caught-them-hop</guid>
      <description>&lt;p&gt;&lt;strong&gt;Turning RSS feeds, Google Gemini, and a GitHub cron job into an early warning system for crypto exchange risk.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In November 2022, a cascade of news headlines told the story of FTX’s collapse in slow motion. Alameda’s balance sheet leaked on November 2nd. Binance announced it was dumping FTT on the 6th. By the 8th, withdrawal delays were making headlines. On the 11th, FTX filed for bankruptcy.&lt;/p&gt;

&lt;p&gt;Nine days. The signals were public, scattered across CoinTelegraph, Decrypt, The Block, and Google News. But most people — myself included — weren’t synthesizing that information fast enough. The problem wasn’t access. It was attention.&lt;/p&gt;

&lt;p&gt;That observation sat with me for a while. Not as a regret, but as an engineering question: &lt;strong&gt;what would it take to automate the “paying attention” part?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer turned out to be surprisingly small. About 450 lines of TypeScript, three npm dependencies, and an LLM that’s good at reading headlines.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bet: LLMs as Risk Filters, Not Risk Analyzers
&lt;/h2&gt;

&lt;p&gt;There’s a temptation to over-scope AI in financial applications — building trading bots, predicting prices, replacing analysts. Most of those projects fail because they ask the AI to be &lt;em&gt;right&lt;/em&gt; about uncertain things.&lt;/p&gt;

&lt;p&gt;Crypto Sentinel takes a different bet: use the LLM as a &lt;strong&gt;filter&lt;/strong&gt; , not an oracle. It doesn’t predict what will happen. It reads a batch of headlines and answers one narrow question: &lt;em&gt;does this collection of news suggest elevated risk for the exchanges I hold funds on?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That’s a classification task, not a prediction task. And classification is something current LLMs do reliably.&lt;/p&gt;

&lt;p&gt;The system defines five risk tiers with explicit criteria:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Trigger Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Critical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Insolvency, confirmed hack, withdrawal freeze, regulatory shutdown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;High&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Major security breach, regulatory enforcement, suspected bank run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Medium&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Regulatory investigation, partnership failure, leadership departure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Low&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minor negative press, market downturn, routine operational changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;None&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Neutral or positive coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only &lt;code&gt;medium&lt;/code&gt; and above triggers an alert. This single design choice — an aggressive threshold — is what prevents the system from becoming noise. Every notification that reaches my phone is worth reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture: Six Stages, No Server
&lt;/h2&gt;

&lt;p&gt;The entire pipeline runs as a scheduled GitHub Actions job, four times a day. There’s no always-on server, no database, no container orchestration. Here’s the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
RSS Feeds ──&amp;gt; Keyword Filter ──&amp;gt; Dedup Cache ──&amp;gt; Gemini Analysis ──&amp;gt; Alerts
 (5 sources (configurable (MD5 of URL, (structured (email via
 + Google watchlist) last 500) JSON output) Resend +
   News) Telegram)

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

&lt;/div&gt;



&lt;p&gt;Each stage is a single TypeScript module. The orchestrator in &lt;code&gt;index.ts&lt;/code&gt; is 59 lines — a &lt;code&gt;for&lt;/code&gt; loop with error handling. Let’s walk through the interesting parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Aggregation: RSS + Google News with a Redirect Trap
&lt;/h3&gt;

&lt;p&gt;Four crypto-focused RSS feeds provide the baseline coverage:&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;RSS_FEEDS&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CoinTelegraph&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cointelegraph.com/rss&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Decrypt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://decrypt.co/feed&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The Block&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.theblock.co/rss.xml&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CryptoSlate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cryptoslate.com/feed/&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google News adds broader coverage, dynamically querying for each watched keyword (“bybit crypto”, “youhodler crypto”, etc.). But there’s a trap here that took some debugging.&lt;/p&gt;

&lt;p&gt;Google News RSS doesn’t serve direct article URLs. Every link is a redirect wrapper: &lt;code&gt;news.google.com/rss/articles/CBMi...&lt;/code&gt;. In a browser, this transparently redirects to the real article. But email services like Resend wrap outbound links in their own click-tracking redirect. So clicking a link in the email creates a &lt;strong&gt;double redirect&lt;/strong&gt; : Resend → Google News → actual article. Google interprets that chain as bot traffic and blocks it with a CAPTCHA page.&lt;/p&gt;

&lt;p&gt;The fix resolves Google News URLs at ingestion time, before they ever reach the email:&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;const&lt;/span&gt; &lt;span class="nx"&gt;resolveGoogleNewsUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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;try&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HEAD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;follow&lt;/span&gt;&lt;span class="dl"&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;manual&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;location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;location&lt;/span&gt;&lt;span class="dl"&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;location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fall through */&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="nx"&gt;url&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;The HEAD-first approach minimizes bandwidth. If the server doesn’t support HEAD (some don’t), it falls back to a manual redirect extraction. Worst case, the original Google News URL passes through unchanged.&lt;/p&gt;

&lt;p&gt;All resolutions happen in parallel via &lt;code&gt;Promise.all&lt;/code&gt;, so this doesn’t meaningfully slow down the pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deduplication: MD5 Hashing with a Rolling Window
&lt;/h3&gt;

&lt;p&gt;Each article is identified by the MD5 hash of its resolved URL. A JSON file caches the last 500 seen hashes — roughly a week of coverage at current volume. The cache is persisted between GitHub Actions runs using &lt;code&gt;actions/cache@v4&lt;/code&gt; with a rolling key strategy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache.json&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sentinel-cache-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sentinel-cache-&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Every run creates a new cache key (by run ID), but restores from the most recent one. This means the cache auto-updates without manual pruning of stale keys.&lt;/p&gt;

&lt;p&gt;Why not a database? Because a 16 KB JSON file with 500 hex strings doesn’t need one. The entire state model fits in a single &lt;code&gt;fs.readFileSync&lt;/code&gt; call.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Gemini Prompt: Structured Output via Role-Playing
&lt;/h3&gt;

&lt;p&gt;The prompt engineering is deliberate. Rather than asking Gemini to “analyze these headlines,” it’s given an explicit role and output contract:&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;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`You are a crypto-exchange risk analyst. Given these headlines
about crypto exchanges, assess the overall risk level.

Risk levels:
- CRITICAL: insolvency, confirmed hack, withdrawal freeze, regulatory shutdown
- HIGH: major security breach, regulatory enforcement, suspected bank run
- MEDIUM: regulatory warning, partnership failure, major leadership departure
- LOW: minor negative press, market downturn, routine changes
- NONE: neutral news, positive coverage

Headlines:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;headlines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&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="s2"&gt;`&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="mi"&gt;1&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;h&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;

Return ONLY valid JSON:
{ "risk_level": "...", "summary": "1-2 sentence summary", "alerts": ["concern 1", ...] }`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Three things matter here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enumerated risk levels with examples&lt;/strong&gt; — removes ambiguity about what “high” means&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbered headlines&lt;/strong&gt; — helps the model reference specific items in its summary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Return ONLY valid JSON”&lt;/strong&gt; — reduces the chance of markdown wrappers or preamble text&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When JSON parsing fails (it happens ~2% of the time with Flash models), the system falls back to &lt;code&gt;low&lt;/code&gt; risk with a manual review flag rather than crashing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alert Formatting: Color-Coded HTML for Fast Scanning
&lt;/h3&gt;

&lt;p&gt;The email alert is designed to be scannable in under 10 seconds. A color-coded header immediately communicates severity:&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%2Fagd54at9wvnab5w6pvtg.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%2Fagd54at9wvnab5w6pvtg.png" alt="Crypto Sentinel email alert showing MEDIUM risk with AI summary and source articles"&gt;&lt;/a&gt;&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;const&lt;/span&gt; &lt;span class="nx"&gt;riskColors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RiskLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#dc2626&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Red — immediate action&lt;/span&gt;
  &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ea580c&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Orange — urgent attention&lt;/span&gt;
  &lt;span class="na"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#d97706&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Amber — worth investigating&lt;/span&gt;
  &lt;span class="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#65a30d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Green — low concern&lt;/span&gt;
  &lt;span class="na"&gt;none&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#6b7280&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Gray — informational&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Below the header: AI summary, specific concerns as bullets, and a table of source articles with direct links. Telegram gets a condensed version — same information hierarchy, fewer articles (10 vs 20), plain text formatting.&lt;/p&gt;

&lt;p&gt;Telegram is entirely optional and implemented with raw &lt;code&gt;fetch()&lt;/code&gt; against the Bot API. No SDK, no npm dependency. If the env vars aren’t configured, it silently skips. If the API call fails, the error is logged but doesn’t block the email alert.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resilience as a Feature
&lt;/h2&gt;

&lt;p&gt;A monitoring system that crashes is worse than no monitoring system — it gives false confidence. Every stage in the pipeline is designed to degrade rather than fail:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One RSS feed times out&lt;/td&gt;
&lt;td&gt;Other feeds still process; warning logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google News returns 403&lt;/td&gt;
&lt;td&gt;Skipped; dedicated feeds provide baseline coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini returns invalid JSON&lt;/td&gt;
&lt;td&gt;Falls back to &lt;code&gt;low&lt;/code&gt; risk + manual review flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resend API error&lt;/td&gt;
&lt;td&gt;Hard fail (email is the primary channel — this &lt;em&gt;should&lt;/em&gt; be loud)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram API error&lt;/td&gt;
&lt;td&gt;Logged, not propagated; email already sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache file missing/corrupt&lt;/td&gt;
&lt;td&gt;Starts fresh; may re-alert on seen articles (acceptable)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only intentional hard failures are missing API keys for Gemini and Resend. Everything else bends rather than breaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack, and Why Each Piece
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Alternative Considered&lt;/th&gt;
&lt;th&gt;Why This One&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TypeScript (strict)&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Type safety for AI response parsing; catches schema mismatches at compile time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;GPT-4o-mini, Claude Haiku&lt;/td&gt;
&lt;td&gt;Generous rate limits on the free tier (250 req/day); sub-second for classification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Email&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;SendGrid, AWS SES&lt;/td&gt;
&lt;td&gt;Simplest API surface; works without domain verification via shared sender&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Messaging&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Telegram Bot API&lt;/td&gt;
&lt;td&gt;Slack, Discord&lt;/td&gt;
&lt;td&gt;Native mobile push; no OAuth dance; direct &lt;code&gt;fetch()&lt;/code&gt; with zero dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RSS parsing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;rss-parser&lt;/td&gt;
&lt;td&gt;feedparser, custom&lt;/td&gt;
&lt;td&gt;Handles RSS 2.0 and Atom; tolerant of malformed feeds; 10s timeout built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scheduling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions cron&lt;/td&gt;
&lt;td&gt;AWS Lambda, Vercel cron&lt;/td&gt;
&lt;td&gt;Secrets management built in; cache persistence built in; already where the code lives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Persistence&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSON file&lt;/td&gt;
&lt;td&gt;SQLite, Redis&lt;/td&gt;
&lt;td&gt;16 KB of data doesn’t justify a database; human-readable for debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total production dependencies: &lt;code&gt;@google/generative-ai&lt;/code&gt;, &lt;code&gt;resend&lt;/code&gt;, &lt;code&gt;rss-parser&lt;/code&gt;. That’s it. Everything else is native Node.js 22 (including &lt;code&gt;fetch&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas Worth Knowing
&lt;/h2&gt;

&lt;p&gt;A few things that weren’t obvious upfront:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google News RSS is region-locked.&lt;/strong&gt; The &lt;code&gt;ceid&lt;/code&gt; and &lt;code&gt;gl&lt;/code&gt; parameters control which regional edition you get. &lt;code&gt;AU:en&lt;/code&gt; returns Australian English results. If you’re watching for news about a Southeast Asian exchange, you might want &lt;code&gt;SG:en&lt;/code&gt; or multiple regional queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resend’s click tracking creates double redirects.&lt;/strong&gt; Any URL in a Resend email gets wrapped in a &lt;code&gt;resend-clicks.com&lt;/code&gt; tracking redirect. If the original URL is &lt;em&gt;also&lt;/em&gt; a redirect (like Google News), the target server may block the chained request. Always resolve redirects before including URLs in emails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM JSON output isn’t guaranteed.&lt;/strong&gt; Even with explicit “return ONLY valid JSON” instructions, Gemini occasionally wraps the response in markdown code fences or adds a preamble. The &lt;code&gt;JSON.parse&lt;/code&gt; call needs a &lt;code&gt;try/catch&lt;/code&gt; with a sensible fallback — not just for robustness, but because the failure mode (crashing at 3 AM with no alert) is worse than the degraded mode (a slightly less precise risk assessment).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Actions cron is approximate.&lt;/strong&gt; The &lt;code&gt;schedule&lt;/code&gt; trigger doesn’t guarantee exact timing — GitHub queues jobs, and during high load, runs can be delayed by 15-30 minutes. For a monitoring system that runs 4x daily, this is fine. For anything requiring precise timing, it’s not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;actions/cache&lt;/code&gt; has a 10 GB limit per repo.&lt;/strong&gt; With a rolling key strategy, old cache entries accumulate. For a 16 KB file this is irrelevant, but worth knowing if you extend the pattern to larger datasets.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Broader Pattern
&lt;/h2&gt;

&lt;p&gt;Strip away the crypto-specific parts and what remains is a general-purpose architecture for &lt;strong&gt;AI-augmented monitoring of public information&lt;/strong&gt; :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Aggregate&lt;/strong&gt; from multiple public data sources (RSS, APIs, web scraping)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter&lt;/strong&gt; by relevance criteria (keywords, rules, heuristics)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate&lt;/strong&gt; against a rolling history to avoid re-processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify&lt;/strong&gt; using an LLM with structured output and explicit criteria&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route&lt;/strong&gt; alerts conditionally based on severity thresholds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliver&lt;/strong&gt; through multiple channels with graceful degradation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This same pipeline could monitor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Competitor news&lt;/strong&gt; for a product team&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulatory filings&lt;/strong&gt; in a specific industry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security advisories&lt;/strong&gt; for your dependency stack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brand mentions&lt;/strong&gt; across news outlets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supply chain disruptions&lt;/strong&gt; for logistics operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LLM is the key differentiator from traditional keyword alerts. It can distinguish between &lt;em&gt;“Exchange X partners with major bank”&lt;/em&gt; (positive) and &lt;em&gt;“Exchange X under investigation by major bank regulator”&lt;/em&gt; (concerning) — something a keyword filter fundamentally cannot do.&lt;/p&gt;




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

&lt;p&gt;The project is open source and designed to be forked. Three API keys (all free-tier, no credit card), a few GitHub secrets, and you have a running monitor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/furic/crypto-sentinel" rel="noopener noreferrer"&gt;github.com/furic/crypto-sentinel&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://furic.github.io/crypto-sentinel/" rel="noopener noreferrer"&gt;furic.github.io/crypto-sentinel&lt;/a&gt;&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;# Local development&lt;/span&gt;
git clone https://github.com/furic/crypto-sentinel.git
&lt;span class="nb"&gt;cd &lt;/span&gt;crypto-sentinel &amp;amp;amp&lt;span class="p"&gt;;&lt;/span&gt;&amp;amp;amp&lt;span class="p"&gt;;&lt;/span&gt; npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env &lt;span class="c"&gt;# Add your API keys&lt;/span&gt;
npm run dev

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

&lt;/div&gt;



&lt;p&gt;The whole thing is ~450 lines of TypeScript. Read it in an afternoon, fork it, make it yours.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The next exchange collapse will have warning signs. The question is whether you’ll be reading headlines fast enough to notice them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/the-ftx-collapse-had-warnings-an-llm-could-have-caught-them/" rel="noopener noreferrer"&gt;The FTX Collapse Had Warnings. An LLM Could Have Caught Them.&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>crypto</category>
      <category>finance</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Built a Free AI Portfolio Assistant That Emails Me Every Morning</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Wed, 25 Feb 2026 14:57:44 +0000</pubDate>
      <link>https://dev.to/furic/i-built-a-free-ai-portfolio-assistant-that-emails-me-every-morning-3plm</link>
      <guid>https://dev.to/furic/i-built-a-free-ai-portfolio-assistant-that-emails-me-every-morning-3plm</guid>
      <description>&lt;p&gt;I wanted to start building my investment portfolio by the end of the year. The problem? I don’t have time to monitor stock prices, read financial news, and analyze market data every day — and I don’t have the expertise to do it well.&lt;/p&gt;

&lt;p&gt;What I wanted was simple: wake up, check my email, and know exactly what’s happening with my portfolio and what I should buy. And if something changes during the day — a price dip, a strengthening signal — I want to know before I miss it.&lt;/p&gt;

&lt;p&gt;I looked at a lot of open-source projects. Some did portfolio tracking. Some did news aggregation. Some did basic alerts. But none did everything I wanted, and none were flexible enough to customize the output. I wanted full control over what data gets analyzed, how the AI reasons about it, and exactly what gets sent to me.&lt;/p&gt;

&lt;p&gt;Most importantly — &lt;strong&gt;I wanted it free.&lt;/strong&gt; And it turns out that’s entirely achievable with Yahoo Finance, Gemini’s free tier, and GitHub Actions.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Richfolio&lt;/strong&gt; — a zero-maintenance AI portfolio assistant — with Claude in a few sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;p&gt;Richfolio is a single TypeScript pipeline. It runs once, produces a report, and exits. No API server, no database, no background processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every morning at 8am&lt;/strong&gt; , GitHub Actions triggers the pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetches live prices, P/E ratios, 52-week ranges, ETF holdings (Yahoo Finance)&lt;/li&gt;
&lt;li&gt;Computes technical indicators — SMA50, SMA200, RSI, momentum (Yahoo Finance chart data)&lt;/li&gt;
&lt;li&gt;Pulls news headlines per ticker (NewsAPI)&lt;/li&gt;
&lt;li&gt;Analyzes allocation gaps, P/E signals, ETF overlap against my target portfolio&lt;/li&gt;
&lt;li&gt;Sends everything to Gemini, which returns ranked buy recommendations with confidence scores, reasoning, and &lt;strong&gt;limit order prices&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Delivers via email and Telegram&lt;/li&gt;
&lt;/ol&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%2Flx63jqzauqpt64czczgm.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%2Flx63jqzauqpt64czczgm.png" alt="The daily morning brief email" width="800" height="1547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every 2–3 hours during market hours&lt;/strong&gt; , it re-runs in intraday mode — comparing against the morning baseline and only alerting me when signals strengthen. No change = no message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every Monday&lt;/strong&gt; , a weekly rebalancing report shows what’s drifted from target — BUY, TRIM, or OK.&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%2Fdfdk7jx1wjekg2hdlcfo.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%2Fdfdk7jx1wjekg2hdlcfo.png" alt="Daily brief, intraday alert, weekly rebalance" width="800" height="809"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline
&lt;/h2&gt;

&lt;p&gt;The whole system is one &lt;code&gt;index.ts&lt;/code&gt; that wires independent modules together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const tickers = allUniqueTickers();
const prices = await fetchAllPrices(tickers);
const report = runAnalysis(prices);

// Daily mode: full brief with news + AI + technicals
const [news, technicals] = await Promise.all([
  fetchNews(tickers),
  fetchTechnicals(tickers),
]);
const aiRecs = await aiAnalyze(report, prices, news, technicals);

// Save morning baseline for intraday comparison
saveBaseline({
  timestamp: new Date().toISOString(),
  recommendations: aiRecs,
  prices: priceMap,
});

await sendBrief(report, news, aiRecs, technicals);
await sendTelegramBrief(report, news, aiRecs, technicals);

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

&lt;/div&gt;



&lt;p&gt;Each module does one thing — fetch prices, compute technicals, run AI, send email. They communicate through typed interfaces and don’t know about each other. This makes it trivial to add new data sources or delivery channels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Indicators From Scratch
&lt;/h2&gt;

&lt;p&gt;I didn’t want to add a charting library dependency for what’s essentially just math. Richfolio fetches 250 days of daily price data from Yahoo Finance and computes everything directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
function computeSMA(prices: number[], period: number): number | null {
  if (prices.length &amp;lt; period) return null;
  const slice = prices.slice(-period);
  return slice.reduce((sum, p) =&amp;gt; sum + p, 0) / period;
}

function computeRSI(prices: number[], period: number = 14): number | null {
  if (prices.length &amp;lt; period + 1) return null;

  const recent = prices.slice(-(period + 1));
  let avgGain = 0;
  let avgLoss = 0;

  for (let i = 1; i &amp;lt; recent.length; i++) {
    const change = recent[i] - recent[i - 1];
    if (change &amp;gt; 0) avgGain += change;
    else avgLoss += Math.abs(change);
  }

  avgGain /= period;
  avgLoss /= period;

  if (avgLoss === 0) return 100;
  const rs = avgGain / avgLoss;
  return 100 - 100 / (1 + rs);
}

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

&lt;/div&gt;



&lt;p&gt;From these primitives, I classify each ticker’s momentum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
let momentumSignal: "bullish" | "bearish" | "neutral" = "neutral";

if (currentPrice &amp;gt; sma50 &amp;amp;&amp;amp; (sma200 == null || sma50 &amp;gt; sma200) &amp;amp;&amp;amp; rsi14 &amp;gt; 40) {
  momentumSignal = "bullish";
} else if (currentPrice &amp;lt; sma50 &amp;amp;&amp;amp; sma200 != null &amp;amp;&amp;amp; sma50 &amp;lt; sma200 &amp;amp;&amp;amp; rsi14 &amp;lt; 60) {
  momentumSignal = "bearish";
}

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

&lt;/div&gt;



&lt;p&gt;It also tracks 7-day and 30-day lows as support levels, and detects golden/death crosses (SMA50 crossing SMA200). All of this feeds into the AI prompt for smarter recommendations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI Prompt
&lt;/h2&gt;

&lt;p&gt;The AI layer is where everything comes together. Gemini receives the full context for every ticker — fundamentals, technicals, allocation data, and news — in a structured prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
function buildPrompt(report, priceData, news, technicals) {
  const tickerSummaries = report.items.map((item) =&amp;gt; {
    const quote = priceData[item.ticker];
    const tech = technicals[item.ticker];

    const lines = [
      `${item.ticker}:`,
      ` Price: $${item.price.toFixed(2)}`,
      ` Trailing P/E: ${quote?.trailingPE?.toFixed(1) ?? "N/A"}`,
      ` 52-week position: ${(item.fiftyTwoWeekPercent * 100).toFixed(0)}%`,
      ` Current allocation: ${item.currentPct.toFixed(1)}% (target: ${item.targetPct.toFixed(1)}%, gap: ${item.gapPct.toFixed(1)}%)`,
    ];

    if (tech) {
      lines.push(` Technical indicators:`);
      lines.push(` 50-day MA: $${tech.sma50} (price ${tech.priceVsSma50}% vs MA)`);
      lines.push(` RSI(14): ${tech.rsi14}`);
      lines.push(` Momentum: ${tech.momentumSignal}`);
      lines.push(` 7-day low: $${tech.recentLow7d}, 30-day low: $${tech.recentLow30d}`);
    }

    return lines.join("\n");
  });
  // ... instructions follow
}

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

&lt;/div&gt;



&lt;p&gt;The instructions tell Gemini to consider allocation need AND valuation together — a small gap with great valuation should rank above a large gap with poor valuation. It also asks for limit order prices based on nearby support levels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
8. For STRONG BUY and BUY tickers, suggest a limit order price slightly below
   current market. Base it on the nearest support level: 50-day MA, recent
   7d/30d low, or a round number.
9. Use technical indicators (MA, RSI, momentum) to refine confidence. A bullish
   momentum signal with oversold RSI strengthens a buy case.

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

&lt;/div&gt;



&lt;p&gt;Gemini returns structured JSON using a defined schema, so parsing is reliable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const response = await ai.models.generateContent({
  model: "gemini-2.5-flash",
  contents: prompt,
  config: {
    responseMimeType: "application/json",
    responseSchema,
  },
});

const recommendations = JSON.parse(response.text ?? "[]");

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

&lt;/div&gt;



&lt;p&gt;Each recommendation includes an action (STRONG BUY/BUY/HOLD/WAIT), confidence score, reasoning, suggested dollar amount, limit order price, and the reasoning behind the limit price. No free-text parsing needed.&lt;/p&gt;

&lt;p&gt;If Gemini is down or quota is exhausted, the system falls back to gap-based recommendations. The brief still gets delivered.&lt;/p&gt;

&lt;h2&gt;
  
  
  ETF Overlap Detection
&lt;/h2&gt;

&lt;p&gt;One feature I’m particularly proud of: if I hold individual stocks that are also inside my ETFs, the system detects the overlap and adjusts buy suggestions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// ETF overlap discount
if (quote.holdings &amp;amp;&amp;amp; suggestedBuyValue &amp;gt; 0) {
  for (const h of quote.holdings) {
    const heldShares = currentHoldings[h.symbol] ?? 0;
    const heldQuote = priceData[h.symbol];
    if (heldShares &amp;gt; 0 &amp;amp;&amp;amp; heldQuote) {
      const heldValue = heldShares * heldQuote.price;
      const etfExposure = h.holdingPercent * suggestedBuyValue;
      overlapDiscount += Math.min(etfExposure, heldValue);
    }
  }
  suggestedBuyValue -= overlapDiscount;
}

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

&lt;/div&gt;



&lt;p&gt;Example: VOO contains ~7% AAPL. If I hold $8,000 in AAPL and VOO’s suggested buy is $10,000, the overlap is min(7% × $10,000, $8,000) = $700. VOO’s suggestion drops to $9,300. This prevents over-concentrating in stocks I already hold directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intraday Alerts: Don’t Miss the Dip
&lt;/h2&gt;

&lt;p&gt;The morning brief is great, but markets move. The intraday system saves the morning AI recommendations as a baseline, then re-runs every 2–3 hours and compares:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const ACTION_RANK = { WAIT: 0, HOLD: 1, BUY: 2, "STRONG BUY": 3 };

for (const rec of currentRecs) {
  const morning = baselineMap.get(rec.ticker);
  const confidenceDelta = rec.confidence - (morning?.confidence ?? 0);
  const actionUpgraded = ACTION_RANK[rec.action] &amp;gt; ACTION_RANK[morning?.action ?? "WAIT"];

  // Trigger 1: Confidence jumped significantly
  if (confidenceDelta &amp;gt;= config.confidenceIncreaseThreshold &amp;amp;&amp;amp;
      rec.confidence &amp;gt;= config.minConfidenceToAlert) {
    triggerType = "confidence_increase";
  }

  // Trigger 2: Action upgraded (e.g., BUY → STRONG BUY)
  if (actionUpgraded &amp;amp;&amp;amp; rec.confidence &amp;gt;= config.minConfidenceToAlert) {
    triggerType = "action_upgrade";
  }

  // Trigger 3: New signal that wasn't in morning recs
  if (!morning &amp;amp;&amp;amp; rec.confidence &amp;gt;= config.minConfidenceToAlert) {
    triggerType = "new_signal";
  }
}

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

&lt;/div&gt;



&lt;p&gt;An alert fires only when something actually improved — confidence increased by 5+ points, an action upgraded, or a new strong signal appeared. All thresholds are configurable. No alert = no message. I only hear from it when it matters.&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%2Fy321b58v6ovkh1m68afr.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%2Fy321b58v6ovkh1m68afr.png" alt="An intraday alert when SMH's signal strengthened during the day" width="800" height="823"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack (All Free)
&lt;/h2&gt;

&lt;p&gt;This was a key constraint. I wanted zero recurring costs. Here’s what makes it possible:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Yahoo Finance&lt;/strong&gt; (yahoo-finance2)&lt;/td&gt;
&lt;td&gt;Prices, fundamentals, technicals, ETF holdings&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Google Gemini 2.5 Flash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AI recommendations + limit prices&lt;/td&gt;
&lt;td&gt;250 req/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NewsAPI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Headlines per ticker&lt;/td&gt;
&lt;td&gt;100 req/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTML email delivery&lt;/td&gt;
&lt;td&gt;3,000/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Telegram Bot API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Mobile alerts&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scheduled cron&lt;/td&gt;
&lt;td&gt;2,000 min/month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The runtime is &lt;strong&gt;TypeScript + Node.js&lt;/strong&gt; , executed with &lt;code&gt;tsx&lt;/code&gt; — no build step, no compilation. Each module is independent and the whole pipeline runs in ~30 seconds.&lt;/p&gt;

&lt;p&gt;For technical indicators, I computed SMA, RSI, and momentum from raw chart data — no charting library needed. For Telegram, I use native &lt;code&gt;fetch&lt;/code&gt; instead of adding an npm package. The whole project has only 4 runtime dependencies: &lt;code&gt;yahoo-finance2&lt;/code&gt;, &lt;code&gt;@google/genai&lt;/code&gt;, &lt;code&gt;resend&lt;/code&gt;, and &lt;code&gt;dotenv&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
src/
├── config.ts # Typed loader for config.json + .env
├── index.ts # Entry point — wires everything together
├── fetchPrices.ts # Yahoo Finance: price, P/E, 52w, beta, dividends, ETF holdings
├── fetchTechnicals.ts # Yahoo Finance chart: SMA50, SMA200, RSI, momentum
├── fetchNews.ts # NewsAPI with ticker-to-company-name mapping
├── analyze.ts # Allocation gaps, P/E signals, ETF overlap, portfolio metrics
├── aiAnalysis.ts # Gemini prompt builder + JSON schema + response parser
├── state.ts # Morning baseline save/load for intraday comparison
├── intradayCompare.ts # Compare current vs morning, detect strengthening
├── email.ts # Daily HTML email template + Resend delivery
├── intradayEmail.ts # Intraday alert email (triggered only)
├── weeklyEmail.ts # Weekly rebalancing email + Resend
└── telegram.ts # Telegram Bot API (daily + intraday + weekly formatters)

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

&lt;/div&gt;



&lt;p&gt;Every module exports typed interfaces (&lt;code&gt;QuoteData&lt;/code&gt;, &lt;code&gt;TechnicalData&lt;/code&gt;, &lt;code&gt;AllocationItem&lt;/code&gt;, &lt;code&gt;AIBuyRecommendation&lt;/code&gt;, &lt;code&gt;IntradayAlert&lt;/code&gt;) and communicates through them. No shared state, no side effects. If a new data source or delivery channel is needed, it’s just a new file that plugs into &lt;code&gt;index.ts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;Your portfolio is defined in a single &lt;code&gt;config.json&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;
{
  "targetPortfolio": {
    "VOO": 20,
    "QQQ": 15,
    "GLD": 10,
    "BSV": 20,
    "BTC": 1.5
  },
  "currentHoldings": {
    "AAPL": 30,
    "VOO": 1,
    "BTC": 0.0002
  },
  "totalPortfolioValueUSD": 50000
}

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

&lt;/div&gt;



&lt;p&gt;Target allocations are percentages. Current holdings include stocks not in your target (like AAPL above) — these are tracked for ETF overlap detection. Crypto tickers like BTC are automatically converted to BTC-USD for Yahoo Finance.&lt;/p&gt;

&lt;p&gt;In GitHub Actions, this file is stored as a repository variable (&lt;code&gt;CONFIG_JSON&lt;/code&gt;) so your portfolio data stays private.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;You don’t need a Bloomberg terminal.&lt;/strong&gt; Yahoo Finance freely provides everything from real-time prices to earnings history to ETF holdings to 250 days of chart data. The AI layer just synthesizes it into actionable recommendations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled pipelines are underrated.&lt;/strong&gt; No server, no uptime concerns, no costs. GitHub Actions runs the cron, the pipeline executes in ~30 seconds, and I get my email. If it fails, it just retries next time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free tiers are generous for personal tools.&lt;/strong&gt; I use ~3 of 3,000 monthly Resend emails and ~10 of 250 daily Gemini requests. The whole stack runs comfortably within free limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical data makes the AI smarter.&lt;/strong&gt; Adding SMA, RSI, and momentum to the Gemini prompt noticeably improved recommendations. Instead of just “the allocation gap is large,” the AI now says things like “price near 50-day MA support with RSI at 38 — good entry point for a limit order at $217.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured JSON output from Gemini is a game-changer.&lt;/strong&gt; Using &lt;code&gt;responseSchema&lt;/code&gt; means the AI returns exactly the fields I need — no regex parsing, no “oops it returned markdown this time.” Every response is type-safe and immediately usable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graceful degradation matters.&lt;/strong&gt; Every optional service (Gemini, NewsAPI, Telegram) can be missing or down, and the system still works. The brief adapts to what’s available instead of failing entirely.&lt;/p&gt;

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

&lt;p&gt;Richfolio is open source. Fork the repo, add your config and API keys, and you’ll have your own AI portfolio assistant by tomorrow morning. Setup takes about 10 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/furic/richfolio" rel="noopener noreferrer"&gt;github.com/furic/richfolio&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://furic.github.io/richfolio" rel="noopener noreferrer"&gt;furic.github.io/richfolio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It’s been running daily and I genuinely look forward to checking my email every morning now — which is probably the first time I’ve ever said that.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/i-built-a-free-ai-portfolio-assistant-that-emails-me-every-morning/" rel="noopener noreferrer"&gt;I Built a Free AI Portfolio Assistant That Emails Me Every Morning&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>finance</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>ai</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/furic/building-unmask-the-city-a-solo-game-jam-journey-with-ai-pair-programming-44b4</link>
      <guid>https://dev.to/furic/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" width="800" height="568"&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" width="800" height="568"&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" width="800" height="568"&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 plaintext"&gt;&lt;code&gt;
material.onBeforeCompile = (shader) =&amp;gt; {
  // Add custom uniforms
  shader.uniforms.fogMap = { value: fogTexture };

  // Inject custom fragment shader code
  shader.fragmentShader = shader.fragmentShader.replace(
    '#include &amp;lt;fog_fragment&amp;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;/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 plaintext"&gt;&lt;code&gt;
// BAD: 300 draw calls
buildings.forEach(building =&amp;gt; {
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.copy(building.position);
  scene.add(mesh);
});

&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 plaintext"&gt;&lt;code&gt;
// GOOD: 1 draw call
const instancedMesh = new THREE.InstancedMesh(geometry, material, 300);
buildings.forEach((building, i) =&amp;gt; {
  const matrix = new THREE.Matrix4();
  matrix.compose(building.position, rotation, building.scale);
  instancedMesh.setMatrixAt(i, matrix);
  instancedMesh.setColorAt(i, building.color);
});

&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 plaintext"&gt;&lt;code&gt;
// Find nearest fragment
let nearest = uncollected[0];
let minDist = Infinity;

uncollected.forEach(fragment =&amp;gt; {
  const dist = playerPos.distanceTo(fragment.getPosition());
  if (dist &amp;lt; minDist) {
    minDist = dist;
    nearest = fragment;
  }
});

// Calculate angle and rotate compass arrow
const dx = nearest.getPosition().x - playerPos.x;
const dz = nearest.getPosition().z - playerPos.z;
const angle = Math.atan2(dx, dz);

compassElement.style.transform = `rotate(${angle}rad)`;

&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 plaintext"&gt;&lt;code&gt;
playThunder() {
  const data = buffer.getChannelData(0);

  for (let i = 0; i &amp;lt; data.length; i++) {
    const t = i / sampleRate;

    // Envelope: quick attack, slow decay
    const env = Math.exp(-t * 1.5) * (1 - Math.exp(-t * 20));

    // Low-frequency rumble
    const rumble = Math.sin(t * 30 + Math.random() * 0.5) * 0.5;

    // High-frequency crack
    const crack = (Math.random() * 2 - 1);

    // Mix and apply envelope
    data[i] = (rumble + crack * 0.5) * env;
  }

  // Low-pass filter for deep rumble
  filter.type = 'lowpass';
  filter.frequency.value = 200 + Math.random() * 100;
}

&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 plaintext"&gt;&lt;code&gt;
function worldToMinimapPercent(worldPos: Vector3): { x: number, y: number } {
  const citySize = 400; // World spans -200 to +200

  // Normalize to 0-1, then convert to percentage
  const normalizedX = (worldPos.x + citySize / 2) / citySize;
  const normalizedZ = (worldPos.z + citySize / 2) / citySize;

  return { x: normalizedX * 100, y: normalizedZ * 100 };
}

// Position fragment dots
dot.style.left = `${pos.x}%`;
dot.style.top = `${pos.y}%`;

&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 plaintext"&gt;&lt;code&gt;
explode(position: Vector3) {
  for (let i = 0; i &amp;lt; particleCount; i++) {
    // Random direction on sphere
    const theta = Math.random() * Math.PI * 2;
    const phi = Math.random() * Math.PI;

    const velocity = new Vector3(
      Math.sin(phi) * Math.cos(theta),
      Math.sin(phi) * Math.sin(theta),
      Math.cos(phi)
    ).multiplyScalar(8 + Math.random() * 4);

    particles.push({ position, velocity, life: 1.0 });
  }
}

update(delta: number) {
  particles.forEach(p =&amp;gt; {
    p.position.add(p.velocity.clone().multiplyScalar(delta));
    p.velocity.y += -9.8 * delta; // Gravity
    p.life -= delta * 0.5; // Fade
  });
}

&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 / Raw Fun 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/furic/building-bubble-2048-a-technical-deep-dive-52o9</link>
      <guid>https://dev.to/furic/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%2Cformat=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%2Cformat=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/furic/unity-webgl-background-tabs-autoplay-performance-fix-1m9k</link>
      <guid>https://dev.to/furic/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/raw-fun-gaming/building-a-web-unity-webgl-bridge-a-practical-guide-3nbe</link>
      <guid>https://dev.to/raw-fun-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/raw-fun-gaming/introducing-blocky-ui-lite-a-3d-blocky-themed-component-library-58k3</link>
      <guid>https://dev.to/raw-fun-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 plaintext"&gt;&lt;code&gt;
/* Multi-layer box shadows create depth */
box-shadow:
    0 4px 0 rgba(0, 0, 0, 0.3), /* Base shadow */
    0 8px 16px rgba(0, 0, 0, 0.4), /* Far shadow */
    inset 0 2px 0 rgba(255, 255, 255, 0.2), /* Top highlight */
    inset 0 -2px 0 rgba(0, 0, 0, 0.3); /* Bottom shadow */

/* Gradient backgrounds with transparency */
background: linear-gradient(
    180deg,
    rgba(85, 223, 255, 0.95) 0%,
    rgba(85, 223, 255, 0.7) 50%,
    rgba(85, 223, 255, 0.5) 100%
);

/* Radial overlay for extra depth */
&amp;amp;::before {
    background: radial-gradient(
        circle at center,
        rgba(255, 255, 255, 0.2) 0%,
        transparent 70%
    );
}

&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 plaintext"&gt;&lt;code&gt;
import { BlockyUI, ComponentVariant } from 'blocky-ui-lite';

// TypeScript knows all available options
const button = BlockyUI.createButton({
    text: 'Click Me',
    variant: 'primary', // 'default' | 'primary' | 'secondary' | 'danger'
    onClick: () =&amp;gt; console.log('Clicked!'),
    disabled: false
});

&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%2FfuR-Gaming%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%2FfuR-Gaming%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 plaintext"&gt;&lt;code&gt;
&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;!-- CSS --&amp;gt;
    &amp;lt;link rel="stylesheet" href="https://unpkg.com/blocky-ui-lite@latest/dist/blocky-ui.css"&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div id="game-ui"&amp;gt;&amp;lt;/div&amp;gt;

    &amp;lt;!-- JavaScript --&amp;gt;
    &amp;lt;script src="https://unpkg.com/blocky-ui-lite@latest/dist/index.umd.js"&amp;gt;&amp;lt;/script&amp;gt;
    &amp;lt;script&amp;gt;
        const { BlockyUI } = window.BlockyUILite;

        // Create a game stats card
        const statsCard = BlockyUI.createCard({
            title: 'Player Stats',
            variant: 'primary',
            content: `
                &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;
            `
        });

        // Create multiplier tags
        const multiplierTag = BlockyUI.createTag({
            content: '×5.0',
            variant: 'secondary'
        });

        // Create action buttons
        const playButton = BlockyUI.createButton({
            text: 'PLAY NOW',
            variant: 'primary',
            onClick: () =&amp;gt; {
                BlockyUI.showNotification(
                    'Game Started!',
                    'Good luck and have fun!'
                );
            }
        });

        // Add to page
        const container = document.getElementById('game-ui');
        container.appendChild(statsCard);
        container.appendChild(playButton);
    &amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;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 plaintext"&gt;&lt;code&gt;
// Factory method creates instance
const modal = BlockyUI.createModal({
    title: 'Confirm Action',
    content: 'Are you sure?',
    buttons: [
        { text: 'Cancel', variant: 'default', onClick: () =&amp;gt; {} },
        { text: 'Confirm', variant: 'primary', onClick: () =&amp;gt; {} }
    ]
});

// Instance methods for control
modal.show(); // Display the modal
modal.close(); // Close programmatically

// Or use convenience methods (auto-shown)
BlockyUI.showNotification('Success!', 'Operation completed.');
BlockyUI.showError('Error!', 'Something went wrong.');
BlockyUI.showConfirmation('Delete?', 'This cannot be undone.', onConfirm, onCancel);

&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 plaintext"&gt;&lt;code&gt;
:root {
    /* Colors */
    --blocky-primary: #55dfff;
    --blocky-danger: #ff4444;

    /* 3D Effects */
    --blocky-shadow-base: 0 4px 0 rgba(0, 0, 0, 0.3);
    --blocky-shadow-far: 0 8px 16px rgba(0, 0, 0, 0.4);

    /* Spacing */
    --blocky-padding-md: 12px;
    --blocky-border-radius: 6px;

    /* Z-Index Layers */
    --blocky-z-content: 10;
    --blocky-z-dropdown: 100;
    --blocky-z-overlay-modal: 900;
}

&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 plaintext"&gt;&lt;code&gt;
const button = BlockyUI.createButton({
    text: 'START GAME',
    variant: 'primary',
    onClick: () =&amp;gt; startGame(),
    disabled: false
});

&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 plaintext"&gt;&lt;code&gt;
const modal = BlockyUI.createModal({
    title: 'Game Over',
    content: 'You scored 1,234 points!',
    showCloseButton: true,
    buttons: [
        { text: 'Play Again', variant: 'primary', onClick: restart }
    ]
});

modal.show(); // Display when ready

&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 plaintext"&gt;&lt;code&gt;
const card = BlockyUI.createCard({
    title: 'Daily Rewards',
    variant: 'secondary',
    content: 'Come back tomorrow for more coins!'
});

&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 plaintext"&gt;&lt;code&gt;
const tag = BlockyUI.createTag({
    content: '×2.5',
    variant: 'danger' // Red for high multipliers!
});

&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 plaintext"&gt;&lt;code&gt;
const info = BlockyUI.createInfo({
    title: 'Achievement Unlocked!',
    titleColor: 'yellow',
    content: 'You reached level 10!'
});

document.body.appendChild(info);

&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 plaintext"&gt;&lt;code&gt;
const page = BlockyUI.createPage({
    content: `
        &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;
    `,
    onClose: () =&amp;gt; console.log('Rules closed')
});

page.show();

&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 plaintext"&gt;&lt;code&gt;
# npm
npm install blocky-ui-lite

# yarn
yarn add blocky-ui-lite

# pnpm
pnpm add blocky-ui-lite



// Import CSS (required)
import 'blocky-ui-lite/styles';

// Import components
import { BlockyUI } from 'blocky-ui-lite';

// Use in your app
const button = BlockyUI.createButton({
    text: 'Click Me',
    variant: 'primary'
});

&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 plaintext"&gt;&lt;code&gt;
&amp;lt;!-- CSS --&amp;gt;
&amp;lt;link rel="stylesheet" href="https://unpkg.com/blocky-ui-lite@latest/dist/blocky-ui.css"&amp;gt;

&amp;lt;!-- JavaScript (UMD) --&amp;gt;
&amp;lt;script src="https://unpkg.com/blocky-ui-lite@latest/dist/index.umd.js"&amp;gt;&amp;lt;/script&amp;gt;

&amp;lt;script&amp;gt;
    const { BlockyUI } = window.BlockyUILite;
    // Use BlockyUI here
&amp;lt;/script&amp;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 plaintext"&gt;&lt;code&gt;
import { useEffect, useRef } from 'react';
import { BlockyUI } from 'blocky-ui-lite';
import 'blocky-ui-lite/styles';

function GameButton() {
    const containerRef = useRef&amp;lt;HTMLDivElement&amp;gt;(null);

    useEffect(() =&amp;gt; {
        if (containerRef.current) {
            const button = BlockyUI.createButton({
                text: 'PLAY',
                variant: 'primary',
                onClick: () =&amp;gt; console.log('Game started!')
            });
            containerRef.current.appendChild(button);
        }
    }, []);

    return &amp;lt;div ref={containerRef} /&amp;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 plaintext"&gt;&lt;code&gt;
&amp;lt;script setup lang="ts"&amp;gt;
import { onMounted, ref } from 'vue';
import { BlockyUI } from 'blocky-ui-lite';
import 'blocky-ui-lite/styles';

const containerRef = ref&amp;lt;HTMLDivElement | null&amp;gt;(null);

onMounted(() =&amp;gt; {
    if (containerRef.value) {
        const button = BlockyUI.createButton({
            text: 'PLAY',
            variant: 'primary',
            onClick: () =&amp;gt; console.log('Game started!')
        });
        containerRef.value.appendChild(button);
    }
});
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
    &amp;lt;div ref="containerRef"&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;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 plaintext"&gt;&lt;code&gt;
&amp;lt;script lang="ts"&amp;gt;
    import { onMount } from 'svelte';
    import { BlockyUI } from 'blocky-ui-lite';
    import 'blocky-ui-lite/styles';

    let container: HTMLDivElement;

    onMount(() =&amp;gt; {
        const button = BlockyUI.createButton({
            text: 'PLAY',
            variant: 'primary',
            onClick: () =&amp;gt; console.log('Game started!')
        });
        container.appendChild(button);
    });
&amp;lt;/script&amp;gt;

&amp;lt;div bind:this={container}&amp;gt;&amp;lt;/div&amp;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://fur-gaming.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/fuR-Gaming/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/fuR-Gaming/blocky-ui/wiki" rel="noopener noreferrer"&gt;GitHub Wiki&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🎯 &lt;strong&gt;Stack Rush&lt;/strong&gt; : Original game that inspired the design&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 plaintext"&gt;&lt;code&gt;# Clone the repository
git clone https://github.com/fuR-Gaming/blocky-ui.git

# Install dependencies
npm install

# Start development server
npm run dev

# Build the library
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>
    <item>
      <title>Planet Blue Invasion: Building the Future of Casino Gaming with WebGPU and Three.js</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Fri, 12 Sep 2025 14:03:36 +0000</pubDate>
      <link>https://dev.to/raw-fun-gaming/planet-blue-invasion-building-the-future-of-casino-gaming-with-webgpu-and-threejs-45l</link>
      <guid>https://dev.to/raw-fun-gaming/planet-blue-invasion-building-the-future-of-casino-gaming-with-webgpu-and-threejs-45l</guid>
      <description>&lt;p&gt;&lt;em&gt;How we built a cutting-edge 3D Earth simulation casino game using modern web technologies&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.richardfu.net/planet-blue-invasion-building-the-future-of-casino-gaming-with-webgpu-and-three-js/" rel="noopener noreferrer"&gt;View Post&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As a game developer, I’m excited to share the technical journey behind &lt;strong&gt;Planet Blue Invasion&lt;/strong&gt; , now live on &lt;a href="https://stake.com/zh/casino/games/furgaming-planet-blue-invasion" rel="noopener noreferrer"&gt;Stake.com&lt;/a&gt;. This isn’t just another casino game—it’s a showcase of what’s possible when you combine modern web graphics technology with innovative game design.&lt;/p&gt;

&lt;h2&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%2F2lcr1gkrvemv2svi2k54.png" alt="🌍" width="72" height="72"&gt; The Vision: Alien Invasion Meets Casino Gaming
&lt;/h2&gt;

&lt;p&gt;Planet Blue Invasion puts players in the role of elite alien commanders aboard a war-class spaceship. The gameplay is deceptively simple yet thrilling: spin to target random Earth locations, fire orbital lasers, and earn payouts based on real population density data. Hit Shanghai? Massive payout. Strike the Pacific Ocean? Zero reward.&lt;/p&gt;

&lt;p&gt;What makes this special is the underlying technology that creates an immersive, visually stunning experience that feels more like a AAA game than traditional casino fare.&lt;/p&gt;

&lt;h2&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%2Fwip4140thkpy78q7t7fv.png" alt="⚡" width="72" height="72"&gt; The Tech Stack: Pushing Web Graphics Forward
&lt;/h2&gt;

&lt;h3&gt;
  
  
  WebGPU: The Graphics Revolution
&lt;/h3&gt;

&lt;p&gt;At the heart of Planet Blue Invasion is &lt;strong&gt;WebGPU&lt;/strong&gt; —the next-generation graphics API for the web. Unlike WebGL, WebGPU provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native GPU Performance&lt;/strong&gt; : Direct access to modern GPU features with significantly lower overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced Shading&lt;/strong&gt; : Complex material systems with better performance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Future-Proof Architecture&lt;/strong&gt; : Built for modern GPUs and rendering pipelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We’re using Three.js’s WebGPU renderer, which puts us at the forefront of web graphics technology. The Earth you see isn’t just a textured sphere—it’s a multi-layered planetary system with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dynamic day/night cycles&lt;/li&gt;
&lt;li&gt;Realistic atmospheric scattering&lt;/li&gt;
&lt;li&gt;Volumetric cloud rendering&lt;/li&gt;
&lt;li&gt;Surface detail mapping with bump effects&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  TSL (Three Shading Language): Node-Based Materials
&lt;/h3&gt;

&lt;p&gt;One of the most exciting aspects of development was using &lt;strong&gt;TSL (Three Shading Language)&lt;/strong&gt;. This node-based material system allows us to create complex shaders visually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// TSL material example for Earth's day/night blending
const earthMaterial = new TSLMaterial({
  nodes: {
    diffuse: mix(
      texture(dayTexture, uv()),
      texture(nightTexture, uv()),
      sunDirection.dot(normal).add(0.5)
    ),
    normal: texture(bumpTexture, uv()).xyz.mul(2).sub(1)
  }
});

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

&lt;/div&gt;



&lt;p&gt;This approach gives us real-time material editing capabilities and better performance than traditional GLSL shaders.&lt;/p&gt;

&lt;h3&gt;
  
  
  TypeScript: Type Safety in Complex Systems
&lt;/h3&gt;

&lt;p&gt;With a game this complex, &lt;strong&gt;TypeScript&lt;/strong&gt; was essential. Our architecture includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strict typing&lt;/strong&gt; for 3D math operations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interface contracts&lt;/strong&gt; between game systems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compile-time safety&lt;/strong&gt; for API integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The population data system, for example, handles millions of coordinate lookups with complete type safety:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
interface LocationData {
  latitude: number;
  longitude: number;
  population: number;
  city: string;
  country: string;
  payoutMultiplier: number;
}

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

&lt;/div&gt;



&lt;h2&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%2Fihygdnfxaaahzqddtzya.png" alt="🎮" width="72" height="72"&gt; The fuR Gaming Engine: Framework-Agnostic Architecture
&lt;/h2&gt;

&lt;p&gt;Planet Blue Invasion is built on our proprietary &lt;strong&gt;fuR Gaming Engine&lt;/strong&gt; —a framework-agnostic system designed specifically for casino games. The engine provides:&lt;/p&gt;

&lt;h3&gt;
  
  
  Internationalization (i18n) System
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;16 language support&lt;/strong&gt; including RTL languages like Arabic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic loading&lt;/strong&gt; with fallback chains&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cultural adaptation&lt;/strong&gt; for different markets&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Audio System
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spatial audio effects&lt;/strong&gt; for orbital laser sounds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic music&lt;/strong&gt; that responds to game states&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Howler.js integration&lt;/strong&gt; for web audio optimization&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Math &amp;amp; RTP System
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provably fair mathematics&lt;/strong&gt; with 97% RTP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weight distribution algorithms&lt;/strong&gt; for balanced gameplay&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Population-based payout calculations&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&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%2Fhrg3gvhf9v8fub7w3gic.png" alt="🌐" width="72" height="72"&gt; Real-World Data Integration
&lt;/h2&gt;

&lt;p&gt;One of the most challenging aspects was integrating real population data. Our system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uses GeoNames database&lt;/strong&gt; with 32,283 cities having 15K+ population&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries random coordinates&lt;/strong&gt; via BigDataCloud and Nominatim APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validates location data&lt;/strong&gt; – locations without city/country data become “empty payout” entries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calculates realistic payouts&lt;/strong&gt; based on actual population density from GeoNames&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The formula is elegantly simple: &lt;code&gt;Math.round((population / 100000) * 100)&lt;/code&gt;, making Shanghai, 24.87M population, the theoretical maximum payout at 248.74x.&lt;/p&gt;

&lt;h2&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%2F0fjgr7bha1n1hjuxtxwv.png" alt="🚀" width="72" height="72"&gt; Performance Optimizations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Asset Management
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Relative path architecture&lt;/strong&gt; for CDN compatibility&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Texture optimization&lt;/strong&gt; with 4K Earth textures compressed efficiently&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progressive loading&lt;/strong&gt; for smooth startup experience&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rendering Pipeline
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Level-of-detail (LOD) systems&lt;/strong&gt; for Earth surface detail&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frustum culling&lt;/strong&gt; for off-screen elements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch rendering&lt;/strong&gt; for UI elements&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&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%2Fpm9bfmo5g7sp2e04z1vq.png" alt="🎯" width="72" height="72"&gt; The Casino Gaming Mathematics
&lt;/h2&gt;

&lt;p&gt;Behind the stunning visuals lies sophisticated gambling mathematics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Target RTP: 97%&lt;/strong&gt; – Industry-leading return to player&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hit Rate: 33%&lt;/strong&gt; – Balanced win frequency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier Distribution&lt;/strong&gt; :

&lt;ul&gt;
&lt;li&gt;No Win: 67% (Ocean/uninhabited)&lt;/li&gt;
&lt;li&gt;Small Wins: 23.1% (1x-10x)&lt;/li&gt;
&lt;li&gt;Medium Wins: 8.25% (10x-100x)&lt;/li&gt;
&lt;li&gt;Big Wins: 1.65% (100x+)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;Our analytics system continuously verifies these mathematics to ensure fair, engaging gameplay.&lt;/p&gt;

&lt;h2&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%2F3ovay7f5094jnq0qf9c2.png" alt="🌟" width="72" height="72"&gt; Bonus Features: Super Spin Mode – Human Radar
&lt;/h2&gt;

&lt;p&gt;The “Super Spin Mode: Human Radar” showcases our engine’s capabilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smart Targeting&lt;/strong&gt; – Advanced alien technology detects human settlements before firing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guaranteed Impact&lt;/strong&gt; – Every shot hits a populated zone, no wasted ammunition&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Premium Cost&lt;/strong&gt; – Activate for 2x base bet to access elite targeting system&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher Rewards&lt;/strong&gt; – Focus destruction on densely populated areas for maximum devastation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&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%2Fw6n1fekf9sk082jqtwi7.png" alt="🔧" width="72" height="72"&gt; Development Workflow
&lt;/h2&gt;

&lt;p&gt;Our development setup prioritizes modern tooling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vite&lt;/strong&gt; for lightning-fast development builds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local Three.js builds&lt;/strong&gt; for WebGPU feature access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GLSL shader integration&lt;/strong&gt; with hot reloading&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated testing&lt;/strong&gt; for gambling mathematics verification&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&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%2Fp02smn7o2y1tl0r7pkby.png" alt="🎉" width="72" height="72"&gt; Play Planet Blue Invasion Today
&lt;/h2&gt;

&lt;p&gt;Planet Blue Invasion represents what’s possible when you combine cutting-edge web technology with innovative game design. Every spin is a journey through real Earth data, every win is backed by provably fair mathematics, and every visual effect is rendered using the latest WebGPU technology.&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%2Fyqjaui9tsmlra11m5089.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%2Fyqjaui9tsmlra11m5089.jpg" alt="Stake New Release - Planet Blue Invasion" width="782" height="1024"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://stake.com/casino/games/furgaming-planet-blue-invasion" rel="noopener noreferrer"&gt;Experience Planet Blue Invasion on Stake.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Whether you’re a fellow developer curious about WebGPU implementation, a gaming enthusiast looking for the next evolution in casino games, or someone who simply enjoys blowing up virtual Earth locations for profit—Planet Blue Invasion delivers.&lt;/p&gt;

&lt;h2&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%2F746uwh5mj78jfvfjigmn.png" alt="🛠" width="72" height="72"&gt; Technical Resources
&lt;/h2&gt;

&lt;p&gt;For developers interested in the technologies used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://threejs.org/" rel="noopener noreferrer"&gt;Three.js WebGPU Renderer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/gpuweb/gpuweb" rel="noopener noreferrer"&gt;WebGPU Specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.typescriptlang.org/" rel="noopener noreferrer"&gt;TypeScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vitejs.dev/" rel="noopener noreferrer"&gt;Vite Build Tool&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Ready to join the alien invasion? The Earth is waiting… &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%2Fpxklodq1vriqsfyebjm9.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%2Fpxklodq1vriqsfyebjm9.png" alt="👽" width="72" height="72"&gt;&lt;/a&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%2F2lcr1gkrvemv2svi2k54.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%2F2lcr1gkrvemv2svi2k54.png" alt="🌍" width="72" height="72"&gt;&lt;/a&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%2Fwip4140thkpy78q7t7fv.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%2Fwip4140thkpy78q7t7fv.png" alt="⚡" width="72" height="72"&gt;&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/planet-blue-invasion-building-the-future-of-casino-gaming-with-webgpu-and-three-js/" rel="noopener noreferrer"&gt;Planet Blue Invasion: Building the Future of Casino Gaming with WebGPU and Three.js&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>Introducing Cosmic UI Lite: A Zero-Dependency Space-Themed UI Library</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Mon, 01 Sep 2025 09:30:55 +0000</pubDate>
      <link>https://dev.to/raw-fun-gaming/introducing-cosmic-ui-lite-a-zero-dependency-space-themed-ui-library-20bi</link>
      <guid>https://dev.to/raw-fun-gaming/introducing-cosmic-ui-lite-a-zero-dependency-space-themed-ui-library-20bi</guid>
      <description>&lt;p&gt;Ever needed a futuristic, sci-fi UI for your web project but found existing solutions too heavyweight or framework-dependent? That’s exactly the problem I faced when building my game project. Today, I’m excited to introduce &lt;strong&gt;Cosmic UI Lite&lt;/strong&gt; – a lightweight, zero-dependency TypeScript UI component library designed for space-themed interfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🌟 &lt;a href="https://fur-gaming.github.io/cosmic-ui-lite/" rel="noopener noreferrer"&gt;Try the Live Demo&lt;/a&gt;&lt;/strong&gt; | &lt;strong&gt;📦 &lt;a href="https://www.npmjs.com/package/cosmic-ui-lite" rel="noopener noreferrer"&gt;NPM Package&lt;/a&gt;&lt;/strong&gt; | &lt;strong&gt;📚 &lt;a href="https://github.com/fuR-Gaming/cosmic-ui-lite" 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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq1sclf9q0ckxp1y0dahy.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%2Fq1sclf9q0ckxp1y0dahy.jpg" alt="Cosmic UI Lite modal screenshot" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 The Motivation
&lt;/h2&gt;

&lt;p&gt;While working on my game project, I discovered &lt;a href="https://github.com/rizkimuhammada/cosmic-ui" rel="noopener noreferrer"&gt;rizkimuhammada/cosmic-ui&lt;/a&gt; – a beautiful cosmic-themed UI library. However, it had some limitations for my use case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React Dependency&lt;/strong&gt; : My game was built with vanilla TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature Bloat&lt;/strong&gt; : I only needed a handful of essential components&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle Size&lt;/strong&gt; : Needed something lightweight for game performance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build Complexity&lt;/strong&gt; : Wanted a simple, drop-in solution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I decided to create a focused alternative that prioritizes simplicity, performance, and universal compatibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛠️ The Technology Behind Cosmic UI Lite
&lt;/h2&gt;

&lt;h3&gt;
  
  
  SVG-Based Dynamic Rendering
&lt;/h3&gt;

&lt;p&gt;The heart of Cosmic UI Lite lies in its dynamic SVG generation system. Instead of using static images or complex CSS tricks, every component creates its cosmic borders and backgrounds programmatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// SVG elements are created on-the-fly
const backgroundSvg = createSvgElement('cosmic-bg', '0 0 474 332');
const gradient = createGradient('cosmicGradient', [
  { offset: '0%', color: '#1a1a2e' },
  { offset: '50%', color: '#2a2a4e' },
  { offset: '100%', color: '#1a1a2e' }
]);

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

&lt;/div&gt;



&lt;p&gt;This approach provides several advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scalability&lt;/strong&gt; : Vector graphics look crisp at any size&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customization&lt;/strong&gt; : Colors and gradients can be modified programmatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt; : No external image dependencies to load&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency&lt;/strong&gt; : Unified shape language across all components&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Four-Layer Architecture
&lt;/h3&gt;

&lt;p&gt;Each component follows a consistent 4-layer structure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrapper Element&lt;/strong&gt; : Container with positioning and hover effects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG Background&lt;/strong&gt; : Animated gradient fill with cosmic patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG Border&lt;/strong&gt; : Glowing outline that responds to interactions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content Layer&lt;/strong&gt; : Text, buttons, and interactive elements&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Zero Dependencies Philosophy
&lt;/h3&gt;

&lt;p&gt;Cosmic UI Lite is built with pure TypeScript and vanilla JavaScript – no runtime dependencies whatsoever. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Universal Compatibility&lt;/strong&gt; : Works with any framework or vanilla JS&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Tiny Bundle Size&lt;/strong&gt; : No dependency tree bloat&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Security&lt;/strong&gt; : No supply chain vulnerabilities&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Reliability&lt;/strong&gt; : Won’t break due to dependency updates&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🎨 Component Showcase
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Cosmic Button
&lt;/h3&gt;

&lt;p&gt;Animated buttons with hover effects and multiple variants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
import { CosmicUI } from 'cosmic-ui-lite';

const launchButton = CosmicUI.createButton({
  text: '🚀 Launch Mission',
  variant: 'primary',
  onClick: () =&amp;gt; console.log('Mission started!')
});

document.body.appendChild(launchButton);

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cosmic Modal
&lt;/h3&gt;

&lt;p&gt;Full-featured modals with backdrop blur and cosmic styling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
const confirmModal = CosmicUI.createModal({
  title: 'Mission Control',
  content: `
    &amp;lt;p&amp;gt;Are you ready to launch the mission?&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;This action cannot be undone.&amp;lt;/p&amp;gt;
  `,
  buttons: [
    {
      text: 'Cancel',
      variant: 'secondary',
      onClick: () =&amp;gt; console.log('Cancelled')
    },
    {
      text: 'Launch',
      variant: 'danger',
      onClick: () =&amp;gt; console.log('Launching!')
    }
  ]
});

CosmicUI.showModal(confirmModal);

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Utility Methods
&lt;/h3&gt;

&lt;p&gt;Built-in utilities for common patterns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Quick confirmation dialog
CosmicUI.showConfirmation(
  'Delete Save File',
  'This will permanently delete your progress.',
  () =&amp;gt; console.log('Deleted'),
  () =&amp;gt; console.log('Cancelled')
);

// Error notifications
CosmicUI.showError(
  'Connection Lost',
  'Unable to connect to mission control.',
  () =&amp;gt; console.log('Acknowledged')
);

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  🎯 Perfect for Game Development
&lt;/h2&gt;

&lt;p&gt;Cosmic UI Lite was specifically designed with game development in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Performance Optimized&lt;/strong&gt; : Lightweight components that don’t impact game performance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thematic Consistency&lt;/strong&gt; : Space/sci-fi aesthetic perfect for space games, RPGs, and strategy games&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework Agnostic&lt;/strong&gt; : Works with any game engine that supports web technologies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsive Design&lt;/strong&gt; : Adapts to different screen sizes and device types&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🚀 Getting Started
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
# NPM
npm install cosmic-ui-lite

# Yarn
yarn add cosmic-ui-lite

# CDN
&amp;lt;script src="https://unpkg.com/cosmic-ui-lite@latest/dist/index.umd.js"&amp;gt;&amp;lt;/script&amp;gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Basic Usage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
import { CosmicUI } from 'cosmic-ui-lite';
// CSS is automatically imported

// Create a space dashboard
const statusCard = CosmicUI.createCard({
  title: 'Ship Status',
  content: `
    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Hull Integrity:&amp;lt;/strong&amp;gt; &amp;lt;span style="color: #00ff88;"&amp;gt;100%&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Power Level:&amp;lt;/strong&amp;gt; &amp;lt;span style="color: #00d4ff;"&amp;gt;Optimal&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Shields:&amp;lt;/strong&amp;gt; &amp;lt;span style="color: #ffaa00;"&amp;gt;Charging&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
  `
});

document.body.appendChild(statusCard);

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  🎨 Visual Design Philosophy
&lt;/h2&gt;

&lt;p&gt;The visual design draws inspiration from classic sci-fi interfaces with modern web standards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Angled Corners&lt;/strong&gt; : Distinctive beveled edges that evoke spaceship control panels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animated Gradients&lt;/strong&gt; : Subtle particle-like animations that bring interfaces to life&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cosmic Color Palette&lt;/strong&gt; : Deep space blues, electric cyans, and warning oranges&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Glowing Effects&lt;/strong&gt; : Subtle borders that pulse and respond to user interaction&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📊 Technical Specifications
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;strong&gt;Bundle Size&lt;/strong&gt; : ~15KB minified + gzipped&lt;/li&gt;
&lt;li&gt;🎯 &lt;strong&gt;TypeScript&lt;/strong&gt; : Full type safety with comprehensive interfaces&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;Browser Support&lt;/strong&gt; : All modern browsers (ES2020+)&lt;/li&gt;
&lt;li&gt;📱 &lt;strong&gt;Responsive&lt;/strong&gt; : Mobile-first design with adaptive layouts&lt;/li&gt;
&lt;li&gt;♿ &lt;strong&gt;Accessible&lt;/strong&gt; : WCAG-compliant with keyboard navigation&lt;/li&gt;
&lt;li&gt;⚡ &lt;strong&gt;Performance&lt;/strong&gt; : Zero-dependency, optimized for games&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔮 Future Roadmap
&lt;/h2&gt;

&lt;p&gt;While Cosmic UI Lite is designed to stay lightweight, there are some exciting possibilities on the horizon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Theme System&lt;/strong&gt; : Multiple cosmic color schemes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animation Presets&lt;/strong&gt; : Configurable animation intensity levels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Component Extensions&lt;/strong&gt; : Additional specialized components based on community feedback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework Integrations&lt;/strong&gt; : Optional wrappers for React, Vue, and Angular&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🌟 Try It Today!
&lt;/h2&gt;

&lt;p&gt;Ready to add some cosmic flair to your next project? Check out these resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;&lt;a href="https://fur-gaming.github.io/cosmic-ui-lite/" rel="noopener noreferrer"&gt;Interactive Demo&lt;/a&gt;&lt;/strong&gt; – Try all components live&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/cosmic-ui-lite" rel="noopener noreferrer"&gt;NPM Package&lt;/a&gt;&lt;/strong&gt; – Install with your favorite package manager&lt;/li&gt;
&lt;li&gt;📚 &lt;strong&gt;&lt;a href="https://github.com/fuR-Gaming/cosmic-ui-lite/wiki" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt;&lt;/strong&gt; – Complete guides and examples&lt;/li&gt;
&lt;li&gt;🛠️ &lt;strong&gt;&lt;a href="https://github.com/fuR-Gaming/cosmic-ui-lite" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/strong&gt; – Source code and issue tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether you’re building a space exploration game, a sci-fi web application, or just want to add some futuristic flair to your project, Cosmic UI Lite provides the perfect balance of visual impact and technical simplicity.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What kind of cosmic interfaces will you build? I’d love to see your creations – share them in the comments below or tag me on social media!&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;small&gt;&lt;em&gt;Cosmic UI Lite is open source and available under the MIT License. Contributions, feedback, and cosmic creativity are always welcome! 🚀&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/introducing-cosmic-ui-lite-a-zero-dependency-space-themed-ui-library/" rel="noopener noreferrer"&gt;Introducing Cosmic UI Lite: A Zero-Dependency Space-Themed UI 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>typescript</category>
      <category>webdev</category>
      <category>spacetheme</category>
      <category>scifi</category>
    </item>
    <item>
      <title>Building an Automated LeetCode Solution Post Sync Feature for LeetHub</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Tue, 29 Jul 2025 14:09:39 +0000</pubDate>
      <link>https://dev.to/raw-fun-gaming/building-an-automated-leetcode-solution-post-sync-feature-for-leethub-2mbb</link>
      <guid>https://dev.to/raw-fun-gaming/building-an-automated-leetcode-solution-post-sync-feature-for-leethub-2mbb</guid>
      <description>&lt;p&gt;As a passionate LeetCode enthusiast, I tackle the daily coding challenge religiously and share my solution insights with the community through detailed posts (often polished with AI assistance). My coding journey is powered by LeetHub, an incredible Chrome extension that automatically synchronizes my LeetCode solutions to my GitHub repository, helping me build a robust coding portfolio over time.&lt;/p&gt;

&lt;p&gt;However, I noticed a gap in my workflow. While LeetHub excellently captures my code solutions as plain script files, my thoughtful solution explanations—complete with intuition, approach breakdowns, and complexity analysis—were stuck on LeetCode’s platform. I found myself manually copying these posts to create &lt;code&gt;Solution.md&lt;/code&gt; files in my GitHub repo, which was both tedious and inconsistent.&lt;/p&gt;

&lt;p&gt;That’s when it hit me: why couldn’t LeetHub handle this automatically? This realization sparked my journey to extend LeetHub’s capabilities, and today I’m excited to share the story of building an automated solution post sync feature that bridges this gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Feature Works
&lt;/h2&gt;

&lt;p&gt;The new feature operates seamlessly in the background, requiring zero additional effort from users:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detection&lt;/strong&gt; : When you publish a solution post on LeetCode, the extension automatically detects the action by intercepting the GraphQL request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content Extraction&lt;/strong&gt; : It captures your solution title, content, and problem metadata from the request payload&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart Mapping&lt;/strong&gt; : The extension maps the LeetCode problem slug to your existing GitHub repository folder structure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit Message Consistency&lt;/strong&gt; : It fetches your previous solution commit message from GitHub API (e.g., “Time: 44 ms (100%), Space: 73 MB (100%) – LeetHub”)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic Upload&lt;/strong&gt; : Creates a &lt;code&gt;Solution.md&lt;/code&gt; file in the same folder as your code solution with the identical commit message&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result? Your GitHub repository now contains both your code solutions AND your detailed explanations, creating a complete documentation of your problem-solving approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Challenges and Solutions
&lt;/h2&gt;

&lt;p&gt;Building this feature presented several fascinating technical challenges that pushed the boundaries of Chrome extension development:&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenge 1: Request Interception in Modern Web Applications
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The Problem&lt;/strong&gt; : LeetCode uses GraphQL requests for solution posting, but traditional content script approaches couldn’t reliably intercept these requests due to Chrome’s security model and timing issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Solution&lt;/strong&gt; : We implemented a dual content script architecture using Chrome Extension Manifest V3’s &lt;code&gt;world: "MAIN"&lt;/code&gt; feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// manifest.json
"content_scripts": [
  {
    "js": ["src/js/interceptor.js"],
    "run_at": "document_start",
    "world": "MAIN" // Runs in page's JavaScript context
  },
  {
    "js": ["src/js/leetcode.js"],
    "run_at": "document_idle" // Runs in isolated extension context
  }
]

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

&lt;/div&gt;



&lt;p&gt;This approach allows the interceptor to catch network requests in the same execution context as LeetCode’s JavaScript while maintaining secure communication with the main extension logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenge 2: Content Security Policy Restrictions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The Problem&lt;/strong&gt; : LeetCode’s strict Content Security Policy blocked our initial attempts to inject interceptor code directly into the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Solution&lt;/strong&gt; : Instead of inline script injection, we created a separate interceptor file that runs in the MAIN world context, bypassing CSP restrictions while maintaining functionality:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Intercept both fetch and XMLHttpRequest methods
const originalFetch = window.fetch;
window.fetch = function(...args) {
  // Intercept and process GraphQL requests
  if (body.operationName === 'ugcArticlePublishSolution') {
    // Extract and forward solution data
  }
  return originalFetch.apply(this, args);
};

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Challenge 3: Problem Name Mapping and GitHub Integration
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The Problem&lt;/strong&gt; : LeetCode’s problem slugs (e.g., “maximum-matching-of-players-with-trainers”) needed to be mapped to GitHub folder names (e.g., “2410-maximum-matching-of-players-with-trainers”), and we needed to maintain commit message consistency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Solution&lt;/strong&gt; : We implemented intelligent problem name matching and GitHub API integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
async function getLastCommitMessage(problemName) {
  // Fetch commit history from GitHub API
  const commitsUrl = `https://api.github.com/repos/${hook}/commits?path=${folderPath}`;

  // Find the most recent solution commit (skip README/NOTES)
  for (const commit of commits) {
    if (commit.message.includes('Time:') &amp;amp;&amp;amp; commit.message.includes('Space:')) {
      return commit.message; // Use the same format
    }
  }
}

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Challenge 4: User Experience and Settings Integration
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The Problem&lt;/strong&gt; : The feature needed to be discoverable and controllable without disrupting existing LeetHub workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Solution&lt;/strong&gt; : We added a collapsible settings section in the extension popup with a toggle switch (enabled by default), maintaining consistency with LeetHub’s existing UI patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;The feature now works flawlessly, automatically creating comprehensive documentation in users’ GitHub repositories. Here’s what a typical problem folder looks like after the enhancement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
/2410-maximum-matching-of-players-with-trainers/
├── README.md # Problem description
├── 2410-maximum-matching-of-players-with-trainers.ts # Solution code  
└── Solution.md # Solution explanation ✨ NEW!

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

&lt;/div&gt;



&lt;p&gt;Both the code and solution post files share the same commit message format, creating a cohesive development history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Support This Feature
&lt;/h2&gt;

&lt;p&gt;I’m thrilled to contribute this feature back to the LeetCode community! The implementation is now live as a pull request in the official LeetHub-3.0 repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;👍 If you’d find this feature useful, please show your support by giving a thumbs up to the PR: &lt;a href="https://github.com/raphaelheinz/LeetHub-3.0/pull/69" rel="noopener noreferrer"&gt;https://github.com/raphaelheinz/LeetHub-3.0/pull/69&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your support helps demonstrate community interest and encourages the maintainers to merge this enhancement, making it available to thousands of LeetCode enthusiasts worldwide.&lt;/p&gt;

&lt;p&gt;This feature transforms LeetHub from a simple code backup tool into a comprehensive documentation system that captures both your solutions and the thought processes behind them. It’s a small change that makes a big difference in how we showcase our problem-solving journey on GitHub.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ready to supercharge your LeetCode documentation workflow? Check out the PR and let’s make this feature a reality for everyone! 🚀&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/building-an-automated-leetcode-solution-post-sync-feature-for-leethub/" rel="noopener noreferrer"&gt;Building an Automated LeetCode Solution Post Sync Feature for LeetHub&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>javascript</category>
      <category>chromeextension</category>
      <category>webdev</category>
      <category>leetcode</category>
    </item>
    <item>
      <title>Solving Starfield Perspective Distortion in 3D Space: A Three.js Case Study</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Mon, 28 Jul 2025 08:25:33 +0000</pubDate>
      <link>https://dev.to/raw-fun-gaming/solving-starfield-perspective-distortion-in-3d-space-a-threejs-case-study-44dh</link>
      <guid>https://dev.to/raw-fun-gaming/solving-starfield-perspective-distortion-in-3d-space-a-threejs-case-study-44dh</guid>
      <description>&lt;p&gt;When building a 3D space simulation with Three.js WebGPU, I encountered a challenging visual problem that many 3D developers face: &lt;strong&gt;perspective distortion of billboard sprites&lt;/strong&gt;. Here’s how I identified the issue and implemented an elegant solution.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.richardfu.net/solving-starfield-perspective-distortion-in-3d-space-a-three-js-case-study/" rel="noopener noreferrer"&gt;Read more: Solving Starfield Perspective Distortion in 3D Space: A Three.js Case Study&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Stretched Stars Behind the Camera
&lt;/h2&gt;

&lt;p&gt;In my Earth simulation game, I implemented a starfield background using 15,000 instanced plane geometries positioned on a sphere around the camera. The stars looked perfect when viewed straight-on, but had severe distortion issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stars behind the camera&lt;/strong&gt; appeared as vertical lines (stretched 2-3x in height)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brightness varied dramatically&lt;/strong&gt; based on viewing angle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shape inconsistency&lt;/strong&gt; made the starfield look unrealistic&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Understanding the Root Cause
&lt;/h3&gt;

&lt;p&gt;The distortion occurred due to &lt;strong&gt;perspective projection foreshortening&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;
// Original problematic approach
const radius = 40 + Math.random() * 20; // Stars too close to camera
const position = new THREE.Vector3(
  radius * Math.sin(phi) * Math.cos(theta),
  radius * Math.sin(phi) * Math.sin(theta), 
  radius * Math.cos(phi)
);

// Static plane orientation - doesn't face camera
const quaternion = new THREE.Quaternion(); // Identity rotation
matrix.compose(position, quaternion, scale);

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this failed:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Close proximity&lt;/strong&gt; (40-60 units) amplified perspective effects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed orientation&lt;/strong&gt; meant planes viewed at oblique angles appeared stretched&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perspective projection&lt;/strong&gt; caused extreme foreshortening near camera edges&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Attempted Solutions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Solution 1: Increase Distance
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Attempt 1: Move stars further away
const radius = 200 + Math.random() * 100;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Stars disappeared beyond the camera’s far clipping plane (100 units).&lt;/p&gt;

&lt;h3&gt;
  
  
  Solution 2: Shader-Based Billboarding
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Attempt 2: TSL billboarding (complex, error-prone)
const viewDirection = normalize(cameraPosition.sub(instancePosition));
const right = normalize(cross(viewDirection, vec3(0, 1, 0)));
const up = cross(right, viewDirection);

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Complex TSL syntax caused shader compilation errors in WebGPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Solution 3: CPU Billboarding
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// Attempt 3: Per-frame matrix updates (expensive)
for (let i = 0; i &amp;lt; starCount; i++) {
  const lookMatrix = new THREE.Matrix4();
  lookMatrix.lookAt(position, camera.position, camera.up);
  // Update 15,000 matrices per frame
}

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; 15,000 matrix calculations per frame caused performance issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Elegant Solution: Dual-Hemisphere Architecture
&lt;/h2&gt;

&lt;p&gt;The final solution involved &lt;strong&gt;architectural redesign&lt;/strong&gt; rather than complex workarounds:&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
export class Background {
  private frontStarfield!: THREE.InstancedMesh;
  private backStarfield!: THREE.InstancedMesh;
  private starGroup!: THREE.Group;

  createStarfield(): THREE.Group {
    this.starGroup = new THREE.Group();

    // Split into two hemispheres
    this.frontStarfield = this.createStarPlane(7500, true); // Front
    this.backStarfield = this.createStarPlane(7500, false); // Back

    this.starGroup.add(this.frontStarfield);
    this.starGroup.add(this.backStarfield);

    return this.starGroup;
  }

  private createStarPlane(starCount: number, isFront: boolean): THREE.InstancedMesh {
    // Generate hemisphere positions
    for (let i = 0; i &amp;lt; starCount; i++) {
      const phi = Math.acos(2 * Math.random() - 1);
      const theta = Math.random() * Math.PI * 2;

      // Separate front/back hemispheres
      let z = Math.cos(phi);
      if (!isFront) z = -z;

      const distance = 50; // Fixed distance eliminates perspective issues
      position.set(
        distance * Math.sin(phi) * Math.cos(theta),
        distance * Math.sin(phi) * Math.sin(theta),
        distance * z
      );

      // Calculate proper billboard rotation
      const lookDirection = new THREE.Vector3()
        .subVectors(new THREE.Vector3(0, 0, 0), position)
        .normalize();

      // Create rotation matrix to face camera
      const up = new THREE.Vector3(0, 1, 0);
      const right = new THREE.Vector3().crossVectors(up, lookDirection).normalize();
      up.crossVectors(lookDirection, right);

      const rotMatrix = new THREE.Matrix4();
      rotMatrix.makeBasis(right, up, lookDirection);
      rotMatrix.decompose(new THREE.Vector3(), quaternion, new THREE.Vector3());

      matrix.compose(position, quaternion, scale);
    }
  }
}

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key Advantages
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fixed Distance&lt;/strong&gt; : All stars at consistent 50-unit distance eliminates perspective distortion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proper Billboarding&lt;/strong&gt; : Each star’s rotation matrix calculated to face camera origin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hemisphere Separation&lt;/strong&gt; : Dedicated handling for front/back visibility&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt; : One-time calculation during initialization, not per-frame&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Technical Benefits
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Visual Quality
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consistent appearance&lt;/strong&gt; across all viewing angles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shape distortion&lt;/strong&gt; regardless of camera position&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uniform brightness&lt;/strong&gt; independent of perspective&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Performance
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero runtime cost&lt;/strong&gt; for billboard calculations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU-optimized&lt;/strong&gt; instanced rendering for 15,000 stars&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalable&lt;/strong&gt; to even larger star counts&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Maintainability
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Simple architecture&lt;/strong&gt; easier to debug and extend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear separation&lt;/strong&gt; between front/back star management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusable pattern&lt;/strong&gt; for other billboard sprite scenarios&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Enhanced Features
&lt;/h2&gt;

&lt;p&gt;The solution also enabled advanced visual effects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// TSL-based twinkling with individual star timing
const sparkle = sin(mul(timeUniform, sparkleSpeedUniform)
  .add(mul(randomAttr, float(6.28))))
  .mul(float(0.5)).add(float(0.5));

const pulse = sin(mul(timeUniform, pulseSpeedUniform)
  .add(pulsePhase))
  .mul(float(0.4)).add(float(0.6));

// Sharp-edged circular stars
const circularAlpha = smoothstep(float(0.5), float(0.2), distance);
const finalAlpha = mul(circularAlpha, mul(sparkleEnhanced, pulse));

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Features achieved:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Individual star twinkling&lt;/strong&gt; with unique timing per star&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU-parallelized effects&lt;/strong&gt; for 15,000 stars with zero performance cost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realistic sparkle and pulse&lt;/strong&gt; effects mimicking atmospheric scintillation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharp-edged circular stars&lt;/strong&gt; with configurable falloff&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Performance Metrics
&lt;/h2&gt;

&lt;p&gt;The final implementation delivers excellent performance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;15,000 stars&lt;/strong&gt; rendering at 60+ FPS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero CPU overhead&lt;/strong&gt; for star animation (GPU-only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal memory footprint&lt;/strong&gt; using instanced rendering&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebGPU optimized&lt;/strong&gt; with Three.js TSL shaders&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Architectural solutions&lt;/strong&gt; often outperform technical workarounds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed-distance billboards&lt;/strong&gt; eliminate many perspective issues&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hemisphere separation&lt;/strong&gt; provides better control than sphere-based approaches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU shader effects&lt;/strong&gt; can be simpler than CPU-based alternatives&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebGPU + TSL&lt;/strong&gt; requires different approaches than traditional WebGL&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common Pitfalls to Avoid
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Camera Clipping Issues
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// ![❌](https://s.w.org/images/core/emoji/15.1.0/72x72/274c.png) Bad: Stars beyond far plane
const camera = new THREE.PerspectiveCamera(25, aspect, 0.1, 100);
const starDistance = 200; // Beyond far plane!

// ![✅](https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png) Good: Stars within camera range
const camera = new THREE.PerspectiveCamera(25, aspect, 0.1, 100);
const starDistance = 50; // Well within range

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  TSL Shader Compatibility
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// ![❌](https://s.w.org/images/core/emoji/15.1.0/72x72/274c.png) Bad: onBeforeCompile doesn't work with WebGPU
material.onBeforeCompile = (shader) =&amp;gt; {
  // This won't execute in WebGPU mode
};

// ![✅](https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png) Good: Use TSL node system
const sparkle = sin(mul(timeUniform, speedUniform));
material.opacityNode = sparkle;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Performance Anti-Patterns
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
// ![❌](https://s.w.org/images/core/emoji/15.1.0/72x72/274c.png) Bad: Per-frame matrix updates
animate() {
  for (let i = 0; i &amp;lt; 15000; i++) {
    updateStarMatrix(i); // 15k calculations per frame
  }
}

// ![✅](https://s.w.org/images/core/emoji/15.1.0/72x72/2705.png) Good: One-time setup with GPU animation
createStars() {
  // Calculate matrices once
  material.opacityNode = gpuAnimationNode; // GPU handles animation
}

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Instead of fighting perspective projection with complex mathematical solutions, restructuring the problem space proved more effective. The dual-hemisphere approach provides perfect visual results while maintaining excellent performance and code clarity.&lt;/p&gt;

&lt;p&gt;This pattern can be applied to any 3D scenario requiring consistent billboard sprite appearance across all viewing angles – from particle systems to UI elements in 3D space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Question architectural assumptions when facing visual artifacts&lt;/li&gt;
&lt;li&gt;GPU-based solutions often outperform CPU workarounds&lt;/li&gt;
&lt;li&gt;WebGPU + TSL requires rethinking traditional Three.js patterns&lt;/li&gt;
&lt;li&gt;Fixed-distance billboards solve many perspective distortion issues&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sbcode.net/threejs/webgpu-renderer/" rel="noopener noreferrer"&gt;Three WebGPU Renderer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mrdoob/three.js/wiki/Three.js-Shading-Language" rel="noopener noreferrer"&gt;TSL (Three.js Shading Language) Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://threejs.org/docs/#api/en/objects/InstancedMesh" rel="noopener noreferrer"&gt;Instanced Rendering Best Practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Sprite_(computer_graphics)" rel="noopener noreferrer"&gt;Billboard Sprite Techniques in 3D Graphics&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/solving-starfield-perspective-distortion-in-3d-space-a-three-js-case-study/" rel="noopener noreferrer"&gt;Solving Starfield Perspective Distortion in 3D Space: A Three.js Case Study&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>webdev</category>
    </item>
    <item>
      <title>Efficient Lazy Loading in Svelte: A Practical Guide for Svelte 4 and Svelte 5 (Runes)</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Mon, 28 Jul 2025 02:51:34 +0000</pubDate>
      <link>https://dev.to/raw-fun-gaming/efficient-lazy-loading-in-svelte-a-practical-guide-for-svelte-4-and-svelte-5-runes-4951</link>
      <guid>https://dev.to/raw-fun-gaming/efficient-lazy-loading-in-svelte-a-practical-guide-for-svelte-4-and-svelte-5-runes-4951</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.amazonaws.com%2Fuploads%2Farticles%2Fy19538islcjmo868cs2z.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%2Fy19538islcjmo868cs2z.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Modern web applications are growing in complexity and size, often including many components that are not needed on the initial page load. Lazy loading is a powerful technique to optimize bundle size and improve performance by loading components only when they are actually needed—such as widgets, modals, or rarely-used UI elements. In this post, we’ll explore how to implement lazy loading in Svelte, compare the syntax between Svelte 4 and Svelte 5 (runes mode), and discuss the benefits and potential drawbacks of this approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Lazy Load Components?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Performance:&lt;/strong&gt; By splitting your bundle and loading code only when required, you reduce the initial load time and memory usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User Experience:&lt;/strong&gt; Faster initial load means a snappier experience, especially on mobile devices or slow networks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalability:&lt;/strong&gt; As your app grows, lazy loading keeps it maintainable and modular, making it easier to manage and extend.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Svelte 4 Syntax
&lt;/h2&gt;

&lt;p&gt;In Svelte 4, dynamic imports are combined with the &lt;code&gt;&amp;lt;svelte:component&amp;gt;&lt;/code&gt; tag for lazy loading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
{#await import('./MyWidget.svelte') then Widget}
  &amp;lt;svelte:component this={Widget.default} someProp={value} /&amp;gt;
{/await}

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

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;svelte:component&amp;gt;&lt;/code&gt; allows you to render a component dynamically.&lt;/li&gt;
&lt;li&gt;Props are passed as usual.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Svelte 5 (Runes) Syntax
&lt;/h2&gt;

&lt;p&gt;Svelte 5 introduces runes and direct component invocation, but for lazy loading, the recommended approach is to use the imported component as a tag inside an &lt;code&gt;{#await}&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
{#await import('./MyWidget.svelte') then Widget}
  &amp;lt;Widget.default someProp={value} /&amp;gt;
{/await}

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

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;This is the idiomatic way for lazy loading in Svelte 5.&lt;/li&gt;
&lt;li&gt;Direct invocation (&lt;code&gt;Widget.default({ someProp: value })&lt;/code&gt;) is for advanced use cases, not markup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Dynamic Import in a Loop
&lt;/h2&gt;

&lt;p&gt;You can dynamically load a component inside an &lt;code&gt;{#each}&lt;/code&gt; loop, which is useful for rendering lists of widgets or cards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
{#each items as item}
  {#await import('./MyWidget.svelte') then Widget}
    &amp;lt;Widget.default data={item} /&amp;gt;
  {/await}
{/each}

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Performance Note:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Svelte will only import the module once, even if the loop has many items. Subsequent imports use the cached module, so there is no repeated network or disk load.&lt;/li&gt;
&lt;li&gt;The only overhead is rendering multiple component instances, which is normal for any list rendering.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Drawbacks and Considerations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Initial Delay:&lt;/strong&gt; The first time a component is loaded, there may be a slight delay as the code is fetched and parsed. For critical UI, consider preloading or keeping it in the main bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSR Compatibility:&lt;/strong&gt; Lazy loading is primarily a client-side optimization. If you use server-side rendering, ensure your approach is compatible or fallback gracefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Splitting:&lt;/strong&gt; Too many small chunks can increase HTTP requests. Group related components when possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State Management:&lt;/strong&gt; If your lazy-loaded component depends on global state, ensure the state is available when the component mounts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Lazy loading components in Svelte is a simple yet effective way to optimize your app’s performance and scalability. By leveraging dynamic imports and Svelte’s template syntax, you can keep your initial bundle small and load features only when needed. Both Svelte 4 and Svelte 5 support this pattern, with Svelte 5 offering a more streamlined syntax.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://svelte.dev/docs/svelte/typescript#The-Component-type" rel="noopener noreferrer"&gt;Svelte Docs: Dynamic Components&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://svelte.dev/docs/svelte/v5-migration-guide#svelte:component-is-no-longer-necessary" rel="noopener noreferrer"&gt;Svelte 5 Migration Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sveltejs/rfcs/pull/53" rel="noopener noreferrer"&gt;Svelte RFC: Runes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/efficient-lazy-loading-in-svelte-a-practical-guide-for-svelte-4-and-svelte-5-runes/" rel="noopener noreferrer"&gt;Efficient Lazy Loading in Svelte: A Practical Guide for Svelte 4 and Svelte 5 (Runes)&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>svelte</category>
      <category>webdev</category>
      <category>lazyloading</category>
      <category>dynamicimports</category>
    </item>
  </channel>
</rss>
