DEV Community

no momo
no momo

Posted on

I Built a Game Site MVP with games.json and iframe postMessage, but Scores Kept Disappearing

TL;DR

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

Fixing both took about 4 hours of debugging. Here's the full process.


Environment

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

Goal and Architecture

The goal was a zero-backend MVP with this route structure:

  • / homepage, grid of games
  • /game/:id game detail page
  • /play/:id game runtime page
  • /leaderboard/:id leaderboard

Game metadata lived in public/data/games.json:

[
  {
    "id": "typing-hero",
    "title": "Typing Hero",
    "description": "Type words quickly to score points.",
    "thumbnail": "/img/typing-hero-thumb.png",
    "entry": "/games/typing-hero/index.html",
    "category": "puzzle"
  },
  {
    "id": "mini-football",
    "title": "Mini Football",
    "description": "Score goals in 60 seconds.",
    "thumbnail": "/img/mini-football-thumb.png",
    "entry": "/games/mini-football/index.html",
    "category": "sports"
  }
]
Enter fullscreen mode Exit fullscreen mode

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.

The game runtime page loaded the game entry in an iframe, listened for postMessage, and wrote scores to localStorage.

The initial implementation looked like this:

// GameShell.vue — the broken version
window.addEventListener('message', (event) => {
  if (event.data.type === 'END') {
    const scores = JSON.parse(localStorage.getItem(`scores-${gameId}`) || '[]')
    scores.push({ score: event.data.score, time: Date.now() })
    localStorage.setItem(`scores-${gameId}`, JSON.stringify(scores))
  }
})
Enter fullscreen mode Exit fullscreen mode

The game side sent:

window.parent.postMessage({ type: 'END', score: 123 }, '*')
Enter fullscreen mode Exit fullscreen mode

Looks fine. But in practice, problems showed up one after another.


Problem 1: Scores Were Getting Cross-Contaminated

Symptoms

After playing mini-football, scores from typing-hero appeared in the leaderboard. The two games' scores were mixed together.

Even stranger, sometimes after finishing a round, the score wasn't saved at all.

First Misdiagnosis

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

After adding onUnmounted cleanup, the problem persisted.

How I Tracked It Down

I opened DevTools Console and added a log line inside the message listener:

window.addEventListener('message', (event) => {
  console.log('Received message:', {
    origin: event.origin,
    type: event.data?.type,
    score: event.data?.score,
    source: event.source === window ? 'self' : 'unknown'
  })
  // ...
})
Enter fullscreen mode Exit fullscreen mode

Two things stood out.

First, origin wasn't '*' — 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.

Second, event.source 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 postMessage events were still being captured by the new page's listener.

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

The Fix

The sender must use an explicit targetOrigin instead of '*'. The receiver must validate event.origin, event.data.type, and the data shape together.

// Fixed GameShell.vue
const iframeRef = ref(null)
let activeSource = null

onMounted(() => {
  // Capture the iframe's contentWindow after it loads
  iframeRef.value?.addEventListener('load', () => {
    activeSource = iframeRef.value.contentWindow
  })
})

window.addEventListener('message', (event) => {
  // Three checks
  if (event.source !== activeSource) return
  if (event.origin !== window.location.origin) return
  if (event.data?.type !== 'END') return
  if (typeof event.data.score !== 'number') return

  const scores = JSON.parse(
    localStorage.getItem(`scores-${gameId}`) || '[]'
  )
  scores.push({ score: event.data.score, time: Date.now() })
  localStorage.setItem(`scores-${gameId}`, JSON.stringify(scores))
})
Enter fullscreen mode Exit fullscreen mode

The game side changed to:

// No more '*'
window.parent.postMessage(
  { type: 'END', score: 123 },
  window.location.origin
)
Enter fullscreen mode Exit fullscreen mode

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


Problem 2: Scores Wouldn't Save in iOS Safari Private Mode

Symptoms

After fixing the origin validation, I tested in Safari private mode on an iPhone 12. The console showed:

Uncaught QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'scores-typing-hero' exceeded the quota.
Enter fullscreen mode Exit fullscreen mode

The score data was only a few hundred bytes, far below the 5MB quota.

Root Cause

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

Fallback

I implemented a storage layer with an in-memory fallback. When localStorage is unavailable, it falls back to memory storage so data isn't lost within the current session.

// storage.js
function createStorage() {
  // First, probe whether localStorage actually works
  try {
    const testKey = '__storage_test__'
    localStorage.setItem(testKey, '1')
    localStorage.removeItem(testKey)
    return localStorage
  } catch {
    // Fall back to in-memory storage
    const memory = new Map()
    return {
      getItem: (key) => memory.get(key) ?? null,
      setItem: (key, value) => memory.set(key, value),
      removeItem: (key) => memory.delete(key)
    }
  }
}

const storage = createStorage()
Enter fullscreen mode Exit fullscreen mode

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.


Problem 3: Scroll Bleed-Through on Mobile

Symptoms

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.

Cause

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.

Fix

The game runtime page container had a fixed height, and the parent page disabled scrolling while on the game page:

// play route's onMounted
document.body.style.overflow = 'hidden'
document.body.style.position = 'fixed'
document.body.style.width = '100%'

// onUnmounted restore
document.body.style.overflow = ''
document.body.style.position = ''
document.body.style.width = ''
Enter fullscreen mode Exit fullscreen mode

If the game iframe itself needs to scroll, add -webkit-overflow-scrolling: touch to the iframe container and listen to the iframe's load event to adjust the container height dynamically.


Verification Data

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):

Test Before After
Score write success (normal mode) 7/10 10/10
Score write success (private mode) 0/10 (threw error) 10/10 (memory fallback)
Score cross-contamination 3/10 0/10
Background scroll bleed-through Yes No

Test conditions: Wi-Fi, Chrome DevTools without throttling, iOS tested directly in Safari.


Trade-offs and Limitations

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

Unresolved

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

Repo

A minimal reproduction demo(eg:playfiddlebops.net) 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.

Top comments (0)