Skip to content

· Tutorial

How to create a custom command for your Minecraft server

Minecraft server console showing a custom command and its plugin.yml declaration

A command is almost everyone's first plugin. You want to type /kit pvp and have something happen. The Java that does the work is thirty lines. The Java is never the problem: the problem is the chain around it, and above all a six-line file that everyone forgets the first time.

This guide walks the chain in the exact order the server walks it: declaration, executor, arguments, tab completion, permissions, denial message. And it ends with the diagnostic table I wish somebody had handed me on day one, mapping what the player sees against what the console says for every way of getting it wrong.

The silent failure, explained once and for all

Here is what happens when a command is not declared in plugin.yml. The plugin loads normally. It shows up green in /plugins. The console says nothing at all. The player types the command and the server answers with its vanilla unknown-command message. Your code was never reached, and nothing anywhere tells you so.

That is what sets this apart from every other plugin failure. A wrong Java version throws UnsupportedClassVersionError at you. A missing dependency throws NoClassDefFoundError. A command that was never declared throws nothing. So the beginner concludes the Java is broken and rewrites it three times, when the Java was correct all along.

The habit that saves you two hours

Before touching any Java, type /help followed by your command name. If the server does not know it, the problem is in plugin.yml, full stop. No amount of Java can fix a command the server never registered.

The full chain, in server order

When a player sends a command, the server always follows the same steps. Knowing them in order means knowing which link broke.

  1. Registration. At plugin load, the server reads the commands: block of plugin.yml and creates one command per entry. Nothing here means nothing exists.
  2. Resolution. The server looks up the typed name, or one of its aliases.
  3. Permission test. If the permission: field is declared and the sender lacks it, the server prints the denial and stops. Your code is not called.
  4. Executor call. Your onCommand receives the sender, the command, the label that was actually typed, and the argument array.
  5. Return value. true means you handled it, false makes the server print the usage: line.

Tab completion runs on a parallel path: it fires on every keystroke, before anything is sent, through a different method. API references live on docs.papermc.io and the javadoc on jd.papermc.io.

Step 1: declare the command in plugin.yml

Here is a complete plugin.yml for a /kit command with an alias, a usage line, a permission and a denial message.

plugin.yml
name: MinaxKit
version: 1.0.0
main: fun.minax.kit.MinaxKit
api-version: '1.21'
description: Hands out kits to players.

commands:
  kit:
    description: Give a kit to a player.
    usage: /kit <starter|pvp|builder> [player]
    aliases: [k]
    permission: minaxkit.use
    permission-message: You do not have access to kits.

permissions:
  minaxkit.use:
    description: Allows using /kit on yourself.
    default: true
  minaxkit.other:
    description: Allows /kit <name> <other player>.
    default: op

Three details break everything and are invisible to the naked eye. The command name is a YAML key, so indentation matters: two spaces under commands:, never a tab. The file must sit at the root of src/main/resources, not next to your class. And api-version has to be a quoted string, otherwise YAML reads 1.21 as a decimal number.

The number that decides your api-version

The July 2026 bStats sample of 62,948 servers puts 1.21.11 at 36.4 %, 26.1.2 at 14.9 %, 26.2 at 7.4 % and 1.21.4 at 7.2 %. The 1.21 family is still comfortably the majority. An api-version that is too recent gets your plugin rejected by more than one server in three. I declare the 1.21 family and compile against Java 21: a Java 21 .jar loads on a newer runtime, and the reverse is not true.

Step 2: the executor, and the getCommand trap

The default executor of a command is the plugin itself. In other words, if you write onCommand straight into your main class, it works without registering anything. That is convenient, and it is exactly what makes the silent failure so nasty: with no call to getCommand, nothing ever verifies that the command exists on the server side.

My take, and it is a firm one: always register explicitly, even from the main class, and handle the null. You are trading a mute failure for a loud one, which is an enormous upgrade.

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("Command 'kit' is missing from plugin.yml, shutting the plugin down.");
            getServer().getPluginManager().disablePlugin(this);
            return;
        }

        command.setExecutor(handler);
        command.setTabCompleter(handler);
        getLogger().info("Command /kit registered.");
    }
}

That last getLogger().info is not decoration. It is your visual proof at startup: if you do not see the line in the console, the problem sits upstream of the Java.

