Olivier Couthaud

Small experiments

Twelve from-scratch builds: a programming language, a database, a chess engine, a renderer, a tiny operating system and a few more.

The cloth: a 20 by 20 grid of particles joined by constraints, top two corners pinned, falling under gravity. The clip ends before it settles.

I made these to see what I could build in one quick session, how much creativity would come out of it, and whether any of it would give me an idea for a project worth doing properly. The result is twelve small projects. Each takes something I use every day without thinking about it, like a programming language, a database, a chess opponent, or the way a computer draws a picture of a room, and builds it from nothing. No ready-made parts for the interesting bit. It is a bit like making your own pasta instead of opening a jar: slower and messier, but you find out what was in the jar.

Some of them you can look at. One is a ray tracer, which draws a picture by following rays of light backwards from the camera and seeing what they hit. It has to do that many times per pixel, and the slider shows why. With one try per pixel the picture is full of grain. With sixty-four it is much smoother, though still a little grainy.

The same scene of a glass ball, a blue ball and a mirror ball on a grey floor, much smoother, with a little grain left. The same scene drawn with one ray per pixel, full of speckle.
1 try per pixel64 tries
The same scene, drawn twice. Drag the slider.

Others move. There is a cloth that falls and swings, a billiards break, and a figure-eight orbit of three bodies that stays stable for twenty laps. A separate program turns that orbit into sound, one pitch per body. Others learn. A digit reader I wrote from nothing gets handwritten numbers right about 99 times out of 100. Another one reads a lot of Shakespeare and then writes its own, which sounds like Shakespeare if you do not listen closely, with invented words in it.

A square grid of 400 handwritten-looking digits that slowly change from 6 to 1, 3, 5, 2, 8, 9, 4, 7 and 0 across the grid.
None of these digits were written by anyone. A model that learned what handwritten digits look like drew one for each point on a grid, and they blend into each other.

The rest are the things computers are built from: a small programming language, a compiler that turns C-like code into machine instructions, a database, a chess engine, a web server, a tiny operating system that boots and shows a command prompt, and the maths that keeps messages secret. That last one is a way to learn the pieces and is not safe to use.

Some of it failed. An AI I trained to play an Asteroids-style game never really got better. A chess AI that learns by playing itself lost every game it played against an ordinary chess engine. My Newton's cradle ended up with the balls jostling into each other instead of passing the swing along.

What's next: these were a quick exploration, so none of them is finished the way a product is. Two loose ends I know about. The language can read its own source but cannot yet run itself, and the chess learner never won a game against the engine, and mostly drew against itself.

Why I built them

I wanted to see what could get built in a single quick session, how much creativity would come out of it, and whether anything along the way would inspire a project of its own. To give it some shape I picked twelve things I use all the time, a programming language, a database, a chess engine, a renderer, a tiny operating system and a few others, and built the core of each myself instead of importing a library that already does it. They are not products. Each is a few thousand lines written to reach the interesting part and stop. They were made in April 2026 and come to about 45,000 lines of Python (blank lines and comments included), plus about 11,500 lines of C, assembly and Lune.

The rules were loose. I still used numpy, scipy, matplotlib, pygame and Pillow for arrays, plots and windows, and the compiler and OS experiments use gcc, nasm and ld to assemble and link. The two learning experiments train on public data, the MNIST digits and the Tiny Shakespeare text, which the scripts download. Neither is mine.

How they fit together

Several of them ended up depending on each other. Click a box to see what it is. On a phone, use the buttons under the diagram.

6 C compilerx86-64, optimizer, JIT 1 Lunelanguage, 4 versions 12 Crypto, Lune to WasmAES, ECDH, TLS-style 11 WebAssemblyinterpreter + WASI 10 MiniOSbootloader, kernel, FAT12 7 DatabaseB+ tree, WAL, SQL, Raft 9 Web serverHTTP/1.1 and 2, chess app 8 Chess enginealpha-beta, UCI 2 Renderingray tracer, rasterizer 4 2D physicscloth, chain, billiards 5 N-body and soundRK4 gravity, synth 3 Neural netsnumpy: CNN, VAE, GPT, RL

