Aseprite spritesheet JSON to anim8 in 15 lines of Lua — skip TexturePacker
Aseprite's native export JSON is enough to drive anim8 in LÖVE. Here's the 15-line adapter we shipped in the M28 scaffold template — no TexturePacker format needed, no paid plugins.
If you've been wiring Aseprite sprites into a LÖVE game using anim8, you've probably hit the same fork in the road I did: the anim8 README uses a uniform grid (anim8.newGrid(w, h, sheetW, sheetH)), and most tutorials tell you to set up TexturePacker to get the JSON anim8 can consume. Except anim8 doesn't actually consume TexturePacker JSON — it takes raw pixel dimensions, not a JSON structure. And Aseprite's own export JSON is already close enough to TexturePacker that you don't need either.
This post shows the 15-line adapter that lets you feed Aseprite-exported spritesheets (the format aseprite --batch --script produces, also what pixel-plugin's export_spritesheet emits) straight into anim8 without installing a single extra tool.
The test that killed my assumptions
I went into this expecting to either (a) find a TexturePacker export option in pixel-plugin, (b) write a converter from Aseprite JSON to TexturePacker JSON, or (c) give up and pay for TexturePacker Pro. The spike report from M28 T0 (spike-report.md in the monorepo) caught me off-guard:
export_spritesheetMCP tool has NO format selector. It accepts onlyinclude_json: boolean. The/pixel-export ... format=texturepackerslash command is a Claude-orchestration layer built on top of the native export, not a native MCP capability.
So there isn't a TexturePacker format at the tool layer. pixel-plugin emits Aseprite-native JSON. Looking at the shape:
{
"frames": {
"goblin 0.aseprite": {
"frame": { "x": 0, "y": 0, "w": 32, "h": 32 },
"rotated": false,
"trimmed": false,
"spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 },
"sourceSize": { "w": 32, "h": 32 },
"duration": 100
},
"goblin 1.aseprite": { ... },
...
},
"meta": {
"size": { "w": 128, "h": 32 },
"frameTags": [
{ "name": "walk", "from": 0, "to": 3, "direction": "forward" }
]
}
}
That's actually more useful than TexturePacker's equivalent — you get per-frame duration values and pre-sliced frameTags for named animations. And because anim8.newGrid only needs uniform frame dimensions + sheet size, the Aseprite JSON gives you everything to compute those two numbers.
The 15-line adapter
Drop this in src/systems/aseprite_adapter.lua or equivalent:
local json = require("json") -- any pure-Lua JSON lib; we vendor dkjson
local anim8 = require("anim8")
local function aseprite_to_anim8(json_path)
local f = assert(io.open(json_path, "rb"))
local data = json.decode(f:read("*a"))
f:close()
-- Aseprite frame keys are "<sprite> <i>.aseprite" — sort by frame.x
-- to get them in horizontal-layout order, ignore the key names.
local frames = {}
for _, fr in pairs(data.frames) do
table.insert(frames, fr.frame)
end
table.sort(frames, function(a, b) return a.x < b.x end)
local fw, fh = frames[1].w, frames[1].h
local sheet_w, sheet_h = data.meta.size.w, data.meta.size.h
return anim8.newGrid(fw, fh, sheet_w, sheet_h)
end
return aseprite_to_anim8
Usage in your game loop:
local aseprite_to_anim8 = require("src.systems.aseprite_adapter")
local img = love.graphics.newImage("assets/sprites/goblin.png")
local grid = aseprite_to_anim8("assets/sprites/goblin.json")
local walk = anim8.newAnimation(grid("1-4", 1), 0.1)
function love.update(dt) walk:update(dt) end
function love.draw() walk:draw(img, 100, 100) end
That's it. The key insight is that anim8 doesn't need TexturePacker semantics — it just needs uniform frame dimensions and a sheet size. Both come out of the Aseprite JSON verbatim.
Why the frame-key sort matters
Aseprite doesn't guarantee pairs(data.frames) iteration order. The keys look like "goblin 0.aseprite", "goblin 1.aseprite" — numerically ordered but Lua's pairs returns them in hash order. If you read frames[1] you might get frame 2 or frame 0 depending on Lua version.
The sort-by-frame.x trick works because pixel-plugin (and Aseprite CLI) lay frames out horizontally by default — frame 0 is at x=0, frame 1 is at x=32, frame 2 is at x=64, etc. Sorting by X recovers the original frame sequence regardless of how pairs iterates.
If you export with vertical layout or packed layout, sort by (y, x) lexicographically instead. You can also use data.meta.frameTags to pull animation ranges by name rather than anim8's positional "1-4" syntax:
for _, tag in ipairs(data.meta.frameTags) do
if tag.name == "walk" then
local range = string.format("%d-%d", tag.from + 1, tag.to + 1) -- Aseprite is 0-indexed
return anim8.newAnimation(grid(range, 1), 0.1)
end
end
When this breaks
The adapter assumes uniform frame dimensions (all frames same w × h) and horizontal layout. If you have packed layouts with variable frame sizes, anim8.newGrid is the wrong tool — use anim8.newAnimation with explicit rect tables per frame, or switch to a library that supports packed atlases like Spritesheet or the built-in LÖVE quads.
For the typical game-dev use case (1 animation per spritesheet, horizontal frame layout from Aseprite --sheet-type horizontal), this 15-line adapter is the whole integration. No TexturePacker. No extra deps beyond anim8 + any pure-Lua JSON library.
Monorepo context
This pattern ships in the M28 scaffold template at .claude/skills/love-reference-anim8/SKILL.md — it's the reference instruction Claude Code follows when scaffolding new LÖVE projects. The spike report that locked the decision is at docs/tooler-core/M28-T0-spike-report.md.