DEV Community

Cover image for Nobody told the bytes to reproduce
Patrick for PSGtechnauts

Posted on AI-assisted

Nobody told the bytes to reproduce

Every living organism on our planet exists because something, only once, copied itself. Nobody was there around to architect something, it just happened. So how do you get the first copier when nothing is in place yet?

A few years ago, a team at Google published a paper that doesn't try to answer that question for biology, but in a computational sense. They watched the first replicator emerge out of random noise. It seemed to spontaneously emerge on a computer. I rebuilt the experiment in a browser tab (although - on a much smaller scale) and this is what it looked like;

Running experiment

Figure 1: Noise becomes something

The app runs entirely in your browser. You can access the app here.

Fastest route to a takeover: language BFF, soup size 10,000, seed 1111, max steps 8,192, head1 offset 0, Pause on takeover on, press Play. Expect a dip in entropy around epoch 650 and a full takeover at epoch 6,794.

If you find a seed please let me know - now lets dive in.


Press play and watch it unfold

Ten thousand coloured squares. A 100 by 100 grid, every cell a different shade, no pattern anywhere. Each square is a program: 64 bytes of random noise, never touched by a human hand. There is no instruction anywhere in the system that says "copy yourself". No function whatsoever (except for the instructions that are embedded in the program). Nothing is being selected for.

At the beginning of the experiment you'll see mostly noise, but the entropy chart in the sidebar has a small decrease. A few dozen cells share a colour. A few hundred epochs later the dent is more like a cliff that wants to go down, but cannot, yet. By the time you maybe came back from the coffee machine to grab a cup, one colour is spreading across the grid like ants that are building a colony - thousands of individual agents all following rules to build something nobody gave them instructions for in the fist place. Then, at epoch 6,794 if you used seed 1111, the grid is one flat colour. Unique genomes: 1. Entropy: 0. Replications: tens of thousands.

One program, which did not exist when you pressed play, has copied itself into all ten thousand slots. Nobody wrote it. Nobody told it to reproduce. It just happened, in a browser tab, while I was trying to cook spaghetti (it failed by the way - and yes, you can cook spaghetti wrong).

This post covers three things: the paper that describes this phenomenon, the browser simulation I built to see it with my own eyes, and some questions that would keep me up at night. I'm a frontend developer - design systems, Web Components and generic apps, not artificial life.

The paper

In 2024, a team at Google published Computational Life: How Well-formed, Self-replicating Programs Emerge from Simple Interaction (Agüera y Arcas et al., arXiv:2406.19108). Blaise Agüera y Arcas leads Google's Paradigms of Intelligence team, and the paper is a small concrete piece of a bigger argument he has been making for years: life and intelligence are, at bottom, computational phenomena, and computation is a substrate where life-like things appear on their own.

The experiment is almost embarrassingly simple. Take a large number of short programs in a tiny language. Fill them with random bytes. Put them in a "soup". Repeatedly pick two programs at random, stick them together end to end, run the combined program for a while, split them apart, put them back. That's the whole thing. No explicit selection step. The programs change over time because when the two of them are pairing, one can write into the others bytes.

What the authors found is that self-replicating programs appear on their own, and once they appear they take over. The soup starts as noise. Then, often quite suddenly, a program emerges that, when run next to another program, overwrites it with a copy of itself. From that moment the soup belongs to the replicators.

Why this matters: the usual story about evolution starts after replication exists. Once things copy themselves imperfectly, natural selection follows almost by logic. The hard question in origin-of-life research is how you get the first replicator. This blog doesn't try to solve this. But it shows that in at least one computational substrate, you don't need to design the first replicator. Random interaction between random programs is enough. It falls out.

A little bit of history: Von Neumann worked out the theory of self-reproducing automata in the late 1940s. Tierra and Avida were both software simulations created in the 90’s. But they manually injected a replicator. In our experiment and in the paper's - we don't inject a replicator, it's almost an emergent property of our experiment.

