XP und Level: meine erste Formel, die keiner verstand
Ein Levelsystem ist in einer Stunde gebaut und in drei Wochen kaputtgespielt. Über Kurven, Abklingzeiten, Spam-Anreize und die Frage, wofür man eigentlich Punkte vergibt.
1058 Wörter · 5 Min. Lesezeit
- yurna
- discord
- spielmechanik
Levelsysteme gehören zu den Sachen, die jeder Discord-Bot hat und die trotzdem fast nie gut gemacht sind. Meine erste Fassung war keine Ausnahme: Pro Nachricht gab es einen Punkt, alle hundert Punkte ein Level. Fertig.
Nach zwei Wochen hatten wir einen Nutzer auf Level 40, der ausschließlich „k" in einen Kanal geschrieben hatte. Er fand das großartig, alle anderen fanden es nicht großartig, und ich musste zum ersten Mal darüber nachdenken, was so ein System eigentlich belohnen soll.
Das Grundproblem: Was misst man?
Ein Levelsystem misst nicht „Aktivität". Es misst genau das, was man ihm sagt — und Menschen optimieren darauf, sobald sie es merken.
- Punkte pro Nachricht belohnen viele kurze Nachrichten.
- Punkte pro Zeichen belohnen lange, sinnfreie Texte.
- Punkte pro Minute im Sprachkanal belohnen, stumm herumzusitzen.
Es gibt keine Formel, die „Beteiligung" misst. Alles, was man tun kann, ist die offensichtlichen Fehlanreize zu entschärfen und den Rest über Moderation zu regeln.
Die drei Bausteine, bei denen ich gelandet bin
Eine Abklingzeit. Punkte gibt es höchstens einmal pro Minute, egal wie viel jemand schreibt. Das entwertet Spam sofort, ohne ihn zu verbieten. Ein einziger Wert, größte Wirkung.
Eine Spanne statt eines festen Werts. Zwischen 15 und 25 Punkte pro zählender Nachricht. Das macht Punktestände unvorhersehbar genug, dass niemand mehr ausrechnet, wie viele Nachrichten bis zum nächsten Level fehlen.
Eine Mindestlänge und ein paar Ausschlüsse. Nachrichten unter drei Zeichen zählen nicht, Befehle zählen nicht, bestimmte Kanäle zählen nicht — Memes und Sprachchat-Untertitel sind nett, aber kein Beitrag zur Unterhaltung.
const ABKLINGZEIT_MS = 60_000;
const letzteVergabe = new Map();
function punkteFuer(nachricht) {
if (nachricht.author.bot) return 0;
if (nachricht.content.trim().length < 3) return 0;
if (AUSGESCHLOSSENE_KANAELE.has(nachricht.channelId)) return 0;
const schluessel = `${nachricht.guildId}:${nachricht.author.id}`;
const jetzt = Date.now();
if (jetzt - (letzteVergabe.get(schluessel) ?? 0) < ABKLINGZEIT_MS) return 0;
letzteVergabe.set(schluessel, jetzt);
return 15 + Math.floor(Math.random() * 11);
}Die Kurve
Der zweite Teil ist die Frage, wie viel ein Level kostet. Mein erster Ansatz — hundert Punkte pro Level, linear — hat den Effekt, dass Level 50 genauso weit weg ist wie Level 5, was sich nach nichts anfühlt.
Was funktioniert, ist eine Kurve, die anfangs schnell und später langsam ansteigt. Die verbreitetste Variante sieht so aus:
// Punkte, die man insgesamt für ein bestimmtes Level braucht
function schwelle(level) {
return (5 / 6) * level * (2 * level * level + 27 * level + 91);
}Konkret bedeutet das:
| Level | Punkte gesamt | Nachrichten (ca.) |
|---|---|---|
| 1 | 100 | 5 |
| 5 | 1.395 | 70 |
| 10 | 4.675 | 235 |
| 20 | 21.925 | 1.100 |
| 50 | 265.375 | 13.300 |
Level 1 bis 5 gehen an einem Abend. Level 20 ist ein Ziel. Level 50 ist eine Aussage. Genau das will man: schnelle erste Erfolge, ein spürbarer Mittelbau, ein langer Schwanz für die, die wirklich dauerhaft dabei sind.
Tipp
Speichere die Gesamtpunkte, nicht Level und Restpunkte getrennt. Das Level ist berechenbar, und alles Berechenbare sollte man nicht speichern — sonst hat man irgendwann zwei Werte, die sich widersprechen. Ich hatte anfangs beides und durfte nach einer Änderung an der Kurve alle Levelstände neu berechnen.
Was mir bei der Umsetzung Probleme gemacht hat
Die Abklingzeit im Speicher. Meine Map mit den letzten Vergabezeitpunkten war ein prozesslokales Objekt. Nach jedem Neustart war sie leer — und wer genau in diesem Moment schrieb, bekam Punkte, obwohl er eigentlich in der Sperrzeit war. Für ein XP-System ist das egal. Als ich später dasselbe Muster für etwas verwendet habe, wo es nicht egal war, hat es mich einiges gekostet.
Die Levelaufstiegs-Nachricht. Erste Fassung: Bot schreibt in den Kanal, in dem der Aufstieg passiert ist. Bei einem aktiven Server sind das dutzende Nachrichten am Tag, und irgendwann will jeder nur noch, dass es aufhört. Die Lösung war eine Einstellung pro Server: aus, in einen festen Kanal, oder per Direktnachricht.
Rollen zu Leveln. Der Wunsch kam sofort: „Ab Level 10 soll man automatisch eine Rolle bekommen." Technisch einfach, praktisch voller Kanten — der Bot muss über der Rolle stehen, die Rolle kann gelöscht werden, jemand kann sie manuell entfernt bekommen. Ich habe irgendwann eine Prüfung eingebaut, die beim Beitritt eines Mitglieds die passenden Rollen nachträgt, statt sie nur beim Aufstieg zu vergeben.
Was ich beim Zurücksetzen gelernt habe
Nach ein paar Monaten musste ich die Punktestände einmal zurücksetzen, weil die erste Formel unbrauchbar war. Das war die unangenehmste Ankündigung, die ich in diesem Projekt je gemacht habe.
Drei Sachen haben geholfen, und ich würde sie jederzeit wieder so machen:
Ankündigen, mit Datum und Grund. Zwei Wochen vorher, im Chat und beim Aufruf des Befehls. Wer erst danach erfährt, dass seine Zahl weg ist, fühlt sich übergangen — auch wenn die Zahl objektiv nichts wert war.
Den alten Stand aufheben. Die Werte von damals liegen bis heute in einer Tabelle. Benutzt hat sie nie jemand. Aber die Aussage „es ist nichts gelöscht, es zählt nur nicht mehr" hat den Unterschied gemacht.
Etwas zurückgeben. Wer vorher ein hohes Level hatte, hat eine dauerhafte Auszeichnung bekommen, die man nicht mehr erreichen kann. Das kostet nichts und wandelt einen Verlust in eine Geschichte.
Der Teil, den ich unterschätzt habe: Anzeige
Die Formel war die spannende Arbeit. Die Wirkung hatte etwas anderes.
Ich habe irgendwann eine Fortschrittsanzeige eingebaut — nicht nur „du bist Level 12", sondern eine Leiste, die zeigt, wie weit es bis Level 13 ist. Danach wurde der Profilbefehl deutlich häufiger benutzt als vorher.
An der Mechanik hatte sich nichts geändert. Der Unterschied war, dass ein Fortschritt sichtbar wurde, der vorher nur rechnerisch existierte.
Das ist eine Sache, die ich seitdem bei jeder Art von Fortschritt beachte, auch außerhalb von Spielmechanik: Ladebalken, Bearbeitungsstände, Warteschlangenpositionen. Menschen ertragen fast jede Dauer, solange sie sehen, dass sich etwas bewegt. Und sie empfinden dieselbe Dauer als Stillstand, wenn nichts angezeigt wird.
Was ich rückblickend über Spielmechanik gelernt habe
Der wichtigste Satz hat mit Code nichts zu tun: Ein sichtbarer Zähler verändert das Verhalten der Leute, die ihn sehen. Das ist keine Vermutung, das konnte ich in unserem Chat direkt beobachten. Nachdem das Levelsystem lief, wurde mehr geschrieben, und ein Teil davon war schlechter als vorher.
Man kann das gut finden — mehr Betrieb im Chat ist erst mal, was man wollte — aber man sollte es wissen. Ich habe seitdem einen gewissen Respekt vor jeder Zahl, die ich öffentlich anzeige. Ranglisten sind mächtige Werkzeuge, und sie ziehen die Aufmerksamkeit dorthin, wo sie messen.
Was ich deshalb heute anders mache: Ich zeige lieber Ranglisten mit mehreren Kategorien als eine einzige Gesamtwertung. Wenn es fünf Wege gibt, oben zu stehen, jagt niemand mehr nur einer Zahl hinterher. Dieselbe Überlegung ist Jahre später in die Statistiken meiner Minecraft-Server geflossen — dort gibt es Spielzeit, abgebaute Blöcke, zurückgelegte Strecke und ein paar andere Kategorien nebeneinander, und ganz bewusst keine Gesamtpunktzahl.