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_TABLEis the single source of truth that mapseventName → particle count, andMicroSfx’s own table mapseventName → sting. - Unknown events are silently dropped.
if (!j) return;— no crash, no warn. Adding a new juice event means adding a row toJUICE_TABLE. - Particles are emitted at the ship. Bursts always spawn at
ship.x, ship.ywith 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:
| Group | Events |
|---|---|
| Combat — player offense | player_shot, kill, enemy_kill_tiny, enemy_kill, enemy_kill_large, elite_kill, boss_hit, boss_kill |
| Combat — player defense | player_hit, player_hull_hit, shield_hit, shield_break, shield_broken, invuln, revive |
| Progression | levelup, warp_collect |
| World events | beacon_done, event_done, solar_flare, extraction_done, chase_catch, detected |
| Pickups / crates | comet_catch, crate_break, crate_break_big |
| Environment / ram | ram_hit_light, ram_hit_medium, ram_hit_heavy, object_hit, object_destroyed |
| AoE / death | aoe_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 layer | Where it actually lives |
|---|---|
| Particle bursts on named events | engine/vfx/juice.ts (this system) |
| Micro-SFX stings on named events | engine/audio/micro-sfx.ts |
| Ship squash/stretch on fire | ShipRecoil (same file) — driven by weapon archetype + weapon-id overrides + per-spec RecoilProfile |
| Weapon anticipation squeeze during warmup | ShipRecoil.anticipate(progress, pulseStrength) |
| Bullet/death VFX, boss death rings, shield shards | dedicated 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:
- Anticipation — each warming-up weapon calls
ShipRecoil.anticipate(progress, pulseStrength)every frame. Squeezes horizontally, stretches vertically, scaled by an ease-inprogress²and capped atANTICIPATE_MAX = 0.12(12 % at peak warmup × pulseStrength). - Recoil —
ShipRecoil.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
- Pick a snake_case event name.
- Add a row to
JUICE_TABLEinengine/vfx/juice.tswith the particle count (0 if audio-only). - Add (or reuse) a sting mapping in
engine/audio/micro-sfx.tsfor the same event name. - 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.