gamedev.
gamedev11 min read

Aseprite Tilemap JSON Export Lua Script

Aseprite Tilemap JSON Export Lua Script

A friend pinged me at midnight last fall: her tilemap rendered correctly inside Aseprite but her engine read garbage indices. We dug in for about an hour and found two separate off-by-one bugs in her exporter — one on the empty-tile index, one on the flip-flag mask. By the time we shipped a fix, I'd written most of the Lua script I wish I'd owned six months earlier. This article is that script, plus the mental model behind it. I'll walk through why Aseprite's built-in JSON isn't quite what game engines want, how the tilemap object model fits together, a schema I've shipped on three projects, and the pitfalls that cost me hours so they won't cost you any.

Why a custom JSON exporter at all

Why isn't Aseprite's built-in Export Sprite Sheet good enough for tilemaps? The official Aseprite documentation describes a perfectly serviceable sprite-sheet export — but the moment you load a tilemap into a game engine, that JSON schema falls short in three specific ways. That built-in flow is good for static sprite atlases — animation tags, frame durations, slice rectangles — but the JSON it emits is sprite-centric, not tilemap-centric. There is no notion of "this is a 32 by 24 grid of tile indices into a tileset of width 16."

When you load a tilemap into your engine you almost always want three things. A 2D grid of integers, one per cell, indexing into a tileset. Metadata about the tileset: tile width, tile height, columns, source image path. Optional per-layer metadata: name, visibility, opacity, blend mode, z-order. None of that comes back nicely from the default Export Sprite Sheet JSON. So we write a small Lua script that walks the document's tilemap layers, builds the schema we want, and writes it to disk. Aseprite's Lua scripting API on GitHub exposes everything we need: app.activeSprite, Sprite.layers, Cel.image, and the tilemap-specific Image:getPixel(x,y) returning tile indices rather than RGBA.

The Aseprite tilemap object model

Why does the tile at the top-left corner of your map sometimes read as the wrong tile, even when Aseprite renders it correctly? The answer is hiding inside how Sprite, Layer, Cel, Image, and Tileset fit together — and once you see the shape, the bug fixes itself. The hierarchy looks like this when you peel back the editor.

A Sprite owns a list of Layer objects. Most layers are regular raster layers, but some are tilemap layers — those have a non-nil tileset property. Each tileset is a sequence of small tile images, each one Tileset.grid.tileSize pixels (width by height). Inside a tilemap layer, every animation frame is a Cel. A cel on a tilemap layer doesn't hold a raster image of pixels; instead it holds an Image whose pixels are tile indices into the layer's tileset. Index 0 is reserved for "empty"; index 1 maps to the first painted tile in the tileset, index 2 to the second, and so on.

That last point trips up a lot of newcomers. When you getPixel(x, y) on a tilemap cel image, you do not get an RGBA value — you get an integer in the range [0, Tileset.tileCount). If you forget the off-by-one between "index 0 is empty" and "tile #1 in your engine's tileset", you will spend a confused afternoon staring at a map that looks shifted by one tile.

The cel itself has a position (cel.position.x, cel.position.y) measured in tiles, not pixels. So a tilemap layer with a cel positioned at (3, 2) and an image size of 10 by 5 covers tiles in columns 3 through 12 and rows 2 through 6. Cells outside the cel's image bounds are implicitly empty. Engines that expect a uniform full-canvas grid per layer must expand on import, never assume.

Hello world: listing tilemap layers

The first time I opened Aseprite's scripting console I closed it again ten seconds later, convinced the docs would be a wall. They aren't — the aseprite/api GitHub repository is just an index of single-page references for Sprite, Layer, Cel, Image, and Tileset, and the dev loop turns out to be one of the smallest I have used in any tool. The README index links each Sprite, Layer, Cel, Image, Tileset page individually. The simplest possible script just enumerates tilemap layers in the active document.

-- list_tilemap_layers.lua
local sprite = app.activeSprite
if not sprite then
  return app.alert("Open a sprite first.")
end

