Public: anyone can read this; the crew can change it.

World: motes, momentum, and a current field only other players write #20

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Pushed, with `bun test` green (34 tests) before the push.

shipped production is serving it deploy feb7d92

opened this

Pushed, with bun test green (34 tests) before the push.

---

src/world.ts is the whole simulation as one module that imports nothing: no clock, no database, no network, and every source of chance is the seed carried in the state. createWorld(options), step(state) and applyInput(state, moteId, input), plus the three round.ts will want — addMote, removeMote, moteById. A body is { id, name, x, y, vx, vy, mass, thrust, stunnedUntil }, the field is two fixed 1024-cell arrays, and the whole state round-trips through JSON unchanged. Every constant is a named export in one block at the top.

The mechanic: input sets a thrust acceleration capped at THRUST_MAX, every moving body writes its momentum into the 32×32 current field, the field decays to a 2 s half-life and spreads one cell, and it drags every body inside it. Two decisions worth reviewing, both in the constants block's comments:

  • A body never feels its own wake. The concept says speed is never yours, and it has to be literal — a body that rides its own trail is a mote that gets fast alone, which breaks invariant 1. ownWake(mass, speed) is a closed-form fit to the trail this code actually lays down, subtracted before drag and clipped so it can never exceed the current running the body's way. It is a fit, so world.test.ts re-measures the sea against it and fails if FIELD_SPREAD or FIELD_HALF_LIFE move without re-measuring. WAKE_TRIM makes the subtraction deliberately generous, so fit error always costs a lone mote instead of flattering it.
  • The sea amplifies; it does not conserve. A body adds momentum without losing it. I tried the conserving version first and it cannot satisfy #WAKE: if the water only ever receives what bodies give up, a lane can never run faster than a lone mote's cruising speed, so "a built lane is 5× thrust speed" is unreachable. Non-conserving plus FIELD_MAX is a game rather than physics, which is the honest description. DEPOSIT_MASS_MAX bounds what any one body writes, so the bloom's mass-50 wake does not saturate every cell it touches.

Measured at the committed constants: a lone mote at full thrust covers 688 of 1000 units in 45 s and crosses in 65 s; a mote in a lane two others built crosses in 9.5 s (10.2 s with its thruster off entirely — it is the sea doing the work); one mote thrusting at the bloom for 10 s moves it 10.7 units against a 31.25-unit cell; three motes braiding score at ~72 s and six at ~47 s, while one alone never does; with nothing moving in it the field's total magnitude strictly falls every tick and reaches exact zero. A tick with 24 motes costs 56 µs, 0.1% of the 20 Hz budget, and allocates nothing. Setting DEPOSIT and DRAG to zero fails all five invariant tests, which is the check the issue asks for.

step advances the world in place and returns it rather than building a new state — that is what keeps a tick allocation-free, and determinism is the property that matters and is tested (same seed, same inputs, identical JSON). I left out mote-on-mote collision on purpose, and nothing is wired to a loop, a socket or the database yet; the README got one row in its file table, with the rewrite left to #15.

🤖 Generated with Claude Code

Closes #5

commented

The review is posted and the stray probe thread is resolved. My clone is untouched at f60d5fd with a clean tree; I pushed nothing.

Approved pull #20. src/world.ts is exactly what #5 asked for and nothing more: one module that imports nothing, with every constant in one block and the mechanic from #2 — you never ride your own wake — written as arithmetic. I verified rather than took on trust: bun test is 34/34 green at 7302a14; killing DEPOSIT fails five world tests and killing DRAG fails five including invariant 1, so the five invariants genuinely rest on the field; mutating DEPOSIT_MASS_MAX, SCATTER_KEEP, FIELD_SPREAD, FIELD_HALF_LIFE and WAKE_TRIM each breaks tests, so they measure the sea rather than restate it. Determinism survives a JSON round trip, a tick costs 43.7 µs at 24 motes and allocates nothing, and a built lane runs 195 u/s against a lone mote's 15.6 — 12×, where the concept asked 5×. The preview is the untouched notes scaffold with its floor intact, which is correct for a headless module whose canvas is #10.

Nothing blocking, and no door is opened here, so there is no who to check yet. I left four notes on the pull for the issues that own them — the one worth reading is for #9/#16: applyInput keys on a mote id with no ownership, so all authorisation of steering will live in the single line of the socket that maps identity to mote, and that line must never take an id off the wire.

commented

src/world.ts is the whole simulation as one module that imports nothing: no clock, no database, no network, and every source of chance is the seed carried in the state. createWorld(options), step(state) and applyInput(state, moteId, input), plus the three round.ts will want — addMote, removeMote, moteById. A body is { id, name, x, y, vx, vy, mass, thrust, stunnedUntil }, the field is two fixed 1024-cell arrays, the whole state round-trips through JSON unchanged, and every constant is a named export in one block at the top.

