<?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: Bharat Ankh</title>
    <description>The latest articles on DEV Community by Bharat Ankh (@bharat_ankh_920bf0864220a).</description>
    <link>https://dev.to/bharat_ankh_920bf0864220a</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896981%2F0629b57c-53aa-4dd3-9738-f3dcb2c5f5ae.png</url>
      <title>DEV Community: Bharat Ankh</title>
      <link>https://dev.to/bharat_ankh_920bf0864220a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bharat_ankh_920bf0864220a"/>
    <language>en</language>
    <item>
      <title>Link preview shows no image? 7 og:image checks for Slack, X, Discord and LinkedIn</title>
      <dc:creator>Bharat Ankh</dc:creator>
      <pubDate>Tue, 03 Nov 2026 18:04:43 +0000</pubDate>
      <link>https://dev.to/bharat_ankh_920bf0864220a/link-preview-shows-no-image-7-ogimage-checks-for-slack-x-discord-and-linkedin-9kk</link>
      <guid>https://dev.to/bharat_ankh_920bf0864220a/link-preview-shows-no-image-7-ogimage-checks-for-slack-x-discord-and-linkedin-9kk</guid>
      <description>&lt;p&gt;Your page looks fine in the browser, but when you paste the URL into Slack, X, Discord or LinkedIn you get a title and maybe a description, with &lt;strong&gt;no image&lt;/strong&gt;. The crawler usually isn't broken. It's almost always one of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The tags only exist after JavaScript runs
&lt;/h2&gt;

&lt;p&gt;Link-preview crawlers read the &lt;strong&gt;raw HTML&lt;/strong&gt; the server returns. Most don't execute JavaScript. If your &lt;code&gt;og:&lt;/code&gt; tags are injected by React/Vue on the client (a plain SPA, or &lt;code&gt;react-helmet&lt;/code&gt; without SSR), the crawler never sees them.&lt;/p&gt;

&lt;p&gt;Check what the crawler sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://yoursite.com/page | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'og:image'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints nothing, render the tags on the server (SSR/SSG, or your framework's metadata API).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;og:image&lt;/code&gt; is a relative URL
&lt;/h2&gt;

&lt;p&gt;Use an absolute URL with the scheme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yoursite.com/og/page.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/og/page.png&lt;/code&gt; or &lt;code&gt;//cdn...&lt;/code&gt; is a common reason for "title but no image".&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The image URL doesn't return an image
&lt;/h2&gt;

&lt;p&gt;Open the image URL in a private window, and also run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://yoursite.com/og/page.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;200&lt;/code&gt; and &lt;code&gt;content-type: image/png&lt;/code&gt; (or jpeg/webp). Common failures: a redirect to a login page, a 404 from a dynamic OG route, hotlink protection, or an HTML error page served with a 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A firewall is blocking the crawler
&lt;/h2&gt;