I read the paper on a Saturday while it was relatively hot in the Northern Hemisphere and had a reaction a lot of (frontend?) developers have: I want to see that. On my own screen, plus a seed I can share.

So I built it. Albeit, with the help of AI.

BFF: a language in ten opcodes

The programs in the soup are written in BFF, a variant of Brainfuck the paper defines. Brainfuck gives you a tape of memory cells and a head; > and < move it, + and - change the cell, [ and ] loop, . and , do I/O. BFF adds a second head and replaces I/O with copying between heads:

export const BFF_OP = {
    LEFT0: 0x3c,  // <   move head0 left
    RIGHT0: 0x3e, // >   move head0 right
    LEFT1: 0x7b,  // {   move head1 left
    RIGHT1: 0x7d, // }   move head1 right
    MINUS: 0x2d,  // -   decrement the byte under head0 
    PLUS: 0x2b,   // +   increment the byte under head0 
    DOT: 0x2e,    // .   copy the byte under head0 to head1  
    COMMA: 0x2c,  // ,   copy the byte under head1 to head0 
    OPEN: 0x5b,   // [   if byte under head0 is 0, jump past matching ] 
    CLOSE: 0x5d,  // ]   if byte under head0 is not 0, jump back to matching [
} as const;
Enter fullscreen mode Exit fullscreen mode

Two design decisions do all the work. First, the program and the data are the same tape - the instruction pointer walks over the same bytes the heads read and write, which is what makes self-modification possible at all. Second, any byte that isn't one of the ten opcodes is a no-op. A random 64-byte program contains about 2.5 valid instructions on average. So a random program isn't an error. It's a mostly-empty program. It runs; it just doesn't do much.

The entire interpreter is one switch statement in a 160-line file. I spend my working life in codebases where a single form component is 160 lines or even more. Here, 160 lines is a complete universe with its own physics.

The five byte replicator

Once you see a replicator, the whole experiment clicks. This is the one my app injects when you press "Inject replicator":

[.>}]

Five bytes. Both heads start at position 0, except you set head1 to start at position 64, where the other program begins. Then: [ enters the loop (the byte under head0 is [ itself, non-zero). . copies the byte under head0 to head1. > and } move both heads one step right. ] jumps back if the byte under head0 is non-zero. It copies itself, byte by byte, into the other program, until it reads a zero. Every program this one touches becomes a copy of it. That is, in the operational sense this experiment uses, ‘life’.

The paper's own canonical replicator is longer and cleverer ([[{.>]-]]-]> .{[[) and it's in the codebase too.

What counts as alive

The function I thought about most is also the shortest:

export function detectReplication(
     parentId: number,  
     childId: number, 
     parentGeneration: number,
     steps: number, 
     beforeActor: Uint8Array, 
     beforeVictim: Uint8Array, 
     afterActor: Uint8Array,
     afterVictim: Uint8Array, 
): ReplicationEvent | null { 
      if (!genomesEqual(afterActor, beforeActor)) return null;  
      if (!genomesEqual(afterVictim, beforeActor)) return null; 
      if (genomesEqual(beforeVictim, beforeActor)) return null; 
      return { parent: parentId, child: childId, generation: parentGeneration + 1, steps, genome: afterVictim.slice() }; 
}
Enter fullscreen mode Exit fullscreen mode

Three equality checks. The actor didn't damage itself. The victim is now an exact copy of the actor. And the victim wasn't already a copy, or nothing happened. That's the entire operational definition of replication in this system.

It's arbitrary - imperfect copies don't count, and imperfect copying is the whole engine of evolution - and it's reasonable, because it mirrors how we talk about life at the edges. Is a virus alive? Is a prion? You draw a line somewhere and accept the line is a choice. Here the line is three loops over 64 bytes, cheap enough to run 5,000 times per epoch. Note also that the checks are blind to intent: if a random flailing of instructions happens to produce an exact copy, that counts. At the start, before any real replicator exists, that's exactly how the first copies happen. An accident.

