Animation brings a game world to life. A character that slides across the screen feels like a placeholder; a character that runs, jumps, and idles with fluid animation feels like a character. Browser games have excellent animation tools built into the platform — canvas, WebGL, and requestAnimationFrame give you everything you need.
A sprite sheet packs multiple animation frames into a single image. Instead of loading 12 separate PNG files for a walk cycle, you load one sheet and draw a different region of it each frame. This reduces HTTP requests (critical for load time) and keeps all frames in a single GPU texture (critical for render performance).
class SpriteSheet {
constructor(image, frameWidth, frameHeight, columns) {
this.image = image;
this.fw = frameWidth;
this.fh = frameHeight;
this.cols = columns;
}
draw(ctx, frameIndex, x, y) {
const sx = (frameIndex % this.cols) * this.fw;
const sy = Math.floor(frameIndex / this.cols) * this.fh;
ctx.drawImage(this.image, sx, sy, this.fw, this.fh, x, y, this.fw, this.fh);
}
}
Do not advance animation frames every render frame. At 60 FPS, a 6-frame walk cycle would complete in 100ms — impossibly fast. Instead, use a frame timer that advances at a configurable speed:
class Animation {
constructor(frames, frameDuration) {
this.frames = frames; // array of frame indices
this.duration = frameDuration; // ms per frame
this.elapsed = 0;
this.index = 0;
this.loop = true;
}
update(dt) {
this.elapsed += dt;
while (this.elapsed >= this.duration) {
this.elapsed -= this.duration;
this.index++;
if (this.index >= this.frames.length) {
this.index = this.loop ? 0 : this.frames.length - 1;
}
}
}
currentFrame() {
return this.frames[this.index];
}
}
A frame duration of 100-150ms per frame works well for walk cycles. Faster actions (sword swings, explosions) use 50-80ms. Idle animations use 200-300ms for a relaxed feel.
Characters have multiple animations: idle, walk, run, jump, fall, attack, hurt, die. A state machine manages which animation plays and handles transitions between them:
class AnimationController {
constructor() {
this.animations = {};
this.current = null;
}
add(name, animation) {
this.animations[name] = animation;
}
play(name) {
if (this.current === name) return;
this.current = name;
this.animations[name].index = 0;
this.animations[name].elapsed = 0;
}
update(dt) {
if (this.current) {
this.animations[this.current].update(dt);
}
}
frame() {
return this.animations[this.current]?.currentFrame() ?? 0;
}
}
Transition logic belongs in the game update, not the animation system. The game decides "player is moving and grounded, play walk" or "player velocity.y > 0, play fall." The animation system just plays what it is told.
Top-down and isometric games need sprites facing multiple directions. The common approach: a sprite sheet with rows for each direction (down, left, right, up) and columns for each frame in the animation.
For 2D side-scrollers, flip the sprite horizontally for the opposite direction instead of drawing separate left-facing frames. Canvas supports this with negative scale:
function drawFlipped(ctx, image, sx, sy, sw, sh, dx, dy) {
ctx.save();
ctx.translate(dx + sw, dy);
ctx.scale(-1, 1);
ctx.drawImage(image, sx, sy, sw, sh, 0, 0, sw, sh);
ctx.restore();
}
Not everything is sprite-based. UI elements, camera transitions, damage numbers, and particle effects use tweened animations — smoothly interpolating a value from start to end over a duration.
function easeOutQuad(t) { return t * (2 - t); }
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
class Tween {
constructor(from, to, duration, easing = t => t) {
this.from = from;
this.to = to;
this.duration = duration;
this.easing = easing;
this.elapsed = 0;
}
update(dt) {
this.elapsed = Math.min(this.elapsed + dt, this.duration);
const t = this.easing(this.elapsed / this.duration);
return this.from + (this.to - this.from) * t;
}
done() { return this.elapsed >= this.duration; }
}
For indie browser games, asset tools that work well: