Offline HTML5 Game with PWA: A Working Recipe

Offline HTML5 Game with PWA: A Working Recipe

Honesty note: this is the standard PWA recipe applied to a browser game. My game is portal-first (CrazyGames) where PWA doesn’t apply (the portal owns the page); this is the recipe for your own hosted build — the version you control (where the game runs).

TL;DR

  1. PWA = three files: a manifest.json (name, icons, display), a service worker (the offline brain), and HTTPS. That’s the whole recipe — no framework needed.
  2. Cache-first is the game-appropriate strategy: precache the app shell (HTML, JS, assets) at install; serve from cache afterward; fall back to network when missing. Games benefit most: one load, offline replay forever (load-time reality).
  3. The trap for games is stale assets: aggressive caching that never updates breaks your next release. Use cache-busted URLs (hashed filenames) for game files and a versioned cache name (mygame-v2); on activate, delete old caches.
  4. Honest scope: PWA only matters on your own hosted build. Portal iframes (CrazyGames/Poki) run in the portal’s page — your SW can’t control their page, and portals don’t need it (portal reality).

The three files

1. manifest.json

{
  "name": "Merge Fish 2048",
  "short_name": "Merge2048",
  "start_url": "/",
  "display": "fullscreen",
  "background_color": "#1a2b3c",
  "icons": [{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }]
}

(Add icon sizes; see icon requirements.)

2. Service worker (sw.js) — cache-first with versioning:

const CACHE = 'merge2048-v1';
self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll([
    '/', '/index.html', '/game.js', '/assets/...'   // your shell
  ])));
});
self.addEventListener('fetch', e => {
  e.respondWith(
    caches.match(e.request).then(hit => hit || fetch(e.request))
  );
});
self.addEventListener('activate', e => {
  e.waitUntil(caches.keys().then(keys =>
    Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
  ));
});

3. Register in your HTML:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

The recipe steps (20 minutes)

  1. Add manifest.json + link it in your HTML <head>.
  2. Add sw.js with a versioned cache name and cache-first fetch.
  3. Register the SW; precache your real asset list (audit sizes first — asset guide).
  4. Deploy on HTTPS (required for SW).
  5. Test: load once → go offline (DevTools) → reload → game plays.

The game-specific decisions

Pitfalls

  1. No cache versioning — one bad cache and every player is stuck on the old game.
  2. Caching un-hashed files — game.js gets served forever after first cache.
  3. Offline test skipped — DevTools offline is 30 seconds and catches half the recipe failures.
  4. PWA on the portal build — meaningless and can break iframe behavior (export/embed reality).
  5. Ignoring HTTPS — SW won’t register on HTTP; your host must be HTTPS.

Bottom line

PWA for a self-hosted HTML5 game is three files and one habit: manifest + versioned cache-first service worker + HTTPS, with cache-busted game files and a cache bump on every release. It gives your own build offline replay and instant reloads — and for the portal version, it simply doesn’t apply (the portal owns the page). The recipe is the self-hosted half of the distribution picture.