LÖVE2D + anim8 Sprite Animation: A Code-First Walkthrough Using the Monorepo Aseprite-JSON Adapter
Wire Aseprite spritesheet exports into a LÖVE2D game using anim8, with a working adapter pattern drawn from the black_hat_rust monorepo's M28 scaffold template.
Sprite animation in LÖVE2D looks trivial until you actually ship a character with four directions, three states, and variable frame timing. The demos on the LÖVE wiki stop at love.graphics.draw(quad, x, y). The real problem is the pipeline: how does a 4x12 spritesheet exported from Aseprite become a player:update(dt) call that plays the correct frames without you hand-coding every quad offset?
This walkthrough builds that pipeline using anim8 plus a small adapter that reads Aseprite's native JSON export. The adapter pattern comes straight from the scaffold_love_project template shipped in the monorepo at toolers/tooler-core/ (see the M28 scaffold spec committed on 2026-04-23). If you want to follow along against the reference implementation, the template lives in the tinktink repo: https://github.com/hglong16/tinktink.
Why anim8 and not raw quads
LÖVE's Quad API is fine for one-shot sprites. Once you need state machines (idle / walk / attack / hurt) with different frame counts and durations per state, you will reinvent a bad version of anim8. Kikito's anim8 is the de-facto animation library in the LÖVE ecosystem for a reason: it handles grids, frame ranges, flipping, onLoop callbacks, and variable durations in roughly 400 lines of Lua.
The friction point is that anim8 expects you to describe your grid in code:
local g = anim8.newGrid(32, 32, image:getWidth(), image:getHeight())
local walk = anim8.newAnimation(g('1-4', 2), 0.1)
That works for hand-crafted sheets. It breaks the moment an artist re-exports from Aseprite with a different frame count, adds a new tag, or changes the canvas size. You end up with magic numbers sprinkled across every entity file. The fix is to treat Aseprite's JSON export as the source of truth and generate anim8 animations at load time.
Aseprite's JSON export format
When you export a spritesheet from Aseprite with the JSON data option, you get two files: a PNG and a JSON. The JSON has two sections that matter:
frames— an array (or object, depending on export config) where each entry hasframe: {x, y, w, h}anddurationin milliseconds.meta.frameTags— animation ranges tagged in the Aseprite timeline, each withname,from,to, anddirection.
The monorepo's pixel-plugin MCP shipped in M28 T0 emits this format by default. The spike notes captured in docs/tooler-core/ confirmed that export_spritesheet always emits Aseprite-native JSON with no format selector, which simplifies the parser because we only need to handle one shape. If you are exporting by hand from Aseprite 1.3, set the Output data format to Array and you get the same structure.
Here is a trimmed example of what the JSON looks like for a 4-frame walk cycle:
{
"frames": [
{ "filename": "walk 0.ase", "frame": { "x": 0, "y": 0, "w": 32, "h": 32 }, "duration": 120 },
{ "filename": "walk 1.ase", "frame": { "x": 32, "y": 0, "w": 32, "h": 32 }, "duration": 120 },
{ "filename": "walk 2.ase", "frame": { "x": 64, "y": 0, "w": 32, "h": 32 }, "duration": 140 },
{ "filename": "walk 3.ase", "frame": { "x": 96, "y": 0, "w": 32, "h": 32 }, "duration": 120 }
],
"meta": {
"image": "player.png",
"size": { "w": 128, "h": 32 },
"frameTags": [
{ "name": "walk", "from": 0, "to": 3, "direction": "forward" }
]
}
}
Notice that durations are per-frame and in milliseconds. anim8 wants seconds, either as a single number or a table matching the frame count. That mismatch is the first thing the adapter has to fix.
The adapter
Drop this into src/lib/aseprite.lua in your LÖVE project. It has no external dependencies beyond anim8 and LÖVE's built-in love.filesystem plus a JSON decoder. I am using rxi/json.lua because it is one file and ships with the M28 scaffold.
local anim8 = require("lib.anim8")
local json = require("lib.json")
local Aseprite = {}
Aseprite.__index = Aseprite
function Aseprite.load(json_path, image_path)
local raw = love.filesystem.read(json_path)
assert(raw, "aseprite json not found: " .. json_path)
local data = json.decode(raw)
local self = setmetatable({}, Aseprite)
self.image = love.graphics.newImage(image_path)
self.image:setFilter("nearest", "nearest")
self.frames = data.frames
self.tags = {}
self.animations = {}
for _, tag in ipairs(data.meta.frameTags or {}) do
self.tags[tag.name] = tag
end
return self
end
function Aseprite:animation(tag_name)
if self.animations[tag_name] then
return self.animations[tag_name]
end
local tag = self.tags[tag_name]
assert(tag, "unknown tag: " .. tag_name)
local quads = {}
local durations = {}
for i = tag.from + 1, tag.to + 1 do
local f = self.frames[i].frame
table.insert(quads, love.graphics.newQuad(
f.x, f.y, f.w, f.h,
self.image:getDimensions()
))
table.insert(durations, self.frames[i].duration / 1000)
end
local anim = anim8.newAnimation(quads, durations)
if tag.direction == "pingpong" then
anim = anim:clone()
anim.flippedH = false
end
self.animations[tag_name] = anim
return anim
end
return Aseprite
Two non-obvious things are happening here. First, anim8's newAnimation accepts a raw list of quads instead of a grid expression, which is how you sidestep the "every frame must be the same size on a uniform grid" assumption. Second, the ping-pong direction from Aseprite does not map cleanly onto anim8's position modes; if you need true ping-pong you will clone the animation and manage the direction flip in your update loop. For the 95% case (forward loops), this is enough.
Wiring it into a player entity
Your player module now looks like a real state machine instead of a pile of quad math:
local Aseprite = require("lib.aseprite")
local Player = {}
Player.__index = Player
function Player.new(x, y)
local self = setmetatable({}, Player)
self.x, self.y = x, y
self.sheet = Aseprite.load("assets/player.json", "assets/player.png")
self.state = "idle"
self.anims = {
idle = self.sheet:animation("idle"),
walk = self.sheet:animation("walk"),
hurt = self.sheet:animation("hurt"),
}
return self
end
function Player:setState(name)
if self.state == name then return end
self.state = name
self.anims[name]:gotoFrame(1)
end
function Player:update(dt)
self.anims[self.state]:update(dt)
end
function Player:draw()
self.anims[self.state]:draw(self.sheet.image, self.x, self.y)
end
return Player
setState guards against restarting the animation on every frame a key is held, which is a bug every LÖVE tutorial reproduces. Re-entering a state mid-animation and calling gotoFrame(1) makes the walk cycle look like it is stuttering; the guard clause is one line and you will thank yourself later.
Hot-reload during development
LÖVE has no native hot-reload, but you can fake it for the JSON side cheaply:
function love.keypressed(key)
if key == "f5" then
package.loaded["lib.aseprite"] = nil
Aseprite = require("lib.aseprite")
player.sheet = Aseprite.load("assets/player.json", "assets/player.png")
end
end
This only reloads the adapter and the sheet data, not the running animations themselves. For full hot-reload including state, see lurker which watches the filesystem. Lurker is vendored in the M28 scaffold template alongside anim8, so new projects generated via the scaffold_love_project tool get it for free.
Gotchas worth internalizing
A few things the LÖVE forums argue about that are actually settled once you look at the code:
-
Frame durations in Aseprite are per-frame, not per-tag. If your artist wants the hurt animation to linger on frame 3, they set it in Aseprite, not in Lua. Respect that by reading the per-frame
durationfield, not by computing an average and passing a single number to anim8. -
image:setFilter("nearest", "nearest")matters. Without it, any non-integer position (which happens the moment you add velocity) produces a blurred sprite. The adapter sets this at load time so you cannot forget. -
Do not share Quad objects across animations that use different source images. Each Quad is tied to the image's dimensions at creation time via the last two arguments to
newQuad. If you reuse a quad against a different sheet, you get UV coordinates that go outside the texture and render garbage. -
Aseprite tag indices are zero-based; Lua is one-based. The
+ 1offset in the adapter's loop is the entire reason this is a library and not inline code. Miss it once and every animation is shifted by a frame.
Where this fits in the larger pipeline
For context, the adapter above is one piece of the M28 scaffold template committed to toolers/tooler-core/ in the monorepo (see the scaffold spec and the create_game_project tool). When you run the scaffolder, it generates main.lua, conf.lua, a lib/ directory with anim8, json.lua, and this adapter pre-wired, and a stub assets/ layout that matches the JSON schema the pixel-plugin MCP emits. The end-to-end loop — Aseprite edit → PNG+JSON export → LÖVE hot-reload — takes under 300 milliseconds on an M1, which is fast enough that you can tune a walk cycle interactively.
The full scaffold spec and the adapter's test fixtures live in the monorepo; the entry point for the scaffold tool is at toolers/tooler-core/ per the M28 T4 artifacts. If you want to crib the adapter without the rest of the scaffolder, copy the code block above verbatim, drop anim8 and json.lua alongside it, and you have a working pipeline in under 100 lines.
Sprite animation in LÖVE stops being a content-pipeline problem once you let Aseprite own the frame data. The code you write is about state transitions and game feel, which is where it should have been all along.