← All multiplayer Lenses
Strategy · .io battler Playable build · internal

Mob Siege

Mob Siege is a top-down army battler. You steer your own 3D Bitmoji with a joystick while a ring of marching soldiers orbits you. That ring is your score, your health and your weapon all at once. Walk over neutral soldiers to recruit them. Walk your ring into someone else's and both armies start dying at five units a second until one of you breaks.

Sixteen towers sit across the island. Standing in a tower's radius trades your soldiers against its garrison one for one until it flips to you, after which it regrows and starts feeding you reinforcements. It is the most systemically complex of the three games, and it was only buildable because the networking, the procedural rendering pipeline and the performance discipline were already solved.

11,459
Lines of TypeScript
35
Gameplay scripts
16
Capturable towers
0
TODO markers in the codebase
Gameplay

The game

  1. 01

    Steer your Bitmoji with the joystick. A ring of soldiers orbits you, and its size is your score.

  2. 02

    Walk over neutral soldiers scattered across the island to recruit them into your ring.

  3. 03

    Push your ring into another player's to engage. Both armies burn five units per second until someone breaks off or dies.

  4. 04

    Stand inside a tower's radius to trade soldiers against its garrison one for one. At zero it flips to you.

  5. 05

    Held towers regrow to a garrison of 30, then send you a free soldier every three seconds.

Controls

Joystick movement at 250 cm/s. There is no attack button; engaging is purely positional, which means the whole game is played through where you put your body.

Scoring

Live army size, broadcast on a half-second heartbeat and ranked on a leaderboard with Bitmoji portraits. Milestone banners fire at 15, 30, 50, 80, 120, 180 and 250 soldiers with escalating emoji tiers.

Death & respawn

Your army reaching zero while engaged kills you. Your Bitmoji plays a fall animation, a one-second grayscale death cam runs, your army scatters as up to twenty pickups for everyone else, and a stats card shows your peak army, recruits, towers captured and rank. Respawn is automatic after ten seconds with a 3.5-second grace period, broadcast network-wide, so every client sees you pulse while you are untouchable.

Rounds

Endless, with the same hop-on/hop-off rationale as Hex. The win-condition machinery is fully built and deliberately dormant.

In the camera
A live session: bots and players ranked together, with a kill posted to the shared event feed

A live session: bots and players ranked together, with a kill posted to the shared event feed

The death card, drawn at runtime from supersampled SDF shapes rather than imported as art

The death card, drawn at runtime from supersampled SDF shapes rather than imported as art

Tiles, trees and marching soldiers, every one of them generated by MeshBuilder at runtime

Tiles, trees and marching soldiers, every one of them generated by MeshBuilder at runtime

Networking

Multiplayer architecture

The code calls it the proven Hex split. Each client is authoritative over itself (its own pickups, its own losses, its own death) and the host is authoritative over the world. Both sides of a fight burn units on their own client and reconcile on the score heartbeat, so counts can never diverge. Every host duty is written re-entrant per tick, which makes host migration a non-event: the new host simply resumes from the last state it received.

Client-authoritative
Every client owns itself
  • Its own recruit pickups
  • Its own unit losses in combat
  • Its own death and grace period
  • Its own hero position
Host-authoritative
One host owns the world
  • Bot AI simulation and bot deaths
  • Tower ownership, garrison and gifting
  • Neutral soldier top-up on the field
  • The game state machine
What travels over the wire
ChannelOwnerPayloadCadence
scoreLeaderboardOwn army count0.5 s, every client
twrTowerManagerFull state of all 16 towers: owner and garrison0.5 s, host
giftTowerManagerAggregated soldier grants, serial-deduped1 s, host
botStateBotManagerBot position, heading and score0.15 s, host
koCombatSystemKill notification to the killerOn kill
grace / deathSnakeBodyManagerSpawn invulnerability, harmless-corpse markOn change
deathDropPointsSpawnerEvery drop position from one death in one packetOn death
currentStateGameStatesSynchronizerState machine index via StoragePropertyHost-gated
Swipe the table →
Full state beats deltas, when the state is small

Sixteen towers fit in 200 to 350 bytes. So rather than tracking what changed, the host rebroadcasts every tower's owner and garrison twice a second whether anything moved or not. Late joiners and lost packets converge within one beat, and there is no delta bookkeeping that can corrupt. It is the opposite call from Hex's territory sync, made deliberately because the state set is three orders of magnitude smaller.

