A web game in a native shell: shipping to Google Play and the App Store with Capacitor in five days
Cannon King: Wind Tank Battle started as a browser game — TypeScript, a canvas, a physics loop I could reload in 300 ms. The problem was distribution: nobody finds a URL, and the stores are where the installs are. I had
Cannon King: Wind Tank Battle started as a browser game — TypeScript, a canvas, a physics loop I could reload in 300 ms. The problem was distribution: nobody finds a URL, and the stores are where the installs are. I had five days and one person (me) before the planned launch date.
I did not rewrite anything. I put the existing web build inside a Capacitor shell and shipped it to Google Play and the App Store. Here is what those five days actually looked like, including the coupon system that ate an entire day and turned out to be the most store-sensitive part of the whole thing.
Why Capacitor and not a rewrite
The game is a 2D artillery battle: 48 tanks, each with its own shell and skill, 20 destructible maps, 53 single-player stages, and real-time 1v1 turn-based matches. All of it was already running in a browser at a stable frame rate.
A rewrite would have cost weeks and would have thrown away the one thing I had: a build that worked. A native shell gives you an app icon, a store listing, and access to device APIs, while the game keeps running the code you already tested.
The honest cost:
- You now maintain two shells (Android and iOS) around one web build.
- Some things that are free in a browser (audio, safe areas, keyboard, back button) are not free in a WebView.
- Store review applies to you even though "it's just a website". Age ratings, in-app purchase rules, privacy declarations, data safety forms.
That is still much less work than a second codebase.
Day 1 — the shell, and the WebView differences nobody warns you about
Adding the platform is one command. Making it feel like an app is the rest of the day:
-
Canvas size and DPR. A phone WebView reports a device pixel ratio your browser test window never did. Size the canvas from
window.innerWidth/Heightat load and onresize, and cap the backing resolution so a 3x DPR device doesn't render at six times the pixels for no visible gain. - Audio unlock. Sound does not start until a real user gesture. One tap on the start screen before any audio exists, and everything after it behaves.
-
Safe areas. The notch and the home indicator overlap the game on both platforms. I keep the firing UI inside
env(safe-area-inset-*)padding and let the battlefield run edge to edge. - Back button (Android). Default behaviour closes the app mid-match. Intercept it: in a match, show the pause dialog; on a menu, let it through. A player losing a live match to a stray back swipe does not come back.
- Orientation. Locked to portrait — the whole control scheme assumes it.
- Status bar and splash. Keep the splash up until the first frame is actually painted, otherwise you get a white flash that reads as a crash.
None of this is hard. All of it is invisible until it is broken on a real phone, which is why I tested on device from hour one instead of at the end.
Day 2 — native purchases, and the idempotency rule
The game sells credits and gacha pulls. On the web that was a payment page; in the stores it has to be the store's billing, because selling digital currency outside the store is exactly what gets an app rejected.
The flow: the client calls the billing plugin, gets a purchase token, and posts it to my backend. The backend verifies the token with the store's API, then grants currency.
Two rules made this survive real users:
- Verify on the server, never on the client. A client that says "I paid" is a client you do not control.
- Grant exactly once, keyed by the store's transaction id. Every grant is a database insert with a unique constraint on that id, inside a transaction. If the client retries — and it will, because mobile networks drop responses after the payment succeeded — the second insert fails and the player gets the purchase once. The first time I shipped without this, a double-tap could double-grant.
Restore purchases belongs here too. A player who reinstalls must get back what they bought, and the store expects a visible way to do it.
Day 3 — the coupon system
Launch coupon: POTANKING2027 — 5,000 credits plus 10 premium draw tickets, once per account, valid until 2027-12-31.
It sounds like a 30-line feature. It is a 30-line feature with five sharp edges:
-
One per account, enforced in the database. Not "check if used, then insert". A unique index on
(user_id, code)and let the insert fail. Two taps on a slow network otherwise redeem twice. - Validate the code on the server. The client may show the reward, but the server decides. Any client-side check is a suggestion.
- Expiry is server time. A device clock is a user-editable field.
- Rate-limit attempts. Without it, a coupon form is a brute-force endpoint for anyone who guesses that codes look like words.
- Say the terms in the store listing and in the game. "One per account, expires on a date" in the coupon screen saved me a support thread before it started.
There is also a store-policy side people miss: a free promo code for in-game currency is fine, a paid one is selling currency outside the store's billing. If the coupon page ever grows a price, it becomes a violation. Mine is free, so it stays a promo.
One WebView quirk worth its own line: a fixed-position input at the bottom of the screen gets covered by the on-screen keyboard on both platforms in different ways. I render the coupon field inside a scroll container and let the layout shift, instead of anchoring it to the viewport.
Day 4 — release builds and the paperwork
The build itself is the easy part: a signed Android App Bundle, an iOS archive, icons and splash generated from one source image.
The paperwork is not:
- Store metadata in three languages — Korean, English, Traditional Chinese — written by hand. This is the part that keeps growing every time I reword the description in one language and have to carry it to the other two.
- Screenshots per device size. Crop them to show the game, not the UI chrome.
- Privacy and data-safety forms. Say what you collect. "Nothing" is a valid answer and the easiest one to defend.
- Age rating with in-app purchases. A gacha mechanic is a random-item disclosure; the store wants it declared, and in some markets, the odds.
Day 5 — submission, and the review queue that asks questions
Android went through quickly because the app had been live there already. iOS was a second pass through a checklist I had already learned, plus TestFlight builds and one rejection round about metadata that took an afternoon to answer.
The thing I would tell anyone doing this for the first time: the review queue does not care that you are one person. Build in a day of slack for a rejection, and make sure the answer is a documentation edit, not a code change.
What I would do differently
- Set the store listing text before the shell is finished, so the copy and the screenshots are not the last-day scramble.
- Put the idempotency key on the payment grant on day zero. It is the cheapest insurance in the whole project.
- Test the WebView on a low-end Android phone on day one, not a flagship. The frame budget there decides your map sizes.
The result
Cannon King: Wind Tank Battle is free on both stores, one person, one web codebase in a Capacitor shell:
- Google Play — https://play.google.com/store/apps/details?id=com.cpcai.potanwang
- App Store — https://apps.apple.com/kr/app/id6827588104
- Launch coupon:
POTANKING2027— 5,000 credits + 10 premium draw tickets, once per account, valid until 2027-12-31 - In Korea it also runs as a mini app inside Toss
If you are shipping a web game to the stores, the five days are mostly not about the shell. They are about billing, coupons, and paperwork — the parts a browser never asked you for.
I keep the store checklists, the coupon rules and the build notes in one place with an AI assistant called Pickle, which is how a one-person studio keeps a game, a submission queue and a support inbox in one head. If that sounds useful: https://pickle.com/i/SJWDRVNKFDSY
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.