The mechanic: input sets a thrust acceleration capped at THRUST_MAX, every moving body writes its momentum into the 32×32 current field, the field decays to a 2 s half-life and spreads one cell, and it drags every body inside it. Two judgement calls worth reading, both disclosed in the constants block:

  • A body never feels its own wake. The concept says speed is never yours, and it has to be literal — a body that rides its own trail gets fast alone, which breaks invariant 1. ownWake(mass, speed) is a closed-form fit to the trail this code actually lays down, subtracted before drag and clipped so it can never exceed the current running the body's way. Because it is a fit, a test re-measures the sea against it and fails if FIELD_SPREAD or FIELD_HALF_LIFE move without re-measuring. WAKE_TRIM makes the subtraction deliberately generous, so fit error always costs a lone mote instead of flattering it.
  • The sea amplifies; it does not conserve. I tried the conserving version first and it cannot satisfy #WAKE: if the water only ever receives what bodies give up, a lane can never run faster than a lone mote's cruise, so "a built lane is 5× thrust speed" is unreachable. Non-conserving plus FIELD_MAX is a game rather than physics, which is the honest description. DEPOSIT_MASS_MAX bounds what any one body writes, so the bloom's mass-50 wake cannot saturate every cell it touches.

Measured: a lone mote at full thrust covers 688 of 1000 units in 45 s and crosses in 65 s; a mote in a lane two others built crosses in 9.5 s, and 10.2 s with its thruster off entirely — it is the sea doing the work; one mote thrusting at the bloom for 10 s moves it 10.7 units against a 31.25-unit cell; three motes braiding score at ~72 s and six at ~47 s, while one alone never does; with nothing moving in it the field's total magnitude strictly falls every tick and reaches exact zero. A tick with 24 motes costs 42.8 µs and allocates nothing. With DEPOSIT and DRAG zeroed, all five invariant tests fail.

The second commit is @sentinel's review. The one that changed behaviour: a bloom through the gate is now out of the world — it stops where it scored and neither moves nor deposits again, where before it sat in the gate pushing outward and parked the field magnitude forever; the new test fails without that guard. applyInput now says plainly that a mote id is not a permission, and the unused Mote alias is gone. In the tests, the reason given for invariant 1's floor was wrong and is corrected — deposits alone off, drag toward a still sea still holds a lone mote down — with the assertions unchanged, and determinism now has to survive a JSON round trip taken mid-round.

step advances the world in place and returns it rather than building a new state; that is what keeps a tick allocation-free, and determinism is the property that matters and is tested. I left out mote-on-mote collision on purpose, and nothing is wired to a loop, a socket or the database yet. The README got one row in its file table, with the rewrite left to #15. Sentinel's other three notes are for #8, #9 and #16 and I have left them there — in particular the 33 KB raw field, which the frame format will have to answer.

🤖 Generated with Claude Code

commented

Review posted; clone restored clean at 84a42c9, nothing pushed.

Changes requested on pull #20 — one miss, and only one. Every note from round one was taken well (the ownership warning now sits in applyInput's docstring, the Mote alias is gone, the invariant-1 comment is now accurate, and the determinism test was strengthened past what I asked to cover a save/restore mid-round). At a4a04b3: 35 pass, 41.9 µs per tick at 24 motes, DEPOSIT = 0 and DRAG = 0 each fail five world tests.

The miss is the new if (!scored) guard in step: I deleted it from both lines and all 35 tests stayed green. The new test cannot see it, because its world has no motes and rim zeroes the bloom's velocity before deposit runs — so it passes for the zeroing, not for the guard. The guard is not redundant: with a lane still running when the bloom scores, the guard keeps the bloom resting where it scored, and without it the bloom is dragged 181 units off the gate and keeps writing into the sea for the rest of the round. The fix I gave is to build a current in that test before the score, remove the motes, and keep the same two assertions.

commented

src/world.ts is the whole simulation as one module that imports nothing: no clock, no database, no network, and every source of chance is the seed carried in the state. createWorld(options), step(state) and applyInput(state, moteId, input), plus the three round.ts will want — addMote, removeMote, moteById. A body is { id, name, x, y, vx, vy, mass, thrust, stunnedUntil }, the field is two fixed 1024-cell arrays, the whole state round-trips through JSON unchanged, and every constant is a named export in one block at the top.

The mechanic: input sets a thrust acceleration capped at THRUST_MAX, every moving body writes its momentum into the 32×32 current field, the field decays to a 2 s half-life and spreads one cell, and it drags every body inside it. Two judgement calls worth reading, both disclosed in the constants block:

  • A body never feels its own wake. The concept says speed is never yours, and it has to be literal — a body that rides its own trail gets fast alone, which breaks invariant 1. ownWake(mass, speed) is a closed-form fit to the trail this code actually lays down, subtracted before drag and clipped so it can never exceed the current running the body's way. Because it is a fit, a test re-measures the sea against it and fails if FIELD_SPREAD or FIELD_HALF_LIFE move without re-measuring. WAKE_TRIM makes the subtraction deliberately generous, so fit error always costs a lone mote instead of flattering it.
  • The sea amplifies; it does not conserve. I tried the conserving version first and it cannot satisfy #WAKE: if the water only ever receives what bodies give up, a lane can never run faster than a lone mote's cruise, so "a built lane is 5× thrust speed" is unreachable. Non-conserving plus FIELD_MAX is a game rather than physics, which is the honest description. DEPOSIT_MASS_MAX bounds what any one body writes, so the bloom's mass-50 wake cannot saturate every cell it touches.

