Grundlagen der Canvas-2D-Spielschleife
Die Spielschleife ist der Herzschlag deines Spiels. Sie läuft ununterbrochen, aktualisiert den Spielzustand und zeichnet Frames, so schnell der Browser es erlaubt. Machst du das richtig, wird alles andere leichter. Machst du es falsch, jagst du Fehlern hinterher, die nur auf bestimmten Rechnern auftauchen.
Probier es aus (Pfeiltasten oder WASD):
Die einfachste Fassung
Eine auf das Wesentliche reduzierte Spielschleife:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function gameLoop() {
update()
render()
requestAnimationFrame(gameLoop)
}
function update() {
// Game logic here
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Drawing here
}
gameLoop()Das funktioniert, hat aber ein Problem. Die Funktion update weiß nicht, wie viel Zeit seit dem letzten Frame vergangen ist. Auf einem 144-Hz-Monitor läuft sie doppelt so schnell wie auf einem 60-Hz-Monitor. Deine Spielphysik verhält sich auf verschiedenen Rechnern unterschiedlich.
Delta-Zeit behebt Bildratenprobleme
Damit dein Spiel überall gleich schnell läuft, musst du verfolgen, wie viel Zeit zwischen Frames vergeht. Das nennt man Delta-Zeit, üblicherweise mit dt abgekürzt.
let lastTime = 0
function gameLoop(currentTime) {
const dt = (currentTime - lastTime) / 1000 // convert to seconds
lastTime = currentTime
update(dt)
render()
requestAnimationFrame(gameLoop)
}
function update(dt) {
player.x += player.speed * dt // now works at any frame rate
}
requestAnimationFrame(gameLoop)Der Callback von requestAnimationFrame bekommt den aktuellen Zeitstempel in Millisekunden. Ich teile durch 1000, um Sekunden zu erhalten, das macht die Rechnung einfacher. Eine Geschwindigkeit von 200 heißt dann 200 Pixel pro Sekunde, und das ist intuitiv.
Eine Sache zum Aufpassen: Im ersten Frame ist lastTime gleich 0, dt wird also riesig. Manche initialisieren lastTime vor dem Start der Schleife mit performance.now(), andere überspringen das Update des ersten Frames.
Warum requestAnimationFrame statt setInterval
Du fragst dich vielleicht, warum wir requestAnimationFrame statt setInterval nutzen. Dafür gibt es mehrere Gründe.
Erstens synchronisiert sich requestAnimationFrame mit der Bildwiederholrate des Displays, du bekommst also flüssige Animation ohne Tearing. Zweitens pausiert es automatisch, wenn der Tab im Hintergrund ist, was Akku und CPU schont. Drittens kann der Browser es besser optimieren.
Dieses Pausieren ist meist erwünscht, heißt aber, dass dein Spiel einfriert, wenn der Nutzer den Tab wechselt. Baust du ein Multiplayer-Spiel oder etwas, das weiterlaufen muss, brauchst du einen anderen Ansatz.
Fester Zeitschritt für Physik
Variable Delta-Zeit funktioniert für einfache Bewegung, macht bei Physiksimulationen aber Ärger. Dauert ein Frame zu lange, tunnelt deine Figur womöglich durch eine Wand, weil die Kollisionsprüfung den Moment des Aufpralls verpasst hat.
Die Lösung ist ein fester Zeitschritt. Du sammelst Zeit an und führst Physik-Updates mit fester Rate aus, unabhängig davon, wie schnell Frames gerendert werden.
const FIXED_DT = 1 / 60 // 60 physics updates per second
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)
}Das Math.min(..., 0.25) ist wichtig. War der Tab im Hintergrund und kommt zurück, kann frameTime riesig sein. Ohne die Deckelung würde die while-Schleife Hunderte Updates zum Aufholen fahren und das Spiel einfrieren. Bei 0,25 Sekunden zu deckeln heißt, dass nie mehr als 15 Physik-Updates pro Frame laufen.
Spielzustand organisieren
Wächst dein Spiel, brauchst du einen Ort für all die Daten. Ich nehme gern ein einzelnes Objekt, das alles hält:
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: [],
}So lässt sich der Spielzustand leicht speichern und laden, beim Tod des Spielers zurücksetzen und debuggen, indem du game in die Konsole loggst. Manche bevorzugen Klassen, aber für kleine bis mittlere Spiele reicht ein einfaches Objekt.
Alles zusammen
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
// Keep in bounds
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)
}
// Input handling
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)Das ist eine vollständige, lauffähige Spielschleife. Der Spieler bewegt sich mit Pfeiltasten oder WASD, bleibt im Bild und alles läuft unabhängig von der Bildrate gleich schnell. Du kannst das als Ausgangspunkt für jedes Canvas-2D-Spiel kopieren.
Beachte, dass die Eingabeverarbeitung von der Spielschleife getrennt ist. Die Event-Listener setzen nur Flags, und die Update-Funktion liest diese Flags. Diese Entkopplung ist wichtig, weil Tastatur-Events zu unvorhersehbaren Zeitpunkten feuern und deine Spiellogik mit vorhersehbarer Rate laufen soll.
Sprites laden und zeichnen
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)
}
}Bilder laden asynchron, du musst also auf sie warten, bevor dein Spiel startet. Ein gängiges Muster ist, alle Sprites in einer Funktion loadAssets zu laden, die ein Promise zurückgibt, und die Spielschleife erst danach zu starten.
Die Funktion drawSprite prüft vor dem Zeichnen, ob das Sprite existiert. Das verhindert Fehler, wenn du etwas zeichnen willst, das noch nicht geladen ist.
Animierte Sprites
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
)
}
}Diese Klasse geht davon aus, dass dein Sprite-Sheet die Frames horizontal in einer Reihe anordnet. Jedes Frame hat dieselbe Größe. Die Methode update schaltet anhand der vergangenen Zeit durch die Frames, und draw nutzt die neunargumentige Variante von drawImage, um genau das aktuelle Frame auszuschneiden.
So würdest du sie nutzen:
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)
// In update:
playerWalk.update(dt)
// In render:
playerWalk.draw(ctx, player.x, player.y)Kollisionserkennung
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
}
// In update:
for (const enemy of game.enemies) {
if (rectCollision(game.player, enemy)) {
handleCollision(game.player, enemy)
}
}Rechteck-Kollision (auch AABB genannt, für achsenparallele Bounding Box) ist die einfachste und schnellste Variante. Nimm sie für die meisten Fälle. Kreis-Kollision passt besser zu runden Objekten oder wenn Kollisionen großzügiger wirken sollen.
Bei wenigen Entitäten ist es in Ordnung, jedes Paar zu prüfen. Bei Hunderten willst du räumliche Unterteilung (Quadtrees oder ein Gitter), aber das ist ein Thema für ein anderes Tutorial.
Eine einfache Kamera
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)
// Draw world
drawWorld()
drawEntities()
ctx.restore()
// Draw UI (not affected by camera)
drawUI()
}Der Kniff sind ctx.save() und ctx.restore(). Du sicherst den aktuellen Transformationszustand, wendest mit translate den Kameraversatz an, zeichnest alles im Weltraum und stellst danach wieder her, damit UI-Elemente von der Kamera unberührt bleiben.
Für weichere Kamerabewegung kannst du zum Ziel hin lerpen, statt hart darauf zu springen. Etwas wie this.x += (targetX - this.x) * 0.1 erzeugt diesen angenehmen Nachziehereffekt.
Das Spiel pausieren
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('PAUSED', canvas.width / 2, canvas.height / 2)
}Die zentrale Einsicht ist, dass du im pausierten Zustand keine Zeit mehr ansammelst, aber weiter renderst. So erscheint das Pause-Overlay sofort und das Spiel bleibt für die Eingabe zum Fortsetzen empfänglich.
Du kannst dieses Muster erweitern, um mehrere Spielzustände wie Menüs, Zwischensequenzen und Game-Over-Bildschirme abzudecken. Prüf einfach den aktuellen Zustand und verzweige zu unterschiedlichen Update- und Render-Funktionen.
Typische Fehler
Ein paar Dinge, über die ich Leute stolpern sehe:
Das Canvas nicht leeren. Rufst du am Anfang von render kein clearRect auf, zeichnest du über das vorherige Frame und bekommst Schlieren.
Die Delta-Zeit nicht deckeln. Wechselt jemand den Tab und kommt zurück, kann eine riesige Delta-Zeit deine Physik zerlegen oder das Spiel sekundenlang "aufholen" lassen.
Zeichnen, bevor Bilder geladen sind. Canvas ignoriert drawImage-Aufrufe mit nicht geladenen Bildern stillschweigend. Deine Sprites erscheinen einfach nicht, ohne Fehlermeldung.
setInterval für die Schleife nutzen. Es synchronisiert sich nicht mit dem Display und pausiert nicht, wenn der Tab im Hintergrund ist. Nimm requestAnimationFrame.
Welt- und Bildschirmkoordinaten vermischen. Sobald du eine Kamera ergänzt, behalte im Blick, ob eine Position im Welt- oder im Bildschirmraum liegt. Mausklicks kommen im Bildschirmraum und müssen umgerechnet werden.
Wie es weitergeht
Dieses Tutorial deckt die Grundlagen ab. Wenn dir diese Muster vertraut sind, lohnt sich der Blick auf:
Responsives Spiel-Canvas für unterschiedliche Bildschirmgrößen und High-DPI-Displays.
Eingaben in Spielen für Touch-Steuerung, Gamepads und ausgefeiltere Eingabesysteme.
Pixel-Art-Rendering für knackige Pixelgrafik ohne Unschärfe.
Physik-Bibliotheken für Spiele, um deiner Spielschleife Starrkörperphysik, Kollisionen und Gelenke hinzuzufügen.
Wenn du zwischen Canvas 2D, WebGL und WebGPU für dein Projekt schwankst, schlüsselt der Leitfaden Tech-Stack für Web-Spiele 2026 die Abwägungen auf. Web-Game-Engines im Vergleich behandelt Frameworks, die dir die Spielschleife abnehmen.
Externe Ressourcen
- MDN: requestAnimationFrame — Referenz zur Browser-API
- MDN: Canvas API — vollständige Canvas-2D-Dokumentation
- MDN: KeyboardEvent — Tastencodes und Event-Eigenschaften
- Fix Your Timestep! — Glenn Fiedlers Klassiker über Spielschleifen mit festem Zeitschritt