A vertical race up the oviduct. Two hundred sperm, every one of them driven by a neural network, evolve across generations until some of them learn to reach the ovum — and every generation is raced in sixteen parallel worlds at once. Training draws no race at all, so every cycle goes to evolution; when you want to face what it learned, one champion comes out of the vault and races you head to head.
Written in Zen C (b27d623, built from main with
ZC_HAS_JIT=0 — the JIT REPL needs libtcc, which Homebrew no longer ships), rendered
with raylib 6.0.
make # release build
make install # build an app bundle in ~/Applications
make uninstall # remove the bundle, keep the trained models
make run # build and launch from the tree
make test # the suite in tests/suite.zc
make check # type check only
make debug / make clean
make install writes ~/Applications/Spexma.app — double-clickable, in Spotlight, with
an icon. Override the destination with APP_DIR=/Applications make install.
Trained models live in ~/Library/Application Support/Spexma/spexma.db, resolved from
$HOME at runtime rather than relative to the working directory: launched from Finder the
working directory is /, and a relative path would have tried to create /data and died.
make uninstall deliberately leaves that database alone.
./spexma opens a menu; there are no flags. Arrows move, enter starts, tab opens the
settings column, escape leaves whatever is running and returns to the menu.
| menu entry | what it does |
|---|---|
| train | resume the lineage where it stopped, or start one |
| train a new lineage | discard it and evolve from random weights |
| play against the model | one on one, mirrored start |
| watch it race itself | light against shadow, the same mind twice |
| replay a champion | a shoal of clones of one champion |
Escape in a race abandons it and returns to the menu; the abandoned generation is discarded rather than written, so a half-run race never pollutes the curve. Training then resumes from the last completed generation, because the whole population — not just its champion — is written to the vault after every generation as a single 393 KB blob that is replaced in place. Costing about a millisecond against a two-and-a-half second generation, it is what makes training survive quitting.
Settings live beside the menu and persist in the vault: worlds in parallel (1–16, training only), duel hardship, and opponent generation (0 means the strongest on record). They are written the moment you leave the menu, not only on a clean exit.
In play, arrows or WASD steer and thrust, shift or space surges.
The Makefile expects the Zen C toolchain at $(HOME)/.local/share/zenc and raylib under
/opt/homebrew. Override with ZC_ROOT and RAYLIB_PREFIX.
Nothing here imports the Zen C standard library. Everything it offered that this project
needs — formatting, file existence, directory creation, timing — is two lines of C behind
an extern fn, and going direct also sidesteps generic-monomorphisation bugs in
std/string.zc that made zc check fail on code that compiled fine. std/thread and
std/sync are the exception: the pool needs their pthread wrappers.
Every genome is raced in sixteen worlds at once, each on a different channel, and its merit is the mean across all sixteen. That is not decoration. Raced in a single world, selection is mostly noise — a genome that drew an easy channel outbreeds one that drew a hard one, and the "champion" is whoever got lucky. Measured against ten channels it had never seen:
| trained in | arrivals on unseen channels | mean merit |
|---|---|---|
| one world | 0 % | 278 |
| sixteen worlds | 36.5 % | 1319 |
The single-world champion generalises to nothing. Sixteen samples cut the standard error by four and produce a model actually worth racing against. (The sixteen-world run also reached a harder curriculum, 0.44 against 0.31, so some of the gap is that it trained on more; the direction is not in doubt.)
Training draws no race. It shows a console — generation, worlds, hardship, survivors, arrivals, the rate in generations per minute, and the merit curve — refreshed every ninetieth tick so the simulation never waits on a frame. Drawing sixteen live races cost about thirty times the wall clock for nothing the numbers do not already say; the console turns roughly two seconds per generation into twenty-four generations a minute.
Every race is reproducible from its seed: all randomness comes from a seeded PCG32 generator, and the simulation itself consumes none. That is what lets a champion be replayed on the exact channel it was bred against.
Each swimmer carries a genome of 491 floats, read as a 21 → 14 → 10 → 3 multilayer perceptron with tanh activations. It senses seven forward range readings, its alignment with the channel, its lateral offset, its own velocity in body frame, its stamina, the local flow, viscosity and corrosion, how crowded it is, and a proximity-gated beacon on the ovum. It outputs turn, thrust and surge.
Between generations the population is ranked and rebuilt: 50 elites survive untouched, 20 slots are refilled with fresh random genomes to keep diversity alive, and the rest come from tournament selection with BLX-α crossover and Gaussian mutation whose spread decays with generation count.
Merit rewards distance travelled along the channel axis — never Euclidean distance to
the ovum, which would pin swimmers against the nearest wall — plus a large bonus for
arriving, scaled by how early. Scraping walls divides that total rather than subtracting
from it: an earlier subtractive penalty clamped at zero and left 40 % of every generation
tied on the same score, so the tournament was ordering four hundred swimmers at random.
tests/suite.zc guards against the tie returning.
Swimmers sense the ovum only as a close-range beacon, gated by proximity. Handing them an unconditional bearing to the goal measurably hurts: they learn to swim straight at it and stop reading the channel, and arrivals collapse in the later generations.
Geometry is analytic rather than baked. The centreline is the sum of two sines and the half-width pinches with a third, so the signed distance to a wall is a closed-form expression normalised by the gradient magnitude. Range sensing is sphere tracing over that expression: no grid, no memory, exact at any zoom.
Hazards are discs that contribute flow, viscosity, corrosion or death. Currents push and swing, acid pools burn, mucus thickens the medium, and phagocytes hunt the leader and kill on contact. Phagocytes are the only hazard swimmers can see with their range sensors, because they are the only solid ones.
Difficulty is a curriculum aimed at a six percent arrival rate: the channel twists harder, pinches tighter and carries more hazards when the population clears that bar, and eases when it doesn't. The controller smooths the arrival rate first, ignores errors inside a dead band, and moves proportionally to what's left. A plain bang-bang version — always ±0.03, no dead band — hunted forever instead of settling: over sixty generations it reversed direction 18 times against this one's 2, and parked the arrival rate at 8.4 % instead of 5.8 % against a 6 % target.
Only --replay uses this; training draws nothing and --play simply follows you.
There are two shots and the director cuts between them every SHOT_DURATION seconds,
locking onto the leader once the front passes FINISH_FOCUS. Chase trails the race
front by LENS_TRAIL; field sits on the leading quantile of the pack. Within a shot
the focus eases and may only fall back at LENS_RECOVER units per second; a cut bypasses
both and jumps.
Two shots rather than one because measurement said one is impossible. Chase keeps the leader on screen essentially always but shows ~50 swimmers; field shows ~300 but the leader appears 2 % of the time. Interpolating between them lands in the empty gap and gets the worst of both — ~90 swimmers, leader on screen 4 %. The field genuinely spreads across the whole channel: even the leading 5 % sit around 1,000 units behind the front against a half-view of 570. The progress rail on the right edge carries what neither shot can.
An earlier camera picked the densest histogram bin, and its > tie-break resolved to the
lowest bin index — the back of the track — so a thinly spread field parked the camera on
the stragglers. It also read the leader from the live swimmers only, so every arrival and
every death yanked it backwards: worst case 988 units per second against the 90 it manages
now.
src/
main.zc composition root and argument handling
config.zc every tunable constant
core/ scalar and vector maths, colour, PCG32, ranking, history ring
ai/ senses, Pilot trait, genome, network, breeding, population
sim/ channel, distance field, hazards, stage, swarm, physics,
perception, scoring, Observer and Workforce traits, the
multiverse and the contest loop
view/ raylib boundary, palette, camera, shot gallery, body geometry,
scene, overlay, helm, and three Observers
app/ curriculum, session, replay, duel
io/ the SQLite vault
tests/suite.zc 18 cases, run with `make test`
data/spexma.db runs, generations and champion weights
Four traits carry the design. Pilot lets the simulation drive a swimmer without knowing
what a neural network is — which is the whole reason a human can be dropped into a race. Observer lets one race loop serve the headless run, the
rendered exhibition and the instrumented harnesses, which is also what guarantees they
agree. Hazard lets the stage sum influences without knowing what kinds exist —
sim/hazards/roster.zc is the only module that names them all. Workforce decides
whether the deliberation pass runs on one thread or many.
./spexma --play is one against one. You are swimmer zero; swimmer one is the champion
pulled from the vault. No crowd, no clones — just you and the model, so the result says
something about the model.
Which champion is strongest is not the one with the highest merit. Merit is only
comparable within a hardship level: a good swimmer scores about 1,700 on an easy channel
and less than that on a hard one, so ranking by merit systematically picks early, easy
generations. strongest orders by how deep into the curriculum a generation got, then by
generation, which is the honest measure of skill.
The only new machinery this needed was a Helm that implements Pilot. The simulation
drives a human exactly the way it drives a network, because it never knew the difference.
./spexma --versus races two champions head to head and keeps a running score in the
banner, re-seeding the channel after every race. The score is the point: a single race
between two models is mostly noise. Watching one frame of generation 0 against generation
59 showed the older one ahead — over twelve unseen channels with two hundred clones each,
generation 0 lands 0.04 % of its swimmers on the ovum and generation 59 lands 55.9 %.
| generation | arrivals on twelve unseen channels |
|---|---|
| 0 | 0.04 % |
| 10 | 10.4 % |
| 20 | 30.4 % |
| 40 | 36.9 % |
| 59 | 55.9 % |
./spexma --replay loads the best champion so far, stamps it onto all thousand swimmers,
and races the clones on the exact arena that champion faced — the weights file carries its
generation and hardship, so the race reconstructs itself. It loops until the window closes.
A field of clones reads the learned policy at a glance in a way the evolution view cannot.
Pass a path to watch a particular generation instead.
Everything a run produces lands in one SQLite file, data/spexma.db:
runs(id, seed, population, worlds, senses, hidden_a, hidden_b, controls)
generations(run, generation, hardship, mean_merit, peak_merit, arrival_rate)
champions(run, generation, merit, weights BLOB)
lineage(run, generation, hardship, peak, weights BLOB)
settings(name, value)Weights are a 1,964-byte blob — exact round trip, no decimal reconversion, and an eighth of the text it replaced. One champion per generation. Every row carries the network topology it was produced with, so widening the sensor budget starts a fresh lineage instead of silently mixing incompatible weights, and lookups search every run of that topology rather than only the newest — an earlier version read only the most recent run and offered a generation-9 opponent while a forty-generation run sat beside it. Writes happen once per generation and never inside a frame.
The previous scheme was a CSV and one text file per generation; after a thousand generations of twenty champions that would have been twenty thousand files, and no way to ask for "twenty distinct opponents from generation 24" without writing a query engine by hand.
A tick is two passes. Deliberation builds every swimmer's senses and runs its network: read-only, so any thread may compute any swimmer, and which one does cannot change the answer. Enaction integrates physics serially, because crowding reads neighbours' positions as they move and the order is part of the result.
Deliberation is where the time goes — raycasting is 66 % of a tick and the networks another 18 %, against 15 % for physics — so the pool covers 84 % of the work. Measured on nine workers: 2.09 s per generation down to 0.96 s, a 2.2× speedup. A test races the same seed once on one thread and once on the pool and asserts all thousand scores match exactly.
Crowding and the density sensor read a uniform grid rebuilt every tick. It used to be fixed-depth buckets, 48 swimmers per cell, and it overflowed badly: at hardship 0.67 a single race dropped nearly twenty thousand insertions. Dropped swimmers were invisible to the shove that keeps swimmers apart, so they piled up further, so more were dropped.
It is now a counting sort — tally per cell, prefix sum, scatter — which has no per-cell cap at all and uses an eighth of the memory. Peak occupancy across nine hardships fell from a pinned 48 to 25, because the pile-ups were an artefact of the cap. A test packs all thousand swimmers into one cell and asserts every one is still indexed, in its own cell, exactly once.
make and make check both report zero errors. The warnings that remain are all
inherent: twenty-seven Unknown type notes where std/thread and std/sync hand the
checker opaque pthread handles — their own source documents that those cannot be
described to Zen C — seven unused parameters in trait implementations that legitimately
ignore an argument, and one shadows a previous declaration false positive. Trait
parameters are deliberately given names used nowhere else, which is what keeps that last
count at one instead of ninety.