Sprites
A sprite draws an entity as one frame of a sprite sheet: one image and the frames and clips in it. Every sprite of one sheet and one blend is one draw, so five hundred skeletons cost one draw call. A sheet’s clips play through playClip, the call a 3D character’s clips take, so one swing system serves a model and a sprite. Read the 2D frame first: it sets the plane, the pixels per metre and the pixel look a sprite draws under. Under WebGPU no sprite draws, as WebGPU lists.
Sprites come from the 2D layer’s own entry, which a 3D game never downloads:
import { CharacterSprite, defineSpriteSheet, Sprite,} from "@spawnite/engine/2d";Declare a sheet
Section titled “Declare a sheet”defineSpriteSheet declares a sheet at module scope and returns its handle. It takes one of five forms:
- Aseprite’s JSON, array or hash, exported with
--list-tags. Each frame tag is a clip with its frames’ own durations, its direction and its repeat. Export with--list-layerstoo, and each cel’s user data becomes a mark on that frame. - TexturePacker’s JSON, array, hash or Pixi’s format. Its trims, rotations and pivots draw where the untrimmed frame would. Pixi’s
animationsare clips. TexturePacker writes no durations, so passdurationSeconds. - A pxl file,
import hero from "./hero.px.js", which the engine paints as the game loads, with its clips typed, as Pixel art as code says. pxl’s own sheet or anim export reads too: TexturePacker’s hash with each anim frame’sdurationand Aseprite’s frame tags, where a static sprite has no duration, so a clip of one takesdurationSeconds. - A grid: one image cut into frames of one size, with
columns, and the clips the game names. - A painted grid: the same grid over a painted
definePixelTexturerather than a file, as Paint a sheet in code shows.
A sheet samples its image nearest, which keeps pixel art square; filter: TextureFilter.Linear blends it, for art that is not pixel art.
A sheet is checked when it is defined. A frame outside the image, a tag with no frames or past the file’s frames, and a duration of zero each throw an error that names the file and the frame. The decoded image is checked against the data when it loads.
clips names the clips the game plays, which types a Sprite’s clip. An empty entry, { walk: {} }, takes the file’s tag of that name, and an entry with frames declares a clip of the game’s own. Left out, every tag is a clip, and a file with no tags is one clip named all. A clip loops unless its loop says ClipLoopMode.Once or ClipLoopMode.PingPong.
Paint a sheet in code
Section titled “Paint a sheet in code”A sheet’s image takes a painted definePixelTexture in place of a URL, so a seed or a set of parts makes a new sheet with no file. A view paints it on first use from the game’s texture seed, and a room reads its frames and clips from the grid and paints nothing. Unity builds a sprite the same way from a Texture2D filled with SetPixels, and Godot from an ImageTexture of a painted Image.
import { definePalette, definePixelTexture, Entity } from "@spawnite/engine";import { defineSpriteSheet, Sprite } from "@spawnite/engine/2d";
const palette = definePalette({ ramps: { slime: ["#1d4a2a", "#2f7a3a", "#4fae4a", "#8ad85e", "#d4f59a"], outline: ["#241820"], },});
/** Two 16 by 16 frames of a slime, the second squashed a texel lower. */const slimeImage = definePixelTexture({ name: "slime", width: 32, height: 16, paint: ({ color, alpha, size: [width, height] }) => { const { slime, outline } = palette.ramps; // A painter's alpha starts at 1: clear the frames first. for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) alpha.setPixel(x, y, 0); for (const frame of [0, 1]) { const squash = frame; for (let y = 6 + squash; y <= 13; y += 1) for (let x = 3 - squash; x <= 12 + squash; x += 1) { const rim = y === 6 + squash || y === 13 || x === 3 - squash || x === 12 + squash; color.setPixel( frame * 16 + x, y, rim ? outline[1] : slime[y < 9 ? 4 : 3], ); alpha.setPixel(frame * 16 + x, y, 1); } } },});
export const slimeSheet = defineSpriteSheet({ image: slimeImage, frameSize: [16, 16], columns: 2, clips: { idle: { frames: [0, 1], durationSeconds: 0.4 } },});
/** A slime on the ground, breathing in its two painted frames. */export function Slime() { return ( <Entity position={[0, 0, 0]}> <Sprite sheet={slimeSheet} clip="idle" upright /> </Entity> );}A painted sheet follows these rules:
- Clear the alpha. A painter’s alpha starts at 1, which suits a ground texture. A sheet that never clears it draws every frame as a square, and the engine warns about it in development.
- Colour and coverage only. A sprite takes the painter’s colour and alpha. It drops the height, the normal and the emissive layers, since a lit sprite bends its normal across the quad.
- Size at definition. The painted size is known when the sheet is defined, so a grid wider or taller than it throws there, naming both sizes.
- One pixels per metre. The sheet takes the texture’s
pixelsPerMetre, and throws when it gives another. - Painted textures only. A texture drawn from image files is refused: pass its map’s URL as
image, which samples it as a sprite.
Each painted sheet is its own texture, so each one is its own draw, as each image file is.
Start from the library
Section titled “Start from the library”The asset library holds a pixel pack to start from: 0x72’s dungeon characters, enemies and props, which are CC0, and an experience gem drawn with pxl under the platform license. Each is an Aseprite sheet at 16 pixels a metre with its clips, such as a knight’s idle, run and hit. spawnite assets list --category sprites lists them, and add_asset in the MCP adds one as the command does:
spawnite add asset dungeon-knightThe command copies the sheet and its JSON into src/sprites/, writes its row in src/sprites/catalog.json, and prints the lines that declare it:
Register it: paste these into src/sprites/index.ts:import { defineSpriteSheet } from "@spawnite/engine/2d";import sheetDungeonKnightImage from "./dungeon-knight.png";import sheetDungeonKnightData from "./dungeon-knight.json";export const sheetDungeonKnight = defineSpriteSheet({ image: sheetDungeonKnightImage, data: sheetDungeonKnightData, pixelsPerMetre: 16, clips: { idle: {}, run: {}, hit: {} },});A second sheet’s lines leave out the import src/sprites/index.ts already has. A sheet drawn with pxl brings its .px.js too, which you can import in place of the PNG and JSON, as Pixel art as code says.
Draw an entity
Section titled “Draw an entity”<Sprite> inside an <Entity> draws it as its sheet’s clip, looping, or as one frame. Its origin sits on the frame’s pivot, the frame’s bottom centre unless the file or pivot says otherwise, so a character’s feet stand on the entity. The rest of its props are in the reference:
flipXandflipYmirror the frame about its pivot, so a character turning keeps its feet where they were.tintmultiplies a colour in, andflashmixes a colour over the frame, starting again each time itsplaysrises.blendisSpriteBlendMode.Cutoutunless set, which writes depth.Alphablends soft edges, andAdditiveadds a glow.blobShadowdraws an ellipse under the feet.uprightstands the sprite on its feet facing the camera, for a tilted 2.5D look.lit,castShadow,leanandopacitylight it, cast its shadow and fade it, as Light a sprite in a tilted world says.
While the scene’s Frame2DTrait holds pixelSnap, as a pixel-perfect camera sets it, a flat sprite’s origin snaps to the texel grid by the rule the camera’s aim snaps by. A sprite the camera follows then holds still on the screen. Its pivot then rounds to a whole texel of the frame, and a sprite that does not snap keeps its pivot where you set it.
The sample below declares two grid sheets, a character view and a skeleton. Its test runs it against a room:
import { Entity, type CharacterViewProps } from "@spawnite/engine";import { CharacterSprite, ClipLoopMode, defineSpriteSheet, Sprite,} from "@spawnite/engine/2d";
// Two grid sheets at the engine's 16 pixels a metre: a 16 by 24 hero, a// metre wide and a metre and a half tall, and a 16 by 16 skeleton.export const heroSheet = defineSpriteSheet({ image: "/sprites/hero.png", frameSize: [16, 24], columns: 6, clips: { idle: { frames: [0, 1], durationSeconds: 0.4 }, walk: { frames: [2, 3, 4, 5], durationSeconds: 0.1 }, },});
export const skeletonSheet = defineSpriteSheet({ image: "/sprites/skeleton.png", frameSize: [16, 16], columns: 8, clips: { walk: { frames: [0, 1, 2, 3], durationSeconds: 0.12 }, attack: { frames: [4, 5, 6, 7], durationSeconds: 0.1, loop: ClipLoopMode.Once, marks: { hit: 2 }, }, },});
/** Every character as the hero's sprite, with no model or capsule: * `characters({ view: HeroView })` among the game's plugins. It walks * as the character moves and faces left or right with its heading. */export function HeroView(props: CharacterViewProps) { return <CharacterSprite {...props} sheet={heroSheet} blobShadow />;}
/** A skeleton at `position`, walking on the spot, with its shadow. */export function Skeleton({ position }: { position: [number, number, number] }) { return ( <Entity position={position}> <Sprite sheet={skeletonSheet} clip="walk" blobShadow /> </Entity> );}Play a clip
Section titled “Play a clip”playClip(entity, "attack") plays a sheet’s clip once over the loop, and the loop comes back when it ends. hold: true keeps its last frame until stopClip, as a death does. A one-shot’s start, end and speed work as they do on a model. fadeSeconds means nothing to a frame, and a sprite plays ClipLayer.UpperBody not at all.
The frame is never stored. Each page works it out from the tick the play stands on, at the tick the page draws the entity at. A streamed entity is drawn behind the room, so its swing lands with its body. The player’s own character is drawn at the page’s predicted tick, and a correction that takes a play back takes the frame back with it. A late joiner works out the frame every other page shows.
A sprite’s onFrameChange and onClipEnd run on the page, for a footstep sound or a return to idle. A mark on a frame, from Aseprite’s cel user data or a clip’s marks, raises ClipMarkEvent the frame the page shows it, which useClipMark hears.
Land damage on a frame
Section titled “Land damage on a frame”A room mounts no view and loads no image. readSpriteMarks(step, entity, sheet) reads the marks the entity’s one-shot reached on the tick being stepped, from the sheet’s data alone. A system that deals the swing’s damage on its hit mark therefore deals it on the tick every page shows that frame. readSpriteFrame(step, entity, sheet) reads the frame itself, for a hit box that follows the swing.
The swing below plays on a predicted command and counts its hit on the room and on the player’s page on the same tick:
import { addTrait, createQuery, defineCommand, definePlugin, defineSystem, defineTrait, Phase, playClip, readCommands, type PredictedStep,} from "@spawnite/engine/core";import { ClipLoopMode, defineSpriteSheet, readSpriteMarks,} from "@spawnite/engine/2d";
// A sword swing drawn from a sprite sheet, built as a creator's plugin// is, from the engine's public entry alone: a predicted command plays the// sheet's swing, and the hit lands on the tick its `hit` frame shows, read// headless, so the room, which loads no image, and the player's page// count it on the same tick. J sends the command.
/** Four 0.1 s frames, the third marked `hit`. */export const swingSheet = defineSpriteSheet({ image: "/sprites/knight.png", frameSize: [16, 16], columns: 4, clips: { swing: { frames: [0, 1, 2, 3], durationSeconds: 0.1, loop: ClipLoopMode.Once, marks: { hit: 2 }, }, },});
/** The hits the character's swings landed, and the tick of the last. */export const SwingTrait = defineTrait( "outsideSwing", { hits: 0, hitTick: -1 }, { predicted: true },);const swingers = createQuery(SwingTrait);
export const swings = definePlugin(() => ({ name: "swings", commands: { swing: defineCommand( {}, { predicted: true, entity: true, keys: ["KeyJ"] }, ), }, onCharacterSpawn: ({ writer, character }) => { addTrait(writer, character, SwingTrait); }, systems: [ defineSystem({ name: "swing", phase: Phase.Input, predicted: true, answers: ["swing"], run: swing, }), ],}));
function swing(step: PredictedStep) { for (const command of readCommands(step, swings.commands.swing)) { playClip(command.entity, "swing"); command.accept(); } step.updateEachPredicted(swingers, ([held], character) => { if (!readSpriteMarks(step, character, swingSheet).includes("hit")) return; held.hits += 1; held.hitTick = step.tick; });}Draw a character as a sprite
Section titled “Draw a character as a sprite”<CharacterSprite> is a character view: list it in characters({ view }), as the sample’s HeroView does, and every character draws as the sheet with no model, capsule or placeholder. It plays idle, walk, run, jump and fall from the character’s movement, and clips renames any of them, such as { run: "dash" }.
With no directions, it mirrors one side view by the way the character moves. With directions={4} it plays walk-down, walk-left, walk-up and walk-right, and with 8 the diagonals too, such as walk-down-left. A sheet with no right draws its left mirrored.
A character sprite never turns with the character’s yaw, so an aim turns the character but not its sprite. aimAt and setPlayerAim face the character to a point, as the player’s aim says, while its sprite shows the way it last moved. Until it first moves, the sprite shows the way it faces. A game whose sprite faces its aim, as a twin-stick shooter’s does, draws its own view with Sprite and picks the clip and flipX from readAimDirection(character, out).
Light a sprite in a tilted world
Section titled “Light a sprite in a tilted world”Under a tilted camera, such as a pixel diorama’s, a sprite stands among lit blocks and trees, so it takes their light and casts their kind of shadow. The following props do it, each off unless set:
litlights the sprite with the scene’s sun, fill and lamps, through a normal bent toward the camera and up byupNormalBlend, 0.6, and across the quad bysideNormalBlend, 0.55, so a lamp beside it rims one edge.wrap, 0.6, keeps a sprite with the sun behind it from going black, andemissiveIntensityadds a faint glow of each texel’s own colour, 0.05 by day and 0.03 at night. A lit sprite takes the look’s fog and the shadows other things cast.castShadowcasts into the sun’s shadow map.truecasts through a proxy: the same frame, stood upright and turned to the sun in the shadow pass, so the shadow is the whole silhouette at every camera yaw.SpriteShadowCasting.Selfcasts the quad the camera sees, as Unity’s Sprites-Diffuse withaddshadowdoes, so the silhouette thins as the camera turns side-on to the sun. A lit sprite that casts through its proxy never shadows itself.leanis the share of the camera’s pitch a sprite facing the camera leans back, from 0, upright, to 1, square to the view. The default of 1 keeps a sprite square to every camera, one looking straight down too. A diorama’s characters stand at 0.lean: { shadow }leans the proxy back from the sun by a share of its elevation.opacityfades the sprite from 1 to 0. A cut out sprite fades through a 4 by 4 ordered dither, so it keeps writing depth and casting a shadow that fades with it.
Unity’s Sprites-Diffuse and Godot’s Sprite3D with shaded light a sprite with plain Lambert on the quad’s normal. The bent normal and the wrap are the reference diorama’s, so a sprite reads as a rounded figure in the low sun. The sprite casts in the sun’s two live cascades and not in its far shadow map, and blobShadow still draws under its feet. CharacterSprite takes the same props.
Leaf cards build a tree’s crown for the same scene. The sample below stands a lit, casting villager in the square and builds an oak’s crown beside it. Its test draws both:
import { useMemo } from "react";import { Color, type Texture } from "three";import { Entity, LeafCards, type LeafCard, type Position,} from "@spawnite/engine";import { defineSpriteSheet, Sprite } from "@spawnite/engine/2d";
// A villager on 32 by 32 frames at the engine's 16 pixels a metre: two// metres tall, as a pixel diorama's characters stand.export const villagerSheet = defineSpriteSheet({ image: "/sprites/villager.png", frameSize: [32, 32], columns: 4, clips: { idle: { frames: [0, 1, 2, 3], durationSeconds: 0.25 } },});
/** A villager standing in the square: lit by the sun and the lamps, * upright under the tilted camera, casting its whole silhouette away from * the sun with a contact blob under its feet. */export function Villager({ position }: { position: Position }) { return ( <Entity position={position}> <Sprite sheet={villagerSheet} clip="idle" upright lean={0} lit castShadow blobShadow /> </Entity> );}
/** An oak's crown: ten 2 m leaf cards on a golden-angle sphere round * `crown`, the lower ones darker, as the inside of a crown is. */export function OakCrown({ crown, leaves,}: { crown: Position; leaves: Texture;}) { const cards = useMemo<LeafCard[]>(() => { const [x, y, z] = crown; return Array.from({ length: 10 }, (_, index) => { const height = 1 - (index + 0.5) / 5; const ring = Math.sqrt(1 - height * height); const angle = index * Math.PI * (3 - Math.sqrt(5)); const shade = 0.75 + 0.25 * ((height + 1) / 2); return { position: [ x + Math.cos(angle) * ring * 1.2, y + height * 0.9, z + Math.sin(angle) * ring * 1.2, ], crownCenter: crown, rollRadians: (index % 3) * 0.2 - 0.2, color: new Color(shade, shade, shade), }; }); }, [crown]); return <LeafCards cards={cards} map={leaves} />;}Leaf cards
Section titled “Leaf cards”<LeafCards> draws a set of square leaf cards in one draw: a crown’s, or a grove’s. Each card’s normal points out from its crownCenter, so the crown shades as one rounded mass, with a lit top and a dark underside. Each card turns to the camera’s yaw about its centre and leans back 0.42 of the camera’s pitch. In the shadow pass it turns to the sun and leans 0.15 of the sun’s elevation, so a crown casts its whole cover at every camera yaw. The cards sway in the look’s wind by their windWeight, as the grass does. For a bed of flowers or crops rather than a crown, upNormalBlend lights each card as a lit sprite, toward the camera and up, and rootDarkening darkens its foot, as a block map’s cover does.
map is the leaf texture, cut out at half alpha, such as a pixel texture’s. The tree a crown belongs to, its trunk and where its cards stand, is yours or a kit’s to build, as the sample’s OakCrown does. The props are in the reference.
A trunk built in code bends with its crown through <WindBendMesh>. Its geometry carries windBendAttribute, two floats a vertex: the vertex’s share of the bend, from 0 at the root to 1 at a free top, and the sway’s phase in turns. Each vertex moves downwind by the leaf cards’ own sway at its share, so a top at 1 moves as a card of windWeight 1 does. The shadow bends with it, the gust is read at the mesh’s origin, and still air, the player’s plant sway setting and WebGPU hold it still. Unreal’s World Position Offset bends foliage by painted weights in the same way. It takes over its material’s onBeforeCompile after the material’s own and grows the bounds three culls it by. The props are in the reference.
Sort by the feet
Section titled “Sort by the feet”Under the scene’s Camera2D, a sprite lies flat on the 2D plane, and a cut out sprite writes depth. Each flat sprite’s depth moves toward the camera by its sort key, which leaves the picture where it was:
sortingLayer, a whole number from −4 to 3, decides first: a roof’s layer covers the characters under it.sortingOrder, from −64 to 63, decides within a layer. A tile layer takes the same two ranges.- The feet decide the rest: a sprite whose feet stand lower on the screen draws in front, in 64 rows down the screen, about 6 texels each on a 360-texel frame.
So a character walks behind a tree whose trunk stands south of the character’s feet, and in front of one north of them, with no sort on the page. Name your layers with a constant of your own, such as const Layer = { Ground: -1, Characters: 0, Roofs: 2 } as const.
A scene’s Alpha sprites sort back to front together, by the same key, across up to eight images. A ninth image’s blended sprites draw in a batch of their own and sort apart from the rest, and a development build warns of it. An Additive sprite needs no order.