Nova - a multi-game platform under a 75 MB ceiling
Nova is the Unity 6 client that hosts every game we ship at Garud Lab - currently 11 slot titles and a multiplayer fishing suite, scaling to 30+ slots with lottery and other game shapes on the roadmap. It is also white-label: the same codebase ships as three separate operator-branded apps - Nova, JUWA Vault, and Game Vault - each with its own backend, asset set, and store identity, selected at build time with no code change. One shell, mode-aware content delivery, server-authoritative state, shared auth and wallet. I built it from scratch under a hard 75 MB install-size cap. This is the design and the parts that were hard to get right.
The constraint that shaped everything: 75 MB
The install size cap was 75 MB. That number is the reason Nova exists as a platform and not a monolithic app - at 30+ slot titles plus a multiplayer suite, bundling everything into one APK was never going to fit. The architecture is the constraint's answer, and today, with all three brands and the full game roster, the build ships at 74 MB - just under the line.
The non-obvious problem was multiplayer. Unity bakes scene-referenced assets into the build; Addressables
can't reach into a scene and pull sprites out at runtime if those sprites are placed in the editor. Slot
scenes were straightforward - they're prefab-driven and stream cleanly through Addressables, scoped by
per-game labels (game_LIFE_OF_LUXURY, game_WILD_BUFFALO, etc.). Multiplayer
scenes fought back. The fix was structural: keep multiplayer scenes nearly empty, instantiate everything
from Addressable-loaded prefabs at runtime, and reserve in-scene assets for the absolute minimum the scene
needs to boot.
Fishing was the hardest part of that budget. Because the fishing games are multiplayer, Netcode for GameObjects requires their scenes to sit in Build Settings, so the pipeline bakes their directly-referenced assets into the player even though the same folders are also Addressables groups - the first build came in around 106 MB. Getting under the cap took two passes:
- Fishing scenes: 106 MB → 78 MB. Applying the keep-the-scene-empty rule above to the fishing scenes - moving their heavy sprites, atlases, and prefabs to runtime Addressable loads instead of in-scene references - was the biggest single lever, roughly 28 MB.
- Pre-scene set, login + main menu: 78 MB → 74 MB. Those screens are preloaded into memory at boot so they paint instantly, but eager preloading costs both build size and startup time. Paring back what loads up front took the last few MB - a deliberate size-versus-first-load tradeoff, not a free win.
106 MB → 74 MB. The fishing scene assets accounted for most of the reduction; the login and main-menu preload set the rest. What's left is the floor: the assets that genuinely have to ship in-build rather than stream.
White-label: one codebase, three operators
Nova doesn't ship as one app. The same codebase builds into three separate operator-branded clients - Nova, JUWA Vault, and Game Vault - each with its own backend, asset set, and store identity. Onboarding a fourth operator is a configuration and content task, not a code change.
The three operator brands - one Nova codebase, each with its own backend, assets, and store identity. Shared features like the leaderboard render in each brand's skin.
The mechanism is a single Addressables address. Each brand owns an Addressables group containing a
BrandConfig asset published at the well-known address "BrandConfig". A build packs
exactly one brand's group, so that address resolves uniquely at runtime. On boot,
BrandRegistry.LoadAsync() loads the active BrandConfig, which carries that
operator's dev and prod environment config - base URLs, socket endpoints, everything environment-specific.
UrlManager is initialized straight from it. No per-scene Inspector references, no per-brand
#if branches, no forked builds.
That puts brand resolution at the very front of the boot sequence, and the order is load-bearing:
Addressables has to come up first so the brand group is reachable, the brand has to resolve before
UrlManager can know which backend to point at, and UrlManager has to be live
before GameModeProvider asks the backend whether to run Authentic or Dummy. Get the order
wrong and env resolution silently falls back to Dummy - so the invariant
(Addressables → BrandRegistry → UrlManager → GameModeProvider) is documented in-code.
Architecture
Each layer has a single responsibility. Everything below the per-game layer is reusable infrastructure that any new sub-game inherits without touching shared code.
graph TD
subgraph Boot[Bootstrap]
GB[GameBootstrapper]
BR[BrandRegistry]
UM[UrlManager]
GMP[GameModeProvider]
end
subgraph Net[Networking · BestHTTP]
WA[WebApi REST]
WSL[Lobby WS · /ws/lobby]
WSS[Slot WS · /ws/slot]
WSF[Fish WS · /ws/fish]
end
subgraph Persist[Persistent · PersistentSingleton]
US[UserSessionService]
TR[TokenRefreshService]
LS[LobbySocketController]
AS[AudioService]
SC[SessionController]
AM[AppStateMonitor]
end
subgraph Content[Content]
CO[ContentOrchestrator]
AAP[AddressableAssetProvider]
end
subgraph Modules[Game Modules]
BSC[BaseSlotController]
FB[Fishing base]
end
subgraph Games[Per-game · Assets/Games/Slots/game_*]
LOL[Life of Luxury]
WB[Wild Buffalo]
Z2[Zeus II]
ETC[+ 8 more]
end
BC[BrandConfig · per-brand Addressable] --> BR
GB --> BR
BR --> UM
UM --> GMP
GB --> CO
GMP --> WA
TR --> US
LS --> WSL
BSC --> WSS
FB --> WSF
CO --> AAP
LOL -->|extends| BSC
WB -->|extends| BSC
Z2 -->|extends| BSC
ETC -.-> BSC
- Bootstrap -
GameBootstrapper,BrandRegistry,UrlManager,GameModeProvider. The boot-critical order is Addressables init →BrandRegistry.LoadAsync→UrlManager.Initialize→GameModeProvider.DetermineGameModeAsync(); the last callsUrlManager.AppConfiginternally and silently falls back to Dummy if the order is wrong. The invariant is documented in-code because it's the kind of thing that breaks once and is hard to find. - Networking - REST through
WebApi(BestHTTP-based) for catalog and auth; three WebSocket routes (/ws/lobby,/ws/slot?game_slug=...,/ws/fish?game_slug=...) for stateful, in-game traffic. All authenticate viaAuthorization: Bearerattached inOnInternalRequestCreatedbefore handshake. - Persistent services - anything that needs to survive scene loads goes through
PersistentSingleton<T>:AudioService,LobbySocketController,SessionController,TokenRefreshService,NetworkMonitor,AppStateMonitor. Plain non-MonoBehaviour singletons (UserSessionService.Instance) are used where no Unity lifecycle is needed. - Content -
ContentOrchestratorwrapsIAssetProviderwith mode-aware label selection (login_,dummy_, per-game slug). Thegeneric_shared bundle is pinned in a static handle for the app lifetime, released only onOnApplicationQuit. - Game modules -
BaseSlotController(abstract) owns spin-state machinery, reconnect, free-spin handling. The fishing base owns multiplayer-specific lifecycle. - Per-game - each title under
Assets/Games/Slots/game_*/with its own scene, prefabs, and a controller subclassing the base. Life of Luxury is the reference implementation every new slot follows. This is the leverage - onboarding a new slot is hours, not weeks.
Two boot paths
Production goes through BootStrap.unity: boot Addressables, resolve the active brand via
BrandRegistry and initialize UrlManager from its environment config, download
mode-specific Addressables, preload generic_, run the boot UI (8s video, ≥2s smoothed progress
bar), hit /unity/app-config, decide Authentic vs Dummy, then load auth or the dummy entry. For developer convenience, every game scene
also has a StandaloneGameBootstrapper with a playStandalone toggle - tick it,
press play, the scene performs silent auth and runs as if launched normally. With 11 slot games today and
30+ planned, the developer-convenience path is what makes parallel feature work survivable.
Per-game ownership: the rule that scales
Every shared codebase eventually grows an if (gameSlug == "X") branch in a base class, and
that's the moment the abstraction dies. Nova has explicit ownership rules to prevent it:
- Game-specific scripts live only inside that game's
Scripts/folder. The sharedAssets/Scripts/tree is for infrastructure, never for one game's quirk. - Cross-game references are forbidden. If two games need the same logic, it gets extracted to shared code - with explicit review, not a quiet copy-paste.
- Per-game variation is implemented via subclass override, never by branching shared base classes. The Open/Closed half of SOLID, applied with teeth.
These rules are written down because at 30 sub-games the cost of breaking them is paid every release for the rest of the platform's life.
The spin lifecycle and the free-spin sentinel
Slot spins are optimistic UI with server-authoritative reconciliation: deduct the bet locally, send the spin over the socket, render symbols only when the server result lands, commit the balance after the win presentation finishes. Optimistic so the UI feels instant; reconciled because the server is always right.
The interesting part is free spins. Server-triggered free spins arrive as a side effect of a base spin -
the server has decided you're owed N free rounds, but the new round IDs haven't been allocated yet. The
client can't sit on the open currentRoundId (that's the round just resolved) and it can't
invent one. The pattern: a sentinel value FREE_SPIN_PENDING set on
currentRoundId until the server returns the next round, at which point the real ID replaces
it. RequestSpin treats any non-empty currentRoundId as a free-spin context, and
the outgoing payload omits roundId entirely when the sentinel is set. It's a small piece of
state, but every flag around it (_pendingFreeSpinResume, _startupBoardApplied,
_retryNow) covers a specific edge case - reconnect mid-free-spin, app-resume mid-free-spin,
initial board hydration vs reconnect board hydration. Each one is documented inline because each one was
a bug that bit before it was a flag.
sequenceDiagram
participant UI as SlotUIPanel
participant BSC as BaseSlotController
participant SS as SlotSocket
participant BE as Backend
UI->>BSC: Spin pressed
BSC->>BSC: Optimistic deduct
BSC->>SS: Spin(payload)
SS->>BE: BASE_SPIN
BE-->>SS: SPIN_RESULT (free spins owed: 5)
SS-->>BSC: OnSpinResult
BSC->>BSC: roundId = FREE_SPIN_PENDING
UI->>BSC: Spin pressed (free)
BSC->>SS: Spin(no roundId)
SS->>BE: FREE_SPIN
BE-->>SS: SPIN_RESULT (roundId: r-123)
SS-->>BSC: OnSpinResult
BSC->>BSC: roundId = r-123
Reconnect: the part that's actually hard
Mobile clients drop. iOS and Android background sockets. WiFi-to-LTE handoffs kill connections. Tokens rotate. None of these are edge cases - they're the common case. Nova's reconnect path covers three independent triggers:
- App resume.
AppStateMonitorfiresAppEvents.OnAppResumed; live socket controllers (lobby, slot, fish) re-handshake with their cached token. - Token refresh.
TokenRefreshServicerotates the access token roughly every 10 minutes and firesOnAccessTokenUpdated. Slot controllers observe this and wake their reconnect loop with the new token immediately, instead of waiting for the next reconnect interval. This means tokens can rotate without ever dropping a spin. - BestHTTP TLS hangs. BestHTTP's WebSocket client sometimes fails to fire
OnErrorwhen a TLS handshake stalls - the connection is dead but no error event arrives. The reconnect coroutine has an explicithandshakeTimeoutSecondsthat catches this case; without it, the socket would silently fail to reconnect forever.
The state machinery in BaseSlotController that sits behind all of this - pending spin
payload, free-spin sentinel, board-hydration flags - is treated as load-bearing in the codebase: every
flag has a comment explaining the bug it covers, and "don't simplify without reading the comments" is
written into the project's architecture notes.
Cross-platform multiplayer
Multiplayer ships to native (Android, standalone) and WebGL from one codebase. Unity's Netcode for GameObjects supports pluggable transports, so the same server logic runs against Unity Transport for native clients and WebSocket for WebGL. The full netcode model - server authority, projectile sync, reconnection during gameplay, payout reconciliation - is in the multiplayer case study.
Edgegap orchestration
Multiplayer game servers run on Edgegap. The matchmaker design uses a single matchmaker profile
with string_equality attributes - MM_EQUALITY environment
variables are injected into each deployment and read at boot to pick the correct scene/map. Pre-warming is
handled by Server Browser scaling policies, not by multiplying matchmaker profiles per map. This is the
kind of design choice that's invisible until you've built it the wrong way once.
The orchestration mostly works. The one notable hiccup: scaling policies stopped firing one day. Manual deployments worked; policy-driven prewarming didn't. The ticket I filed:
Server Browser instance: 9328a3d6-8741-48ab-a1c9-72340c9a2a89 (Hobbyist, Newark NJ, ONLINE). Scaling policies are configured (e.g.
prewarm-daily-fishing,minimum_active_instances: 1). There are 0 registered instances so the monitor should be deploying. No deployments appear in the dashboard with prewarm tags. Manual deployments of app Nova versiondailyFishing-0.0.1work correctly and self-register. What is preventing scaling policies from triggering deployments?
It was a bug in their scaling-policy monitor. Edgegap shipped a fix in 0.0.7. When you depend on a third-party control plane, your job isn't just to call its API correctly - it's to instrument enough that when the control plane misbehaves, you can prove which part is broken. Half the value of that ticket was that it was reproducible and specific.
What I'd do differently
I'd bring feedback-driven, agile planning in earlier. The white-label, multi-brand requirement wasn't on
the table when I started Nova - it came out of customer feedback well into development - so brand resolution
got retrofitted into a boot sequence that had assumed a single operator. If I were starting over I'd treat
"this will ship as more than one brand" as a day-one assumption, build the BrandConfig
indirection in from the start, and run shorter feedback loops with the business so requirements that big
surface before the architecture has hardened around the opposite assumption.