Repository navigation
M7: presentation, combat feel, and the pure-core repair/melee systems - #1
Merged
Merged
Conversation
`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>
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.
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