Top down 8 directional movement
const MAX_SPEED_2D = 180.0
const ACCELERATION_2D = 1400.0
const FRICTION_2D = 1800.0
state velocity_2d = Vector2(0, 0)
state facing_octant = 0 // 0..7, one of 8 compass directions, for sprite selection only
function move_toward_vector(current, target, max_delta):
diff = target - current
dist = diff.length()
if dist <= max_delta or dist == 0:
return target
return current + diff / dist * max_delta
function get_2d_input_vector():
v = Vector2(0, 0)
if input.is_down("move_right"): v.x += 1
if input.is_down("move_left"): v.x -= 1
if input.is_down("move_down"): v.y += 1
if input.is_down("move_up"): v.y -= 1
// Clamp, don't force-normalize: a full diagonal keypress naturally has
// length sqrt(2), which must be reduced to 1. A partial analog-stick push
// shorter than 1 should be preserved as-is (that's real analog magnitude).
if v.length() > 1.0:
v = v.normalized()
return v
function apply_2d_acceleration(dt):
input_vec = get_2d_input_vector()
target_velocity = input_vec * MAX_SPEED_2D
rate = ACCELERATION_2D if input_vec.length() > 0 else FRICTION_2D
velocity_2d = move_toward_vector(velocity_2d, target_velocity, rate * dt)
function get_facing_octant(v):
if v.length() < 0.1:
return facing_octant // no input — keep last facing, don't snap to a default
angle = atan2(v.y, v.x)
return round(angle / (PI / 4)) mod 8 // quantize continuous angle to 1 of 8 slices
function update(dt):
apply_2d_acceleration(dt)
facing_octant = get_facing_octant(velocity_2d)
position += velocity_2d * dt
resolve_collision(axis = "x")
resolve_collision(axis = "y")
How it works¶
Structurally this is the same accel/friction model as section 1, just lifted from a single scalar axis to a full Vector2 — move_toward_vector does for direction+magnitude what move_toward did for a plain number: close the gap between current and target velocity by a limited amount per frame, without overshooting, and (importantly) without changing direction incorrectly along the way, since it operates on the diff vector as a whole rather than per-axis.
The classic bug this problem introduces that the platformer sections didn't have is diagonal speed inflation: if you compute v.x and v.y independently as -1/0/1 and multiply each by MAX_SPEED separately, pressing up+right gives a resultant vector of length MAX_SPEED * sqrt(2) — roughly 41% faster than moving in a cardinal direction. The fix is to clamp the raw input vector's length to 1.0 before scaling by MAX_SPEED_2D, which is why get_2d_input_vector checks v.length() > 1.0 and normalizes only in that case — clamping (not unconditionally normalizing) is important so a partial analog stick push, which is legitimately shorter than length 1, isn't inflated up to full speed.
The other piece specific to top-down games with 8-directional sprites is get_facing_octant: many top-down/action-adventure games (classic Zelda-likes) only have art for 8 discrete facing directions, but there's no reason the underlying movement needs to be similarly quantized. The pattern is to keep velocity_2d fully continuous/analog for physics feel, and separately derive a quantized octant purely for animation/sprite selection by bucketing the continuous angle into one of 8 slices. Note the early-return when input magnitude is near zero — deriving an octant from a near-zero vector gives a meaningless/noisy angle, so facing should simply hold its last value rather than being recomputed every frame regardless of input.