Self-healing by construction: late joiners and lost packets converge within one beat, and there is no delta bookkeeping to corrupt.Project source
Eighty soldiers, zero packets

An army can be eighty rendered units, each with a position, a facing and a walk frame. None of it is networked. Every client rebuilds every army from the hero transform and the synced count using deterministic ring maths seeded from the player's colour rather than a random number, so all clients independently compute an identical formation. Colours themselves are assigned by sorting connection ids, meaning everyone agrees on who is which colour without a single negotiation packet.

Fights that cannot desync

Both sides of an engagement burn their own units on their own client, then reconcile on the half-second score heartbeat. Engagement uses hysteresis, 60 cm to engage and 160 cm to disengage, so a fight on the boundary does not flicker on and off, and no client has to ask another whether it is currently in combat.

Under the hood

Engineering highlights

Everything you can see is generated

There is no imported 3D art in the gameplay at all. 35 MeshBuilder call sites produce every soldier, tower, tree and tile in the game.

  • ·The soldier is built at exactly 1.0 units tall so world scale equals height in centimetres, with a white-toned vertex-colour bake that a runtime tint turns into any army's colour, so one mesh serves every player
  • ·Its four-frame marching cycle is produced by shearing the legs into three shared meshes that units swap between: an assignment, not an upload, so the whole walk animation costs three mesh builds total
  • ·The island is roughly 1,880 tiles as chunked vertex-coloured meshes, 192 per chunk, so a repaint only re-uploads its own chunk
  • ·Towers come in four archetypes chosen deterministically from their grid cell, with garrison bars, a 48-segment trade-radius ring and clumped pine forests
A bot AI that knows when to leave

The bots run a five-mode goal stack (cruise, recruit, attack, flee and capture) with real thresholds rather than randomness: flee armies 1.25× your size within nine metres, attack armies below 0.77× within eleven, capture towers you can afford within twenty-one, otherwise recruit, weighted by how clustered the nearby pickups are.

  • ·Steering decisions are staggered at 0.1 s so bots never all think in the same frame
  • ·Non-host clients dead-reckon between the 0.15 s state packets rather than waiting for them
  • ·Population is adaptive: bots fill the session up to five participants and get parked as real players join, shedding simulation cost at exactly the moment a full lobby needs the headroom
  • ·Parked bots are detected by packet staleness rather than an explicit despawn message, so a dropped packet cannot leave a ghost behind
Interface painted pixel by pixel

The death screen, leaderboard rows, loading screen and mute button are all drawn at runtime into procedural textures using 2×2 supersampled signed-distance painters: rounded cards, rings, gradients, trophies and a point-in-polygon crown laid out on a 24×18 design grid. Every shape is baked once and shared across instances.

Shaders, light and shadow

Custom shader graphs cover the tint pipeline, cartoon water with animated bands and shore foam, a shadow catcher, and a colour grade, alongside a GPU crop that masks Bitmoji portraits into circles.

  • ·The vertex-coloured floor is unlit and therefore cannot receive shadows, so an invisible shadow-receiver quad sits 0.25 cm above the tiles
  • ·The sun is deliberately placed low, so shadow length lands at about 0.87× height, because a steeper sun hides a tree's shadow completely under its own canopy
  • ·Shadow configuration is authored in the scene rather than set at runtime, where it applies inconsistently
Audio built around two hard limits

Every sound is algorithmically generated: a music bed plus capture, claim, countdown, death, kill, respawn and UI variants. The manager exists because of two measured engine constraints.

  • ·AudioComponent has no pitch control and plays one sound at a time, so playback runs a round-robin voice pool with two voices for rapid-fire sounds and pre-baked variant files for escalation
  • ·Calling play() costs a probe-measured 15–19 ms, so one-shots queue and at most one plays per frame, with real sounds jumping ahead of ambient ticks. One frame of latency is inaudible
Performance as a design constraint

PerfProbe carried over from Hex and stayed in use. Hot paths are allocation-free by construction, and almost every system runs on an explicit per-frame budget.

  • ·Collision circles and food positions are snapshot once per frame. Rebuilding them per query allocated tens of thousands of short-lived objects a second, and the resulting GC pauses showed up as periodic movement hitches
  • ·Budgets: 80 rendered units with the true count always shown on the label, 3 new units per update, 10 rotations per frame, 7 drops per frame, 4 network spawns per tick
  • ·The 1–2 second freeze on entering the playing state is hidden behind a black fade by intercepting the state switch, which also discards the freeze frame's oversized delta time
