Paper Plugin Guide
File mode0

Projects

Project: Chat formatter and filter

Custom chat format per rank, a word filter, mutes and a staff chat channel.

IntermediateProject, about 75 min read

In this project you build a chat plugin like the ones real servers run: every rank gets its own chat format, a word filter cleans up messages, moderators can mute players for a set time, and staff get a private channel. Along the way you learn the one thing that makes chat different from every other event: it does not run on the main thread.

What you will build

Out of the box, Minecraft chat looks like <Steve> hello. Server owners want more: a rank badge in front of the name, no bad language, a way to silence someone who will not stop spamming, and a place where staff can talk without players reading along. The plugin is called ChatTools, and it does four jobs.

1. A format for every rank. Which format a player gets depends on a permission they have. The formats live in config.yml, so the server owner can change colors and badges without touching Java:

Steve > Does anyone have spare iron?
[VIP] Mia > I can trade some for diamonds.
OWNER Alex > Remember: no griefing near spawn.

2. A word filter. The server owner lists the words they do not want. In replace mode the word turns into stars, in block mode the whole message is stopped and the player is told why:

Steve > Oh ****, I fell into lava again
Your message was not sent because it contains a word that is not allowed.

3. Timed mutes. A moderator types /mute Steve 10m. For ten minutes, everything Steve types is blocked and he sees how long is left. The mute survives a server restart because it is saved on the player:

Muted Steve for 10m.
You are muted for another 9m 41s.

4. Staff chat. /sc switches your chat to a staff-only channel, and /sc some text sends one message there. Only players with the staff permission can read it:

[Staff] Alex > Steve keeps asking for free diamonds, please keep an eye on him
The journey of one chat message A chat message passes four checks in the ChatListener: staff chat, mute, word filter and rank format. Staff chat limits who sees the message, a mute cancels it, the filter replaces words or cancels, and the last step picks the format. Player sends a message 1. Staff chat switched on? StaffChat.isToggledOn yes Only staff stay viewers staff format, then done no 2. Is the player muted? MuteManager.remainingMillis yes Cancel the event tell the player the time left no 3. Bad word in the text? WordFilter.check yes Replace it with **** or cancel, in block mode no 4. Pick the rank format and set the renderer Paper builds one line and shows it to every viewer
One chat message passes four checks, in this order. The first three can end its journey early; the last one decides how the message looks.

The plan

Every row below is one feature, the piece of the Paper API that does it, and the chapter that explains that piece in depth. You can read the chapters first or whenever you get stuck.

FeatureThe piece that does itChapter
React to a chat messageAsyncChatEvent in a listenerEvents and listeners
Change how a message looksChatRenderer and MiniMessage placeholdersMiniMessage
A format per rankplayer.hasPermission(...) and a list read from the configPermissions, Configuration files
Word filterA regular expression built from the config's word listWorking with text
Mute with a durationA Map in memory plus a value saved in the PersistentDataContainerSaving data on things, Lists, sets and maps
The /mute, /unmute and /sc commandsBasicCommandSimple commands
Staff-only messagesTrimming the event's viewers() setThis page
Doing all of it safely off the main threadThread-safe collections and read-only settingsThreads, performance and lag

The build has eight small steps. Each one gives you something to run and test, so when something breaks you know which step caused it.

  1. Create the project

    A plugin.yml with the commands' permissions.

  2. Take over the chat format

    The smallest useful chat plugin: one fixed format.

  3. Understand the chat thread

    What you may and may not do inside the handler.

  4. Move formats into config.yml

    One format per rank, chosen by permission.

  5. Add the word filter

    Replace or block, from a list in the config.

  6. Add timed mutes

    A duration parser, a mute manager and two commands.

  7. Add staff chat

    A toggle, a command and a trimmed audience.

  8. Wire it together and test

    The main class, a test checklist and the usual mistakes.

The finished plugin is in the download. Try to type the code yourself first, because typing is how it sticks.

Step 1: create the project

Start from a plugin project that builds and runs on a test server (see Your first plugin and Anatomy of a plugin project). This project uses the package com.example.projectchat. If you pick another package, the main: line in plugin.yml must always match your main class exactly.

The plugin.yml declares the plugin and every permission it uses. Declaring permissions here gives each one a default: who has it when nobody has configured anything. Here default: op means "server operators have it", and default: false means "nobody has it until a permissions plugin such as LuckPerms hands it out".

plugin.ymlCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainresourcesplugin.yml

Files in src/main/resources are copied into the jar exactly as they are. Paper looks for plugin.yml at the top of the jar.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlyou are hereTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1name: ChatTools2version: '1.0.0'3main: com.example.projectchat.ChatToolsPlugin4api-version: '26.3'5description: Chat formats by rank, a word filter, timed mutes and a staff chat channel.6permissions:7  projectchat.mute:8    description: Lets a player use /mute and /unmute.9    default: op10  projectchat.staff:11    description: Lets a player use staff chat and read it.12    default: op13  projectchat.bypass.filter:14    description: Chat messages from this player skip the word filter.15    default: op16  projectchat.rank.owner:17    description: Shows the owner chat format.18    default: op19  projectchat.rank.staff:20    description: Shows the staff chat format.21    default: false22  projectchat.rank.vip:23    description: Shows the VIP chat format.24    default: false
  1. The full name of the main class. It does not exist yet; you write it in step 8.
  2. Who may use /mute and /unmute.
  3. Who may use staff chat and who can read it.
  4. Players with this permission are never filtered, handy for trusted staff.
  5. A permission that exists only to pick a chat format. Operators get it by default, so you can test the owner format with /op.
  6. default: false means nobody has this until you give it to them with a permissions plugin.

The commands are not in plugin.yml. This plugin registers them in code with BasicCommand, the way Commands, part 1 shows. Each command class names its permission itself.

Step 2: take over the chat format

Before the clever parts, let us make the smallest chat plugin possible: it changes every message to Steve > hello in gray and white. This is the whole plugin for step 2:

ChatFormatListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chat-step1srcmainjavacomexampleprojectchatstep1ChatFormatListener.java

The package com.example.projectchatstep1 is the folder path com/example/projectchatstep1 inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat-step1/
    • src/main/
      • java/com/example/projectchatstep1/Package com.example.projectchatstep1
        • ChatFormatListener.javayou are hereListener: reacts to events
        • ChatToolsStep1Plugin.javaMain class: Paper starts here (named as main in plugin.yml)
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchatstep1;2 3import io.papermc.paper.chat.ChatRenderer;4import io.papermc.paper.event.player.AsyncChatEvent;5import net.kyori.adventure.text.minimessage.MiniMessage;6import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;7import org.bukkit.event.EventHandler;8import org.bukkit.event.Listener;9 10public final class ChatFormatListener implements Listener {11 12    private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();13 14    @EventHandler15    public void onChat(AsyncChatEvent event) {16        event.renderer(ChatRenderer.viewerUnaware((source, sourceName, message) ->17            MINI_MESSAGE.deserialize("<gray><player> <dark_gray>> <white><message>",18                Placeholder.component("player", sourceName),19                Placeholder.component("message", message))));20    }21}
  1. Paper's event for a chat message. "Async" means it does not run on the main game thread; step 3 explains what that changes.
  2. One shared MiniMessage object that turns text with tags such as <gray> into a formatted Component.
  3. A renderer is the piece that builds the final chat line. Giving the event a new one replaces Minecraft's <Steve> hello look.
  4. "Viewer unaware" means the renderer builds one line and every viewer sees the same one. It is the simplest kind, and it is faster because the line is built once, not once per viewer.
  5. A lambda: a tiny method written inline. Paper calls it with the player who chatted (source), that player's display name, and the message. It must return the finished line.
  6. Turns the format text into a component. <player> and <message> in the text are placeholders that the next lines fill in.
  7. Inserts the display name as a component, so it keeps its own color or hover text if a nickname plugin gave it some.
  8. Inserts the typed message as a finished component. It is not parsed as MiniMessage, so a player typing <rainbow> just sees those characters. That is exactly what you want from untrusted text.

The main class only has to register the listener:

ChatToolsStep1Plugin.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chat-step1srcmainjavacomexampleprojectchatstep1ChatToolsStep1Plugin.java

The package com.example.projectchatstep1 is the folder path com/example/projectchatstep1 inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat-step1/
    • src/main/
      • java/com/example/projectchatstep1/Package com.example.projectchatstep1
        • ChatFormatListener.javaListener: reacts to events
        • ChatToolsStep1Plugin.javayou are hereMain class: Paper starts here (named as main in plugin.yml)
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchatstep1;2 3import org.bukkit.plugin.java.JavaPlugin;4 5public final class ChatToolsStep1Plugin extends JavaPlugin {6 7    @Override8    public void onEnable() {9        getServer().getPluginManager().registerEvents(new ChatFormatListener(), this);10    }11}

Build it, start the test server and send a message. You see the new format, and so does the server console:

Steve > hello everyone

Step 3: the chat thread

Every other event in this guide runs on the main thread, the single thread that runs the game and ticks 20 times a second. Chat is different. When a player presses Enter, the message arrives on the network, and Paper does not make the whole game wait for your plugins to think about it. It fires AsyncChatEvent on another thread, a thread being a separate line of work that runs at the same time as the game.

What a chat handler may and may not do AsyncChatEvent runs on a chat thread, not the main game thread. Reading your own thread-safe data, changing the message and sending text are fine. Changing the world, inventories or player state, or reading a plain HashMap that the main thread writes, is not safe. Main thread (the game, 20 ticks per second) Commands, join and quit events, blocks, inventories, teleports Chat thread (where AsyncChatEvent runs) Safe here Read the event and change it event.message(...) event.renderer(...) Read thread-safe data of your own ConcurrentHashMap Check permissions, send a message player.hasPermission(...) Not safe here Change the game world block.setType(...) Change a player player.teleport(...) Share a plain HashMap main thread writes, chat thread reads Fix: hop back with the scheduler
Your chat handler runs on a chat thread. Reading and rewriting the message is fine; touching the game world is not.

This matters for three reasons, and the whole plugin is shaped by them:

  • Do not change the game from the handler. No block.setType(...), no teleports, no changing a player's inventory or health. Those calls only belong on the main thread. If you need one, schedule it with getServer().getScheduler().runTask(plugin, task -> ...), as the scheduler chapter shows.
  • Shared data must be thread-safe. Our mute list is written by the /mute command on the main thread and read by the chat handler on the chat thread. A plain HashMap may break when two threads use it at once. A ConcurrentHashMap is built for that, so the plugin uses one. A thing that is thread-safe can be used from several threads at the same time without breaking.
  • Settings must not change while chat reads them. We load the settings once into a record that never changes. Reading something that never changes is always safe.

You may read the event, change its message, renderer and viewers, check permissions and send messages to players. The plugin does exactly those things and nothing else.

Why does Paper bother? A busy server can have hundreds of chat messages a minute, and a plugin that looks something up in a database for each one would freeze the game if chat ran on the main thread. Running it off to the side keeps the ticks smooth. Threads, performance and lag goes much deeper.

Step 4: formats per rank, from the config

Typing the format into Java means every change needs a rebuild. We move all texts into config.yml. Here is the part that holds the formats:

config.yml (formats and ranks)
1formats:2  default: "<gray><player> <dark_gray>> <white><message>"3 4ranks:5  owner:6    permission: projectchat.rank.owner7    format: "<dark_red><bold>OWNER</bold> <red><player> <dark_gray>> <white><message>"8  staff:9    permission: projectchat.rank.staff10    format: "<blue><bold>STAFF</bold> <aqua><player> <dark_gray>> <white><message>"11  vip:12    permission: projectchat.rank.vip13    format: "<gold>[VIP] <yellow><player> <dark_gray>> <white><message>"
  1. The fallback format, used by everyone who has none of the rank permissions below.
  2. Order matters. A player with both the owner and the VIP permission gets the first match, owner, because we look from the top down.
  3. The permission that selects this format. Hand it to a group in LuckPerms and everyone in the group gets the format.
  4. <player> and <message> are placeholders. The code replaces them with the real name and text.

