I shipped ApexDrawn as a plain static site: you draw a closed loop, it becomes a racetrack, and then you drive it. Later I needed a second build target - a mobile shell that loads the same game from local assets with no network at all.

The interesting part wasn't the game logic. It was that the whole app assumed a server: accounts, /api/* for purchases and garage state, a WebSocket relay for races, analytics, payments. None of that can exist in an offline WebView. I didn't want a fork, so the split is one build-time flag and everything else follows from it.

One flag, two bundles

// src/appMode.ts export const OFFLINE_APP = import.meta.env.VITE_OFFLINE_APP === "true"; 

Because this is a compile-time constant, Rollup tree-shakes the unreachable branch. The offline bundle genuinely contains no auth SDK, no analytics and no checkout code - not "code that checks a boolean at runtime and returns early". That distinction matters when your CSP forbids all network egress: the easiest network call to forget is the one hidden behind a feature flag.

The web build emits absolute /assets/... URLs. The offline build is emitted with relative paths and copied under assets/web/ in the Flutter project, so its HTML references ./i18n.js and assets/index-*.js instead.

Replace the session at the boundary, not in every component

Components ask a context for authLoaded, signedIn, getToken() and user. The web provider wires that to an auth SDK. The offline provider is deliberately boring:

export function OfflineSessionProvider({ children }: { children: ReactNode }) { const session: AppSession = { authLoaded: true, signedIn: true, getToken: async () => "local-only", userLoaded: true, user: /* a local profile */ null, }; return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>; } 

No component needs if (offline). That's the whole point: one boundary, two implementations. The same trick covers state. Web state lives behind /api/*; offline state is a localStorage record with a schema version in the key (apexdrawn.offline-profile.v1) holding the balance, a ledger, owned items and tuning. Every read is defensive:

const raw = window.localStorage.getItem(STORAGE_KEY); const parsed = JSON.parse(raw); return { ...fallback, ...parsed, balance: Number.isFinite(Number(parsed.balance)) ? Math.max(0, Number(parsed.balance)) : 0, ledger: Array.isArray(parsed.ledger) ? parsed.ledger : [], }; 

If storage is unavailable or contains garbage, it falls back to an in-memory copy. A WebView can and will hand you a corrupted profile; treating local storage as a trusted typed object is how you get a crash on launch.

Relative paths are the actual hard part

The site uses directory-style routes (/garage/, /free-racing-games/, /es/...). On a normal host the server resolves those. Inside a WebView loading assets/web/garage/index.html, a relative asset URL resolves relative to that file, so the correct prefix depends on how deep the current page is.

The fix is a small path helper that strips the assets/web/ prefix, counts the remaining segments, and builds the prefix from that depth:

const marker = "/assets/web/"; const markerIndex = pathname.lastIndexOf(marker); const suffix = markerIndex >= 0 ? pathname.slice(markerIndex + marker.length) : pathname; const segments = suffix.split("/").filter(Boolean); if (segments.at(-1) === "index.html") segments.pop(); const rootPrefix = "../".repeat(segments.length); 

Extensionless routes get normalized to index.html, query strings are preserved, and publicAsset() passes through data:, blob: and https?: URLs untouched. Routing itself uses the same logic: appPathParts() removes the WebView mount prefix so the app sees /garage whether it's hosted at a domain root or six directories deep in an APK.

Language selection had the same shape. On the web, the language is the first path segment:

const pathLanguage = window.location.pathname.split("/").filter(Boolean)[0]?.toLowerCase(); if (pathLanguage in localeByLanguage) return pathLanguage; 

Offline there is no meaningful path segment, so it reads a stored preference (apexLang) and finally falls back to document.documentElement.lang. Same public API, different source of truth.

Enforce offline with the platform, not with discipline

The offline HTML injects a strict CSP before anything else:

default-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; navigate-to 'self' 

It also short-circuits fetch, XMLHttpRequest and sendBeacon for cross-origin or /api|auth|oauth URLs, neuters window.open, and cancels clicks on external links in the capture phase. This is belt-and-braces: even if a component accidentally tries to phone home, it can't.

Native purchases go through an explicit bridge instead. The JS side posts a message to the host, listens for a CustomEvent, and matches on a request id:

window.addEventListener("apexdrawn:purchase-result", onResult); bridge.postMessage(JSON.stringify({ type: "purchase", requestId, packageId })); 

A 180-second timeout, a matching requestId, and validation of packageId plus transactionId before resolving. An async bridge without those checks is a race condition waiting for a referee.

A third shape, almost free

There are also ~49 static landing pages generated by a Node script from a template. For those, the script strips the main module bundle and modulepreload links and replaces them with a tiny /info-page.js plus a stylesheet:

html = html.replace(/\s*<script defer src="\/i18n\.js"><\/script>/, '<script defer src="/info-page.js"></script>'); html = html.replace(/\s*<script type="module"[^>]*src="\/assets\/[^\"]+\.js"[^>]*><\/script>/, ""); 

Those pages are content, not an app. Shipping a full SPA bundle to them would be pure cost. It's the same lesson as the offline build in miniature: decide what a page actually needs, then remove the rest.

What this costs

  • Every networked feature needs two implementations. That's a real ongoing tax, not a one-time setup.
  • The build matrix is easy to get wrong. I once nearly deployed the offline dist/ to the live site; the analytics code had been tree-shaken out, so the site would have quietly stopped reporting. Build flags belong in CI, not in someone's memory.
  • Offline state doesn't sync. Local coins, unlocks and tuning are device-local by definition. That's a product decision, not a technical bug.
  • Store rules differ. An offline in-app purchase goes through the platform's billing, with its own review and receipt semantics.

The upside is one set of game logic and one place to fix a bug. Keep the differences at a handful of explicit boundaries (session, state, asset paths, network), and the rest of the codebase never learns that there are two runtimes.

The playable web build is at https://apexdrawn.org/ - no download or account needed. If you've shipped a Vite app inside a WebView, I'd like to hear which boundary caused you the most trouble.