If you have ever looked into pathfinding or emergent behavior, you are probably familiar with the Ant Colony Optimization (ACO) algorithm. It is elegant in theory, but I wanted to test its capabilities under serious load, with a sense of ground reality. I didn't just want a sterile, single-player script. I wanted a mass-multiplayer environment.
Enter Forage: a networked simulation where thousands of players manage their own nests on a shared grid, pushing the engine to simulate millions of ants simultaneously.
Moving from a basic algorithm to a distributed MMO-style simulator means standard programming patterns break down entirely. Here is how I architected the core engine, the multi-threaded server, and the WASM client to handle the swarm.
Scrapping OOP for Data-Oriented Design
If you create an Ant struct with an x, y, state, and nest_id, and then throw a million of them into a standard array, your CPU cache will hate you. Object-Oriented Design dies at this scale due to cache misses.
Instead, the core engine uses strict Data-Oriented Design (DOD). Everything is pre-allocated upfront; there are zero runtime memory allocations. The state is split into tightly packed pools:
- Ant Pool: Flat arrays storing states (searching vs. returning) and
nest_ids. Crucially, positions are not stored asxandycoordinates. They are stored as a single, flattened 1D index of the map tiles. This halves the memory required for tracking positions and maps directly to the arrays without math overhead. We also maintain anant_bitboard, where each bit represents a tile on the map, updating every tick to track physical presence. - Pheromone & Food Pools: Arrays storing quantities and strengths per tile, mapped to 32x32 chunks. To avoid iterating over dead space, the system uses
active_chunkslists andchunk_flagsfor O(1) lookups. - Nest Pool: Tracks active players, nest coordinates, and a
free_listto instantly recycle abandoned nests for new connections.
By ensuring our map width and chunk sizes are powers of two, the engine relies almost entirely on fast bitwise operations rather than expensive modulo arithmetic.
The Tick: Lifecycle of a Swarm
When the engine ticks, it grabs mutable references to all pools and processes the active ants sequentially.
If an ant is searching for food, it looks at its 8 neighboring tiles. We run the ACO logic here: the base weight of a tile is 1 + pheromone_strength. We randomly choose a number between 0 and the total weight across all neighbors—a classic roulette wheel selection—and move the ant's flattened index.
Once it finds food, its state flips. It calculates the nearest path back to its nest. As it walks, it drops pheromones (increasing the tile strength by 10). Crucially, evaporation happens globally. A separate function iterates over active_chunks, decrementing pheromone strengths. If a chunk hits 0, it goes dormant.
The Server Architecture: Crossing the Async Barrier
Simulating a million ants on a single thread is one thing. Broadcasting them to 3,000 players over WebSockets requires carefully isolating the heavy lifting from the async runtime.
The server is built with Tokio and Axum. If I ran the core engine inside a Tokio async task, it would either stall the async executors or the network I/O would drag down the simulation tick rate.
Instead, the Core Engine runs on a dedicated OS thread. It communicates with the Tokio workers via standard mpsc channels.
- Incoming: When Axum upgrades a client to a WebSocket, it listens for commands (like
SpawnFoodorAddPlayer) and ships them across the channel to the engine. - Outgoing: The server maintains a
StreamMapto track broadcast receivers for chunks currently in a client's viewport. After the engine completes a tick, it iterates over every chunk in thechunk_broadcastsvector. If a chunk has active listeners, the engine generates a Delta packet and blasts it across the channel.
The Tokio async loop simply takes these broadcasted bytes and flushes them to the respective WebSockets, keeping the network I/O completely decoupled from the physics step.
The Network Protocol: Bitboards and Deltas
To minimize bandwidth, the server relies heavily on Deltas and Bitboards, serialized cleanly via wincode.
A 32x32 chunk contains 1,024 tiles. We can represent the physical presence of ants in a chunk using exactly 16 u64 integers (16 * 64 = 1,024 bits). That is incredibly dense. When a client moves its camera, it requests a ChunkSnapshot (containing the full state of the chunk). After that, it only receives ChunkDelta packets containing the updated bitboards.
The Client Architecture: WASM and Zero-Copy Rendering
The client is written in Rust, compiled to WebAssembly via wasm-bindgen, and communicates directly with an HTML5 Canvas.
To keep bandwidth low, we cheat on the client side. The server does not constantly broadcast pheromone evaporation. The WASM client runs the exact same evaporation logic locally to keep state in sync. The server only transmits a pheromone_bitboard indicating new drops. If a bit is flipped to 1, the client adds 10 to that tile's local strength.
Rendering 100,000 moving ants in the browser usually causes severe garbage collection stutter if you pass JavaScript objects back and forth. Instead, the WASM client packs raw color and coordinate data into a linear vector render_buffer. JavaScript simply grabs a pointer to this WASM memory buffer via Float32Array and draws directly to the canvas context, bypassing serialization entirely. The client also handles linear interpolation between ticks to ensure the ants move smoothly, rather than snapping to the grid on every server update.
Note: UI code is written with the help of AI
Screenshots
// Fig. world
// Fig. multiplayer
Benchmarking the Swarm
Once the engine and Tokio server were wired up, I ran a stress test: 1,000 players, each with 1,000 ants (1,000,000 active entities).
Here is the raw engine performance:
=== 1000 players × 1000 ants = 1000000 ants ===
Ticks: 100
Elapsed: 2.442s
Average Tick Time: 24.422 ms
Ant Updates / Second: 40.95 million
Chewing through 40 million entity updates a second on a single thread proves the DOD architecture and 1D indexing work. The CPU cache was fully saturated and happy.
Next, I slammed the TCP server to see how many clients the broadcast system could handle before latency spiked.
=== Benchmark Summary ===
Duration: 100 seconds
Avg Throughput: 137.45 MB/s
Avg Packets/sec: 506,216
Clients Connected: 3499
Bottlenecks & The Path Forward
At 3,500 concurrent players, the server gracefully handles over half a million packets a second and pushes 137 MB/s of throughput. This is the current safe limit. Push it past 3,500, and the server doesn't necessarily crash, but the WebSocket streams begin to lag behind the core engine's tick rate.
We are no longer bound by CPU computation; we have hit structural and network constraints. To make this truly production-ready, there are a few glaring bottlenecks that need to be addressed:
- Network I/O & TCP Overhead: Translating massive flat arrays into individual chunk broadcasts for thousands of independent WebSockets is incredibly expensive. To break past the 3,500 player mark, we likely need to drop TCP entirely and build a custom, unreliable UDP protocol to blindly fire chunk deltas at the clients.
- The Viewport Exploit: Currently, there is no server-side cap on the requested viewport size. A client can simply zoom out to capture the entire map, forcing the server to calculate and broadcast every single chunk delta to that specific WebSocket, instantly choking the egress bandwidth.
- Entity Identity Swapping: This is the dark side of the bitboard optimization. Because ant positions are sent as pure boolean grids rather than tracked UUIDs (to save massive amounts of bandwidth), the frontend relies on a nearest-neighbor interpolation algorithm to animate movement between ticks. In highly dense crowds, the client occasionally crosses wires and visually "swaps" ant identities.
- Ephemeral State: There is no persistent database integration (e.g., PostgreSQL or DuckDB) backing the engine yet. The entire world state lives exclusively in the server's RAM. If the server process restarts, the entire ecosystem evaporates.
Building a custom UDP protocol and wiring up a persistent storage manager to stream chunks to disk? That is an experiment for next time.