Since our initial blog post, Bonkus has evolved from a single web app into a multi-platform game with a native mobile client, production deployment, and a fully developed backend architecture. This post covers the current state of the system — where the code lives, how data flows, and the patterns that keep development sustainable across web, server, and mobile.
Repository Shape
The codebase is organized into focused layers and packages:
app/— Next.js App Router pages, rendering the web client, plus API routes that serve both web and mobilecomponents/— Web UI components (buttons, modals, game boards, menus)lib/— Server and client application code: managers, repositories, physics, game logic, analytics, auth wrapperspackages/game-core/— Pure, deterministic game logic and physics shared by the server, web client, and mobile app; no side effects or I/Opackages/game-client/— Shared type definitions for client-side codeapps/mobile/— Expo React Native app, using Expo Router for navigation and sharing@bonkus/game-corefor gameplaycontent/blog/— MDX blog postssupabase/— Local Supabase configuration, seed data, and SQL migrationstraining/— Python-based RL training environment and model toolingdocs/— Subsystem documentation, operational guides, and architecture notes
Server-Side Layers
The backend follows a strict three-layer pattern:
API Route (app/api/**)
↓ (request auth, validation, HTTP mapping)
GameManager (lib/managers/**)
↓ (business rules, orchestration, authorization)
Repository (lib/repositories/**)
↓ (SQL, transactions, persistence)
PostgreSQL
API routes (app/api/**) handle HTTP concerns: request validation with Zod, user authentication, error mapping, and HTTP status codes. They never contain business logic. We use auth wrappers (withUser, withUserAndBody, etc.) from lib/auth/with-user.ts to eliminate try-catch boilerplate and ensure errors map consistently.
Managers (lib/managers/**) own the gameplay rules: turn processing, authorization (game-scoped writes check gameRepository.isPlayerInGame()), orchestration, and any logic that touches multiple tables. Managers call repositories to fetch and persist state.
Repositories (lib/repositories/**) issue raw SQL via pg.Pool and manage transaction boundaries. ServerGameRepository uses the Supabase service role key and bypasses row-level security; it's used internally by the server. ClientGameRepository respects RLS using the user's session key; it's for client-side data validation only (most client reads go through API routes instead).
A crucial consequence: clients do not read game data through PostgREST. All reads and writes go through Next.js API routes, which call managers and repositories as needed.
Frontend and Realtime: REST + Broadcast
Both the web and mobile clients use the same data pattern:
UI state
↓
fetch() / API hooks
↓
Next.js API routes
↓
Managers and repositories
↓
PostgreSQL
Realtime signal:
server emitGameChanged()
→ Supabase Broadcast channel
→ client fetches missing turns over REST
When game state changes, the server emits a lightweight Broadcast signal containing only scalar metadata (scores, status, deadline). Clients receive the signal and fetch the missing turn keyframes (detailed physics results) over REST endpoints. This split minimizes mobile bandwidth and latency — the Broadcast carries a few bytes, while large turn_data JSONs stay on REST pull endpoints where clients request them on demand.
Clients reconstruct animations from shared deterministic playback logic, so no animation state lives on the server.
Turn Lifecycle
Here's how a turn flows through the system:
- Client records moves: As the player drags velocity vectors, the UI stores partial moves locally.
- Client submits: The player taps "submit move" (or the deadline expires), and the client sends moves to
POST /api/games/:id/submit-turn. - Server stores moves: The API route validates and persists moves to the
player_movestable. - Server processes turn: When all required moves exist,
GameManager.processTurn()acquires an advisory lock (to serialize parallel requests), runs the deterministic simulation once, stores turn results, updates scores and deadlines, and emitsemitGameChanged()on the Broadcast channel. - Clients sync: Clients receive the Broadcast signal, fetch the missing turn results over
GET /api/games/:id/turns/:n/result, and play them back sequentially using the shared playback helpers frompackages/game-core/.
The server is authoritative; clients never simulate turns themselves. Shared BasicPhysicsEngine and resolveStandardTurn logic in packages/game-core/ ensures the server and all clients compute the same outcome from the same inputs.
Shared Logic and Cross-Platform Consistency
packages/game-core/ is the heart of consistency. It owns:
- Physics: deterministic collision detection, wall/corner collisions, friction, gravity, projectile ballistics
- Game rules: win conditions, shrinking arena math, power-up effects, ball elimination
- Trajectory prediction: client-side preview of where a ball will land
- Playback helpers: turn animation timing, sprite frame selection, idle-wobble animation
- Sprite and state helpers: ball render state, king protections, power-up icons
- Shared move validation: turn deadline checks, move submission constraints
The web app imports @bonkus/game-core directly. The mobile app (Expo, React Native) also imports the same package; there is no separate game-logic fork. The native mobile UI uses Reanimated, Skia, and Expo Router, but gameplay is identical across platforms.
The server (lib/physics/, lib/game/) re-exports shims that point to packages/game-core/ so the entire system computes turns the same way.
Configuration and Persistence
game_config is the source of truth for available game modes and gameplay constants. The database is never fallback-configured from application code; all mode data (kill zones, spawn points, arena size, turn deadlines, power-up rates) come from game_config rows created by migrations or seeded at deployment time.
Built-in game types:
protect_the_king— eliminate the opponent's king while protecting your ownfour_corners— reach designated corners to score pointssoccer— push the ball into opponent goalsbattle_royale— shrinking arena, last ball standing wins
Authorization and persistence changed as the system matured. Row-level security on public tables was removed by migration 20260612130000_remove_rls_and_postgrest_access.sql. Authorization is now enforced in API routes and managers. ServerGameRepository uses direct PostgreSQL connection (pg.Pool) with the service role key; clients never access PostgREST.
Read-only public routes that have no business rules to enforce (e.g., GET /api/games/:id when the game is public) may call repositories directly and skip the manager layer. Any route that mutates state or enforces user-scoped authorization must not skip the manager.
Operations and Deployment
Production runs as a Docker image deployed to a self-hosted Kubernetes cluster via Flux/GitOps. The deployment is defined in the separate production-infra repository. The image is not deployed on Vercel or Coolify.
Runtime configuration is server-driven for mobile compatibility. The mobile app fetches GET /api/mobile-config on startup and prefers the live server-provided Supabase URL and key, falling back to baked defaults only if the fetch fails. This allows the owner to rotate Supabase credentials without cutting a new TestFlight build.
The web app reads Supabase config via next-runtime-env at request time, so one Docker image can be deployed unchanged across preview, staging, and production environments. The app/layout.tsx is intentionally force-dynamic to support this; the owner weighed the SEO cost against the "build once, run anywhere" goal and chose the latter.
In-process schedulers handle turn timeouts, rating updates, tournament progression, stale-game cleanup, and other background sweeps. They run in-memory within the deployed process; there are no separate worker containers (though the system could be split if needed at scale).
Key Guardrails
- Turn processing is serialized with advisory locks to prevent race conditions.
- Games are bounded:
MAX_GAME_TURNS = 500hard-stops pathological games; games with 10 consecutive zero-human-move turns are abandoned as zombies. - Client efficiency is a design rule: put tiny scalar state on Broadcast, keep large payloads on REST pull endpoints, derive state client-side when possible.
- Test data is flagged server-side only: the
BONKUS_FLAG_TEST_DATA=1environment variable marks test-created data for cleanup; clients cannot opt into cleanup behavior. - Mobile builds are path-gated: workflows trigger on file paths (
apps/mobile/**,packages/game-core/**,packages/game-client/**), not labels, so CI automation stays in the repository.
Next
The architecture is designed to scale horizontally — more game servers behind a load balancer, a shared PostgreSQL database, Supabase Broadcast cluster, and independent turn simulation streams. The shared game logic ensures correctness across all implementations, and the REST + Broadcast split keeps mobile clients efficient even as the player base grows.