Skip to content

Principes fondamentaux de la boucle de jeu Canvas 2D

La boucle de jeu est le cœur de votre jeu. Elle s'exécute en continu, met à jour l'état du jeu et dessine les images aussi rapidement que le navigateur le permet. Si vous la concevez correctement, tout le reste devient plus simple. Dans le cas contraire, vous chercherez des bugs qui n'apparaissent que sur certaines machines.

Essayez (utilisez les touches fléchées ou ZQSD/WASD) :

La version la plus simple

Voici une boucle de jeu réduite à l'essentiel :

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

function gameLoop() {
  update()
  render()
  requestAnimationFrame(gameLoop)
}

function update() {
  // Logique du jeu ici
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // Dessin ici
}

gameLoop()

Cela fonctionne, mais il y a un problème. La fonction update ne sait pas combien de temps s'est écoulé depuis l'image précédente. Sur un écran à 144 Hz, elle s'exécute deux fois plus vite que sur un écran à 60 Hz. La physique de votre jeu se comportera donc différemment selon les machines.

Le delta time corrige les problèmes de fréquence d'images

Pour que votre jeu s'exécute partout à la même vitesse, vous devez mesurer le temps écoulé entre deux images. C'est ce qu'on appelle le delta time, généralement abrégé en dt.

js
let lastTime = 0

function gameLoop(currentTime) {
  const dt = (currentTime - lastTime) / 1000 // convertir en secondes
  lastTime = currentTime
  
  update(dt)
  render()
  
  requestAnimationFrame(gameLoop)
}

function update(dt) {
  player.x += player.speed * dt // fonctionne désormais à toute fréquence d'images
}

requestAnimationFrame(gameLoop)

La fonction de rappel de requestAnimationFrame reçoit l'horodatage actuel en millisecondes. Je le divise par 1 000 pour obtenir des secondes, car cela simplifie les calculs. Une vitesse de 200 signifie 200 pixels par seconde, ce qui est intuitif.

Attention toutefois : lors de la première image, lastTime vaut 0, donc dt sera énorme. Certaines personnes initialisent lastTime avec performance.now() avant de démarrer la boucle, tandis que d'autres ignorent la mise à jour de la première image.

Pourquoi requestAnimationFrame plutôt que setInterval

Vous vous demandez peut-être pourquoi nous utilisons requestAnimationFrame plutôt que setInterval. Il y a plusieurs raisons.

Tout d'abord, requestAnimationFrame se synchronise avec la fréquence de rafraîchissement de l'écran, ce qui produit une animation fluide sans déchirement. Ensuite, il se met automatiquement en pause lorsque l'onglet est masqué, ce qui économise la batterie et le processeur. Enfin, le navigateur peut mieux l'optimiser.

Ce comportement de mise en pause est généralement souhaitable, mais il signifie que votre jeu se fige lorsque l'utilisateur change d'onglet. Si vous créez un jeu multijoueur ou quelque chose qui doit continuer à fonctionner, vous devrez adopter une autre approche.

Un pas de temps fixe pour la physique

Un delta time variable convient aux déplacements simples, mais pose des problèmes dans les simulations physiques. Si le calcul d'une image prend trop de temps, votre personnage risque de traverser un mur parce que la détection de collision n'a pas capté le moment de l'impact.

La solution consiste à utiliser un pas de temps fixe. Vous accumulez le temps et exécutez les mises à jour physiques à une fréquence fixe, quelle que soit la vitesse de rendu des images.

js
const FIXED_DT = 1 / 60 // 60 mises à jour physiques par seconde
let accumulator = 0
let lastTime = 0

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator >= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

Ce Math.min(..., 0.25) est important. Si l'onglet était masqué puis redevient actif, frameTime pourrait être énorme. Sans cette limite, la boucle while exécuterait des centaines de mises à jour pour tenter de rattraper le retard, ce qui figerait le jeu. Une limite de 0,25 seconde garantit que vous n'exécuterez jamais plus de 15 mises à jour physiques par image.

Organiser l'état du jeu

À mesure que votre jeu grandit, vous devez disposer d'un emplacement pour stocker toutes les données. J'aime utiliser un objet unique qui contient tout :

js
const game = {
  state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
  score: 0,
  player: {
    x: 100,
    y: 100,
    vx: 0,
    vy: 0,
    speed: 200,
    width: 32,
    height: 32,
  },
  entities: [],
}

