LÖVE2D anim8 sprite animation tutorial with the monorepo aseprite-json adapter
A code-first walkthrough of wiring anim8 animations in LÖVE2D using Aseprite JSON spritesheets, with the exact adapter path used in the tinktink monorepo scaffold.
If you have ever tried to animate a sprite in LÖVE2D by hand-rolling quad offsets and frame counters, you already know the two places it breaks. The art pipeline changes (a new frame is inserted, a tag is renamed) and the runtime code silently plays the wrong slice. The second break is worse: the sprite visually "works" but drifts by one frame, which nobody catches until a playtester points at a walk cycle that stutters on the left leg.
This walkthrough fixes both by treating Aseprite as the source of truth, anim8 as the playback engine, and a tiny adapter as the glue between them. The adapter lives in the tinktink monorepo under packages/ so it can be shared across every LÖVE game the scaffold generates (see toolers/tooler-core/workflows/tooler-core/PLAN.md milestone M28 for how the game scaffold was introduced, and github.com/hglong16/tinktink for the public mirror).
Why anim8 plus Aseprite JSON, not raw quads
anim8 by kikito is the de facto animation library for LÖVE. It gives you grids, frame ranges, flipped variants, and pauseAtStart / onLoop hooks. The official docs are at https://github.com/kikito/anim8 and the LÖVE wiki entry is at https://love2d.org/wiki/Tutorial:Animation.
On its own anim8 expects a regular grid plus frame ranges like '1-8', 2. That is fine for a single static spritesheet, but it is wrong for the way artists actually work in Aseprite. Aseprite exports a JSON sidecar next to the PNG that carries:
- Per-frame source rectangles (so trimmed frames still render correctly).
- Per-frame duration in milliseconds (so a 6-frame attack can have a long windup frame and fast impact frames).
frameTagswith name, from, to, and direction (forward,reverse,pingpong).
If you ignore the JSON and type frame ranges by hand, every re-export from the artist is a manual reconciliation. The adapter closes that gap.
The aseprite-json adapter, one file
In the monorepo this sits at packages/love-shared/aseprite.lua. It is pure Lua, no dependencies beyond LÖVE's built-in love.filesystem and love.graphics. The entire thing is under 80 lines.
-- packages/love-shared/aseprite.lua
local anim8 = require("libs.anim8")
local json = require("libs.json")
local M = {}
local function load_json(path)
local raw, err = love.filesystem.read(path)
assert(raw, "aseprite json missing: " .. tostring(path) .. " / " .. tostring(err))
return json.decode(raw)
end
-- Build anim8 animations from an Aseprite JSON "hash" export.
-- Returns: { image = Image, anims = { [tag_name] = anim8.animation } }
function M.load(image_path, json_path)
local image = love.graphics.newImage(image_path)
image:setFilter("nearest", "nearest") -- pixel art, never blur
local data = load_json(json_path)
local frames = data.frames -- hash: { ["sprite 0"] = { frame={x,y,w,h}, duration=100 }, ... }
local meta = data.meta
local tags = meta.frameTags or {}
-- Aseprite hash keys aren't ordered, so sort by the numeric suffix.
local ordered = {}
for key, f in pairs(frames) do
local idx = tonumber(key:match("(%d+)%s*$")) or #ordered
ordered[idx + 1] = { key = key, frame = f.frame, duration = f.duration / 1000 }
end
local anims = {}
for _, tag in ipairs(tags) do
local quads = {}
local durations = {}
for i = tag.from + 1, tag.to + 1 do
local entry = ordered[i]
assert(entry, "missing aseprite frame " .. tostring(i) .. " for tag " .. tag.name)
local fr = entry.frame
quads[#quads + 1] = love.graphics.newQuad(fr.x, fr.y, fr.w, fr.h,
image:getDimensions())
durations[#durations + 1] = entry.duration
end
local direction = tag.direction == "reverse" and "reverse" or "forward"
local anim = anim8.newAnimation(quads, durations)
if direction == "reverse" then anim:flipH() end -- tag dir only, not sprite flip
anims[tag.name] = anim
end
return { image = image, anims = anims }
end
return M
Two details matter here and are easy to get wrong.
First, Aseprite exports durations in milliseconds, but anim8.newAnimation expects seconds. Dividing by 1000 at load time is the single correct place to do the conversion. Doing it later means every caller has to remember, and one of them will forget.
Second, the JSON "hash" export uses string keys like "player 0", "player 1", ... with no guarantee of iteration order. Lua's pairs is explicitly unordered. Parsing the trailing integer with key:match("(%d+)%s*$") and rebuilding a dense array is the fix. If you ever see the first frame of your walk cycle appear in the middle of the run cycle, this is why.
The artist workflow, for real
The adapter is only useful if the export side is disciplined. In Aseprite:
- Name every tag. Unnamed tags become
frameTags[i].name = ""and will collide when the adapter uses the name as a table key. - Export as "Hash" (not "Array"). The adapter above assumes hash; switching modes changes
data.framesfrom an object to an array and breaks the loop. If you prefer array, swap thepairsblock for a plainipairs. - Check "JSON Data" and "Image" in the export dialog. Keep the two filenames identical except for the extension:
player.png+player.json. - Do not check "Trim". Trimming changes per-frame offsets and
anim8does not applyspriteSourceSizecorrections. If you need trimming to save atlas space, use theframe.spriteSourceSizefield and draw at that offset yourself. For a small game, untrimmed is fine.
The Aseprite CLI reference for these flags is at https://www.aseprite.org/docs/cli/ and the pixel-plugin in this monorepo (toolers/pixel-plugin / mcp__plugin_pixel-plugin_aseprite__export_spritesheet) emits Aseprite-native JSON with these exact defaults, so scaffolded projects work out of the box.
Wiring it into a player entity
Here is a minimal Player that consumes the adapter. It assumes the scaffold from the M28 game project template, which places entity code under src/entities/ and sprite assets under assets/sprites/.
-- src/entities/player.lua
local aseprite = require("packages.love-shared.aseprite")
local Player = {}
Player.__index = Player
function Player.new(x, y)
local self = setmetatable({}, Player)
self.x, self.y = x, y
self.facing = 1 -- 1 right, -1 left
local sheet = aseprite.load(
"assets/sprites/player.png",
"assets/sprites/player.json"
)
self.image = sheet.image
self.anims = sheet.anims
self.current = self.anims.idle
return self
end
function Player:setState(name)
local next_anim = self.anims[name]
if not next_anim or next_anim == self.current then return end
self.current = next_anim
self.current:gotoFrame(1)
end
function Player:update(dt)
local vx = 0
if love.keyboard.isDown("right") then vx, self.facing = 160, 1 end
if love.keyboard.isDown("left") then vx, self.facing = -160, -1 end
self:setState(vx ~= 0 and "run" or "idle")
self.x = self.x + vx * dt
self.current:update(dt)
end
function Player:draw()
-- Flip via scale on draw, NOT via anim8's flipH (which mutates the animation).
self.current:draw(self.image, self.x, self.y, 0, self.facing, 1, 16, 32)
end
return Player
The flip-on-draw choice deserves a line of explanation because it cost me an afternoon. anim8.animation:flipH() mutates the animation object's internal flip state. If you share the same run animation between facing = 1 and facing = -1 players (or enemies), calling flipH for one mutates it for all of them on the next frame. Flipping via the sx argument to draw is stateless and applies only to that draw call. The anim8 issue discussing this is https://github.com/kikito/anim8/issues/24 and the fix is the one-character self.facing in the draw call above.
Testing the adapter without launching the game
Because aseprite.load touches love.filesystem and love.graphics, you cannot unit-test it in plain lua. The monorepo pattern is to shell out to a headless LÖVE runner. The scaffold ships scripts/love-test.sh which runs love . --test and expects a conf.lua that sets t.window = false. Inside main.lua --test you assert on the returned table:
if arg[2] == "--test" then
local aseprite = require("packages.love-shared.aseprite")
local sheet = aseprite.load("assets/sprites/player.png",
"assets/sprites/player.json")
assert(sheet.anims.idle, "idle tag missing")
assert(sheet.anims.run, "run tag missing")
assert(#sheet.anims.idle.frames == 4, "idle should have 4 frames")
print("ok")
love.event.quit(0)
end
This catches the two regressions that actually happen in practice: an artist rename (tag idle becomes Idle and the assert fires) and a frame count change (a new wind-up frame in run goes from 6 to 7 frames and downstream hit-timing code needs to know).
What to copy, what to skip
If you take three things from this: divide Aseprite durations by 1000 at the boundary, sort hash-mode frames by trailing integer, and never call flipH on a shared animation. Everything else is taste. The adapter file above is the version running in tinktink as of the M28 milestone and has shipped through the scaffolded template without edits.