Une commande, c'est le premier plugin de presque tout le monde. Tu veux taper /kit pvp et qu'il se passe quelque chose. Le code Java qui fait ça tient en trente lignes. Le problème n'est jamais le Java : c'est la chaîne autour, et surtout un fichier de six lignes que tout le monde oublie la première fois.
Ce guide suit la chaîne dans l'ordre exact où le serveur la parcourt : déclaration, exécuteur, arguments, complétion automatique, permissions, message de refus. Avec, à la fin, la table de diagnostic que j'aurais voulu avoir le premier jour : ce que voit le joueur et ce que dit la console pour chaque manière de se tromper.
La panne silencieuse, expliquée une fois pour toutes
Voici ce qui arrive quand une commande n'est pas déclarée dans plugin.yml : le plugin se charge normalement, il apparaît en vert dans /plugins, la console ne dit rien, absolument rien. Le joueur tape la commande, le serveur répond son message vanilla de commande inconnue. Ton code n'a jamais été appelé, et rien nulle part ne te l'indique.
C'est la différence fondamentale avec les autres pannes de plugin. Une mauvaise version de Java te jette une UnsupportedClassVersionError, une dépendance manquante te jette une NoClassDefFoundError. Une commande non déclarée ne jette rien. Le débutant en conclut que son code est faux et le réécrit trois fois, alors que le code était juste depuis le début.
Le réflexe qui te sauve deux heures
La chaîne complète, dans l'ordre du serveur
Quand un joueur envoie une commande, le serveur suit toujours les mêmes étapes. Les connaître dans l'ordre, c'est savoir à quel maillon ça casse.
- Enregistrement : au chargement du plugin, le serveur lit le bloc
commands:de plugin.yml et crée une commande pour chaque entrée. Rien ici, rien n'existe. - Résolution : le serveur cherche le nom tapé, ou l'un de ses alias.
- Test de permission : si le champ
permission:est déclaré et que l'expéditeur ne l'a pas, le serveur affiche le refus et s'arrête là. Ton code n'est pas appelé. - Appel de l'exécuteur : ta méthode
onCommandreçoit l'expéditeur, la commande, le label utilisé et le tableau d'arguments. - Valeur de retour :
truesignifie que tu as traité la demande,falsedéclenche l'affichage de la ligneusage:.
La complétion automatique suit un chemin parallèle : elle est déclenchée à chaque frappe de touche, avant l'envoi, et passe par une méthode différente. Les références d'API sont sur docs.papermc.io et le javadoc sur jd.papermc.io.
Étape 1 : déclarer la commande dans plugin.yml
Voici un plugin.yml complet pour une commande /kit avec un alias, une ligne d'usage, une permission et un message de refus.
name: MinaxKit
version: 1.0.0
main: fun.minax.kit.MinaxKit
api-version: '1.21'
description: Donne des kits aux joueurs.
commands:
kit:
description: Donne un kit a un joueur.
usage: /kit <starter|pvp|builder> [joueur]
aliases: [k]
permission: minaxkit.use
permission-message: Tu n'as pas acces aux kits.
permissions:
minaxkit.use:
description: Autorise l'usage de /kit sur soi-meme.
default: true
minaxkit.other:
description: Autorise /kit <nom> <autre joueur>.
default: op
Trois détails qui cassent tout et qu'on ne voit pas à l'œil nu. Le nom de la commande est une clé YAML, donc l'indentation compte : deux espaces sous commands:, jamais de tabulation. Le fichier doit se trouver à la racine de src/main/resources, pas à côté de la classe. Et api-version doit être une chaîne entre guillemets, sinon YAML lit 1.21 comme un nombre décimal.
Le chiffre qui décide de ton api-version
Étape 2 : l'exécuteur, et le piège du getCommand
L'exécuteur par défaut d'une commande est le plugin lui-même. Autrement dit, si tu écris onCommand directement dans ta classe principale, ça fonctionne sans rien enregistrer. C'est commode, et c'est exactement ce qui rend la panne silencieuse si vicieuse : sans appel à getCommand, rien ne vérifie jamais que la commande existe côté serveur.
Mon avis, et il est tranché : enregistre toujours explicitement, même dans la classe principale, et traite le null. Tu convertis une panne muette en panne bruyante, ce qui est un immense progrès.
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("La commande 'kit' est absente de plugin.yml, le plugin s'arrete.");
getServer().getPluginManager().disablePlugin(this);
return;
}
command.setExecutor(handler);
command.setTabCompleter(handler);
getLogger().info("Commande /kit enregistree.");
}
}
Le getLogger().info de la dernière ligne n'est pas de la décoration. C'est ta preuve visuelle au démarrage : si tu ne la vois pas dans la console, le problème est en amont du Java.
Étape 3 : les arguments, un tableau sans garantie
Le tableau d'arguments contient tout ce que le joueur a tapé après le nom de la commande, découpé aux espaces. Il peut être vide. Il peut contenir n'importe quoi. Aucune validation n'est faite pour toi, et un args[0] sur un tableau vide lève une exception que le joueur verra sous forme de message d'erreur interne.
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; // le serveur affiche la ligne usage: de plugin.yml
}
String kit = args[0].toLowerCase(Locale.ROOT);
if (!KITS.contains(kit)) {
sender.sendMessage(Component.text("Kit inconnu : " + args[0], NamedTextColor.RED));
return true;
}
Player cible;
if (args.length >= 2) {
if (!sender.hasPermission("minaxkit.other")) {
sender.sendMessage(Component.text("Tu ne peux donner un kit qu'a toi-meme.", NamedTextColor.RED));
return true;
}
cible = Bukkit.getPlayerExact(args[1]);
if (cible == null) {
sender.sendMessage(Component.text(args[1] + " n'est pas connecte.", NamedTextColor.RED));
return true;
}
} else if (sender instanceof Player self) {
cible = self;
} else {
sender.sendMessage(Component.text("Depuis la console : /kit <nom> <joueur>", NamedTextColor.RED));
return true;
}
cible.sendMessage(Component.text("Kit " + kit + " recu.", NamedTextColor.GREEN));
return true;
}
// La classe continue a l'etape 4 : onTabComplete est exige par TabCompleter.
Deux choses à remarquer. Le return false du début n'est pas un aveu d'échec : c'est le mécanisme d'aide intégré, il affiche ta ligne usage:. Et l'expéditeur n'est pas forcément un joueur, la console peut envoyer la commande, un bloc de commande aussi. Le test instanceof Player n'est pas optionnel : sans lui, ta commande plante dès la première exécution depuis la console.
Étape 4 : la complétion automatique
C'est la partie que tout le monde saute, et c'est la différence visible entre un plugin d'amateur et un plugin qu'on a envie d'utiliser. Un joueur qui appuie sur Tab et ne voit rien apparaître conclut que la commande n'existe pas.
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
List<String> resultats = new ArrayList<>();
StringUtil.copyPartialMatches(args[0], KITS, resultats);
Collections.sort(resultats);
return resultats;
}
if (args.length == 2 && sender.hasPermission("minaxkit.other")) {
return null; // repli du serveur : les pseudos des joueurs en ligne
}
return Collections.emptyList();
}
}
copyPartialMatches filtre la liste sur ce que le joueur a déjà tapé, sans tenir compte de la casse. Le détail qui compte vraiment : la différence entre null et liste vide. Renvoyer null active le repli du serveur, qui complète avec les pseudos des joueurs connectés. C'est pratique au deuxième argument, c'est une fuite d'information partout ailleurs. Par défaut, renvoie une liste vide.
Étape 5 : permissions et message de refus
Le point contre-intuitif : quand tu déclares permission: dans plugin.yml, le serveur teste la permission avant d'appeler ton exécuteur. Un hasPermission sur cette même permission à l'intérieur de onCommand est donc du code mort. Il reste indispensable pour les permissions liées aux arguments, comme le minaxkit.other de l'exemple.
Conséquence directe sur le message de refus. Si tu gardes permission: dans plugin.yml, ton message est celui du champ permission-message, en texte simple. Si tu veux un refus riche, coloré proprement avec Adventure ou cliquable, il faut retirer permission: du plugin.yml et tester toi-même.
// Variante : permission NON declaree dans plugin.yml, refus entierement controle
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission("minaxkit.use")) {
sender.sendMessage(Component.text("Les kits sont reserves aux membres.", NamedTextColor.RED));
return true;
}
// ... suite du traitement
return true;
}
Mon arbitrage : je garde permission: dans plugin.yml pour les commandes d'administration, parce que le serveur masque alors la commande dans l'aide et bloque au plus tôt. Je le retire pour les commandes destinées aux joueurs, où le message de refus fait partie de l'expérience. Et je déclare toujours le bloc permissions: avec un default: explicite, sinon un gestionnaire comme LuckPerms ne voit pas ta permission tant que personne ne l'a assignée.
La table de diagnostic
Voilà ce que je n'ai jamais trouvé écrit noir sur blanc, et qui permet d'identifier la panne en dix secondes : croiser ce que voit le joueur avec ce que dit la console.
| Ce que voit le joueur | Ce que dit la console | |
|---|---|---|
| Absente de plugin.yml | Commande inconnue (message vanilla) | Rien du tout |
| Déclarée, aucun exécuteur | La ligne usage: | Rien |
| getCommand null non traité | Commande inconnue | Exception au démarrage, plugin désactivé |
| Permission manquante | Le permission-message | Rien |
| onCommand renvoie false | La ligne usage: | Rien |
| Exception dans onCommand | Message d'erreur interne | Trace complète avec ta classe |
| Nom déjà pris par un plugin | La commande de l'autre plugin | Ligne de conflit au chargement |
Le préfixe qui tranche les conflits
Ce qui ne marche pas, et ce que tu vas casser
Quatre pièges qui touchent spécifiquement les commandes, et dont trois n'ont rien à voir avec le code de la commande elle-même.
- Le message de refus est ce qui casse à la mise à jour. Paper 26.x embarque Adventure 5, qui a supprimé des API auparavant seulement dépréciées, et cela touche en priorité tout ce qui affiche du texte aux joueurs. Ton onCommand écrit pour 1.21 avec des messages en texte legacy peut refuser de compiler tel quel. C'est ironique : la partie la plus triviale de ta commande est la plus exposée.
- Une commande qui lit une base de données bloque le serveur. onCommand tourne sur le thread principal. Un accès base synchrone là-dedans fait chuter le TPS pour tout le monde. Le pilote JDBC SQLite est déjà fourni avec Paper, donc tu n'as rien à embarquer, mais la lecture doit partir en asynchrone et revenir sur le thread du serveur pour toucher au monde.
- Sur Folia, ta commande ne peut pas supposer un thread unique. Folia exécute les régions du monde sur plusieurs threads. Une commande qui téléporte, pose un bloc ou modifie un inventaire doit passer par le RegionScheduler, sinon elle fait crasher le serveur.
- La clé de stockage par joueur est un piège de conception. 75,1 % des serveurs tournent en mode hors ligne, et dans ce mode l'UUID d'un joueur est dérivé de son pseudo. Si ton /kit enregistre un délai d'attente par UUID, un changement de pseudo ou un passage en mode en ligne rend toutes tes données orphelines. Prévois-le dès la première version, pas au moment où un joueur réclame son grade.
Ne recharge pas, redémarre
Mon arbitrage : quand rester sur plugin.yml
Je reste sur la voie décrite ici, un bloc commands: et un exécuteur, jusqu'à trois sous-commandes et deux arguments. En dessous de ce seuil, tout le reste est de la complexité gratuite : une seule classe, aucune dépendance, aucun risque de licence.
Au-delà, quand tu commences à écrire des chaînes de conditions sur args[0] pour distinguer give, list, reload et help, je passe à un cadre de commandes comme Lamp, qui est sous licence permissive et donc redistribuable sans problème. Le déclencheur n'est pas le nombre de joueurs, c'est le nombre de branches : au troisième else if sur args[0], le cadre devient rentable.
Ce que je ne recommande pas : attaquer directement l'arbre de commandes bas niveau du serveur pour obtenir de la coloration d'arguments. Ça marche, ça impressionne, et ça te lie à une couche interne qui bouge d'une version à l'autre. Minecraft est désobfusqué depuis 26.1 et le remapper de Paper a disparu, ce qui simplifie beaucoup de choses, mais une API interne reste une API interne.
Une commande complète, générée et compilée
Décris la commande que tu veux, ses arguments et ses permissions. Minax écrit le plugin.yml, l'exécuteur et la complétion automatique, puis te rend un .jar testé.
Créer ma commandeQuestions fréquentes
Pourquoi ma commande ne répond pas du tout, sans aucune erreur ?
Parce qu'elle n'est pas déclarée dans le bloc commands de plugin.yml. Le serveur ne sait pas que ta commande existe, il répond le message vanilla de commande inconnue, et ton plugin n'est jamais appelé. Aucune ligne n'apparaît dans la console : c'est la panne la plus difficile à diagnostiquer pour un débutant.
Faut-il appeler getCommand().setExecutor() si onCommand est dans la classe principale ?
Techniquement non, l'exécuteur par défaut d'une commande est le plugin propriétaire. Mais je recommande de le faire quand même : si la commande est absente de plugin.yml, getCommand renvoie null et tu obtiens une erreur bruyante au démarrage au lieu d'un silence total.
Que se passe-t-il quand onCommand renvoie false ?
Bukkit affiche au joueur la ligne usage déclarée dans plugin.yml. Ce n'est pas un signal d'erreur technique, c'est le mécanisme d'aide intégré. Renvoie false pour un mauvais usage, true quand tu as déjà envoyé ton propre message.
Comment éviter que la complétion automatique propose la liste des joueurs en ligne ?
Renvoie une liste vide plutôt que null. Quand onTabComplete renvoie null, Bukkit applique son repli et complète avec les pseudos des joueurs connectés, ce qui expose la liste des connectés à n'importe qui.
Où mettre le message affiché quand le joueur n'a pas la permission ?
Deux options. Soit le champ permission-message de plugin.yml, et le serveur bloque avant même d'appeler ton code. Soit tu retires permission de plugin.yml et tu testes hasPermission toi-même, ce qui te rend le contrôle total du message et de son format.
Quelle api-version déclarer en juillet 2026 ?
La famille 1.21 reste majoritaire dans le parc réel : 1.21.11 pèse 36,4 % des serveurs mesurés, contre 14,9 % pour 26.1.2 et 7,4 % pour 26.2. Déclarer une api-version trop récente exclut la majorité des serveurs. Je reste aligné sur la famille 1.21 tant que les versions calendaires n'ont pas pris le tiers du parc.
Pour continuer : la liste des erreurs qui empêchent un plugin de charger, le guide pour générer un plugin Spigot minimaliste, et les références d'API sur docs.papermc.io, hub.spigotmc.org et minecraft.wiki. Les parts de versions citées viennent du relevé public de bStats de juillet 2026.


