SlowDen

Why your game breaks inside an iframe

By Liza · SlowDen · Published 2 August 2026

Your game works. It works on your machine, it works on your own domain, it works on itch.io. Then a portal lists it, and the reports start: the fullscreen button does nothing, the save file is gone, there is no sound, and the arrow keys scroll the page instead of moving the player. Nothing in your code changed. What changed is that your game is now a passenger in somebody else's page.

The short version. A cross-origin <iframe> is not a smaller browser window. It is a browser window with a set of features switched off by default, and only the page doing the embedding can switch them back on. This article measures exactly which ones, on a real live embed, and gives you a script that tests your own build in about ten minutes.

What we measured, and on what

Everything below was measured on 2 August 2026, in Chrome 150 on Windows, against a real production page rather than a demo: slowden.com/games/hedgies/. Hedgies is a partner game listed on SlowDen, not a SlowDen-made game — we are using the page because it is a normal, live, third-party game embed of exactly the kind your game would land in.

Here is what that page actually is, read out of the live DOM:

What we readValue on the live page
Origin of the host pagehttps://slowden.com
Origin of the game framehttps://playgama.com
Cross-origin?Yes
allow attribute on the frameautoplay; fullscreen; gamepad; clipboard-write
allowfullscreenpresent
sandbox attributenone
loadinglazy
Can the host read the game's DOM?NoSecurityError
document.activeElement on loadBODY, not the frame

Two things in that table are worth sitting with. The host cannot see inside your game at all — which is good for you, and also means the host cannot fix anything on your behalf. And the frame does not have keyboard focus when the page finishes loading.

1. Fullscreen is off until somebody grants it

This is the single most common "it worked yesterday" report, and it is not your bug.

Fullscreen, autoplay, gamepad and friends are Permissions Policy features. Each one has a default allowlist. MDN documents the default for fullscreen plainly: "The default allowlist for fullscreen is self. The top-level browsing context and same-origin iframes are allowed access to the fullscreen feature by default." A cross-origin frame is not in that set. The only way in is the host writing allow="fullscreen" on the <iframe>.

We tested it rather than assuming it. Two frames, identical content, in a cross-origin position; the only difference is the allow attribute:

Reading, taken inside the frameNo allow attributeWith allow="autoplay; fullscreen; gamepad; clipboard-write"
document.fullscreenEnabledfalsetrue
Relevant entries in allowedFeatures()gamepadautoplay, gamepad, fullscreen, clipboard-write

Notice that gamepad is present in both columns. That is not an accident: MDN gives the default allowlist for gamepad as *, meaning every origin gets it unless a policy takes it away. So gamepad support usually survives an embed; fullscreen and autoplay usually do not. Our measurement and the documented defaults agree exactly, which is a good sign that the test is measuring the real thing.

What to do about it. Do not call requestFullscreen() hopefully and let it reject into a swallowed promise. Read document.fullscreenEnabled at start-up and hide the button when it is false. A missing button reads as a design decision. A button that does nothing reads as a broken game.

2. Storage can be gone entirely — and even when it works, it is not the storage you think

There are two separate problems here and they get conflated constantly. They have different causes and different fixes.

Problem one: the sandbox attribute can remove storage completely

If the host wraps you in sandbox without the allow-same-origin token, your document gets an opaque origin — MDN's wording is that the resource is "treated as being from a special origin that always fails the same-origin policy". Origin-keyed storage has no origin to key against, so it does not merely return empty; it throws.

Measured, same content, three different frames:

Frame configurationwindow.originlocalStorage.setItem()indexedDB.open()
sandbox="allow-scripts""null"SecurityErrorSecurityError
sandbox="allow-scripts allow-same-origin"the real originOKOK
no sandbox attributethe real originOKOK

Be precise about what this does and does not say. Ordinary cross-origin embedding does not produce this error — the live page we measured carries no sandbox attribute at all, and storage is available there. This failure is caused specifically by a sandbox that withholds allow-same-origin. But you do not control which hosts do that, so you have to survive it.

