HTML5 game integration into TileScore tile

    Technical requirements and contracts for game developers (Phaser, PixiJS, plain Canvas/WebGL).

    1. What a game tile is

    A game tile on TileScore is a wrapper page with an iframe that loads your HTML5 bundle. Files are stored in the platform’s private object storage and served via a proxy endpoint with correct MIME types and no CSP-sandbox (the default static delivery from storage blocks script execution inside iframes — that’s why a dedicated proxy is used).

    The HTML document is loaded into the iframe not as a direct URL but as a blob document. The platform automatically injects a <base href="..."> tag into <head> — so all relative paths keep working.

    2. Bundle format

    • Single ZIP archive, limit 30 MB.
    • index.html — required, must be at the ZIP root (not in a nested folder).
    • Allowed extensions: html, htm, js, mjs, css, json, wasm, map, woff, woff2, ttf, otf, png, jpg, jpeg, gif, webp, svg, ico, mp3, ogg, wav, m4a, mp4, webm, txt, xml. Files of other types in the bundle are dropped.
    • Versioning is automatic: each upload bumps the version, the client appends ?v= for cache busting.

    3. Path resolution (important!)

    The platform injects this tag into <head>:

    <base href="https://<project>.supabase.co/functions/v1/serve-game/<tile_id>/">
    // или (если включён прокси для регионов с блокировками *.supabase.co):
    <base href="https://api.lazytraders.club/api/v1/games/<tile_id>/">

    Therefore the rules are:

    • All paths in the bundle should be relative to the ZIP root: assets/img.webp, js/game.js, css/style.css.
    • Avoid ./ and ../ unless necessary.
    • Dynamic loads also resolve via <base>: new Image().src="...", fetch("data.json"), document.createElement("script").src="...", Phaser load.image("k","assets/k.png") — all work.
    • Absolute URLs (https://, data:, blob:, //) are not rewritten.
    • NEVER hard-code http://localhost — there is no such server in production, the game will fail.

    4. Runtime environment inside iframe

    • sandbox: allow-scripts allow-same-origin allow-pointer-lock allow-popups allow-popups-to-escape-sandbox
    • allow: fullscreen; autoplay; gamepad; clipboard-write
    • The document is loaded as blob: → its origin differs from the parent tilescore.net. Any third-party APIs must support CORS on their side.
    • localStorage inside a blob document is ephemeral — do NOT use it for game progress. Pass state to the parent via postMessage (see below).
    • The platform does NOT run Node, an HTTP server, or a WebSocket server. Static files only.

    5. Parameters from the platform

    Before the game loads, a global object is injected into the iframe:

    window.GAME_PARAMS = {
      parentOrigin: "https://tilescore.net",
      lang: "ru" | "en",
      version: <number>,
      isMobile: <boolean>   // true when viewport <768px
    };

    The game must read GAME_PARAMS.lang and apply localization. No separate query parameters — just this object.

    6. postMessage contract

    For future integrations (LAZY rewards, analytics, progress saving) the game sends messages to the parent:

    parent.postMessage({
      source: "<your-game-name>-game",  // обязательно с суффиксом -game / required suffix -game
      type:   "score" | "finish" | "event" | "progress",
      payload: { /* ... */ }
    }, window.GAME_PARAMS.parentOrigin);

    The platform filters incoming messages by data.source.endsWith("-game"). Messages without this suffix are ignored.

    7. Responsiveness & Mobile UX contract

    Mobile behavior (<768px):

    • The game auto-opens fullscreen (100dvw × 100dvh). Site header, tile description and the «Fullscreen» button are hidden — the player needs no extra clicks.
    • The tile aspect ratio is ignored on mobile — the game takes the whole screen as-is.
    • window.GAME_PARAMS.isMobile: boolean — the game can switch to a simplified UI / touch controls.
    • The «exit» button is up to the game. Send postMessage({ source: "<name>-game", type: "exit" }) — the platform will navigate the user back.

    General requirements (desktop + mobile):

    • Listen to resize and orientationchange events.
    • Use Phaser.Scale.RESIZE (preferred) or Phaser.Scale.FIT — never set the canvas to fixed pixel sizes.
    • On mobile, respect safe-area: env(safe-area-inset-top/bottom/left/right).
    • On desktop the owner-selected Frame aspect ratio is used (9:16, 16:9, etc.) — that is the page wrapper, not the canvas size.

    8. What the platform does NOT do

    • Does not run an HTTP server on localhost — forget http://localhost:8080 in code.
    • Does not provide Node.js, a backend, or a WebSocket server. Static only.
    • Does not write to the portal database from the game directly. Any write goes via postMessage and the platform layer.
    • Does not give the game an authorized user token.
    • Does not wrap the game in a Service Worker — offline mode is your responsibility if needed.

    9. Server delivery constraints

    • Response cache: Cache-Control: public, max-age=60, must-revalidate (1 minute).
    • Cache-busting: ALL relative asset URLs (game.js, *.webp, fonts, css) automatically get ?v=<version> appended. Each new ZIP upload increments version in the DB, so players are guaranteed to receive fresh files — the game does not need to handle cache-busting itself.
    • The owner/admin can instantly rebuild the iframe via the "Refresh" button in the tile editor.
    • Content-Security-Policy and X-Frame-Options are NOT set (intentional — otherwise the game would not load in the iframe).
    • Range requests are supported (useful for video/audio).
    • Compression (gzip/brotli) is not enabled yet — keep JS size in mind.

    10. Audio in the game (important — common cause of stutter)

    If the game starts stuttering after you add sound, the cause is almost always sloppy Audio API usage, not the platform. Checklist:

    • Audio pool, not new Audio() per effect. Create one instance per sound and reuse it: a.currentTime = 0; a.play(). Every new Audio() means a fresh decode + GC, hence the freezes.
    • Preload on start. Decode all sounds once when the game boots (a.load() / decodeAudioData), not on first playback.
    • Format and size. Short SFX — .ogg (Opus/Vorbis) or .m4a (AAC), <50 KB each. Long tracks — stream, do not decode the whole thing into memory.
    • WebAudio for frequent SFX. Decode once into an AudioBuffer and schedule via AudioBufferSourceNode — zero latency, no parallel decode on the UI thread.
    • One-voice rule. If a sound is already playing, do not retrigger it. Or cap to 4–6 simultaneous voices per channel.
    • visibilitychange. On document.hidden === true — pause() every channel; resume on focus. Otherwise a background tab piles up decode jobs and dumps them all at once.
    • Volume via GainNode. Do not recreate Audio elements just to change volume — that is a wasted decode.
    • Mobile autoplay. The very first play() / AudioContext.resume() must run inside a user gesture (touchstart/click). Otherwise iOS Safari suspends the audio context silently.
    • Diagnostics. If FPS drops only with sound — DevTools → Performance → look for "Decode Audio Data" or long tasks. That confirms audio is the cause.

    The platform itself imposes no audio restrictions — everything above applies to code inside the bundle.

    11. Pre-upload checklist

    • index.html is at the ZIP root.
    • All paths are relative, no http://localhost and no hard absolute URLs to dev domains.
    • ZIP size < 30 MB. Compress heavy graphics to WebP.
    • Game reacts to container resize (no fixed canvas size).
    • Game reads window.GAME_PARAMS.lang.
    • Progress/score is sent via parent.postMessage, not written to localStorage.
    • Tested in Chrome desktop, iOS Safari, Android Chrome.

    12. Format v2 — modular bundle (recommended for new games)

    Format v1 (single 4–5K-line JS) is fine for prototypes but does not scale. v2 splits code across files — one JS per level, shared infra in core/. The ZIP stays single for upload convenience, but inside it holds separate scripts that the serve-game edge function wires together via manifest.json when synthesizing index.html on the fly.

    Archive layout

    mygame.zip
    ├── manifest.json        # обязательно для v2
    ├── vendor/phaser.min.js
    ├── core/
    │   ├── boot.js
    │   ├── audio.js
    │   └── ui.js
    ├── levels/
    │   ├── level-1.js
    │   ├── level-2.js
    │   └── level-3.js
    └── assets/...

    manifest.json

    {
      "format": "tilescore-game/v2",
      "scripts": [
        "vendor/phaser.min.js",
        "core/boot.js",
        "core/audio.js",
        "core/ui.js",
        "levels/level-1.js",
        "levels/level-2.js",
        "levels/level-3.js"
      ],
      "module": false,
      "orientation": "portrait",
      "aspect_ratio": "9:16"
    }
    • No index.html required — serve-game generates one, wiring scripts in the order from scripts[].
    • Scripts are plain global IIFEs (same as v1), just split. They talk via window.GameNS.* (see /public/games/_template/).
    • For ESM — set "module": true, scripts are then attached as <script type="module"> with import/export.
    • Legacy v1 (index.html + game.js at root) keeps working unchanged.

    13. Dev mode — develop in the repo without re-uploading

    Drop the game folder into /public/games/<slug>/ (see _template). In the tile admin, enable Dev mode and enter the slug. The game loads directly from public/ — bypassing storage, edge functions, and version bumps. Vite reloads the iframe on every save. When ready — zip and upload as usual.

    • Dev mode works only for admins. Regular users always go through storage — even if dev_slug is set.
    • In dev, wire scripts manually in index.html (see _template/index.html) — synthetic HTML is prod-only.
    • To disable dev — clear the slug field in admin and click Save.