Step 3: arguments, an array with no guarantees

The argument array holds whatever the player typed after the command name, split on spaces. It can be empty. It can hold anything. Nothing is validated for you, and reading args[0] on an empty array throws an exception the player will see as a generic internal error.

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; // the server prints the usage: line from plugin.yml
        }

        String kit = args[0].toLowerCase(Locale.ROOT);
        if (!KITS.contains(kit)) {
            sender.sendMessage(Component.text("Unknown kit: " + args[0], NamedTextColor.RED));
            return true;
        }

        Player target;
        if (args.length >= 2) {
            if (!sender.hasPermission("minaxkit.other")) {
                sender.sendMessage(Component.text("You can only give a kit to yourself.", NamedTextColor.RED));
                return true;
            }
            target = Bukkit.getPlayerExact(args[1]);
            if (target == null) {
                sender.sendMessage(Component.text(args[1] + " is not online.", NamedTextColor.RED));
                return true;
            }
        } else if (sender instanceof Player self) {
            target = self;
        } else {
            sender.sendMessage(Component.text("From the console: /kit <name> <player>", NamedTextColor.RED));
            return true;
        }

        target.sendMessage(Component.text("Kit " + kit + " delivered.", NamedTextColor.GREEN));
        return true;
    }

    // The class continues in step 4: onTabComplete is required by TabCompleter.

Two things worth noticing. The early return false is not an admission of failure, it is the built-in help mechanism printing your usage: line. And the sender is not necessarily a player: the console can send the command, so can a command block. The instanceof Player check is not optional, and without it your command crashes the first time an admin runs it from the console.

Step 4: tab completion

This is the part everybody skips, and it is the visible difference between a hobby plugin and one people enjoy using. A player who presses Tab and sees nothing appear concludes the command does not exist.

Java
    @Override
    public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
        if (args.length == 1) {
            List<String> matches = new ArrayList<>();
            StringUtil.copyPartialMatches(args[0], KITS, matches);
            Collections.sort(matches);
            return matches;
        }

        if (args.length == 2 && sender.hasPermission("minaxkit.other")) {
            return null; // server fallback: names of online players
        }

        return Collections.emptyList();
    }
}

copyPartialMatches filters your list against what the player has already typed, case insensitively. The detail that actually matters is the difference between null and an empty list. Returning null enables the server fallback, which completes with the names of online players. Handy on the second argument, an information leak everywhere else. Default to an empty list and opt into null deliberately.

Step 5: permissions and the denial message

The counter-intuitive part: when you declare permission: in plugin.yml, the server tests it before calling your executor. A hasPermission check for that same node inside onCommand is therefore dead code. It stays essential for argument-level permissions, like the minaxkit.other node above.

That has a direct consequence for the denial message. Keep permission: in plugin.yml and your message is whatever sits in permission-message, as plain text. Want a rich denial, properly coloured through Adventure or clickable? Then drop permission: from plugin.yml and run the check yourself.

Java
    // Variant: permission NOT declared in plugin.yml, denial fully under your control
    @Override
    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        if (!sender.hasPermission("minaxkit.use")) {
            sender.sendMessage(Component.text("Kits are for members only.", NamedTextColor.RED));
            return true;
        }
        // ... rest of the handling
        return true;
    }

My call: I keep permission: in plugin.yml for admin commands, because the server then hides the command from help output and blocks as early as possible. I drop it for player-facing commands, where the denial message is part of the experience. And I always declare the permissions: block with an explicit default:, otherwise a manager such as LuckPerms will not surface your node until somebody assigns it by hand.

The diagnostic table

This is the thing I have never seen written down plainly, and it identifies the failure in ten seconds: cross what the player sees with what the console says.

What the player sees What the console says
Missing from plugin.yml Unknown command (vanilla message) Absolutely nothing
Declared, no executor set The usage: line Nothing
getCommand null unhandled Unknown command Startup exception, plugin disabled
Permission missing The permission-message Nothing
onCommand returns false The usage: line Nothing
Exception inside onCommand A generic internal error Full stack trace naming your class
Name already taken The other plugin's command A conflict line at load time

The prefix that settles conflicts