No physics, on purpose

The field border and every tower and tree push the player out by clamping position directly rather than using colliders. A character controller pressed against a static collider receives vertical depenetration nudges, which the follow camera turns into rapid zoom jitter. Obstacle pushback runs before the border clamp so it can never shove a player off the map.

Build log

What broke, and why

Every entry below is a real failure that shaped the code. They are documented in the project source itself, next to the fix.

Sixteen towers, and no way to sync them cheaply
Problem

Tower ownership and garrison change constantly and matter to everyone. Delta-syncing them meant tracking what each client had already seen, and any gap left a client showing a tower as the wrong colour indefinitely.

Fix

Rebroadcast all sixteen towers in full twice a second regardless of change, at 200 to 350 bytes. Every error self-corrects within half a second, and the bookkeeping disappears entirely. The opposite of the Hex approach, chosen because the state is small enough to make it the simpler correct answer.

A death that flooded the session
Problem

Dying scattered up to twenty soldiers as networked pickups. Creating them in one frame flooded the realtime store and dropped the connection; spreading them over seconds looked broken.

Fix

One broadcast containing every drop position, turned into purely local objects on each client, with instantiation capped at seven per frame. Spread across three frames it reads as instant.

The white death screen
Problem

The death screen scavenged an existing material from the scene to draw with. On one build that handed it an additive glow clone and the entire card blew out to solid white.

Fix

Clone the material passed in explicitly, never one found by searching the scene.

Players falling through the map
Problem

Spawning at or below the settle height sometimes started the capsule underneath the ground plane, and the player dropped straight through the island. The scene's authored spawn point sat so high the Bitmoji visibly fell out of the sky instead.

Fix

A fixed spawn height of 15 cm above ground, plus a safe-spawn picker that places new players clear of existing fights.

Scene values silently overriding the code
Problem

Tuning constants exposed as script inputs get serialised into the scene. From then on the scene's stored value wins and editing the code changes nothing, a failure that looks exactly like the code not running.

Fix

Every tuning constant that matters lives as a private static readonly rather than an input. Called out twice in the codebase, in the two places it had already cost time.

Heads that sit in different places
Problem

Anything anchored to a player's head drifted visually off target for remote players only. Local heads report the capsule centre while remote heads report the ground root.

Fix

Anchor to a fixed height instead of the reported head transform, so local and remote render identically.

By the numbers
35
Gameplay scripts
11,459
Lines in Assets/Scripts
35
MeshBuilder call sites
5.22.0
Lens Studio
~130
Scene objects
~1,880
Floor tiles generated
Core scripts
SnakeBodyManager.ts 2,269 lines Master orchestrator: hero discovery, colour assignment, formations, grace, death and camera zoom
TowerManager.ts 1,017 lines The 16 towers: layout, garrison, keepalive sync, geometry and obstacle pushback
BotManager.ts 956 lines Five-mode goal-stack AI with staggered steering and adaptive population
PointsSpawner.ts 647 lines Neutral recruit supply, local score and the one-packet death-drop system
DeathScreen.ts 646 lines Runtime screen-space death modal with rank pill and respawn ring
Leaderboard.ts 553 lines Roster joined with the score heartbeat, including bots
HexFloorRenderer.ts 551 lines The island as chunked vertex-coloured meshes, water, cliffs and shadow catcher
ArmyFormation.ts 505 lines The orbiting soldier ring: slot maths, eased motion, obstacle dodging, marching
CombatSystem.ts 330 lines Army attrition with engagement hysteresis, tower burn and kill notification
AudioManager.ts 296 lines Voice-pool SFX hub with a one-play-per-frame queue
PerfProbe.ts 84 lines Frame-time attribution profiler, zero-cost when disabled
Packages
ConnectedFrameworkBitmoji Player PackageSimpleVertexBaseColorOcean Water MaterialBox BlurBitmoji 3DCharacter ControllerCamera ControllerJoystickEventFeedPresenceIndicatorGameManagerSyncTweenTweenUI Button
Project details
Platform
Snapchat in-camera Lens
Clients
Mobile · Camera Kit
Contexts
Live camera · Reply camera · Video chat
Lens Studio
5.22.0
Tracking
None (synthetic 3D world under a fixed −65° camera)
Session
Remote / matchmade
Players
2–4 matchmade
Bots
Up to 5 total participants, adaptive
Offline
Mocked singleplayer session