Skip to content

M7: presentation, combat feel, and the pure-core repair/melee systems - #1

Merged
Soren-Caron merged 27 commits into
masterfrom
feat/wave-spawner
Aug 29, 2026
Merged

Soren-Caron merged 27 commits into
masterfrom
feat/wave-spawner

Conversation

@Soren-Caron

Copy link
Copy Markdown
Owner

27 commits taking the project from M2's combat skeleton through M7 presentation work: R6 enemy rigs, sprint, reload, melee sweep, field repair, damage direction arrows, an end-of-run summary card, prop set dressing, and a boot loading screen.

See docs/13-NEXT.md for the full ledger of what was fixed, verified, and why.

1,079 pure-core tests and 139 backend tests pass. rojo build is clean.

🤖 Generated with Claude Code

Soren-Caron and others added 27 commits August 23, 2026 00:36
`core/sim/Budget` has computed spawn plans since M2 and nothing called it.
`EntityService.spawn` was reachable only from diagnostics and TickBench, so
a real play session contained no enemies at all. Found by querying
entityCount during a live run, not by reading code -- an empty room looks
like a quiet room from inside the game.

Adds `core/sim/WaveScheduler` (pure: when to ask for a plan, and how much
budget is outstanding) and `server/services/WaveService` (lookups only).
New `waves` tick phase runs before `sim` so an entity spawned this tick is
stepped and recorded into rewind history on the same tick.

Spec addition, recorded in docs/01 §Scaling -> Cadence: the budget formula
is a standing pressure level, not a per-wave allowance. Read as a per-wave
grant, timeRamp compounds the spawn rate and buries the level by minute
eight. Waves top the live population up toward the target instead, and
corpses are excluded from aliveValue -- a body on the floor is not pressure
and would otherwise hold the population ceiling closed.

Also closes M4's hardcoded pressure input: RunPressure.budgetSpent has been
a literal 0 since M4 because nothing tracked standing budget. Injected via
setBudgetSpentProvider rather than required, since WaveService already
requires DirectorService for spawnMultiplier and a direct require would
close a cycle.

Studio verification, live run:

  first wave at 20.06s          matches FIRST_WAVE_DELAY_SECONDS exactly
  census 4 Skitter + 5 Sentry + 1 Reclaimer -> aliveValue 22   (4+10+8)
  55 alive against a 40 cap     -> at_cap, waves unchanged
  over-target population        -> pressure_satisfied
  after waveReset               -> waves resume, spent 12 against target 11.9
  waves phase                   0.039ms mean
  tick p95                      0.29ms against 12ms budget, 0 overruns

Measured and counter-intuitive enough to document: INTERVAL_SECONDS is a
minimum spacing, not a guarantee. The second wave landed at 44.03s rather
than 32s because outstanding budget did not clear MIN_WAVE_BUDGET at the
12s mark -- it spent only 3.71 when it did fire. Pacing is gated by what is
still alive, not by the clock.

spawnPattern is now partly live: hunt_heaviest spawns a Reclaimer,
off-budget and one at a time. It is directorOnly so the weighted picker
never selects it, making this the only path that produces one -- which is
what makes the director's decision legible to players, as docs/01 intends.
Confirmed in-engine: a Reclaimer appeared under hunt_heaviest and not under
later patterns.

Three of my own tests were wrong and got corrected rather than the code:
each assumed the 12s interval governed while a wave was blocked, but
wavesSpawned advances on spawn rather than on elapsed time, so the 20s
first-wave grace keeps applying. Added a test naming that behavior.

  lune run tests   826 passed, 11 todo   (was 797, +29)
  syntax/encoding  133 files pass
  backend          16 files, 139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from a play session: two minutes in contact, no damage taken.

EntityService transitioned entities to "attack", zeroed velocity, faced the
target, and returned. Enemies.dmg and Enemies.atkCooldown were read by
nothing anywhere in src/, and no server code touched player Humanoid health.
There was no backlog entry for this -- not deferred, never written.

M2-4 passed over it because its criterion was "does what its row says",
verified by measuring distance closed. Its recorded output reads
"Skitter SwarmNearest state=attack dist 70.0 -> 4.0" -- a true statement
about arriving, and silent about what happens next.

Adds core/sim/EnemyAttack (pure: cooldowns, range, damage) and
server/services/EnemyCombatService (reads entity state, writes a Humanoid).
Kept out of CombatService: that module is one pipeline -- validate a fire
request against rewound history -- and enemy strikes share no step of it.

Three rules the tests encode:

  - Arriving is not attacking. An entity serves a full cooldown before its
    first strike, or a swarm closing simultaneously deletes a player in one
    tick with no reaction window.
  - Cooldowns advance from now, not from the missed deadline. Otherwise an
    entity held out of range for ten seconds banks ten cooldowns and
    discharges them on contact.
  - Cooldown entries are reaped. A table keyed by entity id that only grows
    is a leak a play session never surfaces but a 12-minute run does.

Also sets entity.targetId, which has existed on Types.Entity since M1 and
was never assigned -- nearestTarget/heaviestTarget returned a position only,
so a strike would land on whoever was nearest at damage time rather than on
the player the entity chose and is facing.

Studio verification, live run:

  Skitter solo   windup held 0.8s, 4 strikes x 8  = 32 damage
  Lancer solo    windup held 1.2s, 3 strikes x 14 = 42, struck from 54 studs
  3 Skitters     13 strikes, 104 damage, player killed, downs=1
  pendingCooldowns tracked entity count exactly, returned to 0 after removal
  enemyCombat phase   0.016ms mean
  tick p95            0.30ms against 12ms budget, 0 overruns

Closes the run-ending path, which had never executed. Killing the player
moved the run from died=0 active=1 complete=false to died=1 active=0
complete=true endReason="squadResolved". RunLifecycleService has bound
humanoid.Died since M2, but nothing could kill a player, so loot-dropping,
insurance claims and the risk half of the economy had never run for real.

Worth knowing when reading the numbers: damageDealt legitimately exceeds
observed HP drop over any window longer than a second, because Roblox's
default health regeneration ticks 1%/s between strikes. Confirmed rather
than assumed -- at t=2.5s and t=3.0s the resolver had dealt an identical 72
damage while HP rose from 29.0 to 30.0. The probe reports both numbers so
this reads as regen rather than as an accounting bug.

One of my own tests was wrong and got corrected rather than the code: a nil
override cannot survive a pairs() copy, so the "no target id" case silently
kept the default and would have passed no matter what the code did.

Not built: no client damage feedback -- no hit indicator, no directional
cue, no sound. From the player's seat, health simply drops. Belongs with
M7-8.

  lune run tests   849 passed, 11 todo   (was 826, +23)
  syntax/encoding  136 files pass
  backend          16 files, 139 tests passed
  sim              18 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from a play session with a screenshot: "positioning of enemy is
weird, they also rapidly rotate". Three independent causes.

Buried to the waist. LevelService.spawnPointsFor returns points at y = 0,
the floor plane, and consumers offset -- LootService already did its own
+1.6. But an entity's position is its centre: the hit-detection sphere is
centred there and the client centres its part there too. Spawning at the
floor sank half of every entity through it and left the top half of the
visible body outside its own hitbox. WaveService.grounded now lifts by
hitboxRadius so the sphere rests tangent to the floor.

Spinning. facingYaw assigned yaw straight from atan2 with no rate limit, so
an entity could reverse facing between two ticks -- at 20 Hz that reads as a
spin rather than a turn. Compounding it, the only guard on a degenerate
direction was EPSILON = 1e-6: a 0.3-stud offset is numerically fine but
directionally violent, swinging tens of degrees per tick as the target
shuffles. Adds Steering.slewYaw (rate-limited, short way round the seam) and
a minMagnitude floor on facingYaw. Per-kind turnRate orders it by mass.

