Snake Battle Royale: High-Frequency Game Server optimization project

IPC,game logic setup and low level fruits

Blog image

The snake server

I've been working on a high-frequency game server optimization project, with a massive multiplayer snake battle royale as the test scenario. The point is to learn and experiment with low-level optimization techniques in Rust by pushing tick rates as high as they'll go.

The target is 200,000+ ticks per second (5µs per tick) with 1000+ concurrent snakes.

The game

1000+ snakes move at the same time on one shared 2D grid. They collide and eat food, and all of it has to stay real-time, with sub-millisecond tick processing.

The architecture is intentionally simple, but performance-critical:

Making it fast

I know this level of optimization might seem excessive for a snake game. In the real world, bottlenecks are usually in rendering, networking, or other higher-level stuff. But here I have the privilege to ignore the big picture and focus purely on shaving every possible microsecond from the game tick loop. This is my playground for exploring performance optimization techniques that I can't justify in my day job.

Low hanging fruit, rebuilding the grid every tick

My first optimization was just obvious, stop recreating the grid every tick:

That one change made a massive difference:

But it also meant managing the grid by hand, which is a bug magnet.

Manual grid updates were a nightmare

With incremental updates, every state change had to be followed by the matching grid update:

It worked, but small changes could easily break grid consistency, and maintaining the code was turning into a nightmare. I needed a better model.

State Criterion bench

Options I considered

A few ideas came up:

Event-driven architecture (rejected)

Use events to decouple state changes from grid updates.

It didn't fit, because snake movement is inherently order-dependent. One snake must move first, then the others operate on the updated state, and events don't handle that sequential dependency well.

Type-level invariants with phantom data (rejected)

Use Rust's type system to enforce grid consistency at compile time. That would mean significant refactoring and complex type machinery, so no.

Smart wrapper types with invariants (the one I went with)

Wrap the data structures in types that handle grid updates automatically. That keeps all the grid logic in one place and the code clean, without costing performance.

Building the wrappers

So I wrote GridAwareSnake and GridAwareApple. Here's the snake one:

pub struct GridAwareSnake {
    snake: Snake,
}

impl GridAwareSnake {
    pub fn move_forward(&mut self, grid: &mut Grid) -> bool {
        let new_head = self.snake.calculate_new_head();
        
        // Check collisions BEFORE moving
        if grid.get_cell(&new_head) == Cell::Snake {
            self.snake.is_alive = false;
            return false;
        }
        
        // Clear old tail, move snake, update new head
        if let Some(tail) = self.snake.body.back() {
            grid.set_cell(*tail, Cell::Empty);
        }
        self.snake.move_forward();
        if let Some(head) = self.snake.body.front() {
            grid.set_cell(*head, Cell::Snake);
        }
        true
    }
}

Raw pointers and memory safety

My initial implementation actually stored a raw pointer (*mut Grid) in the wrapper types:

pub struct GridAwareSnake {
    snake: Snake,
    grid: *mut Grid, // ❌ Dangling pointer after grid moves
}

That broke. GameState::random() created the grid locally and then moved it, so the raw pointers became dangling and I got segmentation faults.

So I switched to passing an explicit grid reference:

pub fn move_forward(&mut self, grid: &mut Grid) -> bool {
    // ✅ Safe, explicit dependency
}

The wrappers made it faster

With the wrappers in, tick time went from 8.22 μs to 4.56 μs, another ~1.7x.

State Criterion bench

This surprised me. I expected the wrapper approach to be slower, because of:

Why the wrappers are faster

The speedup probably comes from four things.

Better control flow

The old code moved first and checked for collisions after:

snake.move_forward();
if self.grid.get_cell(head) == Cell::Snake {
    snake.is_alive = false;
    continue; // Dead snake still processes rest of loop
}

The wrapper checks before moving and exits early:

if grid.get_cell(&new_head) == Cell::Snake {
    self.snake.is_alive = false;
    return false; // Early exit, no further processing
}

Improved apple consumption logic

The old approach checked apple consumption after movement. The wrapper checks it before, with a will_eat_apple flag.

Compiler optimizations

This one is more speculative. The wrapper structure might let the compiler optimize better through:

Less work for dead snakes

Dead snakes return early and skip the rest of the processing.

Where the numbers ended up

Every step so far:

That's ~11x faster in total, and the code is cleaner and easier to maintain than where I started.

What's next

We haven't even touched any of the really juicy low-level stuff yet.

But for now, I'm satisfied with the journey from 51.71 μs to 4.56 μs.