Project: Chat formatter and filter
Custom chat format per rank, a word filter, mutes and a staff chat channel.
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:
[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:
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:
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:
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.
| Feature | The piece that does it | Chapter |
|---|---|---|
| React to a chat message | AsyncChatEvent in a listener | Events and listeners |
| Change how a message looks | ChatRenderer and MiniMessage placeholders | MiniMessage |
| A format per rank | player.hasPermission(...) and a list read from the config | Permissions, Configuration files |
| Word filter | A regular expression built from the config's word list | Working with text |
| Mute with a duration | A Map in memory plus a value saved in the PersistentDataContainer | Saving data on things, Lists, sets and maps |
The /mute, /unmute and /sc commands | BasicCommand | Simple commands |
| Staff-only messages | Trimming the event's viewers() set | This page |
| Doing all of it safely off the main thread | Thread-safe collections and read-only settings | Threads, 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.
Create the project
A
plugin.ymlwith the commands' permissions.Take over the chat format
The smallest useful chat plugin: one fixed format.
Understand the chat thread
What you may and may not do inside the handler.
Move formats into config.yml
One format per rank, chosen by permission.
Add the word filter
Replace or block, from a list in the config.
Add timed mutes
A duration parser, a mute manager and two commands.
Add staff chat
A toggle, a command and a trimmed audience.
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".
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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- The full name of the main class. It does not exist yet; you write it in step 8.
- Who may use
/muteand/unmute. - Who may use staff chat and who can read it.
- Players with this permission are never filtered, handy for trusted staff.
- A permission that exists only to pick a chat format. Operators get it by default, so you can test the owner format with
/op. default: falsemeans 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:
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
- java/com/example/projectchatstep1/Package com.example.projectchatstep1
- 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
- src/main/
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}- Paper's
event for a chat message. "Async" means it does not run on the main game thread; step 3 explains what that changes. - One shared MiniMessage object that turns text with tags such as
<gray>into a formattedComponent . - A
renderer is the piece that builds the final chat line. Giving the event a new one replaces Minecraft's<Steve> hellolook. - "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.
- 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. - Turns the format text into a component.
<player>and<message>in the text areplaceholders that the next lines fill in. - Inserts the display name as a component, so it keeps its own color or hover text if a nickname plugin gave it some.
- 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:
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
- java/com/example/projectchatstep1/Package com.example.projectchatstep1
- 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
- src/main/
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:
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.
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 withgetServer().getScheduler().runTask(plugin, task -> ...), as the scheduler chapter shows. - Shared data must be thread-safe. Our mute list is written by the
/mutecommand on the main thread and read by the chat handler on the chat thread. A plainHashMapmay break when two threads use it at once. AConcurrentHashMapis 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:
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>"- The fallback format, used by everyone who has none of the rank permissions below.
- Order matters. A player with both the owner and the VIP permission gets the first match, owner, because we look from the top down.
- The permission that selects this format. Hand it to a group in LuckPerms and everyone in the group gets the format.
<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.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- A path such as
formats.defaultmeans "the keydefaultinside the sectionformats". The second argument is the fallback if the key is missing. - The
ranks:section as an object, ornullif the owner deleted it, which is why the next line checks fornull. - The names directly under
ranks: owner, staff, vip.falsemeans "only the first level, not nested keys". They come back in the order written in the file. - If a rank is half-filled-in, we tell the owner in the console and skip it instead of crashing.
- Turns the text
REPLACEorBLOCKinto the matchingenum value. It throwsIllegalArgumentExceptionfor anything else, so we catch that and fall back toREPLACE. - 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:
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- Walks the ranks from the top of the file down.
- 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. - 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:
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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();- 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.
- 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.
- If another plugin already canceled the message, skip it. Without this, we would format and filter a message that nobody will see.
- The player who typed the message.
Inside the handler, instead of the fixed text from step 2, the last lines ask the settings:
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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))));- Picked before the renderer is built, outside the lambda, because the lambda is called later and should not have to look anything up.
- 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:
| Piece | Meaning |
|---|---|
\b | A 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:
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}- A
method reference : shorthand for "callPattern.quoteon every word". - Glues the quoted words together with
|between them, which means "or" in a pattern. - So
HECK,Heckandheckall match. - 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:
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- 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.
- With no words there is nothing to find. A pattern built from an empty list would match everywhere, so we skip it and leave
patternasnull. - Makes the case-insensitive match work for letters outside the English alphabet too.
- Never changes anything itself: it returns a
FilterResult, a record with two values, "was something found" and "the cleaned text". - The replacement text could contain
$or\, which have special meaning here. This makes them plain characters.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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:
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- The owner can switch the whole filter off in the config.
- Players with the bypass permission skip the filter.
- The message is a
Component, but the filter works on aString. This turns the component into plain text, dropping any formatting. - In block mode the event is canceled: nobody sees the message. The player gets a reason, because silent blocking looks like a bug.
- 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.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- 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
nullor-1. - The last character is the unit: s, m, h or d.
- 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. - Turns the text
10into the number 10. It throwsNumberFormatExceptionfor text likeabc, so it sits in atryblock. - A safety cap, so
99999999999dcannot overflow the number.
Remembering who is muted
The mute data lives in two places on purpose:
- A
ConcurrentHashMapin 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.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- The name tag under which the end time is stored on the player. Including your plugin keeps it separate from other plugins' data.
- The in-memory table.
ConcurrentHashMapis the thread-safe map. - Called from
/muteon the main thread, which is why it may touch the PDC. - Tells the PDC this value is a
longnumber. - 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.
- When a player leaves, we drop them from memory so the map does not grow forever. Their mute is still in the PDC.
- 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:
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- Exactly two words must follow the command: the name and the time.
- The usage line is built as plain text, not MiniMessage, because it contains
<player>and<time>, which MiniMessage would read as tags. - Finds an online player by exact name. It returns
nullwhen nobody has that name, so the next lines handle that. - Whatever the moderator typed is inserted as plain text. It can never inject formatting.
- The parser found no valid duration, so we show an example of what is allowed.
- 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":
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- Only reads the thread-safe map.
- Nobody receives the message, and it is not printed to the console either.
- Turns the milliseconds left into text such as
9m 41sfor the<time>placeholder.
Finally, SessionListener keeps the memory tidy: on join it loads the saved mute, on quit it forgets the player.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- Join events run on the main thread, so reading the PDC here is safe.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- A thread-safe
Setof the players whose switch is on. A set is a collection that holds each value at most once. - Removing returns
trueif the player was in the set. That means the switch was on, so we just turned it off. - Builds the staff line from the format in the config. Both the toggle (through the renderer) and
/sc messageuse it, so the two look the same. - Every player on the server.
broadcastis only called by the command, on the main thread. - The server console sees staff chat too, so it is also logged.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- 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.
- 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.
- Viewers are
Audienceobjects: players, but also the console. The patterninstanceof Player otherchecks "is this a player?" and names itotherin one step. The console is not a player, so it stays. - 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:
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- Staff chat needs a player, because the toggle belongs to a player. The console gets a short refusal.
- Flips the switch and says what it is now.
- 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.
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
- java/com/example/projectchat/Package com.example.projectchat
- 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
- src/main/
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}- Copies
config.ymlfrom inside the jar toplugins/ChatTools/the first time. It never overwrites a file that already exists, so the owner's edits are safe. - Reads the config once. Every other class receives this finished, unchangeable object.
- Gets the plugin so it can build its
NamespacedKey. - Registers the chat listener. Without this line, nothing in this plugin would ever run.
- Paper asks plugins to register commands at a special moment during startup. Every plugin registers its commands this way on modern Paper.
- An alias:
/staffchatworks 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
- com/example/projectchat/
- resources/
- plugin.ymlName, main class and permissions
- config.ymlFormats, ranks, filter words and messages
- java/
- main/
- src/
Test it properly
Build the plugin, start the test server with Run Paper Server and join. The console should show:
[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 do | What you should see |
|---|---|
| Send "hello" as an operator | The red OWNER format. |
Run /deop YourName in the console, then chat again | The 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 chat | The red "You are muted for another ..." line, counting down on repeated tries. |
| Mute yourself, stop the server, start it, join again, chat | The mute is still there, because it was saved in the PDC. |
/unmute YourName | You can chat again. |
/sc, then chat | The 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 (playerandmessage) exactly. - The config changes do nothing.
saveDefaultConfig()never overwrites an existing file, and this plugin has no reload. Editplugins/ChatTools/config.ymland 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
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.
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
- java/com/example/projectchatexercise/Package com.example.projectchatexercise
- 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
- src/main/
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}- Short messages are never judged, so "GG" and "OMG" are safe.
- Spaces, digits and punctuation do not count as letters.
- 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:
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
- java/com/example/projectchatexercise/Package com.example.projectchatexercise
- 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
- src/main/
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.
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:
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:
1projectchat.rank.builder:2 description: Shows the builder chat format.3 default: falseExtension 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 reloadrebuild 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 (volatileor anAtomicReference). - 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
@Stevein the message and play a sound for Steve. Playing the sound needs the main thread, so schedule it withrunTask. - Per-viewer lines. Switch from
viewerUnawareto a fullChatRenderer, whoserendermethod also receives the viewer, so each player can see their own name highlighted.
Recap
AsyncChatEventcarries the message. AChatRendererdecides how the finished line looks, andviewers()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
ConcurrentHashMapor 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
\bword 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
Why does the mute list use a
ConcurrentHashMapinstead of a normalHashMap?Two threads using a plainHashMapat once can corrupt it.ConcurrentHashMapis built for this. It does not save anything to disk (that is why the PDC is used as well), and any object can be a key in a normal map.Which of these is safe to do inside an
AsyncChatEventhandler that runs on the chat thread?Changing the event's own message only changes data inside the event. Teleports and block changes alter the game and must run on the main thread; use the scheduler to hop back.A player has both the owner and the VIP permission. The config lists
ownerfirst. Which format do they get?formatForreturns as soon as it finds a permission the player has, so the order in the file decides. That is why the most important rank goes first.Why is the typed message inserted with
Placeholder.component("message", message)and not parsed as MiniMessage?Parsing player text as MiniMessage would let anyone add colors, hover text and click actions to their chat line. Inserting a finished component keeps their text as plain characters.How does the toggled staff chat make sure only staff see a message?
Trimmingviewers()lets Paper do the delivery, and the console, which is not a player, stays in the set and still sees the message. Canceling and resending would also work, but needs more code and would mean sending messages by hand.
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.