<?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>Forem: Forrest Miller</title>
    <description>The latest articles on Forem by Forrest Miller (@forrestmiller).</description>
    <link>https://forem.com/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>Forem: Forrest Miller</title>
      <link>https://forem.com/forrestmiller</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/forrestmiller"/>
    <language>en</language>
    <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://forem.com/forrestmiller/inline-style-attributes-vs-css-variables-a-tailwind-v4-light-mode-debug-story-28ic</link>
      <guid>https://forem.com/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>
    <item>
      <title>Tailwind v4 dark mode: the @theme vs @theme inline gotcha that broke my contrast tests</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 11 May 2026 18:06:38 +0000</pubDate>
      <link>https://forem.com/forrestmiller/tailwind-v4-dark-mode-the-theme-vs-theme-inline-gotcha-that-broke-my-contrast-tests-3p3o</link>
      <guid>https://forem.com/forrestmiller/tailwind-v4-dark-mode-the-theme-vs-theme-inline-gotcha-that-broke-my-contrast-tests-3p3o</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: in Tailwind v4 there are two ways to declare a color token in &lt;code&gt;@theme&lt;/code&gt;. One compiles the hex value into your utility classes. The other emits a &lt;code&gt;var(--...)&lt;/code&gt; reference that you can override from a wrapper class. Only one of these supports a multi-layer dark-mode cascade. I shipped six surfaces with invisible text because I picked the wrong one.&lt;/p&gt;

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

&lt;p&gt;I'm running &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;Tailwind v4&lt;/a&gt; on a side project. Dark mode needs to do real work: I have always-light containers (white toast wrappers), always-dark containers (gradient heroes on otherwise-light pages), and components that flip per-theme (everything else). Plus a forced-colors mode for accessibility.&lt;/p&gt;

