
Real frames from the current builds, not renders: four worlds, one readable shot line in each.
Project facts
- Problem
- Mobile golf games tend to sell random chests and let paid equipment decide matches.
- Approach
- A 3D phone game in Expo and three.js, with rewards that are earned and never sold, on a backend of 14 server functions.
- Result
- In development. It passes its pre-release check; launch waits on store purchase setup and signed-off tests on real phones.
- Client
- Swift Ideas (own product)
- Year
- 2026
- Status
- In development
- Work
- Games & 3D, Mobile
Our role
Built with
Expo SDK 57, React Native 0.86, React 19, three.js 0.185, @react-three/fiber 9, Zustand 5, Supabase (Postgres, Auth, Realtime, Edge Functions on Deno), RevenueCat, Apple Game Center, Jest, pgTAP, Maestro on EAS Workflows, Blender, Godot 4.7 + GDScript, gdUnit4
The problem
Mobile golf games are usually either pretty or fair. Many sell random chests and let paid equipment decide live matches. We wanted a game with the art ambition of a premium title and an economy that a parent, a regulator and a competitive player would all accept.
The game also runs 3D terrain, stylised characters and ball physics in React Native on phones from 2019 onwards. Smooth play, the physics and the reward rules had to hold on every device, while the amount of scenery was allowed to change.
Online play, purchases and saved progress all meet in one backend. A lost phone, a repeated purchase notice from the store or an out-of-date device must never cost a player what they earned or paid for.
What we did
The game is an Expo app that draws its 3D world with three.js. A Supabase backend runs sign-in, friend rooms, Daily scores, purchases and usage data, through 14 server functions, 24 database changes and 15 database test suites. RevenueCat handles app store billing. Game Center is optional on iOS, and no platform account is required.
In July 2026 we also tested a dedicated game engine. In nine days and 733 commits we rebuilt the game in Godot 4.7 on the same Supabase backend. The ball physics and course generator were copied across line for line, so the same starting number (seed) produced the same hole in both apps. The Expo build remains the release candidate.
Release is treated as evidence, not optimism. A release checklist, checked by a script, ties every passed test to one exact version of the code and one exact build. The current decision is no-go until store purchases and tests on real phones are signed off, and the checklist will not show go without two separate approvals.
What we considered
The decisions behind the build, with the reasoning and the evidence for each.
StrategyRandom rewards are earned, never sold
Apple and Google require odds disclosure when random items are sold. We chose not to sell them at all. Players buy exactly the cosmetic or currency they choose, and Supply Pods come only from play.
- Every Pod shows its 70% / 25% / 5% reward table before it opens
- Rare-or-better pity protection by the fifth Pod, targeted at the least-progressed club
- Purchased Nova Gems never expire, in line with Apple's IAP rules for currency
- Deliberately rejected: paid chests, timed waits before rewards open and escalating same-day prices
StrategyFair play in friend rooms
Club tuning gives modest solo bonuses, but paid or collected progress never changes a live match. Friend rooms call a stock-club resolver for both players, and the HUD says so.
- Solo tuning capped at +5% power and +15% forgiveness
- Friend matches normalise range, attributes and stage presentation to Stock; the HUD reads 'FAIR PLAY · STOCK STATS'
- Mastery still counts accepted swings in friend matches: Cadet 0, Pilot 25, Ace 75, Elite 150, Legend 300
- Rejected gestures, replays and penalty strokes add no mastery use
PerformanceOlder phones get less scenery, never different physics
A 2019 phone and a current Pro model must play the same game. Device facts choose a render tier; physics substeps, course geometry, scoring and rewards are identical at every tier, and remote config cannot raise a tier.
- Low (older than 2020 or under 3.5 GiB RAM): 30 fps budget, 160 terrain segments, 28 dressing instances
- Balanced (unknown physical devices): 60 fps budget, 210 terrain segments, p95 frame at 24 ms or less
- High (2022+ with 6 GiB or more): 240 terrain segments, p95 frame at 20 ms or less, peak memory at 550 MiB or less
- Cold launch budget 2.8 to 4.0 s by tier, plus a 15 to 20 minute thermal soak before any device row can pass
DataA progress backup that cannot restore purchases
A reinstall should not cost a player their progress, but a backup must not become a way to restore purchases. The server keeps one bounded snapshot per commerce identity, and it can only raise progress.
- player_progress reachable only through get/put RPCs, never by direct table access
- Nova Gems zeroed on write; no purchase, ownership or refund fact is stored in the backup
- Strictly monotonic revisions: a stale device is refused and must re-read and merge
- Twelve local Zustand stores; invalid saves are quarantined locally and never uploaded
SecurityEvery server function refuses by default
Server functions that players reach check who is calling and act with that player's own permissions, never with admin access. Each runbook lists the negative checks a deploy must pass.
- verify_jwt = true for commerce, player operations, telemetry and runtime config; the store webhook authenticates separately
- Production probe on 30 July 2026: seven functions returned 204 to preflight and 401 to an unauthenticated POST
- Runtime config is one revisioned row with a 5-minute to 24-hour TTL; it cannot touch rewards, prices or ownership
- The RevenueCat webhook stays undeployed until its authorisation and environment isolation are proven
- In-app account deletion, with erasure RPCs guarded by permission SQLSTATEs and covered by pgTAP
ShowHide the other 5 decisions
DataUsage data that is off by default and strictly limited
Analytics and diagnostics are separate opt-ins, both off after install. Every event has an exact payload, and any extra key is rejected at the client, the Edge Function and the database.
- Seven event types, for example round_complete with holes 1 to 18 and vs-par -36 to 180
- Never sent: names, room codes, transaction IDs, free text, stacks or coordinates
- Outbox of 64 records, batches of 25, retries at 1 s, 5 s, 30 s, 2 min and 10 min
- Rows older than 90 days deleted; event IDs make replays idempotent
AccessibilityA shot you can take without a swipe
Golf swipes are a motor-skill barrier. An alternative control strip exposes every shot decision as a labelled button and reads the shot state back as a sentence.
- Aim left, Aim right, Change club, Change spin, Power down, Power up and Swing
- Shot state as text, for example 'Stroke 1. Driver · L3. Power 50 percent. Spin CLEAN.'
- Under and over par carry signed values and labels, not colour alone
- Layouts reflow with system text size; reduced-motion behaviour and safe areas retained
DesignAn orbital workbench for the Bag
The Bag screen is where players make economy decisions, so it is built like a premium equipment bench: the real club is the focal object and its next effect sits beside the action.
- Midnight #04102a surfaces; cyan #3ee6ff marks selection, next values and the primary action
- Gold #f0a81e and #ffd464 reserved for Credits and resource shortfalls
- Barlow Black Italic, loaded as BagDisplay, for identity and actions; the platform sans for body copy
- Tune stages from Stock to Ascendant add a capped emissive trim (0.9 intensity) so the bought club colour always shows
InfrastructureA rebuild in a second game engine, on the same backend
To test whether a dedicated game engine would make the characters look better, we rebuilt the game in Godot 4.7 on the same Supabase backend. Deterministic parity came first, visuals second.
- Ball flight and course generation ported 1:1 to GDScript, not handed to Godot's physics engine
- Parity fixtures dumped from the TypeScript app: trajectories, hole corpus, FNV-1a and PRNG vectors
- A test forbids Vector3 in core maths, because Godot's Vector3 is 32-bit and GDScript's float is 64-bit
- Hand-written Phoenix channel client for Supabase Realtime, so both apps can join the same rooms
- 74 gdUnit4 test files; the web export was deliberately dropped to keep scope to iOS and Android
ReleaseA release that two people must sign
A pass on a simulated phone is not a release. The certification ledger names what each row needs, and signed or store rows require a build ID, tester, device, OS and UTC time.
- 52 certification gates carrying 81 earlier live UAT rows, validated by a script
- All six Maestro journeys passed on iOS Simulator and Android Emulator for the frozen SHA
- Remote executable updates disabled for v1; a fix means a new native build and repeated rows
- Current decision: NO-GO, pending App Store products, sandbox purchase testing and physical-device certification
Colour and type
- Midnight ink#04102ADeep surfaces and lettering on the cyan action
- Bag midnight#080A22Bag header, body and preparation backing
- Cyan#3EE6FFSelection, next values and the primary action
- Gold#F0A81ECredits and their material identity
- Gold light#FFD464Shortfalls and market actions
- Lavender#9FB0FFShared supporting text
- Under par#7BF4B9Under-par results, always with a signed value
- Over par#FFAC99Over-par results, always with a signed value
Your Bag · 12 of 36
Bundled as a single face and requested at weight 400 so React Native uses the file's own Black Italic drawing. Broad sporting italic for totals, identities and actions.
Friend matches always use normalised stock equipment.
No bundled body face. The native sans follows Dynamic Type, with tabular numerals for wallets, levels and costs.
The build, screen by screen









By the numbers
- Commits in the Expo app, July to September 2026
- 305
- Source: The Expo app's commit history, all branches
- Commits in the nine-day Godot rebuild
- 733
- Source: The Godot rebuild's commit history, 17 to 25 July 2026
- Release certification gates, carrying 81 user acceptance test results
- 52
- Source: Our release test record, 30 July 2026
- Automated test files, plus 15 database test suites
- 269
- Source: Counted in the project's test suite, latest development version
- Server functions for purchases, sign-in, usage data and settings
- 14
- Source: Counted in the project's backend code, latest development version
Where it landed
Astro Golf is in development. The code passes its full pre-release check, and the live database changes, database checks and sign-in protections are verified. The App Store launch waits on in-app purchase setup in the stores and signed-off tests on real phones, and the release checklist says so in plain terms.
The Godot rebuild answered its question in nine days: the physics and course generator can produce identical holes in another engine on the same backend. The Expo build carried on as the product line.
What carries to client work: economy rules that survive scrutiny, privacy by schema rather than by policy, and a release process where 'done' needs evidence tied to a specific build.