Arrows started as a puzzle game in the browser and is now on the App Store. It’s the same TypeScript code in both places: no React Native, no Swift rewrite. The bridge is Capacitor, which puts a web build inside a native iOS shell and gives it plugins for what a browser can’t do: native ads, haptics, the phone’s storage, the App Store’s rating prompt.
Capacitor’s “Getting started” takes ten minutes. Getting an app through App Review, with ads that respect consent rules and a release you can repeat, took the rest. These are the parts that mattered, from Arrows and the games that reused its setup (Decanta, Moat and Drift).
One codebase, three builds
Vite builds the same source in three modes, and the code asks which one it is in:
- development, with a puzzle picker for testing;
- site, the website at arrows.jonathas.net, with web ads and a service worker;
- ios, the bundle that goes into the app.
// The iOS app keeps records in Preferences and shows AdMob ads; its module loads only there.
const native = Capacitor.isNativePlatform();
// The website's build (`--mode site`); the other builds leave its code out.
const site = import.meta.env.MODE === 'site';
const platform = native
? await (await import('./platform/native.ts')).nativePlatform()
: site
? sitePlatform(document, { testAds: import.meta.env.VITE_ADSENSE_TEST === 'on' })
: webPlatform(document);
import.meta.env.MODE is replaced at build time, so the website’s code is left out of the other builds. (Capacitor.isNativePlatform() is only known at run time, so that branch stays in every build.) And because the native module is a dynamic import, it becomes a chunk of its own: the website deploys it but never downloads it, so no Capacitor plugin reaches the browser, only the small @capacitor/core runtime. (Top-level await needs a build target that supports it, such as Vite’s default; an old target like safari14 fails the build.)
A platform interface the game talks to
The game itself never asks where it runs. It gets a Platform and uses it:
export interface Platform {
readonly store: KeyValueStore;
readonly ads: AdProvider;
/** The phone's haptics, or null where there are none (the web). */
readonly haptics: CuePlayer | null;
/** The choices about ads, or null where no ad network runs (the web). */
readonly privacy: PrivacyChoices | null;
/** Asks iOS for its App Store rating prompt, or null where there is none (the web). */
readonly review: (() => Promise<void>) | null;
}
On the web, the store is localStorage, ads come from AdSense, and haptics are null. In the app, the store is Capacitor’s Preferences, ads come from AdMob, and haptics map the game’s cues (a tap, a mistake, a win) to the Taptic Engine. Everything platform-specific lives behind this interface, so the game’s tests run in Node against fakes, and a new platform is a new implementation, not a search through the codebase.
One detail is worth copying: Preferences is asynchronous, but the game reads its saved progress synchronously all over the place. So the native platform reads every saved key into memory before the game starts, and writes go to memory at once and are saved in order behind it. The game keeps its simple synchronous reads; the phone still gets durable storage.
Making a web page feel like an app
A web page in a web view looks like a web page until you fix a handful of things:
- The whole screen.
contentInset: 'never'incapacitor.config.ts(Capacitor’s default, set explicitly so nobody changes it toautomatic) andviewport-fit=coverin the page let the background run under the status bar and the home indicator; CSS keeps the content clear of them withenv(safe-area-inset-top)and friends. - No bounce on the board. The board’s screen is exactly the window’s height with
overflow: hidden, and takes every touch itself (touch-action: none); an end-to-end test checks it’s never taller than the window. Text screens still scroll, as they would in Safari. - A splash that covers the load. A CSS-only layer that animates while the bundle starts, once per launch, with a reduced-motion variant.
- The status bar’s text, set dark through the Status Bar plugin (
Style.Light, the style meant for light backgrounds) so it reads on the light paper theme.
const config: CapacitorConfig = {
appId: 'net.jonathas.arrows',
appName: 'Arrows',
webDir: 'dist',
ios: {
contentInset: 'never',
backgroundColor: DEFAULT_THEME.colors.background,
scrollEnabled: true,
},
};
Advertisement
Ads, consent and the order things happen in
This was the hardest part to get right, and the one App Review looks at most closely. In the European Economic Area, the UK and Switzerland an app needs the user’s consent before personalised ads, and iOS needs the App Tracking Transparency prompt before an app may use the advertising identifier. Google’s guidance puts them in a specific order:
- Google’s User Messaging Platform first: request the consent information and show the consent form when one is required.
- Apple’s tracking prompt next, if it hasn’t been answered.
- Initialise AdMob last.
Arrows wraps this in an AdConsent object whose ready() runs the flow once and is awaited before every ad request. If the flow fails (no network at launch, say), no ad is requested, and the flow simply runs again the next time an ad is needed. The game is playable the whole time: consent never blocks the first level.
Two lessons came from the details:
- The plugin needed a patch. Versions of
@capacitor-community/admobbefore 8.2.0 only wired their consent forms insideinitialize, which Google’s order puts last. A small pnpm patch wires them when the plugin loads, and a unit test reads the installed source to make sure the patch survives an upgrade. Since 8.2.0 the plugin does this itself, so on a current version the patch can go. - An ad never holds the game hostage. An ad that hasn’t loaded within four seconds is skipped: the interstitial is simply not shown, and a rewarded hint says no ad was available. An ad loaded more than 55 minutes earlier is reloaded first, because AdMob ads expire after an hour.
The app also ships a privacy manifest (PrivacyInfo.xcprivacy) declaring that the game’s own code collects nothing, and the one “required reason” API it uses (UserDefaults, through Preferences, reason CA92.1). Separately, Info.plist carries the AdMob app id, the text of the tracking prompt and the SKAdNetwork ids of Google and its partners, checked by a unit test.
Building for iOS without a Mac on the desk
The Xcode project uses Swift Package Manager, so there is no CocoaPods to install, and cap sync ios copies the web build into it. Everything else runs on GitHub’s macOS runners, with Xcode selected explicitly (since April 2026 App Store Connect only accepts builds made with Xcode 26 or later, so the Xcode version matters more than the runner’s defaults):
ios.ymlbuilds the app for the simulator, without signing, on pull requests that touch the native side: the Xcode project, the Capacitor config, the build config or the dependencies. A macOS minute counts ten times against a private repository’s Actions minutes, so a change to the game’s TypeScript alone never starts it.testflight.yml, run by hand, signs the app and uploads it to TestFlight with fastlane. The build number comes from the workflow run, so even a re-run counts up (the run number is per workflow, so this one must stay the only uploader):
# Every upload needs a higher build number than the last, a re-run included.
- name: Number the build
run: echo "BUILD_NUMBER=$((GITHUB_RUN_NUMBER * 100 + GITHUB_RUN_ATTEMPT))" >> "$GITHUB_ENV"
Signing uses an App Store Connect API key and one Apple Distribution certificate, both stored as repository secrets. They belong to the developer team, not to the app, so every game since Arrows uploads with the same six secrets; only its AdMob ids are its own. The fastlane lane imports the certificate into a temporary keychain and creates only that app’s provisioning profile, so nothing ever revokes another app’s signing.
The App Store screenshots are generated too: Playwright drives the production build at an iPhone’s resolution through six screens, with the page’s clock frozen at a fixed date, so every run produces the same images, byte for byte.
Was Capacitor the right call?
For these games, yes. They are 2D, drawn with SVG or Canvas, and run at 60 fps in a web view on any recent iPhone. One codebase means the website and the app never drift apart, a bug fixed once is fixed everywhere, and the whole game is tested with the same fast tools as any web project. The native work is real (consent, ads, signing, the store), but it’s done once and reused: Decanta, Moat and Drift each took a fraction of the time Arrows did to reach TestFlight.
Where I’d think twice is a game that needs heavy 3D, or deep integration with native UI. For anything that is already a good web app and works as an app in its own right (App Review rejects apps that are little more than a repackaged website), Capacitor turns “we should have an app” into a few days of careful work instead of a rewrite.


