Aseprite sprite sheets
Load Aseprite JSON sprite sheet exports as AsepriteSheet assets through the official @codexo/exojs-aseprite extension.
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:
AsepriteSheetships as an official ExoJS extension package, separate from the core. Install@codexo/exojs-asepritealongside@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
Explicit (recommended)
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 from→to 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 withfrom: 0, to: 2expands to[0, 1, 2, 1].pingpong_reverse— the mirrored shape, starting fromto:[2, 1, 0, 1].- A single-frame tag (
from === to) always yields just that one frame, regardless ofdirection.
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
repeaton the tag → the clip’srepeatis-1(loop indefinitely) — the same behaviour as before. repeat: "1"→ the clip plays exactly once;AnimatedSprite.onCompletethen 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:
AsepriteSheetdocumentsclips,slices,spritesheet, andcreateAnimatedSprite();AnimatedSpritedocuments clip definition (repeat,frameDurations,frameOffsets) and playback;Loaderdocumentsload(...)and the'asepriteSheet'config-map type name. - Guides: Sprites and Loading & resources cover the rendering and asset model the sheet plugs into.