Guide

Guide Assets Aseprite sprite sheets

Aseprite sprite sheets

Load Aseprite JSON sprite sheet exports as AsepriteSheet assets through the official @codexo/exojs-aseprite extension.

Intermediate ~7 min read

What you'll learn

  • activate the Aseprite extension explicitly or via /register
  • load an Aseprite JSON sheet as an AsepriteSheet
  • play tag animations with createAnimatedSprite

Before you start

Aseprite sprite sheets

Aseprite is a popular pixel-art and animation editor. The official @codexo/exojs-aseprite extension adds an asepriteSheet asset type that loads Aseprite’s JSON sheet export through the normal Loader pipeline — fetching the JSON, resolving and loading the packed image as a Texture, and returning an AsepriteSheet with a frame Spritesheet and one playable clip per Aseprite tag.

Note: AsepriteSheet ships as an official ExoJS extension package, separate from the core. Install @codexo/exojs-aseprite alongside @codexo/exojs:

npm install @codexo/exojs @codexo/exojs-aseprite

Export your sprite from Aseprite as a JSON sheet (File → Export Sprite Sheet, with Output → JSON Data enabled). Either Array or Hash frame layout works, and tags become animation clips.

Like all extensions, the core ships nothing Aseprite-specific — an Application only understands the asepriteSheet type once you activate the extension. There are two ways to do that.

Activation

Pass asepriteExtension to ApplicationOptions.extensions. This is explicit, tree-shakeable, and order-independent — the extension is bound to exactly this Application:

import { Application } from '@codexo/exojs';
import { asepriteExtension } from '@codexo/exojs-aseprite';

const app = new Application({ extensions: [asepriteExtension] });

The package root (@codexo/exojs-aseprite) is side-effect-free: importing it registers nothing globally. You decide which Application gets the extension.

/register convenience

For an app that always wants Aseprite support, import the /register entry once at startup. It registers asepriteExtension in the global ExtensionRegistry and re-exports the same public API:

import '@codexo/exojs-aseprite/register';
import { Application } from '@codexo/exojs';

const app = new Application(); // picks up asepriteExtension automatically

The /register import must run before you construct the Application whose extensions are read from the global registry. The explicit extensions: [...] form works regardless of import order.

Core-only

An Application constructed with an explicit empty list takes no extensions — not even globally registered ones — and will not recognise the asepriteSheet type:

import { Application } from '@codexo/exojs';

const app = new Application({ extensions: [] }); // core only; no AsepriteSheet

This is the same add-only model the Tiled maps and Particles extensions use.

Loading a sheet

Once the extension is active, load the JSON export by the AsepriteSheet type token or by the 'asepriteSheet' config-map type name:

import { AsepriteSheet } from '@codexo/exojs-aseprite';

// By type token (most explicit)
const sheet = await loader.load(AsepriteSheet, 'sprites/hero.json');

// By config-map type name — the public `'asepriteSheet'` lookup
const fromConfig = await loader.load({
    hero: { type: 'asepriteSheet', source: 'sprites/hero.json' },
});

Unlike the Tiled adapter, the Aseprite binding does not claim a file extension — a bare loader.load('sprites/hero.json') will not route here. Always name the AsepriteSheet token or the 'asepriteSheet' type. The handler fetches the JSON, validates it (throwing AsepriteFormatError on a malformed document), resolves meta.image relative to the JSON source, sub-loads it as a Texture through the same loader, and returns a fully-parsed AsepriteSheet.

Playing tag animations

AsepriteSheet.createAnimatedSprite() returns an AnimatedSprite with every Aseprite tag pre-defined as a named, playable clip. Call play(tag) with a tag name to start it:

async load(loader) {
    this.sheet = await loader.load(AsepriteSheet, 'sprites/hero.json');
}

init() {
    this.player = this.sheet.createAnimatedSprite();
    this.player.play('walk');
    this.root.addChild(this.player);
}

update(delta) {
    this.player.update(delta); // advance the active clip
}

Like any AnimatedSprite, you must call player.update(delta) each frame to advance playback — see the Animation guide for the full clip/playback API.

The parsed clips are also exposed directly on sheet.clips — a read-only Map keyed by tag name — so you can inspect what was imported or build a sprite by hand:

sheet.clips.size;          // number of tags
sheet.clips.has('walk');   // true when the 'walk' tag exists
sheet.clips.keys();        // iterate tag names

sheet.spritesheet;         // the underlying Spritesheet (frames keyed by index string)

Playback direction (reverse & ping-pong tags)

Aseprite frame tags carry a direction: forward (the default), reverse, pingpong, or pingpong_reverse. AsepriteSheet.parse() expands a tag’s declared fromto range into the actual sequence of frame indices before building the clip, since AnimatedSprite itself always plays forward through whatever frame list it’s given:

  • forward — unchanged: [from, from+1, …, to].
  • reverse — the range played backwards: [to, to-1, …, from].
  • pingpong — a forward pass followed by a backward pass that excludes both endpoints, e.g. a tag with from: 0, to: 2 expands to [0, 1, 2, 1].
  • pingpong_reverse — the mirrored shape, starting from to: [2, 1, 0, 1].
  • A single-frame tag (from === to) always yields just that one frame, regardless of direction.

One consequence: a ping-pong tag’s resulting clip has more frames than to - from + 1 — the bounce frames are appended, not looped separately — so sheet.clips.get(name).frames.length (and the tag’s average fps, see below) already reflect the expanded sequence, not the raw tag range:

