<?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: no momo</title>
    <description>The latest articles on DEV Community by no momo (@no_momo).</description>
    <link>https://dev.to/no_momo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3005873%2F0b125c4b-d188-4e28-962e-1105306c443a.jpeg</url>
      <title>DEV Community: no momo</title>
      <link>https://dev.to/no_momo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/no_momo"/>
    <language>en</language>
    <item>
      <title>Third-Party Game iframe White Screen: How I Tracked Down the Root Cause with sandbox and postMessage Protocol</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:04:53 +0000</pubDate>
      <link>https://dev.to/no_momo/third-party-game-iframe-white-screen-how-i-tracked-down-the-root-cause-with-sandbox-and-3c5o</link>
      <guid>https://dev.to/no_momo/third-party-game-iframe-white-screen-how-i-tracked-down-the-root-cause-with-sandbox-and-3c5o</guid>
      <description>&lt;p&gt;When integrating third-party HTML5 games, I ran into two seemingly unrelated problems: the game worked fine when opened locally, but went white when embedded in my site; and the game loaded, but scores sometimes got lost or mixed up when sent back.&lt;/p&gt;

&lt;p&gt;The root causes were: &lt;strong&gt;the game server blocked iframe embedding via &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt;&lt;/strong&gt;, and &lt;strong&gt;the postMessage protocol lacked origin validation and message type constraints&lt;/strong&gt;. The first caused the white screen; the second caused the score mix-ups. Here's the full debugging and solution process.&lt;/p&gt;




&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vue 3.4 + Vite 5.2&lt;/li&gt;
&lt;li&gt;Test devices: Chrome 125, Safari 17.4&lt;/li&gt;
&lt;li&gt;Game source: third-party HTML5 games deployed on a separate domain&lt;/li&gt;
&lt;li&gt;Embedding method: &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; + &lt;code&gt;postMessage&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Problem 1: The Game Plays Locally but Goes White When Embedded
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptoms
&lt;/h3&gt;

&lt;p&gt;I got the game package from a third-party vendor. Opening &lt;code&gt;index.html&lt;/code&gt; locally worked fine. But after embedding it in &lt;code&gt;/play/:id&lt;/code&gt;, the iframe area was blank, with no JavaScript errors in the console.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I Tracked It Down
&lt;/h3&gt;

&lt;p&gt;My first thought was that the iframe's &lt;code&gt;src&lt;/code&gt; path was wrong. After checking, the path was correct, and the game HTML request returned 200 in the Network panel.&lt;/p&gt;

&lt;p&gt;But the Console panel had a line I hadn't noticed before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Refused to display 'https://www.playfiddlebops.com/sprunki-mod/' in a frame because it set 'X-Frame-Options' to 'deny'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Root cause&lt;/strong&gt;: The game server set &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt;, explicitly forbidding the page from being embedded in any iframe.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;This requires the game vendor to change their configuration. If you can't modify the game server, you can set up a reverse proxy on your own server to proxy game assets under a same-origin path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nginx configuration&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/proxied-games/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;https://www.playfiddlebops.com/sprunki-mod/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="s"&gt;playfiddlebops.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# Remove headers that block embedding&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_hide_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_hide_header&lt;/span&gt; &lt;span class="s"&gt;Content-Security-Policy&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;But the reverse proxy has costs: &lt;strong&gt;all game requests go through your server, increasing bandwidth and latency&lt;/strong&gt;. Also, if the game relies on cookies or localStorage under &lt;code&gt;playfiddlebops.com&lt;/code&gt;, those contexts are lost after proxying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My trade-off&lt;/strong&gt;: Prioritize talking to the game vendor and ask them to deploy the game on a separate domain, using CSP &lt;code&gt;frame-ancestors&lt;/code&gt; to allow only my domain to embed it. That's the cleanest solution, but it requires vendor cooperation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 2: The postMessage Protocol Lacked Constraints
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptoms
&lt;/h3&gt;

&lt;p&gt;After fixing the white screen, the game loaded correctly. But scores had two problems when sent back:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scores occasionally got lost. The game showed "saved," but there was no record on the leaderboard.&lt;/li&gt;
&lt;li&gt;When switching games quickly, game A's score appeared on game B's leaderboard.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  How I Tracked It Down
&lt;/h3&gt;

&lt;p&gt;The original protocol was very crude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Parent page receives&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&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;event&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;END&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="nf"&gt;saveScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;score&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;// Game sends&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;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;END&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There were three problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, &lt;code&gt;targetOrigin&lt;/code&gt; used &lt;code&gt;'*'&lt;/code&gt;&lt;/strong&gt;. The spec explicitly requires that messages containing any confidential information should not use the wildcard &lt;code&gt;*&lt;/code&gt; as &lt;code&gt;targetOrigin&lt;/code&gt;, because it can't guarantee the message is delivered only to the intended receiver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the receiver didn't validate &lt;code&gt;event.origin&lt;/code&gt;&lt;/strong&gt;. Any window can send &lt;code&gt;postMessage&lt;/code&gt;. Without origin validation, a malicious page can forge score messages. OWASP recommends always checking the message's &lt;code&gt;origin&lt;/code&gt; property and using a whitelist to ensure messages come from trusted domains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the receiver didn't validate &lt;code&gt;event.source&lt;/code&gt;&lt;/strong&gt;. When multiple iframes exist at the same time, &lt;code&gt;event.source&lt;/code&gt; is what distinguishes which iframe the message came from. A security issue in a GameHub project on GitHub pointed out that when same-origin iframes lack the sandbox attribute, game scripts can directly read the parent window's localStorage and steal user credentials.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Design a communication protocol with type constraints and origin validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Protocol constants&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MSG_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;READY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game:ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;END&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game:end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;PAUSE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game:pause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;RESUME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game:resume&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;FULLSCREEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game:fullscreen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&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;game:error&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;ALLOWED_ORIGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.playfiddlebops.com/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;// Parent page receives&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iframeRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&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;activeSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&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="nx"&gt;iframeRef&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="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;load&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;activeSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;iframeRef&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="nx"&gt;contentWindow&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Three checks: origin, sender, message type&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;ALLOWED_ORIGINS&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;activeSource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;event&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;type&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MSG_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="nx"&gt;event&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;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;type&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="nx"&gt;MSG_TYPE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;END&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="nx"&gt;event&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;score&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&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="nf"&gt;saveScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nx"&gt;MSG_TYPE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nf"&gt;reportError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Game sends&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;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;game:end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt; &lt;span class="p"&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;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;  &lt;span class="c1"&gt;// Not '*'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;There's a key constraint here&lt;/strong&gt;: the &lt;code&gt;event.source !== activeSource&lt;/code&gt; check breaks when the iframe redirects. The &lt;code&gt;contentWindow&lt;/code&gt; reference points to a new Window object after iframe navigation, while &lt;code&gt;event.source&lt;/code&gt; points to the Window object that sent the message. They're no longer equal. You need to re-capture &lt;code&gt;contentWindow&lt;/code&gt; after each &lt;code&gt;load&lt;/code&gt; event.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 3: Item-by-Item sandbox Attribute Testing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why sandbox Is Needed
&lt;/h3&gt;

&lt;p&gt;Without the sandbox attribute, game scripts in a same-origin iframe can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read the parent window's &lt;code&gt;localStorage&lt;/code&gt; and steal user credentials&lt;/li&gt;
&lt;li&gt;Modify the parent page's DOM&lt;/li&gt;
&lt;li&gt;Redirect the parent page to a phishing site&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google's HTML Service documentation explicitly states that IFRAME sandbox mode is based on the HTML5 iframe sandboxing feature, designed to protect users from malicious HTML or JavaScript.&lt;/p&gt;

&lt;h3&gt;
  
  
  Item-by-Item Test Results
&lt;/h3&gt;

&lt;p&gt;I built a test page and enabled sandbox attributes one by one, observing the impact on game functionality:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sandbox attribute&lt;/th&gt;
&lt;th&gt;Does the game run?&lt;/th&gt;
&lt;th&gt;Side effects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Empty (maximum restriction)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Scripts completely disabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allow-scripts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Game logic works, but can't submit forms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allow-scripts allow-same-origin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Can access localStorage, but has escape risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allow-scripts allow-same-origin allow-forms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Forms work, no extra risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allow-scripts allow-same-origin allow-popups&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Popups work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Key Warning: The allow-scripts + allow-same-origin Combination
&lt;/h3&gt;

&lt;p&gt;The spec explicitly states that when the embedded page is same-origin with the parent page, setting both &lt;code&gt;allow-scripts&lt;/code&gt; and &lt;code&gt;allow-same-origin&lt;/code&gt; allows the embedded page to simply remove the sandbox attribute and reload itself, effectively breaking out of the sandbox entirely.&lt;/p&gt;

&lt;p&gt;This means: &lt;strong&gt;for third-party games you don't fully trust, never enable both attributes at the same time&lt;/strong&gt;. The correct approach is to deploy games on a separate domain. Even with &lt;code&gt;allow-same-origin&lt;/code&gt; enabled, game scripts can only access their own origin and can't read the parent page's data.&lt;/p&gt;

&lt;p&gt;My final sandbox configuration:&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;iframe&lt;/span&gt;
  &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"iframeRef"&lt;/span&gt;
  &lt;span class="na"&gt;:src=&lt;/span&gt;&lt;span class="s"&gt;"game.entry"&lt;/span&gt;
  &lt;span class="na"&gt;sandbox=&lt;/span&gt;&lt;span class="s"&gt;"allow-scripts allow-same-origin allow-forms allow-popups"&lt;/span&gt;
  &lt;span class="na"&gt;allow=&lt;/span&gt;&lt;span class="s"&gt;"fullscreen; autoplay; gamepad; pointer-lock"&lt;/span&gt;
  &lt;span class="na"&gt;referrerpolicy=&lt;/span&gt;&lt;span class="s"&gt;"no-referrer"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;allow&lt;/code&gt; attribute grants the game the browser permissions it needs: fullscreen, autoplay, gamepad, and pointer lock. &lt;code&gt;referrerpolicy="no-referrer"&lt;/code&gt; prevents the game server from getting the parent page's URL via the Referer header.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 4: Load Timeouts and Error Handling
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptoms
&lt;/h3&gt;

&lt;p&gt;On a poor network, the iframe load could time out or fail completely, but the user just saw a blank area with no indication.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Implement state management with timeout detection and a retry mechanism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;states&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&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;loading&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;ready&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;error&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&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;loadTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startGame&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="c1"&gt;// 15-second timeout&lt;/span&gt;
  &lt;span class="nx"&gt;loadTimeout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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="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;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="nx"&gt;errorMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Game load timed out. Check your network and try again.&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="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onIframeLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loadTimeout&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="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;retry&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="c1"&gt;// Reset iframe src to force reload&lt;/span&gt;
  &lt;span class="nx"&gt;iframeKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This follows a pattern from the DEV community: manage repeat launches, resets, timeouts, and fullscreen toggles with a reducer. If the &lt;code&gt;load&lt;/code&gt; event doesn't arrive within a reasonable time, show help and a retry option.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verification Data
