Skip to content

Your first plugin: count ticks

Changing. Follow: #280, #288, #321

In this tutorial you add a plugin that counts server ticks. mcrs has no plugin loader and no jar files, as This is not the Paper API explains, so “installing a plugin” means adding Rust code to the program that starts the server. Your plugin lives in your own copy of the mcrs repository, next to the server binary.

If Rust and Bevy are new to you, read this table first. Rust for Java developers has the full minimum.

Word Closest Java idea Meaning here
plugin JavaPlugin with onEnable a struct with a build method that adds systems and resources to an app
system a scheduled task or an event handler a function that Bevy runs on a schedule, such as FixedUpdate
resource a singleton field of the server one value per world, read with Res<T> or ResMut<T>
observer an event listener a function that runs when an event is triggered, written with On<Event>
query a stream over all players, with a filter a system parameter that visits entities with the components you name
&mut a mutable reference a borrow that may change the value; only one can exist at a time
Clone clone() a copy of the value; a dimension plugin needs it, as step 2 explains
dimension world one World object per dimension each dimension has its own world, and your plugin is built once in each

Clone andreypfau/mcrs. The README Quick start has the toolchain note and the run commands.

A plugin is a struct that implements Plugin. The plugin below registers a resource named Ticks, which holds a number, and one system. The system runs in FixedUpdate and adds one to the number on each server tick.

A plugin that counts ticks
#[derive(Resource, Default)]
struct Ticks<const ALL: bool>(u32);
#[derive(Message)]
struct Ticked;
#[derive(Clone)]
struct CountTicks<const ALL: bool>;
impl<const ALL: bool> Plugin for CountTicks<ALL> {
fn name(&self) -> &str {
if ALL {
"count every tick"
} else {
"count overworld ticks"
}
}
fn build(&self, app: &mut App) {
app.init_resource::<Ticks<ALL>>().add_systems(
FixedUpdate,
|mut ticks: ResMut<Ticks<ALL>>, mut ticked: MessageWriter<Ticked>| {
ticks.0 += 1;
if ALL {
ticked.write(Ticked);
}
},
);
app.add_message::<Ticked>();
}
}

crates/mcrs_minecraft_server/tests/it/dimension_plugins.rs:10-40

This code comes from a test file of the repository, where the plugin counts ticks in the overworld and in the nether. The test uses a const generic ALL to run two variants of the plugin. Read the code as a plain CountTicks and ignore ALL.

Your plugin must be Clone, because the server builds one copy of it for every dimension world. The server binary already has a small plugin that shows the shape:

The smallest Clone plugin in src/main.rs
#[derive(Clone)]
struct LogInsteadOfPanicPlugin;
impl Plugin for LogInsteadOfPanicPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(FallbackErrorHandler(log_instead_of_panic));
}
}

src/main.rs:129-136

Step 3: register it with add_dimension_plugin

Section titled “Step 3: register it with add_dimension_plugin”

The function serve() in src/main.rs already registers one plugin the right way. Add yours on the next line.

Where serve() registers a dimension plugin
fn serve() -> AppExit {
let mut app = App::new();
app.insert_resource(FallbackErrorHandler(log_instead_of_panic));
app.add_dimension_plugin(LogInsteadOfPanicPlugin);

src/main.rs:43-46

The test shows both calls and what the server builds from them:

One plugin in every world, one in the overworld only
#[test]
fn dimension_plugins_run_in_every_world_or_in_the_world_of_their_key() {
let mut app = host_app::make_host_app();
app.add_dimension_plugin(CountTicks::<true>)
.add_dimension_plugin(Rate(1))
.add_dimension_plugin_to(OVERWORLD, SeesSpawn)
.add_dimension_plugin_to(OVERWORLD, AddsCountTicks)
.add_dimension_plugin_to(OVERWORLD, CountTicks::<false>)
.add_dimension_plugin_to(OVERWORLD, Rate(2));

crates/mcrs_minecraft_server/tests/it/dimension_plugins.rs:90-98

Step 4: run the test that checks the count

Section titled “Step 4: run the test that checks the count”

The test plugin prints nothing. The test asserts that the counter is above zero in both worlds, with ticks::<true>(...) in the same file. The command below runs this test, and the chat test uses the same pattern.4

Terminal window
cargo test -p mcrs_minecraft_server --test it dimension_plugins::

We did not run this command for this page. The repository has no log line for this plugin, so this page shows no expected log. If you want one, add a tracing::info! call to your system.

This is illustrative Java. It is not from the repository and it is not mcrs code.

public final class CountTicks extends JavaPlugin {
private long ticks;
@Override
public void onEnable() {
getServer().getScheduler().runTaskTimer(this, () -> ticks++, 0L, 1L);
}
}

Four things a plugin may need are not ready yet. They are public events, chunk sources, saving and dimensions at run time.5678 Status shows what else is missing for plugins, and Dimensions explains why add_plugins does not reach a dimension.

Next: Intercept chat uses the first public event to cancel and rewrite a chat line.

  1. Undecided#321 Tracks making the protocol, network and game data crates publishable. ↩

  2. WIP#288 Plans workspace members under examples/ with a dependency-tree test per example. ↩

  3. Works#280 Lets user plugins run inside every dimension world. ↩

  4. Works#636 Raises a cancellable event for a chat line before its broadcast. ↩

  5. Partial#283 Tracks public player events that a plugin can cancel. ↩

  6. WIP#281 Plans the chunk source as a replaceable plugin point, with empty and flat sources. ↩

  7. WIP#78 Tracks saving changed chunks back to region files. ↩

  8. WIP#546 Plans to create and remove dimension worlds at run time and move players between them. ↩

Sources and verification

Verified: 26.4-snapshot-7, 51d45c26a, 2026-11-09

Tracking: #280, #288, #321