Cela facilite la sauvegarde et le chargement de l'état du jeu, sa réinitialisation lorsque le joueur meurt, ainsi que le débogage en affichant game dans la console. Certaines personnes préfèrent les classes, mais un simple objet convient parfaitement aux jeux de petite ou moyenne taille.

Tout assembler

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0

const input = { left: false, right: false, up: false, down: false }

const game = {
  player: { x: 100, y: 100, speed: 200 },
}

function update(dt) {
  const p = game.player
  
  if (input.left) p.x -= p.speed * dt
  if (input.right) p.x += p.speed * dt
  if (input.up) p.y -= p.speed * dt
  if (input.down) p.y += p.speed * dt
  
  // Maintenir le joueur dans les limites
  p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
  p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}

function render() {
  ctx.fillStyle = '#1a1a2e'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#4ade80'
  ctx.fillRect(game.player.x, game.player.y, 32, 32)
}

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator >= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

// Gestion des entrées
window.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})

window.addEventListener('keyup', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})

requestAnimationFrame(gameLoop)

Il s'agit d'une boucle de jeu complète et exécutable. Le joueur se déplace avec les touches fléchées ou ZQSD/WASD, reste dans les limites et se déplace à une vitesse constante, quelle que soit la fréquence d'images. Vous pouvez copier ce code comme point de départ pour n'importe quel jeu Canvas 2D.

Remarquez que la gestion des entrées est séparée de la boucle de jeu. Les écouteurs d'événements se contentent de définir des indicateurs, puis la fonction de mise à jour lit ces indicateurs. Ce découplage est important, car les événements du clavier se déclenchent à des moments imprévisibles, tandis que la logique du jeu doit s'exécuter à une fréquence prévisible.

Charger et dessiner des sprites

js
const sprites = {}

async function loadSprite(name, url) {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => {
      sprites[name] = img
      resolve(img)
    }
    img.src = url
  })
}

function drawSprite(name, x, y, width, height) {
  const sprite = sprites[name]
  if (sprite) {
    ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
  }
}

Les images se chargent de manière asynchrone. Vous devez donc attendre qu'elles soient prêtes avant de démarrer votre jeu. Une approche courante consiste à charger tous les sprites dans une fonction loadAssets qui renvoie une Promise, puis à démarrer la boucle de jeu uniquement lorsque celle-ci est résolue.

La fonction drawSprite vérifie que le sprite existe avant de le dessiner. Cela évite les erreurs si vous tentez de dessiner un élément qui n'est pas encore chargé.

Sprites animés

js
class AnimatedSprite {
  constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
    this.image = image
    this.frameWidth = frameWidth
    this.frameHeight = frameHeight
    this.frameCount = frameCount
    this.frameDuration = 1 / fps
    this.currentFrame = 0
    this.elapsed = 0
  }
  
  update(dt) {
    this.elapsed += dt
    if (this.elapsed >= this.frameDuration) {
      this.currentFrame = (this.currentFrame + 1) % this.frameCount
      this.elapsed = 0
    }
  }
  
  draw(ctx, x, y) {
    ctx.drawImage(
      this.image,
      this.currentFrame * this.frameWidth, 0,
      this.frameWidth, this.frameHeight,
      x, y,
      this.frameWidth, this.frameHeight
    )
  }
}

Cette classe suppose que les images de votre feuille de sprites sont disposées horizontalement sur une seule ligne. Chaque image a la même taille. La méthode update passe d'une image à la suivante en fonction du temps écoulé, tandis que draw utilise la version à neuf arguments de drawImage pour ne découper que l'image actuelle.

Vous l'utiliseriez comme ceci :

js
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// Dans update :
playerWalk.update(dt)

// Dans render :
playerWalk.draw(ctx, player.x, player.y)

Détection des collisions

js
function rectCollision(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
  )
}

function circleCollision(a, b) {
  const dx = a.x - b.x
  const dy = a.y - b.y
  const distance = Math.sqrt(dx * dx + dy * dy)
  return distance < a.radius + b.radius
}

// Dans update :
for (const enemy of game.enemies) {
  if (rectCollision(game.player, enemy)) {
    handleCollision(game.player, enemy)
  }
}

La collision entre rectangles, également appelée AABB pour « axis-aligned bounding box », est l'option la plus simple et la plus rapide. Utilisez-la dans la plupart des cas. La collision entre cercles convient mieux aux objets ronds ou lorsque vous souhaitez des collisions plus indulgentes.