What to do about it. Never let a bare localStorage call sit in your load path. Probe once at boot, inside try/catch, and fall back to an in-memory object that satisfies the same interface. Your game then runs to completion with saving quietly disabled, instead of dying on line one with a red error nobody will report to you.

// Run this once, before anything else touches storage.
let store;
try {
  localStorage.setItem('__probe', '1');
  localStorage.removeItem('__probe');
  store = localStorage;
} catch (e) {
  const mem = new Map();
  store = {
    getItem: k => (mem.has(k) ? mem.get(k) : null),
    setItem: (k, v) => mem.set(k, String(v)),
    removeItem: k => mem.delete(k)
  };
  // Optional: hide your "save" UI here rather than letting it lie.
}

Problem two: storage is partitioned, so saves do not travel

Suppose storage works fine. Your player has a save on your own site. They find the same game on a portal, load it, and the save is not there. Nothing is broken.

Chrome partitions storage in third-party contexts. Google's own documentation states that the feature "has been enabled for all users on Chrome 115 and later", and that the partition key combines the origin of the embedded context with the top-level site doing the embedding. LocalStorage, sessionStorage, IndexedDB and Cache Storage are all covered.

The practical consequence for a game developer is blunt:

What to do about it. Design saves as a convenience, not a contract. Never write UI copy that promises a player their progress is safe or will be there next time, because on somebody else's page you cannot honour it.

3. The audio does not start, and it is not the file

People lose hours to this one, usually chasing a decoding bug that does not exist.

Browsers will not let a page make noise before the person has interacted with it. An AudioContext created while the page is loading comes up suspended and stays silent until something resumes it inside a real input event. We measured state: "suspended" on a freshly loaded page with no interaction — both at the top level and inside the frame, which is the point: this is not an iframe problem, it is a page-load problem that the iframe then makes harder to notice.

Then the frame adds a second lock on top. Autoplay is a Permissions Policy feature and MDN gives its default allowlist as self — the same rule as fullscreen. So a cross-origin frame needs allow="autoplay" from the host before it may make sound even after a gesture.

What to do about it. One fix covers both: never start audio at boot. Create the context lazily, or create it early and call resume() inside the handler for the first real click, tap or key press. If you already have a start button, you already have the gesture — just make sure the resume happens in that handler and not in a promise chain three ticks later, because the gesture does not last forever.

4. The keyboard goes to the host page, not to you

On the live page we measured, document.activeElement was BODY after load. The frame only became the active element once focus was explicitly given to it — at which point activeElement reported IFRAME.

That gap is where a whole class of "the controls don't work" reports comes from. Until the frame has focus, key events belong to the host document. Your window.addEventListener('keydown', ...) is bound correctly and receives nothing, while the player's arrow keys and spacebar scroll the portal's page around them.

What to do about it. Put a click-to-start overlay inside your game and do not begin play until it is dismissed. It is the single highest-value 20 lines in a portal build, because that one click does three jobs at once:

While you are there: call preventDefault() on the keys you use. Space and the arrow keys scroll by default, and a game that scrolls the page under itself feels broken even when it is working.

5. The frame is not the size you designed for, and part of it is off-screen

The last one is not a permission at all, it is geometry, and it is the one most likely to be silently costing you players.

On the live page, in a browser window 730 px tall, the game frame was 1054 × 658 px and began at y = 251, because a header, a breadcrumb trail and a title sit above it. That leaves 479 px of the game visible — 73%. The bottom quarter of your game is below the fold before the player has scrolled anything.

Now imagine what lives in that bottom quarter in most jam builds: the start button, the mute toggle, the controls legend, the "click here to begin" text.

What to do about it.

Test your own build in ten minutes

You do not need a portal to find these problems. Open any page in your browser, open the console, and paste this. It embeds a probe in a cross-origin position and reports what that position allows. It is the same script that produced the numbers above.

