Stopping the Stutter: Frame-Independent Movement and State Logic in LÖVE
Learn how to eliminate frame-rate dependency in Love2D using Delta Time (dt) and implement a clean state machine to manage game menus and gameplay logic.
03 Jan 2026, 06:46 UTC

The Problem: Variable Hardware, Variable Speed
A common frustration when starting with LÖVE (Love2D) is discovering that your character moves faster on a 144Hz monitor than on a 60Hz one. This happens when movement logic is tied directly to the frame rate—the number of times love.update is called per second—rather than to actual elapsed time.
To build a professional-feeling game, you must decouple your game logic from the rendering speed. The solution is leveraging Delta Time (dt) and a structured state machine to ensure the game behaves identically regardless of the hardware it runs on.
Calculating Movement with Delta Time
LÖVE passes a variable called dt (delta time) into the love.update(dt) callback. This value represents the fraction of a second that has passed since the last frame. For example, at 60 FPS, dt is approximately 0.0166 seconds.
If you add a fixed value to a position every frame (e.g., x = x + 5), you are moving 5 pixels per frame. To move 5 pixels per second, you must multiply your speed by dt.
The Frame-Independent Formula
Position = Position + (Speed * DeltaTime)
By using this formula, if a frame takes longer to process, dt increases, and the object moves a larger distance to compensate, keeping the perceived speed constant.
Managing Complexity with a State Machine
As your project grows, putting all your logic inside a single love.update function leads to "if-else hell," where you constantly check if the game is paused, in a menu, or in active gameplay.
A simple state machine solves this by delegating the update and draw calls to a specific "state object." Instead of checking if gameState == "menu" every frame, you simply call currentState:update(dt).
Worked Example: Movement and State Switching
The following implementation demonstrates a basic state switch between a "Menu" and "Game" state, utilizing dt for smooth movement. Run this in a standard LÖVE environment.
-- main.lua
local states = {}
local currentState = "menu"
-- Define the Menu State
states.menu = {
update = function(dt)
if love.keyboard.isDown("return") then
currentState = "game"
end
end,
draw = function()
love.graphics.print("MENU: Press Enter to Start", 100, 100)
end
}
-- Define the Game State
states.game = {
player = { x = 100, y = 100, speed = 200 },
update = function(dt)
-- Frame-independent movement: speed is pixels per second
if love.keyboard.isDown("right") then
states.game.player.x = states.game.player.x + (states.game.player.speed * dt)
end
if love.keyboard.isDown("left") then
states.game.player.x = states.game.player.x - (states.game.player.speed * dt)
end
if love.keyboard.isDown("escape") then
currentState = "menu"
end
end,
draw = function()
love.graphics.rectangle("fill", states.game.player.x, states.game.player.y, 50, 50)
love.graphics.print("GAME: Use Arrows to move, Esc for Menu", 10, 10)
end
}
function love.update(dt)
states[currentState].update(dt)
end
function love.draw()
states[currentState].draw()
end
Verification and Risks
- Verification: To test the
dtimplementation, use a tool to cap your FPS (e.g., 30 FPS vs 60 FPS). The player square should cross the screen in the exact same amount of time in both scenarios. - Risk: Avoid creating new tables (e.g.,
{x=0, y=0}) inside theupdatefunction. Lua's garbage collector will trigger frequently to clean up these short-lived tables, causing "micro-stutter" or sudden frame drops.
The Trade-off: Immediate Mode Rendering
LÖVE uses an immediate-mode rendering style, meaning love.graphics.draw commands are executed instantly. While this is excellent for rapid prototyping and small games, it becomes a bottleneck if you try to draw thousands of individual objects per frame.
If you notice performance dropping as your entity count increases, you will need to move from individual draw calls to SpriteBatches. A SpriteBatch allows you to send multiple draw commands to the GPU in a single call, significantly reducing the overhead of the CPU-to-GPU communication.
Closing Action
Review your current movement logic. If you see any addition or subtraction happening in love.update that doesn't involve dt, it is a bug waiting to happen on a different monitor. Convert those to speed * dt and move your logic into a state-based table to keep your codebase scalable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.