oykos technical reference.
The system in detail: architecture, schemas, evolutionary algorithm, physics, transport protocols, and the embedding API preview. Aimed at researchers, integrators, and the terminally curious.
1. Introduction
oykos is a habitat for embodied AI. Bodies and brains co-evolve in a persistent simulated world; visitors can watch evolution run, watch a multi-creature biosphere unfold, embed agents of their own (preview), and eventually train on the behavioural record. Everything below describes the v0.1 build: a single Python process running MuJoCo physics, an evolutionary algorithm, and a parallel biosphere; a Next.js frontend with three.js viewers; and a SQLite database of every genome and generation evaluated.
The two surfaces
/lab is the controlled chamber. One creature evaluates at a time, scored by forward locomotion. New generations replace old at a rate you can watch in real time. It’s where the population’s diversity lives — every body plan and brain that gets tried, tried here first.
/world is the open biosphere. Around ten evolved residents coexist on a 30×30m plane, sampled to span distinct founder lineages, top-quartile fitness, and a handful of fresh random genomes for variety. Residents that fall, wander, or age out are despawned and replaced.
The dataset goal
Every genome the Lab evaluates is persisted with its fitness, generation, and parent. Long-running goal: publish the resulting archive — genomes, generation summaries, full lineage trees, and behavioural trajectories — as a research-grade dataset for embodied AI work that previously had no comparable corpus.
Where to start
- New here? Section 2 (Quickstart) covers the five things to do in the first ten minutes.
- Building something on top? Sections 9 (WebSocket) and 10 (REST) are the integration spec.
- Curious how it works? Sections 3, 7, and 8 are the engineering tour.
2. Quickstart
Five steps. None require running anything locally — they all work on the public site.
2.1 Watch the Lab
Open /lab. The viewport renders the latest creature the evolution loop is mid-evaluating; the camera tracks its root segment with a damped follow rig. The right panel updates every ~17ms with telemetry. Look at the bottom strip — each tick is one completed generation, height proportional to best fitness. A run that’s improving has visibly rising peaks.
2.2 Visit the World
Open /world. Around ten residents coexist; their morphologies and gaits should look distinctly different from each other. Drag to orbit, scroll to zoom. Each resident carries a floating mono label with its lineage tag.
2.3 Focus on a resident
Click any row in the RESIDENTS panel. The camera lerps to that creature, the others fade to 10% opacity, and OrbitControls starts rotating around the focused creature so you can inspect its gait in context. Click again or hit Show all in the right panel to release.
2.4 Download a genome
Back in /lab, the right panel has an Export Genome action. It downloads a JSON file identical to what the REST endpoint /api/genome/:id returns. The shape is documented below in Section 6.
2.5 Read the rest of these docs
Pick by audience:
- Researchers — §7 Evolution algorithm and §12 Dataset describe what gets searched, what gets recorded, and how to access it.
- Builders — §9 WebSocket protocol, §10 REST API, and §11 Embedding API are the integration spec.
- Curious observers — §3, §8, and §14 answer most of the “how does this actually work?” questions.
3. Architecture
oykos is one Python process and one Next.js app, talking over a single port. Two simulation loops (Lab evolution + Biosphere) run as parallel asyncio tasks, sharing a SQLite database and a websocket server. The frontend is Next.js 14 with three.js for the viewers; the landing and the docs render server-side, the Lab and the World are client components.
The simulation process
python -m oykos_sim.main boots a websocket server on ws://localhost:8765 with two asyncio tasks running under one asyncio.gather:
- run_evolution — keeps stepping
EvolutionRun.step_generation(). Every generation evaluates a population of 20 creatures, broadcasts frames for one of them at 60Hz, persists every genome and the generation summary, then mutates and continues. - run_biosphere — instantiates a
Biosphere, populates it with a diverse roster drawn from the same SQLite database, steps physics on the shared multi-creature MJCF, and broadcasts frames + spawn events on its own WS path. Despawned residents are replaced inline with new diverse draws.
Persistence
SQLite, single file at sim/data/oykos.db, WAL journaling. Three tables: runs, generations, genomes. The full schema is documented in §12 Dataset. All access is single-threaded from the asyncio event loop; there’s no ORM.
Transport
WebSockets stream live state at 60Hz. There are two paths: /stream for the Lab’s currently-rendering creature, /biosphere/stream for the World. They live on the same port (8765), routed by ws.request.path in the connection handler.
REST endpoints share that port too: the same websockets server intercepts non-WebSocket HTTP requests via a process_request hook and answers them directly. There’s no separate HTTP framework.
Frontend
Next.js 14 (app router). The landing page (/) and the docs (/docs) render server-side; the workspace surfaces (/lab, /world) are client components because they own a WebSocket and a three.js scene. Both viewers share a coordinate-system mapping so the same physics frames work in either viewport (see §8).
4. The Lab
The Lab simulates one creature at a time. Each evaluation runs the creature’s evolved controller against MuJoCo physics for 15 sim seconds; fitness is the forward (+X) displacement of its root segment. Frames stream to the browser at 60Hz while the eval is rendering. Headless evals run as fast as MuJoCo will go.
UI walkthrough
Top bar: brand lockup, route title (THE LAB), live connection status (OPEN / CLOSED), and sim metadata (run id, current generation).
Left panel: workspace nav (Lab / World / Dataset / Docs), followed by two browsable lists. POPULATION shows the 10 most recent top genomes from /api/lineage; clicking a row marks that genome as the active one in the parameters panel. LINEAGE is the same data grouped by founder lineage, with the highest fitness in each lineage as the row label.
Center viewport: three.js scene rendering whatever /stream is currently broadcasting — one creature, camera following its root with a damped lerp rig, a fine-grid floor, soft shadows. A small telemetry HUD pins generation, creature id, and live fitness in the upper-left corner.
Right panel: PARAMETERS (the active genome’s morphology and brain shape), ACTIONS (Add Agent modal, Pause Run toast, Export Genome download), LIVE STATS (best/mean fitness for the current generation, residents count, runtime).
Bottom strip: one tick per generation, height proportional to best-fitness in that generation, scrolling from left to right as new generations land. A run that’s improving has a visibly rising envelope.
How to read the data
Healthy progress looks like a noisy upward trend on the bottom strip. A flat envelope for hundreds of generations is a sign of a local optimum — usually a single dominant lineage that evolution can’t mutate out of. The fresh-injection mechanism (see §7) replaces the bottom of the population every 10 generations to kick the search out of plateaus.
Useful baselines: random genomes typically score 0.0 to 0.5m; early evolved gaits reach 1-2m; well-tuned lineages clear 4-5m. The eval is 15 sim seconds, so 5m of forward travel is a sustained ~0.33 m/s gait.
Action reference
+ Add Agent— opens a modal pointing at the embedding API preview. Live in v0.2.Pause Run— emits a UI toast for now. The sim loop currently has no pause primitive; preserving generation state on disk-checkpointed runs is planned for v0.2.Export Genome— downloads the active genome as JSON viaGET /api/genome/:id. The full schema is in §6.
5. The World
The World is a multi-creature biosphere on a 30×30m square plane (WORLD_HALF_EXTENT = 15.0 in biosphere.py). Around ten residents coexist by default (DEFAULT_MAX_RESIDENTS = 10); each runs its own evolved brain against its own actuators. Physics is the same MuJoCo engine the Lab uses — the World’s MJCF just has all ten body chains stacked under one worldbody.
Resident lifecycle
- Sourced — every spawn is drawn by
select_diverse_genomes(rng, n=1, run_id=None)which mixes 40% best-of-distinct-lineage, 30% top-quartile, and 30% fresh-random genomes. Initial roster of 10 is drawn the same way; respawns are single draws from the same distribution. - Spawned — at a uniform random
(x, y)in±SPAWN_RANGE = 12.0mwith a random yaw. Spawn z is computed from the body’s stacked segment heights plus 0.4m clearance. - Wanders — driven by its own
Controllerreading per-resident sensor slices (jointpos × n_joints, framelinvel × 3) and writing torques to its actuator slice. - Despawn conditions:
wandered(root crosses±DESPAWN_OUT_OF_BOUNDS = 14.0m),fell(root z belowFALLEN_Z_THRESHOLD = 0.10mforFALLEN_LIMIT_SECS = 5.0s), oraged_out(resident has been alive longer thanAGE_OUT_SECS = 300s, i.e. 5 minutes). - Replaced — on despawn the multi-creature MJCF is recompiled with the new genome list. A rebuild failure is caught, the offending resident is dropped, and the rebuild is retried with a smaller list.
Camera controls
OrbitControls with damping enabled. Drag to orbit, scroll to zoom (range 1.2-36m), right-click drag to pan. The polar angle is capped just below horizontal so you can’t accidentally clip below the floor. The world fog kicks in around 12m so creatures at the boundary fade naturally.
Focus mode
Click any row in the RESIDENTS panel. Camera position lerps to creatureRoot + (2.5, 1.5, 3.0) at rate 0.05/frame (~400ms half-life). OrbitControls’ target lerps to the creature’s root, so subsequent drags rotate around it instead of around the world origin. All other creatures’ materials lerp opacity to 0.10; their floating labels fade in lockstep. Click again or hit Show all in the right panel to release. If the focused creature despawns, focus auto-clears.
Events feed
The left panel’s RECENT EVENTS shows the last 8 spawn / despawn events, timestamped. Wider hour-window stats (spawns per hour, despawns per hour, average resident age) populate the right panel.
6. Genome reference
A genome is a single JSON document describing one creature’s body and brain. The schema is defined in sim/oykos_sim/genome.py (Genome.to_dict / Genome.from_dict) and round-trips losslessly. Every evaluated genome is persisted in SQLite under genomes.genome_json.
Top-level fields
id— string, UUID4 hex. Primary key.parent_id— string or null. Null for founders (random genomes from run start or fresh-injection); a parent id otherwise.generation— integer ≥ 0. Founders are generation 0; mutation increments by 1 at each reproduction step.body— object withsegmentsandconnections.brain— object withweights,biases, andarchitecture.
Body segments
size— list of three floats. Half-extents along[x, y, z]in meters. Clamped by mutation to[0.10, 0.60]per axis. So a segment withsize = [0.30, 0.20, 0.15]renders as a 0.60 × 0.40 × 0.30m cuboid.joint_type— string."free"for the root segment (6-DOF freejoint anchoring it to the world),"hinge"for every other segment.joint_axis— list of three floats or null. Null for the root; one of(1,0,0),(0,1,0),(0,0,1)for hinges. Resampled atJOINT_AXIS_MUT_PROB = 0.03per hinge per generation.
Body connections
MVP creatures are linear chains: each connection wires segment from to segment to = from + 1. The anchor is the attachment point in the parent’s local frame — always the midpoint of the parent’s +X face, i.e. [parent.size[0], 0.0, 0.0].
Brain
architecture— list[input_dim, 16, output_dim].input_dim = n_joints + 3(joint angles plus the root’s 3D linear velocity from aframelinvelsensor).output_dim = n_joints(one torque per hinge).HIDDEN_DIM = 16is hard-coded.weights— two 2D matrices.weights[0]has shape[hidden_dim, input_dim];weights[1]has shape[output_dim, hidden_dim]. Initialised by dividing standard-normal samples bysqrt(fan_in).biases— two 1D vectors of lengthhidden_dimandoutput_dimrespectively. Same Xavier-style init.
Mutation operators (summary)
Documented in full in §7 Evolution. Quick index here for genome readers.
- Per-segment size jitter: 5% per segment, ±20% multiplicative per axis, clamped to [0.10, 0.60].
- Add segment / remove segment: 2% each, clamped to 3-6 segments. Rebuilds the brain with fresh Xavier weights.
- Joint axis resample: 3% per hinge, uniform over the three canonical axes.
- Brain weight jitter: ±0.12 Gaussian per weight, plus a 1% sparsity-kick that either zeroes the weight or replaces it with a wide N(0, 1.0) sample.
- Bias jitter: ±0.12 Gaussian per bias.
Example genome
A 6-segment, 5-joint creature. Brain weights truncated for legibility — a real genome lists every float explicitly, so an 8 → 16 → 5 network is 128 + 16 + 80 + 5 = 229 numbers. Comments are illustrative; actual JSON output has no comments (use the example below as a Python literal or strip the comments first).
JSONC{ // Stable UUID4 hex. Primary key in the genomes table. "id": "5ec947ba1f8c4e2db3a6f0e2d8c9a7b1", // Parent's id, or null for founders (random genomes injected at // run start or by the fresh-injection mechanism). Walking // parent_id back to the first null gives you the lineage's root. "parent_id": "9b3e1f02c84d4b91812733e6a05b8d4f", // 0-indexed; founders are generation 0, mutated children are // parent.generation + 1. "generation": 192, "body": { // 3-6 cuboid segments, indexed in chain order (segments[0] is // the root). The chain grows along the body's local +X axis. "segments": [ { // Half-extents along [x, y, z] in meters. So a segment with // size [0.30, 0.20, 0.15] is a 0.60×0.40×0.30m box. // Clamped to [0.10, 0.60] per axis by the size mutator. "size": [0.244, 0.164, 0.156], // Root segment uses a free joint (6-DOF, world-anchored). // Non-root segments use hinge joints (1-DOF rotational). "joint_type": "free", // Root has no joint axis; non-root chooses one of the three // canonical world axes. "joint_axis": null }, { "size": [0.281, 0.142, 0.190], "joint_type": "hinge", // World-aligned axis; sampled from {(1,0,0), (0,1,0), // (0,0,1)} on creation and re-sampled at 3% per joint per // generation. "joint_axis": [0.0, 1.0, 0.0] }, { "size": [0.198, 0.213, 0.122], "joint_type": "hinge", "joint_axis": [1.0, 0.0, 0.0] }, { "size": [0.265, 0.178, 0.155], "joint_type": "hinge", "joint_axis": [0.0, 0.0, 1.0] }, { "size": [0.225, 0.190, 0.143], "joint_type": "hinge", "joint_axis": [0.0, 1.0, 0.0] }, { "size": [0.301, 0.145, 0.169], "joint_type": "hinge", "joint_axis": [1.0, 0.0, 0.0] } ], // Linear chain only in MVP. Each connection anchors the child // body at the midpoint of the parent's +X face. "from"/"to" are // segment indices. "connections": [ { "from": 0, "to": 1, "anchor": [0.244, 0.0, 0.0] }, { "from": 1, "to": 2, "anchor": [0.281, 0.0, 0.0] }, { "from": 2, "to": 3, "anchor": [0.198, 0.0, 0.0] }, { "from": 3, "to": 4, "anchor": [0.265, 0.0, 0.0] }, { "from": 4, "to": 5, "anchor": [0.225, 0.0, 0.0] } ] }, "brain": { // [input_dim, hidden_dim, output_dim]. // input_dim = n_joints + 3 (joint angles + root linear vel). // output_dim = n_joints (one torque per hinge). // hidden_dim = HIDDEN_DIM = 16, fixed. "architecture": [8, 16, 5], // Two layers, both Xavier-scaled at init (Normal / sqrt(fan_in)). // weights[0] is shape [16, 8]; weights[1] is shape [5, 16]. // Truncated here for readability — a real genome lists every // float explicitly. "weights": [ [ [-0.21, 0.04, -0.55, 0.12, 0.31, -0.18, 0.42, -0.07], [ 0.09, -0.32, 0.18, -0.41, 0.06, 0.27, -0.13, 0.36], // ... 14 more rows, one per hidden unit ... ], [ [ 0.18, -0.04, 0.22, -0.09, 0.31, 0.07, -0.15, 0.41, 0.06, -0.22, 0.13, 0.02, 0.28, -0.11, 0.37, -0.05], // ... 4 more rows, one per output ... ] ], // biases[0] shape [16]; biases[1] shape [5]. "biases": [ [ 0.04, -0.11, 0.22, 0.07, -0.15, 0.31, -0.06, 0.18, -0.27, 0.09, 0.13, -0.02, 0.41, -0.08, 0.16, -0.05], [ 0.07, -0.13, 0.21, 0.04, -0.12 ] ] } }
7. Evolution algorithm
Defined in sim/oykos_sim/evolution.py. Plain Python, no torch, no special libraries beyond numpy. Designed to be small enough to read in one sitting.
Population
POP_SIZE = 20— total genomes per generation.TOP_K = 5— number of elites that pass to the next generation unchanged. Their fitness rank is preserved across generations as long as they keep winning.
Selection
Tournament of size 3, drawn from the FULL sorted population (not just the survivors). Three random indices are sampled; the lowest-index participant wins, which — because the population list is sorted by fitness descending — is the highest-fitness participant of the three.
Tournament selection is preferred over uniform sampling among the top-k for two reasons. First, it gives mid-tier creatures a non-trivial probability of becoming parents — the bottom of the population is still drawn from at ~30% per tournament, which preserves diversity that strict elitism throws away. Second, top genomes still win most tournaments (a top-5 genome wins ~58% of all 3-tournaments in a population of 20), so progress isn’t materially slowed.
Mutation operators
Reproductions stack — body and brain mutations both fire in a single mutate() call, gated by independent per-operator probabilities.
- Body size jitter —
SIZE_MUT_PROB = 0.05per segment. When fired, each axis is multiplied by1 + N(0, SIZE_MUT_SIGMA = 0.20)and clamped to[SIZE_AXIS_MIN, SIZE_AXIS_MAX] = [0.10, 0.60]. - Add segment —
ADD_SEG_PROB = 0.02. Only fires if the chain is belowSEG_MAX = 6segments. Adds a uniformly-sized hinge segment with a random canonical-axis joint at the tail. - Remove segment —
REMOVE_SEG_PROB = 0.02. Only fires if the chain is aboveSEG_MIN = 3. Removes the last segment. - Joint axis resample —
JOINT_AXIS_MUT_PROB = 0.03per hinge. When fired, picks a fresh axis uniformly from the canonical three. - Brain weight jitter — every weight gets additive
N(0, BRAIN_WEIGHT_SIGMA = 0.12)noise. - Sparsity kick — independent of the jitter,
BRAIN_RESET_PROB = 0.01per weight: 50% chance the weight is zeroed, 50% chance it’s replaced withN(0, BRAIN_RESET_SIGMA = 1.0). This is the only mechanism in the loop that creates large weight jumps, so it’s the main escape route from local optima. - Bias jitter — every bias gets
N(0, BRAIN_WEIGHT_SIGMA = 0.12)noise. No sparsity kick on biases. - Brain rebuild on topology change — if a segment was added or removed, the brain’s input/output dims change, so it’s thrown away and re-initialised with fresh Xavier-scaled random weights at the new shape.
Fitness
fitness = max(0, +X displacement of root segment over EVAL_DURATION = 15s). Negative displacement (creatures that walked backward) clamps to 0. There’s an early-termination penalty: if the root z drops below EARLY_TERM_Z = 0.10m at any point, the eval ends immediately and fitness is max(0, displacement_so_far × EARLY_TERM_PENALTY = 0.5). This penalises creatures that face-plant — a half-credit signal still gives evolution something to climb if the gait was promising before the fall.
Fresh injection
Every FRESH_INJECTION_INTERVAL = 10 generations, the bottom FRESH_INJECTION_COUNT = 4 of the next population (positions POP_SIZE-4 through POP_SIZE-1, but never overwriting the top-k survivors) are replaced with completely random genomes drawn from the same distribution as run start. The motivation is diversity preservation: stagnant lineages get displaced from the bottom, novel body plans get periodic shots at evaluation even when the search has plateaued.
Design intent
The three diversity mechanisms — tournament selection, the sparsity-kick weight reset, and fresh injection — sit at three different scales. Tournaments preserve diversity within a generation (parents come from across the ranking, not just the top). Sparsity kicks preserve diversity within a brain (one or two large weight changes per reproduction can flip an otherwise-stable controller). Fresh injection preserves diversity across generations (entire founder lineages get added at run-time even when evolution would otherwise have squeezed them out). The three together are why you see a plateau-and-jump pattern in fitness curves rather than smooth monotonic improvement.
8. Physics & MuJoCo
oykos uses MuJoCo as its physics engine via the official Python bindings (pip install mujoco). MuJoCo is the de-facto standard in robotics RL — it has accurate articulated body dynamics, contact resolution good enough for legged locomotion, and a CPU-only build that runs faster than most GPU-resident physics frameworks at this body count.
Timestep and eval duration
- Physics timestep:
0.002s(500 Hz). MuJoCo’s default; oykos doesn’t override it. No sub-stepping. - Eval duration:
EVAL_DURATION = 15.0ssim time = 7,500 physics steps per creature. - Frame broadcast rate: 60Hz, so the rendered creature emits a frame every 8-9 physics steps and sleeps to match wall clock. Headless evals skip the sleep and the frame emission.
MJCF generation
Each genome compiles to a fresh MJCF (MuJoCo’s XML body format) via creature.build_mjcf(). The chain is rooted at seg_0 (a free joint segment) and grows along the body’s local +X axis; each child segment is a nested <body> connected by a hinge whose anchor sits at the parent’s +X face. Spawn z is sum(seg.size[2]) + 0.4m clearance above the floor.
Sensors
Per creature, in this exact order (controllers index data.sensordata by offset):
- One
jointpossensor per hinge joint. Returns the joint angle in radians (range bounded by the joint limitJOINT_RANGE = (-1.57, 1.57), i.e. ±π/2). - One
framelinvelsensor on the root body (seg_0). Returns the root’s 3D linear velocity in world coordinates.
So the full observation vector has length n_joints + 3. That’s exactly the brain’s input_dim.
Actuators
One <motor> per hinge, with ACTUATOR_GEAR = 8.0 and the controller’s tanh output (range [-1, 1]) scaled by gear to produce joint torques. The actuator order matches the sensor order: per-creature, per-joint, in chain order.
Coordinate systems
MuJoCo is z-up: +z points away from gravity, +x is forward (the reward axis), +y is left. three.js is conventionally y-up. The Lab and World viewers map between them with a fixed swap that’s applied per body in applyBodyPose:
TypeScript// sim (z-up) → three (y-up) mesh.position.set(sim.x, sim.z, -sim.y); mesh.quaternion.set(qx, qz, -qy, qw); mesh.scale.set(sx, sz, sy);
The -sim.y negation flips handedness so that the creature’s +X (forward in MuJoCo) becomes -Z in three.js — the camera looks down -Z by convention, so a creature running forward in sim is running toward the camera in the viewer. This is also the reason creatures whose evolved gait chose to walk backward in sim appear to run away from the viewer; see §14.
Why CPU is enough
At 20 creatures × 7,500 steps per eval × 60Hz broadcast for only one of them, the entire Lab loop runs comfortably under full CPU on a single core of a modern laptop. The Biosphere steps a single combined model with ~10 creatures at 60Hz, also single-threaded. There’s no GPU dependency anywhere in the simulation path. WebGL is the only GPU-touching code, and only on the client.
9. WebSocket protocol
Two streams. Same port, different paths. JSON-encoded messages on both. Both broadcast at ~60Hz under normal load.
/stream — Lab
Single-creature evolution frames. Emitted at 60Hz while the Lab is rendering an eval; paused during headless evals (the loop renders one creature per generation, the rest run silent so wall-clock generations still finish in seconds). When a frame is paused you may go quiet for several wall-clock seconds at a time — this is normal.
Frame message: every 1/60s during a rendering eval.
JSON{ "t": 0.0334, "generation": 814, "creature_id": "a8f2b03c4d5e6f7081a2b3c4d5e6f708", "fitness_so_far": 2.14, "bodies": [ { "id": "seg_0", "pos": [0.0123, 0.0042, 0.4811], "quat": [0.9995, 0.0091, 0.0044, 0.0301], "size": [0.488, 0.328, 0.311] }, { "id": "seg_1", "pos": [0.488, 0.0, 0.481], "quat": [0.99, 0.01, 0.0, 0.14], "size": [0.562, 0.284, 0.380] } // ... one entry per segment, in chain order ... ] }
t— current sim time in seconds since the eval started.generation— integer, generation index of the creature being rendered.creature_id— UUID hex of the genome.fitness_so_far— non-negative float: forward displacement so far, in meters.bodies[]— one entry per body segment in chain order.posis world-frame position (x, y, z),quatis the world-frame orientation as MuJoCo(w, x, y, z),sizeis the segment’s full extents (size × 2 from genome half-extents).
Server events — non-frame messages, recognisable by a type field:
generation_complete—{type, generation, best_fitness, mean_fitness, population_size}. Fired after every generation finishes evaluating all 20 creatures.new_best—{type, creature_id, fitness, lineage}. Fired when a new generation produces a higher fitness than any generation before it in this run.fresh_injection—{type, generation, count}. Fired on the boundary generation when fresh-injection replaces the bottom of the population. Only emitted when injection actually fires.
/biosphere/stream — World
Multi-resident frames at 60Hz, continuously, while the Biosphere is alive. There is no “paused” state — the World’s job is to be observable.
Frame message:
JSON{ "t": 0.0334, "uptime": 1234.56, "residents": [ { "creature_id": "1f3a4b5c6d7e8f9012345abc67890def", "lineage": "192-5ec947ba", "fitness_origin": 4.97, "age_seconds": 84.3, "bodies": [ { "id": "c0_seg_0", "pos": [3.21, -1.04, 0.45], "quat": [0.999, 0.0, 0.04, 0.001], "size": [0.488, 0.328, 0.311] } // ... per-resident bodies ... ] } // ... one entry per resident ... ] }
uptime— seconds since the biosphere initialised. Wall-clock, not sim-clock.residents[]— current population. Order is stable per layout (positional indexes); a new resident only changes order on the next model rebuild.lineage— string, either"<gen>-<short_id>"(a DB-rooted lineage) or"fresh-<short_id>"for genomes generated on-the-fly by the diversity sampler.fitness_origin— the resident’s recorded fitness from the Lab when it was sampled. Fresh-random genomes havefitness_origin = 0.0.age_seconds— wall-clock age since spawn.bodies[]— same shape as the Lab frame, butidis prefixed withc{idx}_(e.g.c0_seg_0) so multiple residents’ segments don’t collide.
Server events:
resident_spawned—{type, creature_id, lineage, fitness_origin}. Fired once at initial seed (10 events per fresh start), then again every time a despawn slot is filled.resident_despawned—{type, creature_id, reason}wherereason ∈ { "wandered" | "fell" | "aged_out" | "rebuild_failed" }. The last value only fires if the post-despawn rebuild fails; treat it as a soft-error signal.
Subscriber example (Python)
Minimal client that prints generation summaries and frame cadence. Uses the same websockets library the server runs on.
Pythonimport asyncio import json import websockets async def watch_lab(): async with websockets.connect("ws://localhost:8765/stream") as ws: async for message in ws: payload = json.loads(message) # Server events use a "type" field; frames don't. if "type" in payload: if payload["type"] == "generation_complete": print( f"gen {payload['generation']}: " f"best {payload['best_fitness']:.2f}m, " f"mean {payload['mean_fitness']:.2f}m" ) elif payload["type"] == "new_best": print( f"new best in lineage {payload['lineage']}: " f"{payload['fitness']:.2f}m" ) continue # Frame: render or process as needed. t = payload["t"] fitness = payload["fitness_so_far"] bodies = payload["bodies"] print(f"t={t:.2f}s f={fitness:.2f}m bodies={len(bodies)}") asyncio.run(watch_lab())
10. REST API
Three endpoints today, all on the same port (8765) as the WebSocket server. Responses are JSON, Cache-Control: no-store, Access-Control-Allow-Origin: *. No authentication in v0.1.
GET /api/stats
Aggregate counts over the entire database, plus live biosphere status. The landing page’s counter strip and the World’s top bar both poll this endpoint.
Query params: none.
Response:
JSON{ "morphologies_catalogued": 53390, "generations_observed": 3558, "trajectories_recorded": 53370000, "lineages_diverged": 1492, "residents_count": 10, "biosphere_uptime": 11.58, "biosphere_active": true }
morphologies_catalogued— count of distinct genomes ever evaluated and stored.generations_observed— count of completed generations across all runs.trajectories_recorded— coarse approximation (generations × pop_size × eval_steps, withpop_size = 20andeval_steps ≈ 750). Treat as order-of-magnitude.lineages_diverged— count of founder genomes (rows whereparent_id IS NULL). Each is a root of an evolutionary tree.residents_count— number of currently-alive biosphere residents, updated in-place by the biosphere loop.biosphere_uptime— wall-clock seconds since the biosphere most recently initialised.biosphere_active— boolean, true while the biosphere is stepping; false during cold-start retry.
GET /api/lineage
Top genomes from the most recent run, decorated with lineage labels. The Lab’s POPULATION panel calls this on mount and on every generation_complete WS event.
Query params: none.
Response:
JSON{ "run_id": 4, "genomes": [ { "id": "5ec947ba1f8c4e2db3a6f0e2d8c9a7b1", "id_short": "5ec947ba", "generation": 192, "fitness": 4.97, "n_segments": 6, "n_joints": 5, "lineage": "192-5ec947ba", "parent_id": "9b3e1f02c84d4b91812733e6a05b8d4f" } // ... up to 10 entries, sorted by fitness DESC ... ] }
genomes is at most 10 entries, sorted by fitness descending. run_id is the most recent run id; if the database has no runs yet, { "run_id": null, "genomes": [] } is returned.lineage is the founder-rooted label "<root_generation>-<root_id_short>"; this label is shared across every descendant of the same founder.
GET /api/genome/:id
Returns a single genome’s JSON exactly as it was stored — the response body is the contents of genomes.genome_json, which round-trips losslessly through Genome.from_dict / Genome.to_dict. Schema is documented in §6.
Path params: :id — UUID hex of the genome.
Status codes:
200 OK— genome found, body is the genome JSON.400 Bad Request— empty id segment after the prefix.404 Not Found— no row with that id.
Coming with v0.2
/api/dataset/genomes— paginated genome browser with filters for run, generation range, fitness range, lineage./api/dataset/trajectories— signed-URL access to per-eval trajectory Parquet files./api/dataset/exports— bulk Parquet exports of the genomes and generations tables.
11. Embedding API v0.2 PREVIEW
This section describes a contract that is not yet live. Endpoints, types, error codes, and authentication flow are all subject to change before the v0.2 cut. Track this section for updates; nothing below should be hard-coded into production agents yet.
The Embedding API lets external agents take over an embodied creature in the World, receive the same sensor observations evolved residents use, and send torques back. The body genome is either inherited from a top lineage, picked by id, or fresh random.
Authentication
API key, scoped per workspace. Workspaces are the billing and rate-limit unit; one workspace can hold multiple agents and multiple humans. The key is sent on the WebSocket handshake as a Sec-WebSocket-Protocol sub-protocol value of the form oykos.embed.v1+key.<api_key>; this avoids URL leakage in proxy logs.
Connection flow
- Client opens a WebSocket to
wss://oykos.world/embed/v1, presenting the API key sub-protocol. - Server validates the key, picks a workspace, and sends a
session_openevent with quota and rate-limit parameters. - Client sends an
embody_requestnaming the body source, the surface, and an optional agent name. - Server confirms with
embody_grant+ the slot assignment, then begins the step loop.
Embodiment
body— one of: top-of-lineage by founder id, arbitrary genome id, top-fitness across all runs, or a fresh random genome.surface—"biosphere"(a slot in the open World; visible to other visitors) or"sandbox"(private 30×30m world, billed differently, no visitor visibility).name— optional. Up to 32 chars, shown in the World’s residents panel. Falls back to a generated short id.
Step protocol
Server pushes one step_observation per physics step at a configurable rate (default 60Hz). Client must reply with step_action within the latency budget (default 30ms; configurable per session). If the client misses, the server fills in zero torque and emits a step_skipped warning. After three consecutive skips the session is closed with code 4029 latency-budget-exceeded.
Observation schema
joint_angles— float32 array of lengthn_joints, in radians, range bounded by the joint limit.root_velocity— float32[3], world-frame linear velocity of the root segment in m/s.root_pose— float32[7],(x, y, z, qw, qx, qy, qz)world-frame pose of the root segment.neighbors— list of nearby resident summaries (creature_id, lineage, relative position) withinNEIGHBOR_RADIUS = 5.0m. Empty in sandbox surface.t— sim seconds since session start.
Action schema
Float32 array of length n_joints. Each element is a torque coefficient in [-1, 1], multiplied server-side by the actuator gear (currently 8.0). Values outside the range are clipped silently.
Termination & respawn
- Server-side termination conditions match the biosphere’s:
wandered(root crosses ±14m),fell(root z below 0.10m for 5s sustained),aged_out(5 minutes alive). On any of these, aterminatedevent ships and the slot is freed. - Client-side: send
releaseat any time to gracefully end the session. Closing the socket without it counts as a forced release; quota refunds half-credit. - Auto-respawn: opt-in. When enabled, on termination the same agent name is granted a fresh slot with a re-rolled body (or the same body if you locked it). Off by default.
Quotas & rate limits
- Free research tier: 1 concurrent agent, 60 minutes/day, biosphere-only. No commercial use.
- Paid tier: per-workspace quota, sandbox surface available, up to 4 concurrent agents per workspace.
- Latency budget: 30ms default, 100ms hard cap. Going over burns step credit; sustained breaches end the session.
SDK example (Python)
Pseudocode for the planned Python SDK. The actual SDK package ships with v0.2; the underlying WS protocol is what’s being committed to here, the wrapper is convenience.
Pythonimport os import time import numpy as np from oykos import World, GenomeRef # 1) Connect. The API key is workspace-scoped — one key, one quota. world = World.connect( "wss://oykos.world/embed/v1", api_key=os.environ["OYKOS_KEY"], ) # 2) Embody. Choose a body source: a top-of-lineage genome, a # specific genome ID, or a fresh random one. Choose a surface — # the open biosphere or a private sandbox. Optionally name the # agent so it appears with a label in /world. agent = world.embody( body=GenomeRef.top_of_lineage("0-7886132e"), surface="biosphere", name="my-agent-01", ) def my_policy(observation): """Take in (joint_angles, root_velocity, ...), return a torque vector clipped to [-1, 1] per joint. Replace this with your actual controller (anything from a hand-coded PD loop to a transformer).""" obs_vec = np.concatenate([ observation.joint_angles, observation.root_velocity, ]) # Trivial baseline: zero torque on every joint. action = np.zeros(observation.n_joints, dtype=np.float32) return np.clip(action, -1.0, 1.0) # 3) Step. The server pushes one observation per physics step at a # rate you can configure (default 60Hz). Your policy must reply # within the latency budget (default 30ms) or the server fills # in zero torque and warns. start = time.monotonic() for step in agent.run(timeout_s=600): obs = step.observation action = my_policy(obs) step.act(action) # Optional: react to events as you go. if step.event == "fell": print("Agent fell. Server will respawn me at the next slot.") break elapsed = time.monotonic() - start print( f"Survived {agent.lifespan_s:.1f}s, " f"travelled {agent.distance_traveled:.2f}m, " f"closed connection after {elapsed:.1f}s wall-clock." )
12. Dataset
Every evaluation in the Lab and every step of the World contributes to a single growing record. The v0.1 build captures genomes, generation summaries, and the lineage tree directly; behavioural trajectories and multi-agent interaction logs are committed but not yet persisted at full fidelity.
What’s recorded
- Genomes — every genome the Lab evaluates is inserted into
genomes, deduplicated by UUID (elites that survive a generation aren’t re-inserted). Includes the fullgenome_json, fitness, and parent pointer. - Generation snapshots — best and mean fitness for each generation, with timestamps.
- Lineage tree — implicit in
genomes.parent_id; walking parent pointers reconstructs the founder root for any genome. - Behavioural trajectories (planned, v0.2) — per-eval Parquet logs of
(t, obs, action, fitness_so_far)for both Lab evals and biosphere stretches. - Multi-agent interactions (planned, v0.2) — proximity events between residents in the World plus their mutual states at contact.
Schema
SQLite, single file at sim/data/oykos.db, WAL journaling. All access from the asyncio event loop on a single connection.
SQLCREATE TABLE runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at REAL NOT NULL, -- unix seconds seed INTEGER NOT NULL -- numpy rng seed );
SQLCREATE TABLE generations ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL REFERENCES runs(id), generation INTEGER NOT NULL, -- 0-indexed within run best_fitness REAL NOT NULL, -- top of pop in this gen mean_fitness REAL NOT NULL, finished_at REAL NOT NULL -- unix seconds );
SQLCREATE TABLE genomes ( id TEXT PRIMARY KEY, -- UUID hex run_id INTEGER NOT NULL REFERENCES runs(id), parent_id TEXT, -- NULL for founders generation INTEGER NOT NULL, fitness REAL NOT NULL, -- meters of forward travel n_segments INTEGER NOT NULL, n_joints INTEGER NOT NULL, genome_json TEXT NOT NULL, -- full Genome.to_dict() created_at REAL NOT NULL ); CREATE INDEX idx_genomes_run_gen ON genomes(run_id, generation); CREATE INDEX idx_genomes_fitness ON genomes(run_id, fitness DESC);
Access model
- v0.1 today:
/api/genome/:id,/api/lineage, and/api/statsare live. SQLite copy is local-only. - v0.2 planned: a paginated
/api/dataset/genomesbrowser with filters, signed-URL exports of the genomes / generations tables as Parquet, and a per-eval trajectory archive. - Licensing planned: free for non-commercial research; commercial use of bulk dataset products requires a per-seat or per-deployment licence at scale. The exact terms ship with v0.2.
13. Local development
The repo is a monorepo with two halves that talk to each other over WebSocket and HTTP on localhost:8765. Layout is documented in CONTEXT.md §4; the headline:
sim/— Python 3.11+, MuJoCo, numpy. Usesuvfor the venv atsim/.venv. Entry point:python -m oykos_sim.main.web/— Next.js 14 App Router, TypeScript, three.js. Usespnpm. Dev:pnpm dev.
One-time setup
Shell# from repo root pnpm setup # installs sim deps via uv, then web deps via pnpm
Daily dev
Shell# spawns the sim and the web dev server in parallel pnpm dev # or run them separately: cd sim && .venv/bin/python -m oykos_sim.main cd web && pnpm dev # opens http://localhost:3000
Recording fresh clips
The landing’s LiveDemo cycles short pre-recorded clips from web/public/clips/*.mp4. To regenerate them (e.g. after a long run that produced new top genomes):
Shell# from repo root, with sim's venv active or the helper script: .venv/bin/python sim/scripts/record_clips.py --limit 5
The script samples 5 diverse genomes from the database (using the same 40/30/30 lineage / top-quartile / fresh-random sampler as the biosphere), runs each through a 15s eval, trims to the most active 5.5s window, and writes mp4 + manifest into web/public/clips/.
Resetting a run
Wipe the database before next start:
Shellrm sim/data/oykos.db .venv/bin/python -m oykos_sim.main
Generation 0 of a fresh run takes about 10 seconds to evaluate; thereafter generations land at a few per second until the WS rendering eval takes over the next visualised creature.
14. FAQ
Why cuboid creatures? Why not nicer-looking bodies?
Cuboid chains are the field standard for evolutionary robotics going back to Karl Sims’ 1994 “Evolving Virtual Creatures”. The constraint matters: the simpler the morphology primitive, the more honestly any emergent behaviour can be attributed to the search rather than to the body designer. Nicer-looking bodies (capsules, bones, textured skins) are a v0.2 concern; the underlying physics doesn’t change.
Why does evolution sometimes stagnate?
Local optima. Once a single founder lineage dominates the top-k, elitism keeps copying it forward and small Gaussian weight noise can’t move it far enough to find a better basin. The three diversity mechanisms (tournament selection, sparsity-kick weight resets, fresh injection) exist to break out of these plateaus, but they’re probabilistic — a run can spend hundreds of generations on a flat envelope before catching a useful mutation.
Why is my creature walking backwards?
Coordinate handedness. The reward axis is +X in MuJoCo’s z-up frame; three.js is y-up so the viewer applies a swap and a sign flip on Y. Result: a creature evolving forward (+X) in sim moves toward the camera by convention. If the controller happened to learn -X, the gait scores 0 in fitness (which clamps negatives) but the rendered creature looks like it’s walking away. Mutations will eventually find a forward-going direction; the asymmetric reward makes that the only stable attractor.
Can I run this without GPU?
Yes. MuJoCo runs on CPU only and is fast enough that the entire simulation loop fits in a single core of a modern laptop with headroom to spare. The frontend uses WebGL for the viewers but the simulation never touches a GPU. There’s no CUDA or Metal dependency anywhere in the sim path.
Where’s the source code?
Coming with v0.2. Until then, this guide and the WebSocket / REST contracts in §9 and §10 are the public spec; everything documented here is what the production sim does.