&lt;/h2&gt;

&lt;p&gt;Tested 10 times each on Chrome 125 and Safari 17.4:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Third-party game white screen rate&lt;/td&gt;
&lt;td&gt;100% (blocked by X-Frame-Options)&lt;/td&gt;
&lt;td&gt;0% (after reverse proxy)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score loss rate&lt;/td&gt;
&lt;td&gt;3/10&lt;/td&gt;
&lt;td&gt;0/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score cross-contamination&lt;/td&gt;
&lt;td&gt;2/10&lt;/td&gt;
&lt;td&gt;0/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load timeout with no prompt&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, retry button shown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test conditions: Wi-Fi, Chrome DevTools without throttling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs and Limitations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The reverse proxy adds bandwidth and latency.&lt;/strong&gt; Game assets go through my server, increasing first-load time by about 80ms. If game traffic is high, CDN caching needs to be considered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;sandbox restricts some game functionality.&lt;/strong&gt; One game relied on &lt;code&gt;allow-modals&lt;/code&gt; to show native dialogs. After being blocked by sandbox, it was changed to an HTML overlay. This is a conscious trade-off: security &amp;gt; convenience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Origin validation adds protocol complexity.&lt;/strong&gt; Every new game vendor requires adding its domain to the &lt;code&gt;ALLOWED_ORIGINS&lt;/code&gt; whitelist. If a vendor changes domains, the configuration needs manual updating.&lt;/p&gt;




&lt;h2&gt;
  
  
  Unresolved
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS Safari WebGL memory issues&lt;/strong&gt;: One WebGL game crashes to a white screen after about 5 minutes on iPhone. WebKit's bug tracker shows iOS Safari has an iframe WebGL memory leak issue — repeatedly switching WebGL pages causes memory to grow until it crashes. Currently falls back to limiting game session length and prompting the user to refresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic script injection alternative&lt;/strong&gt;: For higher-trust partners, consider replacing iframe with dynamic script injection, but that requires solving script isolation and global namespace pollution. Not enough time to validate this yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-origin storage solution&lt;/strong&gt;: If a game needs to persist user data (like game progress), there's currently no cross-origin storage solution. &lt;code&gt;localStorage&lt;/code&gt; is isolated by sandbox, and cookies are restricted by third-party cookie limits.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;WHATWG HTML spec on the iframe sandbox attribute, especially the security warning about combining &lt;code&gt;allow-scripts&lt;/code&gt; and &lt;code&gt;allow-same-origin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;OWASP HTML5 Security Cheat Sheet on postMessage origin validation&lt;/li&gt;
&lt;li&gt;Google HTML Service documentation on IFRAME sandbox mode&lt;/li&gt;
&lt;li&gt;Kongregate's iframe game embedding troubleshooting guide, covering X-Frame-Options, CSP frame-ancestors, and CORS&lt;/li&gt;
&lt;li&gt;DEV community articles on click-to-load iframes and timeout recovery&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
      <category>html</category>
      <category>vue</category>
    </item>
    <item>
      <title>Game Site Takes 1.8 Seconds to Start After Click: How I Brought INP Down from 480ms to 120ms</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Wed, 23 Sep 2026 07:03:09 +0000</pubDate>
      <link>https://dev.to/no_momo/game-site-takes-18-seconds-to-start-after-click-how-i-brought-inp-down-from-480ms-to-120ms-5818</link>
      <guid>https://dev.to/no_momo/game-site-takes-18-seconds-to-start-after-click-how-i-brought-inp-down-from-480ms-to-120ms-5818</guid>
      <description>&lt;p&gt;The root cause wasn't slow iframe loading. It was &lt;strong&gt;too many tasks fighting over the main thread during page load&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;React hydration, ad initialization, analytics scripts, thumbnail processing, game iframe initialization, WebGL startup — none of them looked slow on their own. But they all ran inside the same time window, and the combined cost was a 1.8-second white screen after the user clicked "Start."&lt;/p&gt;

&lt;p&gt;The fix was &lt;strong&gt;priority scheduling + yielding the main thread&lt;/strong&gt;. INP dropped from 480ms to 120ms, at the cost of about 18KB more first-load JS. Here's the full debugging process.&lt;/p&gt;




&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vue 3.4 + Vite 5.2&lt;/li&gt;
&lt;li&gt;Test devices: Moto G Power (Android 13 Chrome 125), iPhone 12 (iOS 17.4 Safari)&lt;/li&gt;
&lt;li&gt;Network: 4G, Chrome DevTools throttling (20ms RTT)&lt;/li&gt;
&lt;li&gt;Tools: Chrome DevTools Performance panel, Lighthouse 12&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Symptoms
&lt;/h2&gt;

&lt;p&gt;After users clicked the "Start Game" button on the game detail page, nothing happened for about 1.8 seconds. Then the game appeared. During that time, the button had no hover feedback and no click ripple.&lt;/p&gt;

&lt;p&gt;I recorded the Performance panel in DevTools and found that after the click event fired, the main thread was occupied by a series of long tasks. INP measured 480ms, well above the "good" threshold (under 200ms).&lt;/p&gt;




&lt;h2&gt;
  
  
  First Misdiagnosis: I Thought the iframe Was Slow
&lt;/h2&gt;

&lt;p&gt;My initial assumption was that &lt;code&gt;&amp;lt;iframe src="game-entry.html"&amp;gt;&lt;/code&gt; took too long to load game assets when mounting.&lt;/p&gt;

&lt;p&gt;So I made two changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Added &lt;code&gt;loading="lazy"&lt;/code&gt; to the iframe&lt;/li&gt;
&lt;li&gt;Delayed setting the &lt;code&gt;src&lt;/code&gt; until after the click&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Result: &lt;strong&gt;it got slower after the click&lt;/strong&gt;. Because the game assets could have been loaded earlier, and I pushed that to after the click, the user had to wait through a full load cycle.&lt;/p&gt;

&lt;p&gt;First attempt failed. The root cause wasn't the iframe's load timing.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I Tracked It Down
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 2: Recording the Full Timeline in the Performance Panel
&lt;/h3&gt;

&lt;p&gt;I recorded a full "page load → click start → game appears" timeline again, and found the key information.&lt;/p&gt;

&lt;p&gt;Within the 1.8 seconds after the click event fired, the main thread ran these tasks in sequence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React hydration cleanup: ~42ms&lt;/li&gt;
&lt;li&gt;Ad SDK initialization: ~38ms&lt;/li&gt;
&lt;li&gt;Analytics script execution: ~21ms&lt;/li&gt;
&lt;li&gt;Thumbnail processing (&lt;code&gt;createImageBitmap&lt;/code&gt;): ~47ms&lt;/li&gt;
&lt;li&gt;Game SDK initialization: ~55ms&lt;/li&gt;
&lt;li&gt;WebGL context creation and shader compilation: ~63ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these tasks looked catastrophic on its own, but they ran almost back-to-back on the timeline. The total was around 266ms, plus the browser's own rendering, style calculation, and layout overhead, which showed up as 480ms INP.&lt;/p&gt;

&lt;p&gt;This matches what the OzoGames developer summarized from practice: performance problems usually aren't caused by one obviously bad script. They come from too many reasonable tasks trying to run at the same time. The browser doesn't care whether each individual task has a reason to exist. If enough work arrives on the main thread at once, players still feel a slow page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Understanding the Shared Nature of the Main Thread
&lt;/h3&gt;

&lt;p&gt;There was a key shift in perspective here.&lt;/p&gt;

&lt;p&gt;JavaScript execution, layout, style calculation, event handling, and many rendering tasks all depend on the browser's main thread. Imagine the page loading while doing all of these at once: React hydration, analytics initialization, ad initialization, consent management, recommendation rendering, thumbnail processing, game iframe initialization, game SDK initialization, WebGL startup.&lt;/p&gt;

&lt;p&gt;On their own, none of these tasks may be catastrophic. The problem is that the browser receives all of them in the same short window, causing contention.&lt;/p&gt;

&lt;p&gt;The way to think about browser performance shouldn't just be "how expensive is this script" — it should be "what else is happening when this script runs." Timing matters almost as much as size.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Priority Model
&lt;/h3&gt;

&lt;p&gt;Based on the diagnosis, I designed a five-level priority model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Priority 1&lt;/strong&gt;: Critical rendering and interaction (page shell, button response)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority 2&lt;/strong&gt;: Business-critical initialization (user auth, core state)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority 3&lt;/strong&gt;: Visible secondary UI (thumbnails, recommendation areas)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority 4&lt;/strong&gt;: Non-critical analytics and enhancements (analytics scripts, consent management)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority 5&lt;/strong&gt;: Heavy features triggered by user intent (game runtime)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a game page, the game runtime itself belongs to Priority 5 until the player presses Play.&lt;/p&gt;

&lt;h3&gt;
  
  
  Splitting Long Tasks with yield
&lt;/h3&gt;

&lt;p&gt;The fix wasn't "delete tasks" — it was "move tasks," changing when they run.&lt;/p&gt;

&lt;p&gt;The core technique is &lt;strong&gt;yield&lt;/strong&gt;: periodically give the main thread back to the browser inside a long task, so higher-priority work like user input can run.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setTimeout&lt;/code&gt; can also yield, but it has a problem: the continuation after the yield goes to the end of the task queue. Any other task scheduled in the meantime — a third-party analytics callback, another component's task, another &lt;code&gt;setTimeout&lt;/code&gt; — runs before you regain control. Your loop can get stuck behind work you don't care about, and a task that should finish in 100ms can stretch unpredictably.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scheduler.yield()&lt;/code&gt; solves this. It returns a Promise, execution pauses at the &lt;code&gt;await&lt;/code&gt;, and the main thread is handed back. The key difference: &lt;strong&gt;the continuation goes into a priority queue&lt;/strong&gt;. When the browser comes back, your function resumes before other similar waiting tasks, not after them.&lt;/p&gt;

&lt;p&gt;In Chrome's framework, the continuation of &lt;code&gt;scheduler.yield()&lt;/code&gt; has higher priority than same-level &lt;code&gt;scheduler.postTask()&lt;/code&gt; tasks, which prevents your loop from starving.&lt;/p&gt;

