Ein globales Leaderboard ohne Backend-Server
Wie eine weltweite Top-3-Liste mit rund 40 Zeilen JavaScript funktioniert — und warum der API-Schlüssel dabei völlig offen im Quelltext steht.
Ein persönlicher Highscore in localStorage ist sechs Zeilen Code und fühlt sich nach ungefähr drei Runden einsam an. Man schlägt sich selbst, niemand sieht es, und die Zahl in der Ecke bedeutet nichts. Eine globale Bestenliste ist etwas völlig anderes: Plötzlich spielt man gegen jemanden, den man nicht kennt, und die Zahl bekommt einen Kontext.
Der klassische Weg dahin führt über einen eigenen Server: eine kleine API, eine Datenbank, ein Hosting-Vertrag, TLS-Zertifikate, Updates. Für ein Feierabend-Spiel, das als eine einzige HTML-Datei auf GitHub Pages liegt, wäre das absurd — GitHub Pages liefert ausschließlich statische Dateien aus und kann per Definition keinen Code auf einem Server ausführen. Easter Hop hat trotzdem eine weltweite Top 3, und es gibt keine einzige Zeile Serverlogik. Wie das geht, wo die ehrlichen Grenzen liegen und warum ein API-Schlüssel im Quelltext hier kein Sicherheitsleck ist, 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“.
Die Grundidee: die Datenbank ist die API
Die Lösung heißt Supabase — im Kern eine gehostete PostgreSQL-Datenbank. Das Entscheidende ist aber nicht Postgres, sondern was davor sitzt: eine Komponente namens PostgREST, die jede Tabelle automatisch als REST-Endpunkt verfügbar macht. Man legt eine Tabelle an, und ohne eine Zeile Code zu schreiben, existiert dafür eine HTTP-Schnittstelle.
Die Tabelle hinter der Bestenliste ist so schlicht, wie eine Tabelle nur sein kann:
create table scores (
id bigint generated always as identity primary key,
name text not null,
score integer not null,
created_at timestamptz default now()
);
Vier Spalten, von denen das Spiel nur zwei kennt. Ab diesem Moment ist https://<projekt>.supabase.co/rest/v1/scores ein funktionierender Endpunkt: GET liest, POST schreibt. Und weil PostgREST Abfrageparameter in SQL übersetzt, wandert die komplette Sortier- und Filterlogik in die URL:
/rest/v1/scores?select=name,score&order=score.desc&limit=3
Übersetzt heißt das: Gib mir Name und Punktzahl der drei höchsten Einträge. Was sonst als SQL-Query in einem Backend stünde, das man selbst schreiben, deployen und warten müsste, steht hier als Query-String in einer URL. Genau deshalb reicht für die gesamte Anbindung ein fetch()-Aufruf — die offizielle Supabase-JavaScript-Bibliothek ist bequem, aber für zwei Endpunkte wäre sie mehr Kilobyte als Nutzen. Easter Hop lädt keine einzige externe Bibliothek, und dabei sollte es bleiben.
Der Lesepfad: erst der Cache, dann das Netz
Die drei Konstanten am Anfang des Leaderboard-Abschnitts sind die komplette Konfiguration:
const SB_URL = 'https://<projekt>.supabase.co';
const SB_KEY = 'sb_publishable_…';
const SB_HEADERS = {
apikey: SB_KEY,
Authorization: 'Bearer ' + SB_KEY,
'Content-Type': 'application/json'
};
Darauf folgt der Lesepfad. Wichtig ist hier weniger der fetch()-Aufruf als das, was drumherum passiert:
let cachedLeaderboard = [];
try {
cachedLeaderboard = JSON.parse(localStorage.getItem('easter_lb_cache')) || [];
} catch {}
async function fetchLeaderboard() {
try {
const res = await fetch(
SB_URL + '/rest/v1/scores?select=name,score&order=score.desc&limit=3',
{ headers: SB_HEADERS }
);
if (res.ok) {
cachedLeaderboard = await res.json();
localStorage.setItem('easter_lb_cache', JSON.stringify(cachedLeaderboard));
}
} catch {}
}
Die erste Zeile ist die eigentlich interessante. Sie läuft synchron beim Laden der Seite und holt die zuletzt bekannte Bestenliste aus dem localStorage — noch bevor irgendein Netzwerkverkehr stattgefunden hat. Der Netzabruf startet erst ganz am Ende der Datei, gemeinsam mit dem Game-Loop, und ohne await:
moleY = H / 2;
fetchLeaderboard();
requestAnimationFrame(gameLoop);
Das Spiel wartet also nie auf das Netz. Es startet sofort, zeigt bei Bedarf den gecachten Stand an, und wenn die Antwort irgendwann eintrifft, ersetzt sie stillschweigend den Inhalt von cachedLeaderboard. Der Render-Code kennt diesen Unterschied gar nicht — er ruft schlicht loadLeaderboard() auf, das nichts weiter tut, als die Variable zurückzugeben:
function loadLeaderboard() { return cachedLeaderboard; }
await vor dem Spielstart hätte die Bestenliste zur Startbedingung gemacht: Bei schlechtem Netz hängt der Hase, bei gar keinem Netz startet nichts. So ist die Bestenliste das, was sie sein sollte — eine Zusatzinformation, die auftaucht, wenn sie da ist. Im Flugmodus spielt Easter Hop völlig normal weiter und zeigt dabei die zuletzt gesehene Top 3 an.
Der Schreibpfad: ein POST, mehr nicht
Geschrieben wird nach einem qualifizierenden Lauf, sobald der Name bestätigt ist:
async function submitScore(name, s) {
const cleanName = name.trim().toUpperCase().slice(0, 10) || 'HASE';
try {
await fetch(SB_URL + '/rest/v1/scores', {
method: 'POST',
headers: { ...SB_HEADERS, Prefer: 'return=minimal' },
body: JSON.stringify({ name: cleanName, score: s }),
});
} catch {}
await fetchLeaderboard();
}
Drei Details lohnen einen zweiten Blick. cleanName normalisiert die Eingabe an genau einer Stelle: Leerzeichen weg, Großbuchstaben, hart auf zehn Zeichen gekürzt — und wer nichts eingibt, heißt eben HASE. Der ||-Fallback verhindert leere Zeilen in der Liste, ohne dass es dafür eine Validierung mit Fehlermeldung bräuchte. Für ein Spiel ist ein stiller, sinnvoller Standardwert die freundlichere Lösung als ein rotes „Bitte Namen eingeben“.
Der Header Prefer: 'return=minimal' sagt PostgREST, dass es den gerade geschriebenen Datensatz nicht zurückschicken soll. Standardmäßig antwortet die API mit dem vollständigen neuen Objekt — Daten, die hier niemand liest. Eine Zeile, die eine überflüssige Antwort einspart.
Und der Abschluss mit await fetchLeaderboard() ist der Grund, warum die eigene neue Platzierung sofort korrekt in der Liste steht: Statt den neuen Eintrag lokal ins Array zu basteln und zu hoffen, dass er an der richtigen Stelle landet, wird die Wahrheit einfach neu aus der Datenbank geholt. Die Datenbank sortiert ohnehin besser als handgeschriebener Einfüge-Code, und ein Zustand, den man nicht selbst nachführt, kann auch nicht auseinanderlaufen.
Der Cache als Türsteher — und die ehrliche Lücke darin
Der Namenseingabe-Bildschirm erscheint nicht nach jedem Lauf, sondern nur, wenn der Score tatsächlich für die Top 3 reicht. Diese Entscheidung trifft eine Funktion, die ausschließlich mit lokalen Daten arbeitet:
function qualifiesForLeaderboard(s) {
return cachedLeaderboard.length < 3 ||
s > cachedLeaderboard[cachedLeaderboard.length - 1].score;
}
Aufgerufen wird sie am Ende der Sterbe-Animation, bevor der Game-Over-Bildschirm erscheint:
if (--dyingTimer <= 0) {
if (score > 0 && qualifiesForLeaderboard(score)) {
state = STATE.NAME_ENTRY;
nameInputEl.focus();
} else {
state = STATE.GAME_OVER;
}
}
Das ist der Grund, warum die Datenbank praktisch nie beschrieben wird. Die allermeisten Läufe enden weit unter Platz 3, kommen nie an einem Netzwerkaufruf vorbei und kosten null Schreibvorgänge. Der kostenlose Supabase-Tarif reicht damit auf Jahre — nicht, weil das Spiel wenig gespielt würde, sondern weil die Türsteher-Prüfung vor der Netzwerkanfrage sitzt und komplett offline funktioniert.
Und jetzt die ehrliche Fußnote: Diese Prüfung kann falsch liegen. Sie vergleicht gegen den gecachten Stand, und der ist ein Schnappschuss vom letzten erfolgreichen Abruf. Wenn in der Zwischenzeit jemand anders einen besseren Score eingetragen hat, hält mein Browser eine Platzierung noch für Top 3, die es längst nicht mehr ist. Ergebnis: Ich gebe meinen Namen ein, der Eintrag landet in der Datenbank — und taucht in der Liste trotzdem nicht auf.
Das habe ich bewusst nicht repariert. Die Alternative wäre, vor jedem Game Over erst die aktuelle Liste abzurufen und dann zu entscheiden — also ausgerechnet im emotionalsten Moment des Spiels eine Netzwerkanfrage zwischen Tod und Ergebnisbildschirm zu schieben, mit Wartezeit und Fehlerfall. Der Preis dafür wäre eine spürbare Verzögerung für alle, der Gewinn ein Grenzfall für wenige. Wichtig ist nur: Die Anzeige ist immer korrekt, weil sie mit order=score.desc&limit=3 direkt aus der Datenbank kommt. Es steht also nie ein falscher Name auf dem Podest — es kommt nur gelegentlich ein Eintrag in der Tabelle an, der es nicht aufs Podest schafft. Die Tabelle wächst, die Wahrheit bleibt.
Der Schlüssel im Quelltext ist kein Versehen
Wer play.html im Browser mit Strg+U öffnet, findet dort einen API-Schlüssel im Klartext. Das sieht auf den ersten Blick nach einem groben Fehler aus, ist aber genau so vorgesehen: Ein publishable key (früher „anon key“) ist dafür gemacht, in Client-Code zu stehen. Er identifiziert das Projekt und die Rolle „nicht angemeldeter Besucher“ — mehr nicht. Ein Geheimnis ist er nicht und kann es prinzipiell auch nicht sein, denn jeder Code, der im Browser läuft, ist lesbar. Wer versucht, so einen Schlüssel zu verstecken, löst das Problem nicht, sondern verschiebt es nur außer Sichtweite.
Die eigentliche Absicherung passiert deshalb nicht im Browser, sondern in der Datenbank: Row Level Security. Ist RLS für eine Tabelle aktiviert, ist zunächst alles verboten, und man erlaubt anschließend gezielt einzelne Operationen. Für eine Bestenliste braucht es exakt zwei Erlaubnisse — Lesen und Einfügen:
alter table scores enable row level security;
create policy "Jeder darf die Bestenliste lesen"
on scores for select to anon using (true);
create policy "Jeder darf einen Score eintragen"
on scores for insert to anon with check (true);
Was hier bewusst fehlt, ist der wichtigere Teil: Es gibt keine Policy für update und keine für delete. Ohne Policy keine Erlaubnis — ein DELETE auf den Endpunkt läuft ins Leere, statt die Tabelle zu leeren. Genau das ist der Unterschied zwischen einem harmlosen öffentlichen Schlüssel und einem echten Sicherheitsproblem: Vergisst man RLS zu aktivieren, kann jeder mit einem einzigen HTTP-Aufruf die gesamte Bestenliste löschen. Der Schlüssel im Quelltext ist unkritisch — eine Tabelle ohne RLS ist es nicht.
POST mit beliebiger Punktzahl abschicken. Das ist keine Lücke in der Konfiguration, sondern die logische Konsequenz daraus, dass der Client die Punkte zählt: Ein Endpunkt, der Scores von Unbekannten annimmt, kann nicht wissen, ob wirklich gespielt wurde. Ernsthaft absichern ließe sich das nur serverseitig — mit einer Edge Function, die den Spielverlauf plausibilisiert, statt der Zahl zu glauben. Für ein Osterspiel mit einer Top 3 ist mir dieser Aufwand die Sache nicht wert, und ich finde es ehrlicher, das hinzuschreiben, als so zu tun, als sei die Liste fälschungssicher.
Zwei leere catch-Blöcke, mit Absicht
In beiden Netzwerkfunktionen steht ein try mit einem leeren catch {}. Normalerweise ist das ein Warnsignal — verschluckte Fehler sind die Sorte Bug, die man erst drei Monate später bemerkt. Hier ist es eine bewusste Entscheidung, und sie folgt aus einer einfachen Rangordnung: Das Spiel ist das Produkt, die Bestenliste ist Zierde. Kein Netz, Supabase-Wartung, ein Werbeblocker, der den Aufruf abfängt, eine Firewall im Firmen-WLAN — in all diesen Fällen soll exakt eine Sache passieren: nichts. Der Hase hüpft weiter, die Liste bleibt eben leer oder zeigt den gecachten Stand.
Ein unbehandelter Fehler in submitScore() würde dagegen die aufrufende Kette abbrechen — und die läuft direkt im Enter-Handler der Namenseingabe. Das Spiel würde im Namenseingabe-Zustand festhängen, weil der Zustandswechsel nach dem Absenden nie erreicht wird. Ein Spieler verliert lieber einen Eintrag in einer Liste, als in einem Bildschirm gefangen zu sein, aus dem es keinen Ausweg gibt.
Der gleiche Gedanke steckt schon im Cache-Ladecode ganz oben: Auch dort steht ein try … catch {} um das JSON.parse(). Ein beschädigter localStorage-Eintrag — verkürzt geschrieben, von Hand manipuliert, von einer alten Version übrig — würde sonst beim Parsen eine Exception werfen, und zwar auf oberster Ebene des Skripts. Das Spiel würde gar nicht erst starten. Wegen eines Zwischenspeichers.
Was tatsächlich gespeichert wird
Der Vollständigkeit halber, weil bei „Datenbank“ berechtigterweise die Frage aufkommt: In der Tabelle stehen ein selbst gewählter Name mit maximal zehn Zeichen und eine Punktzahl. Keine IP-Adresse, keine Geräteinformation, keine Kennung, die zwei Einträge derselben Person verbinden könnte. Die Eingabe ist freiwillig — wer den Bildschirm überspringt, spielt einfach weiter. Die Details dazu stehen in der Datenschutzerklärung.
Das ist kein nachträglicher Verzicht, sondern fällt beim Design fast von selbst an: Wenn die Tabelle nur die zwei Spalten hat, die das Spiel überhaupt kennt, gibt es nichts zu vergessen und nichts versehentlich mitzuloggen.
Zum Nachbauen: die Checkliste
Für eine eigene Bestenliste in einem statisch gehosteten Spiel, in dieser Reihenfolge:
- Tabelle mit so wenigen Spalten wie möglich anlegen. Was nicht existiert, muss nicht erklärt werden.
- RLS aktivieren, bevor irgendetwas anderes passiert — und nur
selectundinsertfür anonyme Zugriffe erlauben. Keinupdate, keindelete. Das ist der einzige Punkt in dieser Liste, bei dem Vergessen wirklich weh tut. - Sortierung und Limit als Query-Parameter in die URL legen, statt alle Daten zu holen und im Browser zu sortieren. Die Datenbank kann das besser und überträgt dabei weniger.
- Den letzten bekannten Stand im
localStoragecachen und synchron beim Start laden. Das Spiel darf nie auf das Netz warten. - Vor dem Schreiben lokal prüfen, ob der Score überhaupt relevant ist. Das hält die Schreibvorgänge nahe null — und wenn der Cache dabei gelegentlich danebenliegt, ist das ein akzeptabler Preis, solange die Anzeige aus der Datenbank kommt.
- Alle Netzwerkaufrufe kapseln und Fehler bewusst schlucken. Ein Ausfall der Bestenliste darf das Spiel nicht anfassen.
- Ehrlich bleiben: Ohne serverseitige Validierung ist so eine Liste manipulierbar. Das ist für ein kleines Spiel in Ordnung — man sollte es nur wissen und nicht behaupten, es sei anders.
Unter dem Strich sind das rund 40 Zeilen JavaScript und zwei SQL-Policies für ein Feature, das früher einen eigenen Server bedeutet hätte. Wie die Bestenliste in die Namenseingabe hineinspielt und warum ausgerechnet die iOS-Tastatur dabei der schwierigste Teil war, steht im Deep-Dive zur Touch-Steuerung; die Entstehungsgeschichte gibt es im Devlog. Als Nächstes nehme ich mir die Grafik vor: wie sämtliche Sprites in Easter Hop als Zeichen-Arrays im Code stehen, ganz ohne eine einzige Bilddatei. Bis dahin — spiel eine Runde. Vielleicht reicht es ja für das Podest.