The full file, with every setting of the project, is in the example project (src/main/resources/config.yml). You will see the filter, staff chat and message parts as we reach them.

To read the file, we use the same pattern as in the Welcome kit project: read everything once at startup into small records, and pass those around. A record is a class that only holds values, and Java writes the boring parts for you.

RankFormat.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatRankFormat.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javayou are hereRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchat;2 3public record RankFormat(String id, String permission, String format) {4}
ChatSettings.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatChatSettings.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javayou are hereRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

21public static ChatSettings from(FileConfiguration config, Logger logger) {22    String defaultFormat = config.getString("formats.default",23        "<gray><player> <dark_gray>> <white><message>");24 25    List<RankFormat> ranks = new ArrayList<>();26    ConfigurationSection rankSection = config.getConfigurationSection("ranks");27    if (rankSection != null) {28        for (String id : rankSection.getKeys(false)) {29            String permission = rankSection.getString(id + ".permission");30            String format = rankSection.getString(id + ".format");31            if (permission == null || format == null) {32                logger.warning("Rank " + id + " needs both a permission and a format. Skipping it.");33                continue;34            }35            ranks.add(new RankFormat(id, permission, format));36        }37    }38 39    String staffFormat = config.getString("staff-chat.format",40        "<dark_aqua>[Staff] <aqua><player> <dark_gray>> <green><message>");41 42    FilterMode mode = FilterMode.REPLACE;43    String modeText = config.getString("filter.mode", "replace");44    try {45        mode = FilterMode.valueOf(modeText.toUpperCase(Locale.ROOT));46    } catch (IllegalArgumentException exception) {47        logger.warning("filter.mode must be replace or block, not " + modeText + ". Using replace.");48    }49 50    WordFilter filter = new WordFilter(51        config.getStringList("filter.words"),52        config.getString("filter.replacement", "*"));53 54    ChatMessages messages = new ChatMessages(55        config.getString("messages.muted", "<red>You are muted for another <yellow><time></yellow>."),56        config.getString("messages.blocked", "<red>Your message was not sent."),57        config.getString("messages.staff-chat-on", "<green>Staff chat is on."),58        config.getString("messages.staff-chat-off", "<yellow>Staff chat is off."));59 60    return new ChatSettings(defaultFormat, List.copyOf(ranks), staffFormat,61        config.getBoolean("filter.enabled", true), mode, filter, messages);62}
  1. A path such as formats.default means "the key default inside the section formats". The second argument is the fallback if the key is missing.
  2. The ranks: section as an object, or null if the owner deleted it, which is why the next line checks for null.
  3. The names directly under ranks: owner, staff, vip. false means "only the first level, not nested keys". They come back in the order written in the file.
  4. If a rank is half-filled-in, we tell the owner in the console and skip it instead of crashing.
  5. Turns the text REPLACE or BLOCK into the matching enum value. It throws IllegalArgumentException for anything else, so we catch that and fall back to REPLACE.
  6. Makes the list unchangeable, so it is safe to read from the chat thread.

Choosing the format for one player is a loop with an early exit:

ChatSettings.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatChatSettings.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javayou are hereRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

64public String formatFor(Player player) {65    for (RankFormat rank : ranks) {66        if (player.hasPermission(rank.permission())) {67            return rank.format();68        }69    }70    return defaultFormat;71}
  1. Walks the ranks from the top of the file down.
  2. True when the player has the permission, either from plugin.yml's default or from a permissions plugin. The first match wins and returns right away.
  3. No rank matched, so this player is a normal player.

Now the listener can use it. First look at the top of the real ChatListener, which step 2 did not have. It receives the three shared objects it needs and declares the handler:

ChatListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatChatListener.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javayou are hereListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

