Initial Commit

This commit is contained in:
ookami125 2026-08-18 00:52:29 -04:00
commit 35f3810632
90 changed files with 29267 additions and 0 deletions

View file

@ -0,0 +1,22 @@
# Experiment 008 — Catalyst Ecology
Build four-catalyst emitters for three different populations. All catalysts and expeditions are available immediately. After the four choices, each complete build continues through four additional fields.
Run from the repository root:
```bash
./experiments/008_catalyst_ecology/run.sh
```
Then open <http://127.0.0.1:8000>.
Controls:
- Move with `WASD` or the arrow keys.
- Aim with the mouse.
- Hold the left mouse button to fire.
- Choose one catalyst after each of the first four fields.
Play expeditions in any order, replay anything you want, and stop whenever you feel ready. Click **Save JSONL** at the end.
Please play before reading [`hypothesis.md`](hypothesis.md).

View file

@ -0,0 +1,73 @@
# Experiment 008 Hypothesis — Private Until After Play
## Question
Can the compositional interest found in Experiment 007 survive a strict four-choice budget and changing enemy ecologies, or does it collapse into a universal package or obvious counter-loadouts?
## Why This Experiment
Experiment 007 produced the first complete curiosity chain in the project. Fork, Arc, and Bloom interacted through shared hit/kill interfaces; an inaccurate prediction led to surprising behavior, revised knowledge, and several self-directed build runs. The player also spontaneously distinguished Bloom as useful against small enemies and Arc against large enemies.
That success exposes the original long-term risk: strategy crystallization. The full three-family package may be universally best, all-Bloom may erase every population through runaway scaling, or visible enemy types may turn adaptation into the prescribed part swapping that made Experiment 003 boring.
Catalyst Ecology retains the same movement/shooting substrate and the known Fork/Bloom/Arc vocabulary. It adds three components at narrow existing interfaces:
- **Focus:** repeated primary hits on one body cause a rupture;
- **Conduit:** secondary-effect hits charge a lance toward the healthiest body;
- **Resonance:** amplifies secondary-effect damage, but has no output alone.
Three expeditions—Shoal, Bastion, and Brood—use small bodies, large durable bodies, and spawning mixtures respectively. Each run permits only four choices, with duplicates allowed and all six catalysts offered every time. Revision 2 retains the complete build for fields five through eight after the first run showed that a single mature field was insufficient for the system to become observable.
## Competing Interpretations
1. Conditional enemy ecology sustains build questions and makes knowledge transfer without transferring one whole answer.
2. Ecology produces obvious “Bloom for small, Focus/Arc for large” counter-loadouts with no discovery.
3. The original three-effect package remains dominant, so the larger option space adds false choices.
4. All-Bloom still wins through raw exponential scaling and spectacle.
5. New cross-interface components create multiple viable causal routes: Fork/Bloom feeds Arc/Conduit, Focus starts kills that feed Bloom, and Resonance trades a mechanism slot for stronger products.
6. Four slots are too restrictive and feel like arbitrary prevention rather than productive composition.
7. The successful part of 007 was rapid escalation in one repeated field, not adapting between ecologies.
## Evidence Priorities
Strong evidence:
- a build is chosen based on a prediction about an ecology and then revised from its observed result;
- the same component is valued differently across expeditions for causal reasons;
- a new interaction creates another self-selected build question;
- replaying an expedition with a meaningfully different composition;
- more than one plausible solution is articulated for Brood/mixed populations;
- a powerful cascade remains legible enough that the player attributes it to selected interfaces.
Ambiguous evidence:
- clearing all expeditions;
- fastest clear time or largest cascade;
- taking the visually labeled small/large counter;
- using all six components across required runs;
- longer play caused by extra content.
Failure evidence:
- immediate obvious loadouts from the descriptions;
- one four-choice sequence used everywhere;
- repeated all-Bloom erasure without further questions;
- the slot limit makes choices feel like withholding obvious synergy;
- Brood spawning becomes clutter/attrition rather than an exploitable ecology;
- new trigger chains are unreadable noise.
## Analysis Guardrails
- Do not nerf a spectacular build merely because it is strong; determine whether strength ends or creates reasoning.
- Do not infer adaptation from different builds alone; ask whether the difference was predicted, sampled, or prescribed.
- Compare stop desire and new questions, not expedition duration.
- Focus and Conduit add some automatic targeting, so reduced aim burden remains a confound.
- Expeditions are deliberately not numerically matched. They test qualitative population response, not performance balance.
## Feedback Questions
After telemetry inspection:
1. What question or expectation, if any, drove each build and replay?
2. Did any expedition admit multiple interesting approaches, or did the populations prescribe their counters?
3. Did a new interaction create another thing they wanted to test, and when were they ready to stop?

View file