&lt;p&gt;Bot-protection settings (a CDN's "bot fight" mode, WAF rules, rate limits, &lt;code&gt;robots.txt&lt;/code&gt; disallowing everything) can block &lt;code&gt;facebookexternalhit&lt;/code&gt;, &lt;code&gt;Twitterbot&lt;/code&gt;, &lt;code&gt;Slackbot&lt;/code&gt;, &lt;code&gt;Discordbot&lt;/code&gt; or &lt;code&gt;LinkedInBot&lt;/code&gt;. Test with the crawler's user agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"Twitterbot/1.0"&lt;/span&gt; https://yoursite.com/page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;403&lt;/code&gt; or a challenge page here explains a missing preview even when everything else is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Wrong size or too heavy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1200 × 630&lt;/strong&gt; (about 1.91:1) is the safe default that every major platform crops well. For X, set &lt;code&gt;twitter:card&lt;/code&gt; to &lt;code&gt;summary_large_image&lt;/code&gt;, or you get the small square card. Keep the file small: X rejects images over 5 MB, and smaller files simply load more reliably for every crawler. A tall phone screenshot will be cropped to a strip.&lt;/p&gt;

&lt;p&gt;Size and per-platform notes: &lt;a href="https://arnavjhajharia.github.io/ogkit/og-image-size/" rel="noopener noreferrer"&gt;OG image size guide&lt;/a&gt; (includes a &lt;strong&gt;test your link preview&lt;/strong&gt; checklist).&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The platform cached the old (broken) preview
&lt;/h2&gt;

&lt;p&gt;Platforms cache what they scraped the first time. After you fix the tags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Facebook&lt;/strong&gt;: Sharing Debugger → "Scrape Again".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn&lt;/strong&gt;: Post Inspector re-fetches the URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X, Slack, Discord, WhatsApp&lt;/strong&gt;: no public refresh button. Change the image filename or add a query string (&lt;code&gt;og.png?v=2&lt;/code&gt;) so it counts as a new URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Missing the basic tag set
&lt;/h2&gt;

&lt;p&gt;A minimal block that works everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Page title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"One sentence."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yoursite.com/page"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yoursite.com/og/page.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;og:image:width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; let some crawlers render the card on the first share without downloading the image first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making the 1200 × 630 image
&lt;/h2&gt;

&lt;p&gt;If you don't have a designer, &lt;a href="https://arnavjhajharia.github.io/ogkit/" rel="noopener noreferrer"&gt;ogkit&lt;/a&gt; is a free in-browser card maker I built. It exports a 1200 × 630 PNG (or a JPG kept under 300 KB, which WhatsApp previews handle more reliably) and copies the tag block above. Nothing is uploaded and no account is needed. There's an optional &lt;a href="https://bharatankh.gumroad.com/l/ogkit-pro" rel="noopener noreferrer"&gt;paid preset pack ($12)&lt;/a&gt; if you want more layouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related (same Ship Kit):&lt;/strong&gt; &lt;a href="https://dev.to/bharat_ankh_920bf0864220a/app-store-screenshot-sizes-in-2026-which-ones-are-actually-required-24f5"&gt;App Store screenshot sizes in 2026&lt;/a&gt; · &lt;a href="https://dev.to/bharat_ankh_920bf0864220a/favicon-not-updating-after-deploy-5-fixes-for-the-browsers-favicon-cache-34ip"&gt;Favicon cache fixes after deploy&lt;/a&gt; · &lt;a href="https://arnavjhajharia.github.io/ogkit/og-image-size/#linkedin-og-image" rel="noopener noreferrer"&gt;LinkedIn OG image size&lt;/a&gt; · &lt;a href="https://arnavjhajharia.github.io/ogkit/og-image-size/#whatsapp-link-preview" rel="noopener noreferrer"&gt;WhatsApp link preview&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written by an AI agent working for the author and checked against the platforms' docs. ogkit is MIT-licensed: &lt;a href="https://github.com/arnavjhajharia/ogkit" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>html</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Favicon not updating after deploy? 5 fixes for the browser's favicon cache</title>
      <dc:creator>Bharat Ankh</dc:creator>
      <pubDate>Tue, 03 Nov 2026 16:04:21 +0000</pubDate>
      <link>https://dev.to/bharat_ankh_920bf0864220a/favicon-not-updating-after-deploy-5-fixes-for-the-browsers-favicon-cache-34ip</link>
      <guid>https://dev.to/bharat_ankh_920bf0864220a/favicon-not-updating-after-deploy-5-fixes-for-the-browsers-favicon-cache-34ip</guid>
      <description>&lt;p&gt;You deployed a new favicon, hard-refreshed, and the tab still shows the old one. That is normal: browsers keep favicons in their own cache (Chrome, for example, stores them in a separate favicon database), so a page reload often doesn't re-fetch the icon.&lt;/p&gt;

&lt;p&gt;Here are the fixes, roughly in the order worth trying.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Cache-bust the icon URL
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/favicon.ico?v=2"&lt;/span&gt; &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"32x32"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/icon.svg?v=2"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/svg+xml"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"apple-touch-icon"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/apple-touch-icon.png?v=2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bump &lt;code&gt;v=&lt;/code&gt; every time the icon changes. A new URL is a new cache entry, so the browser has to fetch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Open the icon file directly
&lt;/h2&gt;

&lt;p&gt;Visit &lt;code&gt;https://yoursite.com/favicon.ico&lt;/code&gt; in a tab and hard-reload that tab (Cmd/Ctrl+Shift+R). That often replaces the cached copy faster than reloading the page. A fresh browser profile or a private window is the quickest way to check what new visitors see.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Replace &lt;code&gt;/favicon.ico&lt;/code&gt; at the domain root
&lt;/h2&gt;

&lt;p&gt;Some browsers and tools request &lt;code&gt;/favicon.ico&lt;/code&gt; at the root even when your HTML points somewhere else. If an old &lt;code&gt;favicon.ico&lt;/code&gt; is still sitting at the root (or cached on your CDN), it keeps showing up. Replace it, or purge it from the CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Ship the sizes each platform asks for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;favicon.ico&lt;/code&gt; (16 and 32 inside it) for classic browser tabs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apple-touch-icon.png&lt;/code&gt; at 180x180 for iOS home screens&lt;/li&gt;
&lt;li&gt;192x192 and 512x512 PNGs listed in &lt;code&gt;site.webmanifest&lt;/code&gt; for Android / PWA installs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one of these is missing, that platform falls back to whatever it had before, which looks like "the old icon won't go away".&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make the manifest point at files that exist
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;site.webmanifest&lt;/code&gt; lists &lt;code&gt;/icon-192.png&lt;/code&gt; but you deployed &lt;code&gt;android-chrome-192x192.png&lt;/code&gt;, installed PWAs keep the old icon. Open the manifest URL in the browser and click each icon path to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference
&lt;/h2&gt;

&lt;p&gt;The full size table and a paste-ready &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; block are in this guide: &lt;a href="https://arnavjhajharia.github.io/favikit/favicon-sizes/#force-favicon-refresh" rel="noopener noreferrer"&gt;favicon sizes and cache fixes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather not assemble the set by hand, &lt;a href="https://arnavjhajharia.github.io/favikit/" rel="noopener noreferrer"&gt;Favikit&lt;/a&gt; is a free, MIT-licensed generator (&lt;a href="https://github.com/arnavjhajharia/favikit" rel="noopener noreferrer"&gt;repo&lt;/a&gt;) that runs fully in the browser: you drop in one image and it outputs the &lt;code&gt;.ico&lt;/code&gt;, the PNG sizes, the manifest and the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; tags. Nothing is uploaded.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this post and Favikit were written with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>pwa</category>
      <category>frontend</category>
    </item>
    <item>
      <title>App Store screenshot sizes in 2026: which ones are actually required</title>
      <dc:creator>Bharat Ankh</dc:creator>
      <pubDate>Tue, 03 Nov 2026 15:48:38 +0000</pubDate>
      <link>https://dev.to/bharat_ankh_920bf0864220a/app-store-screenshot-sizes-in-2026-which-ones-are-actually-required-24f5</link>
      <guid>https://dev.to/bharat_ankh_920bf0864220a/app-store-screenshot-sizes-in-2026-which-ones-are-actually-required-24f5</guid>
      <description>&lt;p&gt;Apple and Google both reject uploads that are the wrong pixel size or have an alpha channel. The confusing part is that &lt;strong&gt;blog posts disagree on which iPhone sizes are "required"&lt;/strong&gt; in 2026 — because Apple only forces the &lt;em&gt;fallback&lt;/em&gt; sizes when you skip the newer ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer (November 2026)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;App Store&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Required:&lt;/strong&gt; one Dynamic Island iPhone set at &lt;strong&gt;1179 × 2556&lt;/strong&gt; (or 1206 × 2622).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Required if iPad:&lt;/strong&gt; &lt;strong&gt;2064 × 2752&lt;/strong&gt; (or 2048 × 2732).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional but useful master:&lt;/strong&gt; design at &lt;strong&gt;1290 × 2796&lt;/strong&gt;, then export the required sizes from that artboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Google Play&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Between 320 and 3840 px per side; long side ≤ 2× short side.&lt;/li&gt;
&lt;li&gt;For featuring: at least &lt;strong&gt;4&lt;/strong&gt; screenshots, &lt;strong&gt;1080 px&lt;/strong&gt; or wider (e.g. 1080 × 1920 portrait).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full table with sources: &lt;a href="https://arnavjhajharia.github.io/appshot/screenshot-sizes/#screenshot-sizes-all-devices" rel="noopener noreferrer"&gt;App Store &amp;amp; Play screenshot sizes (Nov 2026)&lt;/a&gt; (6.5" fallback rules: &lt;a href="https://arnavjhajharia.github.io/appshot/screenshot-sizes/#6-5-inch-display" rel="noopener noreferrer"&gt;#6-5-inch-display&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Transparent PNGs&lt;/strong&gt; — both stores reject alpha in listing screenshots. Export JPEG or flatten PNG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only 6.7" assets&lt;/strong&gt; — fine if you also ship 1179 × 2556; otherwise Connect nags you for the medium Dynamic Island slot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stretching a 1080p capture&lt;/strong&gt; — upscaling looks soft; capture on a device or emulator at native resolution, then frame it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Free tool (nothing uploaded)
&lt;/h2&gt;

&lt;p&gt;I made &lt;a href="https://arnavjhajharia.github.io/appshot/" rel="noopener noreferrer"&gt;AppShot&lt;/a&gt; to drop a raw capture, add a frame + headline, and export &lt;strong&gt;exact store pixels&lt;/strong&gt; in one zip — all client-side.&lt;/p&gt;

&lt;p&gt;The size cheat sheet is kept up to date here: &lt;a href="https://arnavjhajharia.github.io/appshot/screenshot-sizes/" rel="noopener noreferrer"&gt;screenshot-sizes guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pro layouts:&lt;/strong&gt; if you want caption formulas + 30 layout presets (not just frames), &lt;a href="https://bharatankh.gumroad.com/l/appshot-pro" rel="noopener noreferrer"&gt;AppShot Pro ($12 on Gumroad)&lt;/a&gt; imports into the free tool — still nothing uploaded.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this post was drafted by AI agents I run and fact-checked against &lt;a href="https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/" rel="noopener noreferrer"&gt;Apple's screenshot specifications page&lt;/a&gt; on Nov 3, 2026. AppShot itself was also built with AI coding agents; MIT repo &lt;a href="https://github.com/arnavjhajharia/appshot" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>android</category>
      <category>mobile</category>
      <category>appstore</category>
    </item>
    <item>
      <title>Faking a shortwave radio in the browser: static, heterodyne whistles and stations that bleed into each other (Web Audio)</title>
      <dc:creator>Bharat Ankh</dc:creator>
      <pubDate>Tue, 03 Nov 2026 05:00:41 +0000</pubDate>
      <link>https://dev.to/bharat_ankh_920bf0864220a/faking-a-shortwave-radio-in-the-browser-static-heterodyne-whistles-and-stations-that-bleed-into-26j4</link>
      <guid>https://dev.to/bharat_ankh_920bf0864220a/faking-a-shortwave-radio-in-the-browser-static-heterodyne-whistles-and-stations-that-bleed-into-26j4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Forecast for Nowhere&lt;/strong&gt; is a small listening game: a shortwave radio for islands that aren't there. You turn one dial and the stations bleed into each other. Every sound except the voices is synthesised live with the Web Audio API: the static, the whistle you hear near a station, the drones and the time pips. No audio library is used.&lt;/p&gt;

&lt;p&gt;Disclosure: the game, its code and this write-up were produced by AI coding agents (Claude Code) working on my behalf. The island paintings and voices are AI-generated (credited in the game).&lt;/p&gt;

&lt;p&gt;Here is how the "radio" works, in about 40 lines that matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Signal strength is just a curve around each station
&lt;/h2&gt;

&lt;p&gt;Each station sits at a frequency on the dial (2.0 to 12.0). The dial position &lt;code&gt;f&lt;/code&gt; gives every station a strength between 0 and 1. A Lorentzian-shaped bump has long tails, so a station you're &lt;em&gt;near&lt;/em&gt; is still faintly there, which sounds like real radio:&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;strength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;W&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one hidden station uses a Gaussian (&lt;code&gt;Math.exp(-((f - s.f) / KW) ** 2)&lt;/code&gt;) instead. Its tails die fast, so you only find it if you're right on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Every station is always playing, on one shared clock
&lt;/h2&gt;

&lt;p&gt;All stations start at once as looping buffers, each at its own phase offset, so turning back to a station drops you into the broadcast "already in progress" rather than restarting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;L&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tuning never starts or stops anything. It only moves gains and filters.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Each station's chain: band-limit, then let the filter open as you tune in
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;buffer -&amp;gt; highpass 260 Hz -&amp;gt; lowpass -&amp;gt; waveshaper (soft clip) -&amp;gt; gain -&amp;gt; master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every animation frame, for each station with strength &lt;code&gt;v&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// louder&lt;/span&gt;
&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5200&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// brighter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slightly off-station sounds muffled and distant. Dead-on, the lowpass opens to ~5.7 kHz and the voice becomes clear. &lt;code&gt;setTargetAtTime&lt;/code&gt; gives smooth glides with no zipper noise as you drag.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Static that breathes
&lt;/h2&gt;

&lt;p&gt;Static is 3 seconds of white noise (&lt;code&gt;Math.random() * 2 - 1&lt;/code&gt;) looped through a wide bandpass. Three touches make it feel alive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;its level follows the &lt;em&gt;inverse&lt;/em&gt; of the total signal, so static swells between stations;&lt;/li&gt;
&lt;li&gt;a 0.23 Hz LFO wobbles the static gain, like the ionosphere fading in and out;&lt;/li&gt;
&lt;li&gt;the bandpass centre also shifts with the dial (&lt;code&gt;900 + f * 220&lt;/code&gt; Hz), so the hiss changes colour as you turn.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. The heterodyne whistle
&lt;/h2&gt;

&lt;p&gt;Real receivers whistle when you're close to a carrier but not on it. That beat note is higher the further off you are. Faking it is one oscillator:&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;off&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;nearest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;whistle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;whistleG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&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;v * (1 - v)&lt;/code&gt; is zero far away &lt;em&gt;and&lt;/em&gt; zero dead-on, and peaks halfway. So the whistle only shows up in the "almost" zone, and it guides you in by ear.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Text that tunes in too
&lt;/h2&gt;

&lt;p&gt;Subtitles are garbled in proportion to signal: below a threshold, words are deterministically replaced with dashes, so the same words drop out each time instead of flickering randomly. The station's painting is cross-faded the same way, with a little jitter when the signal is weak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Play it
&lt;/h2&gt;

&lt;p&gt;It runs in any browser, on desktop (drag or arrow keys) or phone (drag the dial), and takes about 6-10 minutes to hear every island (headphones recommended):&lt;br&gt;
&lt;a href="https://playgama.ai/play/wydweupfpvcwcc" rel="noopener noreferrer"&gt;https://playgama.ai/play/wydweupfpvcwcc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've built sound-driven UI in Web Audio, I'd like to hear how you handled smoothing at 60 fps. &lt;code&gt;setTargetAtTime&lt;/code&gt; was enough here, but I'm not sure it scales.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webaudio</category>
      <category>gamedev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One button per player: the physics, blame rules and CPU behind a 4-player moth party game</title>
      <dc:creator>Bharat Ankh</dc:creator>
      <pubDate>Tue, 03 Nov 2026 04:56:32 +0000</pubDate>
      <link>https://dev.to/bharat_ankh_920bf0864220a/one-button-per-player-the-physics-blame-rules-and-cpu-behind-a-4-player-moth-party-game-49j8</link>
      <guid>https://dev.to/bharat_ankh_920bf0864220a/one-button-per-player-the-physics-blame-rules-and-cpu-behind-a-4-player-moth-party-game-49j8</guid>
      <description>&lt;p&gt;&lt;strong&gt;Play it here (free, in the browser, 1-4 players):&lt;/strong&gt; &lt;a href="https://playgama.ai/play/nuxkra3i4xiowc" rel="noopener noreferrer"&gt;https://playgama.ai/play/nuxkra3i4xiowc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Moth Party is entered in &lt;a class="mentioned-user" href="https://dev.to/playgama"&gt;@playgama&lt;/a&gt;'s **Player Two Found&lt;/em&gt;* challenge for games you play with the friend sitting next to you. #PlayerTwoFound*&lt;/p&gt;

&lt;p&gt;Moth Party is a party game where each player gets &lt;strong&gt;exactly one button&lt;/strong&gt;. Hold it and your moth flies forward. Let go and it spins in place. You steer by timing your release: let go, wait until the moth faces where you want to go, then hold again.&lt;/p&gt;

&lt;p&gt;That's the whole control scheme, so four people can share one keyboard (Q / P / Z / M) or one phone (each player holds a corner). Grab glowing sparks, ram rivals to knock theirs loose, and get away from the lamp when it flickers. Rounds last 45 seconds, and the first moth to win 3 rounds takes the match. Empty seats are filled by CPU moths, so it works solo too.&lt;/p&gt;

&lt;p&gt;The fun part was making one button feel like enough. Some notes on how it works, all from the ~200-line simulation file.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The physics: two states, one gravity
&lt;/h2&gt;

&lt;p&gt;Each moth is always doing exactly one of two things:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;THRUST&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vy&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;THRUST&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;  &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;SPIN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;           &lt;span class="c1"&gt;// 4.2 rad/s, about 1.5 s per full turn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of that, a light pull toward the lamp at the centre, exponential drag and a speed cap. The pull matters most. If you don't hold the button, you drift into the light, which is exactly what a moth would do. It also keeps everyone near the middle of the arena, where the collisions happen.&lt;/p&gt;

&lt;p&gt;The arena wall reflects you at about 70% of your incoming speed, so a hard wall hit throws you back into the fight instead of leaving you stuck at the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Who hit whom?
&lt;/h2&gt;

&lt;p&gt;When two moths overlap, they swap momentum along the collision normal (a bit bouncier than elastic, 1.15x, because bouncy is funnier). The interesting question is &lt;strong&gt;blame&lt;/strong&gt;. If the relative speed is above a threshold, the moth that was moving &lt;em&gt;into&lt;/em&gt; the hit faster is the hitter, and the other one is knocked:&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;hitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;va&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;vb&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="nx"&gt;victim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hitter&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;B&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;victim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;carry&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="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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;victim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;carry&lt;/span&gt; &lt;span class="o"&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The victim drops a third of their sparks (at least one) and is stunned for 0.45 s. Dropped sparks can't be re-grabbed for 0.35 s, so the hitter can't just vacuum them up. Anyone nearby gets a shot at them. That one rule creates most of the "NOOO" moments.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The lamp is the third player
&lt;/h2&gt;

&lt;p&gt;The lamp cycles calm → warn (it flickers) → flare. Any moth inside the flare radius loses &lt;strong&gt;half&lt;/strong&gt; its sparks and is blasted outward. So the safest place to collect sparks is also the most dangerous place to hold them. The richer you are, the more the flicker matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A CPU that only has one button too
&lt;/h2&gt;

&lt;p&gt;The CPU moths play under the same rule: they only decide &lt;em&gt;hold or release&lt;/em&gt; each frame. The bot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;picks a target: run outward if the lamp is about to flare, otherwise the nearest spark, or a &lt;strong&gt;rich rival&lt;/strong&gt; (3+ sparks) if one is close enough to be worth ramming, aiming a little ahead of where they're going;&lt;/li&gt;
&lt;li&gt;holds while it's facing the target within a cone, and releases to spin otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Difficulty (sleepy / buzzing / feral) is just two numbers: random aim jitter and a wider "close enough" cone. A sleepy moth over-rotates and wobbles, while a feral one lines up and rams you.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Testing a party game with no humans
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;node test.js&lt;/code&gt; runs &lt;strong&gt;40 all-CPU matches&lt;/strong&gt; at 60 Hz with mixed skill levels and checks that nothing is NaN, no moth escapes the arena, every match ends with one champion, and no seat is favoured. Latest run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"grab"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;14475&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"bump"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3852&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"burn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;454&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"rounds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;217&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"winsBy"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nl"&gt;"ties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;ALL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;grabs/round&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;66.7&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;bumps/round&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;17.8&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;burns/round&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rounds/match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those rates are also the design dials. If bumps per round drop under 2, the game feels like solo spark-collecting. If burns drop under 0.5, the lamp stops being scary. The test fails in both cases, so a tuning change can't quietly kill the party.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;1-4 players, one button each (Q / P / Z / M, or hold a screen corner on mobile)&lt;/li&gt;
&lt;li&gt;Empty seats = CPU moths (sleepy / buzzing / feral)&lt;/li&gt;
&lt;li&gt;▶ &lt;a href="https://playgama.ai/play/nuxkra3i4xiowc" rel="noopener noreferrer"&gt;https://playgama.ai/play/nuxkra3i4xiowc&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Best with friends on one laptop. Tell me who ended up as the villain.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;AI disclosure: the game's code was written with an AI coding assistant (Claude). This post was also written by an AI agent working on my behalf (the facts, numbers and code snippets are from the game's source and its test run). Art is drawn on canvas in code and sound is WebAudio synthesis. No downloaded assets.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>playgama</category>
    </item>
    <item>
      <title>Porting 5 vanilla-JS games to a web game portal without touching game code: notes on Playgama Bridge</title>
      <dc:creator>Bharat Ankh</dc:creator>
      <pubDate>Tue, 03 Nov 2026 04:54:15 +0000</pubDate>
      <link>https://dev.to/bharat_ankh_920bf0864220a/porting-5-vanilla-js-games-to-a-web-game-portal-without-touching-game-code-notes-on-playgama-bridge-489o</link>
      <guid>https://dev.to/bharat_ankh_920bf0864220a/porting-5-vanilla-js-games-to-a-web-game-portal-without-touching-game-code-notes-on-playgama-bridge-489o</guid>
      <description>&lt;p&gt;I had five small HTML5 games (no engine, plain JS + Web Audio) and wanted them on a web game portal that shares ad revenue. I picked Playgama because it has a self-serve sandbox: upload a zip and the game goes live on a sandbox page the same day. To graduate to the main catalog, a game has to hit three targets: &lt;strong&gt;100 qualifying plays, 5:00 average play time, and 0.30 ad shows per play&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Disclosure: the games, the code and this write-up were produced by AI coding agents (Claude Code) working on my behalf; the notes below are from their build logs. Playgama's upload form asks which agent/model built the game, and the listings carry an AI badge.&lt;/p&gt;

&lt;p&gt;What follows is the integration layer, because it ended up being more interesting than I expected. The rule I set: &lt;strong&gt;the game source stays untouched&lt;/strong&gt;. Every portal-specific behaviour lives in one adapter file plus a build step.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A tiny portal API the games call
&lt;/h2&gt;

&lt;p&gt;Each game only knows about a five-method object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;// gameplay began (level / match / song)&lt;/span&gt;
&lt;span class="nx"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;       &lt;span class="c1"&gt;// gameplay ended or paused&lt;/span&gt;
&lt;span class="nx"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;happy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;// a "nice moment" (record, achievement)&lt;/span&gt;
&lt;span class="nx"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;midgame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// natural break: maybe show an ad, then ALWAYS call cb exactly once&lt;/span&gt;
&lt;span class="nx"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAudio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="c1"&gt;// hand over the AudioContext so the adapter can mute it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no SDK present every call is a no-op and &lt;code&gt;midgame(cb)&lt;/code&gt; just runs &lt;code&gt;cb&lt;/code&gt;, so the same build runs on GitHub Pages. The build script injects the five or six calls into each game with string patches, and the build fails loudly if a patch target is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Load order: run the game only after the SDK is ready
&lt;/h2&gt;

&lt;p&gt;Playgama's required steps are: initialize the Bridge, load saved progress, then send &lt;code&gt;game_ready&lt;/code&gt; once the player can interact. Only &lt;code&gt;game_ready&lt;/code&gt; is mandatory. The &lt;code&gt;level_started&lt;/code&gt; / &lt;code&gt;level_completed&lt;/code&gt; family is optional but useful.&lt;/p&gt;

&lt;p&gt;The trick that made "don't touch game code" possible: the build rewrites every game &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; to &lt;code&gt;type="text/x-pg-deferred"&lt;/code&gt;, so the browser ignores them. The adapter then runs them in order &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;bridge.initialize()&lt;/code&gt; and the save read:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;initBridge&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// with a 10 s timeout so a dead SDK never strands the player&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadSaves&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;           &lt;span class="c1"&gt;// one storage.get for everything&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;domReady&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runDeferred&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;         &lt;span class="c1"&gt;// re-create each deferred &amp;lt;script&amp;gt; in order&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// wait for a painted frame&lt;/span&gt;
  &lt;span class="k"&gt;await&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;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game_ready&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;&lt;strong&gt;Gotcha:&lt;/strong&gt; because game scripts now run &lt;em&gt;after&lt;/em&gt; &lt;code&gt;DOMContentLoaded&lt;/code&gt; has already fired, any game code doing &lt;code&gt;document.addEventListener('DOMContentLoaded', init)&lt;/code&gt; silently never runs. Check &lt;code&gt;document.readyState&lt;/code&gt; instead, or patch it in the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Saves: swap localStorage for platform storage
&lt;/h2&gt;

&lt;p&gt;The games all use &lt;code&gt;localStorage&lt;/code&gt;. The adapter replaces &lt;code&gt;window.localStorage&lt;/code&gt; with an in-memory shim that is filled from one &lt;code&gt;bridge.storage.get&lt;/code&gt; at startup and written back debounced to one key. No game code changed, and saves follow the player across devices where the platform supports it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Audio: one controller, many reasons to be muted
&lt;/h2&gt;

&lt;p&gt;Portals reject games that keep playing sound during ads, when the tab is hidden, or when the platform says audio is off. Several things can want silence at once, so the adapter keeps a &lt;em&gt;set of reasons&lt;/em&gt; and suspends every tracked &lt;code&gt;AudioContext&lt;/code&gt; while the set is non-empty:&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;function&lt;/span&gt; &lt;span class="nf"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;applyAudio&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// suspend all if any reason, resume ones we suspended if none&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// reasons: 'ad', 'hidden', 'platform-pause', 'platform-audio-off'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also wraps the &lt;code&gt;AudioContext&lt;/code&gt; constructor so contexts the game creates are tracked automatically. And if the game calls &lt;code&gt;ctx.resume()&lt;/code&gt; while a reason is active, the call is held back until every reason clears.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ads only at natural breaks, and never strand the player
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;midgame(cb)&lt;/code&gt; asks for an interstitial and &lt;strong&gt;always&lt;/strong&gt; calls &lt;code&gt;cb&lt;/code&gt; exactly once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if the SDK skips the ad (minimum delay not elapsed), no "loading" state ever arrives, so a 2.5 s timer continues the game;&lt;/li&gt;
&lt;li&gt;on &lt;code&gt;closed&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;, continue;&lt;/li&gt;
&lt;li&gt;a 45 s safety timeout as a last resort.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rewarded ads (a hint for an ad in the puzzle game) are only offered where &lt;code&gt;isRewardedSupported&lt;/code&gt; is true. Everywhere else hints stay free, so no button ever leads nowhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Small things that cost me time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google Fonts&lt;/strong&gt;: self-host them at build time. Portals run games in iframes with strict policies, and one font CSS response came back without a woff2 URL and crashed the build. The build now skips such blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-origin isolated hosts&lt;/strong&gt; (COOP/COEP): one external request without CORP headers breaks the page, so the builds make zero external requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel test runs on one machine&lt;/strong&gt;: fixed CDP/http ports collided and one test served the wrong game. Use random ports.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The games
&lt;/h2&gt;

&lt;p&gt;They're live on Playgama's sandbox now. Feedback on the first minute (is it clear what to do?) is very welcome, because average play time is the target I'm least sure about.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Seedsong&lt;/strong&gt;: a rhythm garden. Hit seeds on the beat and a night garden blooms. Type any word and it grows its own song, plus a daily "Today's song". &lt;a href="https://playgama.ai/play/h2lla7p4eujkc6" rel="noopener noreferrer"&gt;https://playgama.ai/play/h2lla7p4eujkc6&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moth Party&lt;/strong&gt;: one button each, four moths, one lamp: a party game for up to 4 players on one keyboard or phone, with CPU moths for solo play. &lt;a href="https://playgama.ai/play/nuxkra3i4xiowc" rel="noopener noreferrer"&gt;https://playgama.ai/play/nuxkra3i4xiowc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unclaimed&lt;/strong&gt;: one rainy night as the clerk of a tiny railway station's lost property office. &lt;a href="https://playgama.ai/play/5sc6xnrxe4da5o" rel="noopener noreferrer"&gt;https://playgama.ai/play/5sc6xnrxe4da5o&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forecast for Nowhere&lt;/strong&gt;: a shortwave radio for islands that aren't there. Turn the dial; the stations bleed into each other. &lt;a href="https://playgama.ai/play/wydweupfpvcwcc" rel="noopener noreferrer"&gt;https://playgama.ai/play/wydweupfpvcwcc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lantern Drift&lt;/strong&gt;: a sliding-lantern puzzle, 28 nights + a daily lantern. &lt;a href="https://playgama.ai/play/nvk6egiap5t7kd" rel="noopener noreferrer"&gt;https://playgama.ai/play/nvk6egiap5t7kd&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've shipped to Playgama, CrazyGames or Poki: what moved your average play time the most?&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>html</category>
    </item>
    <item>
      <title>I made a rhythm game where the song doesn't exist until you play it (160 lines of procedural WebAudio)</title>
      <dc:creator>Bharat Ankh</dc:creator>
      <pubDate>Tue, 03 Nov 2026 04:49:52 +0000</pubDate>
      <link>https://dev.to/bharat_ankh_920bf0864220a/i-made-a-rhythm-game-where-the-song-doesnt-exist-until-you-play-it-160-lines-of-procedural-2pph</link>
      <guid>https://dev.to/bharat_ankh_920bf0864220a/i-made-a-rhythm-game-where-the-song-doesnt-exist-until-you-play-it-160-lines-of-procedural-2pph</guid>
      <description>&lt;p&gt;&lt;strong&gt;Play it here (free, in the browser):&lt;/strong&gt; &lt;a href="https://playgama.ai/play/h2lla7p4eujkc6" rel="noopener noreferrer"&gt;https://playgama.ai/play/h2lla7p4eujkc6&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most rhythm games start with a finished track and build a chart on top of it. Seedsong goes the other way. Each note you hit &lt;strong&gt;is&lt;/strong&gt; the melody. If you miss one, that note just never plays. The garden around the ring blooms as you play, and every stage adds a new layer to the arrangement:&lt;/p&gt;

&lt;p&gt;roots (bass) → canopy (pads) → rustle (drums) → fireflies (arpeggio) → aurora (bells)&lt;/p&gt;

&lt;p&gt;If you miss, flowers wilt and the layers drop out again. The song you hear is a record of how well you played.&lt;/p&gt;

&lt;p&gt;There are no audio files in the game. Everything comes from one ~160-line composer (&lt;code&gt;songgen.js&lt;/code&gt;) and WebAudio oscillators.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (Nov 3):&lt;/strong&gt; this post describes the first version, which synthesised every note with oscillators. The live build now plays the same composed notes on CC0 recorded instruments (VSCO 2 Community Edition harp, pizzicato bass, strings and glockenspiel, pitched with &lt;code&gt;playbackRate&lt;/code&gt;), because the game portal it's published on doesn't accept code-synthesised sound. The composer below is unchanged.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. A seed in, a whole song out
&lt;/h2&gt;

&lt;p&gt;Everything is deterministic from a string. A tiny FNV-1a hash turns the string into a 32-bit number, and a mulberry32 PRNG grows from that number:&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;function&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2166136261&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;h&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;imul&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="mi"&gt;16777619&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;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that RNG the game picks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;mode&lt;/strong&gt; (ionian, dorian, aeolian, mixolydian, lydian)&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;tempo&lt;/strong&gt; (84-124 BPM in steps of 4)&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;root note&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;three &lt;strong&gt;chord progressions&lt;/strong&gt; (A, B, C) from a small hand-picked list, e.g. &lt;code&gt;[0, 5, 2, 6]&lt;/code&gt; = I-vi-iii-vii in scale degrees&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it arranges them as &lt;code&gt;intro (2 bars) → A → B → A → C → outro (2 bars)&lt;/code&gt;. The energy rises from 0 to 2 across the song.&lt;/p&gt;

&lt;p&gt;This makes &lt;strong&gt;seed mode&lt;/strong&gt; possible. You type any word ("pancakes", your cat's name, whatever) and a new song grows from it. The same word always gives the same song, so you can send a word to a friend and you'll both play the same chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The melody walks between chord tones
&lt;/h2&gt;

&lt;p&gt;A melody made of random notes sounds random. So the melody walks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On &lt;strong&gt;strong beats&lt;/strong&gt; (every 4th sixteenth), the melody snaps to the &lt;em&gt;nearest chord tone&lt;/em&gt; of the current chord, in any octave. Half the time it skips a plain repeat, so the line keeps moving.&lt;/li&gt;
&lt;li&gt;On &lt;strong&gt;weak beats&lt;/strong&gt;, it steps by a small interval picked from &lt;code&gt;[-2, -1, -1, 1, 1, 2, 0]&lt;/code&gt;. Small steps are more likely than leaps.&lt;/li&gt;
&lt;li&gt;It stays inside a range of scale degrees 3-13, so it never wanders off the keyboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rhythm comes from short 16-step patterns grouped into 3 difficulty tiers. Each 2-bar motif is re-rolled every 4 bars, so phrases repeat enough to feel like a song but not enough to get boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The chart follows the melody's shape
&lt;/h2&gt;

&lt;p&gt;I like this part the most. The chart isn't placed separately from the music. The melody's direction decides which of the 4 lanes each seed comes from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// melody up = clockwise, down = counter-clockwise, repeat = jump across&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deg&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevDeg&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;lane&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastLane&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mv&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="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mv&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the tune climbs, your hands move clockwise around the ring. When it falls, they move back. A repeated note sends you to the opposite side. After a minute you start to &lt;em&gt;feel&lt;/em&gt; the melody through your fingers, even on a song generated a second ago.&lt;/p&gt;

&lt;p&gt;On hard difficulty, high-energy bars add a harmony note a third below on the opposite lane. That gives you chords to hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Testing a game that's random
&lt;/h2&gt;

&lt;p&gt;Procedural charts can produce unplayable patterns, so &lt;code&gt;node test.js&lt;/code&gt; checks every built-in song and difficulty:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no same-lane "jacks" faster than 120 ms&lt;/li&gt;
&lt;li&gt;lanes stay balanced&lt;/li&gt;
&lt;li&gt;duration is sane&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;determinism&lt;/strong&gt;: the same seed gives the same chart, every time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The composer runs in both Node and the browser (&lt;code&gt;window.SG&lt;/code&gt; / &lt;code&gt;module.exports&lt;/code&gt;), so the music logic can be unit-tested without a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A new song every day
&lt;/h2&gt;

&lt;p&gt;The newest update (rolling out on Playgama now) adds a &lt;strong&gt;"Today's song ✦"&lt;/strong&gt; button next to Plant. It seeds the composer with the local date, so everyone gets the same new song each day, and a streak counts how many days in a row you've played. Under the hood it's just &lt;code&gt;songFromText('today ' + date)&lt;/code&gt;, and the deterministic seed makes it work for free.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Controls: arrow keys / WASD, or tap the lanes on a phone&lt;/li&gt;
&lt;li&gt;3 hand-tuned songs (Dawn Moss, Lantern Tide, Kite Static) + seed mode + today's song&lt;/li&gt;
&lt;li&gt;▶ &lt;a href="https://playgama.ai/play/h2lla7p4eujkc6" rel="noopener noreferrer"&gt;https://playgama.ai/play/h2lla7p4eujkc6&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd love to hear which words give the best songs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;AI disclosure: the game's code was written with an AI coding assistant (Claude). This post was also written by an AI agent working on my behalf (the facts and code snippets are from the game's source). The music is not from an AI music model. It is generated at runtime by the game's own procedural composer described above. No samples, no downloaded assets.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webaudio</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built 3 free, browser-only tools for the boring parts of shipping an app (favicons, store screenshots, OG images)</title>
      <dc:creator>Bharat Ankh</dc:creator>
      <pubDate>Tue, 03 Nov 2026 01:14:33 +0000</pubDate>
      <link>https://dev.to/bharat_ankh_920bf0864220a/i-built-3-free-browser-only-tools-for-the-boring-parts-of-shipping-an-app-favicons-store-5hfa</link>
      <guid>https://dev.to/bharat_ankh_920bf0864220a/i-built-3-free-browser-only-tools-for-the-boring-parts-of-shipping-an-app-favicons-store-5hfa</guid>
      <description>&lt;p&gt;Every time I ship a side project I lose an afternoon to the same three chores: generating every favicon/app-icon size, making App Store / Play Store screenshots that don't look like raw phone captures, and producing an og:image so the link doesn't unfurl as a grey box.&lt;/p&gt;

&lt;p&gt;Every tool I found for these either uploads your files to a server, needs an account, or hides the sizes you actually need behind a subscription. So I built three small ones that run entirely in the browser. Nothing is uploaded, there is no login, and they are free.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Favikit, favicons and app icons
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://arnavjhajharia.github.io/favikit/" rel="noopener noreferrer"&gt;https://arnavjhajharia.github.io/favikit/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drop an SVG or PNG and get the icon set: &lt;code&gt;favicon.ico&lt;/code&gt;, the standard PNG sizes, &lt;code&gt;apple-touch-icon&lt;/code&gt; 180 px, Android 192/512 px, plus the &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags and a web manifest snippet to paste. Everything is resized on a canvas in your tab and zipped locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. AppShot, App Store and Google Play screenshots
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://arnavjhajharia.github.io/appshot/" rel="noopener noreferrer"&gt;https://arnavjhajharia.github.io/appshot/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drop a raw screenshot, pick a device frame (iPhone, Android, iPad or none), add a headline and a gradient background, and export at the exact store sizes: 1290x2796, 1242x2688, 2048x2732, 1080x1920, 1080x2400. There is a "download all sizes" button that zips them. The device frames are drawn on canvas, so there are no external assets and no watermark.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. ogkit, Open Graph / social card images
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://arnavjhajharia.github.io/ogkit/" rel="noopener noreferrer"&gt;https://arnavjhajharia.github.io/ogkit/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five layouts (including a code-snippet card for dev posts), film-grain overlay, export at 1200x630, @2x, and the 1600x900 Twitter size. It also copies the &lt;code&gt;&amp;lt;meta property="og:..."&amp;gt;&lt;/code&gt; block so you don't have to look up the attribute names again.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they're built
&lt;/h2&gt;

&lt;p&gt;Each tool is a single &lt;code&gt;index.html&lt;/code&gt; on GitHub Pages: plain canvas, JSZip for the "download all" buttons, no framework, no backend, zero running cost. If you want to see how a part works, view-source is the documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;These are young. If a size is missing or something renders wrong on your device, tell me in the comments and I'll fix it.&lt;/li&gt;
&lt;li&gt;The three tools link to each other in the footer, and there is a paid pack of 100 mesh-gradient backgrounds (the kind of thing you put behind a store screenshot or an OG card) on Gumroad. The tools don't need it and never nag you about it.&lt;/li&gt;
&lt;li&gt;Disclosure: most of the code and this post were written by AI agents (Claude) working on my behalf, and each tool was smoke-tested in a real Chrome session before publishing. If that's not your thing, no hard feelings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the fourth chore you'd want automated before shipping? I'm collecting the list.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
