Touch-Steuerung und iOS-Eigenheiten

Wie ein Ein-Tap-Spiel auf dem Handy genauso präzise reagieren muss wie auf der Tastatur — und was dabei alles schiefgehen kann.

Pixel-Avatar von Peter Jacob
Peter Jacob — Solo-Entwickler aus Hamburg
Veröffentlicht: · Über mich

Über 60 % der Easter Hop-Sessions laufen auf einem Touchscreen. Für ein Spiel, bei dem ein einziger Tap zur falschen Zeit den Lauf beendet, ist das kein Nebenschauplatz — Touch ist die Haupt-Eingabemethode, Tastatur und Maus sind die Ausnahme. Trotzdem war Touch beim Bau des Spiels der Nachzügler: Der eigentliche touchstart-Listener ist eine einzige Zeile Code. Fast der gesamte Mobile-Aufwand steckte in vier Problemen, die man erst merkt, wenn man das Spiel tatsächlich auf einem echten Telefon in der Hand hält. Diese vier nehme ich hier auseinander — mit dem echten Code aus play.html. Den Gesamtüberblick über das Projekt gibt es in „Wie ich Easter Hop gebaut habe“.

Problem 1: Die 300-Millisekunden-Verzögerung

Mobile Browser haben eine Altlast aus der Zeit vor responsivem Design: Weil eine Website theoretisch auf Doppeltippen mit Zoomen reagieren soll, wartet der Browser nach einem einzelnen Tap kurz ab, ob ein zweiter folgt — bis zu 300 Millisekunden, bevor er das Tap-Event überhaupt auslöst. Für ein Timing-Spiel, bei dem die Differenz zwischen „gerade noch durch die Lücke“ und „Zaunlatte im Gesicht“ oft unter 100 ms liegt, ist das nicht spürbar langsamer — es ist unspielbar.

Die Lösung ist zweistufig. Erstens sagt touch-action: none im CSS dem Browser explizit, dass dieses Element keine eigenen Touch-Gesten (Zoomen, Scrollen, Doppeltipp) mehr interpretieren soll — damit entfällt die Wartezeit von vornherein, moderne Browser müssen gar nicht erst abwägen:

