Guide

Guide Rendering Sprites

Sprites

Render image-based content from textures, sheets, SVG, and video.

Intro ~3 min read

What you'll learn

  • render textures, sheets, SVG, and video as sprites
  • control anchor, blend mode, and frames

Before you start

Sprites

A Sprite is the primary drawable for textured quads — rectangles that display an image, a sub-region of an image, or a rendered-to-texture surface. Most visible content in a 2D scene passes through sprites.

From texture to screen

A sprite needs a texture. The shortest path from file to visible quad:

import { Scene, Sprite, Texture } from '@codexo/exojs';

class MyScene extends Scene {
    async load(loader) {
        await loader.load(Texture, { hero: 'image/hero.png' });
    }

    init(loader) {
        this.hero = new Sprite(loader.get(Texture, 'hero'));
        this.addChild(this.hero);
    }

    draw(context) {
        context.backend.clear();
        context.render(this.root);
    }
}

The sprite’s rendered size defaults to the texture’s pixel dimensions. The sprite draws a quad from (0, 0) to (texture.width, texture.height) in local space.

Positioning and anchor

A sprite’s position places its anchor point in the parent’s coordinate space. By default the anchor is (0, 0) — the top-left corner — so setPosition(400, 300) places the top-left of the sprite at world position (400, 300).

setAnchor(0.5) centers the anchor at the sprite’s middle, making setPosition(400, 300) place the sprite’s center at (400, 300):

init(loader) {
    const { width, height } = this.app.canvas;

    this.hero = new Sprite(loader.get(Texture, 'hero'));
    this.hero.setAnchor(0.5);
    this.hero.setPosition(width / 2, height / 2);
    this.addChild(this.hero);
}

setAnchor(x, y) accepts two arguments for separate horizontal and vertical anchors. A value of 0 is left/top, 0.5 is center, 1 is right/bottom.

Size and scale

sprite.width and sprite.height report the rendered pixel dimensions (texture frame size multiplied by absolute scale). Setting them adjusts the scale to match:

this.hero.width = 64;  // scale.x becomes 64 / textureFrame.width
this.hero.height = 64; // scale.y becomes 64 / textureFrame.height

setScale(x, y) sets raw scale factors. A scale of (2, 2) doubles the sprite’s visual size; (-1, 1) mirrors it horizontally.

Tinting

sprite.tint is a Color that multiplies the sprite’s pixel colors at draw time. White tint (Color.white) leaves the texture unchanged. A red tint mutes green and blue channels. Tinting does not allocate or create new textures:

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

this.hero.tint = new Color(255, 128, 128, 1); // reddish tint
this.hero.tint = new Color(128, 128, 255, 0.8); // blue tint, 80% opacity

Texture frames

A sprite can display a sub-region of its texture through textureFrame. This is the mechanism behind spritesheet frame selection:

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

// Show only the top-left 32x32 pixels of a 128x128 texture
this.sprite.setTextureFrame(new Rectangle(0, 0, 32, 32));

By default setTextureFrame resets the sprite’s width and height to the frame’s dimensions. Pass false as the second argument to keep the current display size — useful for animation where frame dimensions vary but the on-screen size should stay constant:

this.sprite.setTextureFrame(new Rectangle(32, 0, 32, 32), false);

Call resetTextureFrame() to restore the full texture.

Blend modes

sprite.blendMode controls how the sprite composites with pixels already in the framebuffer. The default is normal alpha blending. Alternative modes (additive, subtract, multiply, screen) are available via BlendModes:

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

this.glow.blendMode = BlendModes.Additive;

Blend modes are per-sprite. Combined with tinting they cover common glow, shadow, and knock-out compositing without needing custom shaders.

Spritesheets

A Spritesheet slices a single texture atlas into named frames and optional animation sequences. It accepts a JSON descriptor in the Aseprite / TexturePacker format:

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

const sheet = new Spritesheet(texture, {
    frames: {
        'walk_01': { frame: { x: 0, y: 0, w: 32, h: 32 } },
        'walk_02': { frame: { x: 32, y: 0, w: 32, h: 32 } },
        'walk_03': { frame: { x: 64, y: 0, w: 32, h: 32 } },
    },
    animations: {
        walk: ['walk_01', 'walk_02', 'walk_03'],
    },
});

// Pre-built Sprite for a single frame
const sprite = sheet.sprites.get('walk_01');
this.addChild(sprite);

// Rectangle for a single frame (useful for textureFrame)
const frame = sheet.frames.get('walk_01');

The animations map feeds into AnimatedSprite.fromSpritesheet(), covered in the Animation chapter.

Non-image textures

Sprite accepts any Texture or RenderTexture. This means sprites can display video frames, SVG rasterizations, off-screen renders, or DataTexture pixel buffers — any source the engine can wrap as a Texture:

// Video as a sprite texture
import { RenderTexture, Sprite, Video } from '@codexo/exojs';

const video = loader.get(Video, 'intro');
const sprite = new Sprite(video.texture);

// A RenderTexture (offscreen render) as a sprite texture
const rt = new RenderTexture(256, 256);
// ... render something into rt ...
const offscreenSprite = new Sprite(rt);

Sprite transforms in the graph

