gamedev.
gamedev7 min read

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.

love2danim8sprite animationasepriteluagamedev

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:

  1. Per-frame source rectangles (so trimmed frames still render correctly).
  2. Per-frame duration in milliseconds (so a 6-frame attack can have a long windup frame and fast impact frames).
  3. frameTags with 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:

  1. Name every tag. Unnamed tags become frameTags[i].name = "" and will collide when the adapter uses the name as a table key.
  2. Export as "Hash" (not "Array"). The adapter above assumes hash; switching modes changes data.frames from an object to an array and breaks the loop. If you prefer array, swap the pairs block for a plain ipairs.
  3. Check "JSON Data" and "Image" in the export dialog. Keep the two filenames identical except for the extension: player.png + player.json.
  4. Do not check "Trim". Trimming changes per-frame offsets and anim8 does not apply spriteSourceSize corrections. If you need trimming to save atlas space, use the frame.spriteSourceSize field 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.