Skip to content

Twin stick movement independent of aim

const AIM_DEADZONE = 0.20

state aim_direction = Vector2(1, 0)   // last known aim, always valid, never zero

function get_movement_input():
    return get_2d_input_vector()   // left stick / WASD — reuses section 12 unchanged

function get_aim_direction():
    if input.has_gamepad():
        stick = input.get_right_stick()
        if stick.length() > AIM_DEADZONE:
            aim_direction = stick.normalized()
        // else: stick is neutral — deliberately KEEP the previous aim_direction
        // rather than recomputing from a near-zero vector.
    else:
        to_mouse = input.get_mouse_world_position() - position
        if to_mouse.length() > 1.0:   // avoid a degenerate direction if mouse is on the player
            aim_direction = to_mouse.normalized()

    return aim_direction

function update_facing(dt):
    facing_direction = get_aim_direction()   // purely aim-driven, NOT velocity-driven

function fire_weapon():
    if input.was_pressed("fire"):
        spawn_projectile(origin = position, direction = aim_direction)

function update(dt):
    movement_input = get_movement_input()
    velocity_2d = move_toward_vector(velocity_2d, movement_input * MAX_SPEED_2D,
                                      ACCELERATION_2D * dt)

    update_facing(dt)
    fire_weapon()

    position += velocity_2d * dt
    resolve_collision(axis = "x")
    resolve_collision(axis = "y")

How it works

Every top-down movement system up to this point has one input vector that does double duty: it drives velocity and it determines which way the character faces. Twin-stick control is the deliberate removal of that coupling — movement (get_movement_input, left stick/WASD) and facing (get_aim_direction, right stick or mouse) are read from entirely independent sources and only ever meet at the point where you actually need both, like spawning a projectile with the aim direction from a position that moved via the movement direction. update_facing reinforces this by explicitly deriving facing_direction only from get_aim_direction() — velocity never touches it.

The recurring implementation pitfall is what happens when the aim source goes "empty" for a frame — a right stick released back to center, or (much rarer) a mouse sitting exactly on top of the player. A naive stick.normalized() on a zero-length vector is undefined (or produces a nonsense default direction depending on the math library), which shows up as the character's facing visibly snapping to some arbitrary direction the instant the stick is let go. The fix in both branches of get_aim_direction is the same principle: only overwrite aim_direction when the new input is unambiguous (stick.length() > AIM_DEADZONE, or the mouse is more than a trivial distance away); otherwise leave the previous frame's aim_direction in place. That variable is seeded with a valid default (Vector2(1, 0)) at declaration specifically so there's never a frame where it's zero.