Combat Feel · 2026-06-08 · Visual + Simulation + Audio
The owner played a firefight and named five things wrong: the bullets teleport — they appear halfway between the gun and the target instead of travelling; the feedback is flickery; combat is cluttered; a soldier with rounds cracking past his head fires just as much; and every combatant feels the same. This pass turns each complaint into a hard number, fixes it at the root, and proves the fix on seeds it never tuned on — without making the game one round deadlier than it was.
This wasn't a bug hunt — combat worked. It was a feel problem, which the project's rules say you may not touch until it's a measurement. So the pass ran in four moves:
ballistics.ts → combat.ts → draw.ts/combat-fx.ts → store.ts frame loop) and found the two root mechanisms behind four of the five complaints.scripts/combat-feel-probe.ts) turned each complaint into a number on HEAD, then proved every fix as a delta, on tuned and held-out seeds.The unifying insight. Four of the five complaints share one root cause: the simulation advances on a fixed 0.1 second tick, but the screen redraws ~60 times a second — and the renderer was reading the sim's state verbatim, with no interpolation. A bullet at 880 m/s jumps 88 metres every tick, so over a 200 m flight it only ever exists at two or three frozen points (it "appears midway"). A 0.12 s muzzle flash is aged a whole tick the instant it's born, landing past its own draw cutoff before a single frame can show it. The same quantization, two symptoms. One mechanism fixes both.
The store's frame loop already computes exactly what we need and throws it away. It accumulates real time and steps the sim in fixed 0.1 s chunks; the leftover — _acc, a value between 0 and one tick — is the precise fraction of wall-time into the next tick. We expose it as getSimFrac() and let the renderer draw the world one tick behind, interpolated:
scripts/combat-feel-probe.ts: per-frame jump 123 px → ~20 px, frozen frames 93% → 0%, muzzle flashes that ever render 0 / 453 → 453 / 453.Why this is safe. The interpolation lives entirely in the render layer — it is a read of the loop's accumulator, never a write to the simulation. A seed still reproduces byte-identical outcomes (the adversarial pass confirmed this with same-seed double-runs). Paused ⇒ no tick ⇒ the fraction freezes ⇒ motion stops cleanly. The skeptic also caught and corrected a sign error in the surveyed proposal: the effect age must be interpolated backward (one tick behind), not forward — forward would have pushed the flash further past its cutoff.
This was the owner's most literal complaint, and a first-order doctrinal error. In the model, suppression only ever widened a shooter's dispersion — a man with rounds cracking past his ear sprayed wildly but pulled the trigger exactly as often. That is the inverse of what suppressive fire is for: FM 3-21.8 defines suppression as fire that degrades the enemy's ability to deliver effective return fire — it should cut his volume, not just his aim. The "pinned" state even existed already, but only as a render cue; the simulation never gave it a consequence.
The fix gives suppression two hands on the trigger, both in updateFiring, both reusing existing state (so a seed still reproduces): the pause between bursts lengthens with suppression (capped so a pinned man is slowed, never silenced), and the burst itself shortens above a threshold — you fire and get back down.
combat-feel-probe.ts, micro-probe A). Before the line is flat — even slightly higher under heavy suppression (2.2 rds/s at suppression 0.0 and 0.8). After, it falls monotonically to about half rate as the man is pinned. The harness drove this on a held-out seed too; the curve holds.Each soldier already carried composure, aggression and marksmanship — but in a firefight they barely mattered: burst length came almost entirely from the weapon. So a disciplined US team and a hopped-up insurgent cell fired identical bursts. The fix reshapes the same random draw (no new randomness, so determinism holds) by a discipline score — high composure, low aggression squeezes controlled bursts toward the bottom of the weapon's band; a green or hot-blooded shooter sprays toward the top, and the truly undisciplined mag-dump a little past it.
This one the recon missed entirely — the survey workflow found it. The simulation emits one muzzle event per round, at the gun's true cyclic spacing, and the audio mapper faithfully turns each into one cue. But the synthesizer for a belt-fed gun then expanded every cue into a 5–9 round burst of its own. The two multiplied: a single 8-round M240 burst became roughly 40–70 synthesized cracks — an undifferentiated roar where a soldier's ear expects the unmistakable ~11 Hz hammer of a 7.62 gun. The fix deletes the synth-side burst loop so cadence is emergent from the sim's own rate of fire; the heavier .30-cal timbre stays.
Two small corrections a Korengal veteran clocks instantly. First, colour: every first-hand account describes green tracer coming down at you and red going out — Warsaw-Pact tracer compound burns green, US burns red/amber. The model painted both warm; now friendly tracer stays amber and insurgent tracer burns green. Second, concentration: tracer was sprinkled off roughly a quarter of every weapon, including riflemen, cluttering the air. Real tracer rides the belts — it's loaded into the machine guns to walk fire onto target — so the ratio is now keyed to weapon class: belt-fed guns trace ~1-in-4, riflemen barely at all. Fewer stray streaks, and the eye is drawn to the guns where it belongs.
weapon.cls (still one random draw per round, so the deterministic stream is untouched); the colour is a one-line render change. Together they cut clutter and add the single most recognizable "which way is the fire going" read in the genre.Headless numbers are necessary but never sufficient. The live game was driven — via the Chrome DevTools Protocol — into an actual firefight: a squad stepped off, the enemy director was pushed to stage a contact, and once rounds were flying both ways the camera framed the fight and the simulation was left running so the frame buffer caught real motion.
The bar here is that a skeptic should be able to ask "did an AI really do this, and is it actually true?" and find the answer is yes. So each fix is a measured delta, and the dangerous changes — the two that touch the simulation — were gated hard.
| Claim | How it was proven | Before → after |
|---|---|---|
| Bullets travel smoothly | combat-feel-probe.ts — 60 fps render loop over the sim; per-frame screen jump + frozen-frame fraction, on 3 seeds | 123 px / 93% frozen → ~20 px / 0% |
| The muzzle flash renders | same probe — count of flashes that ever reach a drawable frame | 0 / 453 → 453 / 453 |
| Suppressed men fire less | micro-probe A — one rifleman, suppression swept, everything else held; tuned + held-out seeds | flat ≈ 2.2 rds/s → 2.0 → 1.05 |
| Combatants differ | micro-probe B — same weapon, only the temperament changed | 4.55 vs 4.33 (inert/inverted) → 4.06 vs 4.33 (disciplined tightens) |
| The MG hammers | audio-probe.ts — 1-cue-per-event invariant; crack count per burst by inspection | ~56 cracks → 8 cracks |
| Determinism preserved | adversarial pass — same-seed double-runs, byte-identical serialized state on 3 combat seeds | identical |
| Balance preserved | balance.ts — 16 deployments × 50 game-min, HEAD vs after | US KIA 1.69→1.50 · WIA 4.94→4.75 · enemy 5.50→4.88 · civ 2→0 |
| Nothing else broke | tsc · npm run build · smoke.ts (no-NaN + serialize round-trip) | all green |
The two simulation changes were the risk, and they were contained. Both edits — the suppression cadence gate and the personality burst-shaping — live in the same block of updateFiring and reshape the existing random draw rather than adding one, so the deterministic stream is byte-identical to before for the same inputs. The personality "panic spray" was deliberately tuned down when a first attempt pushed casualties out of tolerance: the win condition was never "more dramatic," it was "more honest, within ±15% of the old balance." A separate skeptical agent re-read the whole diff and could not refute it.
What was deliberately not done. The survey surfaced more than five good ideas. The deeper overlay de-clutter (merging the three contact cues, freezing cosmetic pulses on pause), a supersonic snap for incoming rounds at the listener, a heavy-calibre .50 audio tier, and a probabilistic hold-fire gate were all logged to a follow-up issue rather than crammed in — the hold-fire gate specifically because it would have added a random draw and shifted the deterministic stream, a measured hazard. Five robust fixes, not nine rushed ones.
Engineering record: docs/progress/2026-06-08-combat-feel/ — verbatim HEAD baseline, after-numbers (3 seeds), balance HEAD vs after, and the live captures. Probe & tools: scripts/combat-feel-probe.ts (firefight + two controlled micro-probes), scripts/balance.ts. Touched: state/store.ts (the exposed sub-tick fraction), lib/render/draw.ts + combat-fx.ts (interpolation + tracer colour), lib/sim/combat.ts (suppression + personality), lib/sim/ballistics.ts (tracer ratio), lib/audio/synth.ts (the MG cadence), components/world/WorldView.tsx (the wiring). Surveyed by a six-specialist workflow, built and verified by an autonomous agent; every number is quoted as measured.