Chapter 10, Other Worlds
Register a whole dimension, then walk into it through a block you can step through like a doorway.
Beta 1.7.3 shipped with two dimensions: the Overworld and the Nether. RetroAPI lets you add a third, a tenth, as many as you like, each with its own save folder, its own terrain, and a portal that arms itself exactly the way the Nether's does. We'll register a dimension that reuses overworld generation (so terrain actually appears), then build a walk-in portal block that ferries the player there and back.
The dimension class
A dimension is a class, and the smallest useful one is almost embarrassingly short. Extend OverworldDimension to inherit the overworld's chunk generator and biome source, store the serial id you're handed, and you're done:
package com.example.example_mod;
import net.minecraft.world.dimension.OverworldDimension;
public class ExampleDimension extends OverworldDimension {
public ExampleDimension(int serialId) {
this.id = serialId;
}
}The constructor takes one int, the serial id RetroAPI assigns when you register, and stores it in the inherited id field. That single number does a lot of work: it picks the DIM<serialId>/ save folder where this dimension's chunks live on disk, and it's what gets written into player NBT to remember which world a player is in. You don't choose the number; you receive it and stash it. That's the whole contract.
Registering it
Registration is one call in init(). The factory is the interesting part:
EXAMPLE_DIMENSION = RetroDimensions.register(id("example_dim"), ExampleDimension::new);
LOGGER.info("registered dimension {} (serial id {})",
EXAMPLE_DIMENSION.getId(), EXAMPLE_DIMENSION.getSerialId());The factory ExampleDimension::new is an IntFunction<Dimension>, give it the assigned serial id, get back a dimension. That's why the constructor takes an int: RetroAPI calls the factory with the serial id it picked, so the dimension is born already knowing its number. The logging line shows the two faces of a registered dimension: getId() is your stable namespaced identifier (example_mod:example_dim), and getSerialId() is the runtime number RetroAPI assigned. You name it; RetroAPI numbers it.
The portal block
A dimension you can't reach is just a folder on disk. The way in is a portal, and RetroAPI's CustomPortal interface makes a "walk into it" portal block out of a handful of overrides. Here it is in full:
package com.example.example_mod;
import com.periut.retroapi.achievement.RetroAchievements;
import com.periut.retroapi.dimension.CustomPortal;
import com.periut.retroapi.dimension.HasTeleportationManager;
import net.minecraft.block.Block;
import net.minecraft.block.material.Material;
import net.minecraft.entity.Entity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.util.math.Box;
import net.minecraft.world.World;
import net.minecraft.world.dimension.PortalForcer;
import net.ornithemc.osl.core.api.util.NamespacedIdentifier;
public class ExamplePortalBlock extends Block implements CustomPortal {
public ExamplePortalBlock(int id) {
super(id, Material.GLASS);
}
@Override
public Box getCollisionShape(World world, int x, int y, int z) {
return null; // no collision box, the player can walk into the block
}
@Override
public boolean isOpaque() {
return false; // don't suffocate/black-out the player standing inside it
}
@Override
public void onEntityCollision(World world, int x, int y, int z, Entity entity) {
if (entity instanceof PlayerEntity && entity.vehicle == null && entity.passenger == null) {
PlayerEntity player = (PlayerEntity) entity;
// A gameplay-triggered achievement grant (see ExampleAchievements).
if (!world.isRemote) {
RetroAchievements.grant(ExampleAchievements.DIMENSIONAL_TRAVELER, player);
}
// Standing in the portal: register ourselves as the thing that will switch
// the player's dimension once the (vanilla) portal timer fills up.
((HasTeleportationManager) player).setTeleportationManager(this);
player.tickPortalCooldown();
}
}
@Override
public NamespacedIdentifier getDimension(PlayerEntity player) {
// Where this portal leads. From the example dimension the same portal leads
// back to the overworld automatically (RetroAPI flips the travel direction).
return ExampleMod.EXAMPLE_DIMENSION.getId();
}
@Override
public PortalForcer getTravelAgent(PlayerEntity player) {
// Our own travel agent: finds or places a single Example Portal block on the
// ground at the destination, instead of building a vanilla nether portal frame.
return new ExamplePortalForcer();
}
}Walking through it, override by override
Five methods turn an ordinary block into a doorway, and each does one clear thing:
getCollisionShape returns null, the block has no solid box, so the player walks into it instead of bumping against it. That's what makes it a portal you step into rather than a wall you mine through.
isOpaque returns false, light passes through and, just as importantly, the player standing inside doesn't suffocate or black out. A walk-in block that you can't see out of would be a trap, not a portal.
onEntityCollision arms the portal. This fires every tick an entity is inside the block. It checks the entity is a player and isn't riding or carrying anything, no teleporting boats and their passengers into the void, then does two things. It registers the block as the player's teleportation manager via setTeleportationManager(this), and it ticks the portal cooldown with tickPortalCooldown(). This is precisely how vanilla Nether portals arm themselves: stand in it, a timer fills, and when it's full the manager switches your dimension. (It also grants the DIMENSIONAL_TRAVELER achievement from Chapter 9 on the way, a nice touch worth pointing at, guarded by !world.isRemote so it only fires on the authoritative side.)
getDimension names the destination. It returns the id of the dimension to travel to, here, EXAMPLE_DIMENSION.getId(). And travel flips: a player already in the example dimension who steps into the same portal is sent back to the overworld. RetroAPI reverses the direction automatically, mirroring exactly how the Nether portal you came through is also the one you leave through. This is verified RetroAPI behavior, not a hopeful description, the round trip works.
getTravelAgent finds or builds the landing. It returns a custom ExamplePortalForcer that ships with the showcase, not the stock class. Vanilla's PortalForcer searches for, and if it finds nothing, builds a full obsidian nether portal at the destination. Ours is gentler: it reuses an Example Portal block near the arrival point if one already exists, and otherwise places a single portal block on the ground and stands the player in it, no obsidian-portal building at all.
Here is that travel agent. The whole job is one method, moveToPortal, which RetroAPI calls in the destination world after the dimension switch:
public class ExamplePortalForcer extends PortalForcer {
private static final int SEARCH_RADIUS = 16;
@Override
public void moveToPortal(World world, Entity entity) {
int ex = MathHelper.floor(entity.x);
int ez = MathHelper.floor(entity.z);
// Reuse a portal placed on an earlier trip if one is nearby.
for (int x = ex - SEARCH_RADIUS; x <= ex + SEARCH_RADIUS; x++) {
for (int z = ez - SEARCH_RADIUS; z <= ez + SEARCH_RADIUS; z++) {
for (int y = 127; y >= 0; y--) {
if (world.getBlockId(x, y, z) == ExampleMod.EXAMPLE_PORTAL.id) {
entity.setPositionAndAngles(x + 0.5, y + 1.5, z + 0.5, entity.yaw, entity.pitch);
return;
}
}
}
}
// First arrival here: one portal block on the ground, player standing in it.
// getTopY gives the first air block above the terrain at this column.
int y = world.getTopY(ex, ez);
world.setBlock(ex, y, ez, ExampleMod.EXAMPLE_PORTAL.id);
entity.setPositionAndAngles(ex + 0.5, y + 1.5, ez + 0.5, entity.yaw, entity.pitch);
}
}CustomPortal has one more method with a sensible default: getDimensionScale(player), which returns 1.0, no coordinate scaling. The vanilla Nether overrides this to 8×, so one block in the Nether maps to eight in the Overworld. Override it on your portal if you want the same trick (a compact dimension that covers a vast overworld); leave it alone and the two worlds share coordinates one-to-one.
Registering the portal block
The portal is a normal block registration, with one shortcut worth noticing, it reuses the vanilla glass sprite rather than shipping its own texture:
// A walk-in portal block that sends the player to the dimension above.
// See ExamplePortalBlock, it implements RetroAPI's CustomPortal interface.
EXAMPLE_PORTAL = RetroBlockAccess.of(new ExamplePortalBlock(RetroBlockAccess.allocateId()))
.sounds(Block.GLASS_SOUND_GROUP)
.sprite(Block.GLASS.getTexture(0)) // reuse the vanilla glass texture
.register(id("example_portal"));The .sprite(Block.GLASS.getTexture(0)) call borrows glass's existing atlas sprite, so the portal is translucent and ready without a single new PNG, handy for prototyping, and a reminder that any vanilla texture is yours to reuse.
What RetroAPI does behind the scenes
The two classes above are short because RetroAPI is quietly handling the parts that are genuinely hard. Three of them are worth knowing about.
A stable serial id. Your dimension gets a runtime number that stays put, the DIM<serialId>/ folder doesn't move out from under saved chunks as you add or remove dimensions. That stability is what lets a world reload its other dimensions correctly across sessions.
A prepared spawn region. At server startup, RetroAPI prepares a spawn region for the dimension, so the first player to arrive doesn't fall through ungenerated chunks while terrain catches up. The world is ready before anyone walks into it.
Multiplayer id sync. Serial ids are a runtime detail of the server, so when a client joins, the server tells it which numbers map to which dimensions over the retroapi:dim_sync channel. The client learns the table on join and stays in agreement, the same sidecar-and-sync philosophy that keeps blocks, items, and entities portable applies to whole worlds.
Custom terrain, the honest version
This showcase reuses overworld generation, and that's a deliberate choice, not a limitation. ExampleDimension extends OverworldDimension precisely so that terrain generates immediately and the chapter can stay focused on the thing that's actually new, registering a dimension and walking into it through a portal. The moment you bring your own generator into the room, you're learning a second large subject (chunk generators, biome sources, surface builders) that has nothing to do with portals.
When you do want a world that looks different, the move is to extend Dimension directly (or override the generator hooks on whatever parent you pick) and supply your own chunk generator and biome source. The registration, the portal, the sidecar, the sync, none of that changes. You swap the generation half and keep everything you learned here. The Aether, the Twilight Forest, an end-game void dimension: they're all this same skeleton with a more ambitious createChunkGenerator bolted on.
You now have a world behind a doorway, a creature to put in it, machines to build there, and badges for getting there. The last piece is the one that ties multiplayer together: sending your own messages over the wire.