Drawing Animation and Sprites in LOVE 2D
Drawing animation and sprite rendering are the visual backbone of any 2D game, and LOVE 2D gives you a solid set of primitives for both. At the heart of every 2D game in LOVE are a few key ideas: loading images, cutting out rectangles from a sprite sheet using Quads, and using those Quads in the draw call. This tutorial covers all three, plus the full set of draw parameters for positioning, rotating, and scaling your sprites.
Prerequisites
You need LOVE 2D installed and a basic project set up with a main.lua file. You should also be comfortable with the LOVE game loop callbacks: love.load, love.update, and love.draw. If any of that sounds unfamiliar, start with the Getting Started with LOVE 2D tutorial first. A basic understanding of Lua tables and loops will help with the frame animation examples.
Loading images
You load an image with love.graphics.newImage. Pass in a file path relative to your game directory, and it returns an Image object you can draw each frame.
function love.load()
playerImage = love.graphics.newImage("player.png")
end
function love.draw()
love.graphics.draw(playerImage, 100, 100)
end
LOVE supports PNG, JPG, GIF, BMP, TGA, and HDR image formats. For sprites with transparency, PNG is the right choice because it preserves the alpha channel that LOVE uses for blending during draw operations. Bear in mind that images consume video memory on the GPU, so loading hundreds of large textures can exhaust the available budget on lower-end hardware or integrated graphics chips.
The basic loading approach above is fine for development, but it will crash your game if an asset file is missing or corrupt. During prototyping you might want to handle missing files gracefully so a single bad asset does not stop the whole program. Wrapping the load in pcall catches the error and lets you log a useful message instead of a stack trace. Note that pcall returns the error as its second return value when the call fails, so the tostring(img) call is necessary to convert any error object into a printable string for your error handler.
function love.load()
local ok, img = pcall(love.graphics.newImage, "player.png")
if not ok then
error("player.png not found: " .. tostring(img))
end
playerImage = img
end
Quads: slicing up a sprite sheet
A Quad is a rectangle carved out of a larger texture. You create one with love.graphics.newQuad, which takes the pixel coordinates of the rectangle and the full image dimensions of the source image. Quads are how LOVE lets you pick individual frames from a sprite sheet without splitting the sheet into separate files.
The signature is:
love.graphics.newQuad(x, y, width, height, imageWidth, imageHeight)
x and y specify the top-left corner inside the source image, measured in absolute pixels. The width and height parameters determine how much of the image is captured by the quad, and the last two arguments are the full dimensions of the source image. Always pass image:getWidth() and image:getHeight() for these final two arguments rather than hardcoding numbers. If you swap the sprite sheet for a larger version later, the Quads will break silently when you used literal values that no longer match the new texture size. The quad itself is lightweight and does not copy pixel data out of the image, so you can safely create as many as you need without worrying about memory.
One detail that trips people up: Quad coordinates are in pixels, not normalized values like the 0-1 UV coordinates that some other engines use. LOVE requires absolute pixel positions inside the source image because it operates at the texture level. This means you need to know the exact frame size and layout of your sprite sheet to build quads correctly. The following loop creates five frames from a single-row sprite sheet, assuming each frame is 64 pixels wide and 64 pixels tall.
function love.load()
spriteSheet = love.graphics.newImage("player.png")
local iw, ih = spriteSheet:getWidth(), spriteSheet:getHeight()
local fw, fh = 64, 64
frames = {}
for i = 0, 4 do
table.insert(frames, love.graphics.newQuad(i * fw, 0, fw, fh, iw, ih))
end
end
Frame-by-frame animation
The standard approach is to pre-build a table of Quads, one per frame, then cycle through them in the update loop. By separating the frame data (the Quads) from the animation logic (the timer), you can reuse the same sprite sheet for multiple animated entities without duplicating any pixel data.
function love.load()
spriteSheet = love.graphics.newImage("player.png")
local iw, ih = spriteSheet:getWidth(), spriteSheet:getHeight()
local fw, fh = 64, 64
frames = {}
for i = 0, 4 do
table.insert(frames, love.graphics.newQuad(i * fw, 0, fw, fh, iw, ih))
end
currentFrame = 1
fps = 10
end
function love.update(dt)
currentFrame = currentFrame + dt * fps
if currentFrame > #frames then
currentFrame = currentFrame - #frames
end
end
function love.draw()
local frame = math.floor(currentFrame)
love.graphics.draw(spriteSheet, frames[frame], 300, 200)
end
currentFrame is a float, and on each call to love.update(dt) you add dt * fps to it. When selecting which Quad to draw, you floor the float to get an integer index into the frames table. This technique gives you smooth, consistent animation timing regardless of whether your game runs at 30 or 144 frames per second. The float accumulates fractional progress so no frames are ever skipped, and the floor operation picks the best discrete frame for each render pass.
For multi-row sprite sheets you need to convert a frame index into row and column offsets. Think of the sprite sheet as a grid with a fixed number of columns per row. Divide the frame index by the column count to get the row, and use modulo to get the column. For example, with 5 columns per row and a frame index of 7, you get row 1 and column 2. This nested loop approach handles sprite sheets of any dimension without hardcoding individual frame positions, which is essential when you are working with character spritesheets that can easily span multiple rows for different animation states like idle, walk, and attack.
local cols = 5
local rows = 2
local fw, fh = 64, 64
for row = 0, rows - 1 do
for col = 0, cols - 1 do
local idx = row * cols + col
table.insert(frames, love.graphics.newQuad(col * fw, row * fh, fw, fh, iw, ih))
end
end
The draw parameters
love.graphics.draw accepts a long list of parameters beyond just the drawable and position. Mastering these extra arguments lets you rotate projectiles to face their direction, scale sprites for parallax depth effects, and flip character sprites to face left or right without loading mirrored image files.
The full signature is:
love.graphics.draw(drawable, quad, x, y, r, sx, sy, ox, oy, kx, ky)
- drawable: the Image, Canvas, SpriteBatch, or Mesh to draw
- quad: optional Quad to draw only a region of the texture
- x, y: screen position in pixels (default 0, 0)
- r: rotation in radians (default 0)
- sx, sy: scale on x and y axes (default 1, 1)
- ox, oy: origin offset for rotation and scaling (default 0, 0)
- kx, ky: shearing factors (default 0, 0)
Rotation and origin
When you rotate a sprite around a point other than (0, 0), you set the origin offset. The origin is measured in the sprite’s own coordinate space before scaling is applied. This means if you want to rotate a 64x64 sprite around its center, set ox = 32, oy = 32.
function love.draw()
love.graphics.draw(playerImage, 300, 200, math.pi / 4, 1, 1, 32, 32)
end
A common mistake is scaling first and then trying to compute the origin from the scaled size. The origin should use the original dimensions, not the result after scaling.
Flipping
You can flip a sprite horizontally or vertically by using a negative scale:
love.graphics.draw(spriteSheet, frames[frame], x, y, 0, -1, 1)
Use -1 for scale on an axis to flip without resizing. This is simpler than it sounds because the image dimensions do not change; only the draw direction reverses.
SpriteBatch for many sprites
If you are drawing dozens of the same sprite each frame (particles, bullets, tile layers), calling love.graphics.draw individually is wasteful. love.graphics.newSpriteBatch batches them into a single draw call.
function love.load()
spriteSheet = love.graphics.newImage("particle.png")
batch = love.graphics.newSpriteBatch(spriteSheet, 200, "dynamic")
end
function love.draw()
batch:clear()
for _, p in ipairs(particles) do
batch:add(p.x, p.y)
end
love.graphics.draw(batch, 0, 0)
end
batch:add(x, y) returns an internal index you can store to update or remove individual sprites later with batch:set(index, x, y). The “dynamic” flag tells LOVE you will be modifying the batch each frame. If the batch contents never change, use “static” instead.
For a tile map where every tile comes from the same sprite sheet, this approach eliminates dozens of draw calls and pushes everything through a single render pass.
Common gotchas
Creating Quads inside love.draw() is a performance trap. Quads are cheap to create once and reuse, but allocating them every frame adds garbage collector pressure. Build all your Quads in love.load() and reuse them.
Image wrap mode defaults to clamp, which stretches edge pixels when you draw at non-integer sizes or when using UV coordinates outside [0, 1]. For tiled textures, set image:setWrap("repeat"). This does not affect sprite sheet Quads, but it matters for backgrounds and tilemap layers.
Color state persists in LOVE. After calling love.graphics.setColor(r, g, b, a), it stays that way for all subsequent draw calls until you change it again. Forgetting this leads to sprites tinted the wrong colour. Reset to white (1, 1, 1, 1) when you are done if you want predictable behaviour.
love.graphics.getWidth() inside love.draw() is fine to call, but if you are doing it every frame for the same value, cache it once in love.load() and reuse it:
function love.load()
screenW = love.graphics.getWidth()
screenH = love.graphics.getHeight()
end
Next steps
With drawing and animation under your belt, the natural next step is making your sprites interact. Head over to Collision Detection in LOVE 2D to learn how to detect when sprites bump into each other. If you are building a tile-based game, the Tilemaps in LOVE 2D tutorial shows how to combine SpriteBatches with tile grids for efficient level rendering.
See Also
- Getting Started with LOVE 2D: installation, game loop, and first shapes
- Collision Detection in LOVE 2D: bounding boxes and spatial queries
- Tilemaps in LOVE 2D: tile maps and SpriteBatches together
- Game States in LOVE 2D: organizing game screens with state machines