Ein eigener Befehl ist bei fast allen das erste Plugin. Du willst /kit pvp tippen und es soll etwas passieren. Der Java-Code dafür sind dreißig Zeilen. Das Problem ist nie Java, sondern die Kette darum herum, und vor allem eine Datei mit sechs Zeilen, die beim ersten Mal wirklich jeder vergisst.
Dieser Guide geht die Kette in genau der Reihenfolge durch, in der der Server sie abarbeitet: Deklaration, Executor, Argumente, Tab-Vervollständigung, Rechte, Ablehnungsnachricht. Am Ende steht die Diagnose-Tabelle, die ich am ersten Tag gebraucht hätte: was der Spieler sieht, verglichen mit dem, was die Konsole sagt, für jede Art von Fehler.
Der stille Fehler, einmal komplett erklärt
Das passiert, wenn ein Befehl nicht in plugin.yml deklariert ist: Das Plugin lädt normal, es erscheint grün in /plugins, die Konsole sagt nichts, wirklich gar nichts. Der Spieler tippt den Befehl, der Server antwortet mit seiner Vanilla-Meldung für unbekannte Befehle. Dein Code wurde nie aufgerufen, und nirgendwo steht ein Hinweis darauf.
Das ist der entscheidende Unterschied zu allen anderen Plugin-Fehlern. Eine falsche Java-Version wirft dir einen UnsupportedClassVersionError hin, eine fehlende Abhängigkeit einen NoClassDefFoundError. Ein nicht deklarierter Befehl wirft nichts. Anfänger schließen daraus, dass ihr Code kaputt ist, und schreiben ihn dreimal neu, obwohl er von Anfang an richtig war.
Der Reflex, der dir zwei Stunden spart
Die ganze Kette, in der Reihenfolge des Servers
Schickt ein Spieler einen Befehl, geht der Server immer die gleichen Schritte durch. Wer sie in der richtigen Reihenfolge kennt, weiß sofort, welches Glied gerissen ist.
- Registrierung. Beim Laden liest der Server den
commands:-Block aus plugin.yml und erzeugt pro Eintrag einen Befehl. Steht hier nichts, existiert nichts. - Auflösung. Der Server sucht den getippten Namen oder einen seiner Aliase.
- Rechteprüfung. Ist das Feld
permission:deklariert und hat der Absender es nicht, zeigt der Server die Ablehnung und bricht ab. Dein Code läuft nicht. - Aufruf des Executors. Deine Methode
onCommandbekommt den Absender, den Befehl, das benutzte Label und das Array der Argumente. - Rückgabewert.
trueheißt, du hast die Anfrage erledigt.falselöst die Anzeige derusage:-Zeile aus.
Die Tab-Vervollständigung läuft auf einem parallelen Weg: sie feuert bei jedem Tastendruck, noch vor dem Absenden, und geht über eine andere Methode. Die API-Referenzen stehen auf docs.papermc.io, das Javadoc auf jd.papermc.io.
Schritt 1: den Befehl in plugin.yml deklarieren
Hier eine vollständige plugin.yml für einen Befehl /kit mit Alias, usage-Zeile, Recht und Ablehnungsnachricht.
name: MinaxKit
version: 1.0.0
main: fun.minax.kit.MinaxKit
api-version: '1.21'
description: Verteilt Kits an Spieler.
commands:
kit:
description: Gibt einem Spieler ein Kit.
usage: /kit <starter|pvp|builder> [Spieler]
aliases: [k]
permission: minaxkit.use
permission-message: Du hast keinen Zugriff auf die Kits.
permissions:
minaxkit.use:
description: Erlaubt /kit fuer dich selbst.
default: true
minaxkit.other:
description: Erlaubt /kit <name> <anderer Spieler>.
default: op
Drei Details, die alles kaputt machen und die man mit dem bloßen Auge nicht sieht. Der Befehlsname ist ein YAML-Schlüssel, also zählt die Einrückung: zwei Leerzeichen unter commands:, niemals ein Tabulator. Die Datei muss direkt in src/main/resources liegen, nicht neben der Klasse. Und api-version muss ein String in Anführungszeichen sein, sonst liest YAML 1.21 als Dezimalzahl.
Die Zahl, die über deine api-version entscheidet
Schritt 2: der Executor und die getCommand-Falle
Der Standard-Executor eines Befehls ist das Plugin selbst. Schreibst du onCommand also direkt in deine Hauptklasse, funktioniert es ohne jede Registrierung. Das ist bequem, und genau das macht den stillen Fehler so gemein: ohne Aufruf von getCommand prüft nie etwas, ob der Befehl auf Serverseite überhaupt existiert.
Meine Meinung, und sie ist eindeutig: registriere immer explizit, auch in der Hauptklasse, und fange das null ab. Du tauschst einen stummen Fehler gegen einen lauten, und das ist ein riesiger Fortschritt.
package fun.minax.kit;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class MinaxKit extends JavaPlugin {
@Override
public void onEnable() {
KitCommand handler = new KitCommand();
PluginCommand command = getCommand("kit");
if (command == null) {
getLogger().severe("Befehl 'kit' fehlt in plugin.yml, das Plugin schaltet sich ab.");
getServer().getPluginManager().disablePlugin(this);
return;
}
command.setExecutor(handler);
command.setTabCompleter(handler);
getLogger().info("Befehl /kit registriert.");
}
}
Das getLogger().info in der letzten Zeile ist keine Deko. Es ist dein Beweis beim Start: siehst du die Zeile nicht in der Konsole, liegt das Problem vor dem Java-Code.
Schritt 3: die Argumente, ein Array ohne Garantien
Das Argument-Array enthält alles, was der Spieler nach dem Befehlsnamen getippt hat, an Leerzeichen aufgeteilt. Es kann leer sein. Es kann alles enthalten. Nichts wird für dich geprüft, und ein args[0] auf einem leeren Array wirft eine Exception, die der Spieler als allgemeine interne Fehlermeldung zu sehen bekommt.
package fun.minax.kit;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.util.StringUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
public final class KitCommand implements CommandExecutor, TabCompleter {
private static final List<String> KITS = List.of("starter", "pvp", "builder");
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
return false; // der Server zeigt die usage:-Zeile aus plugin.yml
}
String kit = args[0].toLowerCase(Locale.ROOT);
if (!KITS.contains(kit)) {
sender.sendMessage(Component.text("Unbekanntes Kit: " + args[0], NamedTextColor.RED));
return true;
}
Player ziel;
if (args.length >= 2) {
if (!sender.hasPermission("minaxkit.other")) {
sender.sendMessage(Component.text("Du kannst nur dir selbst ein Kit geben.", NamedTextColor.RED));
return true;
}
ziel = Bukkit.getPlayerExact(args[1]);
if (ziel == null) {
sender.sendMessage(Component.text(args[1] + " ist nicht online.", NamedTextColor.RED));
return true;
}
} else if (sender instanceof Player selbst) {
ziel = selbst;
} else {
sender.sendMessage(Component.text("Aus der Konsole: /kit <name> <Spieler>", NamedTextColor.RED));
return true;
}
ziel.sendMessage(Component.text("Kit " + kit + " erhalten.", NamedTextColor.GREEN));
return true;
}
// Die Klasse geht in Schritt 4 weiter: TabCompleter verlangt onTabComplete.
Zwei Dinge fallen auf. Das return false am Anfang ist kein Eingeständnis einer Panne, es ist die eingebaute Hilfe, die deine usage:-Zeile zeigt. Und der Absender ist nicht zwingend ein Spieler: die Konsole kann den Befehl schicken, ein Befehlsblock ebenfalls. Die Prüfung instanceof Player ist nicht optional, ohne sie stürzt dein Befehl beim ersten Aufruf aus der Konsole ab.
Schritt 4: die Tab-Vervollständigung
Das ist der Teil, den alle überspringen, und genau daran erkennt man den Unterschied zwischen einem Hobby-Plugin und einem, das man gerne benutzt. Ein Spieler, der Tab drückt und nichts sieht, schließt daraus, dass es den Befehl nicht gibt.
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
List<String> treffer = new ArrayList<>();
StringUtil.copyPartialMatches(args[0], KITS, treffer);
Collections.sort(treffer);
return treffer;
}
if (args.length == 2 && sender.hasPermission("minaxkit.other")) {
return null; // Fallback des Servers: Namen der eingeloggten Spieler
}
return Collections.emptyList();
}
}
copyPartialMatches filtert deine Liste nach dem, was der Spieler schon getippt hat, ohne auf Groß- und Kleinschreibung zu achten. Der Punkt, auf den es wirklich ankommt: der Unterschied zwischen null und leerer Liste. Bei null greift der Fallback des Servers und vervollständigt mit den Namen der eingeloggten Spieler. Beim zweiten Argument ist das praktisch, überall sonst gibst du damit Informationen preis. Standard ist die leere Liste, null nur bewusst.
Schritt 5: Rechte und die Ablehnungsnachricht
Der unerwartete Teil: sobald du permission: in plugin.yml deklarierst, prüft der Server das Recht vor dem Aufruf deines Executors. Ein hasPermission auf denselben Knoten innerhalb von onCommand ist damit toter Code. Für Rechte auf Argument-Ebene bleibt es dagegen unverzichtbar, wie beim minaxkit.other aus dem Beispiel.
Das hat direkte Folgen für die Ablehnungsnachricht. Behältst du permission: in plugin.yml, ist dein Text der aus permission-message, als einfacher Text. Willst du eine ordentlich mit Adventure gefärbte oder klickbare Ablehnung, musst du permission: aus plugin.yml nehmen und selbst prüfen.
// Variante: Recht NICHT in plugin.yml deklariert, Ablehnung komplett selbst gesteuert
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission("minaxkit.use")) {
sender.sendMessage(Component.text("Kits gibt es nur fuer Mitglieder.", NamedTextColor.RED));
return true;
}
// ... weitere Verarbeitung
return true;
}
Meine Entscheidung: bei Admin-Befehlen behalte ich permission: in plugin.yml, weil der Server den Befehl dann in der Hilfe versteckt und so früh wie möglich blockt. Bei Befehlen für Spieler nehme ich es heraus, denn dort gehört die Ablehnungsnachricht zum Spielgefühl. Und ich deklariere immer den Block permissions: mit einem ausdrücklichen default:, sonst zeigt ein Rechtesystem wie LuckPerms deinen Knoten erst an, wenn ihn jemand von Hand vergeben hat.
Die Diagnose-Tabelle
So klar aufgeschrieben habe ich das nirgends gefunden, und damit findest du den Fehler in zehn Sekunden: was der Spieler sieht, gegen das, was die Konsole sagt.
| Was der Spieler sieht | Was die Konsole sagt | |
|---|---|---|
| Fehlt in plugin.yml | Unbekannter Befehl (Vanilla-Text) | Absolut nichts |
| Deklariert, kein Executor | Die usage:-Zeile | Nichts |
| getCommand null ignoriert | Unbekannter Befehl | Exception beim Start, Plugin aus |
| Recht fehlt | Die permission-message | Nichts |
| onCommand gibt false zurück | Die usage:-Zeile | Nichts |
| Exception in onCommand | Allgemeiner interner Fehler | Kompletter Stacktrace mit deiner Klasse |
| Name schon belegt | Der Befehl des anderen Plugins | Konfliktzeile beim Laden |
Das Präfix, das Konflikte klärt
Was nicht funktioniert, und was du kaputt machst
Vier Fallen, die speziell Befehle treffen. Drei davon haben mit dem Befehlscode selbst nichts zu tun.
- Die Ablehnungsnachricht bricht beim Update. Paper 26.x bringt Adventure 5 mit, und dort wurden APIs entfernt, die vorher nur veraltet markiert waren. Es trifft vor allem alles, was Text vor Spielern anzeigt. Ein onCommand, das für 1.21 mit alten Text-Nachrichten geschrieben wurde, kompiliert unter Umständen nicht mehr. Das ist die Ironie: der trivialste Teil deines Befehls ist am stärksten betroffen.
- Ein Befehl, der eine Datenbank liest, blockiert den Server. onCommand läuft im Hauptthread. Ein synchroner Datenbankzugriff darin drückt die TPS für alle nach unten. Den JDBC-Treiber für SQLite bringt Paper schon mit, du musst also nichts mitliefern, aber das Lesen gehört in einen eigenen Thread und muss zurück in den Server-Thread, bevor es die Welt anfasst.
- Auf Folia darf dein Befehl nicht von einem einzigen Thread ausgehen. Folia führt Weltregionen auf mehreren Threads aus. Ein Befehl, der teleportiert, einen Block setzt oder ein Inventar ändert, muss über den RegionScheduler laufen, sonst crasht der Server.
- Der Schlüssel für Spielerdaten ist eine Designfalle. 75,1 % der Server laufen im Offline-Modus, und dort wird die UUID eines Spielers aus seinem Namen abgeleitet. Speichert dein /kit eine Wartezeit pro UUID, verwaisen alle deine Einträge, sobald ein Spieler seinen Namen ändert oder der Server später auf den Online-Modus umsteigt. Plane das ab der ersten Version ein, nicht an dem Abend, an dem ein Spieler seinen Rang zurückhaben will.
Nicht neu laden, sondern neu starten
Meine Empfehlung: wann plugin.yml reicht
Den hier beschriebenen Weg, einen commands:-Block plus einen Executor, behalte ich bis zu drei Unterbefehlen und zwei Argumenten. Unter dieser Schwelle ist alles andere unnötige Komplexität: eine Klasse, keine Abhängigkeit, kein Lizenzrisiko.
Darüber, sobald du Bedingungsketten auf args[0] schreibst, um give, list, reload und help auseinanderzuhalten, wechsle ich auf ein Befehls-Framework wie Lamp, das unter einer permissiven Lizenz steht und damit problemlos weitergegeben werden darf. Der Auslöser ist nicht die Spielerzahl, sondern die Anzahl der Verzweigungen: beim dritten else if auf args[0] rechnet sich das Framework.
Wovon ich abrate: direkt in den tiefliegenden Befehlsbaum des Servers zu greifen, nur um farbige Argument-Hinweise zu bekommen. Es funktioniert, es sieht beeindruckend aus, und es bindet dich an eine interne Schicht, die sich von Version zu Version ändert. Minecraft ist seit 26.1 deobfuskiert und der Remapper von Paper ist weg, das vereinfacht viel, aber eine interne API bleibt eine interne API.
Ein kompletter Befehl, generiert und kompiliert
Beschreibe den Befehl, den du willst, seine Argumente und seine Rechte. Minax schreibt die plugin.yml, den Executor und die Tab-Vervollständigung und gibt dir ein getestetes .jar.
Befehl erstellenHäufige Fragen
Warum reagiert mein Befehl überhaupt nicht, ohne jede Fehlermeldung?
Weil er nicht im commands-Block von plugin.yml steht. Der Server weiß nichts von deinem Befehl, antwortet mit der Vanilla-Meldung für unbekannte Befehle, und dein Plugin wird nie aufgerufen. In der Konsole steht keine einzige Zeile. Genau deshalb ist das der Fehler, den Anfänger am schwersten finden.
Brauche ich getCommand().setExecutor(), wenn onCommand in der Hauptklasse steht?
Technisch nein, der Standard-Executor eines Befehls ist das eigene Plugin. Ich empfehle es trotzdem: fehlt der Befehl in plugin.yml, liefert getCommand null, und du bekommst beim Start einen lauten Fehler statt kompletter Stille.
Was passiert, wenn onCommand false zurückgibt?
Der Server zeigt dem Spieler die usage-Zeile aus plugin.yml. Das ist kein technisches Fehlersignal, sondern die eingebaute Hilfe. Gib false zurück, wenn der Befehl falsch benutzt wurde, und true, sobald du deine eigene Nachricht verschickt hast.
Wie verhindere ich, dass die Tab-Vervollständigung alle Spieler auflistet?
Gib eine leere Liste statt null zurück. Bei null greift der Fallback des Servers und vervollständigt mit den Namen der eingeloggten Spieler. Damit sieht jeder, wer gerade online ist.
Wohin gehört die Nachricht, wenn ein Spieler kein Recht hat?
Zwei Wege. Entweder in das Feld permission-message in plugin.yml, dann blockt der Server schon vor deinem Code. Oder du nimmst permission aus plugin.yml heraus und prüfst hasPermission selbst, dann hast du Text und Formatierung vollständig in der Hand.
Welche api-version gehört im Juli 2026 in plugin.yml?
Die 1.21-Familie ist im echten Serverbestand weiter in der Mehrheit: 1.21.11 kommt auf 36,4 % der gemessenen Server, 26.1.2 auf 14,9 % und 26.2 auf 7,4 %. Eine zu neue api-version schließt die Mehrheit aus. Ich bleibe auf der 1.21-Familie, solange die Kalenderversionen kein Drittel des Bestands halten.
Weiterlesen: die Fehler, die ein Plugin am Laden hindern, der Guide zum minimalen Spigot-Plugin, und die API-Referenzen auf docs.papermc.io, hub.spigotmc.org und minecraft.wiki. Die genannten Versionsanteile kommen aus der öffentlichen bStats-Messung vom Juli 2026.


