← Back to Work

Schema-first protocol design across a game engine and a native mobile app

Couch Royale

A local multiplayer party-game platform: a Godot host app renders the game on the TV, phones become controllers, and one wire protocol keeps a game engine and a native app in sync.

Role
Sole engineer, designer, and operator
Year
2026
Status
In Development
  • Godot 4.7 / GDScript (host app)
  • Capacitor / React / TypeScript (controller app)
  • WebSocket (host-authoritative game state)
  • Python / FastAPI
  • PostgreSQL (Neon prod, RDS staging)
  • AWS CDK / EC2 + ALB

158 backend + 66 website + 32 controller + 23 Godot host

Automated tests passing across four runtimes

2026-07-04 · CI on main (GitHub Actions test.yml run 28696056405: pytest, vitest x2, Godot headless)

8/8 valid, 6/6 rejected

Wire-protocol conformance (schema examples valid, malformed payloads rejected)

2026-04-29 · ajv-cli schema validation, protocol/v1 spec, PR #29

Challenge

Couch Royale splits one game across two completely different pieces of software: a Godot desktop app that renders the game on the TV, and a native Capacitor app that turns each player’s phone into a controller. Those two halves are written in different languages on different runtimes, connected over local WiFi, and every message between them has to agree on the same shape or the game desyncs. Browser-based controllers were tried first and ruled out: tab switching, gesture conflicts, and pinch zoom hijacking a button press made a native app the only workable choice, which raised the stakes on the wire format between host and controller even higher.

Process

My fix was to make the protocol itself the contract, not a shared assumption. protocol/v1/messages.json is a canonical JSON Schema (draft 2020-12) covering every message variant on the host-controller WebSocket connection, plus the QR-code pairing payload that bootstraps a session. Godot (GDScript) and the controller (TypeScript) each hand-sync their own type bindings against that one schema, and ajv-cli validates a full set of example payloads in CI so drift between the two runtimes fails the build instead of failing at 2am on someone’s TV. Before the schema shipped, I threw six deliberately malformed payloads at the validator (wrong version, bad token shape, an extra envelope field, and others), and all six were correctly rejected.

Game state itself is host-authoritative: the Godot app is the only source of truth, controllers are pure input devices, and pairing uses session-scoped tokens rather than long-lived credentials. I gave the reconnect matrix the unglamorous attention real-time systems demand: a phone that sleeps mid-round has its slot held and rejoins the same match, and ambiguous reconnect claims are rejected rather than guessed at. Because the product is a downloadable host app plus a native controller app rather than a hosted web app, I deliberately kept the staging mirror API-only: branch pushes deploy the FastAPI catalog backend to a staging instance, while there is no meaningful way, and no attempt, to stage a living-room WebSocket session. One CI gate spans all four runtimes: pytest for the backend, vitest for the controller app and website, headless Godot tests plus gdlint for the host, ajv for the protocol, and a CDK synth.

Result

The website is live at couchroyale.com, the wire protocol has been validated with automated positive and negative tests since April 2026, and the full suite is green across all four runtimes. Word Pop, the launch game, is built, playable, and in polish. The product is pre-launch, with no customers yet, and the business model is set: subscription-only and ad-free, NYT Games style, with a free tier gated by library size and player count rather than by features. The clearest lesson here is that a cross-runtime real-time system needs its message format treated as a first-class, versioned artifact validated in CI, even when both sides’ bindings are still hand-written.