14public final class ChatListener implements Listener {15 16    private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();17 18    private final ChatSettings settings;19    private final MuteManager mutes;20    private final StaffChat staffChat;21 22    public ChatListener(ChatSettings settings, MuteManager mutes, StaffChat staffChat) {23        this.settings = settings;24        this.mutes = mutes;25        this.staffChat = staffChat;26    }27 28    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)29    public void onChat(AsyncChatEvent event) {30        Player player = event.getPlayer();
  1. The listener keeps what it needs in fields. They are filled in by the constructor below, so the main class decides which objects are shared.
  2. Handlers run from the lowest priority to the highest, so the one that runs last has the final say. HIGH lets this plugin set the format after most other chat plugins have touched the message.
  3. If another plugin already canceled the message, skip it. Without this, we would format and filter a message that nobody will see.
  4. The player who typed the message.

Inside the handler, instead of the fixed text from step 2, the last lines ask the settings:

ChatListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatChatListener.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javayou are hereListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

61String format = settings.formatFor(player);62event.renderer(ChatRenderer.viewerUnaware((source, sourceName, message) ->63    MINI_MESSAGE.deserialize(format,64        Placeholder.component("player", sourceName),65        Placeholder.component("message", message))));
  1. Picked before the renderer is built, outside the lambda, because the lambda is called later and should not have to look anything up.
  2. The message here is the one the filter may already have changed in step 5.

To test it, join the test server. You are an operator, so you have projectchat.rank.owner and see the red OWNER badge. Type /deop YourName in the server console and chat again: now you get the gray default format. With LuckPerms you could give a friend projectchat.rank.vip and see the gold one.

Step 5: the word filter

The filter has to find the listed words anywhere in a message, in any capitalization, but only as whole words. "heck" must be caught, but "heckle" must not turn into "****le". For that job there is a regular expression (regex for short): a short pattern that describes text to look for. You do not need to master regex. Here are the only pieces we use:

PieceMeaning
\bA word boundary: the spot between a letter and a space or punctuation. It makes a match start and end at whole words.
word1|word2"word1 or word2".
(?: ... )A group that is only there to hold the "or" together.
Pattern.quote(word)A Java helper that makes sure the characters of a word are matched literally, even if the owner writes something like a+b in the config.

Try the idea on its own first. This small Java program is not a plugin; it builds the same pattern for two words and runs it over four messages. Press the green arrow, then think about which messages changed and which did not:

FilterDemo.javaRuns on Java 25
1import java.util.List;2import java.util.regex.Matcher;3import java.util.regex.Pattern;4import java.util.stream.Collectors;5 6void main() {7    List<String> words = List.of("darn", "heck");8    String alternatives = words.stream()9        .map(Pattern::quote)10        .collect(Collectors.joining("|"));11    Pattern pattern = Pattern.compile("\\b(?:" + alternatives + ")\\b", Pattern.CASE_INSENSITIVE);12 13    List<String> messages = List.of(14        "Oh darn, I fell again",15        "What the HECK was that",16        "Let us not heckle the builder",17        "A darned good build");18 19    for (String message : messages) {20        Matcher matcher = pattern.matcher(message);21        String cleaned = matcher.replaceAll(match -> "*".repeat(match.group().length()));22        IO.println(message + "  ->  " + cleaned);23    }24}
  1. A method reference: shorthand for "call Pattern.quote on every word".
  2. Glues the quoted words together with | between them, which means "or" in a pattern.
  3. So HECK, Heck and heck all match.
  4. Replaces every match. The small function after it receives each match and returns the replacement: one star for each letter.

"darn" and "HECK" become stars. "heckle" and "darned" stay as they are because the boundaries do not line up: they are different words. The word filter class in the plugin is this idea packed into a reusable class:

WordFilter.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatWordFilter.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javayou are hereHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchat;2 3import java.util.List;4import java.util.regex.Matcher;5import java.util.regex.Pattern;6import java.util.stream.Collectors;7 8public final class WordFilter {9 10    private final Pattern pattern;11    private final String replacement;12 13    public WordFilter(List<String> words, String replacement) {14        this.replacement = replacement;15        if (words.isEmpty()) {16            this.pattern = null;17            return;18        }19        String alternatives = words.stream()20            .map(Pattern::quote)21            .collect(Collectors.joining("|"));22        this.pattern = Pattern.compile(23            "\\b(?:" + alternatives + ")\\b",24            Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);25    }26 27    public FilterResult check(String text) {28        if (pattern == null) {29            return new FilterResult(false, text);30        }31        Matcher matcher = pattern.matcher(text);32        if (!matcher.find()) {33            return new FilterResult(false, text);34        }35        String cleaned = matcher.replaceAll(match ->36            Matcher.quoteReplacement(replacement.repeat(match.group().length())));37        return new FilterResult(true, cleaned);38    }39}
  1. The compiled pattern is built once, in the constructor, and reused for every message. Building a pattern is slow and matching is fast, so you never want to rebuild it per message.
  2. With no words there is nothing to find. A pattern built from an empty list would match everywhere, so we skip it and leave pattern as null.
  3. Makes the case-insensitive match work for letters outside the English alphabet too.
  4. Never changes anything itself: it returns a FilterResult, a record with two values, "was something found" and "the cleaned text".
  5. The replacement text could contain $ or \, which have special meaning here. This makes them plain characters.
FilterResult.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatFilterResult.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javayou are hereRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchat;2 3public record FilterResult(boolean flagged, String text) {4}

Because WordFilter never changes after its constructor, several threads can call check at the same time safely. That is another reason for the "build once, never change" habit.

Now use it in the chat listener. Here is the filter part of the handler:

ChatListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatChatListener.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javayou are hereListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

48if (settings.filterEnabled() && !player.hasPermission(ChatPermissions.BYPASS_FILTER)) {49    String plainText = PlainTextComponentSerializer.plainText().serialize(event.message());50    FilterResult result = settings.filter().check(plainText);51    if (result.flagged()) {52        if (settings.filterMode() == FilterMode.BLOCK) {53            event.setCancelled(true);54            player.sendRichMessage(settings.messages().blocked());55            return;56        }57        event.message(Component.text(result.text()));58    }59}
  1. The owner can switch the whole filter off in the config.
  2. Players with the bypass permission skip the filter.
  3. The message is a Component, but the filter works on a String. This turns the component into plain text, dropping any formatting.
  4. In block mode the event is canceled: nobody sees the message. The player gets a reason, because silent blocking looks like a bug.
  5. In replace mode the message is swapped for the cleaned text, and the rest of the handler carries on.

Step 6: timed mutes

A mute has three needs: a command to start and end it, a way to remember who is muted until when, and a check in the chat handler. We split it into three small classes that each do one job.

Reading "10m" from a command

Moderators type durations like 30s, 10m, 2h or 1d. DurationParser turns that text into milliseconds, and also turns milliseconds back into friendly text like 9m 41s for messages.

DurationParser.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatDurationParser.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javayou are hereHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

14public static OptionalLong parseMillis(String text) {15    if (text.length() < 2) {16        return OptionalLong.empty();17    }18    char unit = Character.toLowerCase(text.charAt(text.length() - 1));19    long unitMillis = switch (unit) {20        case 's' -> 1_000L;21        case 'm' -> 60_000L;22        case 'h' -> 3_600_000L;23        case 'd' -> 86_400_000L;24        default -> 0L;25    };26    if (unitMillis == 0L) {27        return OptionalLong.empty();28    }29    try {30        long amount = Long.parseLong(text.substring(0, text.length() - 1));31        if (amount <= 0 || amount > MAX_AMOUNT) {32            return OptionalLong.empty();33        }34        return OptionalLong.of(amount * unitMillis);35    } catch (NumberFormatException exception) {36        return OptionalLong.empty();37    }38}
  1. A box that holds either a number or nothing. It is a clean way to say "this might not be a valid duration" without using null or -1.
  2. The last character is the unit: s, m, h or d.
  3. A switch expression: it gives back a value. Each unit maps to how many milliseconds it contains. A minute is 60,000 ms, because 60 seconds times 1,000.
  4. Turns the text 10 into the number 10. It throws NumberFormatException for text like abc, so it sits in a try block.
  5. A safety cap, so 99999999999d cannot overflow the number.

Remembering who is muted

The mute data lives in two places on purpose:

  • A ConcurrentHashMap in memory, from the player's UUID (the unique id every player has, which never changes even when they rename) to the time the mute ends. The chat thread reads this. It is fast and safe for threads.
  • The player's PersistentDataContainer (PDC), a small data store attached to the player and saved with their data. This survives restarts. It may only be touched from the main thread, which is why the chat handler never reads it.

The time is stored as the clock time when the mute ends (a number of milliseconds since 1970, from System.currentTimeMillis()). Storing an end time, instead of "minutes left", means nothing has to count down: the mute simply ends when the clock passes that moment.

MuteManager.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatMuteManager.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javayou are hereHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchat;2 3import java.util.Map;4import java.util.UUID;5import java.util.concurrent.ConcurrentHashMap;6import org.bukkit.NamespacedKey;7import org.bukkit.entity.Player;8import org.bukkit.persistence.PersistentDataContainer;9import org.bukkit.persistence.PersistentDataType;10import org.bukkit.plugin.Plugin;11 12public final class MuteManager {13 14    private final NamespacedKey mutedUntilKey;15    private final Map<UUID, Long> mutedUntil = new ConcurrentHashMap<>();16 17    public MuteManager(Plugin plugin) {18        this.mutedUntilKey = new NamespacedKey(plugin, "muted_until");19    }20 21    public void mute(Player player, long durationMillis) {22        long until = System.currentTimeMillis() + durationMillis;23        mutedUntil.put(player.getUniqueId(), until);24        player.getPersistentDataContainer().set(mutedUntilKey, PersistentDataType.LONG, until);25    }26 27    public boolean unmute(Player player) {28        boolean wasMuted = mutedUntil.remove(player.getUniqueId()) != null;29        player.getPersistentDataContainer().remove(mutedUntilKey);30        return wasMuted;31    }32 33    public void load(Player player) {34        PersistentDataContainer container = player.getPersistentDataContainer();35        Long until = container.get(mutedUntilKey, PersistentDataType.LONG);36        if (until == null) {37            return;38        }39        if (until <= System.currentTimeMillis()) {40            container.remove(mutedUntilKey);41            return;42        }43        mutedUntil.put(player.getUniqueId(), until);44    }45 46    public void forget(UUID playerId) {47        mutedUntil.remove(playerId);48    }49 50    public long remainingMillis(UUID playerId) {51        Long until = mutedUntil.get(playerId);52        if (until == null) {53            return 0L;54        }55        long remaining = until - System.currentTimeMillis();56        if (remaining <= 0L) {57            mutedUntil.remove(playerId, until);58            return 0L;59        }60        return remaining;61    }62}
  1. The name tag under which the end time is stored on the player. Including your plugin keeps it separate from other plugins' data.
  2. The in-memory table. ConcurrentHashMap is the thread-safe map.
  3. Called from /mute on the main thread, which is why it may touch the PDC.
  4. Tells the PDC this value is a long number.
  5. Runs when a player joins. The PDC still holds their mute from before the restart, so we copy it back into the map. If it already ran out, we delete it instead.
  6. When a player leaves, we drop them from memory so the map does not grow forever. Their mute is still in the PDC.
  7. The only method the chat thread calls. It reads the map and does no PDC work. An expired entry is removed with remove(playerId, until), which only removes it if it still holds that exact value.

The commands

The two commands use the BasicCommand pattern from Commands, part 1. Look at how /mute checks everything a moderator can get wrong before it does anything:

MuteCommand.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatMuteCommand.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javayou are hereCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

23@Override24public void execute(CommandSourceStack source, String[] args) {25    CommandSender sender = source.getSender();26    if (args.length != 2) {27        sender.sendMessage(Component.text("Usage: /mute <player> <time>, for example /mute Steve 10m",28            NamedTextColor.RED));29        return;30    }31    Player target = Bukkit.getPlayerExact(args[0]);32    if (target == null) {33        sender.sendRichMessage("<red>No online player is called <name>.",34            Placeholder.unparsed("name", args[0]));35        return;36    }37    OptionalLong durationMillis = DurationParser.parseMillis(args[1]);38    if (durationMillis.isEmpty()) {39        sender.sendRichMessage("<red>Use a number and a unit: 30s, 10m, 2h or 1d. You typed <text>.",40            Placeholder.unparsed("text", args[1]));41        return;42    }43    mutes.mute(target, durationMillis.getAsLong());44    String time = DurationParser.describe(durationMillis.getAsLong());45    sender.sendRichMessage("<green>Muted <name> for <time>.",46        Placeholder.unparsed("name", target.getName()),47        Placeholder.unparsed("time", time));48    target.sendRichMessage("<red>You were muted for <time>.",49        Placeholder.unparsed("time", time));50}
  1. Exactly two words must follow the command: the name and the time.
  2. The usage line is built as plain text, not MiniMessage, because it contains <player> and <time>, which MiniMessage would read as tags.
  3. Finds an online player by exact name. It returns null when nobody has that name, so the next lines handle that.
  4. Whatever the moderator typed is inserted as plain text. It can never inject formatting.
  5. The parser found no valid duration, so we show an example of what is allowed.
  6. All checks passed. This starts the mute and saves it.

MuteCommand also has a suggest method for tab completion and a permission method that returns projectchat.mute; with it, players without the permission cannot even see the command. /unmute is shorter, because it only needs the name. You can read it in UnmuteCommand.java in the download.

The check in the chat handler

The handler asks the manager how long is left. Zero means "not muted":

ChatListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatChatListener.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javayou are hereListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

40long remainingMillis = mutes.remainingMillis(player.getUniqueId());41if (remainingMillis > 0L) {42    event.setCancelled(true);43    player.sendRichMessage(settings.messages().muted(),44        Placeholder.unparsed("time", DurationParser.describe(remainingMillis)));45    return;46}
  1. Only reads the thread-safe map.
  2. Nobody receives the message, and it is not printed to the console either.
  3. Turns the milliseconds left into text such as 9m 41s for the <time> placeholder.

Finally, SessionListener keeps the memory tidy: on join it loads the saved mute, on quit it forgets the player.

SessionListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatSessionListener.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javayou are hereListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

19@EventHandler20public void onJoin(PlayerJoinEvent event) {21    mutes.load(event.getPlayer());22}
  1. Join events run on the main thread, so reading the PDC here is safe.
SessionListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatSessionListener.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javayou are hereListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

24@EventHandler25public void onQuit(PlayerQuitEvent event) {26    Player player = event.getPlayer();27    mutes.forget(player.getUniqueId());28    staffChat.forget(player.getUniqueId());29}

Step 7: staff chat

Staff chat has two entry points. /sc message sends a single message and always works. /sc on its own is a toggle: a switch you flip on and off. While your switch is on, every normal chat message you type is redirected to staff.

The second entry point is the interesting one. We do not need to send anything ourselves: the chat event has a viewers() set, the list of everyone who will see the message. If we remove the non-staff from it, the message goes only to staff, and Paper still does all the delivery. We also give the event a different renderer, so the line looks like a staff line.

StaffChat.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatStaffChat.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javayou are hereHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchat;2 3import java.util.Set;4import java.util.UUID;5import java.util.concurrent.ConcurrentHashMap;6import net.kyori.adventure.text.Component;7import net.kyori.adventure.text.minimessage.MiniMessage;8import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;9import org.bukkit.Bukkit;10import org.bukkit.entity.Player;11 12public final class StaffChat {13 14    private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();15 16    private final ChatSettings settings;17    private final Set<UUID> toggledOn = ConcurrentHashMap.newKeySet();18 19    public StaffChat(ChatSettings settings) {20        this.settings = settings;21    }22 23    public boolean toggle(UUID playerId) {24        if (toggledOn.remove(playerId)) {25            return false;26        }27        toggledOn.add(playerId);28        return true;29    }30 31    public boolean isToggledOn(UUID playerId) {32        return toggledOn.contains(playerId);33    }34 35    public void forget(UUID playerId) {36        toggledOn.remove(playerId);37    }38 39    public Component format(Component senderName, Component message) {40        return MINI_MESSAGE.deserialize(settings.staffFormat(),41            Placeholder.component("player", senderName),42            Placeholder.component("message", message));43    }44 45    public void broadcast(Player sender, String text) {46        Component line = format(sender.displayName(), Component.text(text));47        for (Player online : Bukkit.getOnlinePlayers()) {48            if (online.hasPermission(ChatPermissions.STAFF)) {49                online.sendMessage(line);50            }51        }52        Bukkit.getConsoleSender().sendMessage(line);53    }54}
  1. A thread-safe Set of the players whose switch is on. A set is a collection that holds each value at most once.
  2. Removing returns true if the player was in the set. That means the switch was on, so we just turned it off.
  3. Builds the staff line from the format in the config. Both the toggle (through the renderer) and /sc message use it, so the two look the same.
  4. Every player on the server. broadcast is only called by the command, on the main thread.
  5. The server console sees staff chat too, so it is also logged.
ChatListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatChatListener.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javayou are hereListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

32if (staffChat.isToggledOn(player.getUniqueId()) && player.hasPermission(ChatPermissions.STAFF)) {33    event.viewers().removeIf(viewer ->34        viewer instanceof Player other && !other.hasPermission(ChatPermissions.STAFF));35    event.renderer(ChatRenderer.viewerUnaware((source, sourceName, message) ->36        staffChat.format(sourceName, message)));37    return;38}
  1. Staff chat applies only when the switch is on and the player still has the permission. Someone who lost the permission falls back to public chat.
  2. Removes every viewer for whom the test is true. It runs in the event's own set, so this is safe on the chat thread.
  3. Viewers are Audience objects: players, but also the console. The pattern instanceof Player other checks "is this a player?" and names it other in one step. The console is not a player, so it stays.
  4. The handler stops here: a staff message must not go through the mute check or the filter.

The command class is short. With no words after /sc it toggles, and with words it broadcasts them:

StaffChatCommand.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatStaffChatCommand.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javayou are hereCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

17@Override18public void execute(CommandSourceStack source, String[] args) {19    if (!(source.getExecutor() instanceof Player player)) {20        source.getSender().sendRichMessage("<red>Only players can use staff chat.");21        return;22    }23    if (args.length == 0) {24        boolean nowOn = staffChat.toggle(player.getUniqueId());25        player.sendRichMessage(nowOn26            ? settings.messages().staffChatOn()27            : settings.messages().staffChatOff());28        return;29    }30    staffChat.broadcast(player, String.join(" ", args));31}
  1. Staff chat needs a player, because the toggle belongs to a player. The console gets a short refusal.
  2. Flips the switch and says what it is now.
  3. Glues the words back into one message, because the command arrives split at every space.

Step 8: wire it together

The main class loads the settings, creates one MuteManager and one StaffChat, and shares them with the listeners and commands. Sharing one object between classes by passing it in the constructor is called dependency injection, and it is the same idea you used in the earlier projects.

ChatToolsPlugin.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chatsrcmainjavacomexampleprojectchatChatToolsPlugin.java

The package com.example.projectchat is the folder path com/example/projectchat inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat/
    • src/main/
      • java/com/example/projectchat/Package com.example.projectchat
        • ChatListener.javaListener: reacts to events
        • ChatMessages.javaRecord: a small data class
        • ChatPermissions.javaHelper class
        • ChatSettings.javaRecord: a small data class
        • ChatToolsPlugin.javayou are hereMain class: Paper starts here (named as main in plugin.yml)
        • DurationParser.javaHelper class
        • FilterMode.javaEnum: a fixed list of choices
        • FilterResult.javaRecord: a small data class
        • MuteCommand.javaCommand (BasicCommand)
        • MuteManager.javaHelper class
        • OnlinePlayerNames.javaHelper class
        • RankFormat.javaRecord: a small data class
        • SessionListener.javaListener: reacts to events
        • StaffChat.javaHelper class
        • StaffChatCommand.javaCommand (BasicCommand)
        • UnmuteCommand.javaCommand (BasicCommand)
        • WordFilter.javaHelper class
      • resources/Files copied into the jar as they are
        • config.ymlDefault settings that server owners can change
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchat;2 3import io.papermc.paper.command.brigadier.Commands;4import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;5import java.util.List;6import org.bukkit.plugin.java.JavaPlugin;7 8public final class ChatToolsPlugin extends JavaPlugin {9 10    @Override11    public void onEnable() {12        saveDefaultConfig();13        ChatSettings settings = ChatSettings.from(getConfig(), getLogger());14        MuteManager mutes = new MuteManager(this);15        StaffChat staffChat = new StaffChat(settings);16 17        getServer().getPluginManager().registerEvents(new ChatListener(settings, mutes, staffChat), this);18        getServer().getPluginManager().registerEvents(new SessionListener(mutes, staffChat), this);19 20        getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event -> {21            Commands commands = event.registrar();22            commands.register("mute", "Stop a player from chatting for a while", new MuteCommand(mutes));23            commands.register("unmute", "Let a muted player chat again", new UnmuteCommand(mutes));24            commands.register("sc", "Toggle staff chat or send one staff message",25                List.of("staffchat"), new StaffChatCommand(settings, staffChat));26        });27 28        getLogger().info("ChatTools is ready with " + settings.ranks().size() + " rank formats.");29    }30}
  1. Copies config.yml from inside the jar to plugins/ChatTools/ the first time. It never overwrites a file that already exists, so the owner's edits are safe.
  2. Reads the config once. Every other class receives this finished, unchangeable object.
  3. Gets the plugin so it can build its NamespacedKey.
  4. Registers the chat listener. Without this line, nothing in this plugin would ever run.
  5. Paper asks plugins to register commands at a special moment during startup. Every plugin registers its commands this way on modern Paper.
  6. An alias: /staffchat works exactly like /sc.