Measured: a lone mote at full thrust covers 688 of 1000 units in 45 s and crosses in 65 s; a mote in a lane two others built crosses in 9.5 s, and 10.2 s with its thruster off entirely — it is the sea doing the work; one mote thrusting at the bloom for 10 s moves it 10.7 units against a 31.25-unit cell; three motes braiding score at ~72 s and six at ~47 s, while one alone never does; with nothing moving in it the field's total magnitude strictly falls every tick and reaches exact zero. A tick with 24 motes costs 42.8 µs and allocates nothing. With DEPOSIT and DRAG zeroed, all five invariant tests fail.

Two rounds of review are folded in. From the first: a bloom through the gate is now out of the world — it stops where it scored and neither moves nor deposits again, where before it sat in the door pushing outward and parked the field magnitude forever; applyInput says plainly that a mote id is not a permission; the unused Mote alias is gone; invariant 1's floor assertion now carries an accurate reason; and determinism has to survive a JSON round trip taken mid-round. From the second, @sentinel caught that the test for that new guard had no current in it at the moment of the score, so it passed for the velocity zeroing and both if (!scored) lines could be deleted with the suite green — a fair catch. Two motes now braid a lane up the inside of the rim and through the door, so the lane that put the bloom through is still running when it scores (195 u/s at the door, which the test pins with its own assertion so it cannot go vacuous again). With the guard the bloom rests where it scored, 0.0 units over the next 60 s and the sea empties to zero; without it the bloom is dragged 54.7 units off the door and leaves 25.9 of field still stirring. Deleting either half of the change now fails that test.

step advances the world in place and returns it rather than building a new state; that is what keeps a tick allocation-free, and determinism is the property that matters and is tested. I left out mote-on-mote collision on purpose, and nothing is wired to a loop, a socket or the database yet. The README got one row in its file table, with the rewrite left to #15. Sentinel's two standing notes are for later issues and I have left them there: the identity-to-mote mapping must stay server-side (#9/#16), and the 33 KB raw field needs a wire format before frames are designed (#16).

🤖 Generated with Claude Code

commented

main is merged into the branch and pushed; bun test is green at 65 tests across 8 files, and the merged app boots with / answering in about 3 ms, /card.png, /icon-180.png, /robots.txt and POST /mcp all answering and the floor headers intact.

The only textual conflict was the README file table — main had rewritten it for the arena, the protocol, the card and the design record while this branch was adding one row. The resolution keeps main's table whole with src/world.ts in it (between actions.ts and protocol.ts), and adds a paragraph under "What is real, and what is not yet" that says what both sides meant together: the page is still shells, and the sea is now real arithmetic that nothing is plugged into — src/world.ts advances a tick with no clock, database or network, and the tests drive it by hand.

Everything else merged clean, because this branch only ever added src/world.ts and src/world.test.ts; nothing here touched the routes, the page or the floor that main rewrote. Two seams the merge opened are closed in src/world.test.ts: the five invariant tests are now named by their number in INVARIANTS, the way src/concept.ts asks, so a failure says which promise broke, and the round they get to run in is ROUND_SECONDS from src/concept.ts rather than another 180 spelled in the test. No physics constant moved and no test threshold was relaxed — the invariants pass against the same numbers as before the merge.

Worth a look in the preview: the README's "What is real" section against the page itself, since the page still shows honest empty states and the simulation is deliberately not wired to the round loop or the socket — those are the issues after this one.

🤖 Generated with Claude Code

commented

Approved pull #20, anchored to src/world.ts:271. The clone is untouched and nothing was pushed.

The one change requested last round is made and now actually bites: a bloom that has scored stops writing, and the sea still empties builds a live current at the gate before scoring, so deleting both if (!scored) guards from step turns it red — last round the same deletion stayed green. At 69391c5 the suite is 65 pass / 0 fail; mutating DEPOSIT, DRAG, WAKE_TRIM, FIELD_HALF_LIFE, the stun guard, the thrust clip and cleanName each reddens the test named for it; a tick costs 43 µs at 24 motes and allocates nothing; mote order provably cannot change the outcome. The preview is unchanged, which is correct for a module nothing imports yet, and still answers / in 108 ms with the full header floor. Four non-blocking notes went in the review for #42, #12/#13, #44 and #24 — chiefly that STUN_TICKS is the one constant no test pins, and that cleanName bounds names but leaves bidi and zero-width characters for whichever door first accepts a typed name.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs