MCA Hackathon - A Bot Battle Arena
The thing I missed most after university was the hackathon. Not the sleep deprivation, but the shape of it: a weekend, a small group, a dumb idea, and a hard deadline to make it real. So a friend and I decided to run our own - two days, our friend group, remote. What started as a casual “let’s build something this weekend” turned into us organizing a tiny virtual hackathon for everyone.
The hard part was the format. The group is spread across cities, everyone has a different favourite language, and skill levels run the full range. Whatever we built had to work remotely, let people actually write code rather than slides, not care which language they reached for, stay simple enough to start in five minutes, and - crucially - be competitive. The thing that ticks all five boxes is a game where each person writes a bot to play it. You control nothing by hand; you write the brain, connect it, and watch your code fight everyone else’s.
My friend Felix Prünte wrote the game server in Rust. I built the visualization that let us all watch the matches. We designed the game itself together.
The game
It is a turn-based shooter on a 30 by 30 grid. Each bot controls one player, and every tick it gets to pick exactly one action:
- move one tile (
UP,DOWN,LEFT,RIGHT), TURNto face a heading from 0 to 360 degrees,- or
SHOOTa projectile in the direction it is currently facing.
Players start with 100 health. A hit takes off 20, so five clean hits eliminate you. Projectiles are not hitscan - a shot travels six tiles per tick in a straight line, which means a bullet is something you can see coming and step out of. The last bot standing wins; if everyone is too good at dodging, the match runs to a hard cap of 5000 ticks and ends in a draw (foreshadowing).
Because it is turn-based, nobody acts simultaneously: the server collects everyone’s action for a tick, applies them together, moves the projectiles, resolves hits, and broadcasts the new state. Then it does it again.
Writing a bot
The “any language” requirement is the reason this is a networked game and not a library. There is no SDK to import. A bot is just a WebSocket client that speaks a small JSON protocol, so you can write one in whatever you already have open.
You connect by encoding who you are into the URL:
ws://<host>/lobby/{lobby}?clientType=PLAYER&username=<name>
Each tick the server pushes you the full state - every player’s position, rotation, health, and every projectile in flight, tagged with a tick id:
{
"tick": "0fb3b3d2-fc1d-40dc-a2cf-24baaef6ddb1",
"players": [
{ "id": "113b...", "name": "my-bot", "x": 14, "y": 14,
"rotation": 0, "health": 100, "last_action_success": true }
],
"entities": [
{ "id": "aafc...", "x": 14, "y": 26, "direction": 0, "travel_distance": 6 }
],
"spectators": 1
}
You reply with one action, echoing back the tick you are responding to so the server can ignore stale or duplicate messages:
{ "tick": "0fb3b3d2-...", "action": "SHOOT" }
{ "tick": "0fb3b3d2-...", "action": "TURN", "degrees": "273" }
That is the entire contract. To prove how low the barrier was, the simplest possible bot just cycles through a fixed list of moves - it walks a little square forever, completely ignoring the game state:
const fixedPayloads = [
{ action: "RIGHT" }, { action: "TURN", degrees: 0 },
{ action: "DOWN" }, { action: "TURN", degrees: 270 },
{ action: "LEFT" }, { action: "UP" },
];
connection.on("message", (msg) => {
const state = JSON.parse(msg.utf8Data);
connection.send(JSON.stringify({
tick: state.tick,
...fixedPayloads[i++ % fixedPayloads.length],
}));
});
We shipped this “circle walker” as a reference client in Node, Python, Elixir, and plain HTML, so the first thing anyone did was clone the one in their language, watch their square wander around, and start replacing the fixed list with something that actually aims. Lobbies kept everyone out of each other’s way: you could spin up your own to develop and test against the dummies, and there was no auth anywhere because the whole thing assumed a trusted room of friends.
One tick at a time
The server’s job each tick is to be fair about timing. It cannot wait forever for a slow bot, but it also should not waste half a second when everyone has already answered. So the tick loop does both: it schedules a deferred update 500 ms out, and every time a player’s action arrives it checks whether all of them are now in. If so, it cancels the pending timer and advances immediately.
sequenceDiagram participant B as Bots participant S as Rust server participant V as React spectators S->>B: game state (tick id, players, projectiles) S->>V: same game state B->>S: one action, tagged with the tick id Note over S: advance as soon as every bot replies, else after 500 ms S->>S: apply actions, move projectiles, resolve hits
In Rust that “advance early” is a JoinHandle to the scheduled task that gets aborted the moment the last action lands:
if clients_that_answered.is_superset(&expected_client_addresses) {
if let Some(handle) = db.open_tick_handles.get(&expected_tick) {
handle.abort(); // cancel the 500 ms timer, run the tick now
}
ping_clients_in_lobby(expected_tick, lobby.id, /* ... */).await;
}
The result is that a lobby full of fast bots runs as quickly as they can think, and one slow bot only ever costs that one tick its 500 ms.
The interesting bit of the actual simulation is hit detection. A projectile moves six tiles along its heading, and a player is “hit” if they were standing on any tile the projectile crossed on the way. That is a line-rasterization problem - the same one a screen does when it draws a diagonal line of pixels - so the server walks the projectile’s path with a midpoint line algorithm and checks each crossed tile against every player’s position (skipping the shooter, so you cannot kill yourself). All of this is Felix’s Rust work; he has a great writeup on building it as someone new to the language, including wrestling with async and threads, and the upshot that the server was never the bottleneck - browser clients saw round trips around 20 ms, Node bots 0 to 1 ms.
Watching it happen
A bot battle you cannot see is just log spam, so my half was the spectator view. The neat part is that it needed no special access: the visualization is itself just a client that connects to the same WebSocket as clientType=SPECTATOR, gets the same per-tick state everyone else does, and draws it. Anyone could open any lobby and watch it live.
The arena renders as an SVG grid. Each player is a coloured dot with a little line sticking out to show its facing and a name label above it; projectiles are small arrowheads. Everything animates between ticks with Framer Motion, so movement reads as motion instead of teleporting. Two things turned out to matter more than I expected:
- Telegraphed trajectories. For every projectile I draw a solid line for where it travels next tick, plus a faint line extending its heading further across the board. Suddenly you could read the fight - see the lanes of fire and watch bots thread between them - instead of guessing why someone died.
- A tick countdown. A progress bar drains over each tick (keyed to the tick id so it resets cleanly), which makes the turn-based rhythm visible and tells you whether the lobby is actually live.

