Intercept chat: cancel and rewrite
This tutorial shows how a plugin cancels a chat line or replaces its text. Chat is the first public player event of mcrs and, for now, the only one. It is the first merged part of the planned public, cancellable player events, and events for join, leave, movement and blocks do not exist yet.12 The examples come from test files of the repository, and we did not run them outside those tests.
The event has three fields
Section titled “The event has three fields”The server raises PlayerChatEvent for a chat line before it broadcasts the line.
#[derive(Event, Debug)]pub struct PlayerChatEvent { pub player: Entity, pub text: Text, pub cancelled: bool,}crates/mcrs_minecraft_chat_server/src/chat_plugin.rs:19-24
| Field | Meaning |
|---|---|
player |
the entity of the sender, in the world of the sender’s dimension |
text |
the line as a Text, not a String; change it to rewrite the line |
cancelled |
set it to true and the line is not broadcast |
Text is the styled text type of the protocol, so a line can carry colors and components. To make a Text from a string, use into_text(), as the tests do.
Which lines raise the event
Section titled “Which lines raise the event”The server checks a line first and raises the event only when the line passes, so the event comes after the checks and before the broadcast.
| Line | What the server does |
|---|---|
| Has an illegal character | Raises no event and logs that it dropped the line |
| Comes from a player who hides chat | Raises no event and sends the player the “chat disabled” message |
| Any other line | Raises the event in the dimension world of the sender. After the observers ran, it reads cancelled and text and broadcasts |
let text = msg.to_string().into_text();
let mut chat_event = PlayerChatEvent { player: event.entity, text, cancelled: false,};
let username = profile.username.clone();commands.queue(move |world: &mut World| { world.trigger_ref(&mut chat_event); if chat_event.cancelled { return; } info!("<{}> {:?}", username, chat_event.text); let text: Text = Text::translate( "chat.type.text", vec![username.clone().into_text(), chat_event.text], );
let text = match RawText::from_text(&text) { Ok(text) => text, Err(err) => { warn!(player = %username, "dropping a chat line that does not encode: {err:?}"); return; } };
world.write_message(to_target(PacketTarget::AllPlayers, system_chat(text)));})crates/mcrs_minecraft_chat_server/src/chat_plugin.rs:64-93
The trigger runs in a queued command. Because of that, the broadcast happens right after handle_chat ends and not inside it.
Cancel a line
Section titled “Cancel a line”In Paper you call setCancelled(true) on AsyncChatEvent. Here you write an observer, which is a function with the parameter On<PlayerChatEvent>, and set the field in it.
#[test]fn an_observer_that_cancels_a_chat_line_stops_its_broadcast() { fn cancel(mut event: On<PlayerChatEvent>) { event.cancelled = true; } let mut app = host_app::make_host_app(); app.add_dimension_plugin(ChatPlugin); let mut server = PlayServer::start(app, &[], 0); let (sender, _) = server.join(Uuid::from_u128(1), "Sender"); let dim = server.dim; server.world_in(dim).add_observer(cancel); server.queue(sender, &line("hello"));
let sent = server.ticks_all(2);
assert!(!sent.iter().any(|packet| { matches!(packet.target, PacketTarget::AllPlayers) && matches!(packet.data, PacketPayload::SystemChat(_)) }));}crates/mcrs_minecraft_server/tests/it/chat.rs:123-142
Replace the text
Section titled “Replace the text”Set event.text to a new Text, and the broadcast uses it. The test below also reads event.player to show who spoke.
#[test]fn an_observer_that_replaces_a_chat_line_changes_its_broadcast() { #[derive(Resource)] struct Speaker(Entity); fn censor(mut event: On<PlayerChatEvent>, mut commands: Commands) { commands.insert_resource(Speaker(event.player)); event.text = "***".to_owned().into_text(); } let mut app = host_app::make_host_app(); app.add_dimension_plugin(ChatPlugin); let mut server = PlayServer::start(app, &[], 0); let (sender, _) = server.join(Uuid::from_u128(1), "Sender"); server.world().add_observer(censor); server.queue(sender, &line("hello"));
let heard: Vec<Text> = server .ticks_all(2) .iter() .filter(|packet| matches!(packet.target, PacketTarget::AllPlayers)) .filter_map(|packet| match &packet.data { PacketPayload::SystemChat(chat) => { Some(chat.content.to_text().unwrap().map(&ItemHoverMap).unwrap()) } _ => None, }) .collect();
let expected = Text::translate( "chat.type.text", vec![ "Sender".to_owned().into_text(), "***".to_owned().into_text(), ], ); assert_eq!(heard, [expected]); let player = server.player(sender); assert_eq!(server.world().resource::<Speaker>().0, player);}crates/mcrs_minecraft_server/tests/it/chat.rs:144-181
Put the observer in a dimension plugin
Section titled “Put the observer in a dimension plugin”Chat runs inside each dimension world, so the observer has to be in the dimension world as well. The tests add it to the world directly with server.world().add_observer(...). In a real server, put app.add_observer(...) into the build method of a Clone plugin and register the plugin with add_dimension_plugin. ChatPlugin itself does the same for its own observer:
impl Plugin for ChatPlugin { fn build(&self, app: &mut App) { if !app.is_plugin_added::<ClientInfoPlugin>() { app.add_plugins(ClientInfoPlugin); } app.add_observer(handle_chat); }}crates/mcrs_minecraft_chat_server/src/chat_plugin.rs:26-33
Your first plugin explains why add_plugins does not work here.
Run the tests
Section titled “Run the tests”The command that runs the repository tests is cargo test -p mcrs_minecraft_server --test it chat::.2 To write your own test, see Test a plugin without a client.
Next: Add a command covers CommandEvent, the event of commands with the same cancelled field. Paper to mcrs has the table of other Paper events and what mcrs has for them.
Footnotes
Section titled “Footnotes”Sources and verification
Verified: 26.4-snapshot-7, 51d45c26a, 2026-11-09