<?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: Forrest Miller</title>
    <description>The latest articles on DEV Community by Forrest Miller (@forrestmiller).</description>
    <link>https://dev.to/forrestmiller</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%2F3872475%2F1b38c5a4-9313-4bc3-8bfd-a21db422888e.jpg</url>
      <title>DEV Community: Forrest Miller</title>
      <link>https://dev.to/forrestmiller</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/forrestmiller"/>
    <language>en</language>
    <item>
      <title>How we built real-time multiplayer bingo for 20 players per room on Next.js 16, Ably, and Supabase</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 16:55:59 +0000</pubDate>
      <link>https://dev.to/forrestmiller/how-we-built-real-time-multiplayer-bingo-for-20-players-per-room-on-nextjs-16-ably-and-supabase-10lb</link>
      <guid>https://dev.to/forrestmiller/how-we-built-real-time-multiplayer-bingo-for-20-players-per-room-on-nextjs-16-ably-and-supabase-10lb</guid>
      <description>&lt;p&gt;I ship &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; — a free, no-signup multiplayer bingo platform. The core feature is the part I want to write about: up to 20 people opening one link, every player landing on their own independently-shuffled board, every tap resolving against an atomic Postgres RPC, and the server detecting bingo so nobody has to adjudicate by hand.&lt;/p&gt;

&lt;p&gt;Most of the interesting decisions sit in three trade-off pairs. None of them are obvious from a brief, and each one cost us a regression before it stabilised.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One unified &lt;code&gt;tap&lt;/code&gt; event, not separate claim/unclaim
&lt;/h2&gt;

&lt;p&gt;The first version had &lt;code&gt;claim&lt;/code&gt; and &lt;code&gt;unclaim&lt;/code&gt; events on the Ably channel. Race conditions appeared the moment two players tapped the same cell within a second of each other — the broadcast order didn't match the database write order, so a fast double-tap could end up with the cell rendered as "unclaimed" on one player's screen and "claimed" on the host's.&lt;/p&gt;

&lt;p&gt;Replacing the pair with a single &lt;code&gt;tap&lt;/code&gt; event fixed it. The server's RPC (&lt;code&gt;tap_claim&lt;/code&gt;) is the single source of truth: it reads the current state, toggles the claim, and returns the new state. The Ably broadcast carries the post-toggle state explicitly — no inference required.&lt;/p&gt;

&lt;p&gt;If you're designing a real-time game protocol: prefer events that carry the &lt;em&gt;resolved&lt;/em&gt; state rather than the &lt;em&gt;intended action&lt;/em&gt;. It eliminates the entire class of "client A and client B intend opposite things at the same time" failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Per-player boards, not a shared board
&lt;/h2&gt;

&lt;p&gt;Every player has their own &lt;code&gt;players.board&lt;/code&gt; jsonb array. A 5×5 board is 25 entries; a 3×3 is 9. The clue set is shared (all players are watching for the same prompts), but the positions are independently shuffled per player.&lt;/p&gt;

&lt;p&gt;This means there's no such thing as "the room's board." Bingo detection runs per-player in TypeScript (&lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;lib/bingo-checker.ts&lt;/a&gt;) using the per-player grid size, derived at runtime from &lt;code&gt;SQRT(jsonb_array_length(players.board))&lt;/code&gt;. We had a dedicated &lt;code&gt;players.grid_size&lt;/code&gt; column for a while — dropped it in a 2026-05 refactor because it was the third source of truth (clues, board length, grid_size column) and the three drifted under concurrency.&lt;/p&gt;

&lt;p&gt;Mixed grids in the same room are deliberate. If a mobile player joins a 5×5 desktop room, the mobile player gets a 3×3 board and wins on 3-in-a-row — the desktop players still need 5-in-a-row. This is the product spec, not a bug, because &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is a casual party / classroom / workplace game, not a competitive ladder. Forcing every player onto the same grid would either make a phone screen unreadable or waste the desktop players' real estate.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Server-authoritative round transitions
&lt;/h2&gt;

&lt;p&gt;The host doesn't decide when to start the next round. The server does, in the same RPC that processed the winning tap. The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Player taps a cell → &lt;code&gt;POST /api/game/tap&lt;/code&gt; → &lt;code&gt;tap_claim&lt;/code&gt; RPC&lt;/li&gt;
&lt;li&gt;RPC writes the claim, checks every player's board for a completed row/column/diagonal&lt;/li&gt;
&lt;li&gt;If anyone wins, the same RPC inserts the round's outcome row AND generates the next round's boards for every player atomically&lt;/li&gt;
&lt;li&gt;The full new state is returned in the response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The host's browser just renders what the server returns. Latecomers can join mid-round and pick up the current state from the same endpoint. Round transitions happen in a single SQL transaction, so there's no race where Player A sees Round 5 and Player B is still on Round 4.&lt;/p&gt;

&lt;p&gt;The catch: Vercel freezes the serverless function before Ably has finished publishing the celebration event. We solved that by &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;publishing the bingo event from the winner's browser&lt;/a&gt; — the only Ably event published from the client. Every other event is server-published.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;player-joined&lt;/code&gt; over Ably presence
&lt;/h2&gt;

&lt;p&gt;Ably presence is convenient — every connected client appears in a &lt;code&gt;members&lt;/code&gt; array — but it leaked ghost members on refresh. A player closing their browser tab didn't disappear from &lt;code&gt;members&lt;/code&gt; until Ably's heartbeat timed out, which made the "more than one human in this room" check unreliable.&lt;/p&gt;

&lt;p&gt;Replaced with a &lt;code&gt;player-joined&lt;/code&gt; Ably event published from the server when &lt;code&gt;players.length&lt;/code&gt; increments. No presence dependency, no ghost rows.&lt;/p&gt;

&lt;p&gt;While I was in there, I also added a &lt;code&gt;round_number&lt;/code&gt; filter to the Ably subscribe handler. Without it, an old round's "claim" event could replay onto the new round's board after a network reconnect — a Cell appearing claimed for a clue the player had never seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The SP/MP boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/play&lt;/code&gt; is the multiplayer route. &lt;code&gt;/cards/{slug}&lt;/code&gt; is the single-player route. There is no in-between — a "lobby" UX, a "waiting for 2nd player" screen, none of it. The instant a second player joins, both clients redirect from &lt;code&gt;/cards/{slug}&lt;/code&gt; to &lt;code&gt;/play&lt;/code&gt;. The instant a player walks into &lt;code&gt;/play&lt;/code&gt; and finds only themselves, they're redirected back.&lt;/p&gt;

&lt;p&gt;This is enforced on both sides: &lt;code&gt;useRoomLifecycle&lt;/code&gt; handles the upward redirect when a reconnect lands ≥2 players; &lt;code&gt;PlayPageClient&lt;/code&gt; handles the downward redirect when first state-applied finds &amp;lt;2 players. It survives localStorage wipes because the room id + player id are also in the URL (&lt;code&gt;?p=&amp;amp;r=&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The whole boundary is ~80 lines, but it pre-empts an entire family of "I'm stuck on a lobby screen forever" bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack — minimal pieces, opinionated wiring
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router) for the React app and server actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; Postgres for state + auth (RLS-enabled, mostly bypassed in API routes via the service-role client)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ably&lt;/strong&gt; for the real-time channel — chat is client-to-client (server publishes from-client would duplicate); claims are server-published&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind v4&lt;/strong&gt; for styling, semantic tokens, dark mode via class strategy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar and the stack helps, the actual product is at &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;bingwow.com&lt;/a&gt; — free, no signup, runs in any browser. The two surfaces that exercise the architecture most are the &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;free bingo caller&lt;/a&gt; (75/90/30-ball, voice + flashboard, projector-ready) and the &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;Real-Time Multiplayer Bingo guide&lt;/a&gt;, which is the human-readable version of this post with screenshots and a step-by-step setup.&lt;/p&gt;

&lt;p&gt;If you're trying to drop something like this into a Slack or Microsoft Teams workflow without an admin install, the &lt;a href="https://bingwow.com/for/slack" rel="noopener noreferrer"&gt;Slack-friendly link share pattern&lt;/a&gt; and the &lt;a href="https://bingwow.com/for/microsoft-teams" rel="noopener noreferrer"&gt;Microsoft Teams pattern&lt;/a&gt; are both just a normal web link — that turned out to be the underrated UX win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The under-rated win
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from a year of running this in production: &lt;strong&gt;the protocol shape decides the bug class&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Pick a &lt;em&gt;resolved state&lt;/em&gt; event payload (not an intended-action payload) and races stop being a category. Pick a &lt;em&gt;per-player board&lt;/em&gt; model (not a shared-board model) and you can ship a mobile-3×3 / desktop-5×5 mixed game on the same room without writing special code. Pick a &lt;em&gt;server-authoritative round transition&lt;/em&gt; (not client-coordinated) and "Player A is on Round 5, Player B is on Round 4" stops being possible. None of these are obvious until you've shipped the wrong shape and watched the bug reports come in.&lt;/p&gt;

&lt;p&gt;If you want to play with the result: &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is free, no signup, and a card builds from any topic in about 60 seconds.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>realtime</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Building an AI face-doppelganger prank with Flux Kontext Pro and aggressive image degradation</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 16:49:37 +0000</pubDate>
      <link>https://dev.to/forrestmiller/building-an-ai-face-doppelganger-prank-with-flux-kontext-pro-and-aggressive-image-degradation-1io1</link>
      <guid>https://dev.to/forrestmiller/building-an-ai-face-doppelganger-prank-with-flux-kontext-pro-and-aggressive-image-degradation-1io1</guid>
      <description>&lt;p&gt;A "face twin" prank pastes a public photo into an AI model, generates three plausible-looking lookalikes, and shows them to your friend inside what looks like a legit AI face-matcher. The hard part isn't the model. It's making the output look like a real photo of a real stranger.&lt;/p&gt;

&lt;p&gt;I shipped two framings of the same backend: &lt;a href="https://pleasejuststop.org" rel="noopener noreferrer"&gt;pleasejuststop.org&lt;/a&gt; (the privacy-art version) and &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt; (the consumer-prank version). Same Replicate model, same pipeline, two front-ends. Source code structure is documented in the project's &lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data" rel="noopener noreferrer"&gt;public CC BY 4.0 dataset&lt;/a&gt; and the &lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;Hugging Face dataset card&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is the technical story: the three prompts I landed on after six rounds of testing, the six degradation profiles that turn AI portraits into something that reads like a 2013 Facebook upload, and the Vercel-serverless pitfalls that made me throw out Sharp and rewrite everything on Jimp.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visual goal: real internet photos, not AI portraits
&lt;/h2&gt;

&lt;p&gt;The entire illusion hinges on the recipient believing the three output images are real photos of real strangers. The moment any image reads as AI-generated, the reveal collapses.&lt;/p&gt;

