luaguides

Drawing and Animation in LOVE 2D

LOVE 2D gives you a solid set of primitives for drawing images and animating them. 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 article covers all three, plus the full set of draw parameters for positioning, rotating, and scaling your sprites.

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. For sprites with transparency, PNG is the right choice.

If the image fails to load, LOVE throws an error with the file path. During development you might want to handle missing files gracefully so a single bad asset does not crash the whole game. Wrapping the load in pcall catches the error and lets you log a useful message instead of a stack trace.

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 texture. You create one with love.graphics.newQuad, which takes the pixel coordinates of the rectangle and the full image dimensions.

The signature is:

love.graphics.newQuad(x, y, width, height, imageWidth, imageHeight)

x and y are the top-left corner inside the source image. width and height are the dimensions of the region. The last two arguments are the full size of the source image (pass image:getWidth() and image:getHeight() for these).

One detail that trips people up: Quad coordinates are in pixels, not normalized values. Some engines use 0-1 UV coordinates, but LOVE uses absolute pixel positions inside the source image. 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.

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. Each frame in love.update(dt) you add dt * fps to it, then floor it when selecting which Quad to draw. This gives smooth, consistent animation timing regardless of your framerate.

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 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. Here is how that looks in code.

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:

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

See Also