85 bits

code; learn; share;

building an atomic bomberman clone, part 3: ownership hell

Tags = [ bomberman-series, rust, gamedev, multiplayer ]

In JavaScript, if two functions need the same object, you just... pass it. Nobody asks who owns it. Nobody cares. The garbage collector handles the mess, and you move on with your life.

Rust does not work this way.

This post is about the first week of writing Rust as someone who's spent most of their career in JavaScript — specifically, the ownership model and all the ways it made me feel like I was fighting the language before I understood it was protecting me.

the first wall: async move

I needed to spawn an async task for each WebSocket connection. In JS, this is a closure that captures whatever it needs from the surrounding scope. In Rust, it's an async move block — and move means what it says. The closure takes ownership of every variable it captures. The original scope can't use them anymore.

Here's where it got me: I was cloning a shared player map before a loop, then trying to move that clone into each spawned task.

let players_clone = Arc::clone(&players);

loop {
    let stream = listener.accept().await;
    tokio::spawn(async move {
        handle_connection(stream, players_clone).await;
    });
}

This compiles on the first iteration. On the second, the compiler yells at you — players_clone was moved into the first task. It's gone. You can't move the same thing twice.

The fix: clone inside the loop, so each task gets its own copy. And Arc::clone is cheap — it just increments a reference counter, it doesn't deep-copy the data behind it.

loop {
    let stream = listener.accept().await;
    let players_clone = Arc::clone(&players);
    tokio::spawn(async move {
        handle_connection(stream, players_clone).await;
    });
}

In JS, this problem doesn't exist. Closures share references freely, and the runtime sorts it out. In Rust, you have to think about who owns what, and the compiler won't let you pretend otherwise.

sharing state: Arc<Mutex<HashMap>>

In a Node.js server, shared state is just... a variable. You declare an object at the top of your file, and every request handler reads and writes to it. Single-threaded, no locks, no problem.

Rust makes you earn shared mutable state. My game server uses Tokio as its async runtime — think of it as the event loop you get for free in Node, except in Rust you choose and install one yourself. I have multiple async tasks — a connection handler per player and a central game loop — all needing access to the same player map. The pattern for this is Arc<Mutex<HashMap>>:

  • Arc (atomic reference counting) — lets multiple tasks hold a reference to the same data. Like a shared pointer that knows when everyone's done with it.
  • Mutex — a lock. Only one task can access the data at a time. You call .lock().await, do your work, and the lock releases when it goes out of scope. You need both because Arc lets everyone reach the data, but Mutex makes sure only one of them gets in at a time.
  • HashMap — the actual data. In this case, player ID to player state.

Every time I wanted to read or write a player, I had to lock the mutex first. Coming from JS where you just do players[id].x += speed, the ceremony felt heavy. But then I realized: in a concurrent system, JS doesn't protect you. If you had actual threads sharing a mutable object, you'd get data races. Rust just forces you to handle it upfront instead of discovering it in production.

everything is private

In JavaScript, everything is accessible by default. Export what you want, import what you need, and if you're lazy, just export everything.

Rust is the opposite. Everything is private by default. You have to explicitly mark things as pub to expose them. The module system works like this:

  • mod player; — declares that a module exists (like registering a file)
  • pub struct Player — makes the struct visible outside its module
  • use crate::player::Player; — imports it where you need it

The first time I split my server into modules, nothing compiled. Every struct, every function, every field — the compiler told me it was private. I went through and added pub everywhere until it worked, which felt like I was doing it wrong. I was. You're supposed to think about what should be public. The default is intentional.

the deadlock that taught me the most

Phase 3 introduced bombs and explosions. The game loop needed to read the map to calculate blast radius, then write to it to destroy blocks. Seemed straightforward — lock the map, read it, calculate, write, unlock.

Except I locked it twice. First an immutable read to calculate the blast, then a mutable write to destroy tiles. Tokio's mutex doesn't support re-entrancy — if you try to lock something you already hold, it doesn't error. It just waits. Forever.

The game would start, a bomb would explode, and everything would freeze. No error message. No crash. Just silence.

The fix was simple: take a single mut lock at the start of the tick and use it for both reads and writes. But finding the bug took longer than it should have, because "everything freezes silently" is not a helpful error message.

This was the moment Rust's ownership model clicked for me. The language didn't prevent this particular bug at compile time — deadlocks are a runtime problem. But the fact that I had to explicitly lock and unlock resources, that I had to think about who holds what and when, meant the bug was localized. I knew it was a locking issue because there was nowhere else it could be.

the rules that made it work

I didn't learn all this alone. I had an AI mentor — Claude — and we established a set of rules early on to make sure the learning actually stuck.

The core idea was "challenge-first": the AI explains new concepts and specs out a goal, then I write the code. No code dumps. If I'm stuck, we talk through it, but I'm the one typing. There's also a mandatory pause — the AI has to stop and wait for me to review before moving on. No steamrolling through steps I haven't digested yet.

After I write the code, we review it together. That's where I pick up Rust idioms I wouldn't find on my own — like using HashMap.retain() to filter in-place instead of building a new collection, or knowing when a local Vec is enough and Arc<Mutex> is overkill.

These rules sound obvious, but without them the default mode of AI-assisted coding is: you describe a problem, the AI writes the solution, you paste it in. You ship faster but you don't learn anything. For a project where the whole point is learning Rust, that's a trap.

The deadlock bug was a good test of this. The temptation was to paste the error (or lack of error — just a frozen game) and let the AI diagnose it. Instead, I talked through what I knew: the game freezes when a bomb explodes, explosions touch the map, the map is behind a mutex. That was enough to find it myself. The AI confirmed the fix, but the understanding was mine.

what I'd tell a JS dev starting Rust

After a week, here's what I wish someone had told me:

  • Stop thinking in references. In JS, everything is a reference and the GC handles lifetime. In Rust, every value has exactly one owner. When you need to share, you make explicit copies (clone) or use smart pointers (Arc).
  • The compiler is your pair programmer. Those error messages aren't obstacles — they're catching bugs you'd ship in JS and discover at 3am.
  • Not everything needs Arc<Mutex>. My first instinct was to wrap everything in shared state because that's how JS works — one global scope. But if only one task touches a piece of data, a local Vec is simpler and faster. Rust taught me to ask "who actually needs this?" instead of making everything globally accessible.
  • let ... else is your friend. Rust's version of early returns and guard clauses. Instead of nesting match inside match inside match, you write let Some(value) = expression else { continue; }; and keep going at the top level. Flattens deeply nested error handling into something readable.

next up

The server was running and the Rust was making sense. But on the client side, I had a different problem: how do you mount a 60fps imperative game renderer inside a declarative React app?

Next post: bridging React and PixiJS, and why everything I knew about state management was wrong for real-time rendering.