← Selected Work
2024 - present Platform Architect & lead engineer

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.

Engine
Unity 6 (6000.3.12f1)
Targets
Android · WebGL · Standalone
Sub-games today
11 slots + fishing
White-label brands
3 · Nova · JUWA Vault · Game Vault
Install size
74 MB

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:

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
            
Runtime architecture

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:

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
            
Free-spin sentinel - bridging the gap between server-triggered free spins and round-ID allocation

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:

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 version dailyFishing-0.0.1 work 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.

← Back to Selected Work