Standing inside the player. The attack branch did vel = 0; continue, so
Steering.separate -- which exists and is tested -- never ran while
attacking. A crowd a moving player walked into stayed interpenetrated
forever because nothing was left to push it apart. Attackers now separate
from neighbours and from the target at a fraction of their speed.

Studio verification, 8 Skitters swarming a live player:

  worst angular rate       1.828 rad/s against an 8.0 rad/s cap
  closest approach         4.65 studs against 4.1 studs personal space
  closest entity pair      2.18 studs
  grounding                Skitter y=1.60 (r 1.60), Sentry y=2.20 (r 2.20)

Screenshot confirms solid cubes resting on the floor rather than flat
squares clipping through it.

A measurement I had to redo: the first pass reported 2.0000 rad turned in
one tick against a 0.4 cap, an apparent 5x breach. It was exactly 5 x 0.4,
which is the tell -- the poll loop was aggregating five server ticks per
sample. Re-measured as an angular rate against wall-clock elapsed time
rather than assuming one tick per sample, it is 1.828 rad/s. The cap was
never breached; the measurement was.

One of my own tests was wrong: slewYaw to exactly +/-pi is a genuine tie,
since half a turn is equally short both ways, and the documented (-pi, pi]
range resolves it positive. The test assumed it broke negative. Corrected
the test and pinned the tie explicitly, since consistency is what matters
there -- an entity that picks a new direction each frame vibrates in place.

New tunables live in config/Combat.luau rather than config/Enemies.luau:
several services iterate that table as `for kind, config in Enemies`, so a
non-kind key there would be read as an enemy called PLAYER_RADIUS and
spawned.

Known, not fixed: the client draws a fixed 3-stud box for every kind because
the snapshot has no room for a kind byte inside the 11-byte-per-entity
budget, so kinds far from a 1.5 radius sit visually high or low. Resolves
with per-kind rigs (M7-3/M7-6), not with a netcode change.

Found while testing, not fixed: a run ends permanently on player death with
no restart path, so a single-player Studio session is one death and done.

  lune run tests   860 passed, 11 todo   (was 849, +11)
  syntax/encoding  137 files pass
  backend          139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "way too hard, impossible to beat" and "sometimes I take damage
when the enemy that's dead isn't there". Both measured; neither was tuning.

Enemies ignored line of sight. Player shots have raycast against level
geometry since M1 (CombatService step 6, "wall"); EnemyAttack checked
distance only. Sentries at 45 studs and Lancers at 55 were shooting through
walls from adjacent rooms, so cover did not exist and contact could not be
broken. Line of sight is now an injected predicate -- core stays pure, the
adapter supplies the raycast, and it is checked before the cooldown is armed
so nothing banks a windup behind a wall and strikes on reveal.

Invisible attackers. MAX_ALIVE was 40 against INTEREST_MAX of 32.
Replication sends a client at most 32 entities and EntityRenderer releases
anything absent from a snapshot, so up to eight entities were alive,
pathing and attacking while never drawn for anyone. That is the damage from
an enemy that was not there. MAX_ALIVE is now 32 and a test asserts
MAX_ALIVE <= INTEREST_MAX -- the old test asserted >=, permitting the exact
bug it should have caught.

Measured balance, before:

  Sentry   11 dmg / 0.35s = 31.4 dps, capPerWave 6
  5 of them = 157 dps -> a 100 HP player dies in 0.64s
  killing 5 of them = 650 HP / 88 dps Sidearm = 7.4s

Sentry cooldown 0.35 -> 0.75, Hauler capPerWave 4 -> 3, and ranged kinds now
fall off with distance using the same curve player weapons use. Melee kinds
deliberately do not: a Skitter biting at 3.9 studs is not weaker than at 1.

Wired weapon switching to number keys 1-4. All four weapons have worked
server-side since M2-5 and setWeapon has existed since M1, but nothing was
ever bound to it, so a real session could only fire the Sidearm. At 88 dps
that made a Warden (700 HP, 15.7 dps) and a Reclaimer (550 HP, 26 dps)
unwinnable solo. The HUD now shows the live weapon and its ammo, since a
switch with no feedback is indistinguishable from a dead keybind.

Studio verification, Sentry, player anchored:

  behind a wall    endDist=35.1 state=attack blocked=100% strikes=0 dmg= 0.0
  clear sightline  endDist=24.0 state=attack blocked=  0% strikes=7 dmg=63.3

state=attack in both, so it is being refused by sight rather than sitting
idle. perHit 9.05 matches the falloff arithmetic exactly:
11 * (1 - 0.55 * (24-14)/(45-14)) = 9.05.

Two of my own tests were invalid and got rewritten rather than believed:

  - The first line-of-sight test required EntityService from the command
    bar, which per CLAUDE.md returns a fresh idle copy whose entities no
    tick phase steps. They sat in "spawn" forever and never attacked, which
    reads exactly like a working sight check and is not one. Now a
    registered diagnostic, and it samples whether the line was genuinely
    blocked rather than assuming placement worked.
  - The balance guards passed a second argument to expect(), which takes
    one. Every message was silently dropped, so the failures said nothing.

Also documented in docs/01 §Scaling -> Survivability: worst case is modelled
on what can physically engage (six melee bodies fit around a player; ranged
kinds all hold a sightline but at falloff distance), not on a naive sum of
per-type caps, which assumes 24 Skitters touch you at once.

  lune run tests   873 passed, 11 todo   (was 860, +13)
  syntax/encoding  137 files pass
  backend          139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Enemies now differ visually by kind. `kind` is packed into the spare bits of
the snapshot's state byte (bits 3-5; there are six states and six kinds,
three bits each) so it costs zero extra bandwidth -- a twelfth byte would be
640 B/s per client at the 32-entity interest cap. Colour, silhouette and
material now vary per kind, with size derived from hitboxRadius so what you
see is what the server lets you shoot.

Two bugs found doing it, both the same shape: the Interpolator builds its own
Sample type and was silently dropping fields the renderer needed.

  - `kind` was dropped, so every enemy fell back to the default grey box.
    The snapshot round-trip tests all passed; they never went through the
    interpolator.
  - `hpPct` was dropped, so the new hit-flash code did `nil - 1e-4` and
    threw every frame. Because RenderStepped aborts the whole callback on
    error, the render loop died partway through its entity list and some
    entities stopped being drawn at all -- while remaining alive and
    attacking on the server. Found by reading the client console after
    noticing 6 entities sampled OK but only 3 rendered.

Both now carried through all three Sample construction sites, with tests
asserting a sample never returns a nil kind or hpPct.

