Skip to content

Latest commit

 

History

History
376 lines (362 loc) · 24.1 KB

File metadata and controls

376 lines (362 loc) · 24.1 KB

STATE.md — handoff notes (living document; update each milestone)

Read PROMPT.md first (the spec — do not modify it). This file tracks progress so a fresh session can resume without re-reading everything.

Where we are

  • M1 skeleton: DONE
  • M2 plants: DONE
  • M3 herbivores: DONE
  • M4 predation & flee: DONE
  • M5 controls & UX: DONE
  • M6 polish: DONE (chart, best-lineage card, tuning pass, acceptance)
  • M7 post-spec polish: DONE (spawn marker rings, shift-click ×5 spawn, tracked inspect line, splash controls legend, file:// watchdog)
  • M8 terrain & biomes: DONE (seeded biome generation + paintable Terrain tool; water/rock impassable, forest/tundra climate multipliers)
  • M9 hazard drops: DONE (Impact tool + severity 1..4; blast kill/damage falloff; s3 scorches the crater, s4 rocks the core + scorches the rim + rad cloud; scorch drains/blocks plants; rad amplifies mutation up to 10x; screen shake + blast ring; side stats panel with population/ecosystem/ evolution breakdowns)
  • M10 tornado + boons: DONE (Tornado = arm → drag a path → release; the head rips along it clearing plants and damaging creatures; Feast drops a mature plant cluster + energy-gain zone; Perk drops a fertility zone that halves the bud threshold to 60 in its core). Post-review fix: the tornado was too subtle to read and too weak to feel (user saw creatures "just keep moving"), so corridor damage is 20 energy/tick (a full ~7-tick pass kills a full-energy creature), the drag shows a faint corridor band the width of the hazard, and the head leaves a fading wake.
  • All spec milestones complete; M7/M8/M9/M10 were user-directed features after browser review.
  • Published: github.com/TheAmericanMaker/evolutionary-arena (public, MIT, README + provenance section, LICENSE, .gitignore).
  • Workflow change (user-directed): all work happens on the qwen-dev branch; changes reach main only via PRs that the human reviews before merging. Never commit features directly to main. PRs created by the agent must identify their author in the body, e.g. a closing line: "Created by the Qwen3.8-27B (local) agent running in opencode, on behalf of TheAmericanMaker."
  • Local server (dev tooling, PR #3): manage the port-8000 dev server with scripts/serve.sh [start|stop|status] — never hand-roll a backgrounded python3 -m http.server (one left in a dead shell session can hang with a full accept queue and block the port). stop finds the listener by port even without a pidfile.

Milestone rule: one milestone per turn, then STOP for human browser review. Do not auto-advance. End every turn with a 1–2 sentence done summary plus what the human should smoke-test. node --test from project root must be green, then commit the milestone: one commit per milestone, message format M<n>: <kebab-case summary> (e.g. M6: polish sparkline best-lineage-card plant-rate-tuning). Only that milestone's changes in the commit.

Roadmap backlog

Full public version in ROADMAP.md. Waves:

  • W1: M10 tornado+boons (DONE), M11 CI (DONE — GH Actions "Tests" check), M12 determinism regression (DONE — tests/determinism.test.js, 600 ticks, two worlds, strict JSON-equality snapshot), seed display + copy in HUD.
  • W2 (make evolution visible): color-by-lineage toggle (diet|lineage|size), lineage detail view (age, population, per-generation trait drift), trait drift sparklines in the stats panel, aging (per-tick cost grows with age).
  • W3 (deepen sim): metabolic trade-offs (speed/size burn energy — currently drift pushes all traits to their max), seasonal plant cycles (regen pool pulses), scenario presets (Drought, Ice Age, Mutagen Storm, Purge).
  • W4 (shareability): screenshot capture (canvas+stats PNG), README screenshot/GIF, interactive population chart (hover, diet series), responsive scale. Out of scope: audio, touch, multiplayer.

Tests

node --test "tests/*.test.js" → 74 pass / 0 fail (verified this session). Also runs in CI (M11): .github/workflows/test.yml — "Tests" check on push to main and on every PR (Node 22, ubuntu-latest, no dependencies to install). M12: tests/determinism.test.js is the reproducibility guard — same seed, two worlds, 600 ticks, strict JSON-equality of the full state snapshot (creatures incl. dna, plants, plant pool, effect zones, records, birth/death counters); also asserts the run exercised births+deaths, and that seed 7 vs 8 diverge.

  • rng.test.js (5), world.test.js (14: +spawn, +M8 terrain), dna.test.js (7: +standard genomes), entity.test.js (8: +M8 water steering), predation.test.js (7), stats.test.js (6: +fitness, records note/load/reset), terrain.test.js (7: M8 — determinism, valid ids, open dominance, toroidal lookups, biome data, paint/version, grid coverage), effects.test.js (9: M9 — blast radii, kill/damage falloff, scorch drain + growth-freeze + ttl expiry, s3/s4 terrain scars, rad falloff + decay, rad-amplified offspring mutation vs control, death-cause/birth tally), tornado.test.js (9: M10 — click releases no tornado, head speed + clamp, corridor clears plants / damages creatures incl. a full-energy (120) victim dying to a clean pass (deathCause 'hazard'), expire after path + linger, feast cluster + gain zone, feast grants energy via world.tick vs control world, fert mult falloff, bud at 70 inside the core but not outside, boon ttls (feast 300 outlives perk 240))
  • M8 note: createWorld now generates terrain, so movement/plant tests that assert fixed coordinates call clearTerrain(world.terrain) to control the layer (seed 3's plant-growth test and the predation chase/flee tests).
  • DOM is not unit tested; instead /tmp/ui-smoke.mjs boots the real engine.js against a minimal DOM stub and exercises every control (21 checks: M5's 13 — boot-paused, resume/space/step, spawn, inspect, kill, plant/feed brush, scatter, wheel clamp, records persist/reset, world reset — M6: sparkline draw calls, best-lineage card content, sparkline survives a post-reset empty window — M8: swatch arms terrain tool, drag paints tiles, version bumps for the bake, brush click leaves the mode — M9: impact arms, s4 drop scars rock core + scorched rim, rad/scorch zones added, shake + 180px blast ring pushed, side panel populates — M10: tornado arm → 3-point drag → release gives a 200px live tornado and disarms, feast click drops 8 plants + zone + 80px ring, perk click adds the fert zone and disarms, armed tornado + plain click releases nothing). Re-run: node /tmp/ui-smoke.mjs from the project root (tmp file, may vanish after reboot — it is disposable, the real gate is node --test).

Key constants & decisions (already encoded, don't re-derive)

  • world.js: W=1024 H=640 DT=1; wrap(); plant pool start 200 / max 1000 / regen +2/tick / sprout cost 5 / max 400 plants / growth +1/tick to 20; initial 20 creatures @ energy 60, lineages L1..L20.
  • entity.js: MAX_ENERGY=120, REPRO_THRESHOLD=120, BIRTH_COST=40, OFFSPRING_ENERGY=40, MAX_TURN=0.35 rad, WIGGLE=0.25 rad. Subtlety: reproduce check runs BEFORE the metabolism drain (a post-drain >=120 check would be unreachable). Metabolism is subtracted after, so parent post-bud energy = 80 - metabolism.
  • dna.js: sigma = 0.4 * traitRange * mutationRate, Box-Muller, clamped. CARNIVORE_AGGRESSION = 0.55 is exported but unused — that's M4's job.
  • spatial.js: 64px hash grid; toroidDelta/Dist/Angle; queryCircle is edge-safe (wrapped cell indices + true toroidal distance filter).
  • render.js: thin, untested. Hue = 130 - aggression*130 (green→red). M6: drawSparkline(ctx, samples, width, height) — HUD population chart over stats.samples (last 200 ticks); total = veg + carn as filled area + grey line, veg/carn split as green/red lines, y auto-scales to window max (floor 10), empty window (boot/reset) just clears.
  • engine.js: only file touching DOM. rAF loop, N substeps = speed slider (1–64 in index.html), sliders read every frame into world.settings.
  • stats.js (M4, real): createStats() rolling window of { tick, veg, carn, energy } samples, WINDOW=200, record(world) evicts oldest; world owns world.stats, tick() records one sample AFTER the dead-filter (sample = end-of-tick populations).
  • ui.js (M5, real): owns ALL DOM wiring; createUI(app) where app = { world, paused } (engine exports it). Sliders feed world.settings every frame in hud(). Decisions: starts paused under intro splash (space on body or Resume toggles); Step ticks once paused or not; speed slider 1..64, wheel over canvas nudges ±1 clamped; Spawn buttons = HERBIVORE_DNA/CARNIVORE_DNA (dna.js: herb spd2.0 vis80 met0.06 agg0.2 size2.5; carn spd2.5 vis100 met0.08 agg0.8 size4.5 — carn passes size+1 on herb) at random toroidal spots via world.rng, energy 40, lineages U1, U2, ...; canvas click by tool: Plant(default) — creature under cursor (<= max(6, size+3)) is INSPECTED (DNA line at cursor), else drop plant (drag paints ~1 per 8px; user plants cost no pool energy, bypass MAX_PLANTS); Feed — tool wins over inspect, click/drag gives +10 energy to creatures within 12px; shift-click scatters (plant: 5 in 48px disc; feed: radius 24); Kill under cursor = one-shot arm mode, next click removes the creature immediately (works while paused).
  • ui.js (M6): hud() also (a) calls drawSparkline on #popChart (240×48 canvas in the HUD row) every frame, and (b) fills #bestCard from records.data.bestFitness — hidden until the first tick, then BEST <lineage> · fit <value> plus the DNA snapshot (spd/vis/met/ agg/size). Survives world reset (card keeps the carried-over all-time record; sparkline redraws from an empty window).
  • M7 decisions (user feedback): spawn buttons put creatures at RANDOM spots, which looked dead → every spawn pushes a marker {x, y, t, hue} to app.fx; engine prunes after render.js SPAWN_FX_TICKS=90, render draws an expanding fading ring (4→30px, hue = 130 - aggression*130, same formula as drawCreature). Shift-click on +buttons spawns 5 (same convention as canvas scatter); tooltips on the buttons, legend line on the splash. Inspect line now FOLLOWS the creature (ui.js keeps an inspected ref, hud() calls refreshInspect() each frame: live E/fit, repositioned over the creature, auto-hides on death, cleared by any other canvas action or reset). window.__arena = app is a console/debug handle (browser smoke tests use it). Verified in headless Chromium: ring pixels confirmed by radial getImageData scan (pause freezes the ring at its radius).
  • M8 decisions (user-directed feature arc; user answered design questions: generated+paintable terrain, single Impact+severity slider for hazards, radiation = mutation multiplier + drain):
    • New terrain.js: TILE=32 (32×20 tiles), biome ids 0..4 in a Uint8Array, BIOMES table (open/water/rock/forest/tundra) with passable, plantable, plantMult, metaMult, color. Generation = seeded blob fields (3 water, 3 rock, 2 tundra, 4 forest blobs; toroidal distance; edge jitter 0.8..1.2r; forest drawn last to trim shores; open stays dominant ~55-70%). paint(x,y,id) bumps version on real change; clearTerrain() for tests.
    • world.js: world.terrain = createTerrain(rng) (consumes rng before spawn placement — deterministic per seed). freeSpot() = 32 random passable tries → coarse grid scan → world center; used by initial creatures and spawn(). Sprouting retries up to 16× on plantable tiles (regen deferred to the pool if none found). Plant growth × plantMultAt (forest 2×, tundra 0.5×).
    • entity.js: movement — if the step along the heading is blocked (water/ rock), align in one tick to the first open shore direction (±45° then ±90°, right-first) and step along it; if all blocked, hold ground and re-steer next tick. Deliberate choice: direct alignment (not gradual MAX_TURN steering) — gradual turns overshot 90° and carried creatures AWAY from wide walls. Metabolism × metaMultAt (tundra +25%). Newborn offset falls back to the parent's spot if impassable.
    • render.js: biome layer baked to an offscreen canvas, cached on terrain.version, blitted after the background fill (before grid); deterministic per-tile speckles (forest dots, tundra flecks, rock inset) so biomes don't read flat.
    • UI: Terrain button + 5 biome swatches (water preselected). Swatch click = pick biome + arm tool; Terrain button toggles; brush click leaves terrain mode. Drag paints the tile under the cursor; shift-click splats 5 tiles in a 96px disc; painting clears inspect.
    • ui.js display fix: fit values render via Math.round, not toFixed(0) — toFixed prints "-0" for (−0.5, 0) fitness, which is legal early on (gain 0, spent > 0).
    • Verified in headless Chromium (test5.mjs): all four biome colors on canvas (open still dominant), swatch→drag paints tiles (typeAt 0→1), version bumps, water pixels render after re-bake, no creature sits in water, zero console errors.
  • M9 decisions (user-directed: bigger impacts must change terrain; add a side stats readout with evolution detail):
    • New effects.js: zone layer owned by the world (world.effects), ticked in world.tick(). Zones {kind, x, y, r, power, ttl, maxTtl}:
      • scorch (every impact): drain SCORCH_DRAIN=1.5 × power × (1 - d/r) energy/tick, blocks sprouting AND freezes plant growth (ash); ttl = 150·s.
      • rad (s4 only): slow constant drain RAD_DRAIN=0.03 × (1 - d/r) plus the mutation hook — offspring bred inside inherit the parent's spot, so updateCreature's bud uses mutationRate × radMultAt, where radMultAt = max over rad zones of 1 + 9·power·(1 - d/r)·(ttl/maxTtl) → 10× at a fresh core, decaying linearly to 1 at expiry (ttl 900). mutateDna itself is unchanged (always mutates at rate>0; sigma scales with rate), so radiation widens the spread, not the odds.
    • Impact(x, y, s): R = 40s+20 (60/100/140/180). d < 0.5R → dead (deathCause 'hazard'); 0.5R..R → 25s×(1-d/R) energy damage to creatures and plants. Terrain scars: s1-2 none; s3 → crater (d < 0.6R) painted scorched id 5; s4 → core (d < 0.45R) rock id 2 (impassable), rim (0.45R..0.75R) scorched, + rad cloud r=1.25R.
    • terrain.js: biome id 5 scorched — passable, unplantable (plantMult 0), metaMult 1.1 (mild climate cost), color #171210; paintable swatch.
    • world.js: world.deaths {starve, predation, hazard, user} + world.births (buds only; user spawns are not offspring). Tick: births++ per bud, zone drain applied after each creature's update (a 0-energy death here is 'hazard'), effects.tick(), then death-cause tally before the dead-filter. User Kill bypasses the dead-filter, so ui.js increments deaths.user directly.
    • entity.js: deathCause set at each death site ('starve' at the met drain, 'predation' on a killing bite); bud mutation rate = settings.mutationRate × effects.radMultAt(parent spot).
    • ui.js: Impact button = one-shot arm mode like Kill (severity slider 1..4, default 3); on drop: impact(), blast fx {ring: R} (render expands out to the blast radius instead of the 30px spawn ring), app.shake = 3 + 2.5s, inspect cleared. hud() now also calls updatePanel().
    • New panel.js: updatePanel(els, world) — pure DOM writes from world state, ids collected once via panelIds(). Sections: Population (live count, herbivore/carnivore split via isCarnivore, all-time peak, births, deaths by cause), Ecosystem (plants, biomass = total plant energy, regen pool, avg energy), Evolution (distinct live lineages, max generation + lineage, all-time best fitness, and the LIVE mean of all 5 DNA traits — "what the population is becoming").
    • render.js: render(ctx, world, fx, shake) — shake ≥0.5 translates the frame by ±shake/2 random (engine decays shake ×0.85/frame to 0); drawZones paints scorch (red, alpha 0.05 + 0.04·power·(0.5+0.5fade)) and rad (green, 0.06 + 0.05·fade) glows after terrain, before plants; scorched tiles get deterministic ash speckles in the bake.
    • index.html: HUD row gains Impact + severity slider; 6th swatch (scorched); canvas wrapped in #stage and #statsPanel sits beside it (224px, HUD+canvas+panel share one left-aligned #frame column); splash legend gains the Impact line.
    • Verified in headless Chromium (test6.mjs): panel visible + populating (counts, lineages, trait means), s4 drop → rock core (impassable) + scorched rim + rad/scorch zones + hazard deaths, far tile untouched, crater pixels distinct from water/open (the fresh glow tints them, so pixel asserts are "distinctly not water/background", exact colors are the tile ids), no creature sits in the new rock, zero console errors.
    • M10 decisions (user-directed: tornado hazard + two boons):
      • effects.js: Tornado is world-owned state, not a zone. startTornado(world, pts) dedupes sub-px points, requires a path >= MIN_TORNADO=24 px (a plain click releases nothing), and stores { pts, cum, total, dist, linger, x, y } on world.tornado. tickTornado(world) — called from world.tick after the creature loop, before the death tally — advances the head TORNADO_SPEED=6 px/tick along the polyline (tornadoPoint interpolates by arc length), clears plants and deals TORNADO_DMG=20 energy/tick to creatures within TORNADO_RADIUS=20 (toroidal; deaths = 'hazard') — a full corridor pass is ~7 ticks ≈ 140 damage, so a full-energy (120) creature dies to a clean sweep while a 1-3 tick graze only wounds (post-review tuning; was 8, which let full-energy creatures shrug off the pass) — then lingers TORNADO_LINGER=40 ticks at the path end and clears. The path is screen space (a seam-crossing drag is one long chord); only the corridor geometry wraps.
      • effects.js boons on the M9 zone layer: feast(world,x,y) drops FEAST_PLANTS=8 mature plants (energy PLANT_MAX_ENERGY) in a FEAST_CLUSTER=40 px disc (user action — bypasses plant pool and MAX_PLANTS like brush plants) plus a 'feast' zone (r=FEAST_R=80, ttl=300) granting FEAST_GAIN=1 × power × (1-d/r) energy/tick via effects.gainAt (world.tick applies it, capped at MAX_ENERGY). perk(world,x,y) adds a 'fert' zone (r=FERT_R=90, ttl=240): effects.fertMultAt = 1 - FERT_STRENGTH=0.5 × power × (1-d/r), deepest zone wins, 1 outside.
      • entity.js: bud check is now c.energy >= REPRO_THRESHOLD * world.effects.fertMultAt(c.x, c.y) — a fresh fert core halves the threshold (120 → 60).
      • world.js: world.tornado = null in createWorld; tick() applies gainAt per creature (after the M9 drain) and calls tickTornado() before effects.tick() so tornado deaths tally in the same tick.
      • ui.js: Tornado = arm → mousedown starts the draft path, mousemove appends points >=12 px apart (render.js draws the corridor band + dashed centerline + start ring + end dot while dragging), mouseup calls startTornado, pushes a 2×radius fx ring at the path start, sets shake 4, and always disarms (one-shot). Feast/Perk are one-shot arm modes like Impact: next canvas click drops the boon (fx rings 80 / 90 px) and disarms.
      • render.js: render(ctx, world, fx, shake, draft) — drawTornado paints three rotating arcs + the corridor ring at the head, plus a fading wake (three trailing arcs sampled 12/24/36 px back along the path, clamped at the start) so a 6 px/tick sweep is visible; drawZones gains feast (amber) and fert (pink) fades; drawTornadoDraft draws a faint band 2×TORNADO_RADIUS wide (round caps) under the dashed centerline so the sweep's reach reads before release.
      • engine.js: app.tornadoDraft (ui's draft path, passed to render); while world.tornado is live the frame loop holds a shake floor of 2.
      • index.html: Tornado/Feast/Perk buttons after the severity label; splash legend gains both lines. No new CSS (.btn.armed reused).
      • Verified: node --test (9 tornado tests), /tmp/ui-smoke.mjs (21 checks), headless Chromium (test7.mjs): buttons visible, drag releases a ~120px tornado that expires after path + linger and its corridor plant is swept, feast click drops 8 plants with core gain 1, perk click gives core mult 0.5, zero console errors.
    • M11 plan (next milestone): W1 remainder — GitHub Actions CI running node --test, determinism regression test (same seed, two worlds, N ticks, identical state hash), seed display + copy in HUD.
  • M6 tuning decision: DEFAULT_PLANT_RATE 0.05 → 0.3 (index.html slider now 0..0.5, default 0.30). Grid probe (plantRate × mutation, 3000–5000 tick runs, 5 seeds) showed plant inflow is the dominant boom/crash lever (MAX_PLANTS had no effect — plants rarely pool near the cap). 0.3 keeps no-extinction/no-blowup with big swings; mutation stayed 0.1 (spec default range). Also fixed a stale hardcode: entity.js eating radius now plantRadius(PLANT_MAX_ENERGY) instead of plantRadius(20).
  • engine.js (M5): slim bootstrap — app state { world, paused } (exported for the smoke harness), rAF loop: if !paused tick n=ui.speed() times, render, ui.hud(). No other DOM.
  • stats.js: +fitness(c) = gain - spent + offspring (spec §4.5); createRecords() all-time bests { bestFitness: {value, dna snapshot, lineageId, tick}, longestLineage: {gen = max generation observed, lineageId, tick}, peakPopulation: {pop, tick} } — note(world) each tick after dead-filter, load(stored) merges better-per-field, reset(). world.records owned by createWorld.
  • Records persistence (ui.js): localStorage key 'arena_records', merged on load, autosaved every 20s + on pagehide; Reset records clears both; Reset world keeps records (all-time, re-merged into the fresh world).
  • M4 decisions (documented at top of entity.js): ATTACK_COST=10 (predator must hold >=10 to bite; cost subtracted, not counted in c.spent which stays metabolism-only per spec); PREY_LOSS=30 FIXED chunk (a fraction would asymptote to 0 and never kill); bite range = predator.size + prey.size; bite replaces movement that tick; c.state ∈ FLEE|HUNT|SEEK_FOOD|WANDER set each tick (observable in tests); world.creatureGrid mirrors plantGrid, rebuilt at tick start (sensing = tick-start positions; newborns join next tick); FLEE target = nearest STRICTLY higher aggression in vision; HUNT target = nearest creature passing size+1 rule in vision; carnivores with no edible prey fall through to SEEK_FOOD (they eat plants too).
  • Git repo (branch main, inited at M6 closeout). Commit at each milestone completion, message M<n>: <kebab-case summary> (see milestone rule). No dependencies, no build step; nothing to ignore.

Ecosystem baseline (measured, default settings, plantRate 0.3)

  • 20k-tick probe × 5 seeds (1, 2, 3, 7, 42): no extinction, no blowup.
    • min pop 15–20 (crash floor near the 20-creature seed size), max pop 146–212 (boom), visible cycles.
    • end state 135–205 creatures, veg/carn mix 3:132 → 100:92 across seeds, plant standing 14–22 (inflow-bound, not pool-cap-bound).
    • records: peak pop 146–212, best fitness 760–1527, longest lineage 11–19g.
  • 3000-tick e2e guard (predation.test.js, maxPop <= 500) stays comfortably green (peaks ~150–210).

Conventions

  • All randomness through world.rng (xorshift32). No Math.random anywhere.
  • 2-space indent, const, ES modules, no globals except engine bootstrap.
  • Files under ~250 lines; surgical edits, never rewrite the project.
  • Ambiguities: decide, then write the decision as a comment at the top of the file you change.
  • Human serves the folder (python3 -m http.server) and reviews in browser; we verify via tests only. Must be served over http — do NOT open index.html via file://: browsers block ES module scripts there, the engine never boots, and the page shows a dead splash. index.html carries a classic-script boot watchdog (engine.js sets window.__arenaBooted) that replaces the splash with serve instructions if boot fails within 1s. Verified in headless Chromium (http: boots, resume/space work, zero console errors incl. favicon; file://: watchdog instructions shown).