1. Lune. A small scripting language with closures, pattern matching and a pipe operator, implemented four ways: a tree-walking interpreter, a bytecode VM, a transpiler to C, and a lexer, parser and compiler written in Lune itself. 8,595 lines of Python, plus the Lune and C programs around it. Its output feeds the C compiler (6) and the WebAssembly compiler (12).

2. Rendering. A ray tracer with diffuse, metal and glass materials, a BVH, triangle meshes, Perlin-noise terrain and a Cornell box, plus a software rasterizer and a progressive real-time path tracer in pygame. 4,404 lines of Python. Stands alone.

3. Neural nets. A framework on numpy with hand-written backward passes, and on top of it a CNN, a VAE, a character-level GPT, a DQN agent and an AlphaZero attempt for chess. 6,810 lines of Python. The AlphaZero part imports the chess board from 8.

4. 2D physics. Circles, walls and distance constraints with Euler or Verlet integration. Runs the cloth, a 30-link chain, a billiards break and a Newton's cradle, and there is an Asteroids clone on top of it. 2,223 lines of Python. Stands alone.

5. N-body and sound. Gravity with a fourth-order Runge-Kutta integrator, and a from-scratch audio side: oscillators, envelopes, filters, a 16-step sequencer and Markov-chain melodies. The two meet in a piece that plays an orbit as sound. 2,348 lines of Python. Stands alone.

6. C compiler. A subset of C (int, char, pointers, arrays, loops, printf) to x86-64 assembly, with a type checker, constant folding, dead-code removal and a peephole pass, plus a JIT experiment. 3,004 lines of Python. It takes the C that Lune transpiles to, after a small post-processor strips it down.

7. Database. A B+ tree on 4 KB pages with a write-ahead log, a SQL parser and executor with indexes and joins, snapshot isolation, and a Raft implementation over localhost sockets. 6,588 lines of Python. It stores the chess app's game history in 9.

8. Chess engine. Move generation, iterative-deepening alpha-beta with a transposition table, and a UCI mode for chess programs. 2,449 lines of Python, including its own AlphaZero attempt. It is the opponent in 3 and the engine behind 9.

9. Web server. HTTP/1.1 on raw sockets, a router, a small template engine, and HTTP/2 with frames, header compression and multiplexed streams. A chess web app sits on top and uses 7 and 8. 2,448 lines of Python. The crypto code in 12 wraps its sockets.

10. MiniOS. A 512-byte bootloader in assembly and a 32-bit kernel in C: screen output, interrupts, keyboard, a shell, a memory manager, paging, a heap and a read-only FAT12 driver. About 1,700 lines of C and assembly, plus build scripts. Stands alone.

11. WebAssembly runtime. A binary parser, a stack-machine interpreter and a minimal WASI layer, enough to run small modules like Fibonacci. 2,955 lines of Python. It runs what the compiler in 12 produces.

12. Crypto and Lune to Wasm. AES-128, SHA-256, HMAC, RSA and ECDH written from the specs, with a TLS-style handshake that wraps the web server, and a compiler from Lune to WebAssembly that handles integer code only. 2,294 lines of Python.

Diagram. An arrow points from the experiment that supplies something to the one that uses it. Boxes without an arrow stand alone.

Experiment 8's own AlphaZero script imports Experiment 3's neural-net layers, and 3's AlphaZero imports 8's chess board, so those two lean on each other. A small integer-only Lune program has gone all the way from Lune source to a native executable through the C compiler. The full language does not fit through, because that compiler only knows int, char and pointers.

Rendering

Experiment 2 is a ray tracer in pure Python. Step through its features below. After the sky come surface normals, diffuse shading, shadows, a metal ball, a glass ball, and finally triangle meshes with a teapot.

Ray tracer, one feature at a time