Here is the project's file layout. Every file has one job, which is why the biggest class is only about 70 lines:

  • project-chat/
    • src/
      • main/
        • java/
          • com/example/projectchat/
            • ChatToolsPlugin.javaMain class: loads settings and registers everything
            • ChatListener.javaThe AsyncChatEvent handler: staff chat, mute, filter, format
            • SessionListener.javaLoads a saved mute on join and tidies up on quit
            • ChatSettings.javaReads config.yml into unchangeable records
            • RankFormat.javaOne rank: its id, permission and format
            • ChatMessages.javaThe texts the plugin sends to players
            • FilterMode.javaThe two filter modes: REPLACE and BLOCK
            • FilterResult.javaWhat the filter found and the cleaned text
            • WordFilter.javaBuilds the pattern and cleans messages
            • DurationParser.javaTurns 10m into milliseconds and back into text
            • MuteManager.javaWho is muted until when, in memory and in the PDC
            • StaffChat.javaThe staff switch and the staff line format
            • MuteCommand.java/mute player time
            • UnmuteCommand.java/unmute player
            • StaffChatCommand.java/sc and /sc message
            • OnlinePlayerNames.javaTab completion helper for player names
            • ChatPermissions.javaThe permission names in one place
        • resources/
          • plugin.ymlName, main class and permissions
          • config.ymlFormats, ranks, filter words and messages