Keeping the browser alive while the soup evolves

Now the part that's actually about my job. One epoch pairs up all 10,000 organisms and runs 5,000 interactions, each up to 8,192 VM steps. Do that on the main thread and the page freezes: no clicks, no paint, and after a few seconds the browser offers to kill the tab.

Everybody knows the answer is Web Workers.

The app is a small tree. The main thread does only UI: canvas, charts, buttons. A simulation worker owns the one true copy of the soup, the RNG state, the history, everything. Below it, navigator.hardwareConcurrency - 1 pair workers. Each receive a chunk of pairs and a packed Uint8Array of ‘genomes’, run the VM, and post results back. They're dumb and stateless. I used the same pattern years ago to keep heavy table rendering off the main thread for very large datasets, and the principle hasn't changed: the thread that paints should never be the thread that thinks.

Three details carried most of the weight.

We transfer instead of copying. A normal postMessage via a structured clone, copying its payload is a lot of operations a second: a 640 KB genome buffer, seven times per epoch, thousands of epochs in a row, that's a lot of pointless copying and garbage. Transferring the ArrayBuffer moves ownership to the worker in constant time:

slot.worker.postMessage(request, [batch.genomes.buffer as ArrayBuffer]);

The catch: you transfer the whole buffer, and the sender is left with a detached, zero-length one. So each chunk gets its own freshly allocated compact buffer containing only the genomes it needs, with job indexes rewritten to point into it.

Workers finish in whatever order they like, so the merge sorts results by pair index before writing them back:

outputs.sort((a, b) => a.output.pairIndex - b.output.pairIndex);

The same seed on a 4-core machine and a 16-core machine produces the same soup at every epoch. I tested this specifically, because the entire value of the seed feature depends on it. (The RNG is mulberry32. It’s fast and the whole state is one 32-bit integer, so saving and restoring is easy).

Backpressure and the hidden tab. The coordinator finishes epochs faster than the UI can usefully display them, so state updates are throttled to one per 100 ms, and everything sent is a delta: only the history rows and events that appeared since the last post. The first version posted the entire history array every update; at epoch 6,000 that's 6,000 rows structured-cloned ten times a second, for no reason. And when you hide the tab, the main thread tells the coordinator, which stops attaching the 640 KB canvas payload to updates at all:

document.addEventListener("visibilitychange", () => { 
  void client.setVisibility(!document.hidden);  
  if (!document.hidden) schedulePaint(); 
});
Enter fullscreen mode Exit fullscreen mode

The simulation runs measurably faster with the tab in the background, which is exactly the right way round for a job that takes half an hour. Meanwhile the tab title becomes a tiny dashboard - 🌀: 2.341 ⏳:4200 ©: 88123 - so you can glance at your tab bar and see whether life has shown up yet. But to be honest - watching it unfold is more fun, if you have the patience that is.

As a side note: between epochs the coordinator has to yield to its own event loop, or a pause command sits in the message queue until the play loop ends, which is never. setTimeout(0) gets clamped to 4 ms when nested, so the yield is a MessageChannel round-trip instead. The kind of fact you only learn by building the thing.

I didn't use any frameworks or libraries. Not because I don't like them but because I want to be in control of how things are rendered. Because every millisecond counts towards a meaningful and efficient render. The tests run on Node's built-in runner (with native type stripping), and npm test finishes in a few seconds.

Seed 1111

Press play with the defaults and seed 1111 (I found this seed by accident). Epochs 0 to ~600: nothing. Noise, entropy pinned at 13.29 bits, an occasional lonely blip in the replication chart that doesn't survive. This is the pre-life phase, and it's where I spent most of the project. Around epoch 650 a dent appears; a few dozen cells share a colour and the blips stop returning to zero. Then the cliff: entropy drops from 12 bits to under 2, replications per epoch climb into the hundreds, the grid turns blotchy single-colour with specks. Then a long tail - a few holdout programs keep breaking the replicator or getting lucky in the shuffle - until epoch 6,794, when unique genomes hit 1 and the simulation pauses itself. About 34 million interactions. A bit over half an hour on my machine. Your time will vary with core count; the end result is the same.


