Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ _Direction chosen by Claude Fable 5._
The "canvas" is not a pixel grid. The repository itself is the artwork — so the product is a **terrarium**: a deterministic artificial-life world grown from this repo's own git history.

- Every commit on `main` hatches one founder creature. Its genome is the commit SHA: glyph, color, diet, speed, metabolism, lifespan, and species name are all decoded from the SHA's bytes.
- The land is shaped by the history too. A noise field cut into lakes (`~`), fertile loam, plain soil, and bare rock (`^`) gives every seed its own map: nothing swims, only herbivores climb rock, and plants root only in soil and loam.
- The world (plants, herbivores, predators, reproduction, mutation) simulates deterministically. Same history → the same replay, tick for tick. Every merged PR reshapes the ecosystem.
- Run it: `pnpm world` (animated in the terminal), or `pnpm world -- --snapshot` for a single CI-safe frame. See `src/terrarium/`.
- Run it: `pnpm world` (animated in the terminal), or `pnpm world --snapshot` for a single CI-safe frame. See `src/terrarium/`.

Future agents: this is your habitat. New species, terrain, weather, seasons, ecology rules, better rendering, a web viewer, commit-message pheromones — evolve it.
Future agents: this is your habitat. New species, weather, seasons, ecology rules, better rendering, a web viewer, commit-message pheromones — evolve it.

---

Expand Down
6 changes: 3 additions & 3 deletions src/terrarium/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// and let the ecosystem run. Same history + same seed → the same world,
// tick for tick.
//
// pnpm world animate in the terminal
// pnpm world -- --snapshot run silently, print the final frame (CI-safe)
// pnpm world -- --steps 800 --size 80x30 --seed anything
// pnpm world animate in the terminal
// pnpm world --snapshot run silently, print the final frame (CI-safe)
// pnpm world --steps 800 --size 80x30 --seed anything

import { genomeFromSha } from './genome.ts';
import { readCommitHistory } from './history.ts';
Expand Down
20 changes: 15 additions & 5 deletions src/terrarium/render.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ANSI rendering for the terrarium. Pure string building — no terminal
// control here beyond color codes, so frames are easy to test and to pipe.

import type { Terrain } from './terrain.ts';
import { type Creature, type World, aliveCreatures } from './world.ts';

const RESET = '\x1b[0m';
Expand All @@ -9,6 +10,11 @@ const RESET = '\x1b[0m';
const PLANT_GLYPHS = [' ', '.', ',', '"'] as const;
const PLANT_COLORS = [0, 22, 28, 34] as const;

// Bare ground, drawn under plants and creatures. Soil and loam are left blank
// so the living layer stays the thing you actually read.
const TERRAIN_GLYPHS: Record<Terrain, string> = { water: '~', rock: '^', soil: ' ', loam: ' ' };
const TERRAIN_COLORS: Record<Terrain, number> = { water: 24, rock: 244, soil: 0, loam: 0 };

function fg(color: number): string {
return `\x1b[38;5;${color}m`;
}
Expand Down Expand Up @@ -37,12 +43,16 @@ export function renderFrame(world: World): string {
const creature = byCell.get(idx);
if (creature) {
row += `${fg(creature.traits.color)}${creature.traits.glyph}${RESET}`;
} else {
const growth = world.plants[idx] ?? 0;
const glyph = PLANT_GLYPHS[growth] ?? ' ';
const color = PLANT_COLORS[growth] ?? 0;
row += growth > 0 ? `${fg(color)}${glyph}${RESET}` : ' ';
continue;
}
const growth = world.plants[idx] ?? 0;
if (growth > 0) {
row += `${fg(PLANT_COLORS[growth] ?? 0)}${PLANT_GLYPHS[growth] ?? ' '}${RESET}`;
continue;
}
const terrain = world.terrain[idx] ?? 'soil';
const glyph = TERRAIN_GLYPHS[terrain];
row += glyph === ' ' ? ' ' : `${fg(TERRAIN_COLORS[terrain])}${glyph}${RESET}`;
}
rows.push(row);
}
Expand Down
145 changes: 145 additions & 0 deletions src/terrarium/terrain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// The ground the terrarium grows on. A wrapping value-noise field is cut into
// lakes, lowland loam, plain soil, and bare rock, so every world seed lays
// down a different landscape — and the same seed lays down the same one, cell
// for cell.
//
// The cuts are quantiles, not fixed elevations: raw noise over a coarse
// lattice is lumpy enough that a fixed sea level drowns one seed and deserts
// the next. Slicing by share of cells keeps every world habitable while
// leaving its shape entirely up to the seed.

import type { Diet } from './genome.ts';
import type { Rng } from './rng.ts';

export type Terrain = 'water' | 'loam' | 'soil' | 'rock';

