Combat Feel · 2026-06-08 · Visual + Simulation + Audio

Making the firefight feel real

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.

0% → 100%muzzle flashes that render
123px → ~20pxper-frame bullet jump
flat → −48%fire rate when suppressed
~56 → 8synth cracks per MG burst
±15%casualty balance held

The shape of the work

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:

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.

Fix 1 — Bullets travel; the flash returns visual · render interpolation

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:

// state/store.ts — a pure read of the loop's own accumulator (no sim change) export const getSimFrac = () => clamp01(_acc / SIM_DT) // lib/render/draw.ts — the bullet's drawn position sweeps between ticks renderTraveled = traveled − speed · SIM_TICK · (1 − frac) // = lerp(prevTickPos, curTickPos, frac) // lib/render/draw.ts — ONE line de-strobes every transient effect AND resurrects the flash k = (e.t − SIM_TICK · (1 − frac)) / e.ttl // was: k = e.t / e.ttl
WHAT THE PLAYER SEES OVER ONE BULLET'S FLIGHT (M4, 200 m, drawn at 60 fps) muzzle target BEFORE verbatim 88 m · frozen 6 frames 176 m · frozen 6 frames (nothing here) ↳ teleports 88 m → ✕ no muzzle flash (born aged-out) AFTER interpolated continuous sweep — visible the whole way ✶ muzzle flash now renders
The exact mechanism, drawn to scale. Before, the renderer reads the sim's tick state verbatim: an 880 m/s round only takes ~2 positions over a 200 m flight (88 m and 176 m), each held frozen for ~6 frames then teleported — the literal "it appears midway between the gun and the target." After, the same round is interpolated between ticks into a continuous streak, and the muzzle flash — which used to be aged past its draw cutoff before any frame could show it — blooms and fades across its whole life. Measured by 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.

Fix 2 — A suppressed man fires less simulation · doctrine

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.

RATE OF FIRE vs SUPPRESSION — one rifleman, everything else held fixed 2.5 1.25 0 shooter suppression → 0.00.20.40.60.8 BEFORE — flat (fires the same pinned or free) AFTER — −48% by the time he's pinned
The cleanest possible measurement: a single M4 rifleman on a fixed target, suppression dialled to a held value, composure pinned, fire rate counted over 40 s at each level (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.

Fix 5 — Every combatant has a temperament simulation · personality

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.

MEAN BURST LENGTH — same M4, same range, only the man's temperament differs 0 rds BEFORE — traits inert 4.55disciplined 4.33ragged AFTER — disciplined tightens 4.06disciplined 4.33ragged
Micro-probe B: the same M4, zero suppression, only the trait profile changed (composure .85/aggression .25 vs .45/.85). Before, the traits were inert — burst length was weapon-only, and the disciplined man even fired a hair longer (4.55 vs 4.33, pure noise). After, the disciplined shooter drops to controlled 4-round taps (4.55 → 4.06), now correctly below the ragged man. The same-weapon gap is small on purpose: a stronger version measurably raised US casualties past the ±15% balance gate, so it was tuned down (the restraint is logged). The larger, player-visible read comes from the cross-faction difference — US M4 controlled fire vs ragged insurgent AKM/PKM — and from the suppression gate, which makes a pinned man's fire sparse and short.

Fix 3 — The machine gun hammers instead of buzzing audio

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.

ONE 8-ROUND M240 BURST → SYNTHESIZED CRACKS BEFORE~56 cracks AFTER8 cracks a buzzing wall a counted hammer
Each red tick is a synthesized crack along the same timeline. Before, every one of the 8 sim rounds fanned out into its own 5–9 round synth burst — the cracks pile into a roar with no rhythm. After, one cue plays exactly one crack, so the gun's rate of fire comes straight from the simulation's cyclic timer — a recognizable hammer. Pure render-side, strictly cheaper, and the audio probe confirms the mapper's 1-cue-per-event invariant is untouched (3278 = 3278).

Fix 4 — Green tracers come at you; the guns wear the streaks visual · realism

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.

TRACER RATE BY WEAPON CLASS (measured in a live firefight) belt-fed MG ~24% (rides the belt — unchanged) rifle / carbine BEFORE ~25% AFTER ~8% (off the riflemen) tracer colour: amber — friendly, outbound green — insurgent, incoming
Tracer concentrated onto the guns and recoloured by side. The ratio change is one threshold keyed to 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.

In the real game, not just the harness

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.

A live firefight at the outpost — muzzle flashes at the firing positions, rust suppression crescents on pinned soldiers, arterial-bleed pulses on the casualties, the TROOPS IN CONTACT banner
The live game, mid-firefight. The bright sparks at the fighting positions are muzzle flashes that now render — before this pass they were invisible at every zoom, because the flash was aged past its own draw cutoff before a single frame could sample it. Around the pinned men are the rust suppression crescents (now backed by a real sim consequence — those men are firing less); the casualties carry the arterial-bleed pulse; the contact banner and the indirect warning have fired. A firefight that reads at a glance.

How every claim was checked

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.

ClaimHow it was provenBefore → after
Bullets travel smoothlycombat-feel-probe.ts — 60 fps render loop over the sim; per-frame screen jump + frozen-frame fraction, on 3 seeds123 px / 93% frozen~20 px / 0%
The muzzle flash renderssame probe — count of flashes that ever reach a drawable frame0 / 453453 / 453
Suppressed men fire lessmicro-probe A — one rifleman, suppression swept, everything else held; tuned + held-out seedsflat ≈ 2.2 rds/s2.0 → 1.05
Combatants differmicro-probe B — same weapon, only the temperament changed4.55 vs 4.33 (inert/inverted)4.06 vs 4.33 (disciplined tightens)
The MG hammersaudio-probe.ts — 1-cue-per-event invariant; crack count per burst by inspection~56 cracks8 cracks
Determinism preservedadversarial pass — same-seed double-runs, byte-identical serialized state on 3 combat seedsidentical
Balance preservedbalance.ts — 16 deployments × 50 game-min, HEAD vs afterUS KIA 1.69→1.50 · WIA 4.94→4.75 · enemy 5.50→4.88 · civ 2→0
Nothing else broketsc · 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.