Quando ho visto per la prima volta il codice sorgente di un gioco Canvas, mi aspettavo qualcosa di complesso e monolitico. Invece era sorprendentemente lineare: un ciclo, un contesto grafico, qualche oggetto. La complessità non sta nella tecnologia - sta nel capire perché le cose sono strutturate in un certo modo.
Questa guida parte da zero e arriva a un gioco funzionante. Non useremo nessuna libreria esterna. Tutti i concetti che vediamo si applicano a qualunque gioco 2D - che tu voglia ricostruire Space Invaders, Pac-Man o inventare qualcosa di completamente nuovo.
Cos'è HTML5 Canvas e perché è perfetto per i giochi
L'elemento <canvas> è una superficie di disegno bitmap
direttamente nel browser. Non ha DOM, non ha elementi figli,
non ha CSS che agisce sul contenuto interno: è un rettangolo di pixel
su cui puoi disegnare qualunque cosa tramite JavaScript.
Questa semplicità è esattamente ciò che lo rende ideale per i giochi. Nei giochi, il rendering avviene frame per frame: ogni frame si cancella tutto e si ridisegna da capo. Il DOM di HTML è ottimizzato per documenti che cambiano raramente - non per aggiornamenti a 60 frame al secondo. Canvas non ha questo problema: è pensato per il ridisegno continuo.
Il setup minimo per iniziare è questo:
<!-- HTML -->
<canvas id="game" width="800" height="600"></canvas>
<!-- JavaScript -->
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// Ora puoi disegnare:
ctx.fillStyle = '#FFD700';
ctx.fillRect(100, 100, 80, 80); // x, y, width, height
L'oggetto ctx (il rendering context 2D)
è il tuo pennello. Ha metodi per disegnare rettangoli, cerchi,
linee, testo, immagini, percorsi arbitrari. Tutto passa attraverso di lui.
Conoscerlo bene è il primo passo per costruire qualsiasi gioco Canvas.
Ecco le operazioni di disegno che userai il 90% delle volte:
ctx.fillRect(x, y, w, h)- rettangolo pienoctx.strokeRect(x, y, w, h)- rettangolo solo bordoctx.clearRect(x, y, w, h)- cancella un'area (usato ogni frame)ctx.fillText(testo, x, y)- testoctx.drawImage(img, x, y, w, h)- immagine o sprite sheetctx.arc(x, y, raggio, 0, Math.PI * 2)- cerchio (poifill()ostroke())
Il game loop: requestAnimationFrame e delta time
Il game loop è il concetto più importante nel game development. È il ciclo infinito che fa vivere il gioco: aggiorna la logica, disegna il risultato, ripete. 60 volte al secondo, idealmente. Senza un game loop, il tuo gioco è uno screenshot.
In JavaScript, il modo corretto di costruire un game loop è con
requestAnimationFrame:
function gameLoop(timestamp) {
update(timestamp); // aggiorna logica
render(); // disegna
requestAnimationFrame(gameLoop); // pianifica il prossimo frame
}
requestAnimationFrame(gameLoop); // avvia il loop
requestAnimationFrame è superiore a setInterval
per tre motivi: si sincronizza con il refresh rate del monitor (60/120Hz),
si mette in pausa automaticamente quando la tab non è visibile,
e fornisce un timestamp ad alta precisione che puoi usare per
calcolare il delta time.
Il delta time è la differenza di tempo tra un frame e il successivo. È fondamentale per rendere il gioco indipendente dalla velocità del dispositivo: su un computer lento il gioco va a meno frame al secondo, ma gli oggetti si devono muovere alla stessa velocità percepita.
let lastTime = 0;
function gameLoop(timestamp) {
const dt = (timestamp - lastTime) / 1000; // secondi dall'ultimo frame
lastTime = timestamp;
update(dt); // la logica usa dt per calcolare i movimenti
render();
requestAnimationFrame(gameLoop);
}
// Esempio: un oggetto che si muove a 200px/secondo
// indipendentemente dal frame rate
function update(dt) {
player.x += player.speed * dt; // 200 * 0.016 = 3.2px per frame a 60fps
}
«Un gioco senza delta time è un gioco che funziona solo sul computer di chi l'ha sviluppato.»
- principio fondamentale del game loop
Disegnare e animare sprite con Canvas 2D
I giochi retro usano sprite sheet: immagini singole che contengono tutti i frame di animazione di un personaggio, affiancati. Invece di caricare decine di immagini separate, carichi una sola immagine e mostri una porzione diversa ogni frame.
Il metodo Canvas per farlo è drawImage nella sua versione
a 9 parametri:
// drawImage(immagine, sx, sy, sw, sh, dx, dy, dw, dh)
// sx,sy = coordinate del frame nello sprite sheet (sorgente)
// sw,sh = dimensioni del frame nello sprite sheet
// dx,dy = dove disegnarlo sul canvas (destinazione)
// dw,dh = dimensioni di output (puoi scalare)
const FRAME_W = 16; // ogni frame è 16x16 pixel
const FRAME_H = 16;
let frame = 0; // frame corrente dell'animazione
function drawPlayer(ctx, spriteSheet, x, y) {
ctx.drawImage(
spriteSheet,
frame * FRAME_W, 0, // sx, sy: salta al frame corretto
FRAME_W, FRAME_H, // sw, sh: dimensioni del frame
x, y, // dx, dy: posizione sul canvas
FRAME_W * 3, FRAME_H * 3 // dw, dh: scala 3x
);
}
Per animare, incrementi frame a intervalli regolari.
Non a ogni game loop (60fps è troppo veloce per un'animazione visibile)
ma ogni N millisecondi:
const ANIM_SPEED = 150; // ms tra un frame e il successivo
let animTimer = 0;
const TOTAL_FRAMES = 4;
function update(dt) {
animTimer += dt * 1000; // converti in ms
if (animTimer >= ANIM_SPEED) {
frame = (frame + 1) % TOTAL_FRAMES; // cicla tra 0,1,2,3
animTimer = 0;
}
}
Se non hai sprite sheet ma stai costruendo un prototipo,
puoi disegnare forme geometriche direttamente con Canvas.
Space Invaders è interamente costruito con
fillRect e arc - zero immagini esterne.
Gestire l'input: tastiera, touch e stato dei tasti
L'errore più comune dei principianti: gestire l'input direttamente
negli event listener, muovendo l'oggetto dentro keydown.
Il problema è che keydown scatta una volta (poi con ritardo)
- non ogni frame. Il risultato è un movimento a scatti.
Il pattern corretto è il key state pattern: gli event listener aggiornano uno stato (questo tasto è premuto o no?), e il game loop legge quello stato ogni frame:
// Stato dei tasti: true = premuto, false = rilasciato
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
e.preventDefault(); // evita scroll della pagina con le frecce
});
document.addEventListener('keyup', e => {
keys[e.code] = false;
});
// Nel game loop, ogni frame:
function update(dt) {
if (keys['ArrowLeft']) player.x -= player.speed * dt;
if (keys['ArrowRight']) player.x += player.speed * dt;
if (keys['Space']) player.shoot();
}
Per il touch su mobile, la strategia è analoga: mappa i touch su azioni logiche, non su coordinate assolute. Un approccio semplice è registrare quale zona dello schermo è stata toccata (sinistra/destra/centro):
canvas.addEventListener('touchstart', e => {
const touch = e.touches[0];
const x = touch.clientX;
const midX = canvas.width / 2;
if (x < midX * 0.4) keys['ArrowLeft'] = true;
else if (x > midX * 1.6) keys['ArrowRight'] = true;
else keys['Space'] = true;
}, { passive: false });
canvas.addEventListener('touchend', () => {
keys['ArrowLeft'] = keys['ArrowRight'] = keys['Space'] = false;
});
Questo approccio unifica tastiera e touch nello stesso stato, così il game loop non deve distinguere tra i due metodi di input.
Collision detection: AABB, circle e quando usarla
La collision detection risponde a una domanda semplice: questi due oggetti si stanno toccando? La risposta dipende dalla forma degli oggetti e dal livello di precisione richiesto. Per il 90% dei giochi retro 2D, due tecniche coprono tutto:
AABB - Axis-Aligned Bounding Box. Ogni oggetto ha un rettangolo di hitbox (x, y, width, height). Due rettangoli si sovrappongono se e solo se:
function collideAABB(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
// Esempio: proiettile che colpisce un nemico
if (collideAABB(bullet, enemy)) {
enemy.destroy();
bullet.active = false;
}
L'AABB è la tecnica più veloce computazionalmente ed è sufficiente per la maggior parte dei giochi. Space Invaders, Breakout, Tetris - tutti usano AABB o una sua variante semplificata.
Circle collision. Per oggetti rotondi (Pac-Man, asteroidi, palle di Pong), la collision circolare è più accurata: due cerchi si sovrappongono se la distanza tra i loro centri è minore della somma dei loro raggi.
function collideCircle(a, b) {
const dx = (a.x + a.radius) - (b.x + b.radius);
const dy = (a.y + a.radius) - (b.y + b.radius);
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < a.radius + b.radius;
}
Nota: Math.sqrt è costoso computazionalmente.
Se controlli molte collisioni per frame, puoi ottimizzare
confrontando i quadrati delle distanze invece delle distanze:
function collideCircleFast(a, b) {
const dx = (a.x + a.radius) - (b.x + b.radius);
const dy = (a.y + a.radius) - (b.y + b.radius);
const sumR = a.radius + b.radius;
return (dx * dx + dy * dy) < (sumR * sumR); // niente sqrt!
}
Per giochi più complessi, esiste la spatial partitioning (quadtree, griglia spaziale): invece di controllare ogni oggetto contro tutti gli altri (O(n²)), dividi lo spazio in celle e controlli solo gli oggetti nella stessa cella. Necessario quando hai centinaia di nemici su schermo - non per la maggior parte dei retro game.
Guarda questi concetti in azione
Space Invaders su PixelPrompt è costruito con esattamente le tecniche di questa guida: game loop con requestAnimationFrame e delta time, key state pattern per l'input, AABB per le collisioni proiettile-alieno, canvas 2D puro senza librerie. Apri i DevTools e leggi il codice - è tutto lì.
Gioca a Space Invaders →Da zero a gioco: struttura di un progetto reale
Conoscere i singoli concetti non basta - serve sapere come metterli insieme. Un gioco Canvas ben strutturato ha questa architettura di base:
- main.js (o game.js) - il punto di ingresso. Crea il canvas, istanzia il gioco, avvia il loop. Non contiene logica di gameplay.
-
Classe Game - gestisce lo stato globale:
quale schermata è attiva (menu, gioco, game over),
il punteggio, le vite, il livello corrente.
Contiene il game loop e chiama
update()erender()sugli oggetti di gioco. -
Classe Player - gestisce posizione, velocità,
animazione e stato del giocatore. Ha metodi
update(dt)edraw(ctx). Non legge l'input direttamente: riceve lo stato dei tasti come parametro. - Classe Enemy / Bullet / etc. - stesso pattern di Player. Ogni entità di gioco è responsabile solo di se stessa. La collision detection è responsabilità della classe Game (che ha visibilità su tutti gli oggetti).
- InputManager - gestisce gli event listener e espone lo stato dei tasti. Può gestire tastiera e touch nello stesso oggetto, così il resto del gioco non sa né vuole sapere da dove arriva l'input.
Il pattern update(dt) / draw(ctx) su ogni oggetto
è il cuore di questa architettura. Il game loop diventa semplicissimo:
update(dt) {
this.player.update(dt, this.input.state);
this.enemies.forEach(e => e.update(dt));
this.bullets.forEach(b => b.update(dt));
this.checkCollisions();
this.cleanupInactive();
}
render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.player.draw(this.ctx);
this.enemies.forEach(e => e.draw(this.ctx));
this.bullets.forEach(b => b.draw(this.ctx));
this.drawHUD();
}
Leggibile, prevedibile, estendibile. Quando vuoi aggiungere
un power-up, crei una classe PowerUp con gli stessi
metodi update e draw, la aggiungi agli array,
e il game loop la gestisce automaticamente.
Un ultimo consiglio pratico: costruisci la versione più semplice possibile che funziona prima di aggiungere qualsiasi feature. Un quadrato che si muove con le frecce e rimbalza sui bordi è già un gioco. Ogni feature che aggiungi deve avere uno scopo preciso nel gameplay - non aggiungere complessità perché puoi, ma perché migliora l'esperienza.
I migliori giochi retro erano eccellenti non perché avevano molte feature, ma perché le poche feature che avevano erano perfettamente bilanciate. Space Invaders ha tre meccaniche: muoversi, sparare, non farsi colpire. Tutto il resto è variazione su questi tre temi. Questa semplicità non è un limite - è game design al suo meglio.
Ti è piaciuta questa guida su HTML5 Canvas?
Ogni settimana pubblichiamo un nuovo articolo tra codice, storia e game design del retro gaming. Dal pixel art alle meccaniche di difficoltà degli anni 80 - iscriviti per non perderti il prossimo.
Zero spam. Solo bit di qualità. Puoi cancellarti quando vuoi.
Parliamone su Discord
Stai costruendo un gioco con Canvas? Condividi il progetto e chiedi consigli nella community.