A smooth pale blue sky gradient and nothing else.
Step 1. A camera and a sky gradient. No objects yet.
A sphere coloured by its surface direction, cyan to pink, against the sky.
Step 2. Ray against sphere. Each pixel is coloured by the direction the surface faces, which is the quickest way to see that the maths is right.
A blue matte sphere on a green ground, slightly grainy.
Step 3. Diffuse material: light bounces off in random directions.
An orange sphere and a small yellow sphere on a green ground with soft dark shadows under them.
Step 4. Two spheres. Shadows fall out of the bouncing without any extra code.
A gold sphere, a blue sphere and a mirror sphere in a row on a grey floor.
Step 5. Metal, with a mirror and a slightly fuzzy gold.
A glass sphere, a blue sphere and a mirror sphere in a row on a grey floor.
Step 6. Glass, which bends the ray as it enters and leaves.
A gold Utah teapot and a glass ball on a grey ground under a pale sky.
Step 7. Triangles. The Utah teapot is a standard test mesh that the script downloads. A BVH keeps each ray from being tested against all 6,320 of its triangles. 800 by 450 at 64 samples per pixel.

Each frame is a render from that stage; the last one is larger.

What does "samples per pixel" mean?

A path tracer fires random light paths backwards from the camera through each pixel and lets them bounce around the scene. One path is a noisy guess at the pixel's colour. Averaging many of them settles the guess, and the grain shrinks roughly with the square root of the count, so four times the samples halves the noise.

A path-traced scene of many coloured matte, metal and glass spheres on a grey ground under a pale sky, with grain from low sample counts.
The ray tracer's "final scene", 800 by 450 at 64 samples per pixel.
A rendered square patch of procedural terrain with green grass patches, grey rock and a strip of blue water at the front edge.
Perlin-noise terrain, with the material picked by height. The speckle is sampling noise, at 32 samples per pixel.

The Cornell box is the classic test scene: a lit room with a red wall and a green wall, where you can see colour bleeding onto the white surfaces. Mine is plain path tracing with no direct light sampling, so even at 256 samples per pixel it is noisy, and in pure Python it takes a while. I also wrote a software rasterizer, which is the other way to draw 3D: edge functions, a z-buffer and flat, Gouraud or Blinn-Phong shading. The teapot in the second image is its output.

A path-traced Cornell box: a white room with a green left-hand wall, red right-hand wall, a ceiling light and two white boxes, heavily speckled.
Cornell box, 400 by 400 at 256 samples per pixel.
A bronze-coloured teapot, shaded smoothly, on a dark grey background.
The rasterizer, Phong shading, 320 by 240.

Languages and compilers

Experiment 1 is Lune, a small scripting language with closures, pattern matching and a pipe operator. I wrote it four times over, each version built on the last. First a tree-walking interpreter, which is clean and slow. Then a bytecode VM, a transpiler that turns Lune into C, and finally a lexer and parser written in Lune itself. The C output is hundreds of times faster than the tree-walker on a 1,000-element sort, which is about what you would expect for leaving Python. That is for sorting. On the ray tracer the C version was no faster than plain Python, because every vector is a table and every access a hash lookup.

What is a bytecode VM, and what is a transpiler?

A tree-walking interpreter reads the program as a tree and evaluates it node by node. A bytecode VM first compiles the program into a flat list of simple instructions and runs those in a loop, which is faster. A transpiler translates the program into another language, here C, so that a C compiler does the hard part.