for i, layer in ipairs(sprite.layers) do
  if layer.isTilemap then
    print(string.format("Layer %d: %s (tileset has %d tiles)",
      i, layer.name, #layer.tileset))
  end
end

Run it from File > Scripts > Open Scripts Folder, drop the file in, hit refresh, and it appears in the Scripts menu. The output prints in View > Console. That is the entire dev loop — write Lua, hit run, read the console. There is no compile step, no language server, no build system. For a tool whose primary user is a pixel artist who happens to script occasionally, that simplicity is exactly right.

Designing the JSON schema

Before writing the exporter, decide on a schema. A schema invented under deadline will leak into ten game engines and rot for years, so it is worth a few minutes upfront. Here is a minimal but sufficient shape I have used on three different projects.

{
  "sprite": {
    "width": 320,
    "height": 240,
    "frame_count": 1
  },
  "tilesets": [
    {
      "id": 1,
      "name": "terrain",
      "tile_width": 16,
      "tile_height": 16,
      "tile_count": 64,
      "image": "terrain.png"
    }
  ],
  "layers": [
    {
      "name": "ground",
      "tileset_id": 1,
      "opacity": 255,
      "visible": true,
      "cels": [
        {
          "frame": 1,
          "position": { "x": 0, "y": 0 },
          "size": { "w": 20, "h": 15 },
          "tiles": [1,1,1,2,3,0,0,0,4,4,4,4,1,1,1,2]
        }
      ]
    }
  ]
}

A few opinionated choices in there. First, the tiles array is a flat 1D array in row-major order, with width and height carried alongside in size. Some folks prefer a nested 2D array, but a flat array compresses better when gzipped, is faster to iterate in tight engine loops, and is what most existing tilemap loaders (Tiled's CSV layer, Godot's TileMap import) already expect. Second, tileset_id is an integer rather than a name string — fewer typos and faster lookups. Third, the script also exports the companion image strip for each tileset, so the engine never has to crack open the .aseprite file to render tiles.

What is deliberately missing from the schema is also worth noting. No collision shapes, no pathfinding hints, no entity placeholders. Those things change too fast in a young codebase, and locking them into the data format means the exporter becomes a moving target. Keep the schema boring and stable; layer game logic on top.

The exporter script

With the schema decided, the script writes itself. Here is the meat of it, trimmed to the essentials.

-- export_tilemap_json.lua
local sprite = app.activeSprite
assert(sprite, "Open a sprite first.")

local outBase = app.fs.filePathAndTitle(sprite.filename)

local function exportTileset(ts, id, name)
  local imagePath = outBase .. "_" .. name .. ".png"
  local stripW = ts.grid.tileSize.width * (#ts - 1)
  local stripH = ts.grid.tileSize.height
  local image = Image(stripW, stripH)
  for i = 1, #ts - 1 do
    image:drawImage(ts:getTile(i),
      (i - 1) * ts.grid.tileSize.width, 0)
  end
  image:saveAs(imagePath)
  return {
    id = id, name = name,
    tile_width = ts.grid.tileSize.width,
    tile_height = ts.grid.tileSize.height,
    tile_count = #ts - 1,
    image = app.fs.fileName(imagePath),
  }
end

local function exportCel(cel)
  local img = cel.image
  local tiles = {}
  for y = 0, img.height - 1 do
    for x = 0, img.width - 1 do
      tiles[#tiles + 1] = img:getPixel(x, y)
    end
  end
  return {
    frame = cel.frameNumber,
    position = { x = cel.position.x, y = cel.position.y },
    size = { w = img.width, h = img.height },
    tiles = tiles,
  }
end

local doc = {
  sprite = {
    width = sprite.width, height = sprite.height,
    frame_count = #sprite.frames,
  },
  tilesets = {}, layers = {},
}

local tilesetIds = {}
for i, ts in ipairs(sprite.tilesets) do
  local name = ts.name ~= "" and ts.name or ("tileset_" .. i)
  doc.tilesets[#doc.tilesets + 1] = exportTileset(ts, i, name)
  tilesetIds[ts] = i
end

for _, layer in ipairs(sprite.layers) do
  if layer.isTilemap then
    local cels = {}
    for _, cel in ipairs(layer.cels) do
      cels[#cels + 1] = exportCel(cel)
    end
    doc.layers[#doc.layers + 1] = {
      name = layer.name,
      tileset_id = tilesetIds[layer.tileset],
      opacity = layer.opacity,
      visible = layer.isVisible,
      cels = cels,
    }
  end
end

local json = require("json")
local f = io.open(outBase .. ".json", "w")
f:write(json.encode(doc))
f:close()
app.alert("Exported " .. outBase .. ".json")

A few notes on what is happening here. Aseprite ships a Lua JSON encoder via require("json") — no third-party module needed. The app.fs namespace gives you safe path manipulation; filePathAndTitle strips the extension off the active document so you can append .json and _terrain.png next to the original. The tileset export loop starts at index 1 instead of 0 because, again, index 0 is the reserved empty tile and you do not want it in the exported image strip.

The Image:drawImage call composites each tile image into a horizontal strip and Image:saveAs writes a PNG. There is also Image:saveCopyAs if you want the script to leave the in-memory image untouched. For most build pipelines, saveAs is fine because the script is running headless and there is nothing to disturb.

Pitfalls that bit me in production

Three things to watch for. First, frame numbers in Aseprite are 1-indexed. If your engine assumes 0-indexed frames, normalise on export rather than on import — the conversion is in one place that way, and you avoid scattering subtract-one statements across the runtime. Second, when a tilemap cel image is smaller than the sprite's total area, the cells outside the cel are implicitly tile 0 (empty). If your engine expects a uniform full-canvas grid per layer, expand the array on import using cel.position and size; do not assume size covers the whole sprite. Third, tile rotation and flipping in Aseprite 1.3 plus is encoded in the high bits of the tile index — bits 30 and 31 mark horizontal and vertical flip, bit 29 marks diagonal. Mask them off with tile & 0x1FFFFFFF to get the plain index, and keep the flip flags as separate booleans in your JSON if your engine supports them. Forget the mask and your indices silently overflow into the billions and your engine crashes on the first painted tile.

A fourth pitfall, less of a footgun and more of a paper cut: the order of sprite.layers is bottom-to-top in render order. If your engine draws layer 0 first, that matches. If your engine draws the last layer first, reverse the array on export or you will spend an hour wondering why the ceiling is below the floor.

Hooking the script into a build pipeline

Manual export is fine for one designer working on one map. As soon as you have more than a handful of .aseprite tilemap files, automate. Aseprite supports headless command-line invocation; you can run a script from the shell without opening the GUI. The official Aseprite CLI documentation covers the flag set in detail.

A typical headless export looks like this.

aseprite -b \
  --script-param sprite=maps/forest.aseprite \
  --script export_tilemap_json.lua

The -b flag means batch mode — no editor window, just run the script and exit. You wire that into a Makefile rule, a package.json script, or a CI step, and your build artefact now includes a fresh forest.json and forest_terrain.png whenever the source file changes. If your build system supports file watching, you get hot-reload tilemaps for free: save in Aseprite, the file watcher fires, your engine reloads the JSON, the map updates without restarting the game.

For teams using LOVE or another Lua engine on the runtime side, you can reuse the same JSON encoder in reverse. The lua-cjson library is the fastest Lua JSON parser I have measured, and dropping it into your engine alongside the exported file gives you sub-millisecond map loads for grids of 200 by 200 tiles. For C++ runtimes, nlohmann/json is the obvious choice; for Rust, serde_json plus a #[derive(Deserialize)] struct that mirrors the schema above.

Where to go from here

The schema and script above cover the 90 percent case: static tilemap layers exported once per build. A few directions worth exploring once that baseline is in place. Animated tile support — Aseprite tilesets can carry per-tile animation, and the API exposes it under Tileset.tile(i).properties. Custom per-tile properties — useful for collision shapes, audio tags, or trigger metadata; pull them from Layer.properties and Tile.properties and stuff them into the JSON unmodified. Multi-frame export — most platformer cutscenes use the same tileset across multiple animation frames, so the exporter can emit one cel per frameNumber and your engine plays the sequence by swapping the active cel.

Whatever direction you take, the discipline that pays off is keeping the exporter dumb. The script's only job is to translate Aseprite's internal model into your engine's preferred JSON. Resist the temptation to bake in collision detection, pathfinding, or any other runtime logic. That logic belongs in the engine where you can iterate on it without restarting Aseprite. A clean, boring exporter that runs in 50 ms and writes a deterministic JSON is worth more in the long run than a clever one that tries to be a game engine. If you remember one thing from this article, remember that: small, dumb, fast tools beat smart, slow, opinionated ones every time.