&lt;p&gt;I started with the obvious thing: one &lt;code&gt;@theme&lt;/code&gt; block declaring all color tokens, plus a &lt;code&gt;.dark&lt;/code&gt; class with overrides. That works for backgrounds. It fell over the moment I tried to override token values from a wrapper class inside a theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two &lt;code&gt;@theme&lt;/code&gt; forms
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Form&lt;/th&gt;
&lt;th&gt;What Tailwind emits for &lt;code&gt;text-warning&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Override-able from cascade?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@theme inline { --color-warning: #F59E0B; }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;color: #F59E0B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No.&lt;/strong&gt; Hex is baked in.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@theme { --color-warning: #F59E0B; }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;color: var(--color-warning)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes.&lt;/strong&gt; Cascade wins.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is undocumented in any obvious place. Both forms generate utility classes. Both work for "default" styling. The difference only shows up when a wrapper class tries to override the token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* This DOES work — token uses var() */&lt;/span&gt;
&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#F59E0B&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.surface-context&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#B45309&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* AA-pass amber-700 on amber-50 */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* This does NOT work — token is compiled to hex inside text-warning */&lt;/span&gt;
&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#F59E0B&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.surface-context&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#B45309&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* ignored — text-warning was inlined */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The bug I shipped
&lt;/h2&gt;

&lt;p&gt;My &lt;code&gt;/research&lt;/code&gt; page had Industry-tier badges using &lt;code&gt;text-warning&lt;/code&gt; on an &lt;code&gt;amber-50&lt;/code&gt; background. With &lt;code&gt;text-warning: #F59E0B&lt;/code&gt; baked into the utility (amber-500), the contrast ratio was 2.06:1 against &lt;code&gt;#FFFBEB&lt;/code&gt;. That's a WCAG AA failure — text barely visible to anyone, invisible to anyone with low vision.&lt;/p&gt;

&lt;p&gt;Five other surfaces had the same shape: light surface context, default warning/gold/success token from the inline &lt;code&gt;@theme&lt;/code&gt;, no way to nudge the token toward an AA-passing shade in that context.&lt;/p&gt;

&lt;p&gt;The fix was a single move: take five tokens out of &lt;code&gt;@theme inline&lt;/code&gt; and put them in &lt;code&gt;@theme&lt;/code&gt;:&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="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="m"&gt;#F59E0B&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* default — amber-500 on dark */&lt;/span&gt;
  &lt;span class="py"&gt;--color-gold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="m"&gt;#EAB308&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="m"&gt;#10B981&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-danger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="m"&gt;#EF4444&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-interactive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6366F1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.surface-context&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="m"&gt;#B45309&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* amber-700 on light — AA pass */&lt;/span&gt;
  &lt;span class="py"&gt;--color-gold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="m"&gt;#A16207&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="m"&gt;#047857&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-danger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="m"&gt;#B91C1C&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-interactive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4338CA&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-surface-context&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="m"&gt;#FCD34D&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* amber-300 on dark slate */&lt;/span&gt;
  &lt;span class="c"&gt;/* ... */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One change. Five surfaces fixed. CI contrast tests turned green across the route × theme matrix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 6-layer cascade
&lt;/h2&gt;

&lt;p&gt;For tokens that need to flip across context AND theme, the cascade resolves in this order (later wins):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@theme         (default for class)
:root          (light-mode defaults)
.dark          (page-level dark)
.surface-context        (always-light container, even on dark page)
.dark .card-fun         (specific component override in dark mode)
.dark-surface-context   (always-dark container, even on light page)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is that &lt;code&gt;.surface-context&lt;/code&gt; lives ABOVE &lt;code&gt;.dark&lt;/code&gt; in specificity by single-class, so a &lt;code&gt;.dark .surface-context&lt;/code&gt; ancestor chain still wins via cascade order, not specificity. You don't need &lt;code&gt;!important&lt;/code&gt; anywhere. You don't need &lt;code&gt;dark:text-*&lt;/code&gt; overrides on individual elements. The wrapper class does the work.&lt;/p&gt;

&lt;p&gt;The 17 text tokens that flip via &lt;code&gt;.surface-context&lt;/code&gt; vs &lt;code&gt;.dark-surface-context&lt;/code&gt; vs &lt;code&gt;tooltip-dark&lt;/code&gt; are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--color-text&lt;/code&gt;, &lt;code&gt;--color-text-secondary&lt;/code&gt;, &lt;code&gt;--color-muted&lt;/code&gt;, &lt;code&gt;--color-heading&lt;/code&gt;, &lt;code&gt;--color-link&lt;/code&gt;, &lt;code&gt;--color-link-hover&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;5 overridable accent tokens (above)&lt;/li&gt;
&lt;li&gt;6 surface-relative tokens (&lt;code&gt;--color-border&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most components never reference theme variables directly. They use the Tailwind utility (&lt;code&gt;text-secondary&lt;/code&gt;, &lt;code&gt;border-default&lt;/code&gt;) which compiles to &lt;code&gt;color: var(--color-text-secondary)&lt;/code&gt;. The wrapper class flips the variable. The element doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke when I tried other shapes
&lt;/h2&gt;

&lt;p&gt;Before this architecture I tried three alternatives. All shipped briefly and reverted.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dark:text-*&lt;/code&gt; Tailwind forks on individual elements.&lt;/strong&gt; Worked for one component, became unmaintainable across 200+ surfaces. Every contrast fix required editing every callsite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second &lt;code&gt;.dark&lt;/code&gt; block inside the cascade.&lt;/strong&gt; Broke source order. The &lt;code&gt;.dark&lt;/code&gt; class's later declarations clobbered earlier &lt;code&gt;.surface-context&lt;/code&gt; overrides because they came after in the stylesheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;!important&lt;/code&gt; on &lt;code&gt;.surface-context&lt;/code&gt; token overrides.&lt;/strong&gt; Felt wrong. Also broke when a child component needed to push a different value through (no precedence left to play with).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The thing that finally worked is "tokens live in &lt;code&gt;@theme&lt;/code&gt; not &lt;code&gt;@theme inline&lt;/code&gt;, wrapper classes override variables, utilities consume variables, no &lt;code&gt;dark:&lt;/code&gt; patches anywhere."&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it stays correct
&lt;/h2&gt;

&lt;p&gt;I run a Playwright + axe-core matrix that probes 50 routes × 2 themes × 2 viewports = 200 scans on every CI run. If any token regresses (someone moves &lt;code&gt;--color-warning&lt;/code&gt; back into &lt;code&gt;@theme inline&lt;/code&gt;, or someone hard-codes a hex in a component), the contrast spec fails.&lt;/p&gt;

&lt;p&gt;The lint that catches the worst class of regression — hard-coded &lt;code&gt;#hex&lt;/code&gt; colors in component files — is a simple regex over the codebase that disallows anything except the design-system-blessed values. Every "I'll just use red here" attempt gets caught at PR time.&lt;/p&gt;

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

&lt;p&gt;This is the design system powering &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, a free real-time multiplayer bingo platform. Light and dark mode are both first-class. Forced-colors works. The contrast matrix is at 100% AA pass on the latest deploy.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;See it light + dark live: &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;bingwow.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A theme-heavy gameplay surface: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;For teachers, with AA-pass dark mode: &lt;a href="https://bingwow.com/for/teachers" rel="noopener noreferrer"&gt;bingwow.com/for/teachers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Browse 2,000+ cards: &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingwow.com/cards&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've solved the same problem differently in Tailwind v4, I'd love to read it — drop a link in the comments.&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>css</category>
      <category>webdev</category>
      <category>a11y</category>
    </item>
    <item>
      <title>I added a grid_size column. Two months later I dropped it. What I learned about derived state in Postgres.</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 11 May 2026 18:06:37 +0000</pubDate>
      <link>https://forem.com/forrestmiller/i-added-a-gridsize-column-two-months-later-i-dropped-it-what-i-learned-about-derived-state-in-4j8b</link>
      <guid>https://forem.com/forrestmiller/i-added-a-gridsize-column-two-months-later-i-dropped-it-what-i-learned-about-derived-state-in-4j8b</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a &lt;code&gt;grid_size INT&lt;/code&gt; column on three tables (rooms, players, card_templates) looked like a sane denormalization for a multiplayer bingo game. Two months later I deleted it on all three tables, because the value is tautologically encoded in another column already in those rows. Derived state at read time was simpler, smaller, and less buggy. Here's the trail.&lt;/p&gt;

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

&lt;p&gt;I'm building BingWow, a free real-time multiplayer bingo platform. Each player has a &lt;code&gt;board JSONB&lt;/code&gt; column that stores their cells: clue ids, positions, and image references. Boards are 3×3 (9 cells), 4×4 (16), or 5×5 (25). Mobile players are clamped to 3×3 regardless of the host's pick — it's an intentional product invariant.&lt;/p&gt;

&lt;p&gt;When I first shipped flexible grid sizes in March 2026, I added:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;rooms&lt;/span&gt;   &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;grid_size&lt;/span&gt; &lt;span class="nb"&gt;INT&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;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;players&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;grid_size&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;-- NULL = "use the room's"&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;card_templates&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;grid_size&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grid_size&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It read cleanly. Server endpoints did &lt;code&gt;SELECT grid_size FROM rooms WHERE id = $1&lt;/code&gt;. The &lt;code&gt;tap_claim&lt;/code&gt; RPC that runs on every cell tap dereferenced &lt;code&gt;COALESCE(player.grid_size, room.grid_size, 5)&lt;/code&gt;. All the bingo-detection logic could read one short integer instead of computing one. Win, right?&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug class I kept hitting
&lt;/h2&gt;

&lt;p&gt;Within six weeks I'd shipped four bugs traceable to &lt;code&gt;grid_size&lt;/code&gt; drift:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mobile clamp regression&lt;/strong&gt; — a refactor wrote &lt;code&gt;room.grid_size = 5&lt;/code&gt; but generated a 9-cell board for a mobile player. Bingo detection then ran a 5-in-a-row check against a 9-cell array and threw out-of-bounds on every tap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resize-mid-game&lt;/strong&gt; — when a player resized from Medium to Large mid-session, the new board got written but the &lt;code&gt;players.grid_size&lt;/code&gt; column was only updated in one of three callsites. The fourth callsite (the auto-save on the cell-image edit path) overwrote the board without touching the column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fork inheritance&lt;/strong&gt; — forks inherited the parent's &lt;code&gt;grid_size&lt;/code&gt; but not always the parent's &lt;code&gt;board&lt;/code&gt;. On rare race conditions a 4×4 parent produced a 3×3 fork that still claimed to be 4×4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "card is being set up" panel&lt;/strong&gt; that fired when &lt;code&gt;grid_size&lt;/code&gt; and &lt;code&gt;jsonb_array_length(board)&lt;/code&gt; disagreed by exactly the time it took the auto-save to flush.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every bug had the same shape: two sources of truth, drifting at different cadences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The realization
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grid_size&lt;/code&gt; is not independent state. It IS the board.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jsonb_array_length(board) = 9   →  3×3
jsonb_array_length(board) = 16  →  4×4
jsonb_array_length(board) = 25  →  5×5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every callsite that wrote &lt;code&gt;grid_size&lt;/code&gt; also generated the board at the same size in the same transaction. The two values were tautologically equal at write time. The bugs were all "we forgot to keep them equal" bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration
&lt;/h2&gt;

&lt;p&gt;Three sequential migrations dropped the column from each table. The most surgical one is the player-level drop, which had to rewrite the &lt;code&gt;tap_claim&lt;/code&gt; RPC to derive &lt;code&gt;grid_size&lt;/code&gt; from the board:&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="c1"&gt;-- Before: COALESCE(v_player.grid_size, v_room.grid_size, 5)&lt;/span&gt;
&lt;span class="c1"&gt;-- After:  SQRT(jsonb_array_length(v_player.board))::INT&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'action'&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="s1"&gt;'grid_size'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&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;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;-- ...&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;players&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;grid_size&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;SQRT&lt;/code&gt; on integers in Postgres returns a &lt;code&gt;double precision&lt;/code&gt;; casting to &lt;code&gt;INT&lt;/code&gt; is exact for perfect squares 9, 16, 25. The cost is negligible — &lt;code&gt;jsonb_array_length&lt;/code&gt; on a hot row is O(1) once the JSONB is parsed.&lt;/p&gt;

&lt;p&gt;I kept the wire shape of the API unchanged. The HTTP response still emits &lt;code&gt;player.grid_size: 5&lt;/code&gt;. Clients didn't change. The only thing that changed was where that integer came from.&lt;/p&gt;

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

&lt;p&gt;I'd skip the column from day one. The instinct to denormalize "just in case the read cost matters" is hard to resist when you're staring at a query plan that reads two columns instead of one. The actual cost of &lt;code&gt;SQRT(jsonb_array_length(board))&lt;/code&gt; on a &lt;code&gt;FOR UPDATE&lt;/code&gt;-locked single row is irrelevant — the network round-trip dominates by three orders of magnitude.&lt;/p&gt;

&lt;p&gt;The thing I should have done is the thing I always tell other engineers and never do for myself: &lt;strong&gt;start with the simplest schema that encodes the invariant, only add denormalized columns when you have a profiled query that needs them&lt;/strong&gt;. The invariant "grid_size is determined by board length" is structural. Encoding it in a separate column made the schema able to express illegal states, and every bug class I hit was a different illegal state realizing itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd add to the schema instead
&lt;/h2&gt;

&lt;p&gt;If you're going to keep computed values, encode them as &lt;code&gt;GENERATED ALWAYS AS ... STORED&lt;/code&gt; columns. Postgres maintains them automatically, you can index them, and they can never drift:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;players&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;grid_size&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&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;board&lt;/span&gt;&lt;span class="p"&gt;))::&lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn't go that route here because the derivation is cheap enough that I don't index on it. But it's the right pattern when you need both the simplicity of derivation AND the speed of an indexed read.&lt;/p&gt;

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

&lt;p&gt;The whole codebase is live and used by real classrooms, party hosts, and senior activity centers. Built with Next.js 16, Supabase, Ably for real-time, Tailwind v4.&lt;/p&gt;

&lt;ul&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 2,000+ cards: &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingwow.com/cards&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;For teachers: &lt;a href="https://bingwow.com/for/teachers" rel="noopener noreferrer"&gt;bingwow.com/for/teachers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Free, no signup, no ads, 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 got a schema-bloat story or a column you regret adding, drop it in the comments. I'd love to read your trail.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>webdev</category>
      <category>supabase</category>
    </item>
    <item>
      <title>We Audited 10 Team Building Platforms — Here's What HR Teams Actually Get for Free</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Wed, 22 Apr 2026 19:07:08 +0000</pubDate>
      <link>https://forem.com/forrestmiller/we-audited-10-team-building-platforms-heres-what-hr-teams-actually-get-for-free-2712</link>
      <guid>https://forem.com/forrestmiller/we-audited-10-team-building-platforms-heres-what-hr-teams-actually-get-for-free-2712</guid>
      <description>&lt;p&gt;I audited 10 team building platforms in April 2026 to answer the question HR teams actually care about: what do you get for free, and where does the paywall hit?&lt;/p&gt;

&lt;p&gt;The findings are from a larger research report analyzing 7 years of Google Trends data, peer-reviewed meta-analyses, and a full feature audit. Here are the highlights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Branding Is Paywalled Almost Everywhere
&lt;/h2&gt;

&lt;p&gt;Of 10 platforms audited, only 2 offer free custom branding — uploading a company logo and customizing colors without paying. Five platforms lock branding behind paid tiers. Three don't offer it at all.&lt;/p&gt;

&lt;p&gt;For HR teams running branded team activities, this is the single most common hidden cost in the category.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Subscription Trap Problem
&lt;/h2&gt;

&lt;p&gt;One platform advertises a $2.95 trial that converts to $19.95/month. A Trustpilot reviewer reported being charged $260 over a year. For teams running one event per quarter, subscription pricing is misaligned with how these tools are used.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Generation Is the New Dividing Line
&lt;/h2&gt;

&lt;p&gt;5 of 10 platforms offer AI-powered content creation. The ability to generate a custom team activity in 10 seconds vs. 30 minutes changes everything. Within 12 months, AI generation will be table stakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Printable Output Is Surprisingly Rare
&lt;/h2&gt;

&lt;p&gt;Only 4 of 10 platforms support printing. Several add watermarks or cap free printable cards. For in-person events — all-hands, conferences, holiday parties — printed materials still matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform Highlights
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TeamBuilding.com&lt;/strong&gt; leads facilitated experiences with 25+ structured activities and trained hosts across major cities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;QuizBreaker&lt;/strong&gt; offers the most comprehensive all-in-one suite: scheduled quizzes, DISC/Big Five psychometrics, escape rooms, pulse surveys, and peer recognition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Donut&lt;/strong&gt; has driven 15 million colleague connections through Slack — the most-adopted async tool for remote culture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Science Says It Works
&lt;/h2&gt;

&lt;p&gt;A 2023 meta-analysis of 41 studies (5,071 participants) found gamified approaches produce a large effect size: Hedges' g = 0.822 compared to traditional methods (Li, He &amp;amp; Yuan, &lt;em&gt;Frontiers in Psychology&lt;/em&gt;, DOI: 10.3389/fpsyg.2023.1253549).&lt;/p&gt;

&lt;p&gt;Teams with regular team-building activities see 14% productivity increases and 23% profitability gains. For every $1 spent, companies report $4-$6 return.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trends
&lt;/h2&gt;

&lt;p&gt;Google Trends data from 2019-2026 shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Virtual team building" grew 736% and never came back down&lt;/li&gt;
&lt;li&gt;"Office bingo" hit an all-time high in February 2026&lt;/li&gt;
&lt;li&gt;"Gamification employee engagement" reached peak search interest in March 2026&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Full Report
&lt;/h2&gt;

&lt;p&gt;The complete report with interactive charts, 19 footnoted references, and CC BY 4.0 licensing:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://bingwow.com/blog/team-building-engagement-report-2026" rel="noopener noreferrer"&gt;The State of Team Building Games in 2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://bingwow.com/research" rel="noopener noreferrer"&gt;BingWow Research Portal&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I built &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; as a free alternative — AI bingo card generation, multiplayer, company logo upload, printing. No subscription, no ads.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>teambuilding</category>
      <category>hr</category>
      <category>gamification</category>
      <category>remotework</category>
    </item>
    <item>
      <title>Building a Virtual Team Activity That Works in Any Browser</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 21 Apr 2026 18:11:49 +0000</pubDate>
      <link>https://forem.com/forrestmiller/building-a-virtual-team-activity-that-works-in-any-browser-57ea</link>
      <guid>https://forem.com/forrestmiller/building-a-virtual-team-activity-that-works-in-any-browser-57ea</guid>
      <description>&lt;h1&gt;
  
  
  Building a Virtual Team Activity That Works in Any Browser
&lt;/h1&gt;

&lt;p&gt;Our team is remote. Fully distributed, five time zones, the usual. Every few weeks someone on the People team asks if we can "do something fun together." The options are always the same: Jackbox (someone can't install it), Kahoot (half the team groans), or a virtual escape room that costs $25/person and requires a purchase order.&lt;/p&gt;

&lt;p&gt;So we built something instead. A multiplayer bingo game that works in any browser, needs zero installs, and starts with a shared link. Here's how it works under the hood, and why the constraints mattered more than the features.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraint That Shaped Everything
&lt;/h2&gt;

&lt;p&gt;The hardest part of virtual team activities isn't the game itself. It's the 15 minutes before the game where someone can't install the app, someone else needs to create an account, and the IT person on the call says the domain is blocked by the corporate firewall.&lt;/p&gt;

&lt;p&gt;We set one rule: &lt;strong&gt;it has to work the moment someone clicks a link.&lt;/strong&gt; No app stores. No sign-up forms. No browser extensions. If it doesn't work in the browser you already have open, it's not worth building.&lt;/p&gt;

&lt;p&gt;That constraint eliminated most architectures. Native apps were out. Anything requiring authentication was out. Heavy client-side frameworks that break on corporate-managed Chrome installations were out.&lt;/p&gt;

&lt;p&gt;What's left? A server-rendered page with a WebSocket connection for real-time gameplay.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Real-Time Part Works
&lt;/h2&gt;

&lt;p&gt;Every bingo game is a room. When a host creates a game, the server generates a room code and a shareable link. Players click the link, land on the game page, and a WebSocket connection opens automatically.&lt;/p&gt;

&lt;p&gt;Here's what travels over the wire:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Player joins&lt;/strong&gt; → server sends them a uniquely shuffled board (same word pool, different arrangement per player)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Player claims a cell&lt;/strong&gt; → broadcast to all players in the room&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Player reacts with an emoji&lt;/strong&gt; → broadcast immediately&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Someone gets bingo&lt;/strong&gt; → server validates the pattern and announces it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important design choice: the server is authoritative. Clients don't decide if they got bingo. They send their board state, the server checks it, and confirms or rejects. This prevents the inevitable "I definitely had bingo first" argument.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client A clicks cell → WebSocket → Server validates → Broadcasts to Room
                                                    ↓
                                            Client B sees claim
                                            Client C sees claim
                                            Host sees claim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use WebSocket heartbeats to track who's still connected. If someone's browser goes to sleep (common on phones during Zoom calls), the connection drops and the player shows as inactive. When they come back, they reconnect and the server replays any missed state.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Content Generation: The Part That Saves 20 Minutes
&lt;/h2&gt;

&lt;p&gt;The second friction point with team bingo is content. Someone has to write 25 squares. For a "things that happen on Zoom calls" card, that means opening a Google Doc, brainstorming with the team, arguing about whether "someone's kid walks in" is too obvious, and eventually giving up at 18 squares.&lt;/p&gt;

&lt;p&gt;We built an AI content pipeline that takes a topic description and generates a full set of bingo clues in seconds. The model gets the topic, the grid size (3×3, 4×4, or 5×5), and returns clues that are specific enough to be funny but broad enough that multiple people can claim them.&lt;/p&gt;

&lt;p&gt;The generation is streaming — clues appear one by one as the model produces them. Users can swap individual clues they don't like, which triggers a single-clue regeneration rather than rebuilding the whole card. This keeps the human in the loop without making them do the tedious part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "No Account" Is a Feature, Not a Missing Feature
&lt;/h2&gt;

&lt;p&gt;We went back and forth on this. Accounts enable saved games, player history, leaderboards across sessions. But accounts also mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Someone in IT has to approve a new SaaS tool&lt;/li&gt;
&lt;li&gt;GDPR compliance for European team members&lt;/li&gt;
&lt;li&gt;Password reset emails when someone forgets their login before the next game&lt;/li&gt;
&lt;li&gt;One more entry in the company's vendor management spreadsheet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a team activity that happens every few weeks, the overhead of accounts kills adoption. So we made accounts optional. You can create one to save your cards, but players never need one. The host shares a link, people click it, they play.&lt;/p&gt;

&lt;p&gt;This is the same reason the game works without a native app. Every friction point — every "download this first" or "create an account to continue" — loses a percentage of your team. By the time you've lost 3 people to setup friction, the game isn't fun anymore because the room is too small.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Printing Path (For When WiFi Fails)
&lt;/h2&gt;

&lt;p&gt;Not every team activity happens on a video call. Some offices do in-person team lunches. Some teams meet up at offsites. We added PDF generation that produces up to 200 uniquely shuffled boards — same content, different cell arrangements — that you can print and play with markers.&lt;/p&gt;

&lt;p&gt;The PDF generation runs server-side. The client sends the card content and grid size, the server generates the PDFs with randomized layouts, and returns a downloadable file. No client-side PDF libraries, no browser compatibility issues.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Distribution beats features.&lt;/strong&gt; A game that works instantly via link beats a game with 50 features that takes 10 minutes to set up. We could add tournament brackets, persistent leaderboards, team scoring. But none of that matters if three people can't join.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The host experience is the bottleneck.&lt;/strong&gt; Players just click a link. The host has to create the game, pick a topic, configure the grid, and share the link. Every second of host setup time reduces the chance they'll do it again next month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Corporate networks are hostile to fun.&lt;/strong&gt; Firewalls block unknown domains. Chrome extensions get disabled by policy. App installations require admin approval. Building for the browser — specifically, building something that works on a locked-down corporate Chromebook — is the only reliable path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. WebSocket reliability matters more than WebSocket performance.&lt;/strong&gt; Nobody cares if a cell claim takes 50ms or 200ms to propagate. They care a lot if the connection drops during the game and they lose their progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It With Your Team
&lt;/h2&gt;

&lt;p&gt;If you want to run this with your own remote team, &lt;a href="https://bingwow.com/for/remote-teams" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is the tool we built. Completely free, no account needed for players, works in any browser. Type a topic, share the link in Slack or Zoom chat, play for 10 minutes.&lt;/p&gt;

&lt;p&gt;The best team activities are the ones that actually happen. And they only happen when the setup cost is close to zero.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>remote</category>
      <category>websockets</category>
      <category>teambuilding</category>
    </item>
    <item>
      <title>How We Generate Bingo Cards with AI in 10 Seconds</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 13 Apr 2026 18:14:15 +0000</pubDate>
      <link>https://forem.com/forrestmiller/how-we-generate-bingo-cards-with-ai-in-10-seconds-1ke</link>
      <guid>https://forem.com/forrestmiller/how-we-generate-bingo-cards-with-ai-in-10-seconds-1ke</guid>
      <description>&lt;p&gt;Type "Super Bowl party" into &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; and you get 25 themed bingo clues in about 10 seconds. Clues stream in one at a time while you watch. You can swap any clue with an AI-generated replacement, adjust the tone (playful vs. realistic), or edit the text directly.&lt;/p&gt;

&lt;p&gt;Here's how the generation pipeline works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Streaming Approach
&lt;/h2&gt;

&lt;p&gt;We use Google's Gemini API with server-sent events (SSE). The prompt includes the topic, desired tone, grid size, and any existing clues to avoid duplicates. Clues stream to the client as they're generated -- the board fills in progressively instead of making the user wait for all 25.&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/templates/generate-clues
→ SSE stream of clues
→ Client appends each clue to the board in real time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tone control gives users three options: Playful (puns, exaggeration, humor), Balanced (mix of funny and realistic), and Realistic (things that actually happen). The prompt engineering for each tone took more iteration than any other part of the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just GPT-4?
&lt;/h2&gt;

&lt;p&gt;We use Gemini for clue generation and Claude for content moderation. The split is intentional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemini&lt;/strong&gt; (clue generation): Faster streaming, cheaper per token, good at creative list generation. The clues are short (under 50 characters each), so the model's creative writing quality matters less than speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude&lt;/strong&gt; (moderation): Better at nuanced content review. When a user-created card goes through moderation, Claude classifies it as KEEP, DELETE, or IMPROVE (rewrite + publish). The IMPROVE path rewrites inappropriate clues while preserving the card's theme.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Semantic Deduplication
&lt;/h2&gt;

&lt;p&gt;A common failure mode: the AI generates "Someone scores a touchdown" and "A touchdown is scored." Same clue, different words. We run semantic dedup after generation -- computing embedding similarity between all clue pairs and replacing duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background Image Generation
&lt;/h2&gt;

&lt;p&gt;When you create a card, we also generate a background image via Replicate's FLUX Schnell model. The image goes through a text detection step (Claude Haiku vision) -- if readable text is detected in the image, we discard it and assign a fallback background. Text in background images makes clue text unreadable.&lt;/p&gt;

&lt;p&gt;The full pipeline (clue generation + background image) completes in under 15 seconds. The image usually finishes before the clues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content Moderation Pipeline
&lt;/h2&gt;

&lt;p&gt;Every user-created card runs through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic checks&lt;/strong&gt; (blocked words, spam patterns)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI review&lt;/strong&gt; (Claude classifies KEEP/DELETE/IMPROVE)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-categorization&lt;/strong&gt; (assigns the card to the right topic category)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Icon assignment&lt;/strong&gt; (matches a Noun Project icon via embedding similarity)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cards are playable immediately (even before moderation completes). The moderation runs in &lt;code&gt;after()&lt;/code&gt; so it doesn't block the response.&lt;/p&gt;

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

&lt;p&gt;Create a card on any topic in 10 seconds: &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;bingwow.com/create&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Browse 1,000+ pre-made cards: &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingwow.com/cards&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything is free -- no signup, no ads, no limits.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>gemini</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Real-Time Multiplayer Bingo with Next.js and Ably</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 13 Apr 2026 17:30:33 +0000</pubDate>
      <link>https://forem.com/forrestmiller/building-real-time-multiplayer-bingo-with-nextjs-and-ably-3j0b</link>
      <guid>https://forem.com/forrestmiller/building-real-time-multiplayer-bingo-with-nextjs-and-ably-3j0b</guid>
      <description>&lt;p&gt;I built &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, a free multiplayer bingo platform. Players open a link, get a uniquely shuffled board, and play together in real time. No app download, no account creation.&lt;/p&gt;

&lt;p&gt;This post covers the interesting technical decisions: real-time sync, optimistic claims, server-side bingo detection, and why each player needs a different board.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Traditional bingo generators give everyone the same card. In person that works because a caller draws numbers. Online, there's no caller -- players mark cells when they spot something happening (during a TV show, a meeting, a baby shower). If everyone has the same arrangement, the first person to mark a cell wins every time.&lt;/p&gt;

&lt;p&gt;Each player needs the same &lt;em&gt;clues&lt;/em&gt; in different &lt;em&gt;positions&lt;/em&gt;. And claiming a cell needs to be instant (optimistic) while still being authoritative (server-verified).&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; with App Router (React 19)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; PostgreSQL for rooms, players, boards, claims&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ably&lt;/strong&gt; for real-time events (claims, bingo, round transitions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; for deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Board Generation
&lt;/h3&gt;

&lt;p&gt;Every board is deterministic. Given a room seed and a player ID, the board is reproducible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;playerSeed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomSeed&lt;/span&gt; &lt;span class="nx"&gt;XOR&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="nx"&gt;playerRng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mulberry32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerSeed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;positions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fisherYatesShuffle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonFreePositions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;playerRng&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The room seed determines &lt;em&gt;which&lt;/em&gt; clues appear. The player seed determines &lt;em&gt;where&lt;/em&gt; they go. Late joiners regenerate the exact same board by using the same seeds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wildcard Mode
&lt;/h3&gt;

&lt;p&gt;In rounds 2+, each player gets ~2/3 shared clues and ~1/3 unique clues. This creates boards that overlap enough for shared moments but diverge enough that bingo timing varies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optimistic Claims
&lt;/h3&gt;

&lt;p&gt;Tapping a cell marks it instantly. The server call is fire-and-forget for non-bingo claims:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Player taps cell&lt;/li&gt;
&lt;li&gt;UI shows claimed immediately (optimistic)&lt;/li&gt;
&lt;li&gt;POST fires to server (no await)&lt;/li&gt;
&lt;li&gt;Server rejects? Next &lt;code&gt;fetchGameState&lt;/code&gt; reconciles&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For bingo-completing claims, the POST is awaited. The server's &lt;code&gt;claim_and_process&lt;/code&gt; PostgreSQL function atomically inserts the claim, checks all winning lines, and updates the room status in one transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real-Time Sync
&lt;/h3&gt;

&lt;p&gt;Ably handles event broadcast. The server publishes; clients subscribe. Events: &lt;code&gt;claim&lt;/code&gt;, &lt;code&gt;bingo&lt;/code&gt;, &lt;code&gt;new-round&lt;/code&gt;, &lt;code&gt;player-joined&lt;/code&gt;, &lt;code&gt;chat&lt;/code&gt;. If Ably disconnects, clients call &lt;code&gt;fetchGameState&lt;/code&gt; on reconnect to reconcile any missed events.&lt;/p&gt;

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

&lt;p&gt;The claim system works but the fire-and-forget architecture means silent failures. If a claim POST fails (network error), the player sees a claimed cell that the server doesn't know about. It self-heals on the next state fetch, but there's a visible "unclaim" moment that confuses users.&lt;/p&gt;

&lt;p&gt;If I rebuilt it, I'd use a write-ahead approach: persist claims locally first, then sync. But for a free tool with casual gameplay, the current approach is good enough.&lt;/p&gt;

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

&lt;p&gt;The whole thing is free -- no ads, no paid tier, no signup required.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a 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 1,000+ cards: &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;bingwow.com/cards&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;For teachers: &lt;a href="https://bingwow.com/for/teachers" rel="noopener noreferrer"&gt;bingwow.com/for/teachers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>I Built a Real-Time Multiplayer Bingo Engine with Next.js, Supabase, and Ably</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Fri, 10 Apr 2026 21:10:09 +0000</pubDate>
      <link>https://forem.com/forrestmiller/i-built-a-real-time-multiplayer-bingo-engine-winextjs-webdev-javascript-reactth-nextjs-2og1</link>
      <guid>https://forem.com/forrestmiller/i-built-a-real-time-multiplayer-bingo-engine-winextjs-webdev-javascript-reactth-nextjs-2og1</guid>
      <description>&lt;p&gt;&lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is a free multiplayer bingo platform. You type a topic, AI generates a card, and up to 20 people play together in the browser. No downloads, no accounts. Teachers use it for classroom review games, event planners use it for baby showers and team building, and watch party hosts use it for live TV events.&lt;/p&gt;

&lt;p&gt;Here's how the real-time multiplayer works under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router) on Vercel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; (PostgreSQL + Auth + Storage)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ably&lt;/strong&gt; for real-time pub/sub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind v4&lt;/strong&gt; for styling&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Hard Problem: Simultaneous Bingo Claims
&lt;/h2&gt;

&lt;p&gt;Most of the game is simple. A player taps a cell, the client marks it optimistically, and a fire-and-forget POST goes to the server. No waiting, no blocking.&lt;/p&gt;

&lt;p&gt;The moment it gets complicated is bingo. When a claim completes a line, you need to atomically determine who won. Two players might complete their lines within milliseconds of each other. The server has to pick one winner and broadcast the result to everyone.&lt;/p&gt;

&lt;p&gt;We solve this with a Supabase RPC function called &lt;code&gt;claim_and_process&lt;/code&gt;. It runs in a single database transaction: insert the claim, check if it completes a line, and if so mark the round winner. The atomicity of the transaction means two simultaneous bingo claims can't both win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Board Generation: Deterministic Randomness
&lt;/h2&gt;

&lt;p&gt;Every player needs a unique board, but late joiners need to reconstruct the exact same board a player would have gotten had they joined at the start. We solve this with seeded PRNGs.&lt;/p&gt;

&lt;p&gt;The room gets a random seed at creation. Each player's board is derived from &lt;code&gt;(roomSeed XOR hash(playerId))&lt;/code&gt;. The hash function is FNV-1a, the PRNG is Mulberry32. Both are deterministic: same inputs, same board, every time.&lt;/p&gt;

&lt;p&gt;This means we never store boards in the database. Any client can reconstruct any player's board from the seed and player ID. Late joiners derive their board locally and overlay the current claim state from a single &lt;code&gt;game/state&lt;/code&gt; API call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wildcard Mode: Shared Clues with Individual Variation
&lt;/h2&gt;

&lt;p&gt;In wildcard mode (always on for online play), players share about 2/3 of their clues with 1/3 unique per player. The room RNG selects a "core" set, and each player's own RNG picks their variable clues from the remaining pool.&lt;/p&gt;

&lt;p&gt;This creates the right balance: enough overlap that calling clues is meaningful, enough variation that copying someone else's board doesn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-Time Events via Ably
&lt;/h2&gt;

&lt;p&gt;Every room subscribes to an Ably channel &lt;code&gt;room:{code}&lt;/code&gt;. Events include &lt;code&gt;claim&lt;/code&gt;, &lt;code&gt;bingo&lt;/code&gt;, &lt;code&gt;new-round&lt;/code&gt;, &lt;code&gt;player-joined&lt;/code&gt;, &lt;code&gt;chat&lt;/code&gt;, and &lt;code&gt;unclaim&lt;/code&gt;. Claims are fire-and-forget from the server -- the DB is the source of truth, and Ably events are convenience notifications.&lt;/p&gt;

&lt;p&gt;On reconnect after a disconnect, the client calls &lt;code&gt;fetchGameState&lt;/code&gt; to reconcile any missed events. A heartbeat POST every 2 minutes keeps &lt;code&gt;last_active_at&lt;/code&gt; fresh for the expiry cron.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Card Generation
&lt;/h2&gt;

&lt;p&gt;When a user types a topic on &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;bingwow.com/create&lt;/a&gt;, we stream clues from Gemini via SSE. The user sees clues appear one by one as the AI generates them. They can edit any clue inline, change the grid size (3x3, 4x4, 5x5), or regenerate with a different tone.&lt;/p&gt;

&lt;p&gt;Background images are generated in parallel via Replicate's FLUX Schnell model (~2 seconds), then screened by Claude Haiku for text artifacts before being attached to the card.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Skip the custom PRNG.&lt;/strong&gt; Mulberry32 was fun to implement but a simple &lt;code&gt;crypto.getRandomValues&lt;/code&gt; with a seed would have been simpler. The deterministic requirement is real, but there are libraries for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use server-sent events for game state instead of polling + Ably.&lt;/strong&gt; Ably works great but adds a dependency. SSE from a Next.js route handler could handle the claim/bingo broadcasts with one fewer service.&lt;/p&gt;

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

&lt;p&gt;The whole thing is free at &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;bingwow.com&lt;/a&gt;. Teachers can find classroom-specific guides at &lt;a href="https://bingwow.com/for/teachers" rel="noopener noreferrer"&gt;bingwow.com/for/teachers&lt;/a&gt;. The card creator is at &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;bingwow.com/create&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>nextjs</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
