feat(combat): frag grenades, server-authoritative#30
Merged
Conversation
Adds the one throwable V1 gets: two frags per life, no resupply, thrown with G. PRD §13.2 and the "V1 throwable" row in the locked decisions are updated in this commit, because neither existed before. Authority sits where CLAUDE.md puts it. The client sends a request and nothing else — no position, no direction, no count. `MatchSimulation.throwGrenade` decides whether one is left, whether the cooldown has passed, where it leaves from and who the blast reaches. Bots use the identical call, so a bot can do nothing a player could not. Flight is deterministic and shared, which is what lets the wire carry one message per throw instead of a position per grenade per snapshot: every client runs the same `stepGrenade` from the same launch state. Two rules in `resolveBlast` are deliberate rather than incidental: - Teammates are immune, the thrower is not. Hitscan already skips teammates, so a grenade that hurt them would be the one griefing vector in a free alpha. The thrower keeps a 60% share or the arc stops being a decision. - Cover blocks damage. Without a line-of-sight trace, one throw deletes the value of the yard's three lanes. Tuning note that is not visible in the numbers: armour absorbs half of incoming damage, so 100 health plus 50 armour survives anything under 150. An obvious-looking 130 left someone standing directly on a frag at 20 health, which reads as a broken grenade. It is 165, lethal inside about 2 m. Protocol goes to 2, and the supported window closes with it. `THROW_GRENADE` already existed in v1 and did nothing, so the message set never changed in a way a version check could see — the *behaviour* did, and a v1 client would take blast damage it can neither cause nor render. New compatibility tests cover it. The explosion effect took three attempts, all of them frame-timing bugs: - `setTimeout` to bound emission measures wall-clock, and expires between frames on a slow machine, so nothing is emitted at all. - Checking that deadline in `update` fails the same way whenever one frame is longer than the window. - `targetStopDuration` leaves the system reporting `isStarted() === false`, and Babylon skips animating those entirely — particles froze at full count, neither drawn nor retired, which is a leak as well as an invisible effect. It is now a `manualEmitCount` burst, which is what an explosion actually is and has no timing question at all. The blast also carries an additive flash quad, not only a PointLight: the yard already exceeds the 6-light material cap and Babylon drops the surplus silently, so the light is a plausible casualty exactly when the scene is busiest. Sound is three synthesised takes (crack, falling 58 Hz body, long debris tail), attenuated by distance. Grenade mesh is the licensed Synty `SM_Wep_Grenade_01`, converted through the existing driver. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
ralyodio
added a commit
that referenced
this pull request
Jul 26, 2026
Two 1.4 MB screenshots from a local Playwright verification run were picked up by a `git add -A` and shipped in #30. They are scratch output, not assets, and carry no provenance entry — which is exactly the rule they would otherwise quietly break. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the one throwable V1 gets: two frags per life, no resupply, thrown with G. PRD §13.2 and a "V1 throwable" row in the locked decisions are added here, because neither existed before — this was a new product decision, not an implementation of a written one.
Authority
The client sends a request and nothing else — no position, no direction, no count.
MatchSimulation.throwGrenadedecides whether one is left, whether the cooldown has passed, where it leaves from, and who the blast reaches. Bots call the identical method, so a bot can do nothing a player could not.Flight is deterministic and shared, which is what lets the wire carry one message per throw instead of a position per grenade per snapshot: every client runs the same
stepGrenadefrom the same launch state (PRD §30.3, network budget).Two deliberate rules in
resolveBlastTuning note
Armour absorbs half of incoming damage, so a spawn loadout of 100 health + 50 armour survives anything under 150. An obvious-looking
maxDamage: 130left someone standing directly on a frag at 20 health, which reads as a broken grenade rather than a balanced one. It is 165 — lethal inside ~2 m, heavy to 3 m, chip damage to 6.5 m.Protocol 1 → 2, window closed
CLIENT_MESSAGE.THROW_GRENADEalready existed in v1 and did nothing, so the message set never changed in a way a version check could see. The behaviour did: a v1 client would take blast damage it can neither cause, see, nor avoid.MIN_SUPPORTED_PROTOCOL_VERSIONis raised with it rather than left open for a smoother deploy — an unwinnable match is worse than a reconnect. Newversion.test.tscovers this.The explosion effect took three attempts, all frame-timing bugs
setTimeoutto bound emission measures wall-clock, and expires between frames on a slow machine — nothing is emitted at all.updatefails identically whenever one frame is longer than the window.targetStopDurationleaves the system reportingisStarted() === false, and Babylon skips animating those entirely — particles froze at full count, neither drawn nor retired. That is a leak as well as an invisible effect.It is now a
manualEmitCountburst, which is what an explosion actually is, and has no timing question at all. The blast also carries an additive flash quad rather than only a PointLight: the yard already exceeds the 6-light material cap and Babylon drops the surplus silently, so the light is a plausible casualty exactly when the scene is busiest.Verification
Typecheck, lint, 235 tests (33 new) and the full build pass. 22 sim tests cover flight, tunnelling, cover, team rules, self-damage, cooldown, carry count and respawn resupply.
Verified in the built game by driving
/playin a browser: the throw decrements the HUD, the grenade renders in flight, and instrumenting the event chain showed detonations resting aty ≈ 0.092— exactly the grenade radius above the floor.One limitation, stated plainly: I could not visually confirm the explosion particles. The only browser available here is a headless software rasteriser, and the pre-existing, known-good impact sparks do not render in it either. The particle state is verified (correct burst counts alive and retiring on schedule); the pixels are not.
🤖 Generated with Claude Code