Client feel:
  - Crosshair (was none at all -- the game asked players to aim at the
    middle of their monitor from memory).
  - Hitmarker on server-confirmed hits. `onHitmarker` and
    `onRemoveHitmarker` were declared in M1 and never wired to anything.
  - Enemy hit flash on health drop, wounded tint as they bleed out, and a
    death collapse-and-fade instead of blinking out.
  - Hurt vignette on taking damage, derived from Humanoid health rather than
    a new remote, since the engine already replicates it.
  - Effects module: tracers that taper and fade rather than a hard 50ms cut,
    muzzle flash with a real PointLight (which matters in the Vault's fog),
    and impact sparks. Every part is CanQuery = false -- a tracer that
    participates in raycasts would block the next shot's wall check.

Studio verification, one of each kind spawned:

  rendered            6 / 6
  distinct heights    6   (3.04 Skitter .. 7.60 Warden)
  distinct colours    6
  distinct materials  6
  crosshair           present, visible, 4 ticks
  hitmarker           present, hidden at rest
  hurt vignette       present, clear at rest

Not verified: the hurt vignette firing. The damage probe did not land a hit
that run (HP 100 -> 100), so it is wired and present but unproven. Recorded
in docs/13-NEXT.md rather than claimed.

  lune run tests   885 passed, 11 todo   (was 873, +12)
  syntax/encoding  138 files pass
  backend          139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what is unverified (hurt vignette and hitmarker have never been seen
firing), what is wired but hollow (audio, damage direction, limb geometry),
known rough edges (a run ends permanently on death, spawn diagnostic rings
world origin), balance knobs that were set to clear test guards rather than
from play, and the milestones still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gait and IK have solved foot and knee positions every frame since M7-1
and drove nothing: every entity rendered as a single box, so the animation
work was real, budgeted, profiled -- and invisible. This is the geometry it
moves.

Per-kind body plans in config/Rigs.luau, drawn by client/EntityRig as a
pooled Model of a body plus two parts per leg. Leg count is the silhouette:
a two-legged Lancer striding, a three-legged Sentry planted and never
stepping, a six-legged Reclaimer scuttling. The gait profile is derived from
the same rig spec, so the legs the planner steps are exactly the legs the
renderer draws -- previously one generic quadruped drove every kind while
the geometry was a box, and the two could not disagree.

Rigs are pooled per kind rather than globally: a Reclaimer has six legs and
a Lancer two, so a shared pool would rebuild limbs on almost every acquire.

Level palette per zone, replacing a placeholder tint whose own comment said
it would be replaced at M7-5 and never was. Floors, walls and materials now
differ by band, plus an emissive trim strip along the base of every wall --
cheap in parts and disproportionately effective, because the Vault's fog
ends at 45 studs and eats surface colour long before it eats a light strip.

  Perimeter   Concrete, cool grey-blue,  cyan trim
  Processing  DiamondPlate / CorrodedMetal, sodium, amber trim
  Vault       Slate, dark blue,          violet trim

Two bugs found by measuring rather than looking:

  - At `reduced` tier legs were shown but never placed, so a rig that never
    reached `full` kept the CFrame it was built with and stretched a limb to
    the world origin. Reduced now uses a straight-line pose (no law of
    cosines -- the bend is sub-pixel at 40+ studs), and hidden limbs are
    parked on the body so there is no frame where a returning rig shows its
    old pose.
  - The HUD read "Sidearm 0" from boot until the first shot. Seeding ammo
    locally in PredictionController is not enough; the HUD only learns a
    count from a hook, so the starting weapon is now pushed once on start.

Studio verification, 32 entities at the population cap, 280 parts:

  rigs                    32 / 32
  leg segments            Sentry 6, Lancer 4, Skitter 8, Reclaimer 12
  interpolation           0.368 ms  (budget 1.5)
  animation + IK          1.494 ms  (budget 2.0)
  raycast peak            24 / 24, never exceeded
  LOD census              full 25, reduced 6, clip 1, culled 0
  stranded limbs          0 across 100 sampled frames
  worst limb from body    4.92 studs
  server tick p95         0.94 ms, 0 overruns

Animation cost rose from 0.439 ms to 1.494 ms -- 75% of budget where it was
22%. That is the honest price of the IK finally driving 280 parts instead of
being discarded, and it passes, but the headroom is materially thinner than
the number recorded at M7-10.

  lune run tests   885 passed, 11 todo
  syntax/encoding  140 files pass
  backend          139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records that per-kind rigs landed, that animation+IK went from 0.439ms to
1.494ms against a 2.0ms budget (making the M7-10 metric stale), and that
M7-3's LOD popping criterion is now testable for the first time because
there is finally limb geometry that can pop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "the legs look WAY too scary" -- they read as an insect rather
than a machine. Two real causes, one of them a genuine bug.

The pole vector was degenerate. A standing leg's hip-to-foot axis is close to
vertical and I passed POLE = (0,1,0), which is near-parallel to it. Its
perpendicular component collapses, TwoBoneIk correctly falls back to
`anyPerpendicular`, and the chosen bend direction then swings between
cardinal axes on tiny numerical differences -- so every leg bent somewhere
different, and re-picked frame to frame. The pole is now horizontal along the
body's forward axis, giving a stable consistent knee.

Planted feet were outside the leg's reach. `TwoBoneIk` clamps an out-of-range
target and extends toward it, which is correct and documented, but a foot the
body has outrun is out of range *every* frame, so the limb is permanently
straight rather than momentarily reaching. Planted feet are now dragged back
inside reach, with reach computed per kind from the rig geometry by Pythagoras
rather than a guessed constant.

Measured, planted foot distance from rest, Skitter gait at 22 studs/s:

  unclamped   2.20 studs   against about 1.2 studs of reach
  clamped     1.19 studs

A first attempt clamped the velocity *lead* instead, and measurement showed
it made things worse -- 8.70 studs of lag against 2.95. The lead is what buys
a leg time: group alternation means each group only steps about every 0.56 s,
and a Skitter covers 12 studs in that. Shortening the lead starved the gait.
That reasoning is now a comment in `desiredFoot` so it is not retried.

Proportions retuned stouter and tucked under the hull, and the Reclaimer
dropped from six legs to four -- six is spider-shaped however it is coloured.

Still not fixed, and measured rather than assumed: mean limb straightness is
0.990 of full extension (1.0 is a straight stick). Making the legs stouter
also made them shorter, and a Skitter now spends 67% of its total leg span
(1.45) on the hip-to-foot drop (0.97), leaving almost nothing to bend with. A
limb needs span comfortably greater than the hip-to-foot distance or the IK
has no bend to express. Superseded by the humanoid rig plan in docs/14.

  lune run tests   890 passed, 11 todo   (was 885, +5)
  syntax           141 files pass
  animation + IK   0.642 ms at 11 entities
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records why the legged rig reads as an insect -- measured limb straightness
0.990 of full extension, because 67% of leg span is spent on the hip-to-foot
drop -- and carries that forward as a hard rule for the replacement: limb
span must exceed hip-to-foot distance by 40-50% or the IK has no bend to
express.

Covers the target skeleton and figure-drawing proportions, per-kind
differentiation without changing the skeleton (the Sentry keeps its tripod,
since it genuinely never walks), procedural arm swing that needs no solver,
and the performance problem up front: humanoid projects to ~1.8-2.1ms against
a 2.0ms budget, so LOD bands need tightening before it fits at 32 entities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "the legs move so weird" and then "can't we have a humanoid same
as the main character?" -- both correct, and the second is the fix.

Roblox's R6 character has rigid limbs that pivot at a joint: an arm is one
part rotating at the shoulder, a leg is one part rotating at the hip, neither
bends in the middle. The IK rig placed limb parts *between two solved
endpoints* with CFrame.lookAt, so limbs slid and stretched toward wherever
the gait had put a foot rather than rotating about anything. Nothing in it
was pivoting, which is exactly what "moves weird" describes.

Shipped: a six-part R6 skeleton -- torso, head, two arms, two legs -- posed by
rotating each limb about its joint, with a contralateral walk cycle whose
phase advances with distance travelled rather than wall time, so a slow kind
takes slow strides instead of the same cycle at a lower amplitude. Standing
still holds a neutral pose rather than marching on the spot. Per-kind
variation is height, bulk and hunch; `build` widens without lengthening,
since scaling both would just produce a larger Skitter.

Stated plainly, because it undoes earlier work:

  - core/anim/TwoBoneIk and core/anim/StepPlanner no longer drive rendering.
    Both remain correct and tested (M7-1, M7-2); they are the wrong tool for
    a skeleton with no knee. This undoes the rendering half of M7-3.
  - The 24-raycast frame budget is now unspent -- feet are placed by pose,
    not by ground probe, so entities no longer conform to uneven ground.
  - Animation cost fell from 1.494ms to 0.111ms.

Studio verification, one of each kind:

  rendered            6 / 6, six parts each
  heights             4.4 .. 8.5 studs, feet on the spawn plane
  leg swing range     71 deg -- exactly 2x the configured 0.62 rad amplitude,
                      both legs equal and opposite
  animation           0.111 ms

Two of my own mistakes worth recording:

  - A scripted edit asserted its *precondition* but not that its replacement
    target existed, so `.replace` matched nothing, the file was written
    unchanged, and it printed success. The renderer then crashed every frame
    on an undefined local while I looked for the bug in the new rig code.
    Edits now assert the marker exists before replacing.
  - The client was running stale code for one round of testing. Rojo had
    synced every file except the one I had just written -- because of the
    above, that write never happened. Confirmed by reading Source out of the
    running DataModel rather than trusting the disk.

docs/14 keeps the original IK plan for its reasoning but is now headed by
what was actually built and why the plan was wrong.

  lune run tests   890 passed, 11 todo
  syntax/encoding  140 files pass
  backend          139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as floating, and moving "back and forth in a humping pattern".

Floating: the server sends an entity position that is a *hitbox centre*,
raised off the floor by the kind's hitboxRadius so the hit-detection sphere
rests tangent to the ground (WaveService.grounded). The R6 rig poses from the
ground up, so that lift was applied twice. Measured: feet 3.02 studs above a
floor at y=0. The renderer now takes the lift back off before posing.

My earlier check reported "feet planted at the spawn height" and called it
correct -- it never verified where the floor actually was, so a 3-stud float
read as a pass. Now raycast against the floor and compared.

Walk cycle: two things were wrong together.

  - Amplitude. legSwing was 0.62 rad, a 71-degree total sweep. A real walking
    stride is closer to 22 degrees each way; 0.38 gives 43.5 degrees total.
  - Stride length was a single shared radians-per-stud constant, so every kind
    covered the same ground per step regardless of leg length -- a tall kind
    minced and a short kind skated. It is now derived per kind from leg
    length, so a Warden takes long strides and a Skitter short quick ones.

Also added stride smoothing: raw per-frame speed jitters with interpolation,
and feeding that straight into the swing amplitude made it pulse, which reads
as pumping rather than walking.

Studio verification:

  lowest foot    0.01   against a floor at 0.00
  leg swing      43.5 deg total (was 71)
  entities       6/6 rendered, all planted

Whether the motion still reads oddly is a judgement call I cannot measure --
these are the two mechanisms most likely responsible, fixed and verified by
number, but the look needs eyes on it.

  lune run tests   890 passed, 11 todo
  syntax/encoding  140 files pass
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as enemies moving back and forth on the spot, still present after
the walk-cycle fix -- because it was never an animation problem. Three
compounding causes, each measured.

1. The standoff push exceeded attack range. A Skitter wants
   hitboxRadius + PLAYER_RADIUS = 4.1 studs of personal space but only
   reaches 4.0, so it shoved itself out of the range it was attacking from,
   dropped to `move`, walked back in, and shoved itself out again. A Hauler
   had the same bug with 0.1 studs of margin. Standoff is now clamped to a
   fraction of atkRange, which puts every kind comfortably inside its own
   band.

2. No hysteresis, and no arrival damping. A Skitter closes at 22 studs/s --
   1.1 studs per tick -- so it overshot the boundary regardless, and each
   crossing swapped a full-speed seek for a slow shuffle. Added a disengage
   band and eased the last few studs of an approach.

3. The crowd shoved itself out. Six entities separating at 2.5x hitbox
   radius need about four studs of radius around their target, which is
   exactly a Skitter's four-stud attack range -- so the crowd expanded to the
   edge of the band and hunted across it. An engaged crowd now packs at 1.5x,
   and the shuffle speed that applies it dropped from 0.35 to 0.12 of walk
   speed: unstacking should be a shuffle, not a shove.

Measured with the player anchored, so any distance change is the entity
moving. Six Skitters engaged, sampled over six seconds:

  before            swing 1.55 studs, ~32 state flips each
  after (1) + (2)   swing 2.10 studs,  ~4 state flips each
  after (3)         swing 0.52 studs,   3 state flips total, 7 entities

Six of seven settle in `attack` and hold it; two do not move at all.

The first two measurements of this were useless and got redone: one mixed
every entity into a single min/max and compared each entity's state against
the previous entity's rather than its own, reporting a 134-stud swing and 914
flips. Per-entity tracking is what made the actual 1.55-stud oscillation
visible.

  lune run tests   890 passed, 11 todo
  syntax/encoding  140 files pass
  backend          139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CombatService.reload has existed since M2-5 and refilled a magazine
instantly. Nothing called it outside diagnostics, and `reloadSeconds` --
defined for all four weapons -- was read by nothing anywhere in src/. So a
real session gave you 14 Sidearm rounds and then a dead weapon for the rest
of the run, with every further shot rejected as "ammo" and no way to refill.
Same built-but-never-wired pattern as the wave spawner, enemy damage,
entity.targetId and weapon switching.

core/sim/Reload owns the timing, which is the part that was actually
missing: when a reload finishes and what a player may do while it runs.
Reserves are infinite by design -- the economy already charges for ammo as a
run-end sink, so a finite reserve would double-count the same cost in two
systems.

Server: a ReloadRequest remote, validated like any other attacker-controlled
payload; completions polled inside the combat tick phase *before* queued
shots resolve, so a reload finishing this tick refills the magazine the shot
is about to check rather than one tick later. Firing mid-reload is rejected
before the ammo count is even read -- otherwise cancelling the animation
client-side would be a free instant reload.

Client: R to reload, auto-reload on empty, and a HUD readout. The readout is
not decoration: without it the weapon stops firing for 1.4 seconds and
nothing on screen says why, which is indistinguishable from it being broken.
Ammo also turns red at zero.

Switching weapons cancels an in-flight reload -- one you walked away from
should not finish in your pocket, or swapping becomes the fastest way to
reload everything at once.

Studio verification against the live service, Sidearm (14 rounds, 1.4s):

  after draining 14 rounds, firing   rejected:ammo
  requestReload                      true
  firing mid-reload (0.6s in)        rejected:reloading
  requestReload again mid-reload     false  already_reloading
  firing after the reload window     accepted
  requestReload on a full magazine   false  magazine_full

The "already_reloading" and "magazine_full" cases are both refusals that
must not write the timer: a rejected restart that still set the deadline
would let holding the key lock a weapon out of firing forever. Pinned by
test as well as measured here.

  lune run tests   912 passed, 11 todo   (was 890, +22)
  syntax/encoding  142 files pass
  backend          139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four reported issues, three of them bugs.

Bullets passing through the little red ones. Rig height and hitboxRadius were
authored independently and drifted: the server centres a ray-sphere of
hitboxRadius on the entity and the renderer stands the body on
`position - hitboxRadius`, so a body taller than `2 * hitboxRadius` has a head
that cannot be shot. A Skitter was 4.2 studs tall against a 3.2-stud sphere --
a full stud of unhittable head -- and a Lancer 1.6 studs. Height is now
derived from hitboxRadius, so the two cannot disagree again.

  Skitter  1.00 -> 0.14 studs unhittable
  Lancer   1.60 -> 0.20
  Warden   0.20 -> 0.39

The residue is the top of the head, where a sphere has almost no horizontal
extent anyway. Not zero, and not claimed to be.

Third-person aim. Shots originated at the camera and travelled along its look
vector, which is wrong twice over: the camera sits about ten studs behind the
character, so the drawn tracer began in mid-air behind the player, and the
server rejects an origin further than ORIGIN_TOLERANCE (8) from the character
and substitutes its own -- so the server was already firing from the character
while the client drew from the camera. Rounds now leave the character's
shoulder toward the point under the cursor, found by casting the camera ray
through the mouse, and the crosshair follows the cursor rather than sitting at
screen centre telling a comfortable lie.

Invisible enemy fire. `Enemies.Lancer.projectile` had never been read by
anything, and nothing rendered an enemy shot at all, so a Sentry at 45 studs
and a Lancer at 55 damaged you from off-screen with nothing to see and no
direction to trace back. Ranged kinds now broadcast an EnemyShot and the
client draws a travelling bolt. The damage was already resolved server-side,
so the 0.11s flight is a deliberate small lie: a bolt slow enough to be honest
would be slow enough to look dodgeable, and it is not.

Sprint with stamina, on shift. Rules in core/sim/Stamina, tunables in
config/Movement, bar under the weight bar since both answer "can I get out of
here". Walk speed is multiplied rather than replaced, so carrying still costs
what it costs.

The server is told. MovementGuard budgets horizontal speed against a provider,
and a sprint it does not know about is indistinguishable from a speed hack --
the same shape as the jump that kicked a player for exceeding a limit they
could legitimately exceed. Verified: 0 violations while sprint is claimed.

Two design rules the tests pin, both closing the same exploit:

  - Emptying the bar locks sprinting out until 35% has recovered. Without the
    latch, tapping shift every frame grants a frame of sprint each time and
    the average stays above walk speed forever.
  - Regeneration is delayed 0.8s after sprinting stops, so disengaging costs
    something.

Three of my own tests were wrong and got rewritten. All three assumed
`hold(sprint, 10s)` leaves a player empty and exhausted. It does not: an
exhausted player is not sprinting, so the bar refills with the key still down,
the lockout clears at the threshold, and the sprint resumes on its own.
Holding shift therefore produces a repeating drain/recover cycle -- sprinting
a minority of the time -- which is now the documented, asserted behaviour
rather than an accident.

Studio verification:

  hitbox        see table above
  enemy bolts   13 drawn from real Sentry fire
  line of sight behind a wall 0 strikes / clear sightline 7 strikes, 63.3 dmg
  stamina bar   present
  movement      0 violations, 0 corrections while sprinting

  lune run tests   931 passed, 11 todo   (was 912, +19)
  syntax/encoding  146 files pass
  backend          139 tests passed
  rojo build       Built project

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three items from docs/13 §1 were "built but never verified". Verifying them
found that two were never broken and two other things were.

runRestart. A run ends the moment the last active player dies, so a solo
Studio session was one death and done: active=0, waves run_inactive forever,
HUD stuck on LOST with the crosshair hidden. The diagnostic clears entities,
resets the scheduler and enemy cooldowns, revives the roster, restarts the
clock, and re-pushes carry. Verified complete=true/active=0/died=1 ->
complete=false/active=1/died=0, and the HUD recovering to "0 carried · 0
credits at risk" with no client change -- the 1 Hz RunState push does it.

This was blocking more than playtesting. The hurt vignette had "never been
seen firing" because the probe was being run after a death: the run was
complete, EnemyCombatService's strike predicate correctly refused every
attack, and the probe reported 0 strikes with no error. Restart first and it
lands 13. The vignette then measured 5 flashes, peak transparency 0.380
against an authored 0.38, colour RGB(150,20,20), each flash tracking an HP
drop 100 -> 76 -> 52 -> 29 -> 6.

Hitmarker, also never seen: 4 clicks, 4 confirmed hits, 4 appearances within
~10ms of their FireResult. It was never broken either. Two harness facts made
it look broken and are now written down: GetGuiInset() is (0,58) here and
user_mouse_input takes inset-excluded coordinates while GetMouseLocation
returns inset-included ones, and only the Sentry is planted -- any other kind
walks out from under the cursor before the click arrives.

Sprint, driven by a real key for the first time: WalkSpeed 16 -> 25.60,
4.65s to empty against a configured 4.55, regen 14.1/s against 14, speed
dropping the frame the bar hit zero, and two full drain/recover cycles from a
single InputBegan -- the stutter cycle tests/stamina.spec.luau asserts,
observed in the engine. Guard-neutral by A/B: 9s sprinting produced 4
violations and 0 corrections, 9s walking the same path produced the same 4.

That trace caught a real bug. `held` was a latch fed only by
InputBegan/InputEnded and CharacterAdded cleared it, so sprint was dead after
every respawn until the player released and re-pressed shift. It now polls
IsKeyDown, which cannot desync from the keyboard, guarded by GetFocusedTextBox
in place of the gameProcessed flag.

Third-person aim parallax, the other real bug. Enemy rig parts are
CanQuery=false, so aimPoint's raycast passed through every enemy and stopped
on the backdrop; the shot then left the shoulder, 12.59 studs in front of the
camera, aimed at that far point. Against a Sentry (radius 2.2) at 26 studs the
shot missed centre by 0.21 with a backdrop at 40 studs, 1.94 at 80, 2.39 at
120, and 3.11 against open sky -- so a corridor forgave it entirely and an
open hall never did, which is the shape of "sometimes the bullet doesn't hit
them". aimPoint now intersects the entity spheres with the same pure
core/sim/Raycast the server judges the shot with. Same shot: 0.28 off centre.

To be clear about attribution: the parallax fix is not what made the hitmarker
verification pass -- the GUI-inset correction was. The bug was found while
chasing it, and for that particular shot the old aim would also have landed.

Also adds spawnAt, which rings the player rather than the world origin and
lifts each entity by its own hitboxRadius, closing another docs/13 §3 item.

935 pure-core tests (+4, pinning the parallax geometry), 139 backend, 146
files pass syntax and encoding, rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of the gaps between "working build" and "finished product". The two that
need a Roblox account -- publishing the place and deploying the backend -- and
the two that need people -- a 4-person playtest and the demo video -- are
untouched and still open.

Redeploy. A run ends the moment the last active player dies, so a solo session
was one death and done, and `runRestart` was a command-bar call no player could
reach. There is now a REDEPLOY [ENTER] control that appears when the run is
over, and the server validates it against a new pure `Run.canRedeploy` rather
than trusting the ask. Verified: refused `run_in_progress` mid-run, refused
`too_soon` at 1.3s, granted at 2.8s, and Enter alone restarted the run
(complete=false, active=1, died=0, entities cleared).

Only once the run is *over*, never on individual death. A player whose
teammates are still alive stays dead and spectates -- respawning mid-run would
delete the only stake an extraction shooter has.

The button is deliberately not the only path. Roblox GuiObject events cannot be
driven from the Studio automation bridge: a synthetic click lands provably
inside the button's rect (697,307 in a rect spanning 567-827 x 281-333) and
emits nothing, not even MouseEnter. On a branch with this much
built-and-never-connected history, the primary control should be one a test can
actually reach, so Enter does the same thing and that is what was verified.

Damage direction. EnemyCombatService fires a per-strike DamageFrom to the
struck player alone; the HUD rotates a chevron by core/look/DamageArc. Against
real Sentry fire: 17 strikes, 17 events, and exactly 1 arrow, because the merge
rule collapses attackers on a similar bearing instead of ringing the player in
chevrons. Rotation -179.8 degrees for a shooter almost directly behind, fade
1.00 -> 0.82, layer empty after expiry.

The mark stores the attacker's world position, not a screen angle. A stored
angle keeps pointing at where the shooter was relative to where the player used
to be facing, so turning toward the arrow swings it away -- worse than no
arrow. The right-vector sign was wrong in the first draft and the tests caught
it; a mirrored indicator is worse than none because the player learns to trust
it and turns into the shot.

Audio. core/audio/Cues (throttle, pitch jitter, attenuation), config/Audio (15
cues), and client/AudioController, with every cue point wired. Every assetId is
empty on purpose: the Creator Store's free weapon audio is user uploads with no
usable provenance -- the top results included one described by its own uploader
as ripped from The Elder Scrolls IV: Oblivion. That trades a silent game for a
copyright problem in the one artefact meant to be shown to employers.

Since a wired-but-assetless layer and an unwired one are both silent, the
controller counts and publishes to attributes on SoundService.DeepcacheAudio.
Measured during real combat: requested=14, playedNoAsset=6,
suppressedByThrottle=8 -- 13 hitTaken plus one runEnd, 8 correctly throttled.
Reachable, and the throttle works. Filling in the ids is all that remains.

Hints. A legend shows for 45s and [R] RELOAD appears on an empty magazine.
Teaches the controls, not the tactic; there is still no ammo pressure making
weapon switching necessary.

docs/11. It stated planning-era targets as measurements. The worst was a résumé
line claiming "p95 queue waits under 30 s in simulation" when metrics/m5.md had
measured 42.8s and recorded a FAIL -- an overclaim in material written for job
applications, contradicted by this repo's own data. $0.08/run was stale
differently: the project moved to self-hosted Ollama, so marginal cost is ~zero
and the real story is the ~50% fallback rate against a <3% target, with a
deliberate refusal to relax the 1200ms budget to improve it. Every figure now
cites a metrics doc or carries [TARGET]. resume.md had this right all along;
only docs/11 was wrong.

977 pure-core tests (+42), 139 backend, 152 files pass syntax and encoding,
rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assemble produces sealed boxes -- floor, four walls, a doorway per connector --
so every room was an open-topped pen with nothing in it, lit by the default
afternoon sun through a missing ceiling. From above the level read as a
dollhouse; from inside every room was the same room. No amount of colour
grading fixes an empty room, so the look pipeline in docs/08 now starts a layer
earlier than it did.

Built geometry now includes ceilings (queryable, unlike the rest of the
decoration -- a shot at the ceiling should stop there), ceiling fixtures, a
band and pilasters on every wall face, and per-zone props: crates and
containers on the Perimeter, barrels and pipework in Processing, stacked
storage in the Vault.

Placement is pure (core/level/Decor) because the constraints are gameplay bugs
rather than aesthetic ones. Nothing in the middle of a room: entities steer by
seek-and-separate rather than a navmesh, so an obstacle in open floor is
something they wedge against. Nothing within clearance of a doorway. And
deterministic per seed, so a replayed run looks like the run it replays and a
screenshot in a bug report is reproducible.

Props collide but are invisible to raycasts. CanCollide=true because set
dressing you walk through reads as a bug rather than as sparseness;
CanQuery=false because the alternative is a combat change -- props would become
cover, which moves damage numbers and invalidates the difficulty pass. That is
a design decision wearing a set-dressing costume, so it is deliberately not in
this commit.

Two calibration facts, both found by looking rather than reasoning, and both
now in docs/08 because they will not be obvious to the next person:

Light range is set by the room, brightness by the overlap. Fixtures mount at
the 24-stud ceiling, so a range of 32 spends three quarters of itself getting
to the floor -- a 112x96 Vault room lit by six of them rendered as pure black
with six glowing panels floating in it, the lights working exactly as
configured against the wrong distance. Range then has to be sized for the
largest room, which means every fixture in a small one covers all of it, so
brightness has to come down or a 56x56 entrance hall saturates to white.

Brightness belongs with the palette, not the fixture. One global value cannot
serve both bands: what lit the Vault's near-black slate blew out the
Perimeter's pale concrete. How much light a room needs is a property of what
its surfaces are made of, so the multiplier lives in Zones.palette next to the
colours it compensates for.

Baseline lighting (ClockTime, ambient, fog colour) moves into LookController,
which already claims to be the only writer of Lighting and where that claim is
load-bearing -- the M7-5 note documents what happened when two owners fought
over one property. It sets the stage the zone presets sit on and never touches
what they blend. The default was mid-afternoon with outdoor ambient at 0.27,
correct for a baseplate and wrong for a level whose rooms now have lids.

Measured, not assumed: 528 parts and 63 lights for a 16-module level, with
lights on versus off differing by 0.004ms of mean frame time -- the p95 jitter
is identical either way, so at this count the lights are free. Server tick p95
0.35ms with 0 overruns. A Skitter spawned 385 studs away pathed 166 studs
through a dressed level with 0 stalled samples, and all three pads remain
reachable at 5, 11 and 15 hops.

997 pure-core tests (+20 for Decor), 155 files pass syntax and encoding, rojo
build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rect

Reported from play: movement lags behind, stutters, and then cancels the move
you wanted, especially around jumping or sprinting. Two guard bugs stacked, and
neither could have been caught by the existing tests -- both live in the
relationship between a fixed server tick and a bursty replication stream, which
nothing was modelling.

Measuring the network instead of the player. The guard sampled a replicated
position on its own tick and divided by that tick's dt. Positions do not arrive
on that schedule: some ticks see no change and the next absorbs two packets'
worth. Instrumented in a live session, an honest player walking at 16 produced
per-tick samples of 24.5, 27.0 and 29.9 studs/s against a 21.6 allowance --
roughly one false violation per second against a decay of one per second, so
the score ratcheted upward instead of settling. Speed is now averaged over a
0.75s window, which is immune to bursting and still catches a hack, because a
hack sustains by definition. The cost is about one window of detection latency,
against a score threshold that takes several more to climb.

The window fixed a second race for free. The client raises its own WalkSpeed the
instant sprint begins and only then tells the server, so for one round trip the
guard budgeted a walk speed against a sprinting player -- visible in the trace
as horiz=29.9/s allowance=16.0 sprint=false. The window keeps the most
permissive allowance it saw, so a sprint starting anywhere inside it is budgeted
as a sprint. Erring permissive is right here: a false negative is one cheater
slightly faster, a false positive is teleporting an honest player backwards.

Corrections without evidence, which was the severe one. The action branch read
only the standing score, so once it crossed correctAt every following tick
issued another correction until the score decayed back under -- at one point per
second from a threshold of five, that is seconds of being teleported to your
previous position twenty times a second. A live session recorded 7 violations
and 109 corrections. From the player's side that is not a correction, it is
being pinned in place, and it is precisely the reported symptom. A correction
now requires a violation in that same evaluation; the score still escalates and
still decides whether a fresh violation is worth acting on, it just no longer
manufactures evidence of its own.

Verified in Studio, like for like. The identical jump-and-sprint input that
produced 6 violations now produces 0. The teleportToZone probe that produced
7 violations and 109 corrections now produces 0 and 0. Fifteen seconds of
sprinting, strafing and jumping: clean. And the guard still bites -- a real
client-side WalkSpeed hack at 90, driven with real keys, was caught at 68-110
studs/s against a 21.6 allowance: 8 violations, 7 corrections, score escalating
5.3 -> 16.6 toward the kick threshold, one correction per violating window
rather than fifteen per violation.

Worth recording about the tests: the first regression test written for the
correction bug passed against the bug. A single teleport lands the score exactly
on correctAt, and one decay tick drops it under before the loop starts, so the
buggy branch never fired. It only became a test once it pushed the score clear
of the threshold -- confirmed by reintroducing the bug and watching it fail. A
regression test that has never been run against the regression is a guess.

Adds a movementViolations diagnostic carrying the reason, both budgets, the
allowance and the sprint flag, because the counters could say a violation
happened and not which budget overran -- and a jump charged against a walk
speed, a sprint the guard was not told about, and a genuine hitch have
completely different fixes. Its per-tick figures are labelled lastTick, since
the judgement is now made over a window.

1001 pure-core tests (+4), 139 backend, 155 files pass syntax and encoding,
rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things reported from play. One was the laptop, one was a real bug, one
was a feature request.

Ranged enemies attacking from outside the fog. Enemy reach is authored per kind
and visibility is authored per zone, and nothing reconciled the two. The Vault's
fog ends at 45 studs; a Lancer's authored range is 55. It was opening fire from
inside a world the renderer stops drawing -- being hit by something you could
never have seen reads as the game cheating rather than as a threat you
misplayed, which is exactly how it was reported.

Reach is now capped at fogEnd * ATTACK_VISIBILITY_FRACTION (0.8) for the zone
the *attacker* stands in, since the shooter is the one who has to be seeable.
Below 1 rather than exactly 1 so it is properly visible when it fires rather
than a silhouette dissolving at the boundary. The cap never widens a kind's
authored range. Verified in the Vault where the cap is 36: a Lancer at 50 studs
lands 0 strikes, the same Lancer at 25 lands 4 strikes for 51 damage.

The melee sweep, on F. Every weapon in the roster is a ray, which leaves one
hole in the fight: a Skitter swarm at four studs is a target you cannot miss and
cannot clear, because one hitscan round kills one of them. 120-degree arc, 12
studs, 3.5s cooldown, 55 damage -- shaped as an escape rather than a damage
upgrade. It kills a Skitter outright and takes a third off a Lancer; it does not
meaningfully dent a Warden's 700.

Two geometry rules keep it honest, and both are in pure core with tests. The arc
is measured from the body's facing, not the camera, so a player cannot hit
something behind them by flicking the mouse. And reach is measured to the near
surface of a target's hitbox rather than its centre, so a wide enemy is caught
from further out than a narrow one -- measuring to centres would make big
enemies harder to sweep than small ones, which is backwards.

The request carries nothing: no position, no direction, no target list. The
server takes all three from the character. Unlike a fire request there is no ray
to sanity-check a claimed origin against, so a client-supplied one would let
anyone sweep any room in the level from the drop point. An empty request makes
the forgery impossible rather than merely detectable.

Verified in Studio: five Skitters lined up inside the arc plus one from the
probe gave 6 targets, 6 kills, 270 damage in one swing (6 x 45, every one killed
outright), and the immediate second swing was refused with cooling_down. A
swing with the player dead is refused with no_character.

The Studio lag was not the game. Recording it because the measurement looked
alarming and meant nothing: 12 fps pinned at exactly 83.3ms across 540 frames,
identical with all 63 lights disabled AND with every entity rig deleted, GPU at
0.00-1.1ms while CPU sat at 84 and physics held a full 60. That is a throttle,
not a load. The machine was on battery at 60% on the Balanced power scheme, and
Roblox's FrameRateManager=Automatic throttles on battery. Studio was already on
the RTX 5070, confirmed via nvidia-smi, so it was never GPU routing either.
Server tick through all of this: p95 0.73ms against a 50ms budget, 0 overruns.

Also extracts spawnRing out of the spawnAt diagnostic so sweepProbe exercises
the same placement rather than a second copy that can drift, and adds a
meleeSwing audio cue alongside the rest of the silent table.

1031 pure-core tests (+30), 160 files pass syntax and encoding, rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things reported from play.

There was no healing mechanism at all. Not a weak one -- none: 100 HP, Roblox's
default regeneration, and no item, ability or pickup anywhere in src/. Forty
damage cost forty seconds of walking it off, which is a large part of why a run
ends in 59 seconds.

[G] now holds a 2.5s channel for 45 HP, on two charges that do not refill.
Shaped to preserve the run's tension rather than remove it: a regenerating heal
turns every fight into attrition the player wins by waiting, while finite
charges make "do I burn one here" a real question -- the same question the whole
game is about. Two charges against a 100-point bar roughly doubles survivable
damage, so a mistake is survivable but standing and trading is not.

Moving cancels it, taking damage cancels it, and the charge is not refunded.
Refunding would make a cancelled channel free, so the correct play would be to
start one every time you were hit and cancel it. Losing the charge means you
commit. The channel sits deliberately under the sweep's 3.5s cooldown, so
sweep-to-make-space-then-heal is available once per cooldown rather than freely.

The interrupt is driven from HealthChanged rather than polled, because the
window that matters is small: a hit landing 40ms before completion has to
cancel it, and a 20Hz poll would let a fifth of those through as free heals. The
repair phase settles after enemyCombat for the same reason, so a strike landing
this tick interrupts before the channel can complete on it.

Verified in Studio with real key presses: HP 40 healed exactly 45.0 with
completed=1; a hit mid-channel gave interrupted=1 with completed unchanged and
no health applied; and the HUD read [G]x0 afterwards, confirming both charges
were spent by one completion and one interrupt.

Corpses were a grey crate. RagdollController was written at M7-4 when entities
really were 2x3 boxes, so the body was a hardcoded 2x2x3 slate part in a fixed
brown-grey. The R6 rigs landed later in 856ac8b and this was never revisited, so
a Skitter and a Warden died into the same crate -- reported as "it spawns these
in their place". Same authored-twice-and-drifted shape as the hitbox bug.

Corpses are now sized from the kind's own hitboxRadius and coloured from the
same palette the renderer uses, reapplied per death because pool slots are
reused across kinds. Verified per kind: Skitter 2.40x1.68x3.24 red, Lancer
3.00x2.10x4.05 green, Warden 6.00x4.20x8.10 violet.

Also corrects the boot banner and the Bootstrap header's phase-order contract,
both of which still listed the pre-repair phase order.

1050 pure-core tests (+19), 165 files pass syntax and encoding, rojo build
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things from play.

Extraction produced no feedback at all, which is remarkable given it is the win
condition. One line caused it: the HUD's RunEvent handler opened with
`if payload.kind ~= "overseerBark" then return end`, so playerExtracted --
carrying the banked value -- along with runEnded and padOpened all arrived and
were thrown away. Reaching an open pad showed nothing but the REDEPLOY button.
Succeeding was quieter than dying.

There is now an end-of-run card: EXTRACTED with credits banked, the pad, and
the time, or LOST with what was left on the floor. Plus a transient banner for
a pad opening and for a squadmate extracting, both of which were also being
discarded. Verified against a real extraction: "+542 credits banked / out via
Perimeter at 5:07".

That surfaced a contradiction worth naming: the status line repeated the payout
from `latest.value`, which is *carried* value and is zeroed the instant it is
banked, so the screen read "+542 credits banked" and "EXTRACTED +0 credits" at
the same time. Two sources for one number, exactly as HudController's own
header warns about. The card owns it now.

Healing was too slow because it paid out only on completion -- holding the key
did visibly nothing for two and a half seconds, which reads as broken rather
than as costly. Health now arrives continuously against progress^2: the bar
moves on the first frame, half way through has paid a quarter, and the final
third still carries more than the first two thirds combined. The channel is
1.6s. An interrupted channel keeps what it already paid, which makes bailing
early weak rather than worthless, and the charge is still spent so cancelling is
never free.

Ranged enemies spawned inside props. Entities are pure simulation with no
collision against level geometry, so nothing stopped one occupying a crate, and
a Sentry has speed 0 so it stayed there. Spawn points are now filtered against
the props in their own module, cleared by the prop's ground-plane diagonal plus
the largest hitbox in the roster -- one figure for all kinds, since erring wide
only costs a spawn point in a room that has plenty. Verified across a real wave:
0 of 8 entities overlapping any of 113 props, tightest clearance 4.32 studs.

1054 pure-core tests (+4), 165 files pass syntax and encoding, rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The weight line read "49 kg   x0.82   committed", and the first thing a player
asked on seeing it was what x0.82 meant.

docs/01 argues that "47 kg" means nothing without "0.82x, committed". That is
right and it does not go far enough: 0.82x does not stand on its own either. It
is only legible to someone who already knows there is a multiplier and knows
what it multiplies -- which is everyone who wrote it and nobody who plays it.

Now "49 kg   -18% speed   committed". A percentage needs no reference table and
answers the question the number exists for: how much is this costing me. The
tier name stays, because it is the design's own vocabulary for how bad the
situation is, and "committed" carries something the arithmetic does not.

Zero gets its own wording rather than "-0% speed".

Verified in Studio at two tiers: 37 kg reads "-8% speed   noticeable", and
empty-handed reads "0 kg   full speed   free".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was 100, which was Roblox's Humanoid default rather than a decision -- this
project never set MaxHealth at all. Against a roster that hits for 8 (Skitter)
to 30 (Hauler), 150 turns a bad room from fatal into expensive, which is the
same thing field repair was added for and works alongside it rather than
instead of it.

Applied on spawn, not once at boot: MaxHealth belongs to the Humanoid, so a
respawn would silently drop back to 100. Health is set with it, because raising
MaxHealth alone leaves the player spawning on a two-thirds-full bar.

The survivability guards in tests/enemyAttack.spec.luau read the config now.
They had `local PLAYER_HP = 100` written as a literal, matching the engine
default by coincidence rather than by reference -- so raising the pool would
have left them quietly certifying a game that no longer existed. Same
authored-twice shape as the rig-height and ragdoll bugs. They are lower bounds,
so nothing fails at the higher number; the point is that they now move with it.

The "plausible number of hits" band widens from 8..20 to 8..28: 150 against a
Skitter's 8 damage is 19 hits, which the old ceiling of 20 would have passed by
one and then failed on any further increase.

Verified in Studio at 150/150 on spawn and 150/150 after a death and respawn.

Also updates the three comments and the docs/13 balance row that still stated
100 as the pool.

1054 pure-core tests, 165 files pass syntax and encoding, rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things, found while answering "what does G do".

`Repair.collect` returns the health owed since the last call, and
`RepairService.step` applied it only on the tick where the channel
completed -- `if not done then continue end` -- discarding every earlier
slice. At 20Hz across a 1.6s channel the completing slice is about a
thirtieth of the accelerating curve, so a repair paid roughly 3 HP of 45
and still spent the charge. The docs verification of "healed exactly
45.0" predates the accelerating payout, so it certified a shape that no
longer existed. Deltas are now applied every tick, with the completion
telemetry reading a per-channel accumulator rather than the last delta.

The heal is now to full rather than a fixed 45. A fixed number made a
charge worth less the healthier you were, so the optimal play was to walk
around hurt waiting for one to be worth spending. `healAmount` is gone
from config/Repair; `charges` is now the only limit on healing, which is
why the config test bounds it directly instead of bounding charges times
points.

Specs pin both: the deltas sum to the whole heal, the completing delta
alone is under a tenth of it, any deficit heals to full, and a channel
does not grow to cover damage taken while it runs.

Studio, run restarted and field cleared so nothing could interrupt:
  {"health":150,"maxHealth":150,
   "repairMetrics":{"completed":1,"started":2,"interrupted":1,
                    "healed":112.4,"channelSeconds":1.6}}
40 HP before the keypress, full bar after, one charge spent. The earlier
probe against the old build interrupted on death, and the partial
"healed":11.48 it left behind is itself the fix working -- that counter
could only move at completion before.

1057 pure-core tests pass; syntax, encoding and rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Roblox's loading screen leaves once the engine is ready, which here is
seconds before there is anything to look at: the level is generated
server-side and parented to Workspace in one go, so until it lands the
client renders an unlit facility with the HUD floating over it. Measured
in Studio -- local server, geometry already resident, the best case that
exists -- that gap is 1.66s. Over a real network it is longer, and it
reads as a broken game rather than a loading one.

core/boot/LoadGate owns the decidable half: which gates have opened, how
far along that is, what to call the current one, and when it is safe to
let go. src/replicatedfirst/LoadingScreen.client.luau reads the gates off
the DataModel and draws. ReplicatedFirst is a new mapped subtree, so Rojo
needs restarting and the Studio plugin needs reconnecting.

Three rules the module exists to enforce, each a way a loading screen
gets worse than no loading screen:

  - Gates latch. ContentProvider's queue drops to zero and rises again as
    more geometry streams, so latching the answer rather than clamping the
    number is what keeps the bar monotonic -- and states the reason once
    instead of leaving a math.max to reverse-engineer.
  - The assets gate is not asked until the world gate opens. The queue is
    legitimately empty before the level exists, so asking early would
    latch it instantly and claim the geometry was ready before the server
    had sent it.
  - A floor and a ceiling. minSeconds stops a one-frame flash on a warm
    client; timeoutSeconds releases regardless and warns, because a screen
    that never leaves is worse than the room it hides.

The bar also stops at 99% until genuinely complete -- a full bar that is
still waiting is what makes a screen feel hung -- and eases rather than
snapping, since the gates open in a few large jumps.

Also adds a missing-module-return lint to tools/check-syntax. config/Boot
was written without a `return`, which compiles, syncs and builds cleanly;
Luau then hands callers `true` and the failure surfaces as "attempt to
index boolean" at the call site, four test cases from the file that is
wrong. Same shape as the BOM guard. Confirmed by deleting the return again
and watching the lint fire. The cross-service require lint is now keyed on
the subtree rather than on a hardcoded server/client pair, so a new mapped
subtree is covered by one line instead of being silently exempt.

Studio, against an injected copy of the script (the Rojo plugin was
disconnected after the server restart):
  {"phases":"... STREAMING GEOMETRY ... | READY@1.66",
   "releasedAt":1.655989,"timedOut":false,"guiStillPresent":false}
and a screen capture with the minimum held open confirms the layout.
The injected copy has been deleted; Rojo owns ReplicatedFirst.

1079 pure-core tests pass; syntax, encoding and rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "pressing G doesn't heal me to max", and reproduced exactly: a
single tap gave started=1, interrupted=1, healed=0.33 -- a third of a hit
point for a whole charge.

The channel required [G] held for 1.6s, and InputEnded cancelled it. The
HUD hint said "[G] repair"; nothing anywhere said to hold it. So the
obvious input spent the charge and revoked it in the same gesture.

The same trap had a second shape: pressing [G] while moving started the
channel and the movement check cancelled it on the next frame, again for a
charge. That is now refused before the request is sent -- a refusal costs
nothing, a cancel costs the charge.

Pressing [G] again abandons a channel in progress, so a mistaken start does
not pin the player in place. Moving still cancels an active channel, since
holding still is the ability's only cost.

Both refusals now put a line on the HUD (HOLD STILL TO REPAIR, REPAIR
INTERRUPTED) through a new HudController.showBanner, sharing the existing
banner element rather than adding a second one that could collide with it.
"Press the key and nothing happens" is unlearnable, and it is what the
report was actually describing.

Studio, single tap standing still, run restarted and field cleared:
  {"healthAfterTap":150,"maxHealth":150,
   "metricsAfterTap":{"started":1,"completed":1,"interrupted":0,
                      "healed":103.93}}
Studio, press while moving, same character alive throughout:
  {"metricsAfterMovingPress":{"started":1,"completed":1,"interrupted":0}}
started unchanged, refused 0 -- the request never left the client, so no
charge was spent.

The general shape: an input that costs a resource must not be cancellable
by the same action that started it.

1079 pure-core tests pass; syntax, encoding and rojo build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Soren-Caron
Soren-Caron merged commit 38b68cc into master Aug 29, 2026
0 of 2 checks passed
@Soren-Caron
Soren-Caron deleted the feat/wave-spawner branch August 29, 2026 03:21
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