obOB STUDIO
Web Tech

Making a PWA From a Godot Game — Play on Your Phone Like an App

5 min readolivers-planes

A friend asked "can I play this on my phone?"

I'd been testing Oliver's Planes on desktop the entire time. My friend pulled out his phone, I sent him the link, and it loaded — slowly — but it loaded. Then he said "can I put it on my home screen?" and I realized the answer was "not yet." That's when I dove into PWAs.

What Godot gives you out of the box

Godot's web export produces three critical files:

  • index.html — the loader page
  • *.wasm — the compiled game (~39MB for Oliver's Planes)
  • *.pck — Godot's resource pack (bundled into the .wasm in single-threaded mode)

The key decision: single-threaded export. Multi-threaded builds require SharedArrayBuffer, which requires sending Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers. Those headers break things — embedded fonts, analytics scripts, any third-party resource without CORP headers. The single-threaded build avoids all of that. On modern phones, performance is fine.

Adding a manifest

A PWA needs a manifest.json so the browser knows it's installable:

{
  "name": "Oliver's Planes",
  "short_name": "Planes",
  "start_url": "/planes/",
  "display": "fullscreen",
  "orientation": "landscape",
  "background_color": "#1a1a2e",
  "theme_color": "#e94560",
  "icons": [
    { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

display: "fullscreen" is important for games — you don't want the browser chrome eating screen space. orientation: "landscape" forces landscape on mobile, which makes sense for a flight sim.

Link it from the HTML <head>:

<link rel="manifest" href="manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

The Apple meta tags are necessary because Safari ignores the manifest for some installability checks. Classic.

The service worker

Without a service worker, you get no offline support and no install prompt on Android. Here's the minimal approach:

const CACHE_NAME = 'planes-v1';
const ASSETS = [
  './',
  './index.html',
  './index.wasm',
  './index.pck',
  './manifest.json',
  './icon-192.png',
  './icon-512.png'
];

self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE_NAME).then(c => c.addAll(ASSETS)));
});

self.addEventListener('fetch', e => {
  e.respondWith(
    caches.match(e.request).then(r => r || fetch(e.request))
  );
});

The big win: the .wasm file is ~39MB. On first load that's a long download. But once the service worker caches it, repeat visits load instantly — even offline. That's the killer feature. Your game essentially becomes a local app after the first visit.

Compression matters — a lot

39MB uncompressed is painful on mobile data. With gzip, it drops to roughly 12MB. With Brotli, even less. Most static hosts (Netlify, Vercel, Cloudflare Pages) serve Brotli automatically if the browser supports it. If you're self-hosting with nginx, you need:

gzip_types application/wasm application/javascript;
gzip_static on;

Or pre-compress with Brotli:

brotli -o index.wasm.br index.wasm

The difference between serving raw and serving compressed is the difference between "this takes forever" and "this is fine." Don't skip it.

Installing on iOS and Android

iOS (Safari): Tap the share button → "Add to Home Screen." There's no install banner — Apple doesn't show one. Your users need to know this exists, so I added a little tooltip in the game's start screen that says "Add to Home Screen for the best experience." It only shows on iOS.

Android (Chrome): If everything is set up right (manifest + service worker + HTTPS), Chrome shows an install banner automatically. Users can also go to the menu → "Install app." The game then launches in its own window with no browser UI.

Testing locally

Before deploying, test the PWA locally. Godot's built-in server doesn't serve the right MIME types for .wasm files. Instead:

python -m http.server 8000

Then open http://localhost:8000 in Chrome. The service worker won't activate on HTTP (it needs HTTPS or localhost), but localhost counts as a secure context, so everything works. Use Chrome DevTools → Application → Service Workers to verify it registered. Check the Cache Storage tab to confirm your .wasm got cached.

Gotcha: iOS audio

iOS Safari requires a user gesture before playing audio. Godot handles this mostly, but I found that the first AudioStreamPlayer sometimes stayed silent until the user tapped the screen. My workaround: the start screen has a big "TAP TO FLY" button that resumes the AudioServer. That tap is the user gesture Safari needs. Problem solved — but it took me an hour of debugging silence to figure out why.

Was it worth it?

Absolutely. About 40% of my traffic is mobile now. People play Oliver's Planes on their phone during commutes. The PWA install makes it feel native — full screen, no URL bar, instant load. And I didn't have to touch the App Store or Google Play.

ob

Written by Oliver

Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.