A Sprite inherits from Drawable, which extends RenderNode. This means every sprite carries a full transform (position, rotation, scale, origin) and participates in the scene graph as a node. When a sprite is a child of a rotated Container, it rotates with the container. When it has its own filters array, those filters apply to the sprite’s rendered quad before it composites into the parent.

Collision helpers — sprite.contains(x, y) for point tests and sprite.getBounds() for AABB queries — work on the sprite’s world-space quad and account for rotation.

Examples

Sprite Basics Open in Playground View source
import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(18, 20, 28),
    loader: {
        basePath: 'assets/',
    },
});

class SpriteBasicsScene extends Scene {
    private ship!: Sprite;
    // A single reusable tint colour whose alpha channel we animate each frame.
    private tint = new Color(120, 200, 255, 1);
    private elapsed = 0;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { ship: 'image/ship-a.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.ship = new Sprite(loader.get(Texture, 'ship'));
        this.ship.setPosition((width / 2) | 0, (height / 2) | 0);
        this.ship.setAnchor(0.5);
        this.ship.setScale(3);
        this.ship.setTint(this.tint);

        this.hud = mountControls({
            title: 'Sprite Basics',
            hint: 'One Sprite, four transforms: position drifts, rotation spins, scale pulses, alpha fades.',
            status: 'alpha 1.00',
        });
    }

    override update(delta): void {
        this.elapsed += delta.seconds;

        const { width, height } = this.app.canvas;

        // Position: a gentle figure-eight drift around the canvas centre.
        const driftX = Math.sin(this.elapsed * 0.8) * 90;
        const driftY = Math.sin(this.elapsed * 1.6) * 50;
        this.ship.setPosition(width / 2 + driftX, height / 2 + driftY);

        // Rotation: a steady spin (degrees per second).
        this.ship.rotate(delta.seconds * 90);

        // Scale: a slow breathing pulse between 2.4x and 3.6x.
        this.ship.setScale(3 + Math.sin(this.elapsed * 1.2) * 0.6);

        // Alpha: fade the tint's alpha channel between 0.2 and 1.0 and re-apply.
        const alpha = 0.6 + Math.sin(this.elapsed * 2) * 0.4;
        this.tint.a = alpha;
        this.ship.setTint(this.tint);

        this.hud.setStatus(`alpha ${alpha.toFixed(2)}`);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.ship);
    }
}

app.start(new SpriteBasicsScene());

A single sprite in the center of the canvas, rotating and cycling through tints.

Spritesheet Frames Open in Playground View source
import { Application, Color, Json, Scene, Spritesheet, type SpritesheetData, Texture } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(24, 28, 38),
    loader: {
        basePath: 'assets/',
    },
});

const CHARACTERS = ['beige', 'green', 'pink', 'purple', 'yellow'];

class SpritesheetFramesScene extends Scene {
    private spritesheet!: Spritesheet;
    private character = CHARACTERS[0];
    private frameIndex = 0;
    private fps = 8;
    private elapsed = 0;
    private playing = true;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { characters: 'image/platformer-characters.png' });
        await loader.load(Json, { characters: 'json/platformer-characters.json' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        const texture = loader.get(Texture, 'characters');
        const data = loader.get(Json, 'characters') as SpritesheetData;

        this.spritesheet = new Spritesheet(texture, data);

        // The spritesheet caches one Sprite per named frame; configure them all
        // once so any frame we draw is centred and scaled up for visibility.
        for (const sprite of this.spritesheet.sprites.values()) {
            sprite.setAnchor(0.5);
            sprite.setPosition(width / 2, height / 2);
            sprite.setScale(3);
        }

        this.hud = mountControls({
            title: 'Spritesheet Frames',
            hint: 'A two-frame walk cycle stepped on a timer from named spritesheet frames.',
        });

        const panel = mountControlPanel({ title: 'Animation' });
        panel.addSlider({ label: 'Speed (fps)', min: 1, max: 16, step: 1, value: this.fps, onChange: value => (this.fps = value) });
        panel.addCycle({
            label: 'Character',
            options: CHARACTERS,
            index: 0,
            onChange: (_, name) => {
                this.character = name;
                this.frameIndex = 0;
                this.updateHud();
            },
        });
        panel.addToggle({ label: 'Playing', value: true, onChange: on => (this.playing = on) });

        this.updateHud();
    }

    private walkFrames(): Array<string> {
        return [`character_${this.character}_walk_a`, `character_${this.character}_walk_b`];
    }

    private updateHud(): void {
        const frames = this.walkFrames();

        this.hud.setStatus(`Frame: ${frames[this.frameIndex]}  (${this.frameIndex + 1}/${frames.length})`);
    }

    override update(delta): void {
        if (!this.playing) {
            return;
        }

        this.elapsed += delta.seconds;

        const frameDuration = 1 / this.fps;

        while (this.elapsed >= frameDuration) {
            this.elapsed -= frameDuration;
            this.frameIndex = (this.frameIndex + 1) % this.walkFrames().length;
            this.updateHud();
        }
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.spritesheet.getFrameSprite(this.walkFrames()[this.frameIndex]));
    }
}

app.start(new SpritesheetFramesScene());

Navigating a spritesheet’s frames by name — the texture-frame mechanism in practice.

Where to go next

The next chapter, Text, covers GPU-accelerated text rendering — font loading, styling, layout, and using text nodes in the scene graph.