Responsives Spiel-Canvas
Ein Canvas, das nicht sauber skaliert, sieht unscharf aus oder bricht auf anderen Bildschirmgrößen. Ich habe dieses Problem öfter debuggt, als ich zählen kann, lass mich dir die Mühe ersparen.
Probier verschiedene Skalierungsmodi:
Das Kernproblem
Ein Canvas hat zwei Größen, die man leicht verwechselt. Die CSS-Größe bestimmt, wie groß es auf dem Bildschirm erscheint. Die interne Auflösung bestimmt, wie viele Pixel du tatsächlich zeichnest. Passen die nicht zusammen, bekommst du Unschärfe oder Verzerrung.
Auf einem Retina-Display sagt dein CSS vielleicht 800x600, der Bildschirm hat aber 1600x1200 physische Pixel. Liegt die interne Auflösung deines Canvas nur bei 800x600, skaliert der Browser sie hoch und alles wirkt matschig.
Grundaufbau für Responsivität
So füllt ein Canvas das Fenster und bleibt scharf:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function resize() {
const dpr = window.devicePixelRatio || 1
const width = window.innerWidth
const height = window.innerHeight
// Set internal resolution to match physical pixels
canvas.width = width * dpr
canvas.height = height * dpr
// Set CSS size to match logical pixels
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
// Scale context so you can draw in logical pixels
ctx.scale(dpr, dpr)
}
window.addEventListener('resize', resize)
resize()Die Zeile ctx.scale(dpr, dpr) ist der Kniff. Danach kannst du an Position (100, 100) zeichnen und es landet unabhängig vom DPR an der richtigen Stelle. Das Canvas rendert intern auf einem 2x-Display mit 200x200, dein Code muss davon nichts wissen.
Eine Falle: ctx.scale ist kumulativ. Rufst du resize mehrfach auf, skalierst du immer weiter hoch. Setz entweder vorher die Transformation mit ctx.setTransform(1, 0, 0, 1, 0, 0) zurück oder skaliere nur einmal.
Festes Seitenverhältnis mit Letterboxing
Viele Spiele brauchen ein bestimmtes Seitenverhältnis. Du willst dein 16:9-Spiel nicht auf einen 21:9-Ultrawide-Monitor gezerrt sehen. Letterboxing ergänzt schwarze Balken und erhält das Verhältnis.
const GAME_WIDTH = 1920
const GAME_HEIGHT = 1080
const ASPECT = GAME_WIDTH / GAME_HEIGHT
function resizeWithLetterbox() {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const windowAspect = windowWidth / windowHeight
let width, height
if (windowAspect > ASPECT) {
// Window is wider than game, add bars on sides
height = windowHeight
width = height * ASPECT
} else {
// Window is taller than game, add bars on top/bottom
width = windowWidth
height = width / ASPECT
}
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
canvas.style.position = 'absolute'
canvas.style.left = (windowWidth - width) / 2 + 'px'
canvas.style.top = (windowHeight - height) / 2 + 'px'
// Internal resolution stays fixed
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
}Damit die Letterbox-Balken sichtbar werden, setz den Hintergrund des Body auf Schwarz:
body {
margin: 0;
background: #000;
overflow: hidden;
}Ganzzahlige Skalierung für Pixel-Art
Pixel-Art wirkt bei ganzzahligen Skalierungen (1x, 2x, 3x) knackig und bei gebrochenen wie 2,7x unscharf. Selbst mit image-rendering: pixelated heißt eine 2,5-fache Skalierung, dass manche Pixel 2 Bildschirmpixel breit sind und andere 3.
const GAME_WIDTH = 320
const GAME_HEIGHT = 180
function resizePixelPerfect() {
const maxWidth = window.innerWidth
const maxHeight = window.innerHeight
// Find largest integer scale that fits
const scaleX = Math.floor(maxWidth / GAME_WIDTH)
const scaleY = Math.floor(maxHeight / GAME_HEIGHT)
const scale = Math.max(1, Math.min(scaleX, scaleY))
const width = GAME_WIDTH * scale
const height = GAME_HEIGHT * scale
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
canvas.style.imageRendering = 'pixelated'
}Der Kompromiss ist, dass dein Spiel den Bildschirm nicht exakt füllt. Auf einem 1920x1080-Display ergibt ein 320x180-Spiel eine 5-fache Skalierung (1600x900) mit schwarzen Rändern. Manche Spieler mögen das, andere wollen ein bildschirmfüllendes Spiel, selbst wenn es leicht unscharf ist.
Skalierung eines WebGL-Canvas
Bei WebGL musst du zusätzlich zur Canvas-Größe das Viewport aktualisieren:
function resizeGL(gl, canvas) {
const dpr = window.devicePixelRatio || 1
const width = canvas.clientWidth * dpr
const height = canvas.clientHeight * dpr
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
gl.viewport(0, 0, width, height)
return true // Resized, you may need to update projection matrices
}
return false
}Ruf das am Anfang deiner Render-Schleife auf, nicht nur bei Resize-Events. Manche Browser ändern den DPR, wenn du ein Fenster zwischen Monitoren ziehst, und das willst du mitbekommen.
Resize-Events entprellen
Resize-Events feuern in schneller Folge, während der Nutzer am Fensterrand zieht. Ist deine Resize-Funktion teuer, ruckelt es.
let resizeTimeout
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(resize, 100)
})Die Verzögerung von 100 ms heißt, dass das Resize erst passiert, wenn der Nutzer aufhört zu ziehen. Für weicheres Verhalten während des Ziehens nimmst du stattdessen ResizeObserver:
const observer = new ResizeObserver(entries => {
resize()
})
observer.observe(document.body)ResizeObserver feuert seltener und wird vom Browser besser optimiert.
Vollbild
Mit der Fullscreen-API übernimmst du den ganzen Bildschirm:
async function toggleFullscreen() {
if (!document.fullscreenElement) {
await canvas.requestFullscreen()
} else {
await document.exitFullscreen()
}
}
document.addEventListener('fullscreenchange', resize)Du musst requestFullscreen als Reaktion auf eine Nutzergeste wie einen Klick aufrufen. Sonst blockieren Browser es. Und denk daran, beim Wechsel in den Vollbildmodus und zurück neu zu skalieren, weil sich der verfügbare Platz ändert.
Ein Plattformvorbehalt: Stand Mitte 2026 deckt die Fullscreen-API auf dem iPhone in Safari weiterhin kein Element-Vollbild ab. requestFullscreen funktioniert auf Desktop-Browsern und unter iPadOS, auf dem iPhone aber nicht für beliebige Elemente wie ein Canvas (nur natives Video-Vollbild geht). Prüf mit if (canvas.requestFullscreen) auf Verfügbarkeit und weich sauber aus, indem du das Canvas etwa auf den sichtbaren Viewport skalierst oder Nutzer bittest, das Spiel als PWA zum Home-Bildschirm hinzuzufügen.
Alles zusammen
Diese Klasse deckt die üblichen Fälle ab:
class ResponsiveCanvas {
constructor(canvas, options = {}) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.gameWidth = options.width || 1280
this.gameHeight = options.height || 720
this.pixelArt = options.pixelArt || false
this.letterbox = options.letterbox !== false
this.bind()
this.resize()
}
bind() {
window.addEventListener('resize', () => this.resize())
document.addEventListener('fullscreenchange', () => this.resize())
}
resize() {
const aspect = this.gameWidth / this.gameHeight
let width = window.innerWidth
let height = window.innerHeight
if (this.letterbox) {
if (width / height > aspect) {
width = height * aspect
} else {
height = width / aspect
}
}
if (this.pixelArt) {
const scale = Math.max(1, Math.floor(Math.min(
window.innerWidth / this.gameWidth,
window.innerHeight / this.gameHeight
)))
width = this.gameWidth * scale
height = this.gameHeight * scale
}
this.canvas.width = this.gameWidth
this.canvas.height = this.gameHeight
this.canvas.style.width = width + 'px'
this.canvas.style.height = height + 'px'
this.canvas.style.position = 'absolute'
this.canvas.style.left = (window.innerWidth - width) / 2 + 'px'
this.canvas.style.top = (window.innerHeight - height) / 2 + 'px'
if (this.pixelArt) {
this.canvas.style.imageRendering = 'pixelated'
}
}
get width() { return this.gameWidth }
get height() { return this.gameHeight }
}So nutzt du sie:
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
width: 320,
height: 180,
pixelArt: true,
letterbox: true
})
// In your game code, use responsive.width and responsive.heightTypische Stolperfallen
Ein paar Dinge, über die Leute stolpern:
Das Canvas wird beim Resize geleert. Setzt du canvas.width oder canvas.height, leert sich das Canvas. Zeichnest du nicht in jedem Frame neu, musst du nach dem Resize neu zeichnen.
Der DPR kann sich ändern. Zieht der Nutzer das Browserfenster von einem Retina- auf ein Nicht-Retina-Display, ändert sich der DPR. Prüf ihn in deiner Render-Schleife, nicht nur beim Resize.
Viewport-Eigenheiten in iOS Safari. Die Viewport-Höhe ändert sich, wenn die Adressleiste ein- und ausgeblendet wird. Nutz window.visualViewport für genauere Größen auf Mobilgeräten.
Der Kontextzustand wird zurückgesetzt. Das Setzen der Canvas-Maße setzt den Kontextzustand zurück, inklusive Transformationen, Füllstile und imageSmoothingEnabled. Setz sie nach dem Resize neu.
Mehr Ressourcen
Canvas-2D-Spielschleife behandelt die Grundlagen von Update- und Render-Schleifen.
Pixel-Art-Rendering geht tiefer auf knackige Pixelgrafik ein.
Mobilfreundliche Web-Spiele behandelt Touch-Eingaben und Viewport-Probleme.
WebGL-Grundlagen behandelt Viewport und Canvas-Setup für WebGL-Kontexte.
Ein Web-Spiel ausliefern, das schnell lädt behandelt die Optimierung der Canvas-Größe für Performance.