Floating damage numbers
Floating damage numbers are the per-hit feedback layer: a numeric value spawns at the impact point, arcs briefly under gravity, freezes, and fades. They’re how the player reads what just happened — how big the hit was, whether it crit, whether the target was immune, whether their AoE actually connected to all those enemies. Color and size both scale with damage, so a screen full of orange-red 400+ reads as “you are deleting things” without the player ever parsing a digit.
Implementation lives in src/starship-survivors/engine/vfx/particles.ts as the DmgNumbers module. Rendering is in src/starship-survivors/engine/rendering/draw.ts via drawAllDamageNumbers().
Spawn
Every enemy-damage entry point calls DmgNumbers.add(x, y, val, col?, isCrit?, overrideSz?). The position is the enemy’s world-space center at the moment of the hit (enemy.x, enemy.y), not the bullet’s last position — this matters for hitscan and AoE where the bullet’s coordinates would feel disconnected from the visible enemy. Destructibles use the same call with an explicit grey override ('#cccccc'). Boss bar damage uses a separate dedicated call at the active boss position with size 14 and the bar’s rarity color (see bridge.ts:2470).
Each spawn pushes a record onto world.dmgNumbers. The record carries position, text, color, size, lifetime, residual rise offset, a velocity pair (vx, vy), and a frame-counter _delay for stagger.
Color and size by damage band
When no explicit color is passed and isCrit is false, raw damage value drives both:
| Damage | Color | Base size |
|---|---|---|
>= 1000 | #ff4444 (red) | 9 |
>= 400 | #ff8833 (orange) | 8 |
>= 100 | #ffcc44 (yellow) | 7 |
< 100 | #ffffff (white) | 6 |
All sizes then multiply by 1.4 (a global readability boost — explicitly “scale up damage text 40% for better visibility through darkness”, which is the darkness-pass overlay the gameplay layer renders below). The final on-screen pixel size compounds with uiScale and a 1.85 font-px multiplier at render time, so the visible step from a 99 white to a 100 yellow is meaningful.
Crit highlight
When the caller passes isCrit = true, the band logic is bypassed entirely: color forces to #ffcc44 (the same yellow as the 100–399 band) and base size multiplies by 1.3. So a crit looks like an oversized yellow number regardless of how small the underlying hit was — a 30-damage crit reads visually similar to a 150-damage non-crit. This is intentional: crits should pop without requiring the player to read the digit.
Explicit color overrides (heal greens, shield blue, destructible grey, the “Immune” tag’s #aabbcc) take the third branch and skip the band logic but still get the 1.4× readability multiplier.
Arc trajectory and freeze
Each new number launches with:
- A uniform random angle on the full circle (
Math.random() * Math.PI * 2) - Speed
80 + Math.random() * 40px/s along that angle - An extra
-60px/s onvyto bias the initial direction upward
For the first 0.25s of life (FREEZE_AT = 0.25), it integrates position with constant gravity DMG_ARC_GRAVITY = 200 px/s². After FREEZE_AT, position is locked and the number fades out in place — “punchier read” per the inline comment, vs. the older behavior of arcing the full lifetime. Total lifetime is 0.5s.
Alpha curve mirrors the physics: fade in over the first 0.05s, full opacity through the arc, then linear fade-to-zero across the remaining 0.25s of frozen lifetime. A 1.4× → 1.0× scale punch over the first 0.15s adds the visceral hit-impact pop.
Aggregation: stagger and merging
Damage numbers don’t merge — each call produces its own record. But two mechanisms keep AoE bursts readable:
- Per-spawn random delay. Each new number gets
_delay = floor(Math.random() * 3)— 0, 1, or 2 frames invisible before it activates. A line laser that hits 12 enemies on the same frame spreads its spawns across 1–3 frames instead of stacking 12 identical numbers on top of each other. The delayed numbers count down in the update loop and are skipped by the renderer (if (dn._delay > 0) continue). - Player-side accumulators. Damage taken does not use
DmgNumbers.addfor hull or shield. Instead,ShieldDmgAccum.collect(n)andHpDmgAccum.collect(n)merge rapid hits into a single-Npopup above the ship that grows with each refresh — a 1.2s timer that resets on each new hit, so a sustained beam reads as one ticking total rather than a vertical stack of separate damage numbers. Same pattern for XP pickup viaXpAccum.collect(val)(2.5s timer). These render indrawAllDamageNumbers()below the enemy-number loop.
The boss bar accumulates a different way: enemy bodies with sharesHealthWithBoss push their damage into game._pendingBarDamage, which the bridge flushes as a single oversized number at the boss’s position once per frame. This avoids spamming dozens of micro-numbers from a boss made of many sub-parts.
Pool cap
DmgNumbers.add is hard-capped:
- Enemy/destructible numbers:
DMG_CAP = 15. Onceworld.dmgNumbers.length >= 15, new spawns are silently dropped (early return). - Labels and big text (
addLabel,addText): the per-method cap is80.
The 15-number cap is aggressive on purpose — at full chaos (mortar barrage, beam sweep across a pack), more than ~15 simultaneous numbers becomes unreadable noise and the dropped spawns are not missed. The 80 ceiling on labels/text is the failsafe ceiling that also gates the boss-bar popup path.
Stress test stressDamageNumberFlood (in stress-tests.ts) confirms the cap holds: 20 add-calls per frame produce no growth past the ceiling, and arc physics + lifetime cleanup stay flat in cost.
Special spawn paths
A few callers bypass the add() band logic and push directly onto world.dmgNumbers:
- “Immune” tag on charger phase-2 hits (
damage.ts:269) —#aabbccgrey-blue, text'Immune', size 4, lifetime0.9s. Cooldown-gated per-enemy at 5s so chargers don’t spam. - “T-BONED” label on ram collisions (
collision-resolver.ts:564) — usesDmgNumbers.addLabel(x, y, 'T-BONED', '#ffffff', 4). - Player hull/shield damage — never goes through
add(); uses the accumulators described above plusaddText()for the legacy block-letter path (Cal Sans, screen-space, stacked above ship, 1.8s lifetime, pause-then-rise instead of arc).
Render and layer order
drawAllDamageNumbers(ctx, world.dmgNumbers, ship) is invoked once per frame from bridge.ts:7388 in the “above-world, below-vignette + HUD” pass. Text is baked to a per-(string, color, size) canvas cache (_dmgTextCache) so each unique combination only rasterizes once for the session — full Bungee/Impact stack with 3D extrusion, dark outline, then fill. Subsequent draws are pure drawImage() calls. Font-size bucketing (round to nearest 2px) caps cache growth.
Lifetime cleanup is swapRemove (O(1) unordered) in the update loop, which is why the pool can churn freely without GC pressure.
Clearing
Damage numbers are wiped to length 0 on:
- Game over / death sequences (
bridge.ts:2003, 3700, 8513) - Run termination cleanup (
bridge.ts:6665) - Any “clear floating text” path that also wipes XP and notifications
Telemetry samples world.dmgNumbers.length each frame (sampler.ts:234) so the peak per scenario is captured in stress reports as peakDmgNumbers.