Skip to content

feat(combat): frag grenades, server-authoritative#30

Merged
ralyodio merged 1 commit into
mainfrom
feat/grenades
Jul 26, 2026
Merged

feat(combat): frag grenades, server-authoritative#30
ralyodio merged 1 commit into
mainfrom
feat/grenades

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

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.throwGrenade decides 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 stepGrenade from the same launch state (PRD §30.3, network budget).

Two deliberate rules in resolveBlast

  • Teammates immune, thrower not. Hitscan already skips teammates, so a grenade that hurt them would be the only griefing vector in a free alpha. The thrower keeps a 60% share, or the arc stops being a decision and every wall in reach gets a grenade.
  • Cover blocks damage. Without a line-of-sight trace from the blast centre, one throw deletes the value of the yard's three lanes.

Tuning note

Armour absorbs half of incoming damage, so a spawn loadout of 100 health + 50 armour survives anything under 150. An obvious-looking maxDamage: 130 left 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_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: a v1 client would take blast damage it can neither cause, see, nor avoid. MIN_SUPPORTED_PROTOCOL_VERSION is raised with it rather than left open for a smoother deploy — an unwinnable match is worse than a reconnect. New version.test.ts covers this.

The explosion effect took three attempts, all frame-timing bugs

  • setTimeout to bound emission measures wall-clock, and expires between frames on a slow machine — nothing is emitted at all.
  • Checking that deadline in update fails identically 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. That 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 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 /play in a browser: the throw decrements the HUD, the grenade renders in flight, and instrumenting the event chain showed detonations resting at y ≈ 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

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>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit e0f623a into main Jul 26, 2026
6 checks passed
@ralyodio
ralyodio deleted the feat/grenades branch July 26, 2026 18:55
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>
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