<?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>Building MESH: A Civic Resilience Platform With AI Agents</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 23 May 2026 08:27:29 +0000</pubDate>
      <link>https://dev.to/furic/building-mesh-a-civic-resilience-platform-with-ai-agents-15jf</link>
      <guid>https://dev.to/furic/building-mesh-a-civic-resilience-platform-with-ai-agents-15jf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A one-day hackathon project at the Claude Impact Lab (Melbourne, May 2026). The brief was open. I built MESH — &lt;em&gt;Melbourne Exchange &amp;amp; Solidarity Hub&lt;/em&gt; — a gamified civic platform where residents see their suburb as a node in a network and AI agents read live open data to suggest the most useful thing the community can do next.&lt;/p&gt;

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




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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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




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

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

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

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




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

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

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

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

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

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

</description>
      <category>ai</category>
      <category>svelte</category>
      <category>webdev</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>Richfolio, three months in: AI architecture in production</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Fri, 22 May 2026 10:14:46 +0000</pubDate>
      <link>https://dev.to/furic/richfolio-three-months-in-ai-architecture-in-production-13il</link>
      <guid>https://dev.to/furic/richfolio-three-months-in-ai-architecture-in-production-13il</guid>
      <description>&lt;p&gt;&lt;em&gt;From v1.0 to v1.6 — what I rebuilt, what I borrowed, and what’s running in production for $0/month&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three months ago I shipped v1.0 of &lt;a href="https://github.com/furic/richfolio" rel="noopener noreferrer"&gt;Richfolio&lt;/a&gt; — a zero-maintenance side project that emails me a daily AI-powered portfolio digest. I wrote about the original motivation and v1 architecture in &lt;a href="https://dev.to/furic/i-built-a-free-ai-portfolio-assistant-that-emails-me-every-morning-3plm"&gt;this earlier post&lt;/a&gt; if you want the background.&lt;/p&gt;

&lt;p&gt;Since then it’s gone through six minor releases. The code roughly tripled in size, the AI architecture got rebuilt from the ground up to handle production use, and I’ve been using it on my own portfolio daily. This post is a project-level update: where Richfolio is, what stack it runs on, the architectural patterns I borrowed (mostly from &lt;a href="https://github.com/TraderAlice/OpenAlice" rel="noopener noreferrer"&gt;OpenAlice&lt;/a&gt;), and one concrete case study showing how those patterns paid off in v1.6.&lt;/p&gt;

&lt;p&gt;At the end I’m looking for alpha testers, so if any of this is interesting and you’ve got a portfolio that doesn’t look like mine, keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Richfolio is now
&lt;/h2&gt;

&lt;p&gt;A single Node.js + TypeScript pipeline, no API server, no dashboard. Runs as a GitHub Actions cron job (8am AEST). Four modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Daily&lt;/strong&gt; — full analysis + email + Telegram&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intraday&lt;/strong&gt; — periodic STRONG BUY change alerts (only fires on real signal changes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly&lt;/strong&gt; — rebalancing drift report&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh&lt;/strong&gt; — re-analyze a single ticker with after-hours prices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Output looks like this: a dark-themed HTML email per morning, condensed Telegram message in parallel, and for every STRONG BUY ticker, a “More Details” link to a dedicated analysis page on GitHub Pages with an interactive TradingView chart, AI-generated buy thesis, and risk analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack — still $0/month
&lt;/h2&gt;

&lt;p&gt;Everything Richfolio runs on is free-tier:&lt;/p&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;Service&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;Prices, fundamentals, technicals&lt;/td&gt;
&lt;td&gt;Yahoo Finance via &lt;code&gt;yahoo-finance2&lt;/code&gt; v3&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;News&lt;/td&gt;
&lt;td&gt;NewsAPI.org&lt;/td&gt;
&lt;td&gt;100 req/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI analysis&lt;/td&gt;
&lt;td&gt;Google Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;250 req/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email delivery&lt;/td&gt;
&lt;td&gt;Resend.com&lt;/td&gt;
&lt;td&gt;3,000/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notification&lt;/td&gt;
&lt;td&gt;Telegram Bot API&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduler&lt;/td&gt;
&lt;td&gt;GitHub Actions cron&lt;/td&gt;
&lt;td&gt;2,000 min/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static hosting&lt;/td&gt;
&lt;td&gt;GitHub Pages&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State persistence&lt;/td&gt;
&lt;td&gt;&lt;code&gt;actions/cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No database. No queue. No server. State is a JSON file in the repo that survives cron runs via &lt;code&gt;actions/cache&lt;/code&gt;. The full cost ceiling for a single user is the Resend tier — and Richfolio sends maybe 60 emails a month, so there’s headroom for ~50 users on a single account.&lt;/p&gt;

&lt;p&gt;The interesting constraint isn’t the budget — it’s the request budget. Gemini’s 250/day cap means every “let me ask the AI” feature has to be designed around batch calls, not per-ticker calls. That shaped a lot of the architecture below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: patterns I borrowed from OpenAlice
&lt;/h2&gt;

&lt;p&gt;The biggest single influence on Richfolio’s post-v1 evolution was &lt;a href="https://github.com/TraderAlice/OpenAlice" rel="noopener noreferrer"&gt;OpenAlice&lt;/a&gt; — an autonomous AI trading agent from TraderAlice. OpenAlice is much bigger than Richfolio (multi-broker, multi-asset, with a UI) but its cognitive architecture transferred cleanly. Four patterns in particular.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Two-stage Think/Plan AI prompting
&lt;/h3&gt;

&lt;p&gt;The v1 pipeline used a single Gemini call: shove everything into one prompt, parse one JSON response. The problem: when the AI conflates data parsing with decision-making, it hallucinates support for whatever conclusion it’s already started writing. STRONG BUY false positives crept in monthly.&lt;/p&gt;

&lt;p&gt;The fix was to split the call into two stages, mirroring OpenAlice’s &lt;code&gt;think&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1 (Observe)&lt;/strong&gt; — Gemini gets raw data and produces &lt;em&gt;structured observations only&lt;/em&gt;: which price-level signals are present, which momentum signals, which risk flags, a one-line valuation summary, a one-line technical summary, news sentiment. No actions, no recommendations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2 (Decide)&lt;/strong&gt; — Gemini gets the observations and applies strict decision rules to produce ranked recommendations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two API calls instead of one — wasteful in theory, but still 2/250 of the daily quota. The practical win is significant: forcing the AI to commit to what it &lt;em&gt;sees&lt;/em&gt; before deciding what to &lt;em&gt;do&lt;/em&gt; dropped false-positive STRONG BUYs to near zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Post-AI guard pipeline
&lt;/h3&gt;

&lt;p&gt;The other half of trustworthy AI output is &lt;em&gt;not trusting the AI&lt;/em&gt;. OpenAlice’s &lt;code&gt;guard-pipeline.ts&lt;/code&gt; runs sequential validation between the AI’s proposed trades and the broker. Richfolio adapts the same pattern in &lt;code&gt;guards.ts&lt;/code&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateRecommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AIBuyRecommendation&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;priceData&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;QuoteData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;technicals&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TechnicalData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AllocationReport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;guardBondETFCap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardEarningsProximity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priceData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardStrongBuyCriteria&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;technicals&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardMaxStrongBuy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardConfidenceSanity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardBuyValueSanity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;report&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;Six sequential checks that programmatically enforce the rules I told the AI to follow. Each guard logs when it fires, so I can see which AI mistakes were caught downstream — useful signal when tuning prompts.&lt;/p&gt;

&lt;p&gt;The architectural pattern — &lt;em&gt;let the AI propose freely, validate deterministically afterward&lt;/em&gt; — is the single most useful idea I borrowed from OpenAlice. It makes prompt experiments low-risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Seven-day reasoning persistence
&lt;/h3&gt;

&lt;p&gt;OpenAlice has a &lt;code&gt;Brain&lt;/code&gt; module that tracks cognitive state across sessions via Git-like commits. Richfolio doesn’t need that level of machinery, but the principle — &lt;em&gt;let the AI see its own past conclusions&lt;/em&gt; — adapts cleanly to a 7-day rolling history of AI snapshots.&lt;/p&gt;

&lt;p&gt;Every decision prompt now receives a “HISTORICAL CONTEXT” block like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
AAPL: BUY 72% → BUY 68% → HOLD 55% — weakening
NVDA: STRONG BUY 88% → STRONG BUY 91% → STRONG BUY 89% — stable
BSV: BUY 75% → BUY 75% → BUY 75% — flat

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

&lt;/div&gt;



&lt;p&gt;(More on that last line in a minute.)&lt;/p&gt;

&lt;p&gt;The history forces the AI to acknowledge trend changes. Without it, the model would sometimes flip a ticker from BUY 70% to HOLD 50% on consecutive days with no acknowledgement — which feels wrong even if individually justified.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Macro environment context
&lt;/h3&gt;

&lt;p&gt;Yahoo Finance is free, and &lt;code&gt;yahooFinance.quoteSummary('^VIX')&lt;/code&gt; works the same as it does for any equity. So every Richfolio run now fetches VIX, the 10-year Treasury yield, S&amp;amp;P 500, oil (WTI), and the USD index (DXY) — five extra calls — and pipes them as a &lt;code&gt;MACRO ENVIRONMENT:&lt;/code&gt; preamble into both the observation and decision prompts.&lt;/p&gt;

&lt;p&gt;This is OpenAlice’s “equity research” influence translated to a portfolio context. The macro block lets Gemini write things like &lt;em&gt;“elevated VIX + high yields suggest defensive positioning”&lt;/em&gt; instead of generic boilerplate, and gates STRONG BUYs more conservatively in high-volatility environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case study: fixing the BSV “75% BUY every day” problem
&lt;/h2&gt;

&lt;p&gt;The patterns above sound abstract. v1.6 was a concrete test of whether they paid off.&lt;/p&gt;

&lt;p&gt;Since v1.4 (which introduced the bond ETF framework back in early April), my morning email had this same line every single day:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;BSV&lt;/strong&gt; — 75% BUY — &lt;em&gt;Systematic accumulation to fill 5% allocation gap.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reason was an over-rigid prompt rule. BSV is a short-duration bond ETF — it has a ~2% annual price range, and equity-style signals like RSI and MACD are noise on a security that barely moves. So I’d hard-coded a confidence formula by allocation gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Gap ≥ 5%: confidence 70-75%
Gap 3-5%: confidence 60-70%
Gap 1-3%: confidence 45-55%
Gap &amp;amp;lt; 1%: HOLD

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

&lt;/div&gt;



&lt;p&gt;That removed false momentum signals but flatlined the output. With a steady ~5% gap, the answer was 75% every day forever.&lt;/p&gt;

&lt;p&gt;The fix needed &lt;em&gt;some&lt;/em&gt; timing signal. Just not equity timing signals. After thinking about what actually matters when buying bond ETFs, three orthogonal signals fell out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Price percentile in a rolling 90-day window.&lt;/strong&gt; Where today’s price sits in BSV’s recent range. Computed from existing chart data:&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;let&lt;/span&gt; &lt;span class="nx"&gt;pricePercentile90d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;last90&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;90&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;min90&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;last90&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;max90&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;last90&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;max90&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;min90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pricePercentile90d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="nx"&gt;currentPrice&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;min90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max90&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;min90&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. 10-year Treasury yield direction.&lt;/strong&gt; Bonds get cheaper when rates rise. I added a 20-trading-day delta on the existing macro fetch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;treasury10y&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;indicators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;treasury10y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;period1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;period1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;period1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;35&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;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;yahooFinance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;period1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;period2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1d&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;closes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quotes&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&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;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;indicators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;treasury10yChange20d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&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="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Distribution yield level.&lt;/strong&gt; Yahoo’s &lt;code&gt;summaryDetail.yield&lt;/code&gt; returns the SEC / 12-month yield for funds. High yield = high income premium.&lt;/p&gt;

&lt;p&gt;Then a new prompt framework that scores via &lt;strong&gt;base + timing modifiers&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;
12a. SHORT-DURATION BOND ETFs:
  BASE (from gap): 5%→55, 3-5%→45, 1-3%→35, &amp;amp;lt;1%→HOLD
  + 90d percentile: ≤20% +12, ≥80% -15
  + 10Y 20d change: &amp;gt;+0.15% +6, &amp;amp;lt;-0.15% -12
  + Yield: &amp;gt;4.5% +3, &amp;amp;lt;3% -2

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

&lt;/div&gt;



&lt;p&gt;On a great day for BSV — near 90-day low, rates spiking, high yield, big gap — confidence reaches ~90. On a bad day — top of range, rates falling — it drops to ~25. Real day-to-day variance.&lt;/p&gt;

&lt;p&gt;This is where the architectural patterns paid off. The bond ETF rewrite is exactly the kind of change I’d previously been nervous to ship — the AI could ignore the new modifiers and hallucinate a STRONG BUY on BSV, which I never want. But the guard pipeline catches it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;for &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;rec&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SHORT_DURATION_BOND_ETFS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="k"&gt;continue&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;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUY&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;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suggestedLimitPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suggestedLimitPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suggestedLimitPrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limitPriceReason&lt;/span&gt; &lt;span class="o"&gt;=&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;95&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;And the action-tier sort (new in v1.6) means even if BSV’s confidence reaches 90 on a great day, equity STRONG BUYs still rank above it visually:&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;ACTION_PRIORITY&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;BUY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;HOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;WAIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;recommendations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ACTION_PRIORITY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;99&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;pb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ACTION_PRIORITY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;99&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;pa&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;pb&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;pa&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pb&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&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 whole BSV fix was about 200 lines of code across four files. The reason it was easy: the guards layer meant I could trust prompt changes to fail safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other things shipped since v1.0
&lt;/h2&gt;

&lt;p&gt;Beyond the architecture and the bond ETF case study, a lot of smaller features landed across v1.1 through v1.6:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Earnings calendar guard&lt;/strong&gt; — Yahoo’s &lt;code&gt;calendarEvents&lt;/code&gt; module ships next-earnings date inside the existing &lt;code&gt;quoteSummary&lt;/code&gt; call. Hard HOLD ≤3 days to earnings, no STRONG BUY ≤7.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;News sentiment scoring&lt;/strong&gt; — every headline tagged bullish/bearish/neutral with high/medium/low impact, in the same Gemini call that was already filtering relevance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical indicators expanded&lt;/strong&gt; — beyond SMA/RSI from v1: MACD with crossover detection, Bollinger Bands with squeeze detection, ATR, Stochastic, OBV trend. All from existing chart data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value investing framework&lt;/strong&gt; — A–D fundamental ratings for individual stocks based on ROE, debt/equity, FCF, growth, analyst target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crypto bottom-fishing model&lt;/strong&gt; — RSI + volume contraction + 200MA + death cross confluence detection for BTC/ETH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STRONG BUY analysis pages&lt;/strong&gt; — interactive TradingView chart + buy thesis + risk analysis per STRONG BUY ticker, all encoded in the URL hash (no server).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh mode&lt;/strong&gt; — re-analyze a single ticker with after-hours / pre-market price for late-night decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intraday alerts&lt;/strong&gt; — only fire on STRONG BUY changes (upgrade, downgrade, or ≥10pt confidence shift). No noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;International currency support&lt;/strong&gt; — non-USD portfolios fully supported with live FX from Yahoo. Sub-unit handling for exchanges quoting in pence/agorot/cents (LSE GBp → GBP ÷100).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI + tests&lt;/strong&gt; — 63 unit tests via &lt;code&gt;node:test&lt;/code&gt;, zero dependencies, runs on every PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18n docs&lt;/strong&gt; — full English / Simplified Chinese / Traditional Chinese site via Jekyll polyglot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full release notes are on &lt;a href="https://github.com/furic/richfolio/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest results so far
&lt;/h2&gt;

&lt;p&gt;I’ve been running Richfolio against my own portfolio for the past few months. Across that window I’m up roughly &lt;strong&gt;6%&lt;/strong&gt; from acting on the AI’s STRONG BUY suggestions.&lt;/p&gt;

&lt;p&gt;The caveat I have to put in writing: US markets have been strong over the same window. A meaningful chunk of that 6% is probably tailwind. I can’t say &lt;em&gt;“the AI added 6%”&lt;/em&gt; — I can only say &lt;em&gt;“following the AI’s suggestions during a strong market gave me 6% on those entries.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To know whether Richfolio actually adds value vs riding the tape, I need to see it run against portfolios that look nothing like mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alpha testers wanted
&lt;/h2&gt;

&lt;p&gt;If you’d like to point Richfolio at your portfolio for a few weeks and tell me where it’s wrong, I’d love to hear from you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I’d love testers to bring:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A portfolio that looks different from mine — heavy crypto, sector-concentrated, dividend-focused, international, REIT-heavy, small-cap heavy&lt;/li&gt;
&lt;li&gt;Honesty about whether the STRONG BUY signal actually matches what you’d buy yourself&lt;/li&gt;
&lt;li&gt;Bug reports — weird tickers, exchanges I don’t trade on, edge cases in the FX layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A working free-tier portfolio monitor&lt;/li&gt;
&lt;li&gt;A 1-on-1 setup walkthrough&lt;/li&gt;
&lt;li&gt;Direct line to me for feedback and feature requests&lt;/li&gt;
&lt;li&gt;First look at upcoming versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setup is a GitHub fork plus a few environment variables — full docs at &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;DM me or open an issue on the repo if you’re interested.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/richfolio-three-months-in-ai-architecture-in-production/" rel="noopener noreferrer"&gt;Richfolio, three months in: AI architecture in production&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>finance</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Unity WebGL Safari Hang: The First-Draw Shader Stall</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 16 May 2026 07:55:26 +0000</pubDate>
      <link>https://dev.to/furic/unity-webgl-safari-hang-the-first-draw-shader-stall-43f2</link>
      <guid>https://dev.to/furic/unity-webgl-safari-hang-the-first-draw-shader-stall-43f2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — This is a war story about a &lt;strong&gt;Unity WebGL Safari hang&lt;/strong&gt; : a game that ran beautifully on Chrome but froze for ~3 seconds on the first major animation event in iOS Safari. After a long detour through material instancing, shader keywords, animator transitions, and post-FX, the culprit was a single prefab nobody thought to warm up: an overlay spawned by an obscure event handler one frame before the animation started. &lt;strong&gt;Mid-game &lt;code&gt;Instantiate&lt;/code&gt; of a prefab with a custom shader triggers Safari’s &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; on first render — synchronous, main-thread, ~3ms per uniform lookup, ~200 uniforms across a complex prefab = 2.8s stall.&lt;/strong&gt; The fix is to render the prefab once during the splash so the cache is warm before gameplay needs it.&lt;/p&gt;
&lt;/blockquote&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>shader</category>
      <category>webgl</category>
      <category>gamedev</category>
      <category>unity3d</category>
    </item>
    <item>
      <title>Re-skinning a 3D Character with AI Image Tools (Without Touching Blender)</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 02 May 2026 15:58:20 +0000</pubDate>
      <link>https://dev.to/furic/re-skinning-a-3d-character-with-ai-image-tools-without-touching-blender-4a14</link>
      <guid>https://dev.to/furic/re-skinning-a-3d-character-with-ai-image-tools-without-touching-blender-4a14</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I bought a well-made chibi farmer model and wanted to reuse it as a zookeeper character — same mesh, same rig, same animations. Meshy.ai’s Retexture didn’t work for me, and naive cropping of the UV atlas pulled in neighbor pieces. The fix turned out to be embarrassingly simple: connected-component labeling to extract one UV island at a time, send each piece to ChatGPT with a tight prompt, then composite the AI result back with an intersection-of-silhouettes mask. I packaged the workflow as a Claude Code skill — &lt;a href="https://github.com/furic/texture-atlas-roundtrip" rel="noopener noreferrer"&gt;furic/texture-atlas-roundtrip&lt;/a&gt; — and ended up with a wildlife keeper that took a couple of evenings instead of a couple of weeks.&lt;/p&gt;

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




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

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

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

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



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



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



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

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

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



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



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

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




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

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

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

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

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




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

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

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



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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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




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

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

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

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

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



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



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



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



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

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




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

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

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

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

</description>
      <category>ai</category>
      <category>skill</category>
      <category>python</category>
      <category>modelling</category>
    </item>
    <item>
      <title>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>
  </channel>
</rss>
