Aller au contenu

· Tutoriel

Créer une commande personnalisée pour son serveur Minecraft

Console d'un serveur Minecraft affichant une commande personnalisée et son plugin.yml

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

Avant de toucher au Java, tape /help suivi du nom de ta commande. Si le serveur ne la connaît pas, le problème est dans plugin.yml, point. Aucune ligne de Java ne peut réparer une commande que le serveur ignore.

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.

  1. 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.
  2. Résolution : le serveur cherche le nom tapé, ou l'un de ses alias.
  3. 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é.
  4. Appel de l'exécuteur : ta méthode onCommand reçoit l'expéditeur, la commande, le label utilisé et le tableau d'arguments.
  5. Valeur de retour : true signifie que tu as traité la demande, false déclenche l'affichage de la ligne usage:.

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.

plugin.yml
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

Le relevé bStats de juillet 2026 sur 62 948 serveurs donne 1.21.11 à 36,4 %, 26.1.2 à 14,9 %, 26.2 à 7,4 % et 1.21.4 à 7,2 %. La famille 1.21 reste largement majoritaire. Une api-version trop récente fait refuser ton plugin par plus d'un serveur sur trois. Je déclare la famille 1.21 et je compile en Java 21 : un .jar Java 21 se charge sur un runtime plus récent, l'inverse est faux.

É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.

Java
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.

Java
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.

Java
    @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.

Java
    // 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

Quand deux plugins déclarent le même nom de commande, le serveur en sert un et rend l'autre accessible sous la forme nomduplugin:commande. Tape /minaxkit:kit : si celle-là fonctionne alors que /kit fait autre chose, tu as un conflit de nom, pas un bug.

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

La commande de rechargement du serveur laisse derrière elle des tâches et des écouteurs orphelins, et ton nouveau plugin.yml n'est pas toujours relu proprement. Une commande qui apparaît après un rechargement et disparaît au redémarrage suivant, c'est ce mécanisme. Arrête et redémarre le serveur pour tester une déclaration.

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 commande

Questions 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.

Articles connexes

Nous utilisons Google Analytics, Microsoft Clarity et Reddit Pixel pour analyser l'utilisation du site et améliorer votre expérience. En savoir plus