Flukz — open-source shmup community resource open-source GPL devlog  ·  about
// indie shmup & game-dev resource

Game Programming · June 30, 2026

Tweening and Easing for Enemy Movement Choreography in Shmups

Enemies that move at constant linear speed in straight lines read as placeholder content. They are mechanically correct — predictable, avoidable, clear — but they communicate nothing about what the enemy is, what it intends to do, or how dangerous it is. Easing functions solve this without adding behaviour complexity: the same linear path, given an ease-in-out curve, suddenly looks like a creature that accelerates into position and decelerates to a stop. That difference in feel is larger than its implementation cost.

The easing vocabulary

Easing functions map a linear progress value (0.0 to 1.0) to a curved output. The output is applied to position, scale, rotation, or any other property. The names describe the curve shape:

Entry choreography: the most important moment

The entry movement is when the player first sees an enemy. It establishes personality before the enemy has fired a single shot. Three entry patterns cover most shmup enemy types:

Sweep and stop: Enemy enters from the edge, moves to a mid-screen position, decelerates with ease-out over 0.4–0.6 seconds, and holds position. The deceleration is the key moment. Without it, the enemy appears to hit an invisible wall. With ease-out, it glides to a stop and reads as intentional. This is the correct entry for turret-type enemies that attack from a fixed position.

Pass-through: Enemy enters from one side and exits the opposite without stopping. Ease-in-out on the full path gives it a patrol feel. Pairing pass-throughs with staggered timing (each ship slightly delayed) creates a formation sweep that looks coordinated and is readable from below.

Dive and retreat: Enemy dives toward the player position with ease-in (accelerating), fires at the dive endpoint, then retreats with ease-out (decelerating). The direction change between dive and retreat should be abrupt — no smooth arc — because the sharp direction change is what reads as an attack.

Chained tween sequences

Complex enemy movement is built from sequences of simple tweens chained together. Each tween moves the enemy along one segment of the full path, and the next tween fires when the previous completes. A boss that circles the arena, pauses at cardinal positions to fire, then repositions diagonally is four tweens in a loop, not complex movement code.

tween = create_tween().set_loops() tween.tween_property(self, "position", top_center, 1.2).set_ease(EASE_OUT) tween.tween_callback(fire_pattern_1) tween.tween_interval(1.0) tween.tween_property(self, "position", right_center, 0.8).set_ease(EASE_IN_OUT) tween.tween_callback(fire_pattern_2) tween.tween_interval(0.8)

The callback between position tweens is where patterns fire, sub-spawns trigger, or phase changes activate. This makes the movement script function as a readable choreography score rather than an update loop full of timer checks.

Spline paths for organic movement

For enemies that need curved flight paths — figure-8s, spirals, interweaving formation loops — spline paths are more natural than tweens. The enemy follows a Bezier or Catmull-Rom curve at a configurable speed, optionally with speed variation along the curve to create moments of acceleration and coast.

Spline paths are best authored in an in-engine path editor rather than defined in code. Most 2D engines provide a Path2D or equivalent node type. Once the paths are authored as assets, enemy spawning code references them by ID. This separates movement path design from code and allows iterating on formations without touching the behaviour layer.

Rotation follows movement direction

Enemies that bank, tilt, or orient toward their movement direction feel physical. The simplest implementation rotates the sprite toward the velocity vector each frame, with a maximum rotation speed that produces a smooth banking lag rather than instant snapping:

if velocity.length() > 0.1: target_angle = velocity.angle() current_angle = rotation rotation = lerp_angle(current_angle, target_angle, 0.12)

The lerp factor (0.12 here) controls the lag. Higher values snap faster. For formation enemies, a tighter lag (0.2–0.3) reads as alert; for slow heavies, a loose lag (0.06–0.1) reads as lumbering weight.

The overuse trap

When every enemy in the game uses complex easing sequences and spline paths, the screen becomes a continuous visual noise of movement that the player cannot parse. Easing is most effective as a contrast: simple enemies move plainly, and the use of a slow ease-out or a back-easing snap calls visual attention to the enemy using it as more significant or dangerous.

Design formation enemies to use consistent, simple movement — their predictability is what makes them navigable. Reserve elaborate tween choreography for boss phases and named sub-bosses where the spectacle and the added readability time both serve a purpose. The moment when a boss decelerates from a charge and hovers, breathing slightly through a scale tween, communicates more about the game's production quality than a hundred identically-moving formations ever could.