Skip to content

Dimensions are separate worlds

Changing. Follow: #280, #357, #362, #546

This page explains where your code has to run to see players and blocks, who is allowed to write state that all dimensions share, and how a player gets from one dimension to another.

In Paper, a World is an object that your plugin can reach from anywhere. In mcrs, a dimension is a separate simulation, and code in the main app cannot touch it directly. The examples come from repository files, and we did not run them outside the repository tests.

The server has one main Bevy App. Each dimension runs as a SubApp, which is a Bevy app nested in the main one, and it has its own World with its own entities and resources.1 In one shared world, every system would visit the entities of all dimensions and filter them by a dimension component. The dimensions also could not tick in parallel or move to another machine later.

This has consequences for your code. A system of a dimension world sees only the entities of that dimension. Immutable registries are shared with the dimensions as Arc values, and mutable state is not shared.

A dimension world also has no Time resource and does not run the stock Main schedule. It runs First, PreUpdate, the Fixed* schedules, Update, PostUpdate and Last once per host tick. A dimension therefore counts ticks and cannot depend on the wall clock, as the doc comment in sub_app.rs says.

A plugin gets into a dimension through add_dimension_plugin

Section titled “A plugin gets into a dimension through add_dimension_plugin”

A plugin added with add_plugins goes to the main app only, where it sees no players and no blocks. To put a plugin into the dimensions, use the AddDimensionPlugins trait:2

The two methods
pub trait AddDimensionPlugins {
fn add_dimension_plugin(&mut self, plugin: impl Plugin + Clone) -> &mut Self;
fn add_dimension_plugin_to(
&mut self,
dimension: impl Into<ResourceKeyBuf<Dimension>>,
plugin: impl Plugin + Clone,
) -> &mut Self;
}

crates/mcrs_minecraft_engine_world/src/world/sub_app.rs:112-120

  • add_dimension_plugin(plugin) builds a copy of the plugin in every dimension world.
  • add_dimension_plugin_to(key, plugin) builds it only in the dimension with that key, for example OVERWORLD.

The plugin must be Clone, because each world builds its own clone. The worlds build the clones in the order the plugins were added. Add your plugin before the dimensions spawn, because a dimension that is already live does not get a later plugin.

One more rule applies to unique plugins, which is the Bevy name for a plugin that an app can hold only once. A unique plugin that you add for one dimension replaces, in that world, the unique plugins with the same name that were added for all dimensions. Any other unique plugin whose name the world already has is skipped, and the server logs an error.

The repository test checks each rule. It adds one counter to every world and another counter only to the overworld. Then it asserts that the nether has no second counter and that the overworld got the value of the plugin added for its key.

Test: a plugin for every world and a plugin for one key
#[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));
host_app::materialise_sub_apps(
&mut app,
&[
("minecraft:overworld", "minecraft:overworld"),
("minecraft:the_nether", "minecraft:the_nether"),
],
);
for _ in 0..5 {
app.update();
}
assert!(ticks::<true>(&mut app, "minecraft:overworld").is_some_and(|n| n > 0));
assert!(ticks::<true>(&mut app, "minecraft:the_nether").is_some_and(|n| n > 0));
assert!(ticks::<false>(&mut app, "minecraft:overworld").is_some_and(|n| n > 0));
assert_eq!(ticks::<false>(&mut app, "minecraft:the_nether"), None);
assert_eq!(
world(&mut app, "minecraft:overworld")
.resource::<RateSet>()
.0,
2
);
assert_eq!(
world(&mut app, "minecraft:the_nether")
.resource::<RateSet>()
.0,
1
);

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

Every dimension reads game time, world clocks, weather, game rules, the world border and scoreboards. Each piece of such state has one owner, and only the owner writes it.3 In one process the owner is the main app.

A dimension holds a read-only snapshot with a version, and it gets a new snapshot only when the version changes. When a dimension wants a change, for example because of a command or a weather cycle, it sends a message to the owner.

For a plugin this means that you do not keep your own copy of the game clock inside a dimension and advance it, because the copies would drift apart. The same holds for a test setup. When it sets game rules, weather or clocks, it writes through the owner and never into a dimension world.

A player moves between dimensions as a message

Section titled “A player moves between dimensions as a message”

A system of one dimension cannot reach another world, so the server cannot move the entity of a player from one world to the other. A portal, or any other move decided inside a tick, writes a request:1

ChangeDimensionRequest
#[derive(Message, Clone, Debug)]
pub struct ChangeDimensionRequest {
pub player: Entity,
pub to: ResourceKeyBuf<Dimension>,
pub transform: Transform,
}

crates/mcrs_minecraft_engine_world/src/entity/player/mod.rs:19-24

At the end of the tick, the old world takes the carried state of the player, despawns the player and hands a departure to the host. The player arrives in the new world on the next tick. The rules are in the section “Change of dimension” of docs/command-dimensions.md.

The same document describes how commands work across worlds. A command runs in one world. Its effects on other worlds are messages, and the host forwards them between ticks. The page Add a command shows how a handler writes such an effect.

You cannot create a dimension at run time yet

Section titled “You cannot create a dimension at run time yet”

Next: Remove and replace vanilla parts.

  1. Partial#362 The decision that each dimension runs as a sub-app with its own world. ↩ ↩2

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

  3. Works#357 The decision that global mutable state has one writer. ↩

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

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

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

  7. WIP#142 Tracks that players arrive in the target dimension as full players. ↩

Sources and verification

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

Tracking: #280, #357, #362, #546