This HTML5 game sprite tutorial starts with the one canvas call every 2D game relies on, drawImage, and builds up to sprite sheets, frame animation, pixel-art characters and the export settings that keep images small and sharp. A sprite is any image your game draws and moves. A sprite sheet puts many of those images, usually the frames of an animation, into one file. The code is plain JavaScript for the canvas, with the Phaser 4 equivalent where it saves you work, and we share how the art in our 50 games is made: entirely in code, without a single sprite image file.
This guide is part of our series on HTML5 canvas games, which covers setting up the canvas and the game loop.
Drawing images to the canvas in an HTML5 game
Load the image once, wait until it's decoded, then draw it every frame. decode() returns a promise, so the game never tries to draw an image that isn't ready:
const sheet = new Image();
sheet.src = "hero.png";
await sheet.decode(); // resolves when the image can be drawn
const ctx = canvas.getContext("2d");
ctx.drawImage(sheet, 100, 200); // the whole image at x = 100, y = 200drawImage has three forms, and games mostly use the longest one, because it draws one rectangle of the source image (one frame) into a rectangle on the canvas:
| Form | Arguments | Use |
|---|---|---|
| Position | image, dx, dy | Draw a whole image at its own size |
| Position and size | image, dx, dy, dw, dh | Draw a whole image scaled |
| Source and destination | image, sx, sy, sw, sh, dx, dy, dw, dh | Draw one frame from a sprite sheet |
Two habits prevent most blurry sprites. Draw at whole-pixel positions (Math.round(x)), and for pixel art set ctx.imageSmoothingEnabled = false, which stops the browser from blending pixels when it scales. The MDN reference covers the property.
What is a sprite sheet?
There are two kinds, and they suit different art:
| Grid sprite sheet | Packed texture atlas | |
|---|---|---|
| Layout | Every frame the same size, in rows and columns | Frames of any size, packed tightly |
| Finding a frame | Frame number times frame width | Look up its rectangle by name in a JSON file |
| Wasted space | Can be a lot | Little |
| Typical tools | Any pixel editor, Aseprite | A texture packer, Aseprite, or code |
| Best for | Characters with uniform frames | Everything else: UI, props, effects, mixed sizes |
Either way, the reason to combine images is the same. The game downloads one file instead of hundreds, and on a WebGL renderer such as Phaser's, sprites that share one texture can be drawn in a single batch. Switching textures between sprites breaks the batch, and on phones that's often where the frame time goes.
Leave 1 to 2 pixels of padding between frames. Without it, scaling and rotation can sample a sliver of the neighbouring frame, which shows up as thin lines at sprite edges.
Creating a sprite sheet for an HTML5 and JavaScript game
You have three practical options:
- Draw it as a sheet. In a pixel editor, make a grid of frames of identical size (32 × 32, for example), one animation per row. Simple and easy to read in code.
- Pack separate images. Draw each frame as its own file, then let a texture packer combine them into one image plus a JSON file with every frame's name and rectangle. Phaser reads the common JSON formats directly. Aseprite also exports a sheet plus JSON from an animated file, and Phaser can turn its animation tags into animations with
createFromAseprite. - Generate it in code. Draw every frame with Canvas 2D at startup and pack them into one canvas yourself.
We use the third option in all 50 of our games. None of them ships a sprite image: every texture is drawn by code while the game runs, almost all of it with Canvas 2D. In Neon Survivor, every hero, enemy, boss and pickup is drawn into frames that a simple shelf packer arranges on one canvas 2,048 pixels wide, with 2 pixels of padding. That canvas becomes a single Phaser texture with named frames, so hundreds of sprites on screen share one texture and batch together:
const canvas = makeCanvas(W, H);
const ctx = canvas.getContext("2d")!;
for (const p of placed) {
ctx.save();
ctx.beginPath();
ctx.rect(p.x, p.y, p.s.w, p.s.h);
ctx.clip(); // a frame can never paint into its neighbour
ctx.translate(p.x, p.y);
p.s.draw(ctx, p.s.w, p.s.h);
ctx.restore();
}
const tex = scene.textures.addCanvas(ATLAS, canvas)!;
for (const p of placed) tex.add(p.s.name, 0, p.x, p.y, p.s.w, p.s.h);Generated art suits clean, geometric styles and keeps downloads small: every one of our games has an initial download between 300 and 750 KB. Hand-painted or pixel-art styles are usually drawn by an artist and packed by a tool.
How to save images for HTML5 games you are making
Pick the format by what the image contains:
| Format | Use it for | Notes |
|---|---|---|
| PNG | Pixel art, sprites with hard edges, anything that needs exact pixels | Lossless with transparency; run it through an optimiser |
| WebP | Painted sprites and large atlases | Lossless or lossy, both with transparency; usually smaller than PNG |
| AVIF | Large backgrounds and illustrations | Often the smallest file, but test decode time on phones |
| JPEG | Backgrounds with no transparency | No alpha channel |
| SVG | Icons and UI | Scales cleanly; rasterise it to a texture for drawing in a game |
A few rules save trouble later:
- Keep each sheet at or under 2,048 × 2,048 pixels unless you've checked your targets. WebGL 2 guarantees at least that size; many devices allow more.
- Remember GPU memory. A texture is decoded to raw pixels on the GPU, whatever its file size: a 2,048 × 2,048 RGBA texture takes 16 MB.
- Export smooth art at 2× so it stays sharp on high-density screens. Export pixel art at 1× and scale it up by whole numbers in the game.
- Trim empty borders and store each frame's offset, so a trimmed frame still lines up with its feet on the ground.
- Name frames predictably, like
hero-run-0tohero-run-5, so animations can be built from a name prefix.
Animating a sprite frame by frame
Frame animation means showing a different rectangle of the sheet as time passes. Base the frame on elapsed time, never on how many frames the browser has drawn, or the animation runs twice as fast on a 120 Hz phone:
const FW = 32, FH = 32, SCALE = 3; // frame size in the sheet, and on-screen scale
const ANIMS = {
idle: { row: 0, frames: 4, fps: 6 },
run: { row: 1, frames: 6, fps: 12 },
jump: { row: 2, frames: 2, fps: 8, loop: false },
};
const hero = { x: 100, y: 200, anim: "idle", t: 0, flip: false };
function play(name) {
if (hero.anim !== name) { hero.anim = name; hero.t = 0; } // restart only on change
}
function update(dt) { hero.t += dt; }
function drawHero(ctx) {
const a = ANIMS[hero.anim];
const n = Math.floor(hero.t * a.fps);
const frame = a.loop === false ? Math.min(n, a.frames - 1) : n % a.frames;
ctx.save();
ctx.translate(Math.round(hero.x), Math.round(hero.y)); // (x, y) is the hero's feet
if (hero.flip) ctx.scale(-1, 1); // face left with the same frames
ctx.drawImage(sheet, frame * FW, a.row * FH, FW, FH, (-FW * SCALE) / 2, -FH * SCALE, FW * SCALE, FH * SCALE);
ctx.restore();
}Three details in that code matter in every game. The animation restarts only when it changes, so calling play("run") every frame is safe. The sprite is anchored at its feet, so switching between frames of different poses doesn't make the character hop. And flipping with scale(-1, 1) gives you left-facing animations for free.
Sprite animations in Phaser 4
Phaser does the bookkeeping for you. Load a grid sheet, define the animation once, then play it on any sprite:
// preload()
this.load.spritesheet("hero", "hero.png", { frameWidth: 32, frameHeight: 32 });
// create()
this.anims.create({
key: "run",
frames: this.anims.generateFrameNumbers("hero", { start: 6, end: 11 }),
frameRate: 12,
repeat: -1, // loop forever
});
const hero = this.add.sprite(100, 200, "hero").play("run");For a packed atlas, load it with this.load.atlas(key, imageUrl, jsonUrl) and build frames by name with generateFrameNames. For Aseprite files, this.anims.createFromAseprite(key) creates one animation per tag. The Phaser animation docs cover timing, chaining and events. If you're choosing between a canvas, SVG or DOM elements for your sprites in the first place, see canvas vs SVG vs DOM.
Making animations for HTML5 games without more frames
Frame animation is only one tool. Our games mostly use these, because they cost no extra art:
- Tweens: squash a sprite as it lands, bob a pickup up and down, pop a button when tapped. Phaser's tweens and a few lines of easing code cover most "juice".
- Cut-out animation: build a character from parts and move the parts in code. In Mini Mart Empire, each shopper is a container of tinted parts (legs, body, apron, head, hair, face, hat, carried items). While walking, the legs swing on a sine wave and the body bobs; standing still, the body sways slightly:
const swing = a.moving ? Math.sin(this.phase) : 0;
this.legL.y = -9 - Math.max(0, swing) * 4;
this.legR.y = -9 - Math.max(0, -swing) * 4;
const bob = a.moving ? -Math.abs(Math.sin(this.phase)) * 2.6 : Math.sin(now * 2 + this.phase) * 0.6;
for (const part of [this.body, this.hands, this.apron]) part.y = -14 + bob;- Tiny frame counts tied to movement: guests in Theme Park Manager have a two-frame walk cycle, and the frame is chosen from the distance walked rather than from time, so their feet keep pace with how fast they move. The art is mirrored to cover both diagonal directions, and the cycle stops when the player turns on reduced motion.
- Skeletal animation: tools such as Spine export bones and meshes that a runtime library deforms. It gives smooth, reusable motion for large characters; Spine is a commercial tool, so check its runtime licence terms before you ship.
Create a game character with HTML5 and JavaScript
You don't need a drawing program to design a pixelated game character. Describe it as rows of letters, map each letter to a colour, and draw it once into a small offscreen canvas:
const PALETTE = { k: "#1b1b2f", s: "#f2c39b", w: "#ffffff", r: "#e94560", b: "#3a6ea5" };
const HERO = [
"....kkkk....",
"...kssssk...",
"...kswswk...",
"...kssssk...",
"....kkkk....",
"...krrrrk...",
"..ksrrrrsk..",
"...krrrrk...",
"...kbbbbk...",
"...kb..bk...",
"...kk..kk...",
];
function makeSprite(rows, palette) {
const c = document.createElement("canvas");
c.width = rows[0].length;
c.height = rows.length;
const g = c.getContext("2d");
rows.forEach((row, y) => [...row].forEach((ch, x) => {
if (palette[ch]) { g.fillStyle = palette[ch]; g.fillRect(x, y, 1, 1); }
}));
return c;
}
const heroSprite = makeSprite(HERO, PALETTE);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(heroSprite, 40, 40, heroSprite.width * 4, heroSprite.height * 4); // 4x, crispFor a walk cycle, copy the array, move the legs in the copy and alternate the two sprites. Swap the palette and you have an enemy or a second player. It's the fastest way to create simple pixel HTML5 games and prototypes, and it scales up: our own art pipeline is the same idea with Canvas 2D paths, gradients and shadows instead of letters.
HTML5 8-bit and pixel art game engines
If you want an engine made for small sprite games, these are good starting points in October 2026:
- Phaser 4 with
pixelArt: truein the game config, which turns off antialiasing and rounds sprite positions to whole pixels. Its docs call it "the best setting for pixel-art games". Our 41 Phaser games run on Phaser 4.2.1. - KAPLAY, the MIT-licensed successor to Kaboom.js, for quick, readable sprite games.
- TIC-80, a free and open-source fantasy computer with a 240 × 136 display, a 16-colour palette and 256 sprites of 8 × 8 pixels, which can export games for the web.
- PICO-8, a paid fantasy console with tighter limits that exports a web player.
Developing pixel art on a MacBook Pro or any other high-density screen needs one extra step. The display has 2 or more device pixels per CSS pixel, so scale your art by whole numbers and set image-rendering: pixelated on the canvas. MDN's guide to a crisp pixel art look explains why non-integer zoom levels can still make some pixels look wider than others.
When your sprites work, our guides on isometric HTML5 games and HTML5 game physics cover the next layers, and HTML5 game performance covers profiling. To see finished art and animation running on phones, every one of our games has a playable demo on its product page.
Frequently asked questions
How big should a sprite sheet be?
Keep each sheet at or under 2,048 × 2,048 pixels for broad compatibility, since WebGL 2 guarantees at least that size. Remember that GPU memory depends on pixel count, not file size: a full 2,048 × 2,048 sheet uses 16 MB once decoded.
Should I use one image per sprite or a sprite sheet?
Use sheets or atlases for anything drawn often. They load as one file and let a WebGL renderer draw many sprites in one batch. Separate images are fine for a few large, rarely drawn pictures such as a title screen.
How many frames does a walk cycle need?
Fewer than you think. Many games use 4 to 8 frames at 8 to 12 frames per second. Small characters can manage with 2 frames if the frame changes with distance walked, which is what our Theme Park Manager guests do.
Why does my pixel art look blurry in the browser?
Usually one of three things: image smoothing is on (set imageSmoothingEnabled to false, or pixelArt: true in Phaser), the scale isn't a whole number, or the sprite is drawn at a fractional position. Round positions and scale by 2, 3 or 4.
We make and sell HTML5 game source code; links to our games are marked as ours.