const child = `<script>
  var r = {};
  try { r.fullscreen = document.fullscreenEnabled; } catch(e) { r.fullscreen = 'THREW'; }
  try { r.origin = String(window.origin); } catch(e) { r.origin = e.name; }
  try { localStorage.setItem('t','1'); r.storage = 'OK'; localStorage.removeItem('t'); }
  catch(e) { r.storage = e.name; }
  try { var AC = window.AudioContext || window.webkitAudioContext;
        var ac = new AC(); r.audio = ac.state; ac.close(); } catch(e) { r.audio = e.name; }
  try { r.features = document.featurePolicy.allowedFeatures()
        .filter(function(f){ return ['fullscreen','autoplay','gamepad','clipboard-write']
        .indexOf(f) > -1; }); } catch(e) { r.features = 'unavailable'; }
  parent.postMessage(JSON.stringify(r), '*');
<\/script>`;

function probe(allowAttr) {
  return new Promise(res => {
    const f = document.createElement('iframe');
    f.setAttribute('sandbox', 'allow-scripts');   // forces a cross-origin position
    if (allowAttr) f.setAttribute('allow', allowAttr);
    f.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px';
    const h = e => { try { const d = JSON.parse(e.data);
      window.removeEventListener('message', h); f.remove(); res(d); } catch(_) {} };
    window.addEventListener('message', h);
    f.srcdoc = child;
    document.body.appendChild(f);
  });
}

console.log('no allow=  ', await probe(null));
console.log('with allow=', await probe('autoplay; fullscreen; gamepad; clipboard-write'));

Then run the same four checks inside your actual game at start-up and log them. If fullscreenEnabled is false, hide the button. If storage throws, switch to the in-memory fallback. If audio is suspended, wait for the click. Four checks, four graceful degradations, and your game stops depending on a hospitable host.

One caveat, stated honestly. The probe uses sandbox="allow-scripts" to put itself in a cross-origin position, because a script cannot read anything out of a genuinely cross-origin frame. That is a faithful proxy for Permissions Policy behaviour — our results matched the documented defaults exactly — but it is stricter than a plain cross-origin embed where storage is concerned. Read the storage line as "what happens if a host sandboxes me", not as "what every host does".

The one-line summary for each problem

SymptomActual causeYour fix
Fullscreen button does nothingPermissions Policy default self; host did not grant itCheck document.fullscreenEnabled, hide the button
Storage throws SecurityErrorSandboxed without allow-same-origin → opaque originProbe in try/catch, fall back to memory
Save file "disappeared"Storage partitioning (Chrome 115+); different site, different bucketTreat saves as per-site; never promise portability
No soundAudioContext starts suspended; autoplay also defaults to selfResume inside the first real input handler
Keyboard ignored, page scrollsFrame has no focus; activeElement is the host's BODYClick-to-start overlay, then preventDefault()
UI below the foldFrame sits under the host's header; 73% visible in our measurementCritical UI in the upper-middle; scale to container

Where these numbers came from

So you can check them rather than take our word for it. All measured on 2 August 2026 in Chrome 150 on Windows:

Why this is worth an afternoon

Every one of these failures shares a shape. The game is fine. The host is fine. Nobody did anything wrong. But the player clicks, gets silence and a dead button, and leaves — and you never hear about it, because people who bounce do not file bug reports.

The good news is that all six fixes are small, they are all defensive, and none of them costs you anything when the host is generous. A game that checks four flags at start-up and degrades quietly will run everywhere you put it. That is worth more than another feature.

If you have a browser game that has never been embedded anywhere, this is a reason to try it rather than a reason not to. Submitting it to SlowDen is free and takes a link. And if you would rather build something new for the occasion, the current Slow Cook round closes on 31 August 2026 — pick a prompt, build a small complete game, and submit it for a chance to be featured. Selected entries may receive a shoutout on SlowDen's social accounts; nothing is guaranteed, and every entry is reviewed by a person.

Submit your game to SlowDen See the Slow Cook round Read: why your game loads to a black screen

Related reading