Animated Spritesheets
Turn a character into a packed, engine-loadable spritesheet by generating one labeled pose-board image — a grid of the same character in the frames of an action — then recovering/slicing it. This is the image-generation method.
Why image generation, not image-to-video. One generation per action keeps identity consistent (cloak/face/weapon stay the same across frames); video morphs the character mid-clip. Trade-off: fewer frames (a model reliably lays out ~4–12 grid cells, not a long strip), so motion is choppier — per-frame pose labels + pixel-snapping make the few frames count.
The happy path
You are an agent. All scripts run with node <script> — no Python, no uv, and
nothing to install: each imports a bundled, dependency-free scripts/_lib/.
# This skill's directory. Claude Code substitutes CLAUDE_SKILL_DIR (project, global
# or plugin install); other agents fall back to wherever `skills add` put it.
SKILL="${CLAUDE_SKILL_DIR}"
[ -d "$SKILL" ] || for d in .agents/skills .claude/skills ~/.agents/skills ~/.claude/skills; do
[ -d "$d/animated-spritesheets" ] && SKILL=$d/animated-spritesheets && break
done
# 0. (once) An approved character anchor PNG on a flat chroma matte (the identity
# reference). Make it via the pixel-art skill, or:
node $SKILL/scripts/sprite-prompt.mjs anchor --direction e --chroma '#00FF00' # -> vg generate run
# 1. ASK what the action needs (frame count / fps)
node $SKILL/scripts/sprite-presets.mjs --action attack --json
# 2. PROMPT — get the labeled pose-board prompt (per-frame semantic poses on an
# implied 4x3 grid, identical-character + no-shadow litany, the craft):
node $SKILL/scripts/sprite-prompt.mjs pose-board --action attack --direction e --frames 8 \
--frame-prompt-style specific --pose-board standard --style lobit-v1 --chroma '#00FF00'
# 3. GENERATE the pose board with the anchor as the identity reference. The board
# is a 4-col x 3-row grid; the first N cells are the frames (4:3 aspect). Use 2K
# so each cell clears ~512px — pixel snapping (step 4) needs that resolution to
# recover a clean grid:
vg generate run fal-ai/nano-banana-pro/edit --prompt "<from step 2>" \
--image_urls '["<anchor_url>"]' --aspect_ratio "4:3" --resolution "2K" \
--download attack-board.png --json
# 4. PROCESS into runtime frames with ONE command. Default = naive uniform slice
# (the robust path). Add --recover to re-center a pose that drifted off its cell
# (it falls back to the uniform slice if it can't, so passing it is always safe):
node $SKILL/scripts/process-sheet.mjs attack-board.png --action attack --rows 3 --cols 4 --frames 8 --out-dir runs/hero-attack
--download x.pngdoes not make the file a PNG. The model chooses the output format, so an endpoint that answers JPEG writes JPEG bytes into the.pngyou named, and every script here — they read PNG only, by design — rejects it withNot a PNG file (bad signature).vg generatewarns when the bytes contradict the extension; when it does, pick an endpoint that returns PNG rather than trying to convert (there is no converter in this toolkit, and no ImageMagick/ffmpeg to fall back on).nano-banana-pro/editreturns PNG;flux/devreturns JPEG.
The deliverable is <out-dir>/spritesheet.png + spritesheet.json, with
runtime/ frames and review/<action>.gif. Load it:
// spritesheet.json -> { frameWidth, frameHeight, frameCount, fps, animations }
this.load.spritesheet("attack", "assets/hero-attack/spritesheet.png", {
frameWidth: 256,
frameHeight: 256,
});
this.anims.create({
key: "attack",
frameRate: 10,
frames: this.anims.generateFrameNumbers("attack", { start: 0, end: 7 }),
}); // end = frameCount - 1
Ship it as PNG or lossless WebP (--columns so no side exceeds 4096 px — mobile GPUs load a wider strip as an empty texture). Lossy WebP makes pixel-art sheets 51–85% bigger, not smaller.
What process-sheet.mjs does (under the hood)
- slice / recover — default is a naive uniform
--rows × --colsslice (the robust path).--recoverinstead re-centers each cell's foreground component to rescue a pose that drifted off-grid; it can't split poses that merged into one blob, so it falls back to the uniform slice when it can't fill the cells — passing--recoveris always safe.--frames Ntakes the first N cells. chroma-clean.mjs clean— key the matte → fringe → despill → decontaminate (global speck-removal so dark/low-contrast sprites stay clean).- pixel snap (on by default) — recover crisp native pixels from the AI's
fake-pixel "mixels". All frames are assembled into one strip and snapped
together so the snapper finds a single grid pitch for the action — every frame
ends up the same scale, so the character doesn't "breathe" size between frames.
--no-pixel-snapkeeps the smooth high-res look (for painterly/non-pixel sprites);--snap-k-colorssets palette size. Needs ≥~512px cells to work well — hence the 2K board (seepixel-snapper). normalize-canvas.mjs— place each frame on a shared 256×256 anchor with headroom (--char-fill, default ~0.5 of the cell) so attack arcs and big poses never clip the edge.pack-spritesheet.mjs— pack tospritesheet.png+ manifest.sheet-qc.mjs— QC the packed sheet and report a verdict (same token in the--jsonqcfield and the human badge, just uppercased there):clean/review(soft hints to eyeball — size outliers, possible facing flips) /warn(hard defects — empty cells, edge-clipping, foot-baseline wander, an inked cell grid). Runs automatically;--no-qcskips it. Read the verdict: regenerate the board onwarn; eyeballreview/<action>.gifonreview. Run it standalone too:sheet-qc.mjs sheet.png [--json] [--strict]. Thegridcheck deserves special attention: models routinely ink the cell outlines despite the prompt forbidding them, and the uniform slice bakes a black rule into every frame. That rule then joins each frame's bounding box, so size/baseline/facing get measured against the rectangle rather than the character — agridwarn makes the rest of the report meaningless, so regenerate rather than reading past it.
Craft
- Consistency is the whole point. Prompt "identical character, do not redesign between frames," and reference the anchor as identity.
- Headroom. Draw the character small with margin (~20–50% of the frame) so effects/arcs have room.
- Grid the model can do. A 4×3 board (first 6–10 cells) is the sweet spot. Past ~12 cells the model loses layout consistency.
- Make it ONE motion, not N poses. The model's default failure is each cell as
a separate dramatic pose, so frames jump around instead of tracing one swing.
Two prompt moves beat this, both built into
sprite-prompt.mjs pose-board: (1) it always frames the used cells as consecutive film frames of one continuous motion sampled at evenly-spaced instants; (2) with--frame-prompt-style specific(the default) it writes per-frame labels as monotonic spatial progression along a single path (weapon back → wind-up peak → mid-strike across centerline → contact → follow-through → recover), not abstract beats. If you add an action tosprite-presets.mjs/frame_label, label it as progression along one path — that, not the frame count, makes it read as motion. - Lock the facing. The model loves to mirror a cell (often frame 1). The
pose-board prompt pins facing in every cell (
_pose_board_facing_lock);--direction e/wadds a side-profile lock.sheet-qc.mjscatches gross flips; eyeball the gif for subtle ones. - Lock the scale. Same size in every cell on a shared foot baseline — pose
change fine, scale change not. The prompt says so.
normalize_canvascan't un-drift mixed scales, sosheet-qc.mjsflags size outliers (sparing monotonic pose arcs like a death collapse). - Matte. Flat
#00FF00(#FF00FFif the subject is green). Generate-time prompts must forbid baked shadows — the engine adds those. - Genre/action data comes from
sprite-presets.mjs(frames, fps, profiles).
Remember
One labeled pose-board image, one consistent character, recovered into frames. Fewer frames than a video clip, but they're the same character across the whole animation — which is what a game sprite needs most.
For 3D props built the same generate-one-reference-image way (rigged,
procedural Three.js instead of frames), see image-to-threejs.