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/:idgame detail page -
/play/:idgame runtime page -
/leaderboard/:idleaderboard
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"
}
]
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))
}
})
The game side sent:
window.parent.postMessage({ type: 'END', score: 123 }, '*')
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'
})
// ...
})
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))
})
The game side changed to:
// No more '*'
window.parent.postMessage(
{ type: 'END', score: 123 },
window.location.origin
)
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.
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()
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 = ''
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:
activeSourceneeds to be re-assigned after the iframeloadevent. 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)