<?xml version="1.0" encoding="utf-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>xenay11621のブログ</title>
<link>https://ameblo.jp/xenay11621/</link>
<atom:link href="https://rssblog.ameba.jp/xenay11621/rss20.xml" rel="self" type="application/rss+xml" />
<atom:link rel="hub" href="http://pubsubhubbub.appspot.com" />
<description>ブログの説明を入力します。</description>
<language>ja</language>
<item>
<title>I Built a Game Site MVP</title>
<description>
<![CDATA[ <h1 id="i-built-a-game-site-mvp-with-games-json-and-iframe-postmessage-but-scores-kept-disappearing">I Built a Game Site MVP with games.json and iframe postMessage, but Scores Kept Disappearing</h1><p>The score loss came from two separate issues: <strong>postMessage had no <code>targetOrigin</code> or receiver-side validation</strong>, so messages from multiple iframes contaminated each other. The second issue was iOS Safari private mode throwing <code>QuotaExceededError</code> on <code>localStorage</code>, which needed its own fallback.</p><p>Fixing both took about 4 hours of debugging. Here's the full process.</p><hr><h2 id="environment">Environment</h2><ul><li>Vue 3.4 + Vite 5.2 + vue-router 4</li><li>Test devices: iPhone 12 (iOS 17.4 Safari), Moto G Power (Android 13 Chrome 125)</li><li>Deployment: Vercel static hosting</li><li>Games: local HTML5 games under <code>public/games/</code>, one directory per game</li></ul><hr><h2 id="goal-and-architecture">Goal and Architecture</h2><p>The goal was a zero-backend MVP with this route structure:</p><ul><li><code>/</code> homepage, grid of games</li><li><code>/game/:id</code> game detail page</li><li><code>/play/:id</code> game runtime page</li><li><code>/leaderboard/:id</code> leaderboard</li></ul><p>Game metadata lived in <code>public/data/games.json</code>:</p><pre><code class="lang-json">[  {    <span class="hljs-attr">"id"</span>: <span class="hljs-string">"typing-hero"</span>,    <span class="hljs-attr">"title"</span>: <span class="hljs-string">"Typing Hero"</span>,    <span class="hljs-attr">"description"</span>: <span class="hljs-string">"Type words quickly to score points."</span>,    <span class="hljs-attr">"thumbnail"</span>: <span class="hljs-string">"/img/typing-hero-thumb.png"</span>,    <span class="hljs-attr">"entry"</span>: <span class="hljs-string">"/games/typing-hero/index.html"</span>,    <span class="hljs-attr">"category"</span>: <span class="hljs-string">"puzzle"</span>  },  {    <span class="hljs-attr">"id"</span>: <span class="hljs-string">"mini-football"</span>,    <span class="hljs-attr">"title"</span>: <span class="hljs-string">"Mini Football"</span>,    <span class="hljs-attr">"description"</span>: <span class="hljs-string">"Score goals in 60 seconds."</span>,    <span class="hljs-attr">"thumbnail"</span>: <span class="hljs-string">"/img/mini-football-thumb.png"</span>,    <span class="hljs-attr">"entry"</span>: <span class="hljs-string">"/games/mini-football/index.html"</span>,    <span class="hljs-attr">"category"</span>: <span class="hljs-string">"sports"</span>  }]</code></pre><p>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.</p><p>The game runtime page loaded the game entry in an iframe, listened for <code>postMessage</code>, and wrote scores to <code>localStorage</code>.</p><p><strong>The initial implementation looked like this:</strong></p><pre><code class="lang-javascript"><span class="hljs-comment">// GameShell.vue — the broken version</span><span class="hljs-built_in">window</span>.addEventListener(<span class="hljs-string">'message'</span>, (event) =&gt; {  <span class="hljs-keyword">if</span> (event.data.type === <span class="hljs-string">'END'</span>) {    <span class="hljs-keyword">const</span> scores = <span class="hljs-built_in">JSON</span>.parse(localStorage.getItem(<span class="hljs-string">`scores-<span class="hljs-subst">${gameId}</span>`</span>) || <span class="hljs-string">'[]'</span>)    scores.push({ <span class="hljs-attr">score</span>: event.data.score, <span class="hljs-attr">time</span>: <span class="hljs-built_in">Date</span>.now() })    localStorage.setItem(<span class="hljs-string">`scores-<span class="hljs-subst">${gameId}</span>`</span>, <span class="hljs-built_in">JSON</span>.stringify(scores))  }})</code></pre><p>The game side sent:</p><pre><code class="lang-javascript">window<span class="hljs-selector-class">.parent</span><span class="hljs-selector-class">.postMessage</span>({ type: <span class="hljs-string">'END'</span>, score: <span class="hljs-number">123</span> }, <span class="hljs-string">'*'</span>)</code></pre><p>Looks fine. But in practice, problems showed up one after another.</p><hr><h2 id="problem-1-scores-were-getting-cross-contaminated">Problem 1: Scores Were Getting Cross-Contaminated</h2><h3 id="symptoms">Symptoms</h3><p>After playing <code>mini-football</code>, scores from <code>typing-hero</code> appeared in the leaderboard. The two games' scores were mixed together.</p><p>Even stranger, sometimes after finishing a round, the score wasn't saved at all.</p><h3 id="first-misdiagnosis">First Misdiagnosis</h3><p>I thought it was a listener cleanup issue caused by route changes. When switching games on <code>/play/:id</code>, the old listener wasn't removed, and a new one was added on top.</p><p>After adding <code>onUnmounted</code> cleanup, the problem persisted.</p><h3 id="how-i-tracked-it-down">How I Tracked It Down</h3><p>I opened DevTools Console and added a log line inside the message listener:</p><pre><code class="lang-javascript"><span class="hljs-keyword">window</span>.addEventListener(<span class="hljs-string">'message'</span>, (<span class="hljs-keyword">event</span>) =&gt; {  console.<span class="hljs-keyword">log</span>(<span class="hljs-string">'Received message:'</span>, {    origin: <span class="hljs-keyword">event</span>.origin,    type: <span class="hljs-keyword">event</span>.data?.type,    score: <span class="hljs-keyword">event</span>.data?.score,    <span class="hljs-keyword">source</span>: <span class="hljs-keyword">event</span>.<span class="hljs-keyword">source</span> === <span class="hljs-keyword">window</span> ? <span class="hljs-string">'self'</span> : <span class="hljs-string">'unknown'</span>  })  <span class="hljs-comment">// ...</span>})</code></pre><p><strong>Two things stood out.</strong></p><p>First, <code>origin</code> wasn't <code>'*'</code> — 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.</p><p>Second, <code>event.source</code> 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 <code>postMessage</code> events were still being captured by the new page's listener.</p><p><strong>Root cause</strong>: <code>targetOrigin</code> was <code>'*'</code>, so messages were broadcast to all possible receivers; the receiving end also didn't validate whether <code>event.source</code> was actually the currently active iframe window.</p><h3 id="the-fix">The Fix</h3><p>The sender must use an explicit <code>targetOrigin</code> instead of <code>'*'</code>. The receiver must validate <code>event.origin</code>, <code>event.data.type</code>, and the data shape together.</p><pre><code class="lang-javascript"><span class="hljs-comment">// Fixed GameShell.vue</span><span class="hljs-keyword">const</span> iframeRef = ref(<span class="hljs-literal">null</span>)<span class="hljs-keyword">let</span> activeSource = <span class="hljs-literal">null</span>onMounted(<span class="hljs-function"><span class="hljs-params">()</span> =&gt;</span> {  <span class="hljs-comment">// Capture the iframe's contentWindow after it loads</span>  iframeRef.value?.addEventListener(<span class="hljs-string">'load'</span>, () =&gt; {    activeSource = iframeRef.value.contentWindow  })})<span class="hljs-built_in">window</span>.addEventListener(<span class="hljs-string">'message'</span>, (event) =&gt; {  <span class="hljs-comment">// Three checks</span>  <span class="hljs-keyword">if</span> (event.source !== activeSource) <span class="hljs-keyword">return</span>  <span class="hljs-keyword">if</span> (event.origin !== <span class="hljs-built_in">window</span>.location.origin) <span class="hljs-keyword">return</span>  <span class="hljs-keyword">if</span> (event.data?.type !== <span class="hljs-string">'END'</span>) <span class="hljs-keyword">return</span>  <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> event.data.score !== <span class="hljs-string">'number'</span>) <span class="hljs-keyword">return</span>  <span class="hljs-keyword">const</span> scores = <span class="hljs-built_in">JSON</span>.parse(    localStorage.getItem(<span class="hljs-string">`scores-<span class="hljs-subst">${gameId}</span>`</span>) || <span class="hljs-string">'[]'</span>  )  scores.push({ <span class="hljs-attr">score</span>: event.data.score, <span class="hljs-attr">time</span>: <span class="hljs-built_in">Date</span>.now() })  localStorage.setItem(<span class="hljs-string">`scores-<span class="hljs-subst">${gameId}</span>`</span>, <span class="hljs-built_in">JSON</span>.stringify(scores))})</code></pre><p>The game side changed to:</p><pre><code class="lang-javascript"><span class="hljs-comment">// No more '*'</span>window<span class="hljs-selector-class">.parent</span><span class="hljs-selector-class">.postMessage</span>(  { type: <span class="hljs-string">'END'</span>, score: <span class="hljs-number">123</span> },  window<span class="hljs-selector-class">.location</span><span class="hljs-selector-class">.origin</span>)</code></pre><p><strong>One trade-off</strong>: the <code>event.source !== activeSource</code> check can break when the iframe redirects, because the <code>contentWindow</code> reference changes. If the game navigates internally, you need to re-capture it on the <code>load</code> event. I haven't hit that scenario yet, so I left it as is.</p><hr><h2 id="problem-2-scores-wouldn-t-save-in-ios-safari-private-mode">Problem 2: Scores Wouldn't Save in iOS Safari Private Mode</h2><h3 id="symptoms">Symptoms</h3><p>After fixing the origin validation, I tested in Safari private mode on an iPhone 12. The console showed:</p><pre><code>Uncaught QuotaExceededError: Failed to<span class="hljs-built_in"> execute </span>'setItem' on 'Storage': Setting the value of 'scores-typing-hero' exceeded the quota.</code></pre><p>The score data was only a few hundred bytes, far below the 5MB quota.</p><h3 id="root-cause">Root Cause</h3><p>In iOS Safari private browsing, storage APIs are severely restricted or completely disabled. Even tiny amounts of data throw <code>QuotaExceededError</code>. This is especially bad in older Safari versions.</p><h3 id="fallback">Fallback</h3><p>I implemented a storage layer with an in-memory fallback. When <code>localStorage</code> is unavailable, it falls back to memory storage so data isn't lost within the current session.</p><pre><code class="lang-javascript"><span class="hljs-comment">// storage.js</span><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createStorage</span>() </span>{  <span class="hljs-comment">// First, probe whether localStorage actually works</span>  <span class="hljs-keyword">try</span> {    <span class="hljs-keyword">const</span> testKey = <span class="hljs-string">'__storage_test__'</span>    localStorage.setItem(testKey, <span class="hljs-string">'1'</span>)    localStorage.removeItem(testKey)    <span class="hljs-keyword">return</span> localStorage  } <span class="hljs-keyword">catch</span> {    <span class="hljs-comment">// Fall back to in-memory storage</span>    <span class="hljs-keyword">const</span> memory = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>()    <span class="hljs-keyword">return</span> {      <span class="hljs-attr">getItem</span>: <span class="hljs-function">(<span class="hljs-params">key</span>) =&gt;</span> memory.get(key) ?? <span class="hljs-literal">null</span>,      <span class="hljs-attr">setItem</span>: <span class="hljs-function">(<span class="hljs-params">key, value</span>) =&gt;</span> memory.set(key, value),      <span class="hljs-attr">removeItem</span>: <span class="hljs-function">(<span class="hljs-params">key</span>) =&gt;</span> memory.delete(key)    }  }}<span class="hljs-keyword">const</span> storage = createStorage()</code></pre><p>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.</p><hr><h2 id="problem-3-scroll-bleed-through-on-mobile">Problem 3: Scroll Bleed-Through on Mobile</h2><h3 id="symptoms">Symptoms</h3><p>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.</p><h3 id="cause">Cause</h3><p>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.</p><h3 id="fix">Fix</h3><p>The game runtime page container had a fixed height, and the parent page disabled scrolling while on the game page:</p><pre><code class="lang-javascript"><span class="hljs-comment">// play route's onMounted</span>document<span class="hljs-selector-class">.body</span><span class="hljs-selector-class">.style</span><span class="hljs-selector-class">.overflow</span> = <span class="hljs-string">'hidden'</span>document<span class="hljs-selector-class">.body</span><span class="hljs-selector-class">.style</span><span class="hljs-selector-class">.position</span> = <span class="hljs-string">'fixed'</span>document<span class="hljs-selector-class">.body</span><span class="hljs-selector-class">.style</span><span class="hljs-selector-class">.width</span> = <span class="hljs-string">'100%'</span><span class="hljs-comment">// onUnmounted restore</span>document<span class="hljs-selector-class">.body</span><span class="hljs-selector-class">.style</span><span class="hljs-selector-class">.overflow</span> = <span class="hljs-string">''</span>document<span class="hljs-selector-class">.body</span><span class="hljs-selector-class">.style</span><span class="hljs-selector-class">.position</span> = <span class="hljs-string">''</span>document<span class="hljs-selector-class">.body</span><span class="hljs-selector-class">.style</span><span class="hljs-selector-class">.width</span> = <span class="hljs-string">''</span></code></pre><p>If the game iframe itself needs to scroll, add <code>-webkit-overflow-scrolling: touch</code> to the iframe container and listen to the iframe's <code>load</code> event to adjust the container height dynamically.</p><hr><h2 id="verification-data">Verification Data</h2><p>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):</p><table><thead><tr><th>Test</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Score write success (normal mode)</td><td>7/10</td><td>10/10</td></tr><tr><td>Score write success (private mode)</td><td>0/10 (threw error)</td><td>10/10 (memory fallback)</td></tr><tr><td>Score cross-contamination</td><td>3/10</td><td>0/10</td></tr><tr><td>Background scroll bleed-through</td><td>Yes</td><td>No</td></tr></tbody></table><p>Test conditions: Wi-Fi, Chrome DevTools without throttling, iOS tested directly in Safari.</p><hr><h2 id="trade-offs-and-limitations">Trade-offs and Limitations</h2><ul><li><strong>The in-memory fallback isn't persistent</strong>: private-mode users lose scores after a refresh. That's an acceptable trade-off — at least it doesn't crash.</li><li><strong>Origin validation adds coupling</strong>: <code>activeSource</code> needs to be re-assigned after the iframe <code>load</code> event. If the game navigates cross-origin internally, that needs extra handling.</li><li><strong>No backend</strong>: leaderboards are local-only and don't sync across devices. That's an expected MVP limitation.</li></ul><hr><h2 id="unresolved">Unresolved</h2><ul><li>Cross-device leaderboard sync needs a backend. That's the next article.</li><li>The game iframe has no load timeout or retry mechanism yet. On a bad network, there's no fallback for a white screen.</li></ul><hr><h2 id="repo">Repo</h2><p>A minimal reproduction demo(<a href="https://www.playfiddlebops.com/" rel="noopener noreferrer" target="_blank">playfiddlebops</a>) 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.</p>
]]>
</description>
<link>https://ameblo.jp/xenay11621/entry-12979564421.html</link>
<pubDate>Wed, 23 Sep 2026 18:46:45 +0900</pubDate>
</item>
</channel>
</rss>
