SN8KE: pre-release optimization and v1.0.0
- Unity
- Optimization
- Multiplayer
- WebGL
- Release

SN8KE is close to a first public build. This post is the release-readiness checkpoint: what's actually finished, and — the part worth writing up in detail — what changed on both the client and the server to make the game hold up once more than a couple of snakes are on screen at once.
No synthetic benchmarks here. Everything below is either a structural fix (the kind where the "before" case is obviously wrong once you see it) or a qualitative before/after from playtesting on a real Android phone. If you're expecting frame-time graphs, they're coming after the next round of stress tests — see the last section.
What's in
The core loop, the whole meta layer around it, and the operational bits that keep a live match alive when a phone drops Wi-Fi:
- Match: server-authoritative movement, merging, PvP collisions, food spawning, death/respawn, live leaderboard, kill feed, floating score popups, match results screen.
- Meta: login, shop, profile with equipped-skin preview, daily quests with live in-match progress, inventory ownership synced from the server's storage layer.
- Controls: mouse-and-keyboard on desktop, a proper on-screen joystick with boost-on-drag for touch, autodetected per device.
- Localization: four languages (English, Ukrainian, Spanish, Chinese) pulled from a published Google Sheet, covered in the previous post.
- Reconnect: if the WebSocket drops mid-match, a
ReconnectServicecatches it, throws up a blocking popup instead of leaving the screen frozen with no explanation, and re-joins the same match on success. If the match already timed out server-side while the connection was down, the player gets a clear message and lands back in the menu instead of staring at a popup with nothing to reconnect to.
That last one sounds like a small feature. It's the difference between "the game glitched" and "the game told me what happened" the first time someone's phone loses signal for ten seconds mid-round — which, on mobile, is not a rare event.
The optimization pass
This came out of a very ordinary playtest observation: framerate held steady around 60 on desktop no matter what, but dropped hard on Android once a head's value crossed into the low thousands — the same match, the same device, worse a few minutes in than at the start. Desktop not caring and mobile caring a lot is a specific kind of signal: it points at GPU submission cost, not simulation logic, because the simulation runs identically on both.
Client: GPU instancing on the snake material
Every block in a snake — head and tail — shares one material, and its color is set per-instance through a MaterialPropertyBlock rather than a unique material instance, specifically so blocks don't fork the shared material:
if (_renderer != null)
{
_renderer.GetPropertyBlock(_propBlock);
_propBlock.SetColor(BaseColorId, ViewPalette.ColorFor(value, fresh));
_renderer.SetPropertyBlock(_propBlock);
}
That's the right shape for GPU instancing — same mesh, same material, only a per-instance color — but the material's Enable GPU Instancing flag was off. With it off, each block is still a separate draw call regardless of how carefully the property block usage is written; the flag is what actually lets Unity batch them. Flipping it on is a one-line asset change, but it only pays off because the rendering path was already structured to not need a unique material per block in the first place.
Client: stop rendering after the round is over
MatchController.Tick() used to keep calling into the full render path — snapshot interpolation, per-snake position/tween/skin updates, leaderboard refresh — every single frame, including after the round had already ended and the results screen was up. The data wasn't changing; the work was happening anyway.
private void RenderSnapshot()
{
if (_roundEnded)
return;
if (!_match.HasSnapshot)
return;
...
Two lines. The kind of fix that's obvious in hindsight and invisible until you go looking for why a "finished" screen is still doing work.
Client: stop the boost UI from working when nobody's boosting
The boost stamina indicator follows the player's head in screen space and updates every frame — WorldToScreenPoint, a RectTransform reposition, a Slider.value write — regardless of whether it's actually visible. For most of a match, boost isn't held down, which means all of that ran at full cost while fully transparent and doing nothing anyone could see.
bool isBoosting = _inputHandler.IsBoosting;
bool isFading = _canvasGroup != null && _canvasGroup.alpha > 0f;
if (!isBoosting && !isFading)
return;
Same shape as the round-end fix: gate the expensive path on whether the result is observable at all.
Client: cap how far the camera is allowed to zoom out
This is the one that actually explained the "worse a few minutes in" symptom. The camera pulls back as the local head's value grows, so a bigger snake sees more of the arena — which also means rendering more of everything else on it at once: more snakes, more food, more blocks in frame simultaneously. That's not a fixed cost; it grows with how far the zoom has pulled back, and it grows exactly in the direction that made the framerate drop line up with reaching a high-value head rather than with match duration on its own.
[SerializeField] private float _maxHeadScaleForZoom = 2.5f;
_targetHeadScale = Mathf.Min(headScale, _maxHeadScaleForZoom);
The zoom still grows smoothly with head size up to that point — this isn't a visual regression, it's a ceiling on how much simultaneous on-screen content the camera is allowed to ask the GPU to render. Head values keep climbing past that point; the camera just stops following them out.
Server: a spatial grid instead of checking every pair
None of the client-side fixes above touch what the server is doing, and the server has its own version of the same problem: collision detection — snake-vs-food, head-vs-head, tail bites — used to check every relevant pair every tick, which scales quadratically as more snakes and food enter the arena.
const GRID_CELL_SIZE = 4;
class SpatialGrid<T> {
private cells = new Map<string, T[]>();
// insert by position, query only nearby cells
}
Food lookups, head-to-head checks, and tail-bite checks each get their own grid, built fresh once per tick and queried per snake against only the handful of cells actually near it — instead of every snake checking against every other entity on the map regardless of distance.
Server: don't send the tail, don't send everyone's food
Two things that were already true before this pass, worth naming here because they're the reason bandwidth wasn't the bottleneck in the first place: tail positions are never transmitted — the client reconstructs them from the head's movement history, so a 40-block snake costs the same on the wire as a 2-block one. And food isn't broadcast globally; each client's snapshot only includes food within a radius around their own head, which grows with their camera zoom (the same zoom the client-side cap above now limits). Cosmetic metadata — face, shape, display name — goes out on its own rare OpMeta message on join or equip change, not packed into every snapshot alongside position data that actually changes 20 times a second.
None of that was new work for this pass, but it's why the optimization effort could stay focused on rendering and collision cost specifically, instead of also fighting a bandwidth problem at the same time.
What's still on the list
This pass fixed what was reproducible in ordinary solo and small-group playtesting. It didn't fix things that only show up under load nobody's generated yet:
- Client-side collision detection has no spatial grid. The server got one; the C# simulation core — the same logic ported line-for-line to the server, see the first post — still checks every snake pair directly. There's a
TODO: add a broadphase grid once snake count reaches 20-50sitting in that file right now. It hasn't mattered yet because nothing in current testing has put 20+ snakes in one arena at once — which is exactly the kind of thing you don't know is a problem until a stress test tells you it is. - The per-block value label is a separate TMP text object per block, which doesn't batch the way the mesh now does with instancing on. It's gated to only update its text when the value actually changes, so it's not the worst offender, but it's still one more render target per block that a longer snake multiplies.
- The wire protocol is JSON. Readable, easy to debug, and explicitly flagged in the protocol file as a placeholder for a future binary format with quantization. Fine at current player counts; not something to leave unexamined once real concurrent load shows up.
The plan is to leave these alone until an actual play/stress test says otherwise — profiling problems that don't exist yet tends to optimize the wrong thing. The framerate issue that started this whole pass was found the same way: by playing the game long enough for something specific to break, not by staring at a profiler looking for numbers that seemed high.