Writing a language in itself found bugs I had not hit any other way. Lune processes string interpolation when it lexes, so you cannot write the two characters ${ inside a string, and I use chr(36) + "{" instead. Newlines are ignored inside braces, so a list literal on the line after a call was being read as an index on that call, and the fix was an explicit return. The lexer and parser in Lune parse most of Lune (match expressions are not handled), and a third piece, a compiler written in Lune, prints bytecode as text. It does not produce executable bytes, and there is no VM written in Lune, so it is not fully self-hosted.

A tiny render of a red, a green and a blue sphere on a flat ground under a sky, blocky because it is only 100 by 100 pixels.
A 168-line ray tracer written in Lune, run through the language I built. It is 100 by 100 pixels, shown at four times size.

Experiment 6 is a compiler for a subset of C: int, char, pointers, arrays, if, while and for, with printf, write and exit. It goes through a lexer, parser, type checker, an optimizer (constant folding, dead-code removal and a peephole pass) and a code generator that writes x86-64 assembly. gcc then assembles and links it, under WSL on my machine. There is also a JIT experiment that writes machine code straight into executable memory and calls it through ctypes.

Experiment 11 is a WebAssembly interpreter, with a binary parser, a stack machine and a minimal WASI layer. Experiment 12 has a Lune to WebAssembly compiler that targets it. It compiles 32-bit integer code only: arithmetic, comparisons, if/else, while loops and functions that call each other. No strings, lists, tables or closures.

Databases, servers and an operating system

Experiment 7 is a key-value database that grew into a SQL one. The base is a B+ tree with up to 100 keys per node, stored on 4 KB pages with a 64-page cache. A write-ahead log makes it survive a crash: on startup it redoes committed transactions and undoes the rest. On top of that I put a SQL parser and executor with secondary indexes, hash and nested-loop joins and EXPLAIN, then snapshot isolation, then a three-node Raft implementation over localhost sockets.

Experiment 8 is a chess engine. It is the opponent in the AlphaZero experiments and the engine behind the web app. It does iterative-deepening alpha-beta with a transposition table and quiescence search, and its evaluation is built from material, piece-square tables and pawn structure. It can also speak UCI. Experiment 9 is the web server: HTTP/1.1 on raw sockets, a router with path parameters, a tiny template engine and HTTP/2 with frames, header compression and multiplexed streams. The chess web app on top of it keeps its game history in the database from 7.

Experiment 10 is MiniOS. A 512-byte bootloader loads a 32-bit kernel in C that handles interrupts and the keyboard, has a shell, a bitmap memory manager, paging, a first-fit heap and a read-only FAT12 driver. This is its own screen, captured after boot. One line in the middle is the kernel testing its own heap: a thousand allocations, freed in shuffled order, with nothing leaked.

MiniOS booting, as captured from its screen

  [OK] Entered 32-bit protected mode
  [OK] Kernel at 0x00001000  |  Stack at 0x00090000
  [OK] IDT installed (#DE, #GP, IRQ1/keyboard)
  [OK] PIC remapped  (IRQ0-7 -> INT 0x20-0x27)
  [OK] PMM ready  (7936 free frames / 32 MB)
  [OK] Paging enabled  (identity map 0-4 MB, heap 4-8 MB)
  [OK] heap: 1000 allocs, shuffled frees, no leak
  [OK] Heap ready  (kmalloc/kfree, 4 MB virtual)
  [OK] Interrupts enabled
  [OK] FAT12 filesystem ready (ls, cat, hex)

  Type 'help' for available commands.

$

This is MiniOS booting in an emulator, minus the boxed title banner. The shell has help, clear, mem, halt, ls, cat and hex.

Physics and sound

Experiment 4 is a 2D physics engine. It has circles, walls and distance constraints that I solve by sweeping over them a handful of times a step (4 to 25 depending on the scene), and you can pick Euler or Verlet integration. It runs the cloth in the clip at the top, a 30-link chain, a billiards break and a Newton's cradle, and I made an Asteroids clone on top of it.

Experiment 4: a billiards break, a cue ball into a rack of 15.

Experiment 5 starts with gravity. It is an N-body simulator with a fourth-order Runge-Kutta integrator, a standard accurate way to step a simulation forward, and I tested it on the figure-eight orbit, where three equal masses chase each other around one figure-eight track. The starting values are the published figure-eight solution from 2000. Total energy should stay constant in an orbit, so any drift in it is the integrator's error, and over 20 laps that drift stays within 7 × 10-14 of the starting energy.

Three coloured dots on a green figure-eight track on a black background.
The figure-eight orbit, 20 periods drawn on top of each other.
Line chart of relative energy error over time for the figure-eight run, wandering between about plus 1e-14 and minus 6e-14.
Relative energy error over the same run.

The same experiment has an audio side I wrote from scratch: oscillators, envelopes, filters, a 16-step sequencer and Markov-chain melodies, including a three-minute generative jazz piece. The two meet in one program that plays the figure-eight as sound. Each body gets a sine tone. Its distance from the centre sets the pitch, snapped to a pentatonic scale, so closer is higher. Its speed sets the loudness, its left-right position sets the pan, and the gravitational energy opens and closes a low-pass filter. Press play for 30 seconds of it.

The figure-eight orbit, three periods, played as three sine tones. The orbit repeats, so the sound does too.
Four stacked charts over 30 seconds for three bodies: pitch in hertz, amplitude, stereo pan and low-pass cutoff. Pitch, amplitude and pan each rise and fall repeatedly; the filter cutoff dips slowly and recovers.
What the sound is made from: pitch, amplitude, pan and filter cutoff over the 30 seconds.

Learning from scratch

Experiment 3 is a neural network framework on numpy. It has dense and convolution layers (the convolution is done as one big matrix multiply), batch norm, dropout and the Adam optimizer, with every backward pass written by hand and the convolution and batch-norm ones checked against numerical gradients. On top of it I trained three digit classifiers, a VAE, a small GPT, a DQN agent and an AlphaZero attempt.

Handwritten digits (MNIST), test accuracy on the 10,000 held-out images. One run each.
ModelAccuracyErrors
Dense net, 784-512-256-10, dropout97.99%201
CNN, two conv layers and a dense layer99.22%78
Deeper CNN, batch norm and augmentation98.85%115

The deeper one was supposed to beat the plain CNN. It aimed for 99.3% or better and ended up lower, at 98.85%. This is the plain CNN's confusion matrix, which shows which digits it mixes up.

Confusion matrix for the CNN, test accuracy 99.22 percent, with a strong diagonal and a few small off-diagonal counts.
The CNN's confusion matrix on the test set. Rows are the true digit and columns what it guessed.

The VAE has a two-number latent space and trained for 40 epochs. Each cell in the grid below is what its decoder draws for one point on a 20 by 20 grid across that space, and the digits blend into each other as you move.

A square grid of 400 handwritten-looking digits that slowly change from 6 to 1, 3, 5, 2, 8, 9, 4, 7 and 0 across the grid.
The VAE's 20 by 20 latent grid. Horizontal and vertical are its two latent numbers, and every digit here was drawn by the model.

The GPT is a character-level transformer with 128-wide embeddings, 4 layers, 4 heads and a 128-character context, trained for 5,000 steps on the Tiny Shakespeare text. It ends at roughly 1.35 training and 1.58 validation cross-entropy, single run, and its output reads like Shakespeare with invented words:

HENRY BOLINGBROKE:
Who, what I afeary small well not book the death,
And be my rebel'd Romeo's Duke of Glounty breath.
Line chart of cross-entropy loss against training step for a tiny character-level GPT, train and validation curves falling from about 4.2 to about 1.35 and 1.58.
The GPT's training curves. Validation flattens above training.
A four by four grid of attention maps, one per layer and head. In the first layer attention is spread thinly over earlier characters, and in later layers it concentrates near the diagonal, with one head in layer three fixed on a single column.
The GPT's attention, one panel per layer and head. In layer 1 each character looks thinly at everything before it. Further up, the heads mostly look at the last few characters, and one head in layer 3 stares at a single character.

What didn't work

The DQN never learned to play. I wrote a headless Asteroids game for it, where the agent sees 25 numbers: the ship's position, speed and angle, and the five nearest asteroids. Over 20,000 episodes the reward hovers around zero and never clearly climbs.

What is a DQN?

A Deep Q-Network is a reinforcement-learning method. The agent plays by trial and error, and a neural network learns to predict how much future score each action is worth in the current situation. It then picks the action with the best prediction, apart from a small share of random moves so that it keeps exploring.

The DQN agent flying in the headless Asteroids environment. It ends with the ship hitting an asteroid.
Three stacked charts over 20,000 episodes: episode reward oscillating around zero, episode length around 900 steps, and exploration rate dropping to 0.05 within about the first ten episodes, which looks instant on a 20,000-episode axis.
DQN training curves. The smoothed reward stays near zero. Exploration drops to 5% almost immediately, so nearly every episode ran with 5% random actions.

AlphaZero did worse. The network lost all 6 games against my alpha-beta engine in every one of ten iterations, with no wins and no draws. Those games were capped at 80 half-moves (40 moves each side) and decided on material, the engine got 0.05 seconds a move and the network 40 search simulations a move. Each iteration was 20 self-play games at 25 simulations a move, and the whole run took about 63 minutes. The network has 560,206 parameters. A second, separate attempt with its own network also ended at 0% against the alpha-beta engine.

What is AlphaZero?

AlphaZero is a method where a neural network guesses which moves look good and how well a position is going, and a tree search uses those guesses to look ahead. The network plays itself, trains on the results, and the next version replaces the champion only if it beats it clearly. Mine used a 55% win-rate threshold.

Almost every self-play game ended in a draw, so there was very little signal about who was winning. Drag through the ten iterations and see it.

AlphaZero attempt, iteration by iteration

Results of each iteration.
IterationSelf-play draws, of 20New against old, 10 gamesAgainst alpha-beta, 6 gamesTraining lossNew champion
1205 wins, 5 draws0 wins, 6 losses3.367yes
21810 draws0 wins, 6 losses3.733no
3195 wins, 5 draws0 wins, 6 losses4.299yes
42010 draws0 wins, 6 losses3.732no
5205 wins, 5 losses0 wins, 6 losses3.852no
61910 draws0 wins, 6 losses4.032no
72010 draws0 wins, 6 losses4.038no
82010 draws0 wins, 6 losses4.174no
92010 draws0 wins, 6 losses4.185no
102010 draws0 wins, 6 losses4.251no

Ten-game pits are noisy. The network took over as champion twice, at iterations 1 (against the untrained starting network) and 3, both on 5 wins and 5 draws. Every other pit scored 50%.

Line chart over ten iterations: win rate against the previous champion at 0.75, 0.5, 0.75 then flat at 0.5, and win rate against the alpha-beta engine flat at zero.
The same run as a chart. A draw counts as half a win. Blue is against the previous network, red against the alpha-beta engine. The dashed green line is the 55% promotion threshold and the dotted line is 50%.

Two more failures. My chaos demo was meant to show two nearly identical starting points drifting apart. I nudged one body by 10-10, and the two runs only separate to about 3 × 10-5 by the end, because the three bodies get flung apart early on (that is the jump at about time 0.6) and then coast off in straight lines. It shows sensitivity, but not chaos.

Log-scale chart of the separation between two nearly identical three-body runs over time: a sharp rise at about time 0.6, then a slow climb to about 3e-5 at time 25, far below the line marking a separation of 1.
Separation between the two runs, on a log scale. It never gets near 1.

And my Newton's cradle does not work like a cradle. The balls collide, but after the first hit they swing together and jostle into each other, with some riding up over their neighbours, instead of passing the motion down the row.

Experiment 4: five balls on strings, restitution 0.995.

The cryptography is not safe to use. Keys, primes and padding come from Python's general-purpose random module, RSA defaults to 512 bits "for speed", and the handshake is not TLS. It is a way to learn the pieces.

Unfinished

Since this was a quick exploration, none of it is polished. Lune is still not fully self-hosted, for the reasons above. I also wrote a second version of the DQN script with Double DQN, reward clipping and best-checkpoint saving, but I never got a result out of it. The HTTP/2 tests use the third-party h2 library as the client.

Stack
Python (numpy, scipy, pygame, matplotlib, Pillow), some C and NASM
Status
Prototype
Code
Not public

← All work