| name | canvas-game |
| category | frontend |
| description | HTML5 Canvas game development patterns — game loop, rendering, input, state management, and Vite/Vitest tooling. |
When to Use This Skill
- When working on trafficGame or any HTML5 Canvas-based game
- When implementing game loops, rendering pipelines, or input handling
- When writing tests for canvas-based components with Vitest
- When configuring Vite for game asset bundling
Game Loop Pattern
const TICK_RATE = 1000 / 60;
let lastTime = 0;
let accumulator = 0;
function gameLoop(timestamp: number): void {
const delta = timestamp - lastTime;
lastTime = timestamp;
accumulator += delta;
while (accumulator >= TICK_RATE) {
update(TICK_RATE / 1000);
accumulator -= TICK_RATE;
}
const alpha = accumulator / TICK_RATE;
render(alpha);
requestAnimationFrame(gameLoop);
}
Canvas Rendering Best Practices
- Layer canvases: static background + dynamic foreground (avoid redrawing everything)
- Batch draws: group similar draw calls (same fill style, same operation)
- Off-screen canvas: pre-render complex shapes, blit to main canvas
- Transform state: always
save()/restore() around transform blocks
- Pixel-perfect: use
Math.round() for positions to avoid sub-pixel blur
- Device pixel ratio: scale canvas for Retina/HiDPI displays
function setupHiDPI(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
}
Input Handling
- Event delegation: single listener on canvas, not per-entity
- Input buffer: collect inputs per frame, process during update
- Keyboard state map: track pressed keys, check in update loop
const keys = new Set<string>();
canvas.addEventListener('keydown', (e) => keys.add(e.key));
canvas.addEventListener('keyup', (e) => keys.delete(e.key));
if (keys.has('ArrowRight')) player.moveRight(dt);
State Management
- Immutable game state: each tick produces a new state object
- Entity-Component-System (ECS): for complex games with many entity types
- State machine: for game phases (menu → playing → paused → game-over)
Testing with Vitest
const mockCtx = {
fillRect: vi.fn(),
clearRect: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
} as unknown as CanvasRenderingContext2D;
describe('GameState', () => {
it('advances simulation by one tick', () => {
const state = createInitialState();
const next = tick(state, 1/60);
expect(next.frame).toBe(state.frame + 1);
});
});
Vite Configuration for Games
export default defineConfig({
build: {
target: 'es2022',
rollupOptions: {
output: {
manualChunks: undefined,
},
},
},
assetsInclude: ['**/*.png', '**/*.wav', '**/*.ogg'],
});
Playwright E2E for Canvas Games
test('game renders initial state', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('canvas');
await expect(page).toHaveScreenshot('initial-state.png', {
maxDiffPixelRatio: 0.01,
});
});
test('pressing S starts simulation', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('s');
const isRunning = await page.evaluate(() => window.__gameState?.running);
expect(isRunning).toBe(true);
});