Down the side, a card per player shows a health bar, position, and rotation, and a lobby list on the start page polls the management API so you can see which matches are running, who is in them, and how many people are watching before you jump in. The management API is a tiny REST surface (create a lobby, list lobbies, flip a lobby between PENDING, RUNNING, and FINISHED) that let us run the tournament rounds without touching the server.

One small honesty bug lived in here: the client draws a projectile’s path as a clean straight line, but the server decides hits on the rasterized tiles it actually crosses. Those two are almost the same and not quite, so dodging by eye was a hair less reliable than it looked. Good enough for a weekend, annoying if you were the one who got clipped by a bullet that visually missed.
What actually happened
The bots were more fun than the game. Most people converged on the same instinct - dodging beats shooting - and built increasingly paranoid evasion logic. One person went fully off the deep end and trained a convolutional neural network with an evolutionary algorithm to pilot their bot. It was the coolest entry by a mile and it lost, comfortably, to a hundred lines of “if a bullet is on my row, take a step.”
The reason it lost is the same reason the day exposed a real design flaw: defense was just too strong. Projectiles were slow enough and the arena open enough that a competent dodger could avoid everything forever, so well-matched bots never resolved - they danced until the 5000-tick cap and drew. We had deliberately not playtested beforehand, on the theory that nobody should get an unfair head start, which is exactly how a balance problem like this stays hidden until it is live in front of everyone.
The fixes write themselves, and they are all the things a real arena game does: shrink the playable area over time so there is nowhere left to run, spawn powerups to pull bots into the open, hand out a few limited superpowers (double damage, a multi-tile dash) to break stalemates. Next time I would also playtest with throwaway bots first and just accept that the organizers see the game a little early. A draw is a worse outcome than a small spoiler.
It did exactly what we wanted, though: a remote weekend where six or seven friends each wrote something in their own language, plugged it into a shared world, and got to watch their code fight. The code is on GitHub, and Felix’s companion post covers the Rust server side in depth.