&lt;p&gt;Real internet photos share specific qualities that AI models do not produce by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lighting is bad.&lt;/strong&gt; Overhead fluorescents, harsh direct flash, uneven natural light. AI models default to soft diffused portrait lighting — the #1 tell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything is in focus.&lt;/strong&gt; Real phone cameras have deep depth of field. No bokeh. No portrait-mode blur. Portrait-mode blur is the signature of AI generation, and Flux models have a baked-in training bias toward it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skin looks like skin.&lt;/strong&gt; Pores, uneven tone, blemishes. Not smoothed-out poreless AI skin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression artifacts are visible.&lt;/strong&gt; JPEG'd to hell — uploaded to Facebook, screenshotted, forwarded on WhatsApp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution is low.&lt;/strong&gt; 400-480px wide, not crisp 1024px.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composition is casual.&lt;/strong&gt; Off-center, slightly crooked. Caught mid-moment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The litmus test: would I believe this is a real photo of a real stranger on Facebook? If lighting is too pretty, background too clean, or skin too smooth — it doesn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three prompts (verbatim from production)
&lt;/h2&gt;

&lt;p&gt;The hardest lesson here was that prompt length is a trap. Every session, Claude (and I) wanted to add defensive instructions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prompt produces a minor issue (the woman looks slightly older).&lt;/li&gt;
&lt;li&gt;Add "do not age the person."&lt;/li&gt;
&lt;li&gt;The instruction draws model attention to aging. The photo gets worse.&lt;/li&gt;
&lt;li&gt;Add MORE defensive instructions. The prompt is now 3x longer. The model is confused. The photo is terrible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;More instructions = more diluted model attention = worse results.&lt;/strong&gt; Tested exhaustively across six rounds.&lt;/p&gt;

&lt;p&gt;The fix is to remove words, not add them. Keep what the subject is wearing, where they are, and the one dramatic visible change. A good prompt is one sentence. More than three sentences and you've already lost.&lt;/p&gt;

