Fish-swim movement
What this is
Fish-swim is the orbital movement model used by ranged projectile enemies that need to circle the player at a target distance while staying in continuous, smooth motion. It blends a radial spring (pulling the enemy toward its orbit radius) with a tangential force (sweeping it sideways around the player), then applies drag for glide and clamps to a max speed. The enemy always faces its current movement direction rather than the player, giving a lithe, sleek silhouette that arcs cleanly through the playfield.
The model is implemented in the shared shooter behavior registration inside src/starship-survivors/engine/enemies/behaviors.ts. Any enemy archetype that sets behavior: 'shooter' inherits it; the only per-archetype tuning is the orbitRadius value on the data file.
The orbit shape
Steering is recomputed every frame from the live player position and the enemy’s own velocity. There is no waypoint or path — the orbit emerges from the force blend.
| Parameter | Constant | Value | Role |
|---|---|---|---|
| Steer force | FISH_STEER_FORCE | 3.0 | Strength of radial pull back to orbit radius |
| Tangent force | FISH_TANGENT_FORCE | 1.8 | Strength of sideways drift along the orbit |
| Drag | FISH_DRAG | 0.97 | Per-step velocity multiplier (glide / inertia) |
| Turn speed | FISH_TURN_SPEED | 8.0 rad/s | Rate at which facing angle catches up to movement angle |
| Max-speed multiplier (idle) | inline | 1.5 × base speed | Velocity cap while not firing |
| Max-speed multiplier (firing) | inline | 0.6 × base speed | Velocity cap during burst / post-fire pause |
| Tangent reduction (firing) | inline | 0.3 × FISH_TANGENT_FORCE | Tangent damping while burst / post-fire pause is active |
| Facing threshold | inline | move speed > 5 | Below this, facing angle does not update |
Per-frame steering:
| Step | Computation |
|---|---|
| 1 | Compute outward radial unit vector from player to enemy |
| 2 | Compute tangent unit vector perpendicular to radial, signed by _fishDir |
| 3 | radialError = (dist − orbitRadius) / orbitRadius clamped to roughly ±1 |
| 4 | radialForce = −radialError × FISH_STEER_FORCE (positive when too far, negative when too close) |
| 5 | Add (radial × radialForce + tangent × tangentForce) × baseSpeed × dt to velocity |
| 6 | Multiply velocity by drag |
| 7 | Clamp velocity magnitude to current max-speed cap |
| 8 | Integrate position |
| 9 | Slew facing angle toward atan2(vy, vx) at FISH_TURN_SPEED |
Orbit direction is randomized once per enemy on first tick. _fishDir is set to +1 or −1 with equal probability and never changes for the lifetime of that enemy, so a wave of shooters mixes clockwise and counter-clockwise orbits.
Which enemies use it
Any enemy whose data file sets behavior: 'shooter' runs the fish-swim movement. Other enemy archetypes (sniper, mortar, charger, gunner) use different movement models registered under their own behavior keys.
| Archetype | Source | Orbit radius | Notes |
|---|---|---|---|
| Shooter | src/starship-survivors/data/enemies/shooter.ts | 130 | Baseline ranged orbiter with arrow-telegraph burst |
| Spitter | src/starship-survivors/data/enemies/spitter.ts | 200 | Slower, tankier variant; wider orbit pressures dodge windows |
Boss-roster orbiting bodies (Valet at 240, awakened-mech satellites at 200–240, prism-cluster cardinals at 240) use the legacy orbit behavior key, which is registered as an alias that redirects to the shooter behavior, so they share the same fish-swim math.
Anti-camping behavior
The model has no idle zone, no chase mode, and no flee mode. A single continuous force blend runs every frame, which removes the “snap between targets” stall that earlier zone-based steering produced when the player hovered near a band boundary.
| Player position | Resulting force | Visible effect |
|---|---|---|
| At orbit radius | Tangent only (radial error ≈ 0) | Smooth sweep around the player |
| Inside orbit radius | Outward radial + tangent | Enemy spirals out while still arcing sideways |
| Outside orbit radius | Inward radial + tangent | Enemy spirals in while still arcing sideways |
The radial spring is proportional to error, so trying to camp the enemy at a distance other than orbitRadius causes it to accelerate back toward the ring rather than committing to the player’s current position. The 1.5× idle max-speed cap (vs. base speed) lets the enemy overshoot its base move-speed stat while repositioning, so a player who sprints out of range cannot simply outrun the orbit.
Firing does not stop motion. While _burstRemaining > 0 or _postFirePause > 0, the tangent force is cut to 30% and the max-speed cap drops to 0.6× base speed, but the radial spring still runs at full strength. The shooter cannot be camped during its post-fire stand — it continues drifting back to orbit radius even while it appears to be “paused.” Drag (0.97/step) decays leftover velocity from per-shot recoil and stun knockback so the enemy converges back onto the orbit instead of being permanently displaced.
Aggro gating sits outside the steering math. Outside the behavior’s smartRange (250 for shooter), the enemy is treated as not aggroed and runs idle friction instead of fish-swim; once aggroed, no out-of-range condition turns the model off. Stun (_stunTimer > 0) is the only inline override that suppresses the movement update entirely.