GateQuake, a 36-player arena with a 10,000-tile floor that collapses under you

Been building this for a while. The floor is the part worth writing up here, so that’s most of what follows.

https://gatequake.com (free practice mode, no signup, no wallet)

The setup

A 100×100 grid of 3.5-unit tiles, so 10,000 of them, 350 units a side. Up to 36 players per match. Every so often the arena quakes: the outer ring drops into the void and random interior tiles go with it. The floor only ever gets smaller, which means the match compresses itself without needing a shrinking-circle overlay on top.

Rendering the floor

Two InstancedMeshes, and that’s the entire floor:

  • base: the tile bodies. BoxGeometry with per-face UV wrapping so the top, sides and bottom all read correctly once a tile tips into view during a drop.
  • warning: an amber emissive overlay that pulses on tiles the server has already marked as doomed.

Two draw calls for 10,000 tiles. Tile state lives in a pair of Uint8Arrays (solid / dropping) rather than per-tile objects, and only tiles with an animation in flight go into a Map. The per-frame update() early-outs entirely when that map is empty, which it is most of the time.

Destroying a tile doesn’t remove it from the instance buffer, it writes scale = 0 into the matrix. Cheaper than repacking, and it keeps the instance index stable for the whole match so the index can double as the tile id.

The drop animation is 0.9s: ease-in cubic (t * t) driving both a shrink and a 6-unit sink, so tiles start slow and accelerate away rather than dropping linearly. Dust is a separate pooled InstancedMesh.

The z-fighting trap, which is the bit I’d warn people about

There used to be a third InstancedMesh. A thin “seam” quad sitting 1mm (0.001u) above each tile, to give every tile a subtle silver rim.

At eye level it looked fine. From the upper walkways, looking down at a steep angle across 350 units of arena, the depth buffer ran out of precision and it became black flicker stripes crawling over the floor. Obvious in hindsight: 0.001u is nothing once you’re that deep into the depth range, and a steep grazing angle is the worst case for it.

Two things fixed it:

  • the surviving warning overlay sits at y = 0.025, not 0.001, an offset that survives the precision loss at distance
  • it also enables polygonOffset on its material, so it never competes for depth regardless of camera distance

The seam mesh itself was deleted rather than fixed. It was 10,000 transparent quads of overdraw for a tint that nobody has once mentioned missing.

Why the server owns all of it

Worth being upfront, since it explains the architecture rather than being incidental to it: this is played for real stakes.

The client sends input only, a keys bitmask plus yaw, pitch and a sequence number. Everything else, positions, damage, and which tiles still exist, is simulated server-side at a fixed 30Hz and broadcast. The client predicts locally and reconciles against the authoritative state; remote players are interpolated between snapshots.

The practical upshot is that a modified client can’t fake a hit, teleport, or treat a tile as solid after the server has dropped it. It is slower and more expensive than trusting the client, and it is not something you can retrofit once anything is riding on the result.

No mobile yet

Worth saying before anyone asks, because two people already have. Aiming is on pointer lock, which mobile browsers do not implement, so there is no quick shim: it needs a real touch layer, virtual stick and drag to look.

The saving grace is that melee is far more forgiving than a shooter. Hits resolve against a cone at 1.6 to 3.2 units of range, so a thumb is accurate enough, and the actual skill is movement and reading the floor rather than precise aim. I am starting on it now. If you have opinions about touch controls for something this twitchy I would like to hear them.

The open question

The thing I haven’t solved nicely. When a big quake drops several hundred tiles at once, the full 10,000-instance matrix buffer re-uploads in that frame, roughly 640KB, and you can feel it.

Two directions I’ve looked at: addUpdateRange on the instance matrix for partial uploads, though the dropped tiles are scattered rather than contiguous so the ranges fragment badly; or splitting the floor into chunked InstancedMeshes (say 10×10 chunks of 100 tiles) so only the chunks containing a dropped tile re-upload, at the cost of 100 draw calls instead of one.

Has anyone landed on something better for scattered partial instance updates at this scale? Happy to be told I’m holding it wrong.