// Aseprite tag: { name: 'idle', from: 0, to: 2, direction: 'pingpong' }
sheet.clips.get('idle').frames.length; // 4 — plays [0, 1, 2, 1], not the 3 declared source frames

One-shot playback (repeat)

Aseprite frame tags can also carry a repeat field — a numeric string ("1", "2", …) set from the Tag Properties panel — specifying how many full cycles the tag plays before stopping. AsepriteSheet.parse() maps it directly onto AnimatedSpriteClipDefinition.repeat:

  • No repeat on the tag → the clip’s repeat is -1 (loop indefinitely) — the same behaviour as before.
  • repeat: "1" → the clip plays exactly once; AnimatedSprite.onComplete then fires and playback stops on the last frame instead of looping.
  • repeat: "3" → the clip plays exactly three full cycles through its (possibly direction-expanded) frame sequence, then stops.
import { AsepriteSheet } from '@codexo/exojs-aseprite';

const sheet = await loader.load(AsepriteSheet, 'sprites/hero.json');
const player = sheet.createAnimatedSprite();

player.onComplete.add(clip => {
    if (clip === 'attack') {
        player.play('idle'); // the 'attack' tag has repeat: "1" — plays once, then this fires
    }
});

player.play('attack');

AnimatedSprite.repeat can be read back at any time and overridden per-call via play(name, { repeat }) — see the AnimatedSprite API reference for the full playback surface.

Frame timing

Each clip still exposes an fps derived from the per-frame duration values Aseprite exports (milliseconds per frame), averaged across the tag’s expanded frame sequence — for ping-pong tags that means bounced frames count toward the average twice, since they play twice:

  • A tag whose frames are all 100 ms produces 10 fps; mixed durations are averaged (100/200/300 ms → 200 ms → 5 fps).
  • When every frame duration in the tag is zero, the clip falls back to 12 fps.

fps alone can’t represent uneven timing, though — an artist holding one “impact” frame twice as long as the rest would get flattened to a single average interval. Each clip therefore also carries frameDurations, a per-frame hold-time array (AnimatedSpriteClipDefinition.frameDurations) taken directly from Aseprite’s per-frame duration field. When present, frameDurations is authoritative during playback; fps is retained only as a display-only average. A non-positive frame duration falls back to the tag’s average, the same degenerate case the fps calculation itself guards against.

sheet.clips.get('walk').fps;             // 5 — display-only average
sheet.clips.get('walk').frameDurations;  // [100, 200, 300] — authoritative per-frame hold times

Frame indices in a tag that fall outside the frame array are skipped, and a tag whose entire (expanded) range is out of bounds produces no clip.

Trimmed frames (frameOffsets)

Exporting with Aseprite’s Trim option removes transparent padding from each frame and records how much was trimmed via spriteSourceSize — different frames in the same animation are often trimmed by different amounts. Naively rendering trimmed frames at a fixed origin makes the sprite visibly jitter frame to frame.

When any frame in a tag is trimmed, AsepriteSheet.parse() populates AnimatedSpriteClipDefinition.frameOffsets with each frame’s spriteSourceSize {x, y} — its trimmed content’s offset within the untrimmed canvas. AnimatedSprite applies that offset as a local translation on top of the sprite’s own position/origin as it advances frames, keeping every frame anchored to the same point instead of jittering. frameOffsets is omitted entirely for tags with no trimmed frames, so untrimmed sheets are unaffected.

This is transparent — nothing to opt into beyond exporting with Trim enabled in Aseprite and consuming clips the normal way through createAnimatedSprite() or sheet.clips.

Slices

Aseprite’s editor lets you define named slices — hitboxes, nine-patch borders, UI anchor points — independently of the frame/animation data. AsepriteSheet.parse() reads meta.slices into AsepriteSheet.slices: ReadonlyMap<string, AsepriteSlice>, keyed by slice name:

sheet.slices.size;                 // number of named slices
sheet.slices.has('hitbox');        // true when the 'hitbox' slice exists
sheet.slices.get('hitbox').keys;   // AsepriteSliceKey[] — bounds per frame

A slice’s bounds can change per frame — each AsepriteSlice carries one AsepriteSliceKey per frame at which its bounds change, not one key per rendered frame. Resolve the key that applies to a given frame index yourself by taking the last key whose frame is at or before it:

import type { AsepriteSlice } from '@codexo/exojs-aseprite';

function resolveSliceBounds(slice: AsepriteSlice, frameIndex: number) {
    let bounds = slice.keys[0]?.bounds;

    for (const key of slice.keys) {
        if (key.frame > frameIndex) break;
        bounds = key.bounds;
    }

    return bounds;
}

Not yet supported

  • Layers / nested tags — Aseprite layer metadata is preserved in the parsed data but is not interpreted into clips.

Texture ownership

The packed image an AsepriteSheet uses is loaded through the Loader and owned by the loader cache. AsepriteSheet.destroy() releases the underlying Spritesheet frames; release the shared texture through the loader’s own unload path when nothing else needs it.

Where to look next

  • Package README: @codexo/exojs-aseprite — install, entry points, and the supported-format matrix.
  • API reference: AsepriteSheet documents clips, slices, spritesheet, and createAnimatedSprite(); AnimatedSprite documents clip definition (repeat, frameDurations, frameOffsets) and playback; Loader documents load(...) and the 'asepriteSheet' config-map type name.
  • Guides: Sprites and Loading & resources cover the rendering and asset model the sheet plugs into.