Skip to content

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.