Test it properly

Build the plugin, start the test server with Run Paper Server and join. The console should show:

Server console
[14:20:03 INFO]: [ChatTools] Enabling ChatTools v1.0.0[14:20:03 INFO]: [ChatTools] ChatTools is ready with 3 rank formats.

Work through this checklist. You can test almost everything alone, because operators may mute themselves:

What to doWhat you should see
Send "hello" as an operatorThe red OWNER format.
Run /deop YourName in the console, then chat againThe gray default format. Run /op YourName afterwards.
Send "oh darn" and "what the HECK"As an operator, nothing changes, because operators have the bypass permission. Run /deop YourName first (see the note below the table).
Run /op YourName again, set mode: block in the config, restart, then /deop YourName and chat "heck"The message does not appear, and you get the red notice.
/mute YourName 30s, then chatThe red "You are muted for another ..." line, counting down on repeated tries.
Mute yourself, stop the server, start it, join again, chatThe mute is still there, because it was saved in the PDC.
/unmute YourNameYou can chat again.
/sc, then chatThe green staff-chat notice, then your messages appear with [Staff]. A second account without the permission must not see them.

Mistakes you will probably make

  • The format shows <player> literally. The placeholder name in the config must match the name in the code (player and message) exactly.
  • The config changes do nothing. saveDefaultConfig() never overwrites an existing file, and this plugin has no reload. Edit plugins/ChatTools/config.yml and restart the server (never use /reload).
  • Everyone has the OWNER format. All operators have projectchat.rank.owner. Deop yourself to test the other formats.
  • An error about "async" or "asynchronous" in the console. You called something that only works on the main thread from the chat handler. Move that work into runTask, or leave it out.
  • Colors in player messages do not work. That is intended. The message goes in as a finished component, never as MiniMessage text, so players cannot make rainbow text.

