Lua + LÖVE: Make a Game · Lesson 05 · 45 min
Move at the speed of time
Read continuous keyboard input and use delta time, normalization, and clamping for reliable movement.
Continuous input and frame-rate independence.
Eight-direction player movement.
Keep diagonal speed fair and stay on-screen.
Movement connects input, math, and the game loop. We want the player to travel at a speed measured in pixels per second, regardless of the computer’s frame rate.
See why dt matters
Suppose you add four pixels every frame:
player.x = player.x + 4
At 30 frames per second that travels 120 pixels in one second. At 120 FPS it travels 480. The faster computer changes the game.
Use a speed and elapsed time instead:
player.x = player.x + player.speed * dt
If speed is 240 pixels per second, the one-second distance stays 240.
Frame-rate laboratory
Simulate one second using either 4 pixels per frame or 240 pixels per second multiplied by dt.
Poll held keys
Movement should continue as long as a key is held. Poll the current keyboard state during every update:
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
isDown can accept more than one key and returns true if any is held. Later, love.keypressed will handle one-time actions such as starting a game.
Add speed to the player table:
local player = {
x = 480,
y = 360,
radius = 14,
speed = 260,
}
Then replace love.update with this first movement version, keeping the signal pulse line:
function love.update(dt)
signal.pulse = signal.pulse + dt * 4
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
if love.keyboard.isDown("up", "w") then
player.y = player.y - player.speed * dt
end
if love.keyboard.isDown("down", "s") then
player.y = player.y + player.speed * dt
end
end
Run the game and move with WASD or the arrows.
Fix faster diagonal movement
The code above adds full horizontal and vertical movement simultaneously. If both components are 260, diagonal speed is about 368 because of the Pythagorean theorem:
√(260² + 260²) ≈ 368
Build a direction first, then normalize it to length 1:
local dx, dy = 0, 0
if love.keyboard.isDown("left", "a") then dx = dx - 1 end
if love.keyboard.isDown("right", "d") then dx = dx + 1 end
if love.keyboard.isDown("up", "w") then dy = dy - 1 end
if love.keyboard.isDown("down", "s") then dy = dy + 1 end
local length = math.sqrt(dx * dx + dy * dy)
if length > 0 then
dx = dx / length
dy = dy / length
end
player.x = player.x + dx * player.speed * dt
player.y = player.y + dy * player.speed * dt
The length > 0 guard matters. Dividing the zero vector by zero would produce invalid values.
Put that block inside love.update, after the signal update. Opposite keys cancel naturally: left contributes -1, right contributes +1, and the result is zero.
Keep the whole player on-screen
Clamping forces a value into a range:
local function clamp(value, minimum, maximum)
return math.max(minimum, math.min(maximum, value))
end
Use the player’s radius as the margin so its edge—not only its center—stays visible:
player.x = clamp(
player.x + dx * player.speed * dt,
player.radius,
love.graphics.getWidth() - player.radius
)
player.y = clamp(
player.y + dy * player.speed * dt,
player.radius,
love.graphics.getHeight() - player.radius
)
This replaces the two simple position assignments. Try holding two direction keys into each corner.
Continuous state versus discrete events
Use the right kind of input for the action:
| Player intent | Input style | Why |
|---|---|---|
| Keep moving | love.keyboard.isDown in update |
true during every held frame |
| Start game | love.keypressed callback |
one event per press |
| Type a name | love.textinput callback |
receives entered text |
| Aim at cursor | love.mouse.getPosition in update |
current position matters |
If you start a game by polling Enter, the key may still be held on the next frame and trigger something again. Event callbacks represent the edge: the moment a key becomes down.
Your turn: boost
While either Shift key is held, make the player move 1.6 times faster. Calculate a local currentSpeed; do not permanently change player.speed.
One solution
Place this before the position calculation:
local currentSpeed = player.speed
if love.keyboard.isDown("lshift", "rshift") then
currentSpeed = currentSpeed * 1.6
end
Then use currentSpeed in both position expressions.
Checkpoint
Your player should move smoothly, travel at the same speed diagonally and straight, accept either control scheme, and stop with its outer edge at every window boundary.