Wie ich Easter Hop gebaut habe
Ein Flappy-Bird-Klon in einer einzigen HTML-Datei — der komplette Weg vom weißen Rechteck zum fertigen Spiel, mit allem Code und allen Irrwegen.
Dieser Artikel ist der Text, den ich selbst gern gefunden hätte, bevor ich angefangen habe: eine vollständige, ehrliche Anleitung, wie ein kleines Browserspiel wirklich entsteht. Nicht als Tutorial mit perfekten Schritten, sondern so, wie es tatsächlich gelaufen ist — inklusive der Abende, an denen nichts funktioniert hat. Am Ende steht Easter Hop: ein Flappy-Bird-Klon mit Osterhase, der in einer einzigen HTML-Datei lebt, ohne Framework, ohne Build-Tool, ohne eine einzige Bilddatei.
Jeder Code-Ausschnitt hier stammt unverändert aus dem Spiel. Du kannst alles im Quelltext von play.html nachlesen — das ist Absicht und Teil des Konzepts.
- Die Regel: eine Datei, null Abhängigkeiten
- Das Grundgerüst: Canvas und Loop
- Physik, die sich gut anfühlt
- Ein Sprite ohne Bilddatei
- Hindernisse und die Schwierigkeitskurve
- Eine Welt, die die Jahreszeit wechselt
- Polish: Partikel, Screenshake, Squash
- Mobile: wo die echten Probleme wohnen
- Eine Bestenliste ohne eigenen Server
- Vom Maulwurf zum Osterhasen
- Veröffentlichen für 0 Euro
- Was ich beim nächsten Spiel anders mache
1. Die Regel: eine Datei, null Abhängigkeiten
Bevor die erste Zeile Code entstand, stand eine Entscheidung: Das ganze Spiel kommt in eine HTML-Datei. Kein npm, kein Bundler, keine Engine, keine Assets. Diese Regel klingt nach Verzicht, ist aber das Gegenteil — sie ist der Grund, warum das Projekt fertig wurde. Jede Abhängigkeit, die man nicht hat, kann nicht kaputtgehen, nicht veralten und nicht konfiguriert werden müssen. Und ein Spiel, das man als eine Datei verschicken kann, hat null Reibung: Link anklicken, spielen. Warum mir das so wichtig ist, habe ich in einem eigenen Essay aufgeschrieben.
Die praktische Konsequenz: alles wird auf einem HTML5-Canvas mit der 2D-API gezeichnet, alle Grafiken sind Code, und die einzige Ausnahme von der Regel ist inzwischen eine Musikdatei und — seit dem Ausbau dieser Website — ein gemeinsames Stylesheet für die Inhaltsseiten. Das Spiel selbst läuft nach wie vor autark.
2. Das Grundgerüst: Canvas und Loop
Tag eins war unglamourös: ein 400×600-Pixel-Canvas und ein weißes Rechteck, das fällt und bei Leertaste springt. Aber unter diesem Rechteck steckt bereits die wichtigste Architektur-Entscheidung des ganzen Projekts — der Fixed-Timestep-Game-Loop:
const FIXED_DT = 1000 / 60;
let lastTime = 0;
let accumulator = 0;
function gameLoop(timestamp) {
if (lastTime === 0) lastTime = timestamp;
let delta = timestamp - lastTime;
lastTime = timestamp;
if (delta > 200) delta = 200;
accumulator += delta;
while (accumulator >= FIXED_DT) {
update();
accumulator -= FIXED_DT;
}
render();
requestAnimationFrame(gameLoop);
}
Warum der Aufwand statt eines simplen setInterval? Weil requestAnimationFrame so oft feuert, wie das Display aktualisiert — auf meinem Monitor 60-mal pro Sekunde, auf einem modernen Smartphone gern 120-mal. Ohne Entkopplung liefe das Spiel auf dem Handy doppelt so schnell. Der Akkumulator sammelt die echte verstrichene Zeit und arbeitet sie in exakten 16,67-ms-Schritten ab: Die Simulation rechnet überall gleich, nur gezeichnet wird unterschiedlich oft.
Das Math-lose Detail if (delta > 200) delta = 200; habe ich erst nach einem Bug ergänzt: Wechselt man den Tab und kommt nach einer Minute zurück, will der Loop 3.600 verpasste Schritte nachholen — der Browser friert ein. Der Deckel wirft überschüssige Zeit einfach weg. Solche Kleinigkeiten stehen in keinem Tutorial-Titel, kosten aber echte Debugging-Abende.
3. Physik, die sich gut anfühlt
Die komplette Flugphysik sind drei Konstanten und drei Zeilen pro Simulationsschritt:
const GRAVITY = 0.45;
const FLAP_IMPULSE = -7.5;
const TERMINAL_VELOCITY = 10;
moleVY += GRAVITY;
if (moleVY > TERMINAL_VELOCITY) moleVY = TERMINAL_VELOCITY;
moleY += moleVY;
Die Zahlen sehen banal aus, aber sie sind das Ergebnis mehrerer Abende reinen Playtestings. Die erste Version hatte Schwerkraft 0,9 und Impuls −7: Die Figur fiel wie ein Stein und verlangte panisches Dauertippen. Nicht schwer — unangenehm. Der Unterschied zwischen beidem ist die eigentliche Kunst bei einem Ein-Knopf-Spiel.
Zwei Design-Entscheidungen waren wichtiger als jede Zahl. Erstens: Ein Tap setzt die Geschwindigkeit (moleVY = FLAP_IMPULSE), er addiert nicht. Dadurch wirkt jeder Hüpfer identisch, egal wie schnell man gerade fällt — der Spieler lernt genau einen Bogen. Zweitens: ein Cooldown von 5 Frames zwischen zwei Hüpfern, damit hektisches Getrommel nicht in unkontrolliertes Dauersteigen ausartet. Was beides für die Spielstrategie bedeutet, steht ausführlich im Tipps-Guide.
4. Ein Sprite ohne Bilddatei
Der Osterhase ist keine PNG. Er ist ein Array aus Zeichenketten — jedes Zeichen ein Pixel, jede Farbe ein Eintrag in einer Zuordnungstabelle:
const BUNNY_SPRITE = [
'00000WW000WW00000',
'0000WWPW0WWPW0000',
'0000WPPW0WPPW0000',
'000WWPPWWWPPWW000',
'00WWWWWWWWWWWWD00',
'0WWWWETWWWTEWWWD0',
// … 14 Zeilen insgesamt
];
const BUNNY_COLORS = {
'W': [240, 235, 225], // Fell
'P': [255, 160, 180], // Ohren innen
'E': [ 40, 30, 60], // Augen
// …
};
Gezeichnet wird mit zwei verschachtelten Schleifen und fillRect, jedes Zeichen als 3×3-Pixel-Block. Das klingt primitiv, hat aber zwei enorme Vorteile: Das Sprite ist im Texteditor direkt bearbeitbar (ich habe die Ohren buchstäblich durch Buchstaben-Verschieben geformt), und es braucht keine einzige externe Datei. Der Nachteil — man wird kein Michelangelo mit 17×14 Zeichen — ist bei diesem Spielstil verschmerzbar.
5. Hindernisse und die Schwierigkeitskurve
Die Zäune sind Rechtecke mit Deko: Pfosten, Querriegel, Plankenlinien und spitze Zaunlatten, die in die Lücke ragen. Interessanter als das Zeichnen ist die Kurve dahinter:
function getGapSize(score) { return Math.max(100, 150 - score * 1.5); }
function getScrollSpeed(score) { return Math.min(5.0, 2.5 + score * 0.04); }
Zwei Einzeiler steuern die gesamte Dramaturgie: Die Lücke schrumpft von 150 auf minimal 100 Pixel (erreicht bei Punkt 33), das Tempo verdoppelt sich von 2,5 auf 5,0 Pixel pro Frame (erreicht bei Punkt 63). Keine Level, keine Stufen — nur zwei stetige Funktionen. Beim Testen habe ich gelernt: Spieler spüren Knicke in der Schwierigkeit sofort und empfinden sie als unfair; stetige Kurven fühlen sich dagegen wie die eigene Ermüdung an.
Die Kollision ist schlichtes AABB (Rechteck-gegen-Rechteck) mit einem Trick: Die Hitbox ist rundum 3 Pixel kleiner als die sichtbare Grafik. Pixelgenaue Kollision wäre „korrekter", fühlt sich aber willkürlich an — das Auge bewertet Beinahe-Treffer großzügiger als der Rechner. Die unsichtbare Gnade lässt knappe Durchflüge wie Können wirken.
6. Eine Welt, die die Jahreszeit wechselt
Das Feature, auf das ich am stolzesten bin, war ursprünglich ein Experiment: Die Welt färbt sich mit steigendem Score stufenlos von Frühling zu Sommer. Die Umsetzung ist verblüffend wenig Code — zwei komplette Farbpaletten und eine lineare Interpolation:
function getPalette(score) {
const t = Math.min(score / 30, 1.0);
const p = {};
for (const key in SPRING) {
p[key] = lerpColor(SPRING[key], SUMMER[key], t);
}
return p;
}
Der Kniff ist nicht die Interpolation, sondern die Disziplin: Keine Zeichenfunktion darf eine Farbe hart codieren. Alles fragt seine Rolle ab — palette.ground, palette.particle, palette.obstacleLED — und weiß nichts von Jahreszeiten. Dadurch wandert buchstäblich jedes Element gleichzeitig durch den Farbraum, vom Himmelsverlauf bis zu den Blütenblättern der Hintergrundblumen, ohne eine Zeile Animationscode. Die komplette technische Aufschlüsselung inklusive des Slot-Hash-Tricks für die endlos scrollenden Wolken und Ostereier steht auf der Technik-Seite.
7. Polish: Partikel, Screenshake, Squash
Der Unterschied zwischen „funktioniert" und „fühlt sich gut an" liegt fast vollständig in Dingen, die spielmechanisch nichts tun:
- Partikel: Sechs kleine Wölkchen bei jedem Hüpfer, 18 bei der Kollision, 28 beim Feuerwerk alle 10 Punkte. Alle aus einem Pool von 80 vorab angelegten Objekten — während eines Laufs wird kein einziges neues Objekt erzeugt, damit der Garbage Collector auf älteren Handys keine Ruckler produziert.
- Squash & Stretch: Beim Hüpfen staucht sich der Hase kurz (
moleSquash = 0.75) und federt zurück. Der älteste Trick der Animationsgeschichte, und er funktioniert immer noch. - Screenshake: Beim Aufprall wackelt das Bild sechs Pixel weit und klingt exponentiell ab. Zwei Zeilen, riesiger Unterschied.
Ich habe alle drei testweise deaktiviert, um das zu überprüfen: Das Spiel spielt sich identisch — und fühlt sich tot an. Wer nur eine Lektion aus diesem Artikel mitnimmt, dann diese: Plane für Polish genauso viel Zeit ein wie für Mechanik.
8. Mobile: wo die echten Probleme wohnen
Auf dem Desktop war das Spiel nach zwei Wochenenden im Wesentlichen fertig. Die restliche Zeit ging fast komplett in Mobile — und zwar in Probleme, die man vorher nicht erraten kann:
Der 300-ms-Tap-Delay. Viele Mobile-Browser verzögern Klick-Events um bis zu 300 Millisekunden, ein Erbe der Doppeltipp-Zoom-Ära. Für ein Timing-Spiel ist das tödlich. Die Lösung: touch-action: none auf dem Canvas plus preventDefault() im touchstart-Handler.
Die iOS-Tastatur. Für die Namenseingabe der Bestenliste braucht man eine Bildschirmtastatur — aber ein Canvas kann keinen Fokus bekommen. Der Standard-Workaround ist ein unsichtbares <input>-Feld, das programmatisch fokussiert wird. Auf Android reicht das; iOS Safari verlangt zusätzlich, dass der focus()-Aufruf synchron aus einem Touch-Event stammt. Diese eine Zeile an die richtige Stelle zu verschieben hat mich einen Abend gekostet.
Audio. Mein erster Versuch mit programmatisch erzeugten Web-Audio-Soundeffekten scheiterte an iOS-Autoplay-Sperren, die sich nur zu 70 % zuverlässig umgehen ließen — und unzuverlässiger Sound ist schlimmer als keiner. Die finale Lösung ist bewusst simpel: eine Musikschleife über ein normales <audio>-Element, gestartet beim ersten Tap, mit Stumm-Schalter. Die ganze Leidensgeschichte steht im Devlog.
9. Eine Bestenliste ohne eigenen Server
Ein lokaler Highscore in localStorage ist sechs Zeilen Code. Aber ein Highscore, den niemand sieht, ist einsam — also wollte ich eine globale Top 3. Ohne Server. Die Lösung: Supabase, eine gehostete Postgres-Datenbank mit automatischer REST-API. Das Spiel spricht sie direkt aus dem Browser an:
const res = await fetch(
SB_URL + '/rest/v1/scores?select=name,score&order=score.desc&limit=3',
{ headers: SB_HEADERS }
);
Geschrieben wird nur, wenn ein Score tatsächlich in die Top 3 gehört — der Schreib-Traffic ist praktisch null, und der kostenlose Supabase-Tarif reicht auf Jahre. Gespeichert werden ausschließlich ein Spitzname (max. 10 Zeichen) und die Punktzahl, bewusst keine IP-Adressen oder Gerätedaten — Details dazu in der Datenschutzerklärung. Die Bestenliste wird zusätzlich in localStorage gecacht, damit sie auch ohne Netz sofort angezeigt wird.
10. Vom Maulwurf zum Osterhasen
Easter Hop hieß die ersten Wochen „Molt the Mole": ein kupferner Roboter-Maulwurf, unterirdische Server-Racks, kaltes Neonlicht. Technisch solide, atmosphärisch grau. Als Ostern näher rückte, habe ich ein Experiment gewagt: gleiche Physik, gleiche Architektur, komplett neue Haut.
Das Rebranding war der beste Test der Architektur, den ich mir hätte ausdenken können. Weil alle Farben im Paletten-System steckten und das Sprite ein Zeichen-Array war, bestand die Verwandlung fast nur aus Datenänderungen: neue Paletten, neues Sprite, Zäune statt Racks. Der Code, der sich bewegt, blieb unangetastet. Wenn deine Grafik sich nicht ohne Logik-Änderungen austauschen lässt, sind Darstellung und Simulation zu eng verheiratet — das habe ich hier zum ersten Mal wirklich verinnerlicht. Die alte Maulwurf-Version existiert übrigens noch als Git-Tag; weggeworfen wird nichts.
11. Veröffentlichen für 0 Euro
Das Spiel liegt in einem GitHub-Repository, und GitHub Pages serviert es direkt aus dem master-Branch — kostenlos, mit HTTPS, ohne Server-Wartung. Der komplette Deploy-Prozess ist git push. Dazu kommt eine eigene Domain (hopzy.de, ein paar Euro im Jahr), die per CNAME-Eintrag auf GitHub zeigt.
Drei Stolperfallen, die ich unterwegs mitgenommen habe: Erstens, relative Pfade verwenden (./assets/… statt /assets/…), sonst brechen Ressourcen, sobald die Seite nicht im Domain-Root liegt. Zweitens, GitHub Pages kann keine echten 301-Redirects — wer URLs umbenennt, behilft sich mit Meta-Refresh-Stubs und rel="canonical". Drittens, der Pages-Build hängt gelegentlich einfach fest; nicht panisch werden, neu anstoßen hilft.
12. Was ich beim nächsten Spiel anders mache
Nach einem halben Jahr mit diesem Projekt ist meine Liste kurz, aber ehrlich:
- UI-Texte von Anfang an in ein Sprachobjekt. Die Umstellung des Spiels auf Deutsch bedeutete, hartcodierte Strings quer durch den Render-Code zu jagen.
- devicePixelRatio von Anfang an. Das Spiel rendert intern in 400×600 und wird hochskaliert; auf Retina-Displays sieht man das minimal. Nachrüsten ist mühsamer als von Beginn an richtig machen.
- Audio zuerst simpel denken. Anderthalb Tage Web-Audio-Kampf, dann war die Lösung ein
<audio>-Tag. Erst die einfache Lösung ausreizen. - Früher veröffentlichen. Die wertvollsten Erkenntnisse kamen von echten Spielern auf echten Geräten — jede Woche, die das Spiel privat blieb, war eine Woche ohne dieses Feedback.
Und die eine Sache, die ich exakt wieder so machen würde: die Eine-Datei-Regel. Sie hat aus einem Projekt, das auf dem Festplatten-Friedhof hätte enden können, ein fertiges Spiel gemacht, das jeder sofort spielen kann. Mehr über den Menschen dahinter steht auf der Über-mich-Seite — und wenn du selbst etwas baust und Fragen hast: Schreib mir. Über Post zu diesem Artikel freue ich mich wirklich.