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.