html, body {
  touch-action: none;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

Zweitens registriert das Spiel den Touch-Listener explizit als { passive: false } und ruft preventDefault() auf — als zweite Absicherung für Browser, die touch-action nicht vollständig respektieren, und um zu verhindern, dass ein Tap am Bildschirmrand versehentlich eine Browser-Zurück-Geste auslöst:

canvas.addEventListener('touchstart', (e) => {
  e.preventDefault();
  // …
  onInput();
}, { passive: false });

Der { passive: false }-Teil ist kein Stilelement, sondern notwendig: Browser gehen inzwischen standardmäßig davon aus, dass Touch-Listener „passiv“ sind, also niemals preventDefault() aufrufen — das erlaubt ihnen, beim Scrollen nicht auf JavaScript zu warten. Ohne die explizite Ausnahme würde preventDefault() im Handler schlicht ignoriert, und man würde sich fragen, warum der Zoom trotzdem manchmal auslöst.

Ergänzend steht im <meta name="viewport">-Tag von play.html user-scalable=no. Das ist redundant zu touch-action: none, aber auf ein paar älteren Android-WebViews greift nur eine der beiden Bremsen zuverlässig — doppelt hält hier tatsächlich besser.

Problem 2: Wo genau wurde getippt?

Der zweite Stolperstein ist unauffälliger, weil er nicht mit einem sichtbaren Bug auffällt, sondern mit leicht daneben liegenden Taps. Die interne Spielwelt ist immer exakt 400×600 Pixel groß — aber die tatsächliche Bildschirmgröße eines Canvas-Elements auf dem Handy hat damit nichts zu tun. Ein iPhone in Hochformat zeigt das Canvas vielleicht mit 340×510 CSS-Pixeln an, ein Tablet mit 600×900. Ein Touch-Event liefert Koordinaten relativ zum sichtbaren Viewport (clientX/clientY) — nicht relativ zur internen 400×600-Auflösung des Canvas.

Wer diese beiden Koordinatensysteme verwechselt, bekommt ein Spiel, das auf dem Desktop perfekt reagiert und auf dem Handy leicht daneben tippt — meistens unbemerkt, weil Easter Hop ohnehin nur irgendwo auf den Canvas tippen muss, um zu hüpfen. Sichtbar wird der Fehler erst dort, wo eine Position wirklich zählt: beim Musik-Symbol oben rechts, das exakt getroffen werden muss. Die Umrechnung braucht daher einen Skalierungsfaktor aus tatsächlicher Anzeigegröße zu interner Auflösung:

canvas.addEventListener('touchstart', (e) => {
  e.preventDefault();
  const t = e.touches[0];
  const rect = canvas.getBoundingClientRect();
  const cx = (t.clientX - rect.left) * (W / rect.width);
  const cy = (t.clientY - rect.top)  * (H / rect.height);
  if (isMusicToggle(cx, cy)) { toggleMusic(); return; }
  if (state === STATE.NAME_ENTRY) { nameInputEl.focus(); return; }
  onInput();
}, { passive: false });

rect.left/rect.top ziehen die Position des Canvas auf der Seite ab, sodass cx/cy relativ zur Canvas-Ecke sind. Die Multiplikation mit W / rect.width (bzw. H / rect.height) skaliert von CSS-Pixeln auf interne Spielkoordinaten. Derselbe Code läuft für mousedown auf dem Desktop — isMusicToggle(cx, cy) ist dieselbe Funktion für Maus und Touch, weil beide am Ende dieselben internen Koordinaten liefern. Genau diese eine Umrechnungszeile ist der Grund, warum das Musik-Symbol auf jedem Gerät exakt dort reagiert, wo man es sieht, egal wie groß der Bildschirm ist.

Problem 3: Die iOS-Tastatur will nicht

Wer einen Top-3-Score erreicht, landet auf einem Namenseingabe-Bildschirm — und dafür braucht es eine Bildschirmtastatur. Das Canvas selbst kann aber keinen Tastaturfokus bekommen; ein <canvas>-Element ist für das Betriebssystem kein Eingabefeld. Der Standard-Workaround: ein unsichtbares, echtes <input>-Element irgendwo im DOM, das man programmatisch fokussiert.

<input id="name-input" type="text" maxlength="10"
  autocomplete="off" autocorrect="off"
  autocapitalize="characters" spellcheck="false"
  style="position:fixed;left:-9999px;top:50%;
         width:1px;height:1px;opacity:0;
         font-size:16px;border:none;outline:none;
         background:transparent;">
Der unscheinbarste Zweizeiler im ganzen Spiel font-size: 16px auf dem versteckten Input ist kein Zufallswert. Safari auf iOS zoomt automatisch in jedes Eingabefeld mit einer Schriftgröße unter 16px hinein, sobald es fokussiert wird — gedacht als Lesehilfe für echte Formulare. Bei einem unsichtbaren 1×1-Pixel-Feld sieht man davon nichts außer einem plötzlich verzerrten, herausgezoomten Canvas. Kleiner Wert, große Wirkung.

Auf Android reicht nameInputEl.focus() irgendwo im Code, und die Tastatur öffnet sich zuverlässig. iOS Safari ist strenger: Es öffnet die Bildschirmtastatur nur, wenn der focus()-Aufruf synchron innerhalb desselben Event-Handlers passiert, der durch eine echte Nutzerinteraktion ausgelöst wurde — ein touchstart zählt, ein setTimeout danach oder ein Aufruf aus einer async-Funktion zählt nicht mehr. Deshalb sitzt der Fokus-Aufruf direkt im Touch-Handler, nicht irgendwo in der Zustandslogik, die ihn eigentlich auslöst:

canvas.addEventListener('touchstart', (e) => {
  // …
  if (state === STATE.NAME_ENTRY) { nameInputEl.focus(); return; }
  onInput();
}, { passive: false });

Das Spiel selbst wechselt in den Zustand NAME_ENTRY, sobald ein Lauf endet und der Score für die Top 3 reicht — aber der eigentliche focus()-Aufruf muss auf den nächsten Tap warten, weil erst dieser Tap wieder ein echtes User-Gesture-Event ist. Das war die Stelle, an der ich am längsten gesucht habe: Der Code sah komplett richtig aus, `focus()` wurde definitiv aufgerufen — nur eben aus der falschen Aufruf-Kette heraus, eine Ebene zu weit weg vom auslösenden Touch-Event, und iOS hat es kommentarlos ignoriert.

Problem 4: Ghost Clicks vermeiden, statt sie zu reparieren

Ein klassischer Mobile-Bug: Man registriert einen touchstart-Handler und einen click-Handler auf demselben Element, weil man Touch und Maus gleichzeitig unterstützen will. Auf vielen Geräten feuert nach einem Tap aber zusätzlich ein synthetisches click-Event hinterher — ein „Ghost Click“ — und die Eingabe wird doppelt verarbeitet. Bei einem Ein-Tap-Spiel würde das bedeuten: ein Tap, zwei Hüpfer, unkontrollierbare Flugbahn.

Easter Hop hat dieses Problem nie gehabt, weil es nie einen click-Listener gab. Es gibt genau zwei Eingabe-Listener auf dem Canvas — mousedown für Desktop, touchstart für Touch — und beide setzen dasselbe inputPressed-Flag, das der Game-Loop einmal pro Frame konsumiert:

let inputPressed = false;
function onInput() { inputPressed = true; }

function handleInput() {
  if (!inputPressed) return;
  inputPressed = false;
  // … Zustandswechsel je nach state
}

Weil touchstart bereits preventDefault() aufruft, wird auf den meisten Geräten ohnehin kein click mehr synthetisiert — aber selbst wenn doch, würde er ins Leere laufen, weil niemand zuhört. Die robustere Lösung war hier nicht, einen Bug zu fixen, sondern die Eingabemöglichkeit zu vermeiden, überhaupt Bugs zu produzieren.

Zum Nachbauen: die Checkliste

Für ein eigenes Tap-basiertes Canvas-Spiel, in dieser Reihenfolge:

  1. touch-action: none auf Body oder Canvas, plus user-scalable=no im Viewport-Meta-Tag, damit der Browser keine eigenen Gesten mehr interpretiert.
  2. touchstart mit { passive: false } registrieren und preventDefault() aufrufen — als zweite Absicherung, nicht als einzige Lösung.
  3. Touch- und Maus-Koordinaten über getBoundingClientRect() und den Skalierungsfaktor aus Anzeigegröße zu interner Auflösung in ein gemeinsames Koordinatensystem umrechnen. Zwei getrennte Koordinatensysteme sind zwei Quellen für Bugs.
  4. Für Texteingaben: ein verstecktes <input> mit font-size: 16px, und den focus()-Aufruf synchron im auslösenden Touch-Handler, nicht in der Zustandslogik danach.
  5. Nur einen Eingabe-Pfad pro Gerätetyp registrieren (mousedown oder touchstart, nie zusätzlich click für dasselbe Verhalten). Das erspart Ghost-Click-Fixes komplett.

Mehr Mobile-Kontext und wie sich das in echten Tests auf altem und neuem Gerät geschlagen hat, steht im Devlog. Wie das globale Bestenlisten-System dahinter funktioniert — eine Top-3-Liste mit Supabase, ohne einen eigenen Server zu betreiben — steht inzwischen im Artikel zum Leaderboard ohne Backend. Ansonsten — spiel eine Runde und beobachte beim nächsten Game Over, wie geschmeidig die Tastatur aufklappt. Das ist keine Selbstverständlichkeit.

Über den Autor

Ich bin Peter Jacob, Webentwickler aus Hamburg. Easter Hop ist mein Feierabend-Projekt — der Gesamtüberblick steht in „Wie ich Easter Hop gebaut habe“, die übrigen Systeme auf der Technik-Seite. Feedback gern per E-Mail.