Practice

Try it

Stop shouting

Add a rule that calms messages written in capital letters. If a message has at least 8 letters and more than 70 percent of them are uppercase, change the whole message to lowercase. Short messages like "GG" or "OMG" must stay as they are.

Hint 1

Loop over the characters with text.toCharArray(). Character.isLetter and Character.isUpperCase tell you what each character is.

Hint 2

Divide the two counts with a double: (double) uppercaseLetters / letters. Dividing two int values throws away the decimals.

Hint 3

Change the message with event.message(Component.text(...)), exactly like the word filter does.

Show the solution

A small helper class does the counting, so the rule can be tested and changed without touching the listener.

CapsLimiter.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chat-exercisesrcmainjavacomexampleprojectchatexerciseCapsLimiter.java

The package com.example.projectchatexercise is the folder path com/example/projectchatexercise inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat-exercise/
    • src/main/
      • java/com/example/projectchatexercise/Package com.example.projectchatexercise
        • CapsLimiter.javayou are hereHelper class
        • CapsListener.javaListener: reacts to events
        • ChatCapsExercisePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

1package com.example.projectchatexercise;2 3import java.util.Locale;4 5public final class CapsLimiter {6 7    private static final int MIN_LETTERS = 8;8    private static final double MAX_UPPERCASE_SHARE = 0.7;9 10    private CapsLimiter() {11    }12 13    public static boolean isShouting(String text) {14        int letters = 0;15        int uppercaseLetters = 0;16        for (char character : text.toCharArray()) {17            if (Character.isLetter(character)) {18                letters++;19                if (Character.isUpperCase(character)) {20                    uppercaseLetters++;21                }22            }23        }24        if (letters < MIN_LETTERS) {25            return false;26        }27        return (double) uppercaseLetters / letters > MAX_UPPERCASE_SHARE;28    }29 30    public static String calmDown(String text) {31        return text.toLowerCase(Locale.ROOT);32    }33}
  1. Short messages are never judged, so "GG" and "OMG" are safe.
  2. Spaces, digits and punctuation do not count as letters.
  3. The (double) turns the whole-number count into a decimal first, so 9 / 10 is 0.9 and not 0.

