Juice System

Game-feel layer that turns named gameplay moments into coordinated particle bursts + micro-SFX. One call site, one event name, two consistent outputs — the engine and metagame both hit Juice.fire(event) so kills, level-ups, chest opens and boss deaths feel uniform across the app.

Source: src/starship-survivors/engine/vfx/juice.ts. No separate fx/juice.ts exists.

Signal bus model

caller → Juice.fire(eventName) → Particles.burst(...) + MicroSfx.play(eventName)
  • Event name is the contract. The caller doesn’t know what particles or sound it gets — only that it fired the right kind of moment. The JUICE_TABLE is the single source of truth that maps eventName → particle count, and MicroSfx’s own table maps eventName → sting.
  • Unknown events are silently dropped. if (!j) return; — no crash, no warn. Adding a new juice event means adding a row to JUICE_TABLE.
  • Particles are emitted at the ship. Bursts always spawn at ship.x, ship.y with a fixed spark color (r:200 g:220 b:255) and 60 base lifetime. There is no per-event color/origin override — that lives in dedicated VFX systems (boss death rings, shield-break shards, etc.), not in Juice.

Event vocabulary

The full table lives in JUICE_TABLE (engine/vfx/juice.ts). Categories:

GroupEvents
Combat — player offenseplayer_shot, kill, enemy_kill_tiny, enemy_kill, enemy_kill_large, elite_kill, boss_hit, boss_kill
Combat — player defenseplayer_hit, player_hull_hit, shield_hit, shield_break, shield_broken, invuln, revive
Progressionlevelup, warp_collect
World eventsbeacon_done, event_done, solar_flare, extraction_done, chase_catch, detected
Pickups / cratescomet_catch, crate_break, crate_break_big
Environment / ramram_hit_light, ram_hit_medium, ram_hit_heavy, object_hit, object_destroyed
AoE / deathaoe_pulse, player_death

Particle counts run from 0 (audio-only) up to 30 (boss_kill). Events with particles: 0 exist purely so micro-SFX has a consistent trigger.

What Juice does NOT do (historic)

The current Juice system is particles + micro-SFX only. Per the source comments:

  • No screen shake. The shake system has been deleted.
  • No hit-stop / time dilation. Juice.update() is intentionally empty.
  • No screen flash. No flash system is wired through Juice.

The brief that often accompanies this concept (“shake + hit-stop + flash + stings”) describes a richer juice surface that was rolled back. Today the visible game-feel layers are split across dedicated systems:

Feel layerWhere it actually lives
Particle bursts on named eventsengine/vfx/juice.ts (this system)
Micro-SFX stings on named eventsengine/audio/micro-sfx.ts
Ship squash/stretch on fireShipRecoil (same file) — driven by weapon archetype + weapon-id overrides + per-spec RecoilProfile
Weapon anticipation squeeze during warmupShipRecoil.anticipate(progress, pulseStrength)
Bullet/death VFX, boss death rings, shield shardsdedicated VFX modules (not Juice)

Ship recoil — co-located but separate

The same file exports ShipRecoil, a pure cosmetic squash/stretch animator on the player ship sprite. It does not run through Juice.fire and it does not apply any physics kick to the player (v5.156.4: physics velocity kick REMOVED entirely). Player movement stays 100% joystick-driven.

Two phases compose multiplicatively into ShipRecoil.sx/sy:

  1. Anticipation — each warming-up weapon calls ShipRecoil.anticipate(progress, pulseStrength) every frame. Squeezes horizontally, stretches vertically, scaled by an ease-in progress² and capped at ANTICIPATE_MAX = 0.12 (12 % at peak warmup × pulseStrength).
  2. RecoilShipRecoil.fire(archetype, weaponId, _angle, pulseStrength, specProfile) sets a target squash and an elastic-snap-back tween. Lookup priority: weapon-spec profile → weapon-id override (WEAPON_RECOIL_OVERRIDES) → archetype default (WEAPON_RECOIL).

pulseStrength (0–1, from WeaponCoreSpec.shipPulseStrength) scales how much of the profile’s deformation is applied. Set 0 to disable hull recoil entirely for that weapon.

Adding a new juice event

  1. Pick a snake_case event name.
  2. Add a row to JUICE_TABLE in engine/vfx/juice.ts with the particle count (0 if audio-only).
  3. Add (or reuse) a sting mapping in engine/audio/micro-sfx.ts for the same event name.
  4. Call Juice.fire('your_event') at the moment of impact.

That’s it — the consumer doesn’t need to know what plays. If you need custom particle color or origin, don’t extend Juice; emit directly from a dedicated VFX system instead.