DEV Community

Cover image for You're Writing Paper Commands Wrong
Eden
Eden

Posted on • Originally published at eande171.hashnode.dev

You're Writing Paper Commands Wrong

You've probably written a CommandExecutor before. Everyone who's touched Bukkit has.

Declare the command in plugin.yml, implement onCommand, cast args[0] to whatever you need, hope nobody fat-fingers the input. It compiles. It runs. It's confusing to debug. And it's the wrong way to do it in 2026.

# plugin.yml
commands:
  punish:
    description: Opens the punishment GUI
    usage: /punish <player>
Enter fullscreen mode Exit fullscreen mode
public class PunishCommand implements CommandExecutor {
    @Override
    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        if (!(sender instanceof Player staff)) return true;
        if (args.length < 1) return true;

        Player target = Bukkit.getPlayer(args[0]);
        if (target == null) {
            sender.sendMessage("Player not found.");
            return true;
        }
        // ... open the GUI
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Tie it together in onEnable() with getCommand("punish").setExecutor(new PunishCommand()), add a separate TabCompleter implementation to handle suggestions, and you're done.

Seems perfectly fine... totally not confusing at all... (if you understood any of that, you're doing better than I am :P)


This implementation has many issues... like Bukkit.getPlayer(args[0]) only matching an exact, currently-online name. No selectors. No partial matching. You write all of that yourself or not at all.

Tab completion lives in a second method you keep in sync with parsing by hand. Change one, forget the other, and tab completion starts "lying" to your players (a problem that has taken me HOURS to solve in the past... i'm getting flashbacks ;-;).

And the tree itself is static, fixed in plugin.yml. Want /report to take a severity argument only when severities are configured? You can't say that in plugin.yml and you end up with a tangled mess that is almost never clean (either to you, or the players).


Paper ships Mojang's Brigadier (the same framework vanilla Minecraft uses for everything) through a lifecycle hook: LifecycleEvents.COMMANDS. You register a tree of literals and arguments. No commands: block needed.

Here's the registration from ModGUI (a moderation plugin I've been building), it starts inside a LifecycleEvents.COMMANDS handler, registering /punish:

event.registrar().register(
    Commands.literal("punish")
        .requires(ctx -> {
            var s = ctx.getSender();
            return s.hasPermission(Permissions.PUNISH_ALL)
                    || s.hasPermission(Permissions.PUNISH_WARN)
                    || s.hasPermission(Permissions.PUNISH_KICK)
                    || s.hasPermission(Permissions.PUNISH_MUTE)
                    || s.hasPermission(Permissions.PUNISH_TEMPBAN)
                    || s.hasPermission(Permissions.PUNISH_BAN);
        })
        .then(Commands.argument("target", ArgumentTypes.player())
            .executes(cmd::onPunish))
        .build()
);

// investigate and modgui (spyglass/reload/version/help) register the same way
Enter fullscreen mode Exit fullscreen mode

.requires() lives on the tree, not the handler, and Brigadier checks it when building tab completion too, a player without modgui.punish.* never sees /punish suggested.

/report is the interesting one, because it doesn't always have the same shape. Its tree gets built conditionally, based on config, read once before either branch registers:

ReportConfig reportConfig = getConfigService().getReportConfig();

if (reportConfig.isSeverityEnabled()) {
    event.registrar().register(
        Commands.literal("report")
            .requires(ctx -> ctx.getSender().hasPermission(Permissions.REPORT))
            .then(Commands.argument("target", ArgumentTypes.player())
                .then(Commands.argument("severity", StringArgumentType.word())
                    .suggests((ctx, builder) -> {
                        for (String key : reportConfig.getSeverities().keySet()) {
                            builder.suggest(key);
                        }
                        return builder.buildFuture();
                    })
                    .then(Commands.argument("reason", StringArgumentType.greedyString())
                        .executes(cmd::onReport))))
            .build()
    );
}
Enter fullscreen mode Exit fullscreen mode

That .suggests() block on severity is doing the same thing TabCompleter did... just better...

And if severities aren't enabled, the branch registered is a different, simpler tree:

else {
    event.registrar().register(
        Commands.literal("report")
            .requires(ctx -> ctx.getSender().hasPermission(Permissions.REPORT))
            .then(Commands.argument("target", ArgumentTypes.player())
                .then(Commands.argument("reason", StringArgumentType.greedyString())
                    .executes(cmd::onReport)))
            .build()
    );
}
Enter fullscreen mode Exit fullscreen mode

Whether severity is part of the grammar gets decided once, at registration, by reading config. Tab completion and error messages stay accurate and are much easier to debug (yay!).

ArgumentTypes.player() Isn't What It Looks Like

ArgumentTypes.player() doesn't actually give you a Player (you're probably thinking 'but wait... that's counterintuitive' but just wait...), it gives you something you have to turn into one:

private Player resolvePlayer(CommandContext<CommandSourceStack> ctx) throws CommandSyntaxException {
    PlayerSelectorArgumentResolver resolver = ctx.getArgument("target", PlayerSelectorArgumentResolver.class);

    List<Player> players = resolver.resolve(ctx.getSource());
    return players.isEmpty() ? null : players.getFirst();
}
Enter fullscreen mode Exit fullscreen mode

It looks like a bunch of jargon but TLDR; you can now accept Steve, @a, @p without implementing it yourself.

And don't forget the empty case, as a selector can match nobody:

public int onPunish(CommandContext<CommandSourceStack> ctx) throws CommandSyntaxException {
    CommandSender sender = ctx.getSource().getSender();
    Player staff = requirePlayer(ctx);

    if (staff == null) return Command.SINGLE_SUCCESS;
    Player target = resolvePlayer(ctx);

    if (target == null) {
        sender.sendMessage(plugin.getMessageService().noTarget());
        return Command.SINGLE_SUCCESS;
    }

    if (target.hasPermission(Permissions.EXEMPT)) {
        sender.sendMessage(plugin.getMessageService().exemptPunish());
        return Command.SINGLE_SUCCESS;
    }

    PunishGui.open(plugin, staff, target);
    return Command.SINGLE_SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

Actual functionality here is handled by PunishGui, keeping everything nice and tidy :D


plugin.yml commands aren't going anywhere, Bukkit's command map still works fine (ig...). But it was always a workaround, not a first choice.

If any of you made it to this bit... THANK YOU!!! If you wanna poke around the plugin I used as an example in this blog, you can find that here.

Top comments (0)