Game Embedding
This guide covers how to embed Slotty Labs games in your platform via iframes and communicate with them using the postMessage protocol.
iframe HTML
<div style="width: 100%; max-width: 1200px; aspect-ratio: 16/9;">
<iframe
id="slotty-game"
src="https://games.slottylabs.com/launch/slotty-slots?token=eyJ...&lang=en&lobby=https://yourcasino.com/lobby&demo=false&channel=desktop"
style="width: 100%; height: 100%; border: none;"
allow="autoplay; fullscreen"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
loading="lazy"
></iframe>
</div>Mobile: fullscreen is required
allow="fullscreen" is not optional
On a phone, our games refuse to run outside fullscreen. A player on a touch device sees a "Play fullscreen" gate instead of the game, and it does not go away until the game actually owns the screen.
If your <iframe> is missing allow="fullscreen", the browser silently refuses our fullscreen request and your players cannot play on mobile at all — they get a "Fullscreen unavailable" message naming this attribute.
(The script in §2 can rescue a missing attribute on Android/desktop by requesting fullscreen from your side — but set the attribute anyway; it is the path that needs nothing else to go right.)
Why the hard rule: squeezed between a casino header and footer, a 16:9 game on a phone is a postage stamp. Fullscreen + landscape is the difference between a web page and something that feels like an app.
⚠️ Size the iframe, always
Two ways to get this wrong, and each naive fix is the other one:
An iframe with no CSS height is 150px tall
That's the HTML default, and it's the most common way to "break" the game: at 150px of viewport the cabinet collapses and you get a black box with a few stray buttons in it. height: 100% does not help unless the parent element has a real height of its own.
…but aspect-ratio: 16/9 alone isn't right either
Then the height follows the width, so on a wide monitor the frame is taller than the space under your header: the page grows a scrollbar and the game's bottom bar (bet + chips) falls below the fold.
What you want is the largest 16:9 frame that still fits on the player's screen. The snippet below does that in six lines, on any screen, with no breakpoints. (Or let embed.js do it for you.)
1. Android, iPad, desktop — two attributes and a fit
<iframe id="slotty-game" src="https://games.slottylabs.com/launch/fox?token=eyJ..."
allow="fullscreen; autoplay" allowfullscreen
style="display:block; width:100%; border:0"></iframe>The game requests fullscreen itself, locks the screen to landscape, and shows on-screen touch controls (joystick + GRAB for Fox'n Flock, a keypad for Bit Key Rush).
allow must be set before the iframe navigates
It is evaluated at navigation. Adding it afterwards does nothing, and re-loading the iframe to apply it drops the player's session — the launch token is single-use with a 30-second TTL.
2. The script: auto-fit + fullscreen for everyone else
When the game can't take fullscreen by itself, it asks your page to via postMessage, and this script answers in two tiers:
- Native fullscreen, requested from your side.
allow="fullscreen"only gates calls made inside the frame — your page may fullscreen the iframe element without any attribute, and the player's tap inside the game counts as your user gesture too (activation propagates to parent frames). This rescues Android/desktop embeds where the attribute was forgotten, with the identical end result: browser chrome gone, screen locked to landscape. - iPhone. WebKit on iPhone has no element fullscreen at all —
<video>only, which is how YouTube does it and why an HTML game can't. The closest the platform allows: pin the iframe over your page, then invite one upward swipe — in landscape, Safari hides its address bar and status bar completely when the page scrolls, which is every pixel YouTube's fullscreen gets. That scroll must come from the player's finger (programmatic scrolling stopped hiding the bars back in iOS 7), so the script shows a small "Swipe up for full screen" coach and removes it the instant the bars are gone.
Copy-paste this. It's inline, so a strict script-src CSP won't block it:
<script>
(function () {
var SLOTTY_ORIGIN = 'https://games.slottylabs.com';
var f = document.getElementById('slotty-game');
var pinned = null; // what to restore when we un-pin (iPhone path)
var nativeFs = false; // we put the iframe into real fullscreen ourselves
// ── 1. Fit the frame to the player's screen ───────────────────────────────
// The largest 16:9 box that still fits below whatever sits above it. Adapts
// to any monitor, any header height, no media queries.
function fit() {
if (pinned || nativeFs) return; // fullscreen — leave it be
var top = f.getBoundingClientRect().top + (window.pageYOffset || 0);
var avail = window.innerHeight - top; // room left on screen
var byWidth = f.clientWidth * 9 / 16; // what 16:9 would want
f.style.height = Math.round(Math.max(320, Math.min(byWidth, avail))) + 'px';
}
fit();
window.addEventListener('resize', fit);
// ── 2. Fullscreen ─────────────────────────────────────────────────────────
function fsEl() {
return document.fullscreenElement || document.webkitFullscreenElement || null;
}
// Is the browser's own chrome still eating the screen? iOS reports screen.*
// in PORTRAIT terms whatever way the phone is held — compare short side to
// short side, never width to width.
function chromeUp() {
var s = Math.min(screen.width || 0, screen.height || 0);
return window.innerWidth > window.innerHeight && s > 0 && window.innerHeight < s - 8;
}
// Tell the game whether it REALLY covers the page. It cannot measure this
// across origins, and it will refuse to start unless you confirm it.
function reply(active) {
var covered = false, r;
if (active) {
if (fsEl() === f) covered = true;
else {
r = f.getBoundingClientRect();
covered = r.width >= window.innerWidth - 2 && r.height >= window.innerHeight - 2 &&
r.top <= 2 && r.left <= 2;
}
}
f.contentWindow.postMessage({ source: 'slotty-host', type: 'host:fullscreen',
payload: { active: active, covered: covered } }, SLOTTY_ORIGIN);
}
// iPhone: Safari hides its bars (completely, in landscape) only for a real
// finger-scroll. Ask for one swipe, then get out of the way.
var coach = null, coachOff = null, coachT = null;
function killCoach() {
if (coachT) clearTimeout(coachT);
if (coachOff) window.removeEventListener('resize', coachOff);
if (coach && coach.parentNode) coach.parentNode.removeChild(coach);
coach = coachOff = coachT = null;
}
function showCoach() {
killCoach();
if (!chromeUp()) return; // nothing to win — don't nag
coach = document.createElement('div');
coach.style.cssText = 'position:fixed;left:0;right:0;bottom:0;height:34%;' +
'z-index:2147483002;display:flex;align-items:flex-end;justify-content:center;' +
'touch-action:pan-y;cursor:pointer;color:#fff;' +
'background:linear-gradient(to bottom,rgba(0,0,0,0),rgba(0,0,0,.5));' +
'font:600 15px/1.3 -apple-system,system-ui,sans-serif;' +
'padding-bottom:max(14px,env(safe-area-inset-bottom))';
coach.innerHTML = '<div style="text-align:center;pointer-events:none">' +
'<div style="font-size:26px">⬆︎</div>' +
'<div style="background:rgba(0,0,0,.65);border:1px solid rgba(255,255,255,.25);' +
'border-radius:999px;padding:10px 18px;margin-top:6px">Swipe up for full screen</div></div>';
document.body.appendChild(coach);
coachOff = function () { if (!chromeUp()) { killCoach(); reply(true); } };
window.addEventListener('resize', coachOff);
coach.onclick = killCoach;
coachT = setTimeout(killCoach, 9000);
}
function pin() {
if (pinned) return;
var meta = document.querySelector('meta[name="viewport"]');
pinned = { css: f.style.cssText, overflow: document.body.style.overflow,
scrollY: window.pageYOffset || 0, spacer: null,
meta: meta ? meta.getAttribute('content') : null };
f.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;height:100dvh;' +
'margin:0;border:0;z-index:2147483000;background:#000';
if (f.requestFullscreen || f.webkitRequestFullscreen) {
document.body.style.overflow = 'hidden'; // this chrome doesn't scroll away
} else {
// iPhone: keep the page SCROLLABLE and guarantee scroll room — the swipe
// that hides Safari's bars needs somewhere to go. Everything visible is
// position:fixed, so the scroll moves nothing but Safari's own chrome,
// and the iframe (sized in dvh) grows into the freed space by itself.
pinned.spacer = document.createElement('div');
pinned.spacer.style.cssText =
'position:absolute;top:0;left:0;width:1px;height:300vh;visibility:hidden;pointer-events:none';
document.body.appendChild(pinned.spacer);
// Edge-to-edge under the notch instead of pillarboxes in your page's
// background colour. The game letterboxes itself, exactly like a video.
if (meta && pinned.meta && pinned.meta.indexOf('viewport-fit') < 0)
meta.setAttribute('content', pinned.meta + ', viewport-fit=cover');
}
}
function unpin() {
if (!pinned) return;
killCoach();
f.style.cssText = pinned.css;
document.body.style.overflow = pinned.overflow;
if (pinned.spacer && pinned.spacer.parentNode) pinned.spacer.parentNode.removeChild(pinned.spacer);
var meta = document.querySelector('meta[name="viewport"]');
if (meta && pinned.meta !== null) meta.setAttribute('content', pinned.meta);
window.scrollTo(0, pinned.scrollY);
pinned = null;
fit();
}
window.addEventListener('message', function (e) {
if (e.origin !== SLOTTY_ORIGIN) return;
var msg = e.data;
if (!msg || msg.source !== 'slotty-game' || msg.type !== 'game:fullscreen') return;
if (msg.payload && msg.payload.active) {
// Best case: real fullscreen on the iframe, requested from THIS side.
// iPhone rejects (no element fullscreen there) and gets pinned instead.
var req = f.requestFullscreen || f.webkitRequestFullscreen;
var p;
try { p = req ? req.call(f, { navigationUI: 'hide' }) : Promise.reject(new Error('none')); }
catch (err) { p = Promise.reject(err); }
Promise.resolve(p).then(function () {
nativeFs = true;
try { screen.orientation.lock('landscape').catch(function () {}); } catch (err) {}
setTimeout(function () { reply(true); }, 120); // let the layout land
}).catch(function () {
pin(); reply(true); showCoach();
});
} else {
if (nativeFs) {
nativeFs = false;
if (fsEl()) { try { document.exitFullscreen(); } catch (err) {} }
}
unpin();
reply(false);
}
});
// The player left fullscreen with Esc / the back gesture.
['fullscreenchange', 'webkitfullscreenchange'].forEach(function (t) {
document.addEventListener(t, function () {
if (nativeFs && fsEl() !== f) { nativeFs = false; reply(false); }
});
});
})();
</script>3. Optional: embed.js instead of the snippet
If you'd rather not paste the listener, we host the same logic:
<script src="https://games.slottylabs.com/embed.js"></script>
<div id="game"></div>
<script>SlottyEmbed.mount('#game', launchUrl);</script>mount(target, launchUrl, { height, aspect, className, title }) creates the iframe with the right attributes and a real height, and handles the iOS expand for you.
This needs a CSP change — the snippet above does not
Loading it cross-origin requires script-src https://games.slottylabs.com in your Content-Security-Policy. If you see
Loading the script 'https://games.slottylabs.com/embed.js' violates the following Content Security Policy directive: "script-src 'self' 'unsafe-inline'"
then either add that origin to script-src, self-host a copy of embed.js, or just use the inline snippet in §2 and drop the script entirely.
What the player gets
| Android / desktop / iPad | iPhone (snippet or embed.js) | Missing both | |
|---|---|---|---|
| Fullscreen | Native Fullscreen API (browser chrome gone) | Iframe pinned over your page; one swipe up hides Safari's bars entirely | ❌ blocked |
| Landscape lock | Automatic | Prompt to rotate | — |
| Your header/footer | Hidden | Hidden | Visible (game unplayable) |
| Safari address bar | — | Hidden after the swipe (returns if the player scrolls back; another swipe re-hides it) | — |
That swipe is the honest limit of the platform: iPhone WebKit reserves true fullscreen for <video> (that's what YouTube uses), and only a real finger-scroll makes Safari surrender its bars. After it, the player sees the game and the home indicator — the same thing they'd see over a fullscreen video. If you need more than that on iPhone, the remaining options are an installed Home-Screen web app or a native wrapper, not a different embed.
Launch URL Format
https://games.slottylabs.com/launch/{gameId}?token={launchToken}&lang=en&lobby={lobbyUrl}&demo=false&channel=desktopQuery Parameters
| Parameter | Required | Type | Description |
|---|---|---|---|
token | Yes (unless session) | string | Single-use SSO launch token (30s TTL) |
session | No | string | Existing session token for reconnection |
lang | No | string | Locale code (default: en) |
lobby | No | string | URL-encoded lobby redirect URL |
demo | No | boolean | Enable demo mode (default: false) |
channel | No | string | desktop or mobile (auto-detected if omitted) |
TIP
Either token or session must be provided. Use token for initial launches and session for reconnections after network drops.
Launch Config
When the game loads, the launch page injects a global configuration object:
interface LaunchConfig {
gameId: string;
apiBaseUrl: string; // relative, e.g. "/api/v1" (same-origin calls)
wsBaseUrl: string; // WebSocket base URL ("" when not applicable)
launchToken: string; // from URL query param (seamless mode)
sessionToken: string; // from URL query param (transfer mode)
locale: string; // e.g. "en"
lobbyUrl: string | null; // redirect on exit
demoMode: boolean;
channel: 'desktop' | 'mobile';
}
// Access at runtime:
const config = window.__SLOTTY_CONFIG__;Demo Mode
When demo=true is passed:
- An orange banner is displayed at the top of the game: "DEMO MODE — Not real money"
- No real SSO token is required
- A configurable demo balance is provided (default: 10,000 credits)
- All game mechanics work identically to real-money mode
- Provably fair verification is still available
- No webhooks are fired
CSP Headers
The launch page sets the following Content Security Policy headers:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self' https://api.slottylabs.com wss://ws.slottylabs.com;
frame-ancestors https://*.yourcasino.com;
img-src 'self' data: https:;
media-src 'self' https:;WARNING
You must request that your operator domain be added to the frame-ancestors directive. Contact office@slottylabs.com with your domain(s).
postMessage Protocol
Games communicate with the parent window using the postMessage API.
Envelope Format
All messages follow this structure:
interface SlottyMessage {
source: 'slotty-game';
type: string;
payload: Record<string, unknown>;
timestamp: number; // Unix ms
}Game → Parent Messages
| Type | Payload | Description |
|---|---|---|
game:loaded | { gameId, version } | Game assets have loaded |
game:ready | { gameId } | Game is ready for player interaction |
game:roundStart | { roundId, betAmount, currency } | A new round has started |
game:roundEnd | { roundId, winAmount, currency, duration } | A round has completed |
game:balanceUpdate | { balance, currency } | Player balance changed |
game:exitRequest | {} | Player clicked the exit/lobby button |
game:fullscreen | { active, reason } | Game asks the parent for fullscreen. Sent only when it couldn't take fullscreen itself (missing allow="fullscreen", or iPhone). Handled for you by the §2 snippet / embed.js: they try iframe.requestFullscreen() from the parent side first, then fall back to pinning |
game:soundToggle | { muted } | Player toggled sound |
game:realityCheck | { sessionDuration, totalWagered, netResult } | Reality check interval reached |
game:sessionExpired | { reason } | Session has expired or timed out |
game:error | { code, message } | An error occurred in the game |
Parent → Game Messages
| Type | Payload | Description |
|---|---|---|
host:mute | { muted } | Mute or unmute game audio |
host:pause | {} | Pause the game (between rounds) |
host:resume | {} | Resume a paused game |
host:close | {} | Close the game gracefully |
host:resize | { width, height } | Notify game of container resize |
host:deposit | { amount, currency } | Notify game of a deposit |
host:fullscreen | { active, covered, vw, vh } | Reply to game:fullscreen. covered must be true only if the iframe now really does cover your page — the game verifies this before it will start, and cannot measure it across origins. The §2 snippet / embed.js set it correctly |
Example Listener
window.addEventListener('message', (event) => {
// Always validate the origin
if (event.origin !== 'https://games.slottylabs.com') return;
const message = event.data;
if (message.source !== 'slotty-game') return;
switch (message.type) {
case 'game:loaded':
console.log(`Game ${message.payload.gameId} loaded (v${message.payload.version})`);
break;
case 'game:roundEnd':
console.log(`Round ${message.payload.roundId} ended. Won: ${message.payload.winAmount}`);
break;
case 'game:exitRequest':
// Redirect to lobby
window.location.href = '/lobby';
break;
case 'game:balanceUpdate':
updateBalanceDisplay(message.payload.balance, message.payload.currency);
break;
case 'game:realityCheck':
showRealityCheckDialog(message.payload);
break;
case 'game:sessionExpired':
handleSessionExpired(message.payload.reason);
break;
}
});Sending Messages to the Game
const gameIframe = document.getElementById('slotty-game') as HTMLIFrameElement;
// Mute the game
gameIframe.contentWindow?.postMessage(
{ source: 'slotty-host', type: 'host:mute', payload: { muted: true } },
'https://games.slottylabs.com',
);
// Gracefully close the game
gameIframe.contentWindow?.postMessage(
{ source: 'slotty-host', type: 'host:close', payload: {} },
'https://games.slottylabs.com',
);