Pour un petit nombre d'entités, vous pouvez sans problème vérifier chaque paire. Si vous avez des centaines d'entités, vous devrez utiliser un partitionnement spatial, comme des arbres quaternaires ou une grille, mais ce sera le sujet d'un autre tutoriel.

Une caméra simple

js
const camera = {
  x: 0,
  y: 0,
  
  follow(target) {
    this.x = target.x - canvas.width / 2
    this.y = target.y - canvas.height / 2
  }
}

function render() {
  ctx.save()
  ctx.translate(-camera.x, -camera.y)
  
  // Dessiner le monde
  drawWorld()
  drawEntities()
  
  ctx.restore()
  
  // Dessiner l'interface utilisateur (non affectée par la caméra)
  drawUI()
}

L'astuce repose sur ctx.save() et ctx.restore(). Vous enregistrez l'état actuel de la transformation, appliquez le décalage de la caméra avec translate, dessinez tous les éléments dans l'espace du monde, puis restaurez l'état afin que la caméra n'affecte pas les éléments de l'interface utilisateur.

Pour obtenir un déplacement de caméra plus fluide, vous pouvez effectuer une interpolation linéaire vers la cible plutôt que de vous y placer instantanément. Une expression comme this.x += (targetX - this.x) * 0.1 produit un agréable effet de suivi avec retard.

Mettre le jeu en pause

js
let paused = false

window.addEventListener('keydown', (e) => {
  if (e.code === 'Escape') {
    paused = !paused
  }
})

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  if (!paused) {
    accumulator += frameTime
    while (accumulator >= FIXED_DT) {
      update(FIXED_DT)
      accumulator -= FIXED_DT
    }
  }
  
  render()
  
  if (paused) {
    renderPauseOverlay()
  }
  
  requestAnimationFrame(gameLoop)
}

function renderPauseOverlay() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#fff'
  ctx.font = '32px sans-serif'
  ctx.textAlign = 'center'
  ctx.fillText('PAUSE', canvas.width / 2, canvas.height / 2)
}

L'idée essentielle est d'arrêter d'accumuler le temps lorsque le jeu est en pause, tout en continuant le rendu. Ainsi, la surimpression de pause apparaît immédiatement et le jeu reste réactif à la commande permettant de reprendre la partie.

Vous pouvez étendre cette approche pour gérer plusieurs états du jeu, comme les menus, les cinématiques et les écrans de fin de partie. Il suffit de vérifier l'état actuel et de rediriger l'exécution vers différentes fonctions de mise à jour et de rendu.

Erreurs courantes

Voici quelques difficultés que j'ai souvent vu poser problème :

Oublier d'effacer le canevas. Si vous n'appelez pas clearRect au début du rendu, vous dessinerez par-dessus l'image précédente et obtiendrez un effet de traînée.

Ne pas limiter le delta time. Si quelqu'un change d'onglet puis revient, un delta time énorme peut perturber votre physique ou obliger le jeu à « rattraper » plusieurs secondes de retard.

Dessiner avant le chargement des images. Canvas ignore silencieusement les appels à drawImage utilisant des images qui ne sont pas encore chargées. Vos sprites n'apparaîtront tout simplement pas, sans aucun message d'erreur.

Utiliser setInterval pour la boucle. Cette fonction ne se synchronise pas avec l'écran et ne se met pas en pause lorsque l'onglet est masqué. Utilisez requestAnimationFrame.

Mélanger les coordonnées du monde et de l'écran. Lorsque vous ajoutez une caméra, veillez à savoir si une position se trouve dans l'espace du monde ou dans celui de l'écran. Les clics de souris utilisent les coordonnées de l'écran et doivent être convertis.

Pour aller plus loin

Ce tutoriel couvre les principes fondamentaux. Une fois ces approches maîtrisées, vous pourrez explorer les sujets suivants :

Canvas de jeu adaptatif pour gérer différentes tailles d'écran et les affichages à haute densité de pixels.

Gestion des entrées de jeu pour les commandes tactiles, les manettes et les systèmes d'entrée plus avancés.

Rendu du pixel art pour obtenir des graphismes en pixel art nets et sans flou.

Bibliothèques de physique pour les jeux pour ajouter la physique des corps rigides, des collisions et des articulations à votre boucle de jeu.

Si vous hésitez entre Canvas 2D, WebGL et WebGPU pour votre projet, le guide Technologies des jeux web en 2026 présente leurs avantages et leurs inconvénients. Le Comparatif des moteurs de jeux web couvre les frameworks qui gèrent la boucle de jeu à votre place.

Ressources externes