Pixel art
For pixel art, turn off smoothing, draw at a small fixed logical resolution, and scale it up by a whole number. spryte snaps unrotated sprites to whole pixels, so edges don't shimmer as things move.
import { createRenderer } from "spryte";
const W = 320;
const H = 180;
const canvas = document.querySelector("canvas")!;
const draw = createRenderer(canvas, { smooth: false });
function fit(): number {
const dpr = devicePixelRatio || 1;
const fitScale = Math.min((innerWidth * dpr) / W, (innerHeight * dpr) / H);
// whole backing pixels per game pixel
const scale = Math.max(1, Math.floor(fitScale));
if (canvas.width !== W * scale || canvas.height !== H * scale) {
canvas.width = W * scale;
canvas.height = H * scale;
canvas.style.width = `${(W * scale) / dpr}px`;
canvas.style.height = `${(H * scale) / dpr}px`;
draw.resize(canvas.width, canvas.height);
}
return scale;
}
function frame() {
const scale = fit();
draw.begin("#1a1c2c", W, H, scale);
draw.sprite(hero, player.x, player.y);
draw.end();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);smooth: false
createRenderer(canvas, { smooth: false }) makes nearest-neighbour filtering the default, so each texel stays a hard-edged block when scaled. You can change the default later with draw.smooth, and override it per sprite:
// crisp, from the default
draw.sprite(hero, x, y);
// soft on purpose
draw.sprite(glowTarget, 0, 0, { origin: [0, 0], smooth: true });A soft upscaled glow or a blurred background layer next to crisp sprites is a common mix.
Logical size and backing size
Two sizes are in play:
- Logical size: the units you draw in, given to
begin(). Here, 320×180. Positions, sprite sizes and text all use these units. - Backing size:
canvas.widthandcanvas.height, the real pixels. You set these and tell spryte withdraw.resize().
The fourth argument to begin() is backing pixels per logical unit, canvas.width / W. With a whole-number scale, every game pixel covers the same square of screen pixels. A fractional scale makes some game pixels one screen pixel wider than others, which is visible on scrolling pixel art.
The example above includes devicePixelRatio in the scale and then sizes the canvas in CSS pixels by dividing back out, so the integer scale is in device pixels, the ones that end up on screen. Letterbox the canvas with CSS (centre it on a dark background) to fill the rest of the window.
Pixel snapping
With smoothing off, draw.sprite() places unrotated sprites on whole pixels. Without that, nearest sampling at fractional positions makes edges gain and lose a pixel from frame to frame, which reads as flicker. You can keep positions as floats for smooth physics and let spryte do the rounding at draw time.
- Snapping applies when the sprite's effective
smoothis false and the sprite isn't rotated (by its ownrotationor the current transform). draw.sprites()snaps each unrotated copy the same way, so bulk particles match asprite()loop pixel for pixel.- If you want movement in whole game pixels only, round positions yourself before drawing.
Crisp text
CSS text is rasterised at the scale you pass to begin(), so it's sharp at the backing resolution rather than blurred up from 320×180. That suits UI text over pixel art.
For text that matches the art, use a BitmapFont built from a glyph sheet. Glyphs are drawn without smoothing and on whole pixels, so it stays crisp at any integer scale:
import { BitmapFont } from "spryte";
const font = new BitmapFont({ frames: fontFrames, chars: BitmapFont.ASCII });
draw.font = font; // default for text() and measure()
draw.text("LIVES 3", 4, 4, { color: "#f4f4f4" });Shapes and text shows how to build fontFrames from a grid and every BitmapFont option.
Rotation and scaling
Rotated sprites aren't snapped, and nearest filtering on a rotated sprite gives jagged edges. That's the expected look for rotated pixel art at low resolution. If you want a smoother result for one sprite, pass smooth: true on it. Scale sprites by whole numbers (scaleX: 2) to keep every texel the same size.
Next: Migrating from PixiJS