Skip to content

Grid-based snap movement

const CELL_SIZE     = 32.0
const MOVE_DURATION = 0.12   // seconds per cell; 0 = instant/turn-based snap

state grid_position   = IntVector2(0, 0)   // logical, authoritative position
state is_moving        = false
state move_start_pos   = Vector2(0, 0)
state move_target_pos  = Vector2(0, 0)
state move_timer       = 0.0
state queued_direction = null   // one-slot input buffer, same idea as section 6

function is_cell_passable(cell):
    return tilemap.is_solid(cell) == false and get_entity_at(cell) == null

function handle_grid_input():
    dir = read_pressed_direction()   // discrete d-pad/WASD press, not held-analog
    if dir == null:
        return

    if not is_moving:
        try_start_move(dir)
    else:
        queued_direction = dir   // buffer it — consumed the instant the current move finishes

function try_start_move(dir):
    target_cell = grid_position + dir
    if not is_cell_passable(target_cell):
        return   // never begin a move that can't be legally completed

    grid_position = target_cell            // logical position updates IMMEDIATELY
    move_start_pos = render_position
    move_target_pos = grid_to_world(target_cell)
    move_timer = 0.0
    is_moving = true

function update_move(dt):
    if not is_moving:
        return

    move_timer += dt
    t = clamp(move_timer / MOVE_DURATION, 0.0, 1.0)
    render_position = lerp(move_start_pos, move_target_pos, ease_out(t))

    if t >= 1.0:
        finish_move()

function finish_move():
    render_position = move_target_pos
    is_moving = false

    if queued_direction != null:
        d = queued_direction
        queued_direction = null
        try_start_move(d)   // chain immediately into the buffered move

function update(dt):
    handle_grid_input()
    update_move(dt)

How it works

This is a different paradigm from everything above it, not just a variant — there's no velocity, no acceleration, no continuous integration. "Movement" is a discrete state transition from one grid cell to an adjacent one, and the defining rule is that a move is only ever started if it can be legally completed: try_start_move checks is_cell_passable on the destination before committing to anything, so the character never ends up partway into a wall the way a continuous-physics character can when it's stopped mid-frame by a collision resolver.

The detail that trips people up is keeping two separate notions of "where the character is": grid_position (logical/integer, used by every gameplay system — collision checks, triggers, other entities querying occupancy) updates the instant a move is validated and begins, while render_position (the thing actually drawn on screen) lags behind and tweens smoothly toward the new cell over MOVE_DURATION. This split matters because gameplay logic should never have to wait for an animation to finish to know the truth — if a second entity checks "is this cell occupied" mid-tween, it needs to see the character's new cell immediately, not the one it's animating away from.

MOVE_DURATION is the knob that spans two genuinely different genres with the same code: set it to 0 and moves resolve instantly with no animation at all — classic turn-based roguelike snap-movement. Give it a small nonzero value and you get the real-time-but-discrete feel of Sokoban or Into the Breach, where movement is still grid-locked but reads as fluid rather than teleporting.

queued_direction is a one-slot input buffer, and it's the same underlying idea as jump buffering in section 6: capturing a press that happens slightly before the system is ready to act on it, so chained movement (holding a direction to walk several cells) doesn't require the player to time each press to the exact frame the previous tween completes. finish_move consuming the queue immediately, in the same frame the previous move ends, is what makes continuous held-direction movement feel like one fluid walk rather than a stutter-step.