Skip to content

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:

js
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.

js
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:

css
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.

js
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:

js
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.

js
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:

js
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:

js
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:

js
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:

js
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
  width: 320,
  height: 180,
  pixelArt: true,
  letterbox: true
})

// In your game code, use responsive.width and responsive.height

Typische 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.