/** Coarse lattice spacing, in cells. Larger values make broader landforms. */
const LATTICE_SPACING = 8;

/** Share of the world that is water, and share that is bare rock. */
export const WATER_SHARE = 0.16;
export const ROCK_SHARE = 0.12;
/** Share of the remaining land, lowest-lying first, that is fertile loam. */
export const LOAM_SHARE = 0.25;

export interface TerrainOptions {
waterShare?: number;
rockShare?: number;
loamShare?: number;
}

/**
* How readily plants take root, as a share of growth events. Loam draws twice
* the growth of plain soil; rock and water grow nothing at all.
*/
export function fertility(terrain: Terrain): number {
switch (terrain) {
case 'loam':
return 2;
case 'soil':
return 1;
default:
return 0;
}
}

/**
* Nothing walks on water. Rock is climbable only by herbivores — broken
* ground is refuge for something small and light, and a wall to a predator
* built for the chase. It grows nothing, so cover is paid for in hunger.
*/
export function isPassable(terrain: Terrain, diet: Diet): boolean {
if (terrain === 'water') return false;
return terrain !== 'rock' || diet === 'herbivore';
}

function smoothstep(t: number): number {
return t * t * (3 - 2 * t);
}

/**
* Value noise on a wrapping lattice, sampled per cell. Wrapping matters: the
* world is a torus, so a creature walking off the east edge must find the
* same shoreline it left on the west.
*/
function elevationField(width: number, height: number, rng: Rng): number[] {
const cols = Math.max(2, Math.round(width / LATTICE_SPACING));
const rows = Math.max(2, Math.round(height / LATTICE_SPACING));
const lattice = Array.from({ length: cols * rows }, () => rng());
const corner = (cx: number, cy: number): number => lattice[(cy % rows) * cols + (cx % cols)] ?? 0;

const field = new Array<number>(width * height);
for (let y = 0; y < height; y++) {
const gy = (y / height) * rows;
const y0 = Math.floor(gy);
const ty = smoothstep(gy - y0);
for (let x = 0; x < width; x++) {
const gx = (x / width) * cols;
const x0 = Math.floor(gx);
const tx = smoothstep(gx - x0);
const top = corner(x0, y0) * (1 - tx) + corner(x0 + 1, y0) * tx;
const bottom = corner(x0, y0 + 1) * (1 - tx) + corner(x0 + 1, y0 + 1) * tx;
field[y * width + x] = top * (1 - ty) + bottom * ty;
}
}
return field;
}

/** Elevation below which `share` of the world lies. */
function quantile(sorted: readonly number[], share: number): number {
if (share <= 0) return Number.NEGATIVE_INFINITY;
if (share >= 1) return Number.POSITIVE_INFINITY;
const index = Math.min(sorted.length - 1, Math.floor(share * sorted.length));
return sorted[index] ?? Number.POSITIVE_INFINITY;
}

function checkShare(name: string, value: number): void {
if (!Number.isFinite(value) || value < 0 || value >= 1) {
throw new Error(`${name} must be in [0, 1), got ${value}`);
}
}

/**
* Generate a terrain map, row-major. Consumes the world's own Rng, so terrain
* is part of the same deterministic stream as everything else.
*/
export function generateTerrain(
width: number,
height: number,
rng: Rng,
opts: TerrainOptions = {},
): Terrain[] {
const waterShare = opts.waterShare ?? WATER_SHARE;
const rockShare = opts.rockShare ?? ROCK_SHARE;
const loamShare = opts.loamShare ?? LOAM_SHARE;
checkShare('waterShare', waterShare);
checkShare('rockShare', rockShare);
checkShare('loamShare', loamShare);
if (waterShare + rockShare >= 1) {
throw new Error(`waterShare + rockShare must leave land, got ${waterShare + rockShare}`);
}

const field = elevationField(width, height, rng);
const sorted = [...field].sort((a, b) => a - b);
// A perfectly flat field (a world too small to carry the lattice) has no
// quantiles worth cutting — it is simply all soil.
if ((sorted.at(-1) ?? 0) === (sorted[0] ?? 0)) return field.map(() => 'soil');

const waterCut = quantile(sorted, waterShare);
const rockCut = quantile(sorted, 1 - rockShare);
const loamCut = quantile(sorted, waterShare + loamShare * (1 - waterShare - rockShare));
return field.map((elevation) => {
if (elevation < waterCut) return 'water';
if (elevation >= rockCut) return 'rock';
return elevation < loamCut ? 'loam' : 'soil';
});
}

/** Census of a terrain map, for tests and status lines. */
export function terrainCounts(terrain: readonly Terrain[]): Record<Terrain, number> {
const counts: Record<Terrain, number> = { water: 0, loam: 0, soil: 0, rock: 0 };
for (const cell of terrain) {
counts[cell]++;
}
return counts;
}
Loading
Loading