Nobody told the bytes

I want to end with the question that wouldn't leave me alone. When a genome completely takes over, what are we looking at?

Among enough random programs that modify each other, some will, by chance, modify others into copies of themselves. Programs that do so persist, because persistence is what copying yourself into other slots means.

Nobody told the bytes to reproduce. There's no line of code anywhere that rewards copying, or even recognizes it - the detector is a passive observer the soup can't see. The replicator that takes over at epoch 6,794 wasn't in the initial soup. It was assembled, byte by byte, by programs overwriting each other without any of them "knowing" what they were building. Then it existed, from where we could consider it to be 'alive' - albeit in a different dimension than ours.

Before the transition, the soup is a closed system: ten thousand slots, no external input, nothing added, nothing removed. Yet it goes from maximum disorder (in this configuration) to perfect order, thirteenish bits of entropy to zero, entirely on its own. No one optimised it. No one even watched it, if you hid the tab. Entropy went down when nobody was looking. It's ‘manufactured’ by the only process capable of manufacturing it: copying (itself). Once a thing can copy itself, order is no longer imposed. It spreads.

While writing this I noticed how hard it is to describe the soup without the language of intention. The replicator "wants" to copy itself. None of that is true, and I've left most of it in anyway, because the alternative is linguistic contortion that makes the writing worse and the understanding no better. Maybe intentional language is just the most compact description we have for systems where some patterns persist by acting on their environment. Which is a roundabout way of saying: when the grid goes monochrome, the honest thing to say isn't "a statistical inevitability occurred". It's that something, for the first time in that little universe, is "alive" - in the only sense that word has ever operationally had.

Nobody told the bytes to reproduce. They worked it out.


Notes

  • The entropy measure is too simple. I use plain Shannon entropy; the paper uses a compression-based "high-order entropy" that sees near-copies as similar. Mine treats two programs differing by one byte as completely different, so the curve stays high longer and drops more abruptly. Good proxy for "has a replicator taken over", poor proxy for "how structured is this soup".
  • The second language is untested. There's a "custom two-tape" language in the app - I asked an AI assistant to design a minimal instruction set that might also support emergent replication, kept what it produced, and have barely tested it. I've never seen a takeover in it, and I don't know whether that's because it can't happen or because I haven't found the right seed. Use BFF if you want a takeover.
  • Memory. A long run at soup size 10,000 can push the tab towards ten gigabytes of RAM. I suspect the replication log, which stores a genome per event and there are millions of events.
  • The rewind window doesn't survive saving. The app keeps the last 64 epochs in a buffer so you can scrub back to the moment an event fired. Works live; the windows aren't serialised, so loading a saved run and scrubbing doesn't work the way the UI implies.
  • The head1 offset setting confuses people. It only exists so the hand-written replicator can start its second head in the victim's region. It'll move to an advanced section or disappear.
  • This is not a complete reproduction of the original Google experiment. The current version uses a soup size of 10.000 instead of 131.072 in the original experiment - due to browser limitations which I haven't worked around yet. I also use a different entropy measure. I made this to show / visualize the experiment for a broader audience.

The interesting questions; when does the transition happen on average, do winning replications have some sort of shared ‘knowledge’ etc. cannot be further inspected due to browser limitations. I need an actual proper backend and research platform to analyze the data correctly. Something I’d like to build.

The source code is currently privately stored somewhere safe. Partially because I am a perfectionist and more features are coming. On the other hand, I don't want people to read my 'updated things' commit messages ;-)


Resources

Top comments (0)