The listener runs at priority HIGH, like the main one, and only rewrites the message:

CapsListener.javaCompiles on Paper 26.3Compile Lab
Where this file livesproject-chat-exercisesrcmainjavacomexampleprojectchatexerciseCapsListener.java

The package com.example.projectchatexercise is the folder path com/example/projectchatexercise inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.

  • project-chat-exercise/
    • src/main/
      • java/com/example/projectchatexercise/Package com.example.projectchatexercise
        • CapsLimiter.javaHelper class
        • CapsListener.javayou are hereListener: reacts to events
        • ChatCapsExercisePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
    • gradle.propertiesVersion numbers used by the build
    • gradlew.batRuns Gradle on Windows without installing it
    • settings.gradle.ktsThe project's name

Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.

12@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)13public void onChat(AsyncChatEvent event) {14    String plainText = PlainTextComponentSerializer.plainText().serialize(event.message());15    if (CapsLimiter.isShouting(plainText)) {16        event.message(Component.text(CapsLimiter.calmDown(plainText)));17    }18}

In the real plugin you would put the same three lines inside ChatListener, right before the word filter.

Try it

Add a Builder rank with no code

Change only config.yml and plugin.yml. Give builders a green [Builder] badge, but make sure an owner who is also a builder still shows the owner badge.

Hint 1

Copy the vip entry. Because the first match wins, the position in the list decides who wins.

Hint 2

Remember to declare the new permission in plugin.yml with default: false.

Show the solution

Put the new entry below owner and indent it two spaces, so it sits under ranks: like the others. Owners match first, so they keep their badge, and builders get theirs:

config.yml (new entry)
1builder:2  permission: projectchat.rank.builder3  format: "<green>[Builder] <white><player> <dark_gray>> <white><message>"

The new permission goes under permissions: in plugin.yml, indented two spaces like its neighbors:

plugin.yml (new permission)
1projectchat.rank.builder:2  description: Shows the builder chat format.3  default: false

Extension ideas

  • Mute offline players. Right now only online players can be muted, because the PDC of an offline player cannot be changed. Store mutes in a file instead, as in Saving player data.
  • A mute reason. Add a third argument, store it next to the end time and show it in the "you are muted" message.
  • A reload command. Let /chattools reload rebuild the settings. The settings object is unchangeable, so you replace the whole object, and the listener needs to read it through something that can be swapped safely (volatile or an AtomicReference).
  • Smarter filtering. Strip dots and digit tricks (h3ck) before checking, using a small text-cleaning method.
  • Chat cooldown. Block messages sent less than one second apart, with the pattern from Cooldowns and timers. Keep the last-message times in a ConcurrentHashMap.
  • Mentions. Highlight @Steve in the message and play a sound for Steve. Playing the sound needs the main thread, so schedule it with runTask.
  • Per-viewer lines. Switch from viewerUnaware to a full ChatRenderer, whose render method also receives the viewer, so each player can see their own name highlighted.

Recap

  • AsyncChatEvent carries the message. A ChatRenderer decides how the finished line looks, and viewers() decides who sees it.
  • Placeholders such as Placeholder.component("message", ...) insert text safely, so player text is never read as MiniMessage.
  • Chat runs off the main thread. Do not change the world or players there, and share data only through thread-safe classes like ConcurrentHashMap or through records that never change.
  • Formats, words and messages live in config.yml, are read once into records, and the rank format is the first permission match from the top of the list.
  • The word filter is a regular expression with \b word boundaries, built once and reused.
  • A mute stores its end time in memory for the chat thread and in the PDC for restarts; durations are parsed from text like 10m.
  • Staff chat is a toggle plus a trimmed viewers() set, and a command for single messages.

Quick quiz

  1. Why does the mute list use a ConcurrentHashMap instead of a normal HashMap?

  2. Which of these is safe to do inside an AsyncChatEvent handler that runs on the chat thread?

  3. A player has both the owner and the VIP permission. The config lists owner first. Which format do they get?

  4. Why is the typed message inserted with Placeholder.component("message", message) and not parsed as MiniMessage?

  5. How does the toggled staff chat make sure only staff see a message?

Next steps

Chat was your first taste of code that runs on another thread. The Threads, performance and lag chapter explains why the main thread is so precious. If you want more real projects, continue with the Parkour course, where a timer ticks on the main thread, or revisit Permissions to learn how groups hand out the rank permissions you used here.