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
- 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. - 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).
- 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); onactivate, delete old caches. - 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)
- Add
manifest.json+ link it in your HTML<head>. - Add
sw.jswith a versioned cache name and cache-first fetch. - Register the SW; precache your real asset list (audit sizes first — asset guide).
- Deploy on HTTPS (required for SW).
- Test: load once → go offline (DevTools) → reload → game plays.
The game-specific decisions
- Precache the whole game if it’s under ~5MB gzip — one load, offline forever. Bigger games: precache the shell, cache assets on first play.
- Cache-bust game files:
game-abc123.jsnames, notgame.js— otherwise an updated release is served from cache and your players see the old version (release reality). - Bump the cache name on release (
v1→v2) soactivatepurges the old one. - No SW for portal builds — the portal page owns caching; your SW would fight it.
Pitfalls
- No cache versioning — one bad cache and every player is stuck on the old game.
- Caching un-hashed files —
game.jsgets served forever after first cache. - Offline test skipped — DevTools offline is 30 seconds and catches half the recipe failures.
- PWA on the portal build — meaningless and can break iframe behavior (export/embed reality).
- 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.