&lt;p&gt;The three production prompts (live at both &lt;code&gt;pleasejuststop.org&lt;/code&gt; and &lt;code&gt;prankmyface.lol&lt;/code&gt;, also in the &lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data/blob/main/prompts/three-prompts.md" rel="noopener noreferrer"&gt;public data repo&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. leather-wall
Edit this photo to show this person posing against a wall. Make them frowning
and wearing a leather jacket and a knit beanie hat. One person, no hands visible.

2. tongue-collared
Edit this photo to show this person outdoors. Sticking their tongue out,
wearing a collared shirt. One person, no hands visible.

3. snow-goggles
Edit this photo to show this person outside. Wearing earmuffs and a jacket.
Give them big braces. One person, no hands visible, no glasses.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Model: &lt;code&gt;black-forest-labs/flux-kontext-pro&lt;/code&gt; on Replicate. Params: &lt;code&gt;aspect_ratio: "3:4"&lt;/code&gt;, &lt;code&gt;output_format: "png"&lt;/code&gt;, &lt;code&gt;safety_tolerance: 2&lt;/code&gt;. Setting &lt;code&gt;output_format&lt;/code&gt; to &lt;code&gt;"jpg"&lt;/code&gt; silently fails every generation — the DB stays "pending" forever, no error.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rules these prompts were built against
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gender-neutral only.&lt;/strong&gt; No beards, no mustaches, no gender-specific features — those cause gender swaps mid-generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hair COLOR changes preserve identity. Hair STYLE changes destroy identity or swap gender.&lt;/strong&gt; Curly, buzz-cut, mullet, bowl-cut — all dead ends. Use clothing, accessories, or expression instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aging prompts turn women into men.&lt;/strong&gt; Never ask the model to age the subject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bold features (jacket, beanie, earmuffs, tongue out) beat subtle features (braces, freckles, nostril ring).&lt;/strong&gt; Small details don't render reliably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One dramatic visible change per prompt.&lt;/strong&gt; More than one and the model balances them poorly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One person only; no hands.&lt;/strong&gt; Hands and second people are where the model's geometry fails first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't describe camera quality.&lt;/strong&gt; Post-processing handles that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;bokeh, shallow depth of field&lt;/code&gt; negative prompt is the load-bearing line. Without it, Flux defaults to portrait-mode blur and the photo immediately looks AI-generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six degradation profiles
&lt;/h2&gt;

&lt;p&gt;After Replicate returns the output, I run it through one of six post-processing profiles that downscale, double-JPEG-compress, color-shift, and noise-up the image until it reads like a real internet photo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| Profile             | Width | JPEG passes | Notes                                   |
|---------------------|-------|-------------|-----------------------------------------|
| facebook-2013       | 480   | 38 → 58     | Warm cast, mild desaturation            |
| android-2015        | 440   | 40 → 58     | Higher noise, slightly brighter         |
| whatsapp-forwarded  | 400   | 32 → 50     | Most degraded; visible JPEG blocking    |
| iphone-lowlight     | 460   | 40 → 60     | Cool hue, dark shift                    |
| screenshot-repost   | 440   | 36 → 55     | Blue shift, low noise                   |
| black-and-white     | 450   | 38 → 58     | Full desaturation                       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full per-profile values are in the &lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;HF dataset&lt;/a&gt; (&lt;code&gt;data/degradation-profiles.jsonl&lt;/code&gt;). Each prompt is paired with one profile — the wall pose pairs with &lt;code&gt;black-and-white&lt;/code&gt; because a candid wall snapshot reads more truthfully in black and white than in color.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharp hangs silently on Vercel — use Jimp, but only three of its methods
&lt;/h2&gt;

&lt;p&gt;I started with Sharp because Sharp is faster than Jimp at everything. Sharp does not work on Vercel serverless. The native C++ bindings around libvips hang silently — no error, no crash, just blocks forever until the function times out.&lt;/p&gt;

&lt;p&gt;Jimp is the only option on Vercel. Jimp also has bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;image.brightness()&lt;/code&gt; — produces black output. Broken in modern Jimp.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.getPixelColor()&lt;/code&gt; / &lt;code&gt;image.setPixelColor()&lt;/code&gt; — broken in ESM, produce black images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only safe methods are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;image.color([{apply, params}])&lt;/code&gt; — channel shifts, desaturation, hue rotation, brightness via the &lt;code&gt;apply&lt;/code&gt; API (the explicit &lt;code&gt;brightness()&lt;/code&gt; method is broken; &lt;code&gt;color([{apply:'brighten', params:[N]}])&lt;/code&gt; works).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.resize({w, h})&lt;/code&gt; — downscaling.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.getBuffer("image/jpeg", {quality})&lt;/code&gt; — JPEG encode with quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For noise I manipulate &lt;code&gt;image.bitmap.data&lt;/code&gt; directly as a &lt;code&gt;Buffer&lt;/code&gt;, adding signed random values per channel inside a hard 15-second timeout via &lt;code&gt;Promise.race()&lt;/code&gt;. Anything more elaborate hangs or produces black output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three more pitfalls that cost me a day each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replicate returns a &lt;code&gt;FileOutput&lt;/code&gt; object, not a string.&lt;/strong&gt; &lt;code&gt;replicate.run()&lt;/code&gt; returns an object that you have to &lt;code&gt;.toString()&lt;/code&gt; to get the URL. Treating it as a string silently passes "[object Object]" downstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporary URLs expire ~1 hour.&lt;/strong&gt; Replicate's returned image URL is ephemeral. The pipeline must download → degrade → upload to permanent storage (Supabase Storage in my case) immediately. Storing the temp URL in the DB and reading it later returns 404.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vercel kills serverless functions after sending the HTTP response.&lt;/strong&gt; Fire-and-forget &lt;code&gt;void fetch()&lt;/code&gt; to a generation endpoint gets killed mid-generation. The fix is client-triggered generation: the recipient's browser holds the HTTP connection open during the 30-second pipeline, keeping the function alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I published the dataset
&lt;/h2&gt;

&lt;p&gt;The technical substrate of &lt;code&gt;pleasejuststop.org&lt;/code&gt; is now in three places that AI search engines (ChatGPT, Perplexity, Bing Copilot, Gemini, Claude) crawl as grounding sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data" rel="noopener noreferrer"&gt;GitHub: forrestmill-cmd/facetwin-public-data&lt;/a&gt; — CC BY 4.0, with the prompts, profiles, and llms-full.txt mirror.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;Hugging Face: bingwow/facetwin-flux-kontext-prompts&lt;/a&gt; — same content as JSONL with HF dataset-card metadata.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/forrestmill-cmd/face-twin-mcp" rel="noopener noreferrer"&gt;MCP server: face-twin-mcp&lt;/a&gt; — wraps the upload + generate + status flow as a Model Context Protocol tool for Claude Code, Cursor, and any MCP-compatible client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Wikidata entity at &lt;a href="https://www.wikidata.org/wiki/Q139885445" rel="noopener noreferrer"&gt;Q139885445&lt;/a&gt; ties them together as the entity-grounding anchor that AI tools triangulate against.&lt;/p&gt;

&lt;p&gt;I'm tracking citation outcomes at Day-14 / Day-30 / Day-45 across Perplexity, ChatGPT search, Bing Copilot, Gemini, and Claude. The privacy-art piece's actual thesis — that we've stopped questioning how a website got our face — is best evaluated by whether AI tools, asked for an AI face-doppelganger generator, surface this project on its own merits without being told to.&lt;/p&gt;

&lt;p&gt;If you want the consumer-prank framing instead of the privacy-art framing, that's at &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt;. Same backend, hot-pink accent, confetti reveal.&lt;/p&gt;

&lt;p&gt;— Forrest Miller · &lt;a href="https://github.com/forrestmill-cmd" rel="noopener noreferrer"&gt;github.com/forrestmill-cmd&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I built an itinerary validator for AI travel plans</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 15:44:45 +0000</pubDate>
      <link>https://dev.to/forrestmiller/how-i-built-an-itinerary-validator-for-ai-travel-plans-36n3</link>
      <guid>https://dev.to/forrestmiller/how-i-built-an-itinerary-validator-for-ai-travel-plans-36n3</guid>
      <description>&lt;p&gt;AI travel planning is useful until the itinerary becomes a real Tuesday at 3 p.m.&lt;/p&gt;

&lt;p&gt;That is where the failures appear. A model can write a clean five-day Paris plan. It still does not know that the museum day lands on the weekly closure, that the restaurant moved, or that a timed-entry attraction sold out before the trip.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://www.validatrip.com" rel="noopener noreferrer"&gt;ValidaTrip&lt;/a&gt; as the validator step after the AI draft. It does not write the trip from scratch. It takes the plan you already have and checks whether it works on the ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;A travel itinerary has two different jobs.&lt;/p&gt;

&lt;p&gt;First, it has to be a good list. The places should match the traveler's interests. The neighborhoods should make sense. The plan should not send someone across a city twice in one afternoon.&lt;/p&gt;

&lt;p&gt;Second, it has to survive live constraints. Each place has hours, booking windows, public holidays, seasonal schedules, temporary closures, and location ambiguity.&lt;/p&gt;

&lt;p&gt;LLMs are good at the first job. They are weak at the second job.&lt;/p&gt;

&lt;p&gt;That split shaped the system. The validator accepts a pasted AI itinerary, resolves each place, then checks the live constraints against the user's dates.&lt;/p&gt;

&lt;p&gt;The primary product page for this flow is &lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;Check a ChatGPT itinerary&lt;/a&gt;. The same validation pattern also covers &lt;a href="https://www.validatrip.com/check/gemini-itinerary" rel="noopener noreferrer"&gt;Gemini itineraries&lt;/a&gt;, &lt;a href="https://www.validatrip.com/check/claude-itinerary" rel="noopener noreferrer"&gt;Claude itineraries&lt;/a&gt;, and &lt;a href="https://www.validatrip.com/check/perplexity-itinerary" rel="noopener noreferrer"&gt;Perplexity itineraries&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parser first, model second
&lt;/h2&gt;

&lt;p&gt;The input is intentionally messy. Real travel notes look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Friend texts with half-remembered restaurant names&lt;/li&gt;
&lt;li&gt;Blog snippets copied with booking notes&lt;/li&gt;
&lt;li&gt;Google Maps short links&lt;/li&gt;
&lt;li&gt;ChatGPT day plans&lt;/li&gt;
&lt;li&gt;Reddit comments&lt;/li&gt;
&lt;li&gt;Duplicate names with different wording&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system starts with deterministic extraction. Bullets, day headings, map URLs, and common booking phrases are cheap to parse without a model.&lt;/p&gt;

&lt;p&gt;The model step handles the ambiguous cases: vague names, mixed prose, and category cleanup. That keeps the expensive part narrow. It also gives the product a better failure mode. When the parser is certain, it stays deterministic.&lt;/p&gt;

&lt;p&gt;I published a CC BY 4.0 public corpus for this exact shape: &lt;a href="https://github.com/forrestmill-cmd/validatrip-public-data" rel="noopener noreferrer"&gt;validatrip-public-data&lt;/a&gt;. It includes 54 pasted-itinerary sample cases, source markdown, a schema file, comparison data, and prompts that generate itineraries worth validating.&lt;/p&gt;

&lt;p&gt;The JSONL file is here: &lt;a href="https://raw.githubusercontent.com/forrestmill-cmd/validatrip-public-data/main/data/ai-itinerary-validation-samples.jsonl" rel="noopener noreferrer"&gt;ai-itinerary-validation-samples.jsonl&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The same corpus is also published as a Hugging Face dataset card: &lt;a href="https://huggingface.co/datasets/bingwow/validatrip-ai-itinerary-validation-samples" rel="noopener noreferrer"&gt;validatrip-ai-itinerary-validation-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The corpus is not user data. It is not a statistical study. It is a test and demonstration set for validation behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checks
&lt;/h2&gt;

&lt;p&gt;After extraction, each named place goes through a set of checks.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve the place to a real venue.&lt;/li&gt;
&lt;li&gt;Attach coordinates and neighborhood context.&lt;/li&gt;
&lt;li&gt;Check opening hours against the trip dates.&lt;/li&gt;
&lt;li&gt;Flag weekly closed days and seasonal closures.&lt;/li&gt;
&lt;li&gt;Flag booking-sensitive categories.&lt;/li&gt;
&lt;li&gt;Detect duplicates.&lt;/li&gt;
&lt;li&gt;Separate day trips from in-city clusters.&lt;/li&gt;
&lt;li&gt;Show everything on a map.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means the answer to “is this a good itinerary?” becomes more specific.&lt;/p&gt;

&lt;p&gt;The useful questions are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which stops are closed during the trip?&lt;/li&gt;
&lt;li&gt;Which stops need a reservation now?&lt;/li&gt;
&lt;li&gt;Which names failed to resolve?&lt;/li&gt;
&lt;li&gt;Which places are duplicates?&lt;/li&gt;
&lt;li&gt;Which stops belong together by neighborhood?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dedicated hours page is &lt;a href="https://www.validatrip.com/validate-trip-hours" rel="noopener noreferrer"&gt;Validate trip hours against your travel dates&lt;/a&gt;. The organization page is &lt;a href="https://www.validatrip.com/organize-travel-recommendations" rel="noopener noreferrer"&gt;Organize travel recommendations&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why source-cited AI still needs validation
&lt;/h2&gt;

&lt;p&gt;Perplexity is a good example. It can cite a travel blog. That proves the blog exists. It does not prove the restaurant from the post is open this month.&lt;/p&gt;

&lt;p&gt;A cited itinerary still needs current place resolution. It still needs date-aware hours. It still needs booking flags.&lt;/p&gt;

&lt;p&gt;So the right sequence is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use an AI tool for the first pass.&lt;/li&gt;
&lt;li&gt;Paste the result into a validator.&lt;/li&gt;
&lt;li&gt;Replace the stops that fail live checks.&lt;/li&gt;
&lt;li&gt;Build the final day plan from the checked list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the product boundary I wanted ValidaTrip to own. It is the layer after the AI answer, before the traveler trusts it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public reference layer
&lt;/h2&gt;

&lt;p&gt;I also keep an AI-readable reference file for the product: &lt;a href="https://www.validatrip.com/llms-full.txt" rel="noopener noreferrer"&gt;llms-full.txt&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It lists the main validation pages, the direct answers those pages support, the schema coverage, and the public entity signals.&lt;/p&gt;

&lt;p&gt;The public data repo mirrors that file. That gives crawlers and researchers the same entity context outside the product domain.&lt;/p&gt;

&lt;p&gt;The project is intentionally narrow. It does not compete with the itinerary generator. It checks the generator's output.&lt;/p&gt;

&lt;p&gt;That narrowness is the point. Travel AI tools already produce the first draft. The missing step is the boring one: open hours, closures, booking windows, duplicates, neighborhoods, and a map that reflects the real trip dates.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>travel</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I built a bingo number caller with zero backend and 331 prerecorded MP3s instead of the Web Speech API</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Fri, 15 May 2026 18:07:49 +0000</pubDate>
      <link>https://dev.to/forrestmiller/i-built-a-bingo-number-caller-with-zero-backend-and-331-prerecorded-mp3s-instead-of-the-web-speech-2g11</link>
      <guid>https://dev.to/forrestmiller/i-built-a-bingo-number-caller-with-zero-backend-and-331-prerecorded-mp3s-instead-of-the-web-speech-2g11</guid>
      <description>&lt;p&gt;A bingo caller is the machine that draws numbers, says them out loud, and shows them on a board. I needed one for &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, a free bingo site. The obvious build is a server that owns the deck and a &lt;code&gt;SpeechSynthesis&lt;/code&gt; call for the voice. I shipped neither. Here is why, and what the no-backend version actually looks like.&lt;/p&gt;

&lt;p&gt;You can play with the result here: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;. It runs entirely in the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state lives in a reducer, not a server
&lt;/h2&gt;

&lt;p&gt;A multiplayer bingo board needs a server, because two players must agree on who claimed what. A caller does not. One person runs it, on one screen, and reads numbers to a room. There is nothing to synchronize.&lt;/p&gt;

&lt;p&gt;So the whole game is a &lt;code&gt;useReducer&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CallerState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BallMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// '30' | '75' | '90'&lt;/span&gt;
  &lt;span class="nl"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;          &lt;span class="c1"&gt;// shuffled, pop() to draw&lt;/span&gt;
  &lt;span class="nl"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BingoBall&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;calledSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// O(1) "was this called"&lt;/span&gt;
  &lt;span class="nl"&gt;isAutoMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;roundNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DRAW&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&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;===&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;return&lt;/span&gt; &lt;span class="nx"&gt;state&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;deck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&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;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;called&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;makeBall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&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;No persistence, no API route, no database row. Refreshing the page starts a new game, which is the correct behavior for a caller anyway. The only thing that survives a reload is two booleans in &lt;code&gt;localStorage&lt;/code&gt;: voice on/off and bingo-lingo on/off. Server cost for the entire feature is zero, and it works on a school projector with flaky wifi.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the Web Speech API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;speechSynthesis.speak()&lt;/code&gt; is free and one line. I used it first. Three problems killed it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The voices are not the same anywhere.&lt;/strong&gt; The available &lt;code&gt;SpeechSynthesisVoice&lt;/code&gt; set depends on OS and browser. The default English voice on Windows Chrome, macOS Safari, and a Chromebook are three different voices with three different cadences. A caller that sounds like a calm host on my laptop sounds like a 1998 GPS on the school's machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cuts off and queues badly.&lt;/strong&gt; Rapid &lt;code&gt;speak()&lt;/code&gt; calls during auto-draw drop utterances or stack them. Cancel/restart logic to fix that is its own bug farm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No personality.&lt;/strong&gt; Traditional 90-ball bingo has spoken calls — "Legs eleven", "Two fat ladies, eighty-eight". A robot monotone reading "eighty eight" is not that. The call IS the fun.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the voice is 331 prerecorded MP3s. Every number in every mode, every milestone ("halfway", "almost done"), every traditional 90-ball nickname, welcome and round-transition lines. They are real recorded clips, consistent on every device, and they have the warmth a party game needs. The cost is a one-time generation pass and a few MB of audio served from the CDN; clips preload per mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: syncing voice to the animation
&lt;/h2&gt;

&lt;p&gt;The drawn ball physically flies from a hero position into its cell on the flashboard. The voice has to fire at the moment the ball lands, not when React happens to re-render.&lt;/p&gt;

&lt;p&gt;The first version triggered the audio from a &lt;code&gt;useEffect&lt;/code&gt; keyed on the called list. It desynced constantly — the effect runs after paint, the animation impact is mid-timeline, and at fast auto-draw the gap compounds. The fix was to stop treating audio as a render side effect and fire it from the animation timeline itself, at the impact keyframe:&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="nf"&gt;runFlyingBallToCell&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
  &lt;span class="na"&gt;onAbsorbed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCellRevealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;voiceRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;playBallImpact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lingoEnabled&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;code&gt;playBallImpact()&lt;/code&gt; also cancels any in-flight banter or milestone clip so the number call never collides with "you're halfway there". Auto-draw is gated on the animation completing, not on the audio finishing — audio is fire-and-forget at impact. That one move removed every desync vector at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this gets you
&lt;/h2&gt;

&lt;p&gt;A bingo caller with no server, no signup, no app, that runs on anything with a browser. It does 75-ball (US), &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;90-ball with the recorded traditional calls&lt;/a&gt;, and 30-ball speed bingo. Pair it with free printable cards and the whole game costs nothing — I wrote the full no-equipment walkthrough here: &lt;a href="https://bingwow.com/blog/free-online-bingo-caller" rel="noopener noreferrer"&gt;Free online bingo caller guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The general lesson is the boring one worth repeating: not every feature that &lt;em&gt;could&lt;/em&gt; have a backend &lt;em&gt;needs&lt;/em&gt; one, and the platform speech API is a demo, not a product. Prerecorded audio plus a timeline that owns its own timing beat both the server and the SDK here.&lt;/p&gt;

&lt;p&gt;Try it: &lt;strong&gt;&lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>showdev</category>
    </item>
    <item>
      <title>We Built a Compound AI System Instead of an Agent. It Costs $200/month and 100k People Use It.</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 14 May 2026 14:40:45 +0000</pubDate>
      <link>https://dev.to/forrestmiller/we-built-a-compound-ai-system-instead-of-an-agent-it-costs-200month-and-100k-people-use-it-hok</link>
      <guid>https://dev.to/forrestmiller/we-built-a-compound-ai-system-instead-of-an-agent-it-costs-200month-and-100k-people-use-it-hok</guid>
      <description>&lt;h2&gt;
  
  
  The architecture nobody is marketing
&lt;/h2&gt;

&lt;p&gt;I just wrote in &lt;a href="https://aijourn.com/i-built-an-ai-agent-for-310-it-failed-for-the-same-reason-yours-will/" rel="noopener noreferrer"&gt;The AI Journal&lt;/a&gt; about why our autonomous AI agent ran for six months, cost $310 in API charges, and produced zero new dofollow backlinks. The reasons generalize: Gartner predicts more than 40% of agentic AI projects will be canceled by end of 2027; McKinsey finds 73% of enterprise AI projects fail to deliver ROI; Writer reports 88% of AI agent pilots never reach production.&lt;/p&gt;

&lt;p&gt;Berkeley AI Research named the alternative in February 2024: the &lt;a href="https://bair.berkeley.edu/blog/2024/02/18/compound-ai-systems/" rel="noopener noreferrer"&gt;Compound AI System&lt;/a&gt;. Either control flow is written in traditional code that calls LLMs at specific bounded steps, or control flow is driven by an LLM that decides what to do next. Compound systems pick the first. Agents pick the second.&lt;/p&gt;

&lt;p&gt;This post is the implementation detail of the working alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six models inside &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is a free AI bingo card platform used by classrooms and HR teams. Six models from four vendors handle different parts of the pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Sonnet 4.5&lt;/strong&gt; — content quality judgment, generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Haiku 4.5&lt;/strong&gt; — classification: moderation, dedup, categorization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt; — bingo-clue generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 3 Flash Preview + Gemini 2.5 Pro fallback&lt;/strong&gt; — themed display names per card topic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPT-4o Vision&lt;/strong&gt; — background image description (accessibility, search)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replicate Flux Schnell&lt;/strong&gt; — background image generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these models decides what happens next. Code decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline is code
&lt;/h2&gt;

&lt;p&gt;Every transition between models is a TypeScript function, a SQL query, or a cron job. Here is the pipeline from the moment a visitor types a card topic to the moment that card is browsable on &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingwow.com/cards&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="c1"&gt;// app/api/cron/process-pending-topics/route.ts (06:00 UTC daily)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;requireCron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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;topics&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;getPendingTopics&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BATCH_SIZE&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;const&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Step 1: Gemini 2.5 Flash generates clues (structured-output schema)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clues&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;generateClues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 2: SQL deduplicates against existing cards in same category&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDuplicate&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;findSemanticDuplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&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;isDuplicate&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;markRejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duplicate&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 3: Claude Haiku 4.5 categorizes (validated against DB read)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validCategoryIds&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;getSubcategories&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;suggestedId&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;classifyToCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;validCategoryIds&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="nf"&gt;isValidSubcategoryId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;suggestedId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fallback */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 4: Claude Sonnet 4.5 makes the publishability call&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;publish&lt;/span&gt; &lt;span class="p"&gt;}&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;decidePublishability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&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;publish&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;hardDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 5: Replicate Flux Schnell generates a background (4 attempts max)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;backgroundId&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;resolveNewCardBackgroundId&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 6: insert card, flip status to 'published'&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;category_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;suggestedId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;backgroundId&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;Six steps. Each one a code decision. The models do bounded work between the decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Auditable cost.&lt;/strong&gt; Every API call has a named caller in the codebase. When the April 2026 Anthropic bill spiked, I found the offender by grepping for &lt;code&gt;claude-3-5-opus&lt;/code&gt; in &lt;code&gt;lib/*.ts&lt;/code&gt; and replacing it with &lt;code&gt;claude-haiku-4-5&lt;/code&gt; in three files. The bill dropped from $560 a month to between $170 and $245. The system generates 30,000 AI bingo cards a month at that cost.&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"claude-3-5-opus"&lt;/span&gt; lib/&lt;span class="k"&gt;*&lt;/span&gt;.ts
lib/moderation-prompt.ts
lib/categorize.ts
lib/dedupe.ts
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="s1"&gt;'s/claude-3-5-opus/claude-haiku-4-5/g'&lt;/span&gt; lib/moderation-prompt.ts lib/categorize.ts lib/dedupe.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent burns the same $560 because routing is a code decision and the agent owned the decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditable failure.&lt;/strong&gt; When categorization started landing in the wrong subcategory in March, the bug was in a static fallback list in the moderation prompt — not in the model's judgment. The fix was to read the subcategory list from the &lt;code&gt;categories&lt;/code&gt; table at request time and validate the AI's returned ID against the same DB read:&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="c1"&gt;// lib/moderation-prompt.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildModerationPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Topic&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;subcategories&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;getSubcategories&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 5-min cached DB read&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;categoryList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subcategories&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;c&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;c&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="s2"&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;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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`[...prompt header...]

Pick a category ID from this list:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;categoryList&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Respond with { "suggested_category_id": "&amp;lt;id&amp;gt;" }`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// lib/categories.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidSubcategoryId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;boolean&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;subcategories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSubcategoriesSync&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;subcategories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&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;is_parent&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 fix is in TypeScript, not in prompt engineering, because the failure was a code failure. There is no prompt edit that can recover from a stale fallback list — the data structure has to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditable evaluation.&lt;/strong&gt; Tests exist for code. Every API route has a test fixture that calls it with a known input and asserts on the output shape. Continuous evaluation runs on every deploy. Drift on any axis triggers a code change, not a vibes-based prompt tweak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bingo caller is a worked example
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;BingWow caller&lt;/a&gt; is the most-trafficked surface in the product. It supports 30-ball, 75-ball, and 90-ball bingo with voice calls, a flashboard, auto-draw, manual draw, and printable number cards. Every layer of it is a worked example of the compound pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The voice that calls each ball is one of 331 pre-recorded MP3s. The choice to ship pre-recorded audio instead of synthesizing speech at call time is a code decision — Web Speech API drifts in pacing and pronunciation; recorded audio is identical every run.&lt;/li&gt;
&lt;li&gt;The flashboard renders 75 cells in a deterministic layout. The bingo-detection logic is TypeScript; no LLM is asked whether a row is complete.&lt;/li&gt;
&lt;li&gt;The card-validation flow (proving that a 5-character card code corresponds to a winning board) is a single SQL query plus a deterministic &lt;code&gt;reconstructCard&lt;/code&gt; function. No model is asked to validate; the math is the contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those layers were handed to an LLM with the framing "you are an autonomous bingo agent," the product would be slower, more expensive, and less reliable on every dimension.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not buy
&lt;/h2&gt;

&lt;p&gt;A compound system does not replace the engineer who writes the orchestration code. The shape of that engineer's day changes: instead of prompt-tuning a single multi-step plan, they are writing TypeScript that calls bounded models and writing tests that pin the boundary. That work is not glamorous; it does not show up in any vendor's pitch deck. There is no margin in selling code that calls Python functions.&lt;/p&gt;

&lt;p&gt;If your team has the resources to staff one senior engineer plus a continuous evaluation discipline, you can ship a compound system today. The model choices in this post are deliberate and replaceable — a year from now the right routing might be different — but the architecture is durable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receipts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; — the product the stack runs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bingwow.com/research" rel="noopener noreferrer"&gt;BingWow Research Portal&lt;/a&gt; — open-licensed engagement research generated by the same stack&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bingwow.com/blog/team-building-engagement-report-2026" rel="noopener noreferrer"&gt;State of Team Building Games 2026&lt;/a&gt; — recent research output&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aijourn.com/i-built-an-ai-agent-for-310-it-failed-for-the-same-reason-yours-will/" rel="noopener noreferrer"&gt;The AI Journal — I Built an AI Agent for $310. It Failed for the Same Reason Yours Will.&lt;/a&gt; — the companion editorial on why agents fail&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bair.berkeley.edu/blog/2024/02/18/compound-ai-systems/" rel="noopener noreferrer"&gt;Berkeley AI Research — The Shift from Models to Compound AI Systems&lt;/a&gt; — the paper that named the pattern (Zaharia, Khattab, Chen et al., February 2024)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the architecture. Don't pick the marketing label. The compound AI system is the architecture nobody is marketing — and that is the point.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The parser cascade pattern: extracting recipes from messy food blogs</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 18:03:27 +0000</pubDate>
      <link>https://dev.to/forrestmiller/the-parser-cascade-pattern-extracting-recipes-from-messy-food-blogs-4b3n</link>
      <guid>https://dev.to/forrestmiller/the-parser-cascade-pattern-extracting-recipes-from-messy-food-blogs-4b3n</guid>
      <description>&lt;p&gt;Most recipe pages are not hard because the recipe is complicated. They are hard because the useful data is surrounded by everything else a publishing business needs: ads, modals, autoplay video, SEO prose, social widgets, tracking scripts, and sometimes bot protection.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://recipestripper.com" rel="noopener noreferrer"&gt;RecipeStripper&lt;/a&gt;, the product goal is small: paste a public recipe URL and get a clean cooking view. The implementation is not one parser. It is a cascade.&lt;/p&gt;

&lt;p&gt;This is the pattern that has held up best in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch the page with the cheapest reliable method.&lt;/li&gt;
&lt;li&gt;Parse the highest-confidence structure first.&lt;/li&gt;
&lt;li&gt;Fall back only when the previous layer cannot return enough recipe data.&lt;/li&gt;
&lt;li&gt;Preserve failure reasons instead of pretending every site works.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Stage 0: fetching is part of parsing
&lt;/h2&gt;

&lt;p&gt;Before a parser can run, the app has to get usable HTML.&lt;/p&gt;

&lt;p&gt;RecipeStripper's fetch chain starts with a normal server-side request using browser-like headers. If a server returns a block status or a challenge-looking page, it can fall back to a headless Chromium request with a realistic user agent and a few stealth evasions. If that still returns a challenge page, the final attempt is a Wayback Machine snapshot.&lt;/p&gt;

&lt;p&gt;That last step matters because many recipe pages expose stable structured data in archived HTML even when the live site blocks server-side fetches.&lt;/p&gt;

&lt;p&gt;The app still does not claim universal support. Some sites, especially PerimeterX-protected properties, are marked as blocked or limited in the &lt;a href="https://recipestripper.com/works-with" rel="noopener noreferrer"&gt;Works With directory&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: JSON-LD first
&lt;/h2&gt;

&lt;p&gt;Most modern recipe sites publish Schema.org &lt;code&gt;Recipe&lt;/code&gt; data in &lt;code&gt;application/ld+json&lt;/code&gt; scripts. That is the best path because it is already structured.&lt;/p&gt;

&lt;p&gt;The JSON-LD parser handles a few common shapes:&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="c1"&gt;// simplified from lib/parsers/jsonld.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&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;isRecipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Recipe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Recipe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also walks arrays and &lt;code&gt;@graph&lt;/code&gt; wrappers, because SEO plugins often place the recipe object inside a graph with breadcrumbs, article metadata, and organization data.&lt;/p&gt;

&lt;p&gt;When a page exposes more than one recipe object, RecipeStripper picks the best candidate by matching URL slug words against recipe names, then falls back to the object with the most ingredients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: Microdata still exists
&lt;/h2&gt;

&lt;p&gt;Older sites sometimes use Microdata instead of JSON-LD:&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;div&lt;/span&gt; &lt;span class="na"&gt;itemscope&lt;/span&gt; &lt;span class="na"&gt;itemtype=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.org/Recipe"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;itemprop=&lt;/span&gt;&lt;span class="s"&gt;"recipeIngredient"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This path is less common, but it is cheap and deterministic. If a page has &lt;code&gt;itemscope&lt;/code&gt; and &lt;code&gt;itemprop&lt;/code&gt; recipe markup, there is no reason to call a model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: heuristic HTML parsing
&lt;/h2&gt;

&lt;p&gt;When structured data is missing, the parser looks for recipe-shaped HTML.&lt;/p&gt;

&lt;p&gt;The heuristic parser searches for known recipe containers, then uses section headings and list patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;headings like "Ingredients", "Instructions", "Directions", or "Method"&lt;/li&gt;
&lt;li&gt;ingredient-looking lines that begin with quantities and units&lt;/li&gt;
&lt;li&gt;ordered or unordered lists inside recipe-like containers&lt;/li&gt;
&lt;li&gt;common WordPress recipe plugin selectors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not as clean as Schema.org data, but it catches a lot of hand-built pages and older blogs.&lt;/p&gt;

&lt;p&gt;The important guardrail is to accept partial confidence without over-trusting it. RecipeStripper filters out non-instruction junk such as nutrition lines, star-rating prompts, social calls to action, and promotional fragments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: model fallback, not model first
&lt;/h2&gt;

&lt;p&gt;The GPT-4o-mini fallback only runs when deterministic parsers fail or return a recipe missing ingredients or instructions.&lt;/p&gt;

&lt;p&gt;That keeps cost and latency under control, and it avoids turning every request into a hallucination risk. The model receives a cleaned text window, not raw page HTML, and is instructed to return structured JSON or &lt;code&gt;{ "found": false }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The useful rule: models are better as recovery layers than as the first parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second cascade: ingredient-to-step matching
&lt;/h2&gt;

&lt;p&gt;Extraction alone still leaves the classic cookbook layout: ingredients at the top, instructions below.&lt;/p&gt;

&lt;p&gt;RecipeStripper's differentiator is inline quantity embedding. After extraction, a matcher links ingredient names to instruction steps. When it is confident, the rendered step can show the quantity where the cook needs it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"fold in the flour" becomes "fold in 2 cups all-purpose flour"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The internal representation uses a small token format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{qty:ingredientId:display text}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lets the renderer highlight matched quantities and lets the servings scaler update both the ingredient list and the inline step amounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this pattern works
&lt;/h2&gt;

&lt;p&gt;A cascade has three practical advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It keeps fast paths fast. JSON-LD extraction is usually enough.&lt;/li&gt;
&lt;li&gt;It keeps fallbacks honest. A blocked site becomes a clear blocked-site error, not a mysterious empty recipe.&lt;/li&gt;
&lt;li&gt;It lets the product improve one layer at a time. Better JSON-LD handling, better heuristics, and better matching all compound.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current public research dataset is here: &lt;a href="https://recipestripper.com/research/recipe-site-markup-coverage-2026" rel="noopener noreferrer"&gt;Recipe Site Markup Coverage and Extraction Observations 2026&lt;/a&gt;. It includes the public site inventory plus anonymized domain-level extraction observations. No submitted recipe URLs or user identifiers are included.&lt;/p&gt;

&lt;p&gt;The browser workflow is also being split into smaller surfaces: a &lt;a href="https://recipestripper.com/bookmarklet" rel="noopener noreferrer"&gt;bookmarklet&lt;/a&gt; and a downloadable &lt;a href="https://recipestripper.com/chrome-extension" rel="noopener noreferrer"&gt;Chrome extension package&lt;/a&gt;. Both simply open the current recipe URL in the clean reader. They do not inject a widget into someone else's site.&lt;/p&gt;

&lt;p&gt;The broader lesson is portable: when the web is inconsistent, build a parser cascade. Put the most trustworthy structure first, keep each fallback narrow, and make failures explicit.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Same Engine, Two Frames: Privacy Art vs Prank Tool</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 17:32:06 +0000</pubDate>
      <link>https://dev.to/forrestmiller/same-engine-two-frames-privacy-art-vs-prank-tool-4j8k</link>
      <guid>https://dev.to/forrestmiller/same-engine-two-frames-privacy-art-vs-prank-tool-4j8k</guid>
      <description>&lt;p&gt;I built one mechanism and gave it two doors.&lt;/p&gt;

&lt;p&gt;One door is &lt;a href="https://pleasejuststop.org" rel="noopener noreferrer"&gt;pleasejuststop.org&lt;/a&gt;. It looks like a privacy art project because that is what it is. You paste a public photo. The app makes fake face twins. The recipient sees a serious-looking AI product, then the reveal shows what happened.&lt;/p&gt;

&lt;p&gt;The other door is &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt;. It is the same engine with a different promise: send your friend a link that looks real and wait for the moment they realize it was a setup.&lt;/p&gt;

&lt;p&gt;That split matters. The privacy framing gives the project its ethical spine. The prank framing gives it motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the serious version exists
&lt;/h2&gt;

&lt;p&gt;The serious version starts from a simple question: if a website showed you your own face without asking you for a photo, would you stop and ask how it got there?&lt;/p&gt;

&lt;p&gt;Most people do not.&lt;/p&gt;

&lt;p&gt;That reaction is the project. Public photos already get scraped, indexed, copied, and processed. The experience compresses that reality into a minute. A sender adds a photo that was already public. The recipient sees their face inside a product they never used. The reveal shows the source.&lt;/p&gt;

&lt;p&gt;No database of real strangers is involved. No permanent biometric record is created. The point is that the fake product feels plausible because the real world already trained people to accept it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the prank version exists
&lt;/h2&gt;

&lt;p&gt;The prank version works because people share jokes faster than arguments.&lt;/p&gt;

&lt;p&gt;Someone who would never send an essay about facial recognition will absolutely send a link that makes a friend say, "wait, how did this thing find my face?"&lt;/p&gt;

&lt;p&gt;That is not a compromise. It is the delivery system.&lt;/p&gt;

&lt;p&gt;The prank surface does not pretend to the sender. It tells the sender exactly what they are doing. Paste the photo. Make the link. Send it. The deception only happens inside the recipient flow, where the whole experience depends on that short moment of belief before the reveal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product difference is mostly tone
&lt;/h2&gt;

&lt;p&gt;Under the hood, both doors use the same flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Accept a photo from the sender.&lt;/li&gt;
&lt;li&gt;Generate three altered face variants.&lt;/li&gt;
&lt;li&gt;Degrade the outputs so they look like real internet photos, not polished AI portraits.&lt;/li&gt;
&lt;li&gt;Show the recipient a fake face-twin product.&lt;/li&gt;
&lt;li&gt;Burn the link after reveal.&lt;/li&gt;
&lt;li&gt;Ask the recipient if they want to send it to someone else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The copy, color, and reveal tone change around that flow.&lt;/p&gt;

&lt;p&gt;The privacy art version is black, sparse, and confrontational. It wants the recipient to sit with the discomfort.&lt;/p&gt;

&lt;p&gt;The prank version is hotter, faster, and more social. It wants the recipient to laugh, screenshot, and send it onward.&lt;/p&gt;

&lt;p&gt;Same engine. Different social context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed for the viral loop
&lt;/h2&gt;

&lt;p&gt;The reveal is the important screen. It is where the recipient learns what happened, and it is also where the next sender is born.&lt;/p&gt;

&lt;p&gt;So the share path now carries attribution. Links copied from the sender screen and reveal screen include campaign parameters that identify whether the chain came from the original sender, the reveal CTA, a copy action, native share, or a social share.&lt;/p&gt;

&lt;p&gt;That makes the chain measurable without collecting victim PII.&lt;/p&gt;

&lt;p&gt;I also added a public &lt;a href="https://prankmyface.lol/hall-of-fame" rel="noopener noreferrer"&gt;Prank Hall of Fame&lt;/a&gt;. It shows anonymous reveal reactions after basic PII scrubbing. The archive is not there to shame anyone. It is there because the reaction is the product's best ad.&lt;/p&gt;

&lt;p&gt;Someone saying "I knew it was fake but still clicked" explains the experience better than a landing page ever could.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I will not break
&lt;/h2&gt;

&lt;p&gt;The tool only works socially.&lt;/p&gt;

&lt;p&gt;If a random account sends a prank link to a stranger, the experience becomes harassment. It also becomes spam. The correct distribution move is not to prank strangers. It is to put the tool in front of people who will decide, voluntarily, to send it to someone they know.&lt;/p&gt;

&lt;p&gt;That is the boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Public posts about the tool are fine.&lt;/li&gt;
&lt;li&gt;Friends pranking friends are fine.&lt;/li&gt;
&lt;li&gt;Cold-sending generated prank links to strangers is out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prank has to come from a real relationship or the whole thing turns sour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bet
&lt;/h2&gt;

&lt;p&gt;The privacy audience will understand why the project exists. The prank audience will actually spread it.&lt;/p&gt;

&lt;p&gt;Those are not competing goals. The prank version gets the experience into group chats. The privacy version explains why the experience is worth taking seriously after the laugh lands.&lt;/p&gt;

&lt;p&gt;The chain starts at &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt;. The argument lives at &lt;a href="https://pleasejuststop.org" rel="noopener noreferrer"&gt;pleasejuststop.org&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>One Playwright Header Broke Every WebSocket Test</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 17:07:43 +0000</pubDate>
      <link>https://dev.to/forrestmiller/one-playwright-header-broke-every-websocket-test-2m8g</link>
      <guid>https://dev.to/forrestmiller/one-playwright-header-broke-every-websocket-test-2m8g</guid>
      <description>&lt;p&gt;The failing tests looked like an Ably outage.&lt;/p&gt;

&lt;p&gt;Every multiplayer browser test on &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; started timing out at the same gate: "wait until the realtime channel attaches." The app loaded. The room existed. The players joined. Then the WebSocket layer never became ready.&lt;/p&gt;

&lt;p&gt;The root cause was not Ably. It was one Playwright config line.&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="nx"&gt;extraHTTPHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-BingWow-Automated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&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;That header was supposed to mark test traffic as internal. Instead, it leaked to every cross-origin request the browser made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this broke realtime
&lt;/h2&gt;

&lt;p&gt;Playwright's &lt;code&gt;extraHTTPHeaders&lt;/code&gt; is global for the browser context. It does not apply only to requests headed for your app. It applies to third-party calls too.&lt;/p&gt;

&lt;p&gt;In this app, a multiplayer room talks to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bingwow.com&lt;/code&gt; for HTML and API routes&lt;/li&gt;
&lt;li&gt;Ably for realtime channels&lt;/li&gt;
&lt;li&gt;Supabase for auth and storage paths&lt;/li&gt;
&lt;li&gt;analytics endpoints for product events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The custom &lt;code&gt;X-BingWow-Automated&lt;/code&gt; header is not CORS-safelisted. When the browser tried to call Ably with that header, it triggered a preflight request. Ably did not whitelist our private test header. The preflight failed. The actual realtime request never happened.&lt;/p&gt;

&lt;p&gt;From the test's point of view, Ably simply never attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The misleading symptom
&lt;/h2&gt;

&lt;p&gt;The app code was doing the right thing:&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForFunction&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__ably_channel_ready&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wait timed out after two minutes. That made the problem look like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a flaky WebSocket connection&lt;/li&gt;
&lt;li&gt;a race in the join flow&lt;/li&gt;
&lt;li&gt;a broken token endpoint&lt;/li&gt;
&lt;li&gt;a headless browser limitation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of those were plausible. None were true.&lt;/p&gt;

&lt;p&gt;The header never showed up in the app's own logs as an error because the failing request was cross-origin. The damage happened before Ably's actual attach request could complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: use a cookie, not a global header
&lt;/h2&gt;

&lt;p&gt;We still needed to mark test traffic so it would not pollute analytics or product metrics. The replacement was a domain-scoped cookie:&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="nx"&gt;storageState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bingwow_dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.bingwow.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;expires&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="na"&gt;httpOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sameSite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lax&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;origins&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;That cookie is sent only to BingWow-owned requests. It never goes to Ably, Supabase, or other third-party origins. The app can still identify internal traffic on its own API routes, and the browser no longer poisons external requests with private headers.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://bingwow.com/play" rel="noopener noreferrer"&gt;real-time multiplayer games&lt;/a&gt;, this difference matters. The transport stack has to be boring. A test harness that changes cross-origin network behavior is not observing the product anymore. It is creating a second product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structural lint
&lt;/h2&gt;

&lt;p&gt;The fix needed a guardrail because the broken line is easy to reintroduce. We added a Jest test that scans Playwright config files for &lt;code&gt;extraHTTPHeaders&lt;/code&gt; and fails if a non-allowlisted header appears.&lt;/p&gt;

&lt;p&gt;The error message explains why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;extraHTTPHeaders is GLOBAL - it applies to every browser request,
including cross-origin calls to ably.io, posthog, supabase, etc.
Custom non-CORS-safelisted headers trigger preflight failures and
silently break those services.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a style preference. It is a production-shaped invariant for browser tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why headless detection is still useful
&lt;/h2&gt;

&lt;p&gt;The app also skips internal analytics for obvious automation signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;navigator.webdriver&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;HeadlessChrome user agents&lt;/li&gt;
&lt;li&gt;a server-side bot detector&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;bingwow_dev=1&lt;/code&gt; cookie&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those checks are safe because they do not alter third-party request headers. They change only how the app classifies traffic after it receives a request.&lt;/p&gt;

&lt;p&gt;That distinction is the key lesson: mark traffic where you own the request, not by mutating every request the browser makes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I debug this class now
&lt;/h2&gt;

&lt;p&gt;When a browser test involving third-party services fails, I check these first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the Playwright context set global headers?&lt;/li&gt;
&lt;li&gt;Are those headers CORS-safelisted?&lt;/li&gt;
&lt;li&gt;Do failed requests show &lt;code&gt;OPTIONS&lt;/code&gt; preflight before the real call?&lt;/li&gt;
&lt;li&gt;Does the same flow pass with a clean context plus app-domain cookies?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most teams only notice this once they add a service that enforces CORS strictly. WebSockets, analytics, file storage, maps, payments, and auth providers all expose the same trap.&lt;/p&gt;

&lt;p&gt;The visible feature in my case was a simple &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingo card&lt;/a&gt; room. The underlying bug was a general browser automation footgun.&lt;/p&gt;

&lt;p&gt;If you need internal test classification, prefer these patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App-domain cookies through &lt;code&gt;storageState&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;server-side bot detection&lt;/li&gt;
&lt;li&gt;test-only query params on your own origin&lt;/li&gt;
&lt;li&gt;init scripts that set app-local flags without touching network headers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid global headers unless every origin the browser contacts is under your control.&lt;/p&gt;

&lt;p&gt;The same rule now protects the multiplayer flow, the &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;online bingo caller&lt;/a&gt;, and the internal QA suite. Tests should make the product easier to trust. They should not create network conditions real users never hit.&lt;/p&gt;

&lt;p&gt;That one-line Playwright config was doing exactly that, and deleting it made the test suite both greener and more honest.&lt;/p&gt;

&lt;p&gt;For a deeper product-level view of the architecture this affected, I wrote up the broader system in &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;the real-time multiplayer bingo guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>testing</category>
      <category>playwright</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Atomic Tap RPC That Keeps a Multiplayer Bingo Board Honest</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 17:07:42 +0000</pubDate>
      <link>https://dev.to/forrestmiller/the-atomic-tap-rpc-that-keeps-a-multiplayer-bingo-board-honest-2kil</link>
      <guid>https://dev.to/forrestmiller/the-atomic-tap-rpc-that-keeps-a-multiplayer-bingo-board-honest-2kil</guid>
      <description>&lt;p&gt;Real-time games have a habit of turning simple UI events into distributed systems problems. A player taps a cell. Another tab reconnects. A stale WebSocket event arrives from the previous round. The user taps again before the first request finishes. If the server treats those as independent "claim" and "unclaim" commands, the board can drift.&lt;/p&gt;

&lt;p&gt;I ran into this while building &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, a browser-based multiplayer bingo game. The product looks simple: every player has a board, taps squares, and wins by completing a row. The data model behind that simple interaction has to answer one question perfectly: after this tap, which clue ids are currently claimed by this player in this round?&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug-prone version
&lt;/h2&gt;

&lt;p&gt;The first version had separate endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/game/claim
POST /api/game/unclaim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matched the UI state. If a square looked unclaimed, the client called &lt;code&gt;claim&lt;/code&gt;. If it looked claimed, the client called &lt;code&gt;unclaim&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem is that the client is not the source of truth. It can be stale by one render, one optimistic update, or one reconnect. A double tap could send two &lt;code&gt;claim&lt;/code&gt; requests. A delayed Ably event could make the square look claimed when the database had already undone it. The API shape made illegal states easy to express.&lt;/p&gt;

&lt;h2&gt;
  
  
  One tap, one server decision
&lt;/h2&gt;

&lt;p&gt;The fix was to collapse the command surface into a single action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/game/tap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint does not accept "claim" or "unclaim" from the browser. It accepts only &lt;code&gt;room_id&lt;/code&gt;, &lt;code&gt;player_id&lt;/code&gt;, and &lt;code&gt;clue_id&lt;/code&gt;. The database decides what the tap means inside one locked transaction.&lt;/p&gt;

&lt;p&gt;The core RPC shape is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;v_room&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;rooms&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_room_id&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;v_existing_claim_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;room_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_room_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_player_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;clue_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_clue_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;round_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_round&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;undone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;v_existing_claim_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;undone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;undone_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_existing_claim_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;v_action&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'unclaimed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ELSE&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clue_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;round_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;undone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_room_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_clue_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_round&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;v_claim_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;v_action&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'claimed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser no longer tells the server what state it thinks the cell is in. The database reads the current claim row and toggles it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response returns the whole active set
&lt;/h2&gt;

&lt;p&gt;The RPC returns the complete active claim set for that player and round:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;array_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clue_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[]::&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
&lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;v_claimed_clue_ids&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;room_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_room_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_player_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;round_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_round&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;undone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one detail matters. The API response is not just "your tap worked." It is "here is the authoritative set now." The client can replace local state instead of trying to patch from a possibly stale starting point.&lt;/p&gt;

&lt;p&gt;On the server, the same response drives the bingo check for &lt;a href="https://bingwow.com/play" rel="noopener noreferrer"&gt;multiplayer bingo&lt;/a&gt;. The endpoint maps the player's board into positions, marks the returned clue ids as claimed, and runs a shared row-completion detector. There is no separate SQL bingo loop that can drift from the browser's single-player detector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round number is part of the key
&lt;/h2&gt;

&lt;p&gt;The active claim uniqueness constraint includes &lt;code&gt;round_number&lt;/code&gt;. That prevents old taps and stale events from contaminating the next round. A claim from round 1 is still auditable, but it is not active in round 2.&lt;/p&gt;

&lt;p&gt;That rule pairs with the WebSocket side. Realtime events include the round number, and subscribers ignore events from older rounds. This is the difference between a pleasant &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingo card game&lt;/a&gt; and a board that appears to resurrect old cells after the next round starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Derive grid size from the board
&lt;/h2&gt;

&lt;p&gt;Another small cleanup made the RPC safer: it derives &lt;code&gt;grid_size&lt;/code&gt; from board length instead of reading a separate column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;SQRT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonb_array_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;))::&lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The board has 9, 16, or 25 cells, so the grid is 3x3, 4x4, or 5x5. Duplicating that value in a column created drift bugs. Deriving it inside the RPC means the server's win check uses the actual board the player has.&lt;/p&gt;

&lt;p&gt;That is especially important because BingWow deliberately clamps mobile players to a smaller board while desktop players can use larger ones. The rule is product behavior, not presentation. It belongs on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;p&gt;The final shape is boring in the best way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The client sends one command: tap this clue.&lt;/li&gt;
&lt;li&gt;The database locks the room and player.&lt;/li&gt;
&lt;li&gt;The database toggles the active claim row.&lt;/li&gt;
&lt;li&gt;The database returns the full active claim set.&lt;/li&gt;
&lt;li&gt;The server checks bingo from that authoritative set.&lt;/li&gt;
&lt;li&gt;The browser publishes realtime events after the HTTP response succeeds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a simple party game like &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;online bingo calling&lt;/a&gt;, that may look overbuilt. It is not. The visible product is casual, but the failure mode is embarrassing: a player taps a square, the UI says one thing, and the server believes another.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning is that multiplayer game commands should describe user intent, not presumed state. "Tap this square" is intent. "Claim this square" is a guess.&lt;/p&gt;

&lt;p&gt;When your API accepts guesses, stale clients become data corruption machines. When the server owns the transition, the UI can be optimistic without being authoritative.&lt;/p&gt;

&lt;p&gt;The same principle applies outside games. Shopping carts, kanban cards, calendar RSVPs, feature toggles - anywhere the user can flip state from multiple tabs or devices. Give the server the minimum intent, lock the row that owns the invariant, and return the new truth.&lt;/p&gt;

&lt;p&gt;That is the pattern behind &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;BingWow's real-time multiplayer bingo architecture&lt;/a&gt;, and it has held up far better than the two-endpoint version I started with.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>postgres</category>
      <category>nextjs</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Instagram Wipes localStorage on Navigation. Here's How We Keep Multiplayer Sessions Alive.</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 15:05:03 +0000</pubDate>
      <link>https://dev.to/forrestmiller/instagram-wipes-localstorage-on-navigation-heres-how-we-keep-multiplayer-sessions-alive-8e0</link>
      <guid>https://dev.to/forrestmiller/instagram-wipes-localstorage-on-navigation-heres-how-we-keep-multiplayer-sessions-alive-8e0</guid>
      <description>&lt;h1&gt;
  
  
  Instagram Wipes localStorage on Navigation. Here's How We Keep Multiplayer Sessions Alive.
&lt;/h1&gt;

&lt;p&gt;A teacher shares a bingo game link to her class group chat. Half the students open it in Instagram's in-app browser. They tap a cell, switch to check a notification, come back. Their game session is gone. They're staring at a fresh board with none of their claims.&lt;/p&gt;

&lt;p&gt;This happened in production on &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; within the first week of launch.&lt;/p&gt;

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

&lt;p&gt;Instagram, TikTok, Snapchat, and Facebook Messenger all use in-app WebView browsers. These are not Safari or Chrome. They're stripped-down renderers with restrictions that vary by platform and OS version.&lt;/p&gt;

&lt;p&gt;The critical one: &lt;strong&gt;some WebViews clear localStorage on navigation events.&lt;/strong&gt; Not every time. Not on every device. But often enough that "store session in localStorage" is a broken architecture for any app where users arrive via social media links.&lt;/p&gt;

&lt;p&gt;Our game sessions stored the player ID, room code, and display name in localStorage. When the WebView cleared it, the server couldn't match the returning player to their board. The player re-entered as a new anonymous participant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dual-write pattern
&lt;/h2&gt;

&lt;p&gt;The fix is writing to both &lt;code&gt;localStorage&lt;/code&gt; and &lt;code&gt;sessionStorage&lt;/code&gt; on every save, and reading from &lt;code&gt;localStorage&lt;/code&gt; first with &lt;code&gt;sessionStorage&lt;/code&gt; as the fallback:&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="c1"&gt;// lib/safe-storage.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dualGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="kr"&gt;string&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;try&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;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dualSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="nx"&gt;value&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="k"&gt;void&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="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dualRemove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="k"&gt;void&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="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sessionStorage&lt;/code&gt; survives the scenarios where &lt;code&gt;localStorage&lt;/code&gt; gets wiped. It's scoped to the tab's lifetime, which in a WebView means "until the user closes the in-app browser or force-quits the app." For a bingo game that lasts 10-30 minutes, tab lifetime is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every operation is wrapped in try/catch
&lt;/h2&gt;

&lt;p&gt;Safari Private Browsing mode throws a &lt;code&gt;QuotaExceededError&lt;/code&gt; on any &lt;code&gt;setItem&lt;/code&gt; call. The storage quota is zero bytes. Older Samsung Internet builds throw on &lt;code&gt;getItem&lt;/code&gt; if storage was disabled in settings. Some enterprise MDM-managed Chromebooks throw &lt;code&gt;SecurityError&lt;/code&gt; on storage access entirely.&lt;/p&gt;

&lt;p&gt;The try/catch blocks are not defensive programming paranoia. They're the result of real crash reports from real users on real devices.&lt;/p&gt;

&lt;p&gt;The priority order: localStorage write → sessionStorage write → silent failure. A failed write is better than a crashed game. If both writes fail, the player gets a new session on refresh — annoying but playable. If the code throws, the game is bricked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-room namespaced keys
&lt;/h2&gt;

&lt;p&gt;A separate pattern that interacts with the dual-write: every session key includes the room code:&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="c1"&gt;// lib/session.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;player_id&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="nl"&gt;room_code&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="nl"&gt;room_id&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="nl"&gt;display_name&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="nl"&gt;is_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;string&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;dualSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`bingwow_session_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room_code&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;roomCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isHost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is &lt;code&gt;bingwow_session_ABCD&lt;/code&gt; where &lt;code&gt;ABCD&lt;/code&gt; is the four-character room code. If a player has two games open in different tabs (it happens during testing), each tab's session is independent. No cross-contamination.&lt;/p&gt;

&lt;p&gt;This namespacing also prevents the stale-session bug: without it, a player who joins Room A, then Room B, then returns to Room A's tab finds Room B's credentials in &lt;code&gt;bingwow_session&lt;/code&gt; and sends taps to the wrong room.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to dual-write
&lt;/h2&gt;

&lt;p&gt;Not everything needs the sessionStorage mirror. We keep two tiers:&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="c1"&gt;// localOnly — single-tab data, no resilience needed&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;localGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="kr"&gt;string&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="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;localSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="nx"&gt;value&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="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// dual — cross-navigation data, WebView-resilient&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dualGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="kr"&gt;string&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="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dualSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="nx"&gt;value&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="k"&gt;void&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;The edit buffer (unsaved changes while creating a card) uses &lt;code&gt;localOnly&lt;/code&gt;. If the buffer is lost, the user re-types a few words. The game session (player identity in a live multiplayer room) uses &lt;code&gt;dual&lt;/code&gt;. If the session is lost, the player loses all their claims and has to rejoin as a stranger.&lt;/p&gt;

&lt;p&gt;The distinction matters because &lt;code&gt;sessionStorage&lt;/code&gt; is per-tab. The edit buffer should persist across tabs (start editing on your phone, finish on your laptop). The game session should NOT persist across tabs (two tabs with the same session = two players fighting over one identity).&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the fix
&lt;/h2&gt;

&lt;p&gt;Before the dual-write: ~8% of Instagram-referred sessions ended with a "session lost" recovery flow within the first 5 minutes. After: &amp;lt;1%. The remaining 1% is genuine cold starts (user closed the in-app browser entirely and re-opened the link).&lt;/p&gt;

&lt;p&gt;The pattern works because &lt;code&gt;sessionStorage&lt;/code&gt; survival guarantees are stronger than &lt;code&gt;localStorage&lt;/code&gt; across the specific set of WebViews our users arrive from. This could change — if TikTok's WebView starts clearing &lt;code&gt;sessionStorage&lt;/code&gt; too, we'd need to move to URL-parameter session tokens. But for now, dual-write catches 87% of the drops that single-write missed.&lt;/p&gt;




&lt;p&gt;The code above is production code from &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, a free multiplayer bingo platform. Try it: pick any card from &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingwow.com/cards&lt;/a&gt;, share the link with a friend, and play. The session management described here runs on every game — whether it's a classroom activity at &lt;a href="https://bingwow.com/for/teachers" rel="noopener noreferrer"&gt;bingwow.com/for/teachers&lt;/a&gt; or a watch party opened from an Instagram story.&lt;/p&gt;

&lt;p&gt;If you're building anything that users reach via social media links, test in at least Instagram's and TikTok's in-app browsers. &lt;code&gt;localStorage&lt;/code&gt; is not as reliable as the MDN docs imply.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Why Our Multiplayer Bingo Game Uses a Singleton Ably Client (and Token Auth Instead of API Keys)</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 12 May 2026 15:05:02 +0000</pubDate>
      <link>https://dev.to/forrestmiller/why-our-multiplayer-bingo-game-uses-a-singleton-ably-client-and-token-auth-instead-of-api-keys-p0h</link>
      <guid>https://dev.to/forrestmiller/why-our-multiplayer-bingo-game-uses-a-singleton-ably-client-and-token-auth-instead-of-api-keys-p0h</guid>
      <description>&lt;h1&gt;
  
  
  Why Our Multiplayer Bingo Game Uses a Singleton Ably Client (and Token Auth Instead of API Keys)
&lt;/h1&gt;

&lt;p&gt;When you're building real-time multiplayer in Next.js, the first architectural question isn't "which WebSocket library." It's "how many connections am I going to accidentally open."&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: connection sprawl
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is a free multiplayer bingo platform. Every player in a room subscribes to an Ably channel. When someone taps a cell, claims bingo, or sends a chat message, every other player sees it in real time.&lt;/p&gt;

&lt;p&gt;The naive approach — instantiate &lt;code&gt;new Ably.Realtime()&lt;/code&gt; in every hook that needs it — creates a connection per hook per render. A single player page has at minimum three hooks subscribing: game subscriptions, chat, and the activity feed. Three connections per player. Twenty players in a room is sixty WebSocket connections for one game. Ably bills per connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The singleton pattern
&lt;/h2&gt;

&lt;p&gt;The fix is a module-level singleton. One connection per browser tab, shared across every hook:&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="c1"&gt;// lib/ably.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ably&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;realtimeClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Realtime&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAblyClient&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Realtime&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getAblyClient can only be called on the client side&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;realtimeClient&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;clientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`client-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;realtimeClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Realtime&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/ably/token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;authMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;authParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;closeOnUnload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;web_socket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xhr_polling&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;logLevel&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="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;realtimeClient&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;Every hook calls &lt;code&gt;getAblyClient()&lt;/code&gt;. The first call creates the connection. Every subsequent call returns the same instance. When the tab closes, one connection drops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why token auth, not the publishable API key
&lt;/h2&gt;

&lt;p&gt;Ably offers two client authentication modes. The quick one — passing &lt;code&gt;key&lt;/code&gt; directly — exposes your Ably API key in client-side JavaScript. Anyone who opens DevTools can read it. Ably's docs say this is fine for prototyping. For a production multiplayer game with arbitrary players, it is not fine.&lt;/p&gt;

&lt;p&gt;Token auth routes through your server:&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="c1"&gt;// app/api/ably/token/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ably&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt; &lt;span class="p"&gt;}&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&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="s1"&gt;clientId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`anon-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ably&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Rest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ABLY_API_KEY&lt;/span&gt;&lt;span class="o"&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;token&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;ably&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTokenRequest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscribe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;publish&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&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 client's &lt;code&gt;authUrl&lt;/code&gt; points at this endpoint. Ably's SDK handles the token lifecycle automatically — requests a new token before the current one expires, reconnects transparently. Zero token-management code on the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server-side split
&lt;/h2&gt;

&lt;p&gt;Client hooks subscribe. Server API routes publish. These are different Ably client types for a reason.&lt;/p&gt;

&lt;p&gt;The browser uses &lt;code&gt;Ably.Realtime&lt;/code&gt; (WebSocket, persistent connection, subscribe + publish). The server uses &lt;code&gt;Ably.Rest&lt;/code&gt; (HTTP, stateless, publish only). Both are singletons at module scope:&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="c1"&gt;// Server-side REST client (publish from API routes)&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;restClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Rest&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAblyRestClient&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Rest&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;restClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;restClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Ably&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Rest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ABLY_API_KEY&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;restClient&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;On Vercel, the REST client lives in the function's warm cache. Cold starts create a new one. Warm invocations reuse it. Ably REST calls are stateless HTTP — no connection to manage, no cleanup needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The channel-name convention
&lt;/h2&gt;

&lt;p&gt;Every room gets one channel: &lt;code&gt;room:${roomCode}&lt;/code&gt;. All event types — claims, bingos, joins, renames, chat — flow through the same channel with different event names:&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;getRoomChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomCode&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getAblyClient&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;channels&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="s2"&gt;`room:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;roomCode&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hooks that subscribe to this channel filter by event name:&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="c1"&gt;// In useGameSubscriptions&lt;/span&gt;
&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claim&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleClaim&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bingo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleBingo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// In useChat&lt;/span&gt;
&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleChat&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One channel per room means one subscription management point. When a player leaves, one &lt;code&gt;channel.detach()&lt;/code&gt; cleans everything up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;closeOnUnload: false&lt;/code&gt; does
&lt;/h2&gt;

&lt;p&gt;This was counterintuitive until I saw it in production. Setting &lt;code&gt;closeOnUnload: false&lt;/code&gt; tells Ably not to close the WebSocket on &lt;code&gt;beforeunload&lt;/code&gt;. Why would you want that?&lt;/p&gt;

&lt;p&gt;Mobile browsers. When a phone user switches to another app and comes back, &lt;code&gt;beforeunload&lt;/code&gt; fires on the tab switch. With &lt;code&gt;closeOnUnload: true&lt;/code&gt;, the connection drops and the player misses events during the app switch. With &lt;code&gt;false&lt;/code&gt;, Ably holds the connection for a grace period and the player catches up on return via Ably's automatic message replay.&lt;/p&gt;

&lt;p&gt;For a bingo game where rounds last 2-5 minutes and players routinely check their phone mid-game, this is the difference between "the game broke" and "I missed a few taps but caught up."&lt;/p&gt;

&lt;h2&gt;
  
  
  The transport fallback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;web_socket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xhr_polling&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebSocket first, long-polling fallback. School Chromebooks running behind restrictive proxies sometimes block WebSocket upgrades. The xhr_polling fallback ensures the game still works — slower, but functional. We discovered this when three teachers in the same district reported "the game loads but nobody else's taps appear." The Chromebook proxy was stripping the &lt;code&gt;Upgrade: websocket&lt;/code&gt; header.&lt;/p&gt;

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

&lt;p&gt;Before the singleton: ~3 Ably connections per player per room. After: 1. For a 20-player room, that's 60 → 20 connections. At Ably's pricing tiers, the singleton pattern cut our real-time infrastructure cost by two-thirds with zero behavior change.&lt;/p&gt;

&lt;p&gt;The token auth pattern added ~50ms to the initial connection (one extra round-trip to &lt;code&gt;/api/ably/token&lt;/code&gt;). After that, token renewal is transparent and adds zero latency to message delivery.&lt;/p&gt;




&lt;p&gt;If you're building a game or any real-time feature with Ably and Next.js, start with the singleton + token auth pattern. The alternative — multiple connections with a publishable key — works until it doesn't, and "doesn't" usually means a surprise bill or a security incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BingWow&lt;/strong&gt; is free and open for multiplayer games at &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;bingwow.com&lt;/a&gt;. Create a card at &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;bingwow.com/create&lt;/a&gt;, share the room code, and play. The Ably architecture described above runs every game session — from classroom activities at &lt;a href="https://bingwow.com/for/teachers" rel="noopener noreferrer"&gt;bingwow.com/for/teachers&lt;/a&gt; to watch parties and team-building events.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>realtime</category>
    </item>
    <item>
      <title>Inline style attributes vs CSS variables: a Tailwind v4 light-mode debug story</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 11 May 2026 18:06:40 +0000</pubDate>
      <link>https://dev.to/forrestmiller/inline-style-attributes-vs-css-variables-a-tailwind-v4-light-mode-debug-story-28ic</link>
      <guid>https://dev.to/forrestmiller/inline-style-attributes-vs-css-variables-a-tailwind-v4-light-mode-debug-story-28ic</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I shipped a "light mode for the bingo caller" feature three times. Each attempt rendered as dark navy squares on a white stage — totally unreadable. The bug was the same every time: an inline &lt;code&gt;style={{ background: hue.deep }}&lt;/code&gt; in a React component winning over the CSS class meant to control the background. Moving from inline styles to inline CSS custom properties unlocked a theme-aware cascade and finally made the feature ship.&lt;/p&gt;

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

&lt;p&gt;I'm building a number bingo caller — the screen that flashes numbers across a board as they're called. Each column has a hue (Blue B, Red I, Green N, Orange G, Purple O). When a number is "called," that cell lights up in its column's color.&lt;/p&gt;

&lt;p&gt;Dark mode shipped first. The visual goal: each called tile is a solid deep block — navy, crimson, forest, pumpkin, royal purple. Looks like a Las Vegas bingo board lit from inside. The implementation was the obvious one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"caller-tile caller-tile-called"&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deep&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// ← the bug, but I didn't know yet&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worked great. Shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The light-mode regression
&lt;/h2&gt;

&lt;p&gt;Six weeks later I tried to add light mode. Visual goal: each called tile becomes a vibrant gradient lit from above — the same hue but the &lt;em&gt;light&lt;/em&gt; end of its range, not the dark end. Think a bright pop instead of a moody glow.&lt;/p&gt;

&lt;p&gt;I wrote the CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.caller-tile-called&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* Light mode: linear gradient from highlight to mid */&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;180deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--hue-highlight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--hue-mid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="nc"&gt;.caller-tile-called&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* Dark mode: solid deep */&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--hue-deep&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;Set the CSS variables on each cell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"caller-tile caller-tile-called"&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// ← still here&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--hue-highlight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;highlight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--hue-mid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--hue-deep&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reloaded the page in light mode. Dark navy squares. Crimson. Forest green. On a stark white stage.&lt;/p&gt;

&lt;p&gt;I tried a few "make it work" patches. Increased CSS specificity. Wrapped the rule in &lt;code&gt;:where(.light)&lt;/code&gt;. Added &lt;code&gt;!important&lt;/code&gt;. Nothing worked. The inline &lt;code&gt;style={{ background: hue.deep }}&lt;/code&gt; was winning over my CSS rule every time.&lt;/p&gt;

&lt;p&gt;I reverted the attempt and stayed in dark mode for another four weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two more attempts that broke the same way
&lt;/h2&gt;

&lt;p&gt;Each time I came back to it I came up with a new theory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attempt 2&lt;/strong&gt;: rewrite the CSS to target both &lt;code&gt;.light .caller-tile-called&lt;/code&gt; and &lt;code&gt;:not(.dark) .caller-tile-called&lt;/code&gt;. Same bug. Inline style still won.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attempt 3&lt;/strong&gt;: rewrite Flashboard to compute the gradient inline and conditionally render it. Forced me to thread theme state through three layers of React just to set a &lt;code&gt;background:&lt;/code&gt; value. Also looked horrible because the inline gradient didn't get the &lt;code&gt;var()&lt;/code&gt; indirection I wanted for component-internal tweaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the third attempt I sat back and read the &lt;a href="https://www.w3.org/TR/css-cascade-5/" rel="noopener noreferrer"&gt;CSS specification on declared styles&lt;/a&gt; carefully.&lt;/p&gt;

&lt;p&gt;Inline &lt;code&gt;style&lt;/code&gt; attributes have higher specificity than ANY external stylesheet rule, including &lt;code&gt;!important&lt;/code&gt; rules in stylesheets (unless the inline style is also &lt;code&gt;!important&lt;/code&gt;). My &lt;code&gt;background: var(--hue-deep)&lt;/code&gt; was authoring an inline declaration that simply couldn't lose. Every CSS rule I wrote was a bystander.&lt;/p&gt;

&lt;p&gt;The fix was straightforward once I saw it: &lt;strong&gt;stop authoring &lt;code&gt;background:&lt;/code&gt; in JSX entirely&lt;/strong&gt;. Move the value into a CSS variable that the stylesheet picks up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that finally shipped
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"caller-tile caller-tile-called"&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--hue-highlight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;highlight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--hue-mid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--hue-deep&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--hue-glow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;glow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;CSSProperties&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.caller-tile-called&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;180deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--hue-highlight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--hue-mid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--hue-mid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="nc"&gt;.caller-tile-called&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--hue-deep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--hue-mid&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 inline style now only sets CUSTOM PROPERTIES. Custom properties don't paint anything — they're just named values. The CSS rule consumes them and decides what to paint, and the CSS rule can switch between light and dark cases because it's the only thing actually authoring &lt;code&gt;background:&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Light mode: gradient. Dark mode: solid deep. Both branches share the same hue palette. Switching theme is one class flip on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently from day one
&lt;/h2&gt;

&lt;p&gt;I should have known this. I've answered Stack Overflow questions on inline-style specificity. The reason I missed it in my own code is that inline &lt;code&gt;style={{ ... }}&lt;/code&gt; in React is so culturally adjacent to "just set the prop" that I didn't think of it as authoring a CSS declaration. It read as data, not as a stylesheet author.&lt;/p&gt;

&lt;p&gt;The general rule I now keep on a sticky note: &lt;strong&gt;inline &lt;code&gt;style&lt;/code&gt; should set values, not paint properties&lt;/strong&gt;. CSS custom properties on the element are fine — they're values. Hex colors on &lt;code&gt;background&lt;/code&gt; or &lt;code&gt;color&lt;/code&gt; are dangerous — they're paint commands that no stylesheet can dethrone.&lt;/p&gt;

&lt;h2&gt;
  
  
  A side benefit
&lt;/h2&gt;

&lt;p&gt;Once the hues were CSS variables instead of inline backgrounds, I could expose them to other parts of the component for free. The cell's box-shadow glow uses the same variables. The current-cell pulse animation uses &lt;code&gt;--hue-glow&lt;/code&gt;. The ghost-number outline uses &lt;code&gt;color-mix(in srgb, var(--hue-mid) 25%, transparent)&lt;/code&gt;. Adding a new effect doesn't require touching React state — just touching the stylesheet.&lt;/p&gt;

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

&lt;p&gt;Live in production — both modes work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;See the caller light mode: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt; (toggle theme in the navbar)&lt;/li&gt;
&lt;li&gt;Create a multiplayer card: &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;bingwow.com/create&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Browse cards: &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingwow.com/cards&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Free, no signup, no premium tier: &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;bingwow.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've hit the same wall with theming a React component, I'd love to hear the route you took. Drop it in the comments.&lt;/p&gt;

</description>
      <category>css</category>
      <category>react</category>
      <category>webdev</category>
      <category>tailwindcss</category>
    </item>
  </channel>
</rss>