When two plugins declare the same command name, the server serves one of them and exposes the other as pluginname:command. Type /minaxkit:kit: if that works while /kit does something else entirely, you have a name conflict, not a bug.

What does not work, and what you will break

Four traps that hit commands specifically, three of which have nothing to do with the command code itself.

  • The denial message is what breaks on upgrade. Paper 26.x ships Adventure 5, which removed APIs that were previously only deprecated, and the removals land hardest on anything that puts text in front of a player. An onCommand written for 1.21 with legacy text messages may simply refuse to compile. It is ironic: the most trivial part of your command is the most exposed part.
  • A command that reads a database freezes the server. onCommand runs on the main thread. A synchronous database read in there drags the TPS down for everyone. The SQLite JDBC driver already ships with Paper, so there is nothing to bundle, but the read has to go async and come back to the server thread before it touches the world.
  • On Folia, your command cannot assume a single thread. Folia runs world regions across several threads. A command that teleports, places a block or edits an inventory has to go through the RegionScheduler, or it crashes the server.
  • Your per-player storage key is a design trap. 75.1 % of servers run in offline mode, and in offline mode a player's UUID is derived from their name. If your /kit stores a cooldown keyed by UUID, a name change or a future switch to online mode orphans every row you wrote. Plan for it in version one, not the evening a player asks where his rank went.

Do not reload, restart

The server reload command leaves orphaned tasks and listeners behind, and your new plugin.yml is not always re-read cleanly. A command that appears after a reload and vanishes on the next restart is that mechanism at work. Stop and restart the server whenever you are testing a declaration.

My verdict: when to stay on plugin.yml

I stay on the path described here, a commands: block plus an executor, up to three subcommands and two arguments. Below that threshold everything else is needless complexity: one class, zero dependencies, zero licensing risk.

Above it, once you are writing condition chains on args[0] to tell give from list from reload from help, I move to a command framework such as Lamp, which carries a permissive licence and is therefore safe to redistribute. The trigger is not the player count, it is the branch count: at the third else if on args[0], the framework starts paying for itself.

What I do not recommend is reaching straight into the server's low-level command tree to get coloured argument hints. It works, it looks impressive, and it welds you to an internal layer that moves between versions. Minecraft has been deobfuscated since 26.1 and Paper's remapper is gone, which simplifies a lot, but an internal API is still an internal API.

A complete command, generated and compiled

Describe the command you want, its arguments and its permissions. Minax writes the plugin.yml, the executor and the tab completion, then hands you a tested .jar.

Create my command

Frequently asked questions

Why does my command do nothing at all, with no error anywhere?

Because it is not declared in the commands block of plugin.yml. The server has no idea your command exists, it replies with the vanilla unknown-command message, and your plugin is never called. Nothing appears in the console: this is the hardest plugin failure for a beginner to diagnose, precisely because there is nothing to read.

Do I need getCommand().setExecutor() if onCommand lives in my main class?

Technically no. The default executor of a command is the owning plugin, so overriding onCommand in the main class already works. I still recommend registering explicitly: if the command is missing from plugin.yml, getCommand returns null and you get a loud startup failure instead of total silence.

What happens when onCommand returns false?

The server shows the player the usage line declared in plugin.yml. It is not an error signal, it is the built-in help mechanism. Return false for wrong usage, and true once you have sent your own message.

How do I stop tab completion from listing every online player?

Return an empty list instead of null. When onTabComplete returns null, the server applies its fallback and completes with the names of online players, which hands the connected-player list to anyone who presses Tab.

Where should the no-permission message live?

Two options. Either the permission-message field in plugin.yml, in which case the server blocks the command before your code runs. Or you drop the permission field from plugin.yml and test hasPermission yourself, which gives you full control of the wording and the formatting.

Which api-version should I declare in July 2026?

The 1.21 family is still the majority of the real server population: 1.21.11 accounts for 36.4 % of measured servers, against 14.9 % for 26.1.2 and 7.4 % for 26.2. Declaring an api-version that is too recent locks out most servers. I stay on the 1.21 family until the calendar versions take a third of the population.

Keep going: the list of errors that stop a plugin from loading, the guide to generating a minimal Spigot plugin, and the API references on docs.papermc.io, hub.spigotmc.org and minecraft.wiki. The version shares quoted above come from the public bStats sample of July 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