Architecture Note: Isolating State and Side Effects in the LÖVE 2D Game Loop
Learn how to structure a LÖVE 2D project with clear data boundaries, frame-rate independent updates, and isolated draw transforms to prevent stutter and global state bugs.
11 Mar 2026, 10:41 UTC

The Problem with Friendly Defaults
LÖVE 2D gives you three callbacks—love.load, love.update, and love.draw—and a global Lua namespace. That simplicity invites a common failure: dropping every variable into globals and running arbitrary logic inside the draw callback. The result is movement that changes speed when the frame rate changes, stutter from Lua garbage collection, and state transitions that no single file can explain.
The useful takeaway is architectural, not syntactic. Treat the main loop as a strict single-threaded contract. Funnel all mutable state through one explicit table. Keep love.draw a read-only projection of that state, and isolate draw-time side effects with the graphics stack.
Requirements and Version Assumptions
This note assumes LÖVE 11.x. The callback names and the love.graphics push/pop stack have been stable across several major releases, but always confirm against the version you ship. The project runs on a single thread by default: no two callbacks execute in parallel, and love.update and love.draw are called once per frame in that order after love.load.
You need three things from the framework:
love.load()—called once at startup.love.update(dt)—called every frame with the delta time in seconds since the previous frame.love.draw()—called every frame after update, where all rendering commands belong.
The Smallest Suitable Design: One Table, Three Callbacks
The smallest design that scales past a prototype is a single local table, often named Game or App, defined in main.lua. It acts as the only explicit data boundary. Other modules receive it as an argument rather than reaching for globals.
A minimal structure looks like this:
-- main.lua
local Game = {
player = { x = 400, y = 300, speed = 200 },
input = { left = false, right = false }
}
function love.load()
love.graphics.setBackgroundColor(0.1, 0.1, 0.15)
end
function love.update(dt)
local p = Game.player
if Game.input.left then p.x = p.x - p.speed * dt end
if Game.input.right then p.x = p.x + p.speed * dt end
end
function love.draw()
love.graphics.push()
love.graphics.translate(Game.player.x, Game.player.y)
love.graphics.setColor(1, 1, 1)
love.graphics.rectangle("fill", -10, -10, 20, 20)
love.graphics.pop()
end
function love.keypressed(key)
if key == "left" then Game.input.left = true end
if key == "right" then Game.input.right = true end
end
function love.keyreleased(key)
if key == "left" then Game.input.left = false end
if key == "right" then Game.input.right = false end
end
In this layout, love.update is the only place that mutates gameplay state. love.draw reads Game.player and issues commands. Input callbacks write flags; they do not move the player directly. That separation makes the simulation deterministic relative to the frame loop.
Trust and Data Boundaries
Lua globals are the opposite of a boundary. Any file can read or write _G.player, so data flow becomes implicit and fragile. The Game table replaces that with an explicit contract: if a system needs state, it receives the table or a sub-table.
Input is asynchronous. love.keypressed and love.keyreleased fire between frames. Do not trust that every press has a matching release—window focus changes can swallow events. Store flags in Game.input and consume them inside love.update, where they are synchronized with the rest of the simulation.
Delta time (dt) is variable. Trust it for time-based integration—speed multiplied by dt—but do not assume a fixed frame duration. If you need deterministic physics, an accumulator inside love.update is required; that is a design change, not a bug fix.
The graphics stack is a scoped trust boundary. love.graphics.push saves the current coordinate transform and color; love.graphics.pop restores them. Trust it only when pushes and pops are balanced within the same callback.
Operational Checks
Verify the architecture with four cheap checks.
Execution order: Temporarily add print statements to each callback. Expected order: love.load once, then alternating love.update and love.draw. Remove the prints afterward; I/O inside the loop distorts timing.
Frame-rate independence: Move the player by speed * dt. Run the binary with vsync enabled and disabled, or on different hardware. The player should cross the screen in the same wall-clock time. If traversal time changes with frame rate, you are using frame-based movement.
Transform isolation: After love.graphics.pop(), draw a reference rectangle at the origin. It should appear at the screen origin, unaffected by the earlier translate. If it does not, a push is missing or a pop is misplaced.
Allocation pressure: During profiling, compare collectgarbage("count") before and after a frame. Steady growth suggests you are creating tables or strings inside love.update or love.draw. Run this check only during development; calling collectgarbage in production adds overhead.
Failure Modes
Blocking updates: LÖVE's main loop is single-threaded. Heavy computation in love.update—pathfinding, procedural generation, synchronous asset loading—delays the next love.draw and drops frames.
Garbage-collection stutter: Lua's garbage collector reclaims memory from short-lived objects. Creating a new table every frame in love.update, or concatenating strings for a HUD inside love.draw, triggers collection cycles that appear as periodic stutter even when average frame time looks acceptable.
Unbalanced graphics stack: A love.graphics.push() without a matching pop() leaks transform and color state into the next frame. The symptom is often a screen that slowly drifts or tints incorrectly.
Draw-time mutation: Changing gameplay state inside love.draw breaks the contract. Update and draw disagree on the world, making replays, netcode, and deterministic tests impossible.
Global leakage: Forgetting the local keyword creates a global variable. It is invisible in the file where it is used and visible everywhere else, which is the exact opposite of a data boundary.
Conditions That Would Change the Design
The single-table pattern is a starting point, not a universal law. Change it when these conditions appear.
| Condition | Design Shift |
|---|---|
| Asset loading or heavy computation blocks the frame loop | Move work to a love.thread and pass results through a Channel. The boundary becomes serialization at the thread edge, not a shared table. |
| Multiple screens or menus need independent lifecycles | Introduce a scene stack or state machine. Each scene owns its own update and draw, and the Game table holds only shared services. |
| Physics requires deterministic steps | Add a fixed-timestep accumulator inside love.update. The simulation rate decouples from the render rate. |
| Entity count grows large enough that table lookups dominate | Adopt a data-oriented layout. The boundary shifts from object tables to contiguous arrays or an entity-component-system. |
Limitations and Final Check
This pattern does not enforce encapsulation; it only makes the boundary visible. LÖVE callbacks are not reentrant, so you cannot safely mutate graphics state from a thread. Also, push and pop cover transforms and color, but confirm which state they preserve in your target version—scissor rectangles and active shaders may require separate management.
To check the result, run the project and hold the left or right key. The square should move smoothly and cross the same distance in the same time regardless of vsync. Draw a second, untransformed rectangle at the origin. If it stays at the origin after the transformed draw, your stack is balanced. Watch memory with a once-per-second collectgarbage("count") print during profiling; a flat line means your update and draw loops are not churning garbage.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.