Skip to content

Build1 publisher3 min readPublished

A browser evolution simulator scales to 33 million positions by splitting each tick into several GPU passes

Moving an evolution simulator from a C# object-per-cell loop to WebGPU buffers took the world from 200x200 to 8192x4096 positions, where one 32-bit field per position already costs 128 MiB and iteration order stops being free.

The Engineer · Build desk

Photograph accompanying A browser evolution simulator scales to 33 million positions by splitting each tick into several GPU passes
Photo: github.com

What happened

  • The author of a dev.to writeup revived an evolution simulator originally written in C#, with a 2D array of objects and a foreach loop over living cells, and moved its core logic to WebGPU compute shaders.
  • The old web version supported worlds of up to 200 by 200 cells; the GPU version targets 8192 by 4096, roughly 33 million positions that can each hold a living cell.
  • At that size a single 32-bit value per position already takes about 128 MiB, and one extra 4-byte field per position can cost another 128 MiB.
  • Instead of a Cell object, state is split across arrays: occupancy in one, energy and traits separately, genome held once per species, and temporary per-tick data in its own buffers.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • capability A simulation with tens of millions of active positions now runs in an unmodified browser through compute shaders, with no native build to ship or sign.
  • decision Porting a sequential world loop forces you to name a conflict rule up front, because when two cells want the same tile the GPU leaves the order undefined.
  • cost Determinism takes extra temporary buffers and several passes per tick, so the cheapest tick is the one that binds the least state.
  • constraint The world lives on the GPU, so anything that wants the state in JavaScript, including saves and statistics, has to justify a readback of tens of millions of positions.

8192 by 4096 is 33,554,432 positions [1]. One 32-bit value for each of them is 134,217,728 bytes, or 128 MiB exactly [2]. The post lists what a cell needs beyond what occupies a position: an age, a species, an energy level, individual traits, and temporary data used while calculating the next state [5]. Hold the first four as separate 32-bit fields per position and the resident set is five buffers of 128 MiB, 640 MiB, before any temporaries [3].

So the genome does not travel with the cell. Species-level data is stored once per species instead of being duplicated for every position [6]. The split also cuts what each shader has to bind: a shader that updates energy does not need access to everything about a cell, and the renderer has no reason to see temporary movement requests [7]. The author writes that the layout is shaped not only by what needs to be stored but by how that data moves through the GPU [8].

The harder rewrite is ordering. In the C# version a cell inspected its neighbours, chose an action, and changed the shared world immediately, so the next cell in the loop saw the result [9]. "This meant that iteration order had quietly become part of the simulation rules," the author wrote on dev.to [10].

On the GPU there is no reliable ordering between thousands of parallel invocations, and two cells can see the same empty position and both decide to move into it [11]. A tick therefore runs in three stages. Cells first write an Intent describing what they want to do, while the world stays unchanged. Claims are then competing requests for shared outcomes, and where several cells want one position the conflict is resolved by a predefined rule. Apply modifies the world last [12]. The author's stated cost for this is additional temporary memory and several compute passes [13].

For that structure to transfer to your simulation, two things have to hold. Your conflicts have to be resolvable by a rule that does not depend on who ran first, because the Claim stage replaces order with policy. And you have to accept that the world lives on the GPU: the new version keeps no full CPU-side copy, and JavaScript handles the UI, camera and user commands [14]. Copying the state into JavaScript after every tick was the option considered and rejected, on the grounds that transferring that much data becomes its own bottleneck [15].

The old web build topped out at 200 by 200, which is 40,000 positions, so the new world holds about 839 times as many [4]. The post does not report tick times, frame rates or the GPU it ran on. Without those numbers the 33 million describes an allocation and a design, not a measured throughput. The engineering that matters here is in the layout and the staging.

One more constraint shows up at the far end of the zoom. Once a cell is smaller than a pixel, the distant view is built from aggregated regions of the world [16].

What to watch

  • Tick timings and the GPU model: until those land, the 33 million figure describes an allocation.
  • Whether the aggregated-region view at far zoom is a separate compute pass or a reduction over the occupancy buffer.
  • Whether saves, statistics or debugging get a readback path, and what a readback costs against the transfer the design avoids.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories