Skip to content

Pay for a participant, carry the kills, and end the match on every machine (milestone 8) - #28

Merged
sbddesign merged 3 commits into
mainfrom
feat/match-rules
Sep 4, 2026
Merged

sbddesign merged 3 commits into
mainfrom
feat/match-rules

Conversation

@sbddesign

Copy link
Copy Markdown
Contributor

Milestone 8 of the multiplayer plan: who is paid for what when there is more than one seat, a kill feed that says so on every machine, and a match that ends with one scoreboard on both. Three commits, on top of the merged #27.

What changes

Attribution (c02301e)

Two holes the code named and deferred: an unattributable kill fell back to seat 0, and shooting another participant paid nothing. Right for one seat, arbitrary for two.

  • The arena is a faction. Mines and scrapes carry FACTION_ENVIRONMENT instead of being blamed on "the other side" (notMe is gone). Credit is one rule in scores.ts: the seat that last landed a hit on the victim, so a hostile chased onto a mine still scores for the chaser; in a match of one seat, that seat (the shipped scoreline bit for bit, the seeded baseline check untouched); with more seats and no last hitter, nobody. Arena damage pays points but not a hit, so accuracy stays bolts landed over bolts fired.
  • Participants pay. A hit on another player is a hit; downing one pays PARTICIPANT_BOUNTY_MULT (2) × the airframe's bounty through the shooter's streak. Never to the victim.
  • Kill feed. Every kill is a KillEvent (killer, victim, hull, award, seq); the snapshot carries the last FEED_RING (4), snapshot version 2. The host announces as it happens; the mirror announces each once by sequence, which survives lost and reordered snapshots. Same text from the same event, only YOU moves: shooter P2 DOWN +300, victim DOWNED BY P1, everyone else P1 ▸ P2 DOWN.

Match result (0a24922)

  • sealMatch is the rule in one place. On a cleared squadron every seat still flying is paid for finishing intact (hull fraction × 1200) and fast (4000 − 25/s, floored at 0); for one seat that is exactly the win bonus the debrief always showed, so single-player is unchanged. placeLines places by score: ties share a place, an eliminated seat places after every flying one whatever it scored, first place and still flying wins. An emptied arena has a placing and no winner.
  • RESULT frame (FRAME.RESULT). The host sends it on the first tick after resolution and again every HELLO_EVERY (30) ticks for as long as it ticks, because the channel is unreliable and a lost RESULT is a debrief the joiner never sees. No snapshot is sent on or after the resolving tick (the roster is cleared; a snapshot of nobody is not a world). The mirror drops its queue and Game.concludes; repeats are ignored. Short, long, foreign or mistyped frames are refused with a RangeError.
  • Debrief on both machines: place, seat, hull, score, K/D/accuracy, local seat lit. Alive-but-outscored reads OUTSCORED, not HULL BREACH.

Evidence

simcheck 567 → 608, check:balance holds, typecheck and npm run build clean, all at 9c6da34 (rebased onto main after #27 merged).

  • Two Hornets on autopilot with station-sized hulls and an untouchable squadron fight to a death: every death is the other's kill, the award is a rung of the streak, both feeds read as above from the same events.
  • Two mined waves: one untouched before anyone fires pays nobody and the feed blames the arena (in a match of one, pays that seat); one worked over by a single shooter pays the shooter, not the idle seat.
  • Two-seat loopback match where a mine dents the host's own hull before the clear: both machines report the same lines, the peer is told once, the dented host places second and does not win, the score gap is exactly the missing hull, no snapshot follows the result. The same match at 50% loss still concludes on the mirror.
  • Over both wire checks the mirror's feed has exactly the host's line count, including at 30% loss.
  • check:mutants: 110 of 110 caught cleanly, 0 survived, 0 not testable, at 9c6da34. Running the gate on the whole rebased branch found five earlier mutants that no longer applied (their target lines moved in M8); 9c6da34 re-points them and adds one more (a result said once and never again). Re-pointing "a teammate's win overwrites the sealed loss" showed it had become equivalent on everything the check read, so the check now also pins the clock: an eliminated seat's result carries the time it died, not the time the match ended. The first gate run on this branch left four real survivors, each a test gap not dead code (arena damage paying the sole seat, sole seat paid for its own death, scrape blamed on the other side, eliminated seat paid the finishing bonus). Each has a check now; a fifth (cleared && on the win) was equivalent and the flag is gone.

Not here

Milestone 9: lobby, seat backfill by AI, respawn policy. The host-side twin of #27's pacing (peer intents are still flown as they arrive).

🤖 Generated with Claude Code

sbddesign and others added 3 commits September 4, 2026 15:25
Milestone 8, first half: who is paid for what, and a feed that says so on
every machine.

Attribution had two holes the code named and deferred: an unattributable
kill fell back to seat 0, and shooting another participant paid nothing.
Both were exactly right for one seat and arbitrary for two. Now:

- Damage the arena inflicts -- a mine, a scrape -- carries a faction of its
  own, `FACTION_ENVIRONMENT`, instead of being blamed on "the other side"
  (`notMe`, gone). Who is *credited* is a rule in one place: the seat that
  last landed a hit on the victim, so a hostile chased onto a mine still
  scores for the chaser; in a match of one seat, that seat, which is the
  shipped scoreline bit for bit (the seeded baseline is untouched); with
  more seats and no last hitter, nobody. The star names the victim's own
  faction and is never a hit for anyone, as before. Arena damage pays
  points but not a hit: accuracy is bolts landed over bolts fired.
- A participant's hull pays like any other: the hit to whoever landed it,
  the kill to whoever is owed it, at `PARTICIPANT_BOUNTY_MULT` (two) times
  the airframe's bounty, through the shooter's streak. Never the victim.
- Every kill is a `KillEvent` -- killer, victim, hull, award, sequence --
  and the snapshot carries the last four (`FEED_RING`). The host announces
  as it happens; a mirror announces each event once as it arrives, by
  sequence, which survives lost and reordered snapshots. Same text from the
  same event on every machine, with only YOU moving: the shooter reads
  "P2 DOWN  +300", the victim "DOWNED BY P1", everyone else "P1 > P2 DOWN".
  Snapshot version 2.

Checks: two Hornets on the autopilot with hulls the size of stations and
an untouchable squadron fight to a death, every death is the other's kill,
the award is a rung of the streak, and both seats' feeds read as above
from the same events. Two mined waves: one untouched before anybody fires
(pays nobody, the feed blames the arena; in a match of one, pays that
seat), one worked over by a single shooter (pays the shooter, not the idle
seat). The pinned "shooting a participant scores nothing yet" check is
replaced. The sealed-loss check that relied on a dead seat 0 going on
earning now pins the opposite. Over both wire tests the mirror's feed has
exactly the host's line count, including at 30% loss.

Simulation checks 567 -> 584, eleven new mutations.

Not here yet: the match result -- a winner, a placing, a debrief on the
mirror -- which is the second half.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
Milestone 8, second half: how a match ends when there is more than one
seat, and telling the mirror.

`sealMatch` is the rule, in one place. On a cleared squadron every seat
still flying is paid for finishing intact (hull fraction x 1200) and fast
(4000 - 25/s, floored at zero) -- for one seat exactly the win bonus the
debrief always showed, so the single-player result is unchanged. Seats
are then placed by score (`placeLines`): equal scores share a place, an
eliminated seat places after every flying one whatever it scored, and the
seats placed first and still flying have won -- a match that emptied the
arena has a placing and nobody flying, so no winner. The time bonus is the
same for everyone and cannot move the placing; the hull bonus can, and is
meant to. `RunResult` carries the `MatchResult` when there was more than
one seat.

The host keeps the result (`Game.result`) and `createHost` sends it to
every peer in a RESULT frame on the first tick after the match resolved
and every half second after, for as long as it ticks: the wire drops
frames, and a result that never arrived is a debrief the joiner never
sees. No snapshot is sent on the resolving tick or after, since `finish`
has cleared the roster and a snapshot of nobody is not a world. The mirror
decodes it (short, long, foreign or mistyped frames refused with a
RangeError), drops whatever snapshots it was still holding, and
`Game.conclude`s: arena cleared, `onEnd` with its own seat's line; a
repeat after that is ignored. The debrief shows the board on both machines
-- place, seat, hull, score, kills/deaths/accuracy, the local seat lit --
and a seat that was alive and outscored reads OUTSCORED rather than HULL
BREACH.

Checks: the placing rules directly (ties, the eliminated, an emptied
arena); the frame codec; and a two-seat match over the loopback where a
mine dents the host's own hull before the squadron is cleared: both
machines report the match ended with the same lines, the peer was told
once, the dented host placed second and did not win, the gap between the
two scores is exactly the hull the host was missing, and no snapshot
followed the result. The same match over a wire that loses half of
everything still concludes on the mirror, because the result was said
again. The sealed-loss check reads the eliminated seat's line off the
board: last, unpaid for finishing, not the winner. The stale sealResult
commentary that deferred all of this is gone.

The first cut of the mutation gate for this branch left four mutants
alive, and each was a test gap rather than dead code: the arena's damage
paying the sole seat points, a sole seat being paid for its own death, a
scrape blamed on "the other side", and the eliminated being paid the
finishing bonus. Each has a check now; a fifth mutant (`cleared &&` on the
win) was equivalent, and the flag is gone.

Simulation checks 584 -> 607, seven new mutations.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
… sealed loss reports

Rebasing onto the merged #27 and running the gate on the whole branch
found five mutations that no longer applied — the gate's "not testable",
which it rightly fails over, and which the earlier per-commit runs had not
seen because they predate the code moving:

- "the host never tells its peers the match ended" targeted the first
  draft of the RESULT send, before it was made to repeat. Now it silences
  the resend branch itself. A sixth mutation joins it: a result said once
  and never again, which the 50%-loss wire check already pins.
- "every hit is credited to seat 0" and "every kill is credited to seat 0"
  targeted the pre-M8 fallback-to-seat-0 code. Now they hit `onDamaged`'s
  direct credit and `bountyGoesTo`.
- "finish waits only for flying seats" and "a teammate's win overwrites the
  drawn seat's sealed loss" targeted `finish(pendingResult ?? …)`, which
  became `resolveMatch`. The first now weakens the guard on
  `resolveMatch(false)`; the second drops the sealed-result branch.

That last one was equivalent on everything the eliminated-seat check read:
the board's line for an eliminated seat already says it lost, with the
score it died with. What differs is the clock — a result sealed at death
carries the time it died; the line read at resolution carries the time
the match ended, seconds later. The check now pins that too: reported
time within a tick of the death, and well before the resolution.

Simulation checks 607 -> 608; 109 -> 110 mutations.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
neon-orbit Ready Ready Preview Sep 4, 2026 7:36pm UTC

Request Review

@sbddesign
sbddesign merged commit dec6a54 into main Sep 4, 2026
4 checks passed

This branch was successfully deployed

1 active deployment
Preview 9c6da346 Deployed Sep 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant