Batching Static Sprites in LÖVE 11: Fewer Draw Calls, Same Pixels
Collapse hundreds of per-sprite draw calls into one with LÖVE's SpriteBatch: build a static tile batch, pick the right usage hint, and verify the win with love.graphics.getStats().
14 Mar 2026, 13:31 UTC

If your LÖVE game draws hundreds of tiles, background decorations, or repeated entities with individual love.graphics.draw calls, you're paying one draw call per sprite. A SpriteBatch collapses all of those into a single GPU draw call, which is often the cheapest performance win available in a 2D game. This guide builds a working tile batch, shows how to verify it actually helped, and covers the one trap that makes batches slower than the naive approach.
When a SpriteBatch is the right tool
A SpriteBatch stores transformed copies of sprites from a single image (usually a tile atlas) and submits them together. It pays off when:
- You draw many sprites that share one texture.
- Their positions change rarely (a static map layer) or in bulk (you rebuild the batch anyway).
It is the wrong tool when sprites come from many different textures (one batch = one texture) or when every sprite moves independently every frame — rebuilding the batch each frame can cost more than the draw calls you saved.
Prerequisites
- LÖVE 11.0 or later (the API described here, including usage hints, applies to 11.x; check
love.getVersion()if unsure). - A single image or texture atlas containing your sprite frames.
- Familiarity with
love.load,love.update, andlove.draw.
Building the batch
The following main.lua renders a 40×25 grid of tiles (1000 sprites) as one draw call. Run it with love . from the project directory — no special permissions needed.
local batch
local atlas
local quad
function love.load()
-- Any 32x32 image works; replace with your own atlas.
atlas = love.graphics.newImage("tile.png")
-- A Quad selects a sub-rectangle of the atlas. Skip this and pass
-- no quad to batch:add if you want the whole image per sprite.
quad = love.graphics.newQuad(0, 0, 32, 32,
atlas:getWidth(), atlas:getHeight())
-- bufferSize: max sprites the batch can hold (can grow later).
-- "static": we will fill it once and never change it.
batch = love.graphics.newSpriteBatch(atlas, 1000, "static")
for ty = 0, 24 do
for tx = 0, 39 do
batch:add(quad, tx * 32, ty * 32)
end
end
end
function love.draw()
love.graphics.draw(batch)
endKey points:
batch:add(quad, x, y, r, sx, sy, ox, oy, kx, ky)takes the same transform arguments aslove.graphics.drawand returns an id you can later pass tobatch:set(id, ...)to update that sprite in place.- The batch ignores the current color set via
love.graphics.setColorat add time in some versions — set color before drawing the batch, and test tinting explicitly if you rely on it. - Color and transform are baked in per sprite at add time; you can't move one sprite without
setor a rebuild.
Choosing the usage hint
The third argument to newSpriteBatch tells the GPU driver how you plan to update the buffer:
| Hint | Use when | Cost profile |
|---|---|---|
static | Filled once (map layers, backgrounds) | Cheapest per frame; expensive to modify |
dynamic | Updated occasionally via batch:set | Moderate upload cost on change |
stream | Cleared and refilled every frame | Upload every frame — often no better than plain draws |
If you find yourself calling batch:clear() and re-adding everything inside love.update, stop and measure — for fully dynamic scenes, individual draws with texture atlasing may be simpler and just as fast.
Verifying the win
Don't assume the batch helped — measure it. LÖVE exposes renderer statistics:
function love.draw()
love.graphics.draw(batch)
local stats = love.graphics.getStats()
love.graphics.print(
("drawcalls: %d sprites in batch: %d fps: %d")
:format(stats.drawcalls, batch:getCount(), love.timer.getFPS()),
4, 4)
endExpected checks:
batch:getCount()equals 1000 (or your grid size). If it's lower, someaddcalls silently didn't happen — check your loop bounds and that the buffer size is sufficient.stats.drawcallsshould be a small constant (typically low single digits for this scene) instead of scaling with sprite count. Compare against a version that draws each tile individually in a loop.- Visually confirm every tile renders at the right position with no gaps or flicker.
Note that getStats().drawcalls counts internal renderer flushes, not strictly one per love.graphics.draw call, so compare relative numbers between the two approaches rather than expecting an exact figure.
Changing sprites after creation
Batches are effectively immutable per sprite unless you keep the id returned by add:
local id = batch:add(quad, x, y)
-- later, e.g. to animate or move that one sprite:
batch:set(id, quad, newX, newY)For bulk changes, batch:clear() then re-add. With a static hint this re-uploads the whole buffer, so reserve it for discrete events (map edit, level load) rather than per-frame updates.
Limitations to keep in mind
- One texture per batch. Multiple atlases mean multiple batches (still fine — a handful of draw calls).
- All sprites in a batch share one draw, so you can't interleave them with other draw calls for depth ordering. Split batches per layer if you need z-ordering against other graphics.
- Behavior described here targets LÖVE 11.x; LÖVE 12 changed parts of the graphics API, so verify against the 12.x wiki if you've upgraded.
The practical takeaway: batch anything numerous, same-texture, and mostly stationary; measure with getStats(); and reach for set/clear only as often as your usage hint says you should.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.