gamedev.
gamedev6 min read

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.

love2danim8asepritesprite animationluagamedev tutorial

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 Hash option keys frames by filename, which is fine but annoying to iterate in Lua.
  • Check Meta: Tags. Without this, your run and idle tags 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.lua from kikito/anim8. Single file, MIT, drop it in.
  • A JSON decoder. rxi/json.lua is the community standard and also single-file.
  • aseprite.lua from 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.