Jeder Server, der wächst, bekommt irgendwann eine Website. Und jede Website will irgendwann mit dem Spiel reden: Ein Shop soll einen Rang vergeben, eine Rangliste soll außerhalb des Spiels sichtbar sein, ein Discord-Konto soll zu einem Spielernamen gehören, ein besiegter Boss soll eine Ankündigung auslösen. Das funktioniert alles gut, solange du verstehst, dass es nicht eine Leitung ist, sondern zwei, dass sie unterschiedliche Regeln haben, und dass genau die Stelle, an der du Netzwerkaufrufe machen willst, die einzige ist, an der du es niemals tun darfst.
Zwei Richtungen, zwei Probleme
Ausgehend und eingehend sind nicht dasselbe Problem
Die erste Richtung ist das Spiel schiebt ins Web. In der Welt passiert etwas, und die Website muss es irgendwann erfahren: Ein Spieler joint, ein Punktestand ändert sich, ein Boss stirbt. Hier ist der Server die Quelle der Wahrheit, und ein paar Sekunden Verzögerung stören niemanden.
Die zweite Richtung ist das Web schiebt ins Spiel. Eine Zahlung geht durch, ein Moderator klickt im CMS, eine Belohnung soll ankommen. Diesmal liegt die Wahrheit auf der Website, der Spieler wartet, und ein Fehler zeigt sich nicht im TPS-Diagramm, sondern in einem Support-Ticket mit dem Satz „ich habe bezahlt und nichts bekommen“. Beide Richtungen gehen unterschiedlich kaputt, also schreibst du sie auch unterschiedlich.
| Spiel schiebt (ausgehend) | Web schiebt (eingehend) | |
|---|---|---|
| Beispiele | Boss-Kill, Punkte, Join | Bezahlte Bestellung, Rang |
| Auslöser | Ein Ereignis in der Welt | Eine Aktion auf der Website |
| Verzögerung | Egal, Sekunden gehen klar | Kritisch, der Spieler wartet |
| Hauptrisiko | Ereignis geht verloren | Zweimal ausgeliefert |
| Was du brauchst | Warteschlange und Retry | Eine Lieferungs-ID |
| Offener Port | Keiner | Keiner, das Plugin holt ab |
Fall 1: Der Shop, der im Spiel einen Rang vergeben muss
Der häufigste Wunsch, und der am häufigsten falsch umgesetzte
Die richtige Kette hat vier Glieder, und die Reihenfolge ist entscheidend. Die Zahlung wird auf der Website bestätigt. Die Website schreibt eine Lieferungszeile mit eindeutiger ID, Zielkonto und Inhalt. Das Plugin fragt in einem asynchronen Thread die offenen Lieferungen ab. Es wendet sie an, danach bestätigt es. Erst nach dieser Bestätigung gilt die Lieferung für die Website als erledigt.
Zwei Details entscheiden, ob das System hält oder Streitfälle produziert. Erstens die Lieferungs-ID: Liest das Plugin dieselbe Zeile zweimal, weil ein Neustart zwischen Anwenden und Bestätigen kam, muss es erkennen, dass der Rang schon vergeben wurde. Zweitens der Offline-Spieler: Eine Lieferung, die ankommt, während ihr Ziel nicht verbunden ist, wird nicht weggeworfen, sondern für den nächsten Join aufbewahrt.
package fun.minax.bridge;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/** ASYNCHRON geplant: nur hier wird mit dem Netzwerk gesprochen, sonst nirgends. */
public final class DeliveryPuller implements Runnable {
private final WebBridgePlugin plugin;
private final Set<String> alreadyApplied = ConcurrentHashMap.newKeySet();
public DeliveryPuller(WebBridgePlugin plugin) {
this.plugin = plugin;
}
@Override
public void run() {
List<Delivery> pending = plugin.fetchPending();
for (Delivery delivery : pending) {
if (!alreadyApplied.add(delivery.id())) {
continue; // schon geliefert: ein Duplikat vergibt den Rang nicht erneut
}
Player player = plugin.getServer().getPlayer(delivery.target());
if (player == null) {
plugin.keepForNextJoin(delivery); // offline: aufbewahren, nie verwerfen
continue;
}
plugin.onOwnerThread(player, () -> plugin.apply(delivery, player));
}
}
public record Delivery(String id, UUID target, String payload) {
}
}Das wirst du kaputt machen
Warum eine Warteschlange und kein direkter Aufruf
Der Unterschied zwischen verspätet und verloren
Die Website direkt aus einem Event-Listener aufzurufen sieht kürzer aus. Tatsächlich bindest du damit die Gesundheit deines Spielservers an die Verfügbarkeit einer Maschine, die du nicht kontrollierst. Die Website antwortet in drei Sekunden, weil der Hoster überbucht ist, und dein Tick dauert drei Sekunden. Die Website ist in Wartung, und das Ereignis ist weg, ohne Spur, ohne zweiten Versuch.
Eine Warteschlange bricht diese Kopplung auf. Der Listener legt eine Nachricht ab und gibt sofort zurück. Ein asynchroner Task leert die Warteschlange in Paketen und legt die Einträge zurück, wenn das Senden scheitert. Die Website kann eine Stunde verschwinden, deine Ereignisse warten einfach.
package fun.minax.bridge;
import org.bukkit.plugin.java.JavaPlugin;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
public final class WebBridgePlugin extends JavaPlugin {
private final ConcurrentLinkedQueue<String> outbox = new ConcurrentLinkedQueue<>();
private HttpClient http;
@Override
public void onEnable() {
saveDefaultConfig();
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
getServer().getPluginManager().registerEvents(new GameEventListener(this), this);
getServer().getScheduler().runTaskTimerAsynchronously(this, this::flush, 100L, 100L);
getServer().getScheduler().runTaskTimerAsynchronously(this, new DeliveryPuller(this), 200L, 600L);
}
/** Aus jedem Thread aufrufbar: es wird nur angehaengt, kein Netzwerk beruehrt. */
public void enqueue(String jsonEvent) {
outbox.add(jsonEvent);
}
private void flush() {
List<String> batch = new ArrayList<>();
String item;
while (batch.size() < 50 && (item = outbox.poll()) != null) {
batch.add(item);
}
if (batch.isEmpty()) {
return;
}
String body = "[" + String.join(",", batch) + "]";
String timestamp = String.valueOf(System.currentTimeMillis() / 1000L);
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(getConfig().getString("endpoint", "")))
.header("Content-Type", "application/json")
.header("X-Bridge-Timestamp", timestamp)
.header("X-Bridge-Signature", Hmac.sign(timestamp + "." + body, secret()))
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 500) {
batch.forEach(outbox::add); // Website down: zurueck in die Warteschlange
}
} catch (Exception exception) {
batch.forEach(outbox::add);
getLogger().warning("Senden verschoben, Warteschlange behalten: " + exception.getMessage());
}
}
private String secret() {
return getConfig().getString("secret", "");
}
}Diese Version hält die Warteschlange im Speicher, ein Neustart verliert sie also. Für einen Shop ist das nicht akzeptabel, du brauchst sie dauerhaft. Gute Nachricht: Der SQLite-JDBC-Treiber wird bereits mit Paper ausgeliefert, das Speichern der Warteschlange kostet dich also keine zusätzliche Abhängigkeit. HikariCP ist dagegen nicht dabei. Wenn du irgendwann einen Connection-Pool brauchst, deklarierst du ihn im Block libraries: in plugin.yml, damit der Server ihn beim Start herunterlädt, statt ihn in dein .jar zu packen.
Der Befehl /reload zerstört deine Warteschlange
/reload hinterlässt verwaiste Tasks und Listener. Dein Sende-Task kann doppelt laufen oder stehen bleiben, während die Warteschlange weiter gefüllt wird. Auf einem Server, der echte Zahlungen bewegt, startest du immer sauber neu.Warum ein Netzwerkaufruf nie im Hauptthread startet
Der eine Thread, den du nie blockieren darfst
Ein Server läuft mit 20 Ticks pro Sekunde, das sind 50 Millisekunden pro Tick für alles: Mobs, Redstone, Weltgenerierung und dein Plugin. Ein HTTP-Aufruf zu einer Website garantiert nicht, in dieses Budget zu passen, und die DNS-Auflösung auch nicht. Der folgende Code ist die häufigste Ursache für „mein Server laggt, seit ich die Anbindung installiert habe“.
// NICHT MACHEN: Netzwerk im Hauptthread
@EventHandler
public void onJoin(PlayerJoinEvent event) throws Exception {
String body = """
{"player": "%s"}
""".formatted(event.getPlayer().getName());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://meine-website.de/api/join"))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// Jede Millisekunde hier geht vom Tick des Servers ab.
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
}Dieselbe Regel gilt für die Datenbank: Ein Zugriff, der den Hauptthread blockiert, lässt die TPS fallen, ob er zu MySQL im Netzwerk geht oder zu einer lokalen SQLite-Datei. Die gesunde Form ist immer gleich. Netzwerk und Datenbank in einem asynchronen Thread, und für die Server-API zurück auf den Thread, der die Entität oder die Region besitzt.
Dieser zuständige Thread ist keine Stilfrage. Folia führt die Regionen der Welt auf mehreren Threads aus, und ein Plugin, das einen einzigen Hauptthread annimmt, verursacht Crashes. Für ortsgebundene Arbeit nimmst du den RegionScheduler, für spielergebundene Arbeit den Scheduler der Entität. Die genauen Signaturen findest du in der Paper-Dokumentation und im Javadoc.
/** Fuehrt die Aktion auf dem richtigen Thread aus, egal ob Paper oder Folia. */
public void onOwnerThread(Player player, Runnable action) {
if (FOLIA) {
player.getScheduler().run(this, task -> action.run(), null);
} else {
getServer().getScheduler().runTask(this, action);
}
}
private static final boolean FOLIA = detectFolia();
private static boolean detectFolia() {
try {
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
return true;
} catch (ClassNotFoundException notFolia) {
return false;
}
}Fall 2: Die Rangliste auf der Website
Ausgehend, mit einer Denkfalle darin
Hier ist die Versuchung, die Website die Datenbank des Plugins direkt lesen zu lassen. Schlechte Idee, aus drei Gründen. Die SQLite-Datei liegt auf der Spielmaschine, du müsstest sie also nach außen öffnen. Ein paralleler Lesezugriff sperrt eine Datei, die der Server braucht. Und deine Startseite schickt damit Abfragen an die Maschine, die die Welt rechnet, genau dann, wenn du dort keine Gäste willst.
Das richtige Modell ist ein Schnappschuss. Alle fünf Minuten berechnet ein asynchroner Task die Top 50 und schickt sie an die Website, die sie aus ihrem eigenen Cache ausliefert. Die Rangliste wird damit zu Website-Daten statt zu Spieldaten. Web-Traffic hat dann null Einfluss auf die TPS, und die Seite funktioniert weiter, wenn der Spielserver in Wartung ist.
Schick immer beide Schlüssel
Fall 3: Ein Discord-Konto mit dem Spielkonto verbinden
Der Beweis muss im Spiel entstehen
Die kaputte Variante dieser Funktion ist ein Feld „dein Minecraft-Name“ auf der Website. Da kann jeder den Namen eines anderen eintragen und dessen Käufe einsammeln. Die Verknüpfung muss im Spiel beginnen, weil nur dort die Identität schon durch die Server-Sitzung feststeht.
Der Spieler tippt im Spiel /link. Das Plugin erzeugt einen kurzen Code, einmalig gültig, zehn Minuten lang, und schiebt ihn über die ausgehende Warteschlange zur Website. Der Spieler fügt diesen Code auf der Website oder im Discord-Bot ein. Die Website prüft, ob der Code existiert, nicht abgelaufen und nicht schon benutzt ist, und schreibt dann die Verknüpfung. Der Code läuft vom Spiel ins Web, und das Vertrauen läuft in dieselbe Richtung.
private static final SecureRandom RANDOM = new SecureRandom();
private final Map<String, PendingLink> pendingCodes = new ConcurrentHashMap<>();
/** Ein kurzer Code, einmalig gueltig, zehn Minuten lang. */
public String createLinkCode(Player player) {
byte[] raw = new byte[4];
RANDOM.nextBytes(raw);
String code = HexFormat.of().withUpperCase().formatHex(raw);
long expiresAt = System.currentTimeMillis() + Duration.ofMinutes(10).toMillis();
pendingCodes.put(code, new PendingLink(player.getUniqueId(), player.getName(), expiresAt));
return code;
}
public record PendingLink(UUID uuid, String name, long expiresAt) {
}Fall 4: Der Webhook, der ein Event ankündigt
Ausgehend, gebündelt, und mit Adventure 5 im Kopf
Außerhalb des Spiels anzukündigen, dass ein Boss gefallen ist oder ein Event startet, ist der einfachste Fall, weil nichts zurückkommt. Er nutzt dieselbe ausgehende Warteschlange. Die einzige zusätzliche Regel betrifft die Rate: Schick nicht für jeden Kill einen HTTP-Aufruf ab. Bündle, und sende ein Paket alle fünf Sekunden. Dienste, die Webhooks empfangen, begrenzen die Anzahl der Aufrufe, und ein gedrosseltes Plugin verliert seine Nachrichten.
Die Adventure-5-Falle
Sicherheit: ein geteilter Schlüssel, kein offener Endpoint
Der Punkt, an dem selbstgebaute Anbindungen scheitern
Ein Endpoint, der „gib diesem Spieler VIP“ annimmt und nur prüft, ob der Spieler existiert, ist ein offener Endpoint. Richtig gemacht braucht es drei Dinge. Ein geteiltes Secret, das Website und Plugin kennen und das nie in einer URL steht. Eine HMAC-SHA256-Signatur über den exakten Body, mit einem Zeitstempel innerhalb der signierten Daten, damit er nicht umgeschrieben werden kann. Und einen Vergleich in konstanter Zeit, sonst verrät die Prüfung selbst Informationen.
package fun.minax.bridge;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public final class Hmac {
private Hmac() {
}
public static String sign(String payload, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
}
/** Konstante Zeit: ein normales equals() verraet die Laenge des korrekten Prefixes. */
public static boolean verify(String payload, String secret, String received) throws Exception {
byte[] expected = sign(payload, secret).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(expected, received.getBytes(StandardCharsets.UTF_8));
}
}Bleibt die Frage nach der eingehenden Richtung, und da bin ich deutlich: Ich öffne nie einen HTTP-Listener im Prozess des Minecraft-Servers. Das Plugin lebt in derselben virtuellen Maschine wie die Welt, eine fehlerhafte Anfrage oder eine Flut von Verbindungen bezahlst du direkt mit TPS, und die IP des Spielservers wird zum Ziel. Der eingehende Endpoint bleibt auf der Website, und das Plugin fragt ihn ab.
Die Falle Nummer eins: die UUID im Offline-Modus
75,1 % der Server sind betroffen
Hier ist die Zahl, die fast kein Shop-Tutorial erwähnt. 75,1 % der Server laufen im Offline-Modus. In diesem Modus wird die UUID eines Spielers aus seinem Namen abgeleitet. Sie kommt nicht von Mojang, sie wird aus dem Namen berechnet.
Für eine Web-Anbindung ist die Folge brutal. Ändert der Spieler seinen Namen, oder stellst du den Server irgendwann auf Online-Modus um, ändert sich der Schlüssel und jede Bestellung, die an der alten UUID hängt, ist verwaist. Der Spieler hat bezahlt, die Zeile existiert in deiner Datenbank, und niemand wird sie jemals abrufen. Das ist die häufigste Ursache für Shop-Streitfälle, und sie entscheidet sich bei der Wahl des Primärschlüssels, nicht beim Auftreten des Fehlers.
Die Lösung ist eine eigene, stabile Konto-ID, die du selbst erzeugst. UUID und Name werden zu Attributen dieses Kontos, Attribute, die sich ändern dürfen. Die Käufe zeigen nur auf das Konto.
CREATE TABLE account (
account_id TEXT PRIMARY KEY, -- von dir erzeugt, aendert sich nie
minecraft_uuid TEXT NOT NULL, -- Attribut: kann im Offline-Modus wechseln
minecraft_name TEXT NOT NULL, -- Attribut: aendert sich bei jeder Umbenennung
discord_id TEXT,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX account_by_uuid ON account(minecraft_uuid);
CREATE TABLE delivery (
delivery_id TEXT PRIMARY KEY, -- die ID, die die Lieferung idempotent macht
account_id TEXT NOT NULL REFERENCES account(account_id),
payload TEXT NOT NULL,
applied_at INTEGER -- NULL, solange das Spiel nicht bestaetigt hat
);Der Test, der das Problem in einer Minute zeigt
Auf welche Version du die Anbindung baust
Der echte Bestand, nicht die neueste Ankündigung
Minecraft ist auf kalendarische Versionen umgestiegen, die neueste Version ist 26.2, erschienen am 16.06.2026. Trotzdem ist sie nicht das Ziel, auf das du baust. Eine bStats-Erhebung über 62.948 Server zeichnet ein deutlich anderes Bild als die Ankündigungen.
| Anteil am Bestand | Was das für deine Anbindung heißt | |
|---|---|---|
| 1.21.11 | 36,4 % | Das Mehrheitsziel, mit großem Abstand |
| 26.1.2 | 14,9 % | Das sinnvolle kalendarische Ziel in Produktion |
| 26.2 | 7,4 % | Kein stabiler Paper-Build, in Produktion meiden |
| 1.21.4 | 7,2 % | Älteres 1.21, noch klar vorhanden |
Die 1.21-Familie ist weiter in der Mehrheit. Praktisch heißt das: Eine Anbindung mit api-version: '1.21' erreicht den größten Teil des Bestands. Beim Kompilieren ist Java 25 die Untergrenze der kalendarischen Versionen, Java 21 als Ziel bleibt aber richtig: Das läuft auf der Mehrheit der Server, und ein .jar für Java 21 lädt auch auf einer neueren Runtime. Umgekehrt gilt das nicht. Noch ein Hinweis für dein Build: Minecraft ist seit 26.1 deobfuskiert und der Remapper von Paper ist verschwunden, für kalendarische Ziele fällt der Remapping-Schritt also weg.
Meine Empfehlung
Was ich empfehle, und ab wann es sich ändert
Unter 50 gleichzeitigen Spielern und bei ein paar Dutzend Lieferungen pro Tag empfehle ich das einfache Abholen: Das Plugin fragt die Website alle dreißig Sekunden. Der Käufer wartet höchstens eine halbe Minute, was niemandem auffällt, und du hast eingehend buchstäblich nichts zu sichern, weil nichts hereinkommt. Das ist das beste Verhältnis von Robustheit zu Aufwand, und dort sollten die meisten Server aufhören.
Darüber, wenn der Rang in unter einer Sekunde ankommen muss, weil der Käufer auf den Bildschirm schaut, verkürze ich das Intervall auf fünf Sekunden, statt einen Port zu öffnen. Ein kurzes Intervall bleibt leichter abzusichern als ein eingehender Endpoint und kostet ein paar HTTP-Anfragen pro Minute, also nichts.
In einem Netzwerk aus mehreren Servern hinter einem Proxy hört die Warteschlange auf, ein Implementierungsdetail zu sein, und wird eine eigene Komponente. Ein Dienst besitzt die offenen Lieferungen, jeder Spielserver holt seine eigenen, und die Markierung „angewendet“ lebt in diesem Dienst und nirgends sonst. Sonst wenden zwei Server dieselbe Lieferung an, und eine lokale Lieferungs-ID schützt sie nicht voreinander.
Eine Web-Anbindung generieren, testen, installieren
Beschreibe den Shop, die Rangliste oder den Webhook, den du anbinden willst. Minax schreibt den Java-Code, kompiliert ihn und gibt dir ein .jar, das du online testen kannst, bevor es auf deinen Server kommt.
Plugin erstellenFazit
Fünf Dinge zum Mitnehmen. Zwei Richtungen, zwei Regelwerke. Eine Warteschlange dazwischen, nie ein direkter Aufruf. Kein Netzwerk und keine Datenbank auf dem Thread, der die Welt besitzt. Ein geteilter Schlüssel mit Signatur, und ein Plugin, das abholt, statt eines Ports, der lauscht. Und bevor du die erste Zeile schreibst, wähle einen Primärschlüssel, der eine Umbenennung übersteht, denn auf 75,1 % der Server ist die UUID keiner. Weiter geht es mit unserem Guide zu Economy-Plugins, der die Speicherung von Guthaben behandelt, und mit dem Guide zur Fehlersuche, der die Ladefehler bei der ersten Bereitstellung abdeckt.