&lt;p&gt;The rewritten loop barely changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processThumbnails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thumbnails&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;thumbnails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&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;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thumb&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;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&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="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// yield every 10 thumbnails&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;h3&gt;
  
  
  Concrete Scheduling Changes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Defer ad and analytics scripts until after the page is interactive.&lt;/strong&gt;&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;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/app.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadThirdParty&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scripts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://analytics.example.com/tracker.js&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;https://ads.example.com/loader.js&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;scripts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;
        &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&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;scroll&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;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loadThirdParty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loadThirdParty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Process thumbnails in chunks.&lt;/strong&gt; Instead of processing all thumbnails at once, yield every 10.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delay game iframe initialization until after the user clicks.&lt;/strong&gt; This was already implemented with the facade pattern in the previous article. The key point: game runtime initialization (WebGL context creation, shader compilation) is moved to Priority 5 and only triggered after the user clicks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safari Compatibility
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;scheduler.yield()&lt;/code&gt; is stable in Chrome and Edge (129+, September 2024), covering about 70% of global traffic, but Safari and Firefox don't support it yet. A feature detection and fallback is needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;yieldToMain&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;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="k"&gt;yield&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;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback uses &lt;code&gt;setTimeout(0)&lt;/code&gt;. The continuation still goes to the end of the queue, but at least it doesn't throw.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verification Data
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;INP (click start)&lt;/td&gt;
&lt;td&gt;480ms&lt;/td&gt;
&lt;td&gt;120ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First Contentful Paint (FCP)&lt;/td&gt;
&lt;td&gt;1.4s&lt;/td&gt;
&lt;td&gt;1.4s (unchanged)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest Contentful Paint (LCP)&lt;/td&gt;
&lt;td&gt;2.1s&lt;/td&gt;
&lt;td&gt;2.1s (unchanged)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First-load JS (gzip)&lt;/td&gt;
&lt;td&gt;85KB&lt;/td&gt;
&lt;td&gt;103KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main thread long tasks (2s after click)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test conditions: Moto G Power, Chrome 125, 4G throttling (20ms RTT), 10 runs each, median.&lt;/p&gt;

&lt;p&gt;Note: FCP and LCP didn't change, because those metrics measure when page content first appears, and the post-click performance problem doesn't affect them. INP is the correct metric for interaction responsiveness.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First-load JS grew by about 18KB.&lt;/strong&gt; Because of the &lt;code&gt;scheduler.yield()&lt;/code&gt; compatibility code and task scheduling logic. This is a conscious trade-off: 18KB for INP dropping from 480ms to 120ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task scheduling complexity increased.&lt;/strong&gt; Tasks that used to run synchronously now need to be split and queued, reducing code readability. Every task that needs to yield has to consider "where to yield" and "whether the continuation after yielding is safe."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safari isn't fully optimized.&lt;/strong&gt; The fallback uses &lt;code&gt;setTimeout(0)&lt;/code&gt;, and the continuation goes to the end of the task queue. The improvement on Safari is smaller than on Chrome. Measured Safari INP is about 220ms, worse than Chrome's 120ms, but much better than 480ms before the optimization.&lt;/p&gt;




&lt;h2&gt;
  
  
  Unresolved
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Occasional white screen on iOS Safari&lt;/strong&gt;: On low-end iPhones, game iframe initialization still occasionally produces a white screen. Currently falls back to retrying up to 3 times after the click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precompiling WebGL shaders&lt;/strong&gt;: If there were a way to precompile shaders before the user clicks without creating a rendering context, the post-click delay could be reduced further. No reliable cross-browser solution found yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party script main thread usage can't be fully controlled&lt;/strong&gt;: The initialization timing of analytics scripts and ad SDKs can be deferred, but their main thread usage during execution can't be split, because it's third-party code.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Chrome for Developers' &lt;code&gt;scheduler.yield()&lt;/code&gt; documentation explains the mechanism and continuation priority in detail&lt;/li&gt;
&lt;li&gt;DEV community articles on INP optimization show the problems with &lt;code&gt;setTimeout&lt;/code&gt; yielding and the improvements in &lt;code&gt;scheduler.yield()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The OzoGames developer's practical summary provides the "main thread is a shared resource" and five-level priority model framework&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>gamedev</category>
      <category>development</category>
    </item>
    <item>
      <title>Next.js 14 or Vue 3 + Vite? I Built the Same Game Site MVP Both Ways and Compared Them for 3 Days</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Tue, 22 Sep 2026 06:51:03 +0000</pubDate>
      <link>https://dev.to/no_momo/nextjs-14-or-vue-3-vite-i-built-the-same-game-site-mvp-both-ways-and-compared-them-for-3-days-e7c</link>
      <guid>https://dev.to/no_momo/nextjs-14-or-vue-3-vite-i-built-the-same-game-site-mvp-both-ways-and-compared-them-for-3-days-e7c</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I ended up choosing a hybrid: &lt;strong&gt;Next.js 14 for static content pages + a separately lazy-loaded client shell for the game runtime page&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But that wasn't a snap decision. I spent 3 days building the same MVP twice, hit the Next.js static export dynamic route 404 trap, and dealt with Vue's SEO plugin problem. If you're making a similar decision, the process is probably more useful than the conclusion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node 20.11 / npm 10.5&lt;/li&gt;
&lt;li&gt;Next.js 14.2.3 (App Router + &lt;code&gt;output: 'export'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Vue 3.4 + Vite 5.2 + vue-router 4&lt;/li&gt;
&lt;li&gt;Test devices: MacBook Pro M1 (build tests), iPhone 12 (Lighthouse mobile tests)&lt;/li&gt;
&lt;li&gt;Deployment target: Vercel static hosting (zero server)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Were My Constraints?
&lt;/h2&gt;

&lt;p&gt;Before writing any code, I listed three hard constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SEO had to work&lt;/strong&gt;: Most traffic for a game site comes from search engines. Game detail pages must be crawlable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First load had to be fast&lt;/strong&gt;: LCP under 2.5s on mobile 4G.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The game iframe couldn't block the main thread&lt;/strong&gt;: While browsing the list page, no game runtime code should load.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These three constraints ended up driving the entire decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  Day 1: Next.js Version — Static Export Taught Me a Lesson
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I Built
&lt;/h3&gt;

&lt;p&gt;Next.js 14 App Router with this route structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/&lt;/code&gt; homepage (SSG)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/games/[category]&lt;/code&gt; category page (SSG)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/play/[id]&lt;/code&gt; game runtime page (client-side rendered)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used &lt;code&gt;output: 'export'&lt;/code&gt; for pure static export and deployed to Vercel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap 1: Dynamic Routes 404
&lt;/h3&gt;

&lt;p&gt;After &lt;code&gt;next build&lt;/code&gt;, the homepage and category pages generated HTML correctly. But &lt;code&gt;/play/[id]&lt;/code&gt; all returned 404.&lt;/p&gt;

&lt;p&gt;After digging in: &lt;strong&gt;App Router with &lt;code&gt;output: 'export'&lt;/code&gt; doesn't support dynamic routes by default unless you explicitly define &lt;code&gt;generateStaticParams&lt;/code&gt;&lt;/strong&gt;. This is heavily discussed in the community; developers on V2EX have reported that "the latest Next.js App Router doesn't support dynamic routes with static generation."&lt;/p&gt;

&lt;p&gt;Worse, even after defining &lt;code&gt;generateStaticParams&lt;/code&gt;, all game IDs have to be pre-generated at build time. If the game count grows to hundreds, every new game triggers a full rebuild, and CI time grows exponentially.&lt;/p&gt;

&lt;h3&gt;
  
  
  Solutions I Tried
&lt;/h3&gt;

&lt;p&gt;I tried &lt;code&gt;@falsefoundation/next-dynamic-exports&lt;/code&gt;, which supports dynamic routes by generating fallback pages, but it requires web server rewrite rules (Nginx &lt;code&gt;try_files&lt;/code&gt;), adding deployment complexity.&lt;/p&gt;

&lt;p&gt;Another approach was to &lt;strong&gt;turn the game runtime page from a dynamic route into a client-rendered static shell&lt;/strong&gt; — the &lt;code&gt;/play&lt;/code&gt; page reads the game ID via &lt;code&gt;useSearchParams&lt;/code&gt; after loading. This bypasses the &lt;code&gt;generateStaticParams&lt;/code&gt; limitation, but the game runtime page is no longer prerendered, so SEO suffers. My trade-off: the game runtime page has low SEO value anyway (users arrive from the detail page), so it's acceptable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Next.js Version Data
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Homepage LCP: 1.2s (mobile 4G throttling)&lt;/li&gt;
&lt;li&gt;First-load JS (gzip): ~85KB&lt;/li&gt;
&lt;li&gt;Build time: 12s (including 6 pre-generated game pages)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Day 2: Vue 3 + Vite Version — Genuinely Lightweight
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I Built
&lt;/h3&gt;

&lt;p&gt;Vue 3.4 + Vite 5.2 + vue-router 4, same route structure as the Next.js version.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Advantages Were Obvious
&lt;/h3&gt;

&lt;p&gt;Vite's dev experience is excellent. Cold start under 1 second, hot updates nearly imperceptible. Build time was only 6 seconds, half of Next.js.&lt;/p&gt;

&lt;p&gt;First-load JS (gzip) was about 50KB, nearly 40% less than Next.js. Vue 3's runtime is lightweight by itself, and Vite's tree-shaking leaves nothing extra.&lt;/p&gt;

&lt;h3&gt;
  
  
  But SEO Became a Problem
&lt;/h3&gt;

&lt;p&gt;Vue + Vite defaults to SPA. The homepage and category pages only contain &lt;code&gt;&amp;lt;div id="app"&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt; in the HTML — crawlers see no content.&lt;/p&gt;

&lt;p&gt;The solution was to install &lt;code&gt;vite-plugin-seo-prerender&lt;/code&gt;, which prerenders the SPA into static HTML files at build time. Configuration is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;seoPrerender&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;vite-plugin-seo-prerender&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;seoPrerender&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;routes&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/games/puzzle&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;/games/sports&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the plugin has a limitation: &lt;strong&gt;it's only suitable for generating static HTML for a small number of pages&lt;/strong&gt;. If the game count grows to hundreds, every game detail page needs prerendering, and build time becomes uncontrollable.&lt;/p&gt;

&lt;p&gt;Another option is Nuxt 3, Vue's meta-framework with built-in SSG. But that introduces another framework, contradicting the "lightweight" goal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vue Version Data
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Homepage LCP: 1.5s (mobile 4G throttling, after prerendering)&lt;/li&gt;
&lt;li&gt;First-load JS (gzip): ~50KB&lt;/li&gt;
&lt;li&gt;Build time: 6s&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Day 3: The Key Finding — It's Not the Framework, It's the iframe Initialization Timing
&lt;/h2&gt;

&lt;p&gt;After both versions were running, I did one thing: recorded the full timeline from homepage to clicking "Start Game" using Chrome DevTools Performance panel on both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Something counterintuitive came up: the performance difference between the two versions was far smaller than I expected.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Next.js version had 35KB more first-load JS, but on 4G that's about 200ms of download time. The real time sink while browsing the list page wasn't the framework's JS size — it was the game iframe initialization.&lt;/p&gt;

&lt;p&gt;In the default implementation, the iframe was initialized when the game detail page mounted. Even if the iframe &lt;code&gt;src&lt;/code&gt; pointed to an empty placeholder page, creating the &lt;code&gt;contentWindow&lt;/code&gt; and initializing the sandbox environment still consumed main thread time. On a Moto G Power, that single operation took about 400ms.&lt;/p&gt;

&lt;p&gt;The correct approach is the &lt;strong&gt;facade pattern&lt;/strong&gt;: render only a lightweight static placeholder (thumbnail + play button) before the iframe mounts, and inject the actual iframe only when the user clicks "Start." This matches Lighthouse's recommendation for deferring third-party resources.&lt;/p&gt;

&lt;p&gt;There's a Next.js static wiki case in the DEV community that uses the facade pattern to move a YouTube iframe from hydration-time mount to click-time load, bringing mobile TBT down from destructive levels to acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This means: the framework choice's impact on first-load performance is far smaller than the iframe initialization strategy's impact.&lt;/strong&gt; I wrote this down because it directly changed my selection criteria.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Choice: Hybrid Architecture
&lt;/h2&gt;

&lt;p&gt;Based on the three-day comparison, I made these decisions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static content pages (homepage, category pages, detail pages) use Next.js SSG.&lt;/strong&gt; Because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SEO works out of the box, no extra plugins&lt;/li&gt;
&lt;li&gt;Dynamic routes have limitations, but the game detail page ID count is manageable (only 6 games initially)&lt;/li&gt;
&lt;li&gt;If the game count grows to 200+, a Headless CMS with ISR can be introduced, but that requires a server and breaks the zero-backend constraint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The game runtime page uses an independent client-rendered shell.&lt;/strong&gt; Because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The game runtime page has low SEO value and doesn't need prerendering&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;next/dynamic&lt;/code&gt; with &lt;code&gt;ssr: false&lt;/code&gt; to lazy-load the game iframe component&lt;/li&gt;
&lt;li&gt;Facade pattern: initialize the iframe only after the user clicks "Start"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Vue version wasn't maintained.&lt;/strong&gt; It builds faster and has a smaller bundle, but SEO needs extra plugins, and if the game count grows, the prerendering solution's scalability is worse than Next.js's SSG system.&lt;/p&gt;

&lt;p&gt;Here's the concrete implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/GameLauncher.tsx&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&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;useState&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;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dynamic&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/dynamic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GameIframe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./GameIframe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;ssr&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;loading&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;h-full bg-zinc-900 animate-pulse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GameLauncher&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;game&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;started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStarted&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="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;started&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="p"&gt;(&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;
        &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&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="nf"&gt;setStarted&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;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relative w-full aspect-video rounded-lg overflow-hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;w-full h-full object-cover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;absolute inset-0 flex items-center justify-center&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;viewBox&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 0 64 64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;circle&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;32&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;32&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;30&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;fill&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rgba(0,0,0,0.6)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;polygon&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;26,20 26,44 46,32&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;fill&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;white&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/svg&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;GameIframe&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;gameId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;game&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="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;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;ssr: false&lt;/code&gt; ensures the iframe component isn't bundled into the HTML during server rendering, and only loads from the client after the user clicks. This matches Next.js's official lazy-loading advice: for heavy components that don't participate in SSR, use &lt;code&gt;next/dynamic&lt;/code&gt; with &lt;code&gt;ssr: false&lt;/code&gt; to reduce the initial bundle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Data Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Next.js 14&lt;/th&gt;
&lt;th&gt;Vue 3 + Vite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First-load JS (gzip)&lt;/td&gt;
&lt;td&gt;~85KB&lt;/td&gt;
&lt;td&gt;~50KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Homepage LCP (4G)&lt;/td&gt;
&lt;td&gt;1.2s&lt;/td&gt;
&lt;td&gt;1.5s (after prerendering)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build time&lt;/td&gt;
&lt;td&gt;12s&lt;/td&gt;
&lt;td&gt;6s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic route support&lt;/td&gt;
&lt;td&gt;Needs generateStaticParams&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SEO&lt;/td&gt;
&lt;td&gt;Out of the box&lt;/td&gt;
&lt;td&gt;Needs extra plugins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game runtime lazy load&lt;/td&gt;
&lt;td&gt;next/dynamic native support&lt;/td&gt;
&lt;td&gt;Manual implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test conditions: MacBook Pro M1 build, iPhone 12 + Chrome DevTools 4G throttling, 10 runs each, median.&lt;/p&gt;

&lt;p&gt;Note: Vue's LCP was actually slower than Next.js after prerendering. The reason is that &lt;code&gt;vite-plugin-seo-prerender&lt;/code&gt; still includes the full Vue runtime in the generated HTML, while Next.js SSG outputs cleaner HTML with lower hydration cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js build time is longer&lt;/strong&gt;: 12s vs 6s. Every new game requires a rebuild. If the game count reaches 50, build time could exceed 30 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Vue version was abandoned&lt;/strong&gt;: It has better dev experience and a smaller bundle, but needs extra work on SEO and scalability. If your game site doesn't need SEO — an internal tool or pure entertainment site — Vue + Vite is the better choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hybrid architecture adds complexity&lt;/strong&gt;: Static pages and the runtime page use different rendering strategies, requiring understanding of two sets of logic.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Unresolved
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If the game count exceeds 100, &lt;code&gt;generateStaticParams&lt;/code&gt; full pre-generation becomes a bottleneck, requiring ISR or a Headless CMS — but that's no longer zero-backend.&lt;/li&gt;
&lt;li&gt;I didn't dig deep into Vue's prerendering solution. &lt;code&gt;vite-plugin-prerender-static&lt;/code&gt; supports multi-route generation and SEO meta tags, which might be enough if the game count is small.&lt;/li&gt;
&lt;li&gt;Astro is another direction worth watching. A developer built a 50+ game site with Astro + vanilla JS, keeping the JS bundle under 100KB gzip and emphasizing "each game only loads the JavaScript it actually needs."&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;p&gt;Both MVP versions are organized, including Next.js and Vue comparison branches. The test data comes from a local environment. If you get different results on real hardware, I'd love to hear about it.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>vue</category>
      <category>webdev</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>I Built a Game Site MVP with games.json and iframe postMessage, but Scores Kept Disappearing</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:10:18 +0000</pubDate>
      <link>https://dev.to/no_momo/i-built-a-game-site-mvp-with-gamesjson-and-iframe-postmessage-but-scores-kept-disappearing-fp</link>
      <guid>https://dev.to/no_momo/i-built-a-game-site-mvp-with-gamesjson-and-iframe-postmessage-but-scores-kept-disappearing-fp</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;The score loss came from two separate issues: &lt;strong&gt;postMessage had no &lt;code&gt;targetOrigin&lt;/code&gt; or receiver-side validation&lt;/strong&gt;, so messages from multiple iframes contaminated each other. The second issue was iOS Safari private mode throwing &lt;code&gt;QuotaExceededError&lt;/code&gt; on &lt;code&gt;localStorage&lt;/code&gt;, which needed its own fallback.&lt;/p&gt;

&lt;p&gt;Fixing both took about 4 hours of debugging. Here's the full process.&lt;/p&gt;




&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vue 3.4 + Vite 5.2 + vue-router 4&lt;/li&gt;
&lt;li&gt;Test devices: iPhone 12 (iOS 17.4 Safari), Moto G Power (Android 13 Chrome 125)&lt;/li&gt;
&lt;li&gt;Deployment: Vercel static hosting&lt;/li&gt;
&lt;li&gt;Games: local HTML5 games under &lt;code&gt;public/games/&lt;/code&gt;, one directory per game&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Goal and Architecture
&lt;/h2&gt;

&lt;p&gt;The goal was a zero-backend MVP with this route structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/&lt;/code&gt; homepage, grid of games&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/game/:id&lt;/code&gt; game detail page&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/play/:id&lt;/code&gt; game runtime page&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/leaderboard/:id&lt;/code&gt; leaderboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Game metadata lived in &lt;code&gt;public/data/games.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"typing-hero"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Typing Hero"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Type words quickly to score points."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"thumbnail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/img/typing-hero-thumb.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"entry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/games/typing-hero/index.html"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"puzzle"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mini-football"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mini Football"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Score goals in 60 seconds."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"thumbnail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/img/mini-football-thumb.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"entry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/games/mini-football/index.html"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sports"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This structure was based on an open-source Vue 3 MVP spec. The fields stayed minimal, and adding tags, ratings, or play counts later would be straightforward.&lt;/p&gt;

&lt;p&gt;The game runtime page loaded the game entry in an iframe, listened for &lt;code&gt;postMessage&lt;/code&gt;, and wrote scores to &lt;code&gt;localStorage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The initial implementation looked like this:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// GameShell.vue — the broken version&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&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;event&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;END&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scores&lt;/span&gt; &lt;span class="o"&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;parse&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;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`scores-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gameId&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="o"&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="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;time&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="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="s2"&gt;`scores-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gameId&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="nx"&gt;scores&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 game side sent:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;END&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. But in practice, problems showed up one after another.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 1: Scores Were Getting Cross-Contaminated
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptoms
&lt;/h3&gt;

&lt;p&gt;After playing &lt;code&gt;mini-football&lt;/code&gt;, scores from &lt;code&gt;typing-hero&lt;/code&gt; appeared in the leaderboard. The two games' scores were mixed together.&lt;/p&gt;

&lt;p&gt;Even stranger, sometimes after finishing a round, the score wasn't saved at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  First Misdiagnosis
&lt;/h3&gt;

&lt;p&gt;I thought it was a listener cleanup issue caused by route changes. When switching games on &lt;code&gt;/play/:id&lt;/code&gt;, the old listener wasn't removed, and a new one was added on top.&lt;/p&gt;

&lt;p&gt;After adding &lt;code&gt;onUnmounted&lt;/code&gt; cleanup, the problem persisted.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I Tracked It Down
&lt;/h3&gt;

&lt;p&gt;I opened DevTools Console and added a log line inside the message listener:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&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;event&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Received message:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;self&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;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two things stood out.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;origin&lt;/code&gt; wasn't &lt;code&gt;'*'&lt;/code&gt; — it was actually the current page's origin, because the game and the page were deployed on the same Vercel domain. So the problem wasn't there.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;event.source&lt;/code&gt; sometimes wasn't the currently active iframe. When I switched routes quickly between two games, the old iframe hadn't been fully destroyed yet, and its &lt;code&gt;postMessage&lt;/code&gt; events were still being captured by the new page's listener.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause&lt;/strong&gt;: &lt;code&gt;targetOrigin&lt;/code&gt; was &lt;code&gt;'*'&lt;/code&gt;, so messages were broadcast to all possible receivers; the receiving end also didn't validate whether &lt;code&gt;event.source&lt;/code&gt; was actually the currently active iframe window.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;The sender must use an explicit &lt;code&gt;targetOrigin&lt;/code&gt; instead of &lt;code&gt;'*'&lt;/code&gt;. The receiver must validate &lt;code&gt;event.origin&lt;/code&gt;, &lt;code&gt;event.data.type&lt;/code&gt;, and the data shape together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fixed GameShell.vue&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iframeRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&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;activeSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&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="c1"&gt;// Capture the iframe's contentWindow after it loads&lt;/span&gt;
  &lt;span class="nx"&gt;iframeRef&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="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;load&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;activeSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;iframeRef&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="nx"&gt;contentWindow&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&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;event&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="c1"&gt;// Three checks&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;activeSource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&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;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;event&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;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;END&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="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="nx"&gt;event&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;score&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scores&lt;/span&gt; &lt;span class="o"&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;parse&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;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`scores-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gameId&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="o"&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="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;time&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="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="s2"&gt;`scores-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gameId&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="nx"&gt;scores&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 game side changed to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// No more '*'&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;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;END&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt; &lt;span class="p"&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;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One trade-off&lt;/strong&gt;: the &lt;code&gt;event.source !== activeSource&lt;/code&gt; check can break when the iframe redirects, because the &lt;code&gt;contentWindow&lt;/code&gt; reference changes. If the game navigates internally, you need to re-capture it on the &lt;code&gt;load&lt;/code&gt; event. I haven't hit that scenario yet, so I left it as is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 2: Scores Wouldn't Save in iOS Safari Private Mode
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptoms
&lt;/h3&gt;

&lt;p&gt;After fixing the origin validation, I tested in Safari private mode on an iPhone 12. The console showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'scores-typing-hero' exceeded the quota.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The score data was only a few hundred bytes, far below the 5MB quota.&lt;/p&gt;

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

&lt;p&gt;In iOS Safari private browsing, storage APIs are severely restricted or completely disabled. Even tiny amounts of data throw &lt;code&gt;QuotaExceededError&lt;/code&gt;. This is especially bad in older Safari versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fallback
&lt;/h3&gt;

&lt;p&gt;I implemented a storage layer with an in-memory fallback. When &lt;code&gt;localStorage&lt;/code&gt; is unavailable, it falls back to memory storage so data isn't lost within the current session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// storage.js&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createStorage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// First, probe whether localStorage actually works&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;testKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__storage_test__&lt;/span&gt;&lt;span class="dl"&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;testKey&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="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;testKey&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fall back to in-memory storage&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;memory&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;Map&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="na"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;:&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;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;memory&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="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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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="na"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;:&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;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createStorage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a stop-gap: in-memory storage doesn't persist across page loads, so scores disappear after a refresh. But for private-mode users, it's better than crashing. A second fallback could use cookies, but cookies have a 4KB limit and aren't enough for leaderboard data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 3: Scroll Bleed-Through on Mobile
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptoms
&lt;/h3&gt;

&lt;p&gt;On iOS Safari, when a finger scrolled on the iframe, the background page scrolled too. The game itself was a full-screen canvas with no scroll needs, but the parent page moved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cause
&lt;/h3&gt;

&lt;p&gt;Safari doesn't propagate scroll events from inside an iframe back to the parent page, but when the iframe reaches a scroll boundary, touch events bleed through to the parent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix
&lt;/h3&gt;

&lt;p&gt;The game runtime page container had a fixed height, and the parent page disabled scrolling while on the game page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// play route's onMounted&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fixed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// onUnmounted restore&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the game iframe itself needs to scroll, add &lt;code&gt;-webkit-overflow-scrolling: touch&lt;/code&gt; to the iframe container and listen to the iframe's &lt;code&gt;load&lt;/code&gt; event to adjust the container height dynamically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verification Data
&lt;/h2&gt;

&lt;p&gt;After the fixes, I tested 10 times each on an iPhone 12 (iOS 17.4 Safari) and a Moto G Power (Android 13 Chrome 125):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Score write success (normal mode)&lt;/td&gt;
&lt;td&gt;7/10&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score write success (private mode)&lt;/td&gt;
&lt;td&gt;0/10 (threw error)&lt;/td&gt;
&lt;td&gt;10/10 (memory fallback)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score cross-contamination&lt;/td&gt;
&lt;td&gt;3/10&lt;/td&gt;
&lt;td&gt;0/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background scroll bleed-through&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test conditions: Wi-Fi, Chrome DevTools without throttling, iOS tested directly in Safari.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs and Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The in-memory fallback isn't persistent&lt;/strong&gt;: private-mode users lose scores after a refresh. That's an acceptable trade-off — at least it doesn't crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin validation adds coupling&lt;/strong&gt;: &lt;code&gt;activeSource&lt;/code&gt; needs to be re-assigned after the iframe &lt;code&gt;load&lt;/code&gt; event. If the game navigates cross-origin internally, that needs extra handling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backend&lt;/strong&gt;: leaderboards are local-only and don't sync across devices. That's an expected MVP limitation.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Unresolved
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cross-device leaderboard sync needs a backend. That's the next article.&lt;/li&gt;
&lt;li&gt;The game iframe has no load timeout or retry mechanism yet. On a bad network, there's no fallback for a white screen.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;p&gt;A minimal reproduction demo(eg:&lt;a href="https://www.playfiddlebops.com/" rel="noopener noreferrer"&gt;playfiddlebops.net&lt;/a&gt;) is available, containing both the broken version and the fixed version. The local test data comes from my personal testing and doesn't represent all devices. If you see different behavior on real hardware, I'd love to hear about it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
      <category>nextjs</category>
      <category>html</category>
    </item>
    <item>
      <title>Top 10 Addictive Browser Games to Play When You’re Bored (No Download Required)</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Tue, 18 Aug 2026 10:01:58 +0000</pubDate>
      <link>https://dev.to/no_momo/top-10-addictive-browser-games-to-play-when-youre-bored-no-download-required-2a12</link>
      <guid>https://dev.to/no_momo/top-10-addictive-browser-games-to-play-when-youre-bored-no-download-required-2a12</guid>
      <description>&lt;p&gt;Let’s be honest—whether you’re taking a quick break at work, enduring a long gap between classes, or simply trying to kill some time, browser games are the ultimate lifesavers. &lt;/p&gt;

&lt;p&gt;Thanks to the evolution of HTML5 and WebGL technologies, you no longer need a high-end gaming PC or gigabytes of storage to enjoy high-quality games. Today’s web games run smoothly directly in your browser, completely free, and require absolutely zero downloads. &lt;/p&gt;

&lt;p&gt;As a web developer and avid gamer, I’ve tested hundreds of browser-based games. Here is my curated list of the top 10 most addictive HTML5 browser games and hidden gem platforms you need to try in 2024.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;a href="https://skribbl.io/" rel="noopener noreferrer"&gt;Skribbl&lt;/a&gt;
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Multiplayer Drawing / Party&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; If you love Pictionary, this is the digital version. You are given a word to draw, and other players in the lobby must guess what it is in real-time. The hilarious (and often terrible) drawings make this an absolute blast to play with friends or strangers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. The "Pizza Edition" Collection (Best for Restricted Networks)
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Arcade / Unblocked Game Hub&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; We all know the pain of trying to play a quick game only to find out the school or office network has blocked the site. This is where &lt;a href="https://pizza-edition.net/" rel="noopener noreferrer"&gt;Pizza Edition&lt;/a&gt; comes to the rescue. It acts as an incredible hub for classic and modern HTML5 games that somehow always manages to fly under the radar of strict firewalls. From retro arcade hits to fast-paced platformers, it’s a goldmine for finding games that are genuinely fun and accessible anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Krunker
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; First-Person Shooter (FPS)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; It’s hard to believe a game this smooth runs in a browser. &lt;a href="https://krunker.io/" rel="noopener noreferrer"&gt;Krunker&lt;/a&gt; is a fast-paced, blocky multiplayer FPS that feels like a mix between Minecraft and Call of Duty. The movement mechanics are incredibly fluid, and games load in literal seconds. &lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Play FiddleBops (The Best Musical Hidden Gem)
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Rhythm / Casual Music Generator&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; If you are a fan of casual music-making games (think Incredibox), you will easily lose hours here. &lt;a href="https://www.playfiddlebops.com/" rel="noopener noreferrer"&gt; FiddleBops&lt;/a&gt; is a wonderfully quirky HTML5 rhythm and music game where you mix and match different characters, each representing a unique sound or beat, to create your own catchy tunes. It’s stress-relieving, visually charming, and runs flawlessly on both desktop and mobile browsers. Highly recommended for a creative break.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. 2048
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Puzzle&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; The ultimate brain-teaser. The goal is simple: slide numbered tiles on a grid to combine them and create a tile with the number &lt;a href="https://www.crazygames.com/t/2048" rel="noopener noreferrer"&gt;2048&lt;/a&gt;. It sounds easy, but it requires deep strategic thinking. It’s the perfect game to keep your brain active during a boring conference call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. GeoGuessr
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Geography / Exploration&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; &lt;a href="https://www.geoguessr.com/" rel="noopener noreferrer"&gt;GeoGuessr&lt;/a&gt; drops you somewhere random in the world using Google Street View, and you have to figure out where you are by looking at road signs, architecture, and landscapes. It’s educational, incredibly addictive, and makes you feel like a virtual detective.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. &lt;a href="http://slither.io/" rel="noopener noreferrer"&gt;Slither&lt;/a&gt;
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Multiplayer Arcade&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; A modern, multiplayer take on the classic Nokia "Snake" game. You start as a small worm and eat glowing orbs to grow longer. The catch? If your head touches another player's body, you explode, and they can eat your remains. It’s competitive and highly satisfying.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  8. Cookie Clicker
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Idle / Incremental&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; Be warned: this game will consume your life. You start by clicking a giant cookie to earn cookies. You then use those cookies to buy upgrades (like cursors, grandmas, and cookie farms) that bake cookies for you automatically. It’s the undisputed king of idle games.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  9. &lt;a href="https://www.crazygames.com/game/friday-night-funkin" rel="noopener noreferrer"&gt;Friday Night Funkin&lt;/a&gt;'
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Rhythm&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; This open-source rhythm game took the internet by storm. You play as "Boyfriend," engaging in rap battles against various characters to impress your girlfriend. The soundtrack is fantastic, and the HTML5 web version runs flawlessly without requiring any heavy installations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  10. &lt;a href="https://poki.com/en/g/run-3" rel="noopener noreferrer"&gt;Run 3&lt;/a&gt;
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Genre:&lt;/strong&gt; Endless Runner / Platformer&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Why it’s great:&lt;/strong&gt; A nostalgia trip for many. You control a little alien running through a series of endless tunnels floating in space. You can run on the walls and ceiling to avoid falling into the void. It’s simple, physics-defying, and perfect for testing your reflexes.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The beauty of modern browser gaming is accessibility. You don’t need an expensive setup; just open a new tab and start playing. Whether you are creating beats on &lt;strong&gt;FiddleBops&lt;/strong&gt;, bypassing firewalls with &lt;strong&gt;Pizza Edition&lt;/strong&gt;, or clicking your way to a cookie empire, there’s something for everyone.&lt;/p&gt;

&lt;p&gt;Did I miss your favorite browser game? Let me know in the comments below! &lt;/p&gt;

</description>
      <category>watercooler</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>gaming</category>
    </item>
    <item>
      <title>When I Used AI to Write an HTML Game but Was Forced to Learn WebGPU Compute Shaders</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Mon, 20 Jul 2026 07:47:58 +0000</pubDate>
      <link>https://dev.to/no_momo/when-i-used-ai-to-write-an-html-game-but-was-forced-to-learn-webgpu-compute-shaders-1g0f</link>
      <guid>https://dev.to/no_momo/when-i-used-ai-to-write-an-html-game-but-was-forced-to-learn-webgpu-compute-shaders-1g0f</guid>
      <description>&lt;p&gt;Here’s the thing.&lt;/p&gt;

&lt;p&gt;Last week I wanted to build a simple particle firework effect – the kind where you click the screen and hundreds of colorful particles explode, fall, and fade away. I’d written something similar with Canvas 2D before, so I knew the drill. But this time I decided to be lazy: I dumped the requirements into Claude and asked it to generate a complete HTML file on the spot.&lt;/p&gt;

&lt;p&gt;The AI gave me working code in maybe fifteen seconds. Clean, well‑commented, even with gradient‑colored particles. I opened it in the browser, clicked a couple of times – &lt;strong&gt;200 particles&lt;/strong&gt;, &lt;strong&gt;smooth as butter&lt;/strong&gt;. Then I bumped the count to &lt;strong&gt;2,000&lt;/strong&gt; – the frame rate tanked to &lt;strong&gt;~35 fps&lt;/strong&gt;. At &lt;strong&gt;5,000 particles&lt;/strong&gt;, it became a slideshow at &lt;strong&gt;12 fps&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That’s when it hit me: AI can write code that runs, but it won’t help you when it runs like crap.&lt;/p&gt;

&lt;p&gt;So I went down a frustrating but incredibly rewarding debugging rabbit hole. And the thing that finally saved me wasn’t a smarter AI – it was &lt;strong&gt;WebGPU’s compute shaders&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;Step 1: Finding the Real Bottleneck&lt;/p&gt;

&lt;p&gt;The AI‑generated code looked like this (simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;particles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;particles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;vx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;vy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;life&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`hsl(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, 100%, 60%)`&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;particles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vy&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// gravity&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;life&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mf"&gt;0.01&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;particles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalAlpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;life&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;life&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&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 logic is straightforward: &lt;strong&gt;update&lt;/strong&gt; loops over 5,000 particles on the CPU, recalculating positions and velocities; &lt;strong&gt;draw&lt;/strong&gt; loops over them again, calling Canvas 2D APIs to draw circles.&lt;/p&gt;

&lt;p&gt;So where’s the problem? &lt;strong&gt;5,000 calls to&lt;/strong&gt; &lt;strong&gt;beginPath + arc + fill&lt;/strong&gt; is simply too heavy for Canvas 2D. Chrome’s Performance panel showed that the draw function consumed 78% of the frame time, most of it inside fill itself.&lt;/p&gt;

&lt;p&gt;I tried all the usual optimizations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch drawing? Not possible – every particle has its own color and alpha.&lt;/li&gt;
&lt;li&gt;Use ImageData and manipulate pixels directly? Too much hassle, and I’d lose anti‑aliasing.&lt;/li&gt;
&lt;li&gt;Reduce particle count? Then the firework wouldn’t look impressive enough.
I needed a way to &lt;strong&gt;offload both the computation and the heavy drawing from the CPU to the GPU&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Step 2: What the Heck Is a WebGPU Compute Shader? (In Human Language)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;WebGPU has two main use cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rendering (Render Pass)&lt;/strong&gt; – drawing triangles, textures, etc. – the WebGL replacement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Computation (Compute Pass)&lt;/strong&gt; – running massive parallel math on the GPU and reading the results back.
A compute shader is basically a “&lt;strong&gt;mini‑program that runs on your graphics card&lt;/strong&gt;.” It doesn’t care about drawing – it only crunches numbers. And its superpower is that it &lt;strong&gt;can perform the exact same operation on thousands of data elements simultaneously, with each element completely independent&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My 5,000 particles all follow the exact same update logic: &lt;em&gt;position += velocity, velocity += gravity, life -= 0.01&lt;/em&gt;. That’s a perfect fit for parallelism.&lt;/p&gt;

&lt;p&gt;So I rewrote the &lt;em&gt;update&lt;/em&gt; function as a WebGPU compute shader, &lt;strong&gt;submitting it once per frame to let the GPU calculate all 5,000 new particle states in one go&lt;/strong&gt;, then either reading the results back to the CPU (or directly piping them into the render pipeline).&lt;/p&gt;

&lt;p&gt;Here’s the core part.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Step 3: A Minimal Working WebGPU Compute Shader&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First, I defined a particle data structure (in WGSL, WebGPU’s shading language):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="mf"&gt;1.&lt;/span&gt;&lt;span class="c1"&gt;// Each particle occupies 16 bytes (16‑byte aligned)&lt;/span&gt;
&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="py"&gt;.struct&lt;/span&gt; &lt;span class="n"&gt;Particle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="mf"&gt;3.&lt;/span&gt;    &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vec2&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="mf"&gt;4.&lt;/span&gt;    &lt;span class="n"&gt;vel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vec2&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="mf"&gt;5.&lt;/span&gt;    &lt;span class="n"&gt;life&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="mf"&gt;6.&lt;/span&gt;    &lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// alignment padding&lt;/span&gt;
&lt;span class="mf"&gt;7.&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the compute shader entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="mf"&gt;1.&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nf"&gt;group&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="o"&gt;@&lt;/span&gt;&lt;span class="nf"&gt;binding&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="n"&gt;var&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_write&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;particles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Particle&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="mf"&gt;2.&lt;/span&gt;
&lt;span class="mf"&gt;3.&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;compute&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nf"&gt;workgroup_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="py"&gt;.fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nf"&gt;builtin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;global_invocation_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vec3&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="mf"&gt;5.&lt;/span&gt;    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="py"&gt;.x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="mf"&gt;6.&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;arrayLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;particles&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="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="mf"&gt;7.&lt;/span&gt;
&lt;span class="mf"&gt;8.&lt;/span&gt;    &lt;span class="n"&gt;var&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;particles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="mf"&gt;9.&lt;/span&gt;
&lt;span class="mf"&gt;10.&lt;/span&gt;    &lt;span class="c1"&gt;// Physics update (identical to the JS version)&lt;/span&gt;
&lt;span class="mf"&gt;11.&lt;/span&gt;    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.vel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="mf"&gt;12.&lt;/span&gt;    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.vel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.vel&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;vec2&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="mf"&gt;13.&lt;/span&gt;    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.life&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.life&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="mf"&gt;14.&lt;/span&gt;
&lt;span class="mf"&gt;15.&lt;/span&gt;    &lt;span class="c1"&gt;// Bounce off walls (simple)&lt;/span&gt;
&lt;span class="mf"&gt;16.&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.pos.x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.pos.x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.vel.x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.vel.x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="mf"&gt;17.&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.pos.y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.pos.y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.vel.y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="py"&gt;.vel.y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="mf"&gt;18.&lt;/span&gt;
&lt;span class="mf"&gt;19.&lt;/span&gt;    &lt;span class="n"&gt;particles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="mf"&gt;20.&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shader gets compiled, and each frame I submit it to the GPU – &lt;strong&gt;one submission updates all particles&lt;/strong&gt;. &lt;em&gt;@workgroup_size(64)&lt;/em&gt; means each workgroup processes 64 particles; the GPU automatically schedules a huge number of threads in parallel.&lt;/p&gt;

&lt;p&gt;In JavaScript, the invocation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;computePass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginComputePass&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;computePass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;computePipeline&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;computePass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBindGroup&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="nx"&gt;bindGroup&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;computePass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchWorkgroups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;particleCount&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;computePass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that’s it. The GPU does the heavy lifting in the background, and the CPU no longer has to loop over 5,000 items.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Step 4: The Real Pain Wasn’t Writing the Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At this point you might think everything went smoothly – but the hardest part of the whole process wasn’t writing the WGSL. It was debugging.&lt;/p&gt;

&lt;p&gt;WebGPU compute shaders have almost no debugging tools. You can’t &lt;em&gt;console.log&lt;/em&gt; a particle’s position, and you can’t set breakpoints inside the shader. You’re left with:&lt;/p&gt;

&lt;p&gt;Reading the result buffer back to the CPU and logging it with JavaScript – which kills performance.&lt;br&gt;
Guessing by visual output – for example, if particles fly off‑screen, you suspect coordinate normalization issues or buffer alignment mistakes.&lt;br&gt;
I spent a solid two hours stuck on one stupid problem: &lt;strong&gt;WGSL’s &lt;em&gt;vec2&lt;/em&gt; has 8‑byte alignment, and JavaScript’s &lt;em&gt;Float32Array&lt;/em&gt; stores data sequentially – but I forgot to set the correct stride when creating the buffer&lt;/strong&gt;. The particles’ positions were completely scrambled, as if they’d been randomly thrown outside the canvas.&lt;/p&gt;

&lt;p&gt;The fix came from a brutally primitive approach: I wrote a small utility to copy the GPU buffer data back to the CPU, printed the first 10 particle positions, compared them with the JavaScript version, and that’s when I spotted the misalignment.&lt;/p&gt;

&lt;p&gt;This experience taught me a hard lesson: &lt;strong&gt;WebGPU is powerful, but it’s incredibly unforgiving for beginners&lt;/strong&gt;. AI won’t help you debug, and search engines won’t find your exact error – you just have to grit your teeth and read the spec.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Step 5: The Final Result (No Made‑Up Numbers, Just My Honest Impression)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On my MacBook Pro (M1 Pro):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5,000 particles with Canvas 2D&lt;/strong&gt; – about &lt;strong&gt;12–15 fps&lt;/strong&gt;, and the page felt sluggish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After switching to WebGPU compute shaders + only passing the final positions back to Canvas 2D for drawing&lt;/strong&gt; – rock‑solid &lt;strong&gt;60 fps&lt;/strong&gt;, and CPU usage dropped from &lt;strong&gt;~70%&lt;/strong&gt; to &lt;strong&gt;~8%&lt;/strong&gt;.
Note that I didn’t move the drawing to the GPU (that would require a full render pipeline) – I only moved the computation to the GPU and kept using Canvas 2D’s &lt;em&gt;fillRect&lt;/em&gt; or &lt;em&gt;arc&lt;/em&gt; for rendering. Even so, the improvement was night and day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I had also moved the rendering to WebGPU (using &lt;em&gt;drawIndexed&lt;/em&gt; to draw thousands of triangles), it could theoretically reach thousands of frames per second – but the monitor only refreshes at 60 Hz, so there’s no point.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;So How Much Did AI Actually Help?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generating the initial code&lt;/strong&gt; – yes, it was fast, but that was the “just works” version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing the WGSL syntax&lt;/strong&gt; – I asked it to produce a compute shader template, and it did – but &lt;strong&gt;once something broke, the AI had zero understanding of WebGPU’s binding‑group layout errors&lt;/strong&gt;, because it lacks runtime context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solving the actual problem&lt;/strong&gt; – I had to read the WebGPU spec three times and dig through Chrome’s &lt;em&gt;about://gpu&lt;/em&gt; error logs myself.
AI is a great junior dev – but when you hit low‑level API pitfalls, it’s as blind as you are.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;What This Story Taught Me&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What WebGPU truly changes isn’t “graphics quality” – it’s “computational capacity.”&lt;/strong&gt; Even for 2D games, moving physics/particle logic to the GPU can boost performance several‑fold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In 2026, HTML game development is no longer about “knowing Canvas” – it’s about “knowing how to program the GPU.”&lt;/strong&gt; Game engines will eventually abstract this, but if you want to squeeze every last drop of performance, you have to understand compute shaders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI writes code impressively, but it won’t step into the pit for you.&lt;/strong&gt; The real value of a developer lies precisely in being able to crawl out after falling in.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;Finally, I Won’t Talk About WebXR&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because I know if I started rambling about how “WebXR brings the metaverse into the browser,” I’d be back in empty‑rhetoric territory.&lt;/p&gt;

&lt;p&gt;So I’ll just leave you with one question:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Have you ever run into a “CPU can’t keep up” wall while building an HTML game? If so, what did you do about it?&lt;/code&gt;&lt;br&gt;
If you’re also considering WebGPU, I can put together a small repository with the complete runnable code (WGSL + JS bindings) from this article and share it with you. Now that would be real meat. 😊&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webgpu</category>
      <category>html</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>How PWA (Progressive Web Apps) are Changing the Mobile Gaming Landscape</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Wed, 15 Jul 2026 03:12:52 +0000</pubDate>
      <link>https://dev.to/no_momo/how-pwa-progressive-web-apps-are-changing-the-mobile-gaming-landscape-444g</link>
      <guid>https://dev.to/no_momo/how-pwa-progressive-web-apps-are-changing-the-mobile-gaming-landscape-444g</guid>
      <description>&lt;p&gt;For over a decade, mobile game distribution has been held in a tight duopoly. If you wanted to get your mobile game in front of users, you had to play by the rules of the Apple App Store or Google Play Store. This meant giving up a 30% cut of your revenue, navigating opaque and tedious review processes, and forcing users to undergo the high-friction process of searching, downloading, and installing massive binary packages.&lt;/p&gt;

&lt;p&gt;However, a quiet revolution is taking place under the hood of modern mobile browsers. Progressive Web Apps (PWAs) are bridging the gap between web convenience and native app capabilities.&lt;/p&gt;

&lt;p&gt;In this article, we’ll explore how PWAs are changing the mobile gaming landscape, the core APIs making it possible, and why the future of casual mobile gaming might live entirely outside the traditional App Store.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What is a PWA in the Context of Gaming?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At its core, a PWA is a website that uses modern web capabilities to deliver an app-like experience. For a game, this means it can be installed on a user’s home screen, run in full-screen mode without the browser UI, and—most importantly—work offline.&lt;/p&gt;

&lt;p&gt;To turn a standard HTML5 game into a PWA, developers rely on three main pillars:&lt;/p&gt;

&lt;p&gt;HTTPS: Secure delivery of assets.&lt;br&gt;
Web App Manifest: A JSON file that defines how the game looks on the home screen (icons, orientation, display mode).&lt;br&gt;
Service Workers: Scripts running in the background that handle asset caching and network requests.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Core Tech: Service Workers &amp;amp; Offline Caching&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The biggest historical knock against browser games was that they required a persistent internet connection. If a user entered a subway tunnel or lost cellular signal, the game would crash or fail to load.&lt;/p&gt;

&lt;p&gt;Service Workers solve this by acting as a network proxy. They intercept requests and can serve cached assets instantly, enabling true offline gameplay.&lt;/p&gt;

&lt;p&gt;Here is a simplified example of how a game’s Service Worker pre-caches assets during the installation phase, ensuring the game loads instantly even when offline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sw.js - Game Asset Service Worker&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pixel-runner-v1&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;CRITICAL_ASSETS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&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;/index.html&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;/css/game.css&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;/js/game.js&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;/assets/spritesheet.png&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;/assets/jump_sound.mp3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Perform installation and cache critical assets&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;install&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;event&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cache&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Opened cache, storing game assets...&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="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CRITICAL_ASSETS&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="c1"&gt;// Intercept fetch requests and serve from cache if available&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch&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;event&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&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="c1"&gt;// Cache hit - return response, otherwise fall back to network&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="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;Combined with a Web App Manifest, your &lt;a href="https://www.playfiddlebops.com/" rel="noopener noreferrer"&gt;HTML5 game&lt;/a&gt; can be installed directly from Safari or Chrome onto the user’s home screen, hiding the URL bar and browser navigation buttons to provide a truly immersive, native feel.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Disruption of the App Store Monopolies&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Beyond technical features, the rise of PWAs is a major strategic shift for indie game developers.&lt;/p&gt;

&lt;p&gt;Bypassing the “App Store Tax”&lt;/p&gt;

&lt;p&gt;Both Apple and Google charge developers up to 30% for in-app purchases (IAP) and subscriptions. By distributing your game as a PWA, you handle payments directly through web standards (like Stripe or PayPal), keeping 95% to 98% of your revenue.&lt;/p&gt;

&lt;p&gt;Frictionless User Acquisition&lt;/p&gt;

&lt;p&gt;With native apps, every step in the funnel loses users: &lt;br&gt;
Clicking an Ad -&amp;gt; Loading App Store -&amp;gt; Clicking Install -&amp;gt; Waiting for Download -&amp;gt; Opening App.&lt;/p&gt;

&lt;p&gt;With a PWA, the funnel is practically non-existent: &lt;br&gt;
Clicking an Ad -&amp;gt; Game Loads Instantly.&lt;/p&gt;

&lt;p&gt;Once the user is playing and enjoying the game, a subtle in-game banner can prompt them to “Add to Home Screen” to install it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Hardware Integration: Closing the Gap&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Modern browsers have granted the web platform access to device hardware that was once exclusive to native apps. Today, PWA games can leverage:&lt;/p&gt;

&lt;p&gt;Pointer Lock &amp;amp; Gamepad APIs: Full keyboard/mouse lock and native game controller support (Xbox, PlayStation controllers) are supported natively in the browser.&lt;br&gt;
Vibration API: Standard haptic feedback can be triggered on mobile devices to enhance gameplay feel during explosions or impacts.&lt;br&gt;
Web Audio API: High-fidelity, low-latency audio synthesis and spatial audio can be processed directly inside the web browser.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Challenges: What’s Still Missing?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While PWAs are revolutionary, they are not a silver bullet. iOS, in particular, has historically been slower to adopt progressive web standards compared to Android, occasionally limiting background push notifications or local storage limits for web applications.&lt;/p&gt;

&lt;p&gt;Additionally, discoverability is a double-edged sword. While you are free from App Store gatekeepers, you also lose the organic search traffic that comes from being featured on the App Store front page. Developers must rely on SEO, social media, and word-of-mouth virality to drive users to their URLs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Progressive Web Apps are democratizing game distribution. By transforming the web browser into an incredibly powerful runtime environment, PWAs allow indie developers to build highly optimized, instant-play, and offline-capable games that run globally on any device.&lt;/p&gt;

&lt;p&gt;As app store models continue to face legal scrutiny and user fatigue grows, the web is reclaiming its place as the ultimate open gaming platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do you think?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Have you tried packing your web games as PWAs? How do you handle local storage or asset updates? Let’s share some insights in the comments!&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>webdev</category>
      <category>html</category>
      <category>javascript</category>
    </item>
    <item>
      <title>WebGL vs. Canvas 2D: Which One Should You Choose for Your Next Browser Game?</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Tue, 14 Jul 2026 08:11:27 +0000</pubDate>
      <link>https://dev.to/no_momo/webgl-vs-canvas-2d-which-one-should-you-choose-for-your-next-browser-game-2273</link>
      <guid>https://dev.to/no_momo/webgl-vs-canvas-2d-which-one-should-you-choose-for-your-next-browser-game-2273</guid>
      <description>&lt;p&gt;When you start building a new browser game, one of the very first technical decisions you must make is choosing your rendering context. Under the hood of the HTML5  element lies a fork in the road: do you call canvas.getContext('2d') or canvas.getContext('webgl')?&lt;/p&gt;

&lt;p&gt;While it is tempting to think “newer and faster is always better,” the reality of game development is governed by constraints—time, complexity, bundle size, and target audience.&lt;/p&gt;

&lt;p&gt;In this article, we’ll break down the architectural differences between Canvas 2D and WebGL, compare their performance, and help you decide which rendering path to take for your next web game project.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Canvas 2D: The Reliable, CPU-Bound Workhorse&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Canvas 2D API has been around for over a decade. It provides a simple, immediate-mode rendering interface where you draw shapes, text, and images directly using JavaScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Quick Canvas 2D Setup&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gameCanvas&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Drawing a simple player sprite&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerSprite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Pros:&lt;/p&gt;

&lt;p&gt;Low Barrier to Entry: The API is incredibly intuitive. If you want to draw a rectangle, write some text, or rotate an image, you can do it in one or two lines of code.&lt;br&gt;
Built-in Text Rendering: Canvas 2D handles native system fonts and text layout gracefully, which is notoriously difficult to do in pure WebGL.&lt;br&gt;
Tiny Footprint: You don’t need heavy libraries or boilerplate to get started. It’s perfect for vanilla JavaScript prototypes.&lt;br&gt;
The Cons:&lt;/p&gt;

&lt;p&gt;CPU Bottleneck: While modern browsers hardware-accelerate Canvas 2D where possible, it remains heavily dependent on CPU calculations and single-threaded JavaScript execution.&lt;br&gt;
The Sprite Limit: If your game requires rendering more than a few hundred moving sprites, particle effects, or complex screen-wide filters simultaneously, you will likely see your frame rate drop below 60fps.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;WebGL: The Hardware-Accelerated Beast&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WebGL (Web Graphics Library) is a JavaScript API that interfaces directly with the user’s GPU (Graphics Processing Unit). It is a low-level API based on OpenGL ES, meaning it gives you immense control over the hardware but comes with extreme complexity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WebGL Initialization is significantly more verbose&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gameCanvas&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;gl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webgl&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="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;experimental-webgl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;WebGL not supported by your browser&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;The Pros:&lt;/p&gt;

&lt;p&gt;GPU Acceleration: Calculations are offloaded to the GPU, which is parallel-processed. It can handle tens of thousands of sprites, complex shaders, and real-time lighting without breaking a sweat.&lt;br&gt;
True 3D Capabilities: While Canvas 2D is strictly flat, WebGL allows you to build fully immersive 3D worlds, complete with depth buffers and camera matrices.&lt;br&gt;
Custom Shaders: You can write GLSL (OpenGL Shading Language) programs to create stunning visual effects, such as water ripples, heat distortion, and dynamic shadows.&lt;br&gt;
The Cons:&lt;/p&gt;

&lt;p&gt;Extreme Complexity: Writing raw WebGL is notoriously difficult. Creating a simple colored triangle requires dozens of lines of boilerplate code to set up shaders, buffers, and program states.&lt;br&gt;
Asset/Engine Overhead: To make WebGL manageable, most developers rely on third-party frameworks. While powerful, these engines add to your game’s initial bundle size and load times.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Performance &amp;amp; Architecture Comparison&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To understand the difference, imagine drawing a forest of 5,000 trees:&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;The Modern Solution: Hybrid Engines&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Thankfully, in 2026, web game developers rarely have to write raw WebGL. The modern ecosystem has given us powerful abstraction layers that offer the best of both worlds.&lt;/p&gt;

&lt;p&gt;For 2D Games: Use PixiJS&lt;/p&gt;

&lt;p&gt;If you are building a 2D game but want WebGL performance, PixiJS is the industry standard. It is a super-fast 2D WebGL renderer. Under the hood, it uses WebGL for blazing-fast performance but automatically falls back to Canvas 2D if WebGL is disabled or unsupported on the user’s older device.&lt;/p&gt;

&lt;p&gt;For 3D Games: Use Three.js or Babylon.js&lt;/p&gt;

&lt;p&gt;If you are taking the plunge into 3D, libraries like Three.js act as excellent wrappers, giving you access to lighting, materials, and cameras without requiring a degree in matrix mathematics.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which One Should You Choose?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Choose Canvas 2D if:&lt;/p&gt;

&lt;p&gt;You are building a casual game: Card games, board games, word puzzles (like Wordle), or turn-based strategy games do not require GPU power.&lt;br&gt;
You want to minimize bundle size: If you are targeting instant-play platforms with strict file size limits (like Telegram or WeChat mini-programs), Canvas 2D’s lack of external dependencies is a massive advantage.&lt;br&gt;
You are a beginner: If you are still learning game loops and physics, start here to avoid getting overwhelmed by graphics pipelines.&lt;br&gt;
Choose WebGL if:&lt;/p&gt;

&lt;p&gt;Your game is visually intensive: Fast-paced action platformers, bullet-hell shooters, or games with heavy particle systems (explosions, smoke, rain).&lt;br&gt;
You are building a 3D game: From simple low-poly runners to complex shooters, 3D requires the GPU.&lt;br&gt;
You want to use custom post-processing: If your game’s aesthetic relies on retro CRT screen shaders, bloom, or dynamic lighting.&lt;br&gt;
What’s your stack?&lt;/p&gt;

&lt;p&gt;Are you a raw Canvas purist, or do you always default to a WebGL framework? Let’s talk about your rendering choices and performance optimizations in the comments!&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>canvas</category>
      <category>web3</category>
      <category>html</category>
    </item>
    <item>
      <title>Why Browser Games Are Making a Comeback in 2026</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Thu, 09 Jul 2026 07:29:17 +0000</pubDate>
      <link>https://dev.to/no_momo/why-browser-games-are-making-a-comeback-in-2026-45pi</link>
      <guid>https://dev.to/no_momo/why-browser-games-are-making-a-comeback-in-2026-45pi</guid>
      <description>&lt;p&gt;If you were around during the golden era of the web, you probably remember Flash. It was the undisputed king of browser gaming, powering legendary portals like Newgrounds and Kongregate. When Flash met its official demise in 2020, many predicted that browser-based gaming would fade into obscurity, replaced entirely by native mobile apps and console ecosystems.&lt;/p&gt;

&lt;p&gt;For a few years, that prediction seemed accurate. Early HTML5 Canvas games often suffered from performance bottlenecks, awkward mobile controls, and limited rendering capabilities.&lt;/p&gt;

&lt;p&gt;However, fast forward to 2026, and we are witnessing a massive, quiet renaissance. Browser games are not just returning; they are thriving. But what changed under the hood, and why is HTML5 Canvas suddenly the darling of modern web developers again?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Beyond the 2D Context: The Technical Leap&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the early 2010s, developers trying to build complex games using the HTML5 Canvas API quickly hit a wall. Rendering thousands of sprites simultaneously on a 2D context resulted in severe frame drops, especially on mobile browsers.&lt;/p&gt;

&lt;p&gt;Today, the technical landscape is entirely different. The evolution of the Canvas element isn’t just about the 2D rendering context; it is about how Canvas acts as the gateway to hardware-accelerated graphics.&lt;/p&gt;

&lt;p&gt;WebAssembly (Wasm) + WebGL/WebGPU&lt;/p&gt;

&lt;p&gt;The modern browser game stack rarely relies on pure JavaScript for heavy calculations. Instead, developers are using C++, Rust, or Go, compiling the game logic into WebAssembly, and rendering it through the Canvas using WebGL or the newly matured WebGPU standard.&lt;/p&gt;

&lt;p&gt;By bypassing the JavaScript garbage collector and running close to native speed, browser games can now handle complex physics, real-time lighting, and 3D environments that were unimaginable a decade ago.&lt;/p&gt;

&lt;p&gt;Here is a look at how modern web engines optimize the rendering loop using requestAnimationFrame to ensure smooth 60fps performance without draining the user’s battery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gameCanvas&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// or 'webgl' / 'webgpu'&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastTime&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gameLoop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Calculate delta time for frame-rate independent physics&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deltaTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;lastTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;updatePhysics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deltaTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;renderGraphics&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Request the next frame only when the browser is ready&lt;/span&gt;
    &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gameLoop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Start the loop&lt;/span&gt;
&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gameLoop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The Battle Against “App Fatigue”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While technology paved the way, user behavior drove the comeback. In 2026, mobile users are suffering from what industry analysts call “App Fatigue.”&lt;/p&gt;

&lt;p&gt;Downloading a new casual game today is a high-friction experience:&lt;/p&gt;

&lt;p&gt;You must visit an App Store.&lt;br&gt;
You must download 200MB+ of assets over mobile data.&lt;br&gt;
You have to grant various privacy permissions.&lt;br&gt;
Your device storage is constantly full.&lt;br&gt;
Browser-based HTML5 games eliminate this entire pipeline. They offer instant play. You click a link on social media or in a chat app, and within three seconds, you are playing the game. No downloads, no installations, no storage warnings. For casual and mid-core gaming, convenience has triumphed over native performance.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Rise of “Instant-Social” Ecosystems&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Another major driver of the 2026 browser game comeback is the integration of web games into existing communication platforms.&lt;/p&gt;

&lt;p&gt;Ecosystems like Discord Activities, Telegram Mini-Apps, and WeChat Games have built-in web views that rely entirely on HTML5 Canvas. Instead of leaving the chat app to play a game with friends, users can launch a fully functional multiplayer game inside their group chats.&lt;/p&gt;

&lt;p&gt;For developers, this is a massive win. You don’t need to build a user acquisition funnel from scratch; you just deploy your game to the web, and the social platforms handle the virality. High-level frameworks like Phaser and PlayCanvas have adapted to this trend, offering lightweight, modular builds specifically optimized for these instant-play environments.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Future: A Golden Age for Indie Web Devs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The democratization of web standards has made browser game development more accessible than ever. With modern build tools, hot module reloading, and robust deployment platforms, indie developers can build, test, and launch a web game globally in a matter of days.&lt;/p&gt;

&lt;p&gt;We are no longer limited to basic puzzle games. The Canvas has evolved into a highly optimized viewport capable of delivering immersive, multiplayer, and cross-platform experiences directly to anyone with a web browser.&lt;/p&gt;

&lt;p&gt;If you haven’t touched game development yet, there has never been a better time to open up an editor, grab a Canvas context, and start building. The web is ready.&lt;/p&gt;

&lt;p&gt;What are your thoughts?&lt;/p&gt;

&lt;p&gt;Are you currently developing web-based games, or do you still prefer native platforms? What frameworks are you using in 2026? Let’s discuss in the comments below!&lt;/p&gt;

</description>
      <category>html</category>
      <category>gamedev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Optimize Your Routine: The Practical Value of a planetary hours Calculator</title>
      <dc:creator>no momo</dc:creator>
      <pubDate>Wed, 23 Apr 2025 08:38:27 +0000</pubDate>
      <link>https://dev.to/no_momo/optimize-your-routine-the-practical-value-of-a-planetary-hours-calculator-73m</link>
      <guid>https://dev.to/no_momo/optimize-your-routine-the-practical-value-of-a-planetary-hours-calculator-73m</guid>
      <description>&lt;p&gt;Have you ever felt that certain times of the day seem better suited for specific activities? Ancient wisdom suggests this isn't mere coincidence. Welcome to the fascinating world of "&lt;a href="https://planetaryhours.org" rel="noopener noreferrer"&gt;planetary hours&lt;/a&gt;" – a system that divides the day (sunrise to sunset) and the night (sunset to sunrise) each into 12 segments, ruled sequentially by the seven classical planets (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn).&lt;br&gt;
    Imagine handling communications and writing during a "Mercury Hour," engaging in social or artistic pursuits during a "Venus Hour," or tackling tasks requiring energy during a "Mars Hour." Sound complicated? Don't worry! If you're interested in this ancient timing wisdom, you should definitely try our &lt;a href="https://planetaryhours.org/" rel="noopener noreferrer"&gt;planetary hours Calculator&lt;/a&gt;. It automatically calculates the precise start and end times for each planetary hours based on your location and the current date. No manual calculations needed – with just a click, you can discover which planetary energy is currently dominant, helping you better synchronize with the natural rhythms of the cosmos. Use our planetary hours Calculator now and start aligning your day with cosmic energies! Read also: &lt;a href="https://planetaryhours.org" rel="noopener noreferrer"&gt;https://planetaryhours.org&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
