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.

ParameterConstantValueRole
Steer forceFISH_STEER_FORCE3.0Strength of radial pull back to orbit radius
Tangent forceFISH_TANGENT_FORCE1.8Strength of sideways drift along the orbit
DragFISH_DRAG0.97Per-step velocity multiplier (glide / inertia)
Turn speedFISH_TURN_SPEED8.0 rad/sRate at which facing angle catches up to movement angle
Max-speed multiplier (idle)inline1.5 × base speedVelocity cap while not firing
Max-speed multiplier (firing)inline0.6 × base speedVelocity cap during burst / post-fire pause
Tangent reduction (firing)inline0.3 × FISH_TANGENT_FORCETangent damping while burst / post-fire pause is active
Facing thresholdinlinemove speed > 5Below this, facing angle does not update

Per-frame steering:

StepComputation
1Compute outward radial unit vector from player to enemy
2Compute tangent unit vector perpendicular to radial, signed by _fishDir
3radialError = (dist − orbitRadius) / orbitRadius clamped to roughly ±1
4radialForce = −radialError × FISH_STEER_FORCE (positive when too far, negative when too close)
5Add (radial × radialForce + tangent × tangentForce) × baseSpeed × dt to velocity
6Multiply velocity by drag
7Clamp velocity magnitude to current max-speed cap
8Integrate position
9Slew 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.

ArchetypeSourceOrbit radiusNotes
Shootersrc/starship-survivors/data/enemies/shooter.ts130Baseline ranged orbiter with arrow-telegraph burst
Spittersrc/starship-survivors/data/enemies/spitter.ts200Slower, 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 positionResulting forceVisible effect
At orbit radiusTangent only (radial error ≈ 0)Smooth sweep around the player
Inside orbit radiusOutward radial + tangentEnemy spirals out while still arcing sideways
Outside orbit radiusInward radial + tangentEnemy 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.