@ -0,0 +1,83 @@
(() => {
"use strict";
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const canvas = $("#field"), ctx = canvas.getContext("2d");
const COLORS = { mint: "#68e0b5", cyan: "#66d8f2", violet: "#b28bf5", amber: "#efb55e", red: "#ee6f72", rose: "#f18cba" };
const KEYS = { KeyW:[0,-1],ArrowUp:[0,-1],KeyS:[0,1],ArrowDown:[0,1],KeyA:[-1,0],ArrowLeft:[-1,0],KeyD:[1,0],ArrowRight:[1,0] };
const ENEMIES = {
mote:{name:"Motes",icon:"●",radius:.017,hp:2,speed:.068,color:COLORS.red},
husk:{name:"Husks",icon:"⬢",radius:.026,hp:7,speed:.044,color:COLORS.amber},
titan:{name:"Titans",icon:"◆",radius:.036,hp:18,speed:.029,color:COLORS.violet},
brood:{name:"Broods",icon:"✹",radius:.032,hp:12,speed:.024,color:COLORS.rose}
};
const EXPEDITIONS = {
shoal:{name:"Shoal",copy:"Small bodies arrive in increasingly dense currents.",waves:[{mote:7},{mote:12},{mote:18,husk:1},{mote:24,husk:2},{mote:32,husk:3},{mote:36,husk:4},{mote:40,husk:5,titan:1},{mote:44,husk:6,titan:2}]},
bastion:{name:"Bastion",copy:"Durable bodies concentrate health into fewer targets.",waves:[{husk:3},{husk:4},{titan:2,husk:2,mote:4},{titan:3,husk:3,mote:5},{titan:4,husk:4,mote:6},{titan:5,husk:5,mote:8},{titan:5,husk:7,mote:12},{titan:6,husk:8,mote:16}]},
brood:{name:"Brood",copy:"Brood bodies release fresh motes while mixed bodies close in.",waves:[{mote:7},{brood:1,mote:6},{brood:2,mote:8},{brood:3,husk:2,mote:8},{brood:3,titan:2,mote:10},{brood:4,titan:2,husk:3,mote:10},{brood:4,titan:3,husk:4,mote:12},{brood:5,titan:3,husk:5,mote:14}]}
};
const MODULES = {
fork:{icon:"⋔",name:"Fork",short:"Fragments on primary hit",color:COLORS.cyan},
bloom:{icon:"✦",name:"Bloom",short:"Seeking sparks on any kill",color:COLORS.mint},
arc:{icon:"ϟ",name:"Arc",short:"Chain damage after projectile hits",color:COLORS.violet},
focus:{icon:"◎",name:"Focus",short:"Rupture after repeated primary hits",color:COLORS.amber},
conduit:{icon:"↯",name:"Conduit",short:"Secondary hits charge a heavy lance",color:COLORS.rose},
resonance:{icon:"◈",name:"Resonance",short:"Amplifies every secondary effect",color:"#f2d47b"}
};
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)), round=v=>Math.round(v*1000)/1000;
const norm=(x,y)=>{const d=Math.hypot(x,y)||1;return[x/d,y/d]}, dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
const session=crypto.randomUUID?crypto.randomUUID():`session-${Date.now()}`;
const state={session,startedAt:Date.now(),logs:[],started:false,active:false,choosing:false,complete:false,expedition:"shoal",attempt:0,wave:0,waveAttempt:0,runTime:0,waveTime:0,choice:0,completed:new Set(),
upgrades:{fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0},player:{x:.5,y:.5,vx:0,vy:0,health:6,inv:0,lastDamage:-99},keys:new Set(),pointer:{x:.75,y:.5,firing:false},fireCd:0,
enemies:[],bullets:[],particles:[],effects:[],enemyId:0,bulletId:0,totalHitCounter:0,secondaryCounter:0,secondaryBudget:300,actions:null,lastFrame:performance.now(),snapshot:0,clearDelay:0,render:{size:1,ox:0,oy:0},bannerTimer:null,toastTimer:null};
const freshActions=()=>({shots:0,primary_hits:0,fragment_hits:0,spark_hits:0,lance_hits:0,arc_triggers:0,focus_ruptures:0,conduit_lances:0,kills:0,spawned_motes:0,damage_taken:0}); state.actions=freshActions();
function log(type,data={}){const e={schema:1,experiment:"008_catalyst_ecology",prototype_revision:2,session_id:state.session,elapsed_ms:Date.now()-state.startedAt,expedition:state.expedition,run_attempt:state.attempt,wave:state.wave+1,wave_attempt:state.waveAttempt,run_seconds:round(state.runTime),type,...data};state.logs.push(JSON.stringify(e));try{localStorage.setItem("catalyst-ecology-last-jsonl",state.logs.join("\n")+"\n")}catch(_){}}
const build=()=>Object.fromEntries(Object.entries(state.upgrades).filter(([,v])=>v));
const secondaryScale=()=>1+state.upgrades.resonance*.55;
function populationText(spec){return Object.entries(spec).map(([k,v])=>`${v} ${ENEMIES[k].name}`).join(" · ")}
function layout(spec){const entries=[];for(const[k,count]of Object.entries(spec)){for(let i=0;i<count;i++)entries.push(k)}return entries.map((kind,i)=>{const n=entries.length,a=i/n*Math.PI*2+state.wave*.39,ring=.34+((i*5+state.wave)%4)*.035;return{kind,x:.5+Math.cos(a)*ring,y:.5+Math.sin(a)*ring}})}
function spawnEnemy(kind,x,y,spawned=false){const d=ENEMIES[kind];state.enemies.push({id:++state.enemyId,kind,x,y,vx:0,vy:0,radius:d.radius,hp:d.hp,maxHp:d.hp,removed:false,flash:0,contact:0,focusHits:0,spawnCd:2+Math.random()*.7,spawnsLeft:kind==="brood"?2+Math.floor(state.wave/2):0});if(spawned)state.actions.spawned_motes++}
function spawnWave(reason){state.waveAttempt++;state.waveTime=0;state.snapshot=0;state.clearDelay=0;state.totalHitCounter=0;state.secondaryCounter=0;state.secondaryBudget=300;state.enemies=[];state.bullets=[];state.particles=[];state.effects=[];state.player={x:.5,y:.5,vx:0,vy:0,health:6,inv:1,lastDamage:-99};state.fireCd=0;state.pointer.firing=false;layout(EXPEDITIONS[state.expedition].waves[state.wave]).forEach(e=>spawnEnemy(e.kind,e.x,e.y));state.active=state.started;state.choosing=false;state.complete=false;$("#choice-overlay").classList.add("hidden");$("#complete-overlay").classList.add("hidden");updateUI();log("wave_started",{reason,population:EXPEDITIONS[state.expedition].waves[state.wave],enemy_count:state.enemies.length,build:build()});showBanner(`Field ${state.wave+1}`);canvas.focus({preventScroll:true})}
function startRun(id,reason){if(state.started&&(state.active||state.choosing)&&!state.complete)log("run_abandoned",{reason,remaining:state.enemies.length,build:build(),actions:state.actions});state.expedition=id;state.attempt++;state.wave=0;state.waveAttempt=0;state.runTime=0;state.choice=0;state.upgrades={fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0};state.actions=freshActions();state.complete=false;state.choosing=false;$$('.expedition').forEach(b=>b.classList.toggle("selected",b.dataset.expedition===id));log("run_started",{reason,expedition:id});spawnWave("run_started")}
function begin(){if(state.started)return;state.started=true;$("#start-overlay").classList.add("hidden");log("session_started",{viewport:[innerWidth,innerHeight]});startRun(state.expedition,"session_started")}
function selectExpedition(id){if(!state.started){state.expedition=id;$$('.expedition').forEach(b=>b.classList.toggle("selected",b.dataset.expedition===id));$("#begin").textContent=`Begin ${EXPEDITIONS[id].name}`;updateUI();return}if(id===state.expedition&&!state.complete)return;startRun(id,"selected")}
function spawnBullet(x,y,vx,vy,damage,radius,kind,life,targetId=null){state.bullets.push({id:++state.bulletId,x,y,vx,vy,damage,radius,kind,life,targetId,removed:false})}
function fire(){if(!state.active||state.fireCd>0)return;const[dx,dy]=norm(state.pointer.x-state.player.x,state.pointer.y-state.player.y);state.fireCd=.21;state.actions.shots++;spawnBullet(state.player.x+dx*.025,state.player.y+dy*.025,dx*.66,dy*.66,1,.0085,"primary",1.5);log("shot_fired",{aim:[round(dx),round(dy)],position:[round(state.player.x),round(state.player.y)]})}
function nearest(origin,count,range,exclude=new Set()){return state.enemies.filter(e=>!e.removed&&!exclude.has(e.id)&&dist(origin,e)<=range).sort((a,b)=>dist(origin,a)-dist(origin,b)).slice(0,count)}
function healthiest(){return state.enemies.filter(e=>!e.removed).sort((a,b)=>b.hp-a.hp)[0]}
function spendBudget(n=1){if(state.secondaryBudget<n)return false;state.secondaryBudget-=n;return true}
function secondaryHit(enemy,kind){if(kind==="lance")return;state.secondaryCounter++;const l=state.upgrades.conduit;if(!l)return;const threshold=Math.max(3,9-l*2);if(state.secondaryCounter%threshold!==0||!spendBudget())return;const target=healthiest();if(!target)return;state.actions.conduit_lances++;spawnBullet(state.player.x,state.player.y,0,0,(3+l*1.8)*secondaryScale(),.012,"lance",1.7,target.id);effect("conduit",state.player.x,state.player.y);log("module_triggered",{module:"conduit",target_id:target.id,threshold})}
function triggerFork(enemy,bullet){const l=state.upgrades.fork;if(!l)return;const count=l*2,base=Math.atan2(bullet.vy,bullet.vx);let made=0;for(let i=0;i<count&&spendBudget();i++){const side=i%2?1:-1,layer=Math.floor(i/2),a=base+side*(.62+layer*.28),target=nearest(enemy,1,.55,new Set([enemy.id]))[0];spawnBullet(enemy.x,enemy.y,Math.cos(a)*.46,Math.sin(a)*.46,.65*secondaryScale(),.0065,"fragment",.9,target?.id||null);made++}effect("fork",enemy.x,enemy.y);log("module_triggered",{module:"fork",source_id:enemy.id,products:made})}
function triggerBloom(enemy){const l=state.upgrades.bloom;if(!l)return;const count=2+l,targets=nearest(enemy,count,.8,new Set([enemy.id]));let made=0;for(let i=0;i<count&&spendBudget();i++){const a=i/count*Math.PI*2,target=targets[i%Math.max(1,targets.length)];spawnBullet(enemy.x,enemy.y,Math.cos(a)*.1,Math.sin(a)*.1,.7*secondaryScale(),.007,"spark",1.6,target?.id||null);made++}effect("bloom",enemy.x,enemy.y);log("module_triggered",{module:"bloom",source_id:enemy.id,products:made,target_ids:targets.map(t=>t.id)})}
function triggerArc(origin){const l=state.upgrades.arc;if(!l)return;const threshold=Math.max(3,7-l);if(state.totalHitCounter%threshold!==0)return;const targets=nearest(origin,1+l,.32,new Set([origin.id]));state.actions.arc_triggers++;for(const t of targets){if(!spendBudget())break;lineEffect("arc",origin,t);damageEnemy(t,.78*secondaryScale(),"arc");secondaryHit(t,"arc")}log("module_triggered",{module:"arc",source_id:origin.id,target_ids:targets.map(t=>t.id),threshold})}
function triggerFocus(enemy){const l=state.upgrades.focus;if(!l)return;enemy.focusHits++;const threshold=Math.max(2,6-l);if(enemy.focusHits<threshold)return;enemy.focusHits=0;if(!spendBudget())return;state.actions.focus_ruptures++;effect("focus",enemy.x,enemy.y);damageEnemy(enemy,(3+l*2)*secondaryScale(),"focus");secondaryHit(enemy,"focus");log("module_triggered",{module:"focus",source_id:enemy.id,threshold})}
function hit(enemy,bullet){if(enemy.removed||bullet.removed)return;bullet.removed=true;if(bullet.kind==="primary")state.actions.primary_hits++;else if(bullet.kind==="fragment")state.actions.fragment_hits++;else if(bullet.kind==="spark")state.actions.spark_hits++;else if(bullet.kind==="lance")state.actions.lance_hits++;damageEnemy(enemy,bullet.damage,bullet.kind);if(bullet.kind==="primary"){triggerFocus(enemy);triggerFork(enemy,bullet)}else secondaryHit(enemy,bullet.kind);state.totalHitCounter++;triggerArc(enemy)}
function damageEnemy(enemy,amount,cause){if(enemy.removed)return;enemy.hp-=amount;enemy.flash=.1;burst(enemy.x,enemy.y,cause==="arc"?COLORS.violet:COLORS.mint,4);log("enemy_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,amount:round(amount),cause,hp_after:round(Math.max(0,enemy.hp))});if(enemy.hp>0)return;enemy.removed=true;state.actions.kills++;burst(enemy.x,enemy.y,ENEMIES[enemy.kind].color,12);log("enemy_killed",{enemy_id:enemy.id,enemy_kind:enemy.kind,cause,remaining_after:state.enemies.filter(e=>!e.removed).length});triggerBloom(enemy)}
function damagePlayer(enemy){if(state.player.inv>0||!state.active)return;state.player.health--;state.player.inv=.85;state.player.lastDamage=state.runTime;state.actions.damage_taken++;const[dx,dy]=norm(state.player.x-enemy.x,state.player.y-enemy.y);state.player.vx+=dx*.22;state.player.vy+=dy*.22;burst(state.player.x,state.player.y,COLORS.red,10);log("player_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,health_after:state.player.health});if(state.player.health<=0)defeat()}
function defeat(){state.active=false;state.pointer.firing=false;state.keys.clear();log("player_defeated",{remaining:state.enemies.length,build:build(),actions:state.actions});showBanner("Field restoring");setTimeout(()=>{if(state.started&&!state.active&&!state.choosing&&!state.complete)spawnWave("defeat_restart")},800)}
function clearWave(){if(!state.active)return;state.active=false;state.pointer.firing=false;log("wave_completed",{duration_seconds:round(state.waveTime),health:state.player.health,build:build(),actions:state.actions});const waves=EXPEDITIONS[state.expedition].waves;if(state.wave===waves.length-1){completeRun();return}if(state.choice<4){showChoices();return}state.wave++;state.waveAttempt=0;const expectedAttempt=state.attempt,expectedExpedition=state.expedition;log("post_build_field_advanced",{next_population:waves[state.wave],build:build()});updateUI();showBanner(`Build retained · Field ${state.wave+1}`);setTimeout(()=>{if(state.attempt===expectedAttempt&&state.expedition===expectedExpedition&&!state.active&&!state.choosing&&!state.complete)spawnWave("post_build_advance")},650)}
function moduleDescription(key,next){if(key==="fork")return `${next*2} fragments leave every primary impact.`;if(key==="bloom")return `${2+next} seeking sparks leave every destroyed body.`;if(key==="arc")return `Every ${Math.max(3,7-next)} projectile hits chain into up to ${1+next} nearby bodies.`;if(key==="focus")return `${Math.max(2,6-next)} repeated primary hits rupture one body for ${3+next*2} damage.`;if(key==="conduit")return `Every ${Math.max(3,9-next*2)} secondary hits launch a ${Math.round((3+next*1.8)*10)/10}-damage lance at the healthiest body.`;return `All fragment, spark, Arc, Focus, and Conduit damage becomes ${(1+next*.55).toFixed(2)}×.`}
function showChoices(){state.choosing=true;state.choice++;const nextSpec=EXPEDITIONS[state.expedition].waves[state.wave+1];$("#next-forecast").textContent=`Next field: ${populationText(nextSpec)}.`;const box=$("#choices");box.replaceChildren();for(const[key,d]of Object.entries(MODULES)){const next=state.upgrades[key]+1,b=document.createElement("button");b.className="choice";b.dataset.module=key;b.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${d.name}</b><small>${moduleDescription(key,next)}</small><em>${state.upgrades[key]?`Level ${state.upgrades[key]}${next}`:"Not selected"}</em>`;b.onclick=()=>choose(key);box.append(b)}$("#choice-overlay").classList.remove("hidden");log("choices_shown",{choice_number:state.choice,next_population:nextSpec,options:Object.keys(MODULES).map(k=>({key:k,current_level:state.upgrades[k]}))})}
function choose(key){if(!state.choosing||!MODULES[key])return;state.upgrades[key]++;state.choosing=false;log("module_chosen",{choice_number:state.choice,module:key,new_level:state.upgrades[key],build:build()});state.wave++;state.waveAttempt=0;spawnWave("module_chosen")}
function completeRun(){state.complete=true;state.active=false;state.completed.add(state.expedition);log("run_completed",{duration_seconds:round(state.runTime),build:build(),actions:state.actions});document.querySelector(`[data-expedition="${state.expedition}"]`).classList.add("complete");const ids=Object.keys(EXPEDITIONS),next=ids[(ids.indexOf(state.expedition)+1)%ids.length];$("#complete-title").textContent=`${EXPEDITIONS[state.expedition].name} stabilized.`;$("#next-expedition").textContent=`Start ${EXPEDITIONS[next].name}`;$("#next-expedition").dataset.expedition=next;$("#complete-overlay").classList.remove("hidden");updateUI()}
function updatePlayer(dt){state.player.inv=Math.max(0,state.player.inv-dt);state.fireCd=Math.max(0,state.fireCd-dt);if(state.player.health<6&&state.runTime-state.player.lastDamage>3.5)state.player.health=Math.min(6,state.player.health+dt*.45);let x=0,y=0;for(const k of state.keys){const v=KEYS[k];if(v){x+=v[0];y+=v[1]}}if(x||y){[x,y]=norm(x,y);state.player.vx+=x*.92*dt;state.player.vy+=y*.92*dt}const drag=Math.pow(.025,dt);state.player.vx*=drag;state.player.vy*=drag;const s=Math.hypot(state.player.vx,state.player.vy);if(s>.29){state.player.vx*=.29/s;state.player.vy*=.29/s}state.player.x=clamp(state.player.x+state.player.vx*dt,.04,.96);state.player.y=clamp(state.player.y+state.player.vy*dt,.04,.96);if(state.pointer.firing)fire()}
function updateEnemies(dt){for(const e of state.enemies){if(e.removed)continue;e.flash=Math.max(0,e.flash-dt);e.contact=Math.max(0,e.contact-dt);if(e.kind==="brood"&&e.spawnsLeft>0){e.spawnCd-=dt;if(e.spawnCd<=0){e.spawnCd=2.35;e.spawnsLeft--;const a=Math.random()*Math.PI*2;spawnEnemy("mote",clamp(e.x+Math.cos(a)*.055,.04,.96),clamp(e.y+Math.sin(a)*.055,.04,.96),true);log("brood_spawned",{brood_id:e.id,remaining_spawns:e.spawnsLeft})}}const[dx,dy]=norm(state.player.x-e.x,state.player.y-e.y),d=ENEMIES[e.kind];e.vx+=dx*d.speed*4.5*dt;e.vy+=dy*d.speed*4.5*dt;const drag=Math.pow(.12,dt);e.vx*=drag;e.vy*=drag;const s=Math.hypot(e.vx,e.vy);if(s>d.speed){e.vx*=d.speed/s;e.vy*=d.speed/s}e.x=clamp(e.x+e.vx*dt,e.radius,1-e.radius);e.y=clamp(e.y+e.vy*dt,e.radius,1-e.radius);if(dist(e,state.player)<e.radius+.022)damagePlayer(e)}for(let i=0;i<state.enemies.length;i++){const a=state.enemies[i];if(a.removed)continue;for(let j=i+1;j<state.enemies.length;j++){const b=state.enemies[j];if(b.removed)continue;const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||.001,min=a.radius+b.radius;if(d>=min)continue;const o=(min-d)/2,nx=dx/d,ny=dy/d;a.x-=nx*o;a.y-=ny*o;b.x+=nx*o;b.y+=ny*o}}state.enemies=state.enemies.filter(e=>!e.removed)}
function updateBullets(dt){for(const b of state.bullets){if(b.removed)continue;b.life-=dt;if(b.targetId&&b.kind!=="primary"){let t=state.enemies.find(e=>e.id===b.targetId&&!e.removed);if(!t){t=b.kind==="lance"?healthiest():nearest(b,1,.8)[0];b.targetId=t?.id||null}if(t){const[dx,dy]=norm(t.x-b.x,t.y-b.y);b.vx+=dx*1.3*dt;b.vy+=dy*1.3*dt;const targetSpeed=b.kind==="lance"?.65:.48,s=Math.hypot(b.vx,b.vy)||1;b.vx=b.vx/s*targetSpeed;b.vy=b.vy/s*targetSpeed}}b.x+=b.vx*dt;b.y+=b.vy*dt;if(b.life<=0||b.x<-.05||b.x>1.05||b.y<-.05||b.y>1.05){b.removed=true;continue}for(const e of state.enemies)if(!e.removed&&dist(b,e)<=b.radius+e.radius){hit(e,b);break}}state.bullets=state.bullets.filter(b=>!b.removed)}
function updateEffects(dt){for(const p of state.particles){p.life-=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=Math.pow(.12,dt);p.vy*=Math.pow(.12,dt)}for(const e of state.effects)e.life-=dt;state.particles=state.particles.filter(p=>p.life>0);state.effects=state.effects.filter(e=>e.life>0)}
function update(dt){if(!state.active)return;state.runTime+=dt;state.waveTime+=dt;state.snapshot+=dt;updatePlayer(dt);updateEnemies(dt);updateBullets(dt);updateEffects(dt);if(!state.enemies.length){state.clearDelay+=dt;if(state.clearDelay>.6)clearWave()}if(state.snapshot>=5){state.snapshot=0;log("state_snapshot",{player:[round(state.player.x),round(state.player.y)],health:round(state.player.health),remaining:state.enemies.length,projectiles:state.bullets.length,secondary_budget:state.secondaryBudget,build:build(),actions:state.actions})}updateUI()}
function burst(x,y,color,count){for(let i=0;i<count;i++){const a=Math.random()*Math.PI*2,s=.03+Math.random()*.1;state.particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,color,life:.18+Math.random()*.3,maxLife:.48,radius:.002+Math.random()*.003})}}
function effect(kind,x,y){state.effects.push({kind,x,y,life:.22,maxLife:.22})}function lineEffect(kind,a,b){state.effects.push({kind,x:a.x,y:a.y,tx:b.x,ty:b.y,life:.16,maxLife:.16})}
const screen=(x,y)=>[state.render.ox+x*state.render.size,state.render.oy+y*state.render.size];
function draw(){const dpr=devicePixelRatio||1,w=canvas.clientWidth,h=canvas.clientHeight;if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr)}ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);const size=Math.max(100,Math.min(w-38,h-38));state.render={size,ox:(w-size)/2,oy:(h-size)/2};ctx.fillStyle="#081219";ctx.fillRect(state.render.ox,state.render.oy,size,size);ctx.strokeStyle="#182c34";ctx.lineWidth=1;for(let i=1;i<10;i++){const p=i/10*size;ctx.beginPath();ctx.moveTo(state.render.ox+p,state.render.oy);ctx.lineTo(state.render.ox+p,state.render.oy+size);ctx.stroke();ctx.beginPath();ctx.moveTo(state.render.ox,state.render.oy+p);ctx.lineTo(state.render.ox+size,state.render.oy+p);ctx.stroke()}ctx.strokeStyle="#36515b";ctx.lineWidth=2;ctx.strokeRect(state.render.ox,state.render.oy,size,size);for(const e of state.effects)drawEffect(e);for(const b of state.bullets){const[x,y]=screen(b.x,b.y);ctx.fillStyle=b.kind==="primary"?"#f5f8df":b.kind==="fragment"?COLORS.cyan:b.kind==="spark"?COLORS.mint:COLORS.rose;ctx.shadowColor=ctx.fillStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.arc(x,y,Math.max(2.5,b.radius*size),0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}for(const e of state.enemies)drawEnemy(e);for(const p of state.particles){const[x,y]=screen(p.x,p.y);ctx.globalAlpha=clamp(p.life/p.maxLife,0,1);ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(x,y,p.radius*size,0,Math.PI*2);ctx.fill();ctx.globalAlpha=1}drawPlayer()}
function drawEnemy(e){const[x,y]=screen(e.x,e.y),r=e.radius*state.render.size;ctx.save();ctx.translate(x,y);ctx.fillStyle=e.flash?"#fff":ENEMIES[e.kind].color;ctx.shadowColor=ENEMIES[e.kind].color;ctx.shadowBlur=8;ctx.beginPath();const sides=e.kind==="mote"?0:e.kind==="husk"?6:e.kind==="titan"?4:8;if(!sides)ctx.arc(0,0,r,0,Math.PI*2);else for(let i=0;i<sides;i++){const a=i/sides*Math.PI*2,px=Math.cos(a)*r,py=Math.sin(a)*r;i?ctx.lineTo(px,py):ctx.moveTo(px,py)}ctx.closePath();ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle="#081015";ctx.lineWidth=2;ctx.stroke();if(e.hp<e.maxHp){ctx.fillStyle="#1b2b30";ctx.fillRect(-r,r+5,r*2,3);ctx.fillStyle=COLORS.mint;ctx.fillRect(-r,r+5,r*2*clamp(e.hp/e.maxHp,0,1),3)}if(e.focusHits){ctx.strokeStyle=COLORS.amber;ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,0,r+4,-Math.PI/2,-Math.PI/2+Math.PI*2*e.focusHits/Math.max(2,6-state.upgrades.focus));ctx.stroke()}ctx.restore()}
function drawPlayer(){const[x,y]=screen(state.player.x,state.player.y),r=.021*state.render.size,[ax,ay]=norm(state.pointer.x-state.player.x,state.pointer.y-state.player.y);ctx.save();ctx.translate(x,y);ctx.rotate(Math.atan2(ay,ax));ctx.globalAlpha=state.player.inv&&Math.floor(state.player.inv*16)%2?.35:1;ctx.fillStyle=COLORS.mint;ctx.shadowColor=COLORS.mint;ctx.shadowBlur=12;ctx.beginPath();ctx.moveTo(r*1.35,0);ctx.lineTo(-r*.8,r*.8);ctx.lineTo(-r*.52,0);ctx.lineTo(-r*.8,-r*.8);ctx.closePath();ctx.fill();ctx.restore()}
function drawEffect(e){const a=clamp(e.life/e.maxLife,0,1);ctx.save();ctx.globalAlpha=a;if(e.tx!==undefined){const[x,y]=screen(e.x,e.y),[tx,ty]=screen(e.tx,e.ty);ctx.strokeStyle=e.kind==="arc"?COLORS.violet:COLORS.rose;ctx.lineWidth=3;ctx.shadowColor=ctx.strokeStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo((x+tx)/2+(Math.random()-.5)*10,(y+ty)/2+(Math.random()-.5)*10);ctx.lineTo(tx,ty);ctx.stroke()}else{const[x,y]=screen(e.x,e.y);ctx.strokeStyle=e.kind==="bloom"?COLORS.mint:e.kind==="fork"?COLORS.cyan:e.kind==="focus"?COLORS.amber:COLORS.rose;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,(1-a)*.05*state.render.size+4,0,Math.PI*2);ctx.stroke()}ctx.restore()}
function updateUI(){const ids=Object.keys(EXPEDITIONS),idx=ids.indexOf(state.expedition),ex=EXPEDITIONS[state.expedition],total=ex.waves.length;$("#expedition-kicker").textContent=`EXPEDITION ${String(idx+1).padStart(2,"0")} · FIELD ${state.wave+1} OF ${total}`;$("#expedition-name").textContent=ex.name;$("#expedition-copy").textContent=ex.copy;$("#wave").textContent=`${state.wave+1} / ${total}`;$("#remaining").textContent=String(state.enemies.length);$("#health").textContent=Array.from({length:6},(_,i)=>i<Math.ceil(state.player.health)?"●":"○").join(" ");const s=Math.floor(state.runTime);$("#run-time").textContent=`${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`;const forecast=$("#forecast-list");forecast.replaceChildren();for(const[k,v]of Object.entries(ex.waves[state.wave])){const d=ENEMIES[k],el=document.createElement("div");el.className="population";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${v}</b> ${d.name}`;forecast.append(el)}const box=$("#build-list");box.replaceChildren();const selected=Object.entries(state.upgrades).filter(([,v])=>v);if(!selected.length){const p=document.createElement("p");p.className="empty";p.textContent="No catalysts selected.";box.append(p)}else for(const[k,v]of selected){const d=MODULES[k],el=document.createElement("div");el.className="build-item";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><span><b>${d.name}</b><small>${d.short}</small></span><em>LV ${v}</em>`;box.append(el)}}
function showBanner(text){const b=$("#banner");b.textContent=text;b.classList.add("show");clearTimeout(state.bannerTimer);state.bannerTimer=setTimeout(()=>b.classList.remove("show"),1000)}function toast(text){const t=$("#toast");t.textContent=text;t.classList.add("show");clearTimeout(state.toastTimer);state.toastTimer=setTimeout(()=>t.classList.remove("show"),3300)}
async function save(){log("session_saved",{completed:[...state.completed],current_build:build(),actions:state.actions,event_count_before_save:state.logs.length});const body=state.logs.join("\n")+"\n",filename=`catalyst-ecology-${state.session}.jsonl`;try{const r=await fetch("/api/playtest-log",{method:"POST",headers:{"Content-Type":"application/x-ndjson","X-Playtest-Filename":filename},body});if(!r.ok)throw Error(r.status);const j=await r.json();toast(`Saved ${j.events} events to ${j.path}`)}catch(_){const blob=new Blob([body],{type:"application/x-ndjson"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=filename;a.click();URL.revokeObjectURL(a.href);toast(`Server unavailable; downloaded ${filename}`)}}
function pointer(e){const r=canvas.getBoundingClientRect();state.pointer.x=clamp((e.clientX-r.left-state.render.ox)/state.render.size,0,1);state.pointer.y=clamp((e.clientY-r.top-state.render.oy)/state.render.size,0,1)}
canvas.onpointermove=pointer;canvas.onpointerdown=e=>{pointer(e);if(e.button===0){state.pointer.firing=true;fire()}e.preventDefault();canvas.focus({preventScroll:true})};addEventListener("pointerup",e=>{if(e.button===0)state.pointer.firing=false});canvas.oncontextmenu=e=>e.preventDefault();addEventListener("keydown",e=>{if(KEYS[e.code]){state.keys.add(e.code);e.preventDefault()}});addEventListener("keyup",e=>state.keys.delete(e.code));addEventListener("blur",()=>{state.keys.clear();state.pointer.firing=false});document.addEventListener("visibilitychange",()=>{if(state.started)log("visibility_changed",{hidden:document.hidden})});
$$('.expedition').forEach(b=>b.onclick=()=>selectExpedition(b.dataset.expedition));$("#begin").onclick=begin;$("#restart").onclick=()=>state.started?startRun(state.expedition,"restart_button"):begin();$("#save").onclick=save;$("#complete-save").onclick=save;$("#next-expedition").onclick=e=>startRun(e.currentTarget.dataset.expedition,"complete_next");$("#replay").onclick=()=>startRun(state.expedition,"complete_replay");
if(new URLSearchParams(location.search).has("validation"))window.__ecologyDebug={getState:()=>state,startRun,spawnWave,choose,damageEnemy,spawnBullet};
function frame(now){const dt=Math.min(.04,(now-state.lastFrame)/1000||0);state.lastFrame=now;update(dt);draw();requestAnimationFrame(frame)}
updateUI();log("session_initialized",{viewport:[innerWidth,innerHeight]});spawnWave("initial_preview");state.active=false;requestAnimationFrame(frame);
})();

View file

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Catalyst Ecology — Experiment 008</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="title"><span>EXPERIMENT 008</span><h1>Catalyst Ecology</h1></div>
<nav aria-label="Expedition selection">
<button class="expedition selected" data-expedition="shoal"><span>01</span> Shoal</button>
<button class="expedition" data-expedition="bastion"><span>02</span> Bastion</button>
<button class="expedition" data-expedition="brood"><span>03</span> Brood</button>
</nav>
<div class="top-actions"><button id="restart">Restart</button><button id="save">Save JSONL</button></div>
</header>
<main>
<aside>
<section class="brief"><span id="expedition-kicker">EXPEDITION 01 · FIELD 1 OF 8</span><h2 id="expedition-name">Shoal</h2><p id="expedition-copy"></p></section>
<section class="forecast"><h3>Current population</h3><div id="forecast-list"></div></section>
<section class="controls"><h3>Controls</h3><p><kbd>WASD</kbd><span>Move.</span></p><p><kbd>HOLD LEFT</kbd><span>Fire toward cursor.</span></p></section>
<section class="build"><h3>Four-choice build</h3><div id="build-list"><p class="empty">No catalysts selected.</p></div></section>
<section class="readouts">
<div><span>INTEGRITY</span><b id="health">● ● ● ● ● ●</b></div><div><span>FIELD</span><b id="wave">1 / 8</b></div>
<div><span>REMAINING</span><b id="remaining">0</b></div><div><span>RUN TIME</span><b id="run-time">0:00</b></div>
</section>
<section class="note">All six catalysts remain available at every choice. Repeats stack. Four selections fit in one emitter.</section>
</aside>
<section class="playfield"><canvas id="field" tabindex="0"></canvas><div id="banner" class="banner"></div><div class="canvas-help">WASD move · aim with mouse · hold left to fire</div></section>
</main>
<div id="start-overlay" class="overlay"><div><span>THREE POPULATIONS</span><b>Build within four choices.</b><p>Choose during the first four fields, then keep the complete build through four more. Everything is available now. Stop whenever you want.</p><button id="begin">Begin Shoal</button></div></div>
<div id="choice-overlay" class="overlay hidden"><div class="wide"><span>FIELD STABLE</span><b>Choose one catalyst</b><p id="next-forecast"></p><div id="choices"></div></div></div>
<div id="complete-overlay" class="overlay hidden"><div><span>EXPEDITION COMPLETE</span><b id="complete-title"></b><p>Choose another expedition, replay this one, save, or stop.</p><div class="complete-actions"><button id="next-expedition"></button><button id="replay">Replay this expedition</button><button id="complete-save">Save JSONL</button></div></div></div>
<div id="toast"></div>
<script src="app.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,113 @@
# Experiment 008 Preliminary Analysis — Session 0ac8e60d
Status: first-run telemetry and report complete; corrective revision required before interpreting H23.
Source: `JSONL/catalyst-ecology-0ac8e60d-a488-4d4d-a4d4-3d220981ef67.jsonl`
## Session Structure
- 2,042 events over 374.0 seconds from initialization to save.
- Shoal, Bastion, and Brood were played in displayed order and each completed once.
- All 15 fields cleared on their first attempt. There were no defeats and six total damage events.
- No expedition was replayed, restarted, switched away from, or abandoned.
- The player saved once after all three expeditions.
Completion is likely compliance with the requested comparison. The lack of replay is meaningful only after asking whether the option space felt exhausted, the player had already formed conclusions, or no build question justified another run.
## Build Sequences
| Expedition | Choice sequence | Final build | Active run time |
|---|---|---|---:|
| Shoal | Bloom → Resonance → Arc → Focus | Bloom, Resonance, Arc, Focus | 48.9 s |
| Bastion | Conduit → Bloom → Arc → Fork | Conduit, Bloom, Arc, Fork | 78.6 s |
| Brood | Bloom → Focus → Arc → Fork | Bloom, Focus, Arc, Fork | 87.5 s |
Bloom and Arc formed a common core across all three populations. The two remaining slots varied:
- Shoal uniquely used Resonance and omitted Fork/Conduit.
- Bastion uniquely used Conduit and omitted Focus/Resonance.
- Brood used Focus and Fork, omitting Conduit/Resonance.
Every one of the six available modules was selected somewhere, but no module was stacked. This may reflect ecology-based choice, coverage/sampling of new options, or a belief that one level of several interacting mechanisms is better than specialization.
## Deliberation Pattern
The first choice in each expedition took much longer than later choices:
- Shoal first choice: about 38.9 seconds.
- Bastion first choice: about 33.5 seconds.
- Brood first choice: about 25.5 seconds.
- Later choices generally took 16 seconds.
The player therefore spent substantial time reading or planning when the build was empty, then selected quickly as the run developed. The decreasing first-choice times could indicate growing understanding of the six-module vocabulary, fatigue, or a progressively clearer plan.
Conduit was selected first in Bastion despite having no output without secondary hits. It remained inactive during field two, then began firing after Bloom was added. Sixteen Conduit lances ultimately hit and six killed targets. This is potentially strong evidence of planning around a future interface, but it could also be experimentation with an attractive description or a misunderstanding corrected by the next choice.
## Population and Effect Results
### Shoal
- 99 kills: 93 Motes and 6 Husks.
- Bloom sparks caused 52 kills; Arc caused 12; Focus caused one.
- Field time fell from 13.9 seconds at baseline to 7.1 seconds in the final field despite a much larger population.
- Focus arrived last and ruptured only once, consistent with most targets dying too quickly to accumulate repeated hits.
- The secondary budget never approached exhaustion (observed minimum 214/300).
### Bastion
- 40 kills: 9 Titans, 16 Husks, and 15 Motes.
- Primary fire still caused 27 kills; Conduit lances caused six.
- Conduit fired/hit 16 times after secondary generators became available.
- Final fields remained around 1820 seconds, so the build did not erase durable populations.
- The secondary budget remained ample (observed minimum 201/300).
### Brood
- 79 kills, including 27 Motes spawned during combat.
- Bloom produced 103 spark hits; Fork produced 46 fragment hits; Arc triggered 32 times; Focus ruptured eight times.
- Final build used all four direct mechanisms and produced all four corresponding kill causes.
- Field time increased with the population from 8.5 seconds to 22.1 seconds.
- The lowest observed secondary budget was 134/300; the recursion cap did not determine the outcome.
## Preliminary Interpretation
Experiment 008 did not immediately collapse into one identical four-slot answer. The player used all six modules across three distinct builds, and the modules materially interacted. It also did not provoke the repeated build tests seen in 007.
The common Bloom+Arc core is the central ambiguity. It may mean:
1. knowledge from 007 transferred successfully as a reusable component pair while the remaining slots adapted;
2. Bloom+Arc is already a partially crystallized universal answer, leaving only two low-stakes flex slots;
3. the player was deliberately holding a familiar baseline while sampling the three new modules;
4. the choices were made for novelty/coverage rather than population response.
The three expedition builds look plausibly ecology-aware, but telemetry cannot establish why. Shoal's Bloom+Resonance generated strong small-target cascades. Bastion's Conduit converted secondary activity into attacks on the healthiest target. Brood combined Focus for durable spawners with Fork/Bloom/Arc for their Motes. Those relationships may have been planned, noticed afterward, or entirely incidental.
Unlike Experiment 003's obvious counter-parts, no module names a specific enemy class. Components still operate through hit/kill interfaces. However, the population descriptions themselves may make the build split obvious enough that it becomes counter-loadout work. The player's subjective decision process is necessary to discriminate H23.
The four-choice budget did preserve tradeoffs: no run could include all six modules. But it may also have suppressed the power-escalation payoff that motivated 007's specialization replays. No repeats were selected, including no all-Bloom attempt, and no run created a logged follow-on test.
## Follow-up Needed
1. What expectation drove each distinctive first choice—especially Bloom for Shoal/Brood and inactive-until-supported Conduit for Bastion—and were later choices completing a plan or sampling?
2. Did the populations create several plausible builds, obvious counters, or a universal Bloom+Arc core with two unimportant flex slots? How did the four-choice limit feel?
3. Did any result surprise or satisfy enough to suggest another build, and why did the player stop after one run per expedition rather than replaying as in 007?
## Player Report
The distinct builds were not substantially planned. Conduit was chosen first because its dependency was misunderstood, not because the player was deliberately investing in a future secondary-hit engine. Do not cite that sequence as forward planning.
The player believes the populations may permit multiple approaches, but could not formulate which effects played off one another well because each expedition contained too few rounds. They described the repeated experience as beginning to see something cool emerge and then having the expedition end before anything really cool happened.
## Final First-Run Interpretation
Experiment 008 failed to measure strategy half-life because it ended at build maturity. Four selections occurred after the first four fields, leaving only field five for the complete build:
- Shoal's complete build existed for 7.1 seconds.
- Bastion's complete build existed for 19.8 seconds.
- Brood's complete build existed for 22.1 seconds.
The larger six-module vocabulary and changing populations required more observation than Experiment 007, but the run structure supplied no additional mature-build time. Lack of replay is therefore not good evidence that the system exhausted its questions. The player's report and telemetry agree that the prototype terminated during emergence rather than after it.
The proper correction is structural, not a new mechanic or balance pass: preserve the exact four choices and current populations, then add several post-build fields so the player can observe the same completed composition against increasing and mixed populations. This keeps the slot-budget test intact while allowing H23 to become measurable.
Do not add replacement choices yet. Swapping would bundle ecology adaptation with interface/optimization work before establishing whether a mature four-module build is interesting to watch and understand. Do not increase the choice budget; the current question specifically concerns composition under exclusion.

View file

@ -0,0 +1,79 @@
# Experiment 008 Revision 2 Preliminary Analysis — Session 7b7c7a92
Status: telemetry and brief initial report inspected; targeted follow-up pending.
Source: `JSONL/catalyst-ecology-7b7c7a92-42cb-4ab2-8a81-d1316ea972c5.jsonl`
## Session Structure
- 5,338 events over 609.4 elapsed seconds, including a roughly 196-second break between Shoal and Bastion.
- All three eight-field expeditions completed once in displayed order.
- All 24 fields cleared first attempt; no deaths, restarts, abandoned runs, or replays.
- Twelve choices total and exactly three post-build transitions per expedition, confirming the corrective structure worked.
- Seven damage events across the session. Combat difficulty was not a meaningful constraint.
- One save occurred after all three expeditions.
The player reports that revision 2 made it “a bit easier to see how my choices impacted my play.” This validates the correction at the legibility/exposure level, not yet at the enjoyment or strategy-half-life level.
## Builds
| Expedition | Choice sequence | Complete build |
|---|---|---|
| Shoal | Bloom → Fork → Conduit → Fork | Bloom 1, Fork 2, Conduit 1 |
| Bastion | Focus → Fork → Bloom → Fork | Focus 1, Fork 2, Bloom 1 |
| Brood | Arc → Fork → Arc → Resonance | Arc 2, Fork 1, Resonance 1 |
Unlike revision 1, Bloom+Arc was not a universal core. Fork appeared everywhere, but at different levels and with different consumers. Each expedition specialized by repeating a module or using Resonance rather than sampling four distinct mechanisms.
The builds are causally distinct:
- Shoal used Fork and Bloom to create secondary hits, then Conduit converted them into 69 heavy lances. Its 237 kills were distributed across primary (46), fragments (73), sparks (70), and lances (48).
- Bastion used repeated primaries for 22 Focus ruptures while Fork produced 658 fragment hits. Bloom supplied 151 spark hits after kills began. Focus caused 13 kills and fragments 60.
- Brood omitted Bloom entirely. Fork generated hits for Arc; Arc was stacked, then Resonance amplified both. Arc triggered 114 times and caused 54 kills; fragments caused 83.
This is much stronger behavioral evidence of conditional composition than revision 1. It is still not proof of deliberate ecology reasoning because the player may have been comparing builds experimentally or avoiding repetition from the previous session.
## Mature-Build Exposure
Each complete build persisted through fields five through eight:
| Expedition | Mature field times | Main growth during mature fields |
|---|---|---|
| Shoal | 9.8, 10.1, 9.9, 11.2 s | Conduit lances rose from 18 cumulative to 69; spark hits from 111 to 349 |
| Bastion | 16.8, 18.2, 19.5, 21.7 s | Fork hits rose from 198 to 658; Focus ruptures from 12 to 22 |
| Brood | 10.6, 12.9, 17.1, 15.1 s | Arc triggers rose from 44 to 114; spawned Motes from 24 to 69 |
The extra fields supplied approximately 41 seconds of mature Shoal, 76 seconds of mature Bastion, and 56 seconds of mature Brood, versus 722 seconds in revision 1. The player now reports clearer choice impact, so the first-run measurement defect was successfully repaired.
No field exhausted the secondary budget, though Bastion approached it most closely (observed minimum 18/300). Large effect chains were therefore bounded but not usually clipped.
## Choice Deliberation
Deliberation did not simply decline with familiarity:
- Shoal Conduit took about 15.0 seconds.
- Bastion's first Focus took about 11.5 seconds and final Fork stack about 14.0 seconds.
- Brood's final Resonance took about 9.8 seconds.
Those pauses are consistent with real build consideration, but reading, distraction, and uncertainty remain alternate explanations. The long Conduit choice is particularly notable because the first session's Conduit selection was a misunderstanding; revision 2 placed it into a functioning secondary-hit engine.
## Preliminary Interpretation
Revision 2 establishes that the system can visibly express multiple four-choice builds across different ecologies. It weakens the immediate crystallization interpretation from revision 1: Bloom+Arc was not universal, stacking occurred, and one expedition deliberately or experimentally omitted Bloom despite its known strength.
It does not yet establish why the builds differed or whether the extra exposure was enjoyable. Three readings remain:
1. The player transferred module knowledge and selected conditional causal engines for each population.
2. The player intentionally sampled three distinct builds to make the experiment informative, regardless of preference.
3. More mature fields made effects readable, but the run still lacked enough decision points or surprise to motivate replay.
Fork may be emerging as a reusable abstraction rather than a complete answer. It appeared in all builds because extra hits feed Bloom, Arc, and Conduit, while its partner determined the resulting capability. That would match “reuse components, not answers.” Alternatively, Fork×1/2 may simply be the strongest generic throughput option and thus an early universal core.
No replay followed the clearer mature exposure. As always, this is ambiguous: three eight-field runs may have provided enough evidence, may have become repetitive, or may not have produced a new specific question. The corrective test now permits that question to be asked meaningfully.
## Follow-up Needed
1. Were the three specialized builds chosen in response to each population, to test different combinations, or for another reason? What was the intended role of Fork in all three?
2. During fields five through eight, did any interaction become surprising, satisfying, or fun rather than merely easier to read? When, if at all, did the extra fields become repetition?
3. Did the results suggest a specific alternative build worth replaying, and did the four-slot limit now feel productive or still premature/restrictive?

View file

@ -0,0 +1,4 @@
# Experiment 008 Results
Playtest interpretations and telemetry summaries belong here.

View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
EXPERIMENT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$EXPERIMENT_DIR/../.." && pwd)"
exec python3 "$REPO_ROOT/tools/playtest_server.py" \
--directory "$EXPERIMENT_DIR/prototype" \
--log-directory "$REPO_ROOT/JSONL" \
--port 8000