LÖVE2D anim8 sprite animation: a code-first walkthrough with the aseprite-json adapter
A working LÖVE 11.x animation pipeline using anim8, Aseprite-exported JSON, and the monorepo scaffold template. Real code, real file paths, zero ceremony.
Most anim8 tutorials stop at g('1-4', 1) and call it done. That works for a 4-frame walk strip, but the moment you open Aseprite, add a run tag, a hurt tag, variable frame durations, and a second layer, the hard-coded grid approach collapses. This walkthrough builds the pipeline the project scaffold in toolers/tooler-core/ actually emits: export once from Aseprite, parse the JSON, drive anim8 from tag metadata, and never touch frame indices by hand again.
Everything below runs on LÖVE 11.5 with anim8 by kikito and Aseprite 1.3. Code paths reference the monorepo at github.com/hglong16/tinktink.
Why the Aseprite JSON matters
anim8 only knows about grids. You tell it "frames 1 through 4 of row 1," and it cycles through them at a constant duration. Aseprite, in contrast, stores:
- Per-frame durations (a slow wind-up frame followed by a fast strike frame).
- Named animation tags (
idle,run,attack,hurt). - Tag direction (forward, reverse, pingpong).
- Layer and slice metadata.
If you hand-transcribe that into Lua tables, every artist iteration becomes a code change. The fix is the adapter pattern: export the spritesheet PNG and the JSON sidecar from Aseprite, then translate the JSON into anim8 quads at load time.
This is exactly what the scaffold_love_project tool in toolers/tooler-core/app/tools/scaffold_love_project.py emits when you run /create-game-project from the Mac Agent. The template ships with lib/aseprite.lua pre-wired. What follows is a rebuild of that adapter from first principles so you understand what the generator is doing.
Export settings in Aseprite
Before any Lua runs, the JSON has to be shaped correctly. In Aseprite: File → Export Sprite Sheet. Then in the Output tab:
- Check JSON Data.
- Set Array for the data format. The
Hashoption keys frames by filename, which is fine but annoying to iterate in Lua. - Check Meta: Tags. Without this, your
runandidletags never make it into the file. - Optionally check Meta: Slices if you use 9-slice UI.
The result is two files: hero.png (the packed spritesheet) and hero.json (the metadata). Drop both into assets/sprites/ in your LÖVE project.
An Array-format frame entry looks like this:
{
"filename": "hero 0.ase",
"frame": { "x": 0, "y": 0, "w": 32, "h": 32 },
"duration": 120
}
Durations are in milliseconds. anim8 wants seconds. That conversion is the first thing the adapter does.
The adapter — lib/aseprite.lua
Create lib/aseprite.lua in your project. This is a condensed version of what the monorepo scaffold writes to toolers/tooler-core/app/tools/templates/love_project/lib/aseprite.lua.
local json = require("lib.json")
local anim8 = require("lib.anim8")
local M = {}
local function read_json(path)
local contents, size = love.filesystem.read(path)
assert(contents, "failed to read " .. path)
return json.decode(contents)
end
function M.load(image_path, json_path)
local image = love.graphics.newImage(image_path)
image:setFilter("nearest", "nearest")
local data = read_json(json_path)
local frames = data.frames
local tags = data.meta.frameTags or {}
local quads = {}
local durations = {}
for i, f in ipairs(frames) do
quads[i] = love.graphics.newQuad(
f.frame.x, f.frame.y, f.frame.w, f.frame.h,
image:getDimensions()
)
durations[i] = f.duration / 1000
end
local animations = {}
for _, tag in ipairs(tags) do
local from, to = tag.from + 1, tag.to + 1
local tag_quads = {}
local tag_durations = {}
for i = from, to do
table.insert(tag_quads, quads[i])
table.insert(tag_durations, durations[i])
end
if tag.direction == "reverse" then
local reversed = {}
for i = #tag_quads, 1, -1 do table.insert(reversed, tag_quads[i]) end
tag_quads = reversed
end
animations[tag.name] = anim8.newAnimation(tag_quads, tag_durations)
end
return {
image = image,
animations = animations,
play = function(self, name)
assert(self.animations[name], "unknown animation: " .. name)
self.current = self.animations[name]:clone()
self.current_name = name
end,
update = function(self, dt)
if self.current then self.current:update(dt) end
end,
draw = function(self, x, y, flip)
if not self.current then return end
self.current:draw(self.image, x, y, 0, flip or 1, 1)
end,
}
end
return M
Two details worth flagging. First, tag.from + 1 — Aseprite is zero-indexed, Lua is one-indexed, and this off-by-one is the single most common reason anim8 renders the wrong frame. Second, anim8.newAnimation accepts a table of per-frame durations as its second argument, not just a single number. That is how the millisecond-to-second conversion flows cleanly through.
The pingpong direction is left as an exercise. Build the forward array, then append the reverse array minus its first and last entries to avoid double-holds on the endpoints. anim8 does not natively understand pingpong; you fake it by duplicating frames.
Wiring it into main.lua
local aseprite = require("lib.aseprite")
local hero
function love.load()
love.graphics.setDefaultFilter("nearest", "nearest")
hero = aseprite.load("assets/sprites/hero.png", "assets/sprites/hero.json")
hero:play("idle")
end
function love.update(dt)
hero:update(dt)
if love.keyboard.isDown("right") then
if hero.current_name ~= "run" then hero:play("run") end
elseif hero.current_name ~= "idle" then
hero:play("idle")
end
end
function love.draw()
love.graphics.push()
love.graphics.scale(4, 4)
hero:draw(100, 100)
love.graphics.pop()
end
That is the entire player-animation system. No frame indices, no magic numbers, no quad math. Every time the artist re-exports the Aseprite file, the code picks up the new timings and the new tags automatically.
Dependencies
Your lib/ folder needs three files:
anim8.luafrom kikito/anim8. Single file, MIT, drop it in.- A JSON decoder. rxi/json.lua is the community standard and also single-file.
aseprite.luafrom above.
The monorepo scaffold vendors all three under toolers/tooler-core/app/tools/templates/love_project/lib/. If you are using the scaffold, /create-game-project drops these in automatically. If you are bootstrapping by hand, the two GitHub links above are the canonical sources.
Switching animations mid-frame: the clone gotcha
Notice self.current = self.animations[name]:clone() in the adapter. anim8 animation objects carry mutable state — current frame index, elapsed time, flipped flags. If you store a single animation in animations.run and assign it directly to self.current, then every entity sharing that adapter will advance the same underlying animation. Four enemies running out of sync becomes four enemies locked in lockstep.
:clone() is cheap (it copies the metadata table and resets the timer) and eliminates this class of bug entirely. The LÖVE wiki page on sharing animations does not mention this because the wiki's example uses a single sprite. Every multi-entity game hits it eventually.
Testing without a display server
This is where the monorepo diverges from typical LÖVE tutorials. CI builds run headless, so you cannot spin up love . in GitHub Actions. The scaffold's answer is to extract the adapter logic into a pure-Lua module with no LÖVE dependencies at load time, then write unit tests against it using busted.
The adapter above fails this cleanly — love.filesystem.read and love.graphics.newImage are called inside M.load. For testability, split it: a parse(json_string, image_dims) function that returns a table of { tag_name, frames, durations }, and a load(paths) wrapper that does the I/O and calls parse. Tests target parse. See scripts/test_check_nested.py in the monorepo for the Python equivalent pattern applied to the AST linter — same shape, different language.
Why not use a higher-level library?
You could reach for Sodapop or wrap everything in an ECS. For a 2-entity jam game, the adapter above is 50 lines and does the job. For a 50-entity production, the pattern still holds; you just build a pool of pre-cloned animations keyed by entity type and reuse from the pool instead of cloning per spawn. The Aseprite JSON never changes shape, so the parse layer stays identical.
The monorepo scaffold picks the small-library path intentionally. Every dependency is one file, auditable in 10 minutes, and the artist pipeline is export from Aseprite → git commit → reload game. No build step, no asset bundler, no runtime codec.
That is the whole pipeline. Export the JSON, parse once, clone per entity, and let the artist iterate without touching Lua. Every future animation — jump, slide, parry — is a new tag in Aseprite and zero lines of new code.