Entrypoints & sides
The retroapi entrypoint, its client and server halves, one rule about which classes meet which side, and why your textures are safe everywhere.
When the game starts, the loader knocks on the doors of your mod and walks through whichever ones it finds. Each door opens onto a different side of the game, and one of them opens at a very specific moment. Get them straight now and the rest of the guide reads like a list of things you do behind the first door.
You already met the entrypoints block in Set up a project's fabric.mod.json. Here it is again, wired the way every mod in this guide is wired:
"entrypoints": {
"retroapi": [
"com.example.example_mod.ExampleMod"
],
"retroapi-client": [
"com.example.example_mod.ExampleModClient"
],
"retroapi-server": [
"com.example.example_mod.ExampleModServer"
]
},| Entrypoint | Class & method | Runs on… |
|---|---|---|
retroapi | ExampleMod.initRetro() | both sides, client and dedicated server |
retroapi-client | ExampleModClient.initRetroClient() | the client only |
retroapi-server | ExampleModServer.initRetroServer() | a dedicated server only |
init / client-init / server-init | OSL's own doors, still there | same three sides, unordered (see below) |
Each entrypoint is an interface with exactly one method to implement: RetroModInitializer.initRetro(), RetroClientModInitializer.initRetroClient(), and RetroServerModInitializer.initRetroServer(). You may list several classes under one entrypoint, the loader calls each in turn, but one of each is plenty to start, and that's how the showcase is built.
Why retroapi and not init
OSL's init door still exists and still works. The difference is when it opens: the loader runs every mod's init in an unspecified order, so a mod built on RetroAPI can, and intermittently does, initialize before RetroAPI has finished building the platform its registration calls rely on. That's the root of a whole family of bugs that look like haunted hardware, most famously:
"My recipes just… don't exist. Sometimes some of them work. Relaunching the game sometimes fixes it. The count is right, though."
The retroapi door opens at exactly one moment, and RetroAPI chooses it. Everything you register from initRetro() is ordered:
- after RetroAPI's block and item registries, tag defaults and lang files are ready;
- after vanilla's order-sensitive
Block/Item/Statsstatic-init cycle has been entered from the safe side; - before RetroAPI's own registration events fire, so anything you register is visible to them;
- before recipes are sorted, so a recipe added here takes part in vanilla's shaped-before-shapeless ordering instead of being appended after it;
- before any world assigns ids, so your content is in the world's id map from the first save.
It also fires identically with and without StationAPI. RetroAPI's own block/item/achievement registration events deliberately do not fire when StationAPI is present, because StationAPI owns registration there. The retroapi entrypoint is the one registration hook that behaves the same in both worlds.
Migrating an existing mod is three lines of JSON and one method rename: "init" → "retroapi", implements ModInitializer → implements RetroModInitializer, public void init() → public void initRetro(). Same for the client and server halves. Nothing else changes, and you can keep an init entrypoint alongside for work that isn't RetroAPI registration (your own config loading, say).
The skeletons
Three small classes, each implementing one interface. This is the whole shape of them; the bodies come later.
import com.periut.retroapi.entrypoint.RetroModInitializer;
public class ExampleMod implements RetroModInitializer {
public static final String MOD_ID = "example_mod";
@Override
public void initRetro() {
LOGGER.info("initializing example mod!");
// … register everything here (see below) …
}
}import com.periut.retroapi.entrypoint.RetroClientModInitializer;
public class ExampleModClient implements RetroClientModInitializer {
@Override
public void initRetroClient() {
// renderers, GUI screens, particle factories, client packet listeners …
}
}import com.periut.retroapi.entrypoint.RetroServerModInitializer;
public class ExampleModServer implements RetroServerModInitializer {
@Override
public void initRetroServer() {
// server packet listeners, dedicated-server-only logic …
}
}If a mod throws while RetroAPI is running these, the crash names the mod and the entrypoint it failed in, rather than surfacing as an anonymous stack trace inside RetroAPI. Handy when three mods are in the pile.
The big idea: register everything in initRetro()
Here is the rule that shapes every later page: your content is registered in the common initRetro(), on both sides. Blocks, items, block entities, the dimension, the mob, achievements, recipes, world features, network channels, all of it goes through the one door both sides walk through, so both sides agree on what exists.
That makes ExampleMod.initRetro() the table of contents for the whole mod. Read it top to bottom and you've read the syllabus:
public void initRetro() {
LOGGER.info("initializing example mod!");
// blocks, Blocks
EXAMPLE_BLOCK = RetroBlockAccess.create(Material.STONE)
.strength(1.5f, 10.0f).texture(id("example_block")).register(id("example_block"));
// … SIDED_BLOCK, PIPE_BLOCK …
// block entities, Block entities & GUIs
RetroBlockEntities.register(id("counter"), ExampleCounterBlockEntity.class);
// … COUNTER_BLOCK, CRATE_BLOCK, FREEZER_BLOCK …
// items, Items
SUSPICIOUS_SUBSTANCE = RetroItemAccess.create()
.maxStackSize(64).texture(id("suspicious_substance")).register(id("suspicious_substance"));
// … JUMP_STICK …
// dimension, Dimensions & portals
EXAMPLE_DIMENSION = RetroDimensions.register(id("example_dim"), ExampleDimension::new);
// … EXAMPLE_PORTAL …
// entities, Entities
EXAMPLE_MOB = RetroEntities.register(ExampleEntity.ID, ExampleEntity.class)
.factory((MobFactory) ExampleEntity::new);
// recipes, Recipes & fuel (safe to call inline: this door opens before the sort)
registerRecipes();
// world features, Worldgen
RetroFeatures.ore(RUBY_ORE).size(6).count(8).heightRange(4, 32).register();
// achievements, Achievements
ExampleAchievements.register();
// networking, Networking
ExampleNetworking.registerChannels();
}Every line above is one of the pages that follow. Blocks takes the blocks, Items the items, Block entities & GUIs the block entities, Entities the mob, Achievements the achievements, Dimensions & portals the dimension and portal, Recipes & fuel the recipes, Networking the channels. Notice what's not here: nothing about how things look. Renderers and screens live behind the client door, that's the discipline we get to next.
Recipes are called inline here, which is new. Behind the old init door they had to go through RecipeRegistrationCallback, because vanilla's recipe list might not exist yet and your recipes would land after it was sorted. The retroapi door opens late enough that neither is true, so a plain method call works, and the callback still works too if you prefer it (or need to react to another mod's registrations). Network channels are opened here as well, because both sides must agree they exist; the listeners that use them are wired up per side. More on both in their chapters.
The thing about Beta 1.7.3: there is no integrated server
Modern Minecraft runs a hidden "integrated server" inside singleplayer, a real server thread the client talks to over an in-memory connection. Beta 1.7.3 has no such thing. Singleplayer is just the client, simulating the world directly, by itself.
This single fact reshapes how you think about sides:
- Singleplayer is the client. The world ticks, mobs spawn, blocks save, all inside the client process. So
client-initruns in singleplayer, andserver-initdoes not. - "Server" means a dedicated server, a separate, headless process with no window, launched with
runServeror a server jar. That's the only placeinitRetroServer()ever fires. world.isRemotetells you "am I a remote multiplayer client?" In singleplayer it isfalse, you are the authority, the same way a server is. It'strueonly on a client connected to a remote server, where the world is a shadow of the server's.
If you're coming from modern Fabric/Forge, retrain this reflex: "client-side" and "logical server-side" are not two threads in singleplayer here, they're the same code. Gameplay logic guarded by if (!world.isRemote) runs in singleplayer (good, it's the authority) and on a dedicated server, but is skipped on a remote multiplayer client.
Why your textures are safe on a server (the question everyone asks)
Look back at the initRetro() table of contents: it calls .texture(id("example_block")) and, in Blocks's sided block, RetroTextures.addBlockTexture(...). Those run on a dedicated server too, there's no window there, no GPU, no atlas. Does the server try to decode a PNG and fall over?
No. And this is by design. A texture call in common initRetro() is pure bookkeeping. It records a RetroTexture handle and reserves a sprite slot (a number ≥ 256), that's all. No file is opened, no pixels are read. The actual PNG decoding and atlas compositing happen client-side only, much later, when the texture atlas is being built for rendering.
That's exactly why texture registration does not need to move into client-init. The bookkeeping has to happen on both sides anyway, so that both sides assign the same sprite slots and stay in sync. The server keeps the ledger; only the client ever paints from it.
The shape of a RetroTexture: it carries a NamespacedIdentifier and a public int id (the reserved sprite index). On a server that id is a number nobody ever draws. On a client it's the live index into the atlas. Same object, same number, harmless either way. Full story in Textures, names & files.
Classloading discipline: the rule that prevents crashes
Here is the one rule that, if you keep it, you'll almost never see a sided crash:
Never reference a client-only class from code that loads on a server, and never reference a server-only class from code that loads on a client.
"Client-only" means anything under net.minecraft.client.*, screens, renderers, models, the Minecraft class itself, plus client networking like ClientPlayNetworking. "Server-only" means ServerPlayerEntity, ServerPlayNetworking, MinecraftServer, and friends. A dedicated server jar simply does not contain the client classes; touching one from server-loaded code throws NoClassDefFoundError the instant the JVM tries to load the referencing class, often before your method even runs.
The fix isn't to scatter if checks. It's to keep dangerous classes physically out of reach of the wrong side, by which class mentions which. The showcase does this cleanly.
Client-only stays behind the client door
ExampleCrateScreen extends a net.minecraft.client class, so it must never be classloaded on a server. It isn't, it's only ever named inside a lambda registered in ExampleModClient, and lambdas don't load their bodies' classes until they actually run (which, on a server, is never):
RetroGuiRegistry.register(ExampleMod.id("crate"), new RetroGuiHandler(
(player, inventory) -> new ExampleCrateScreen(player.inventory, (ExampleCrateBlockEntity) inventory),
ExampleCrateBlockEntity::new));The common initRetro() never types the word ExampleCrateScreen. A server loads ExampleMod happily, because nothing in it points at a client class.
Server-only stays behind the server door
Symmetrically, ServerPlayerEntity appears only in ExampleModServer and in a mixin listed under "server" (its ServerPlayerTickMixin). The client never loads either:
import net.minecraft.entity.player.ServerPlayerEntity;
import net.ornithemc.osl.networking.api.server.ServerPlayNetworking;
public class ExampleModServer implements RetroServerModInitializer {
public static void welcomeOnceReady(ServerPlayerEntity player) {
// … server-only types live here, and only here …
ServerPlayNetworking.send(player, ExampleNetworking.WELCOME, buf ->
buf.writeString("Welcome to the server, " + player.name + "!"));
}
}Mixins obey the same rule
A mixin is just another class that gets loaded onto whatever side it's listed under, so the mixin config splits its entries into three lists for exactly this reason. A client-only mixin (one that @Mixin-targets a client class) goes under "client"; a server-only one under "server"; everything common under "mixins":
{
"required": true,
"minVersion": "0.8",
"package": "com.example.example_mod.mixin",
"compatibilityLevel": "JAVA_8",
"mixins": [
"LivingEntityJumpMixin",
// … common mixins, loaded on both sides …
],
"client": [
"PlayerTickMixin",
// … client-only mixins …
],
"server": [
"ServerPlayerTickMixin"
],
"injectors": {
"defaultRequire": 1
}
}Put a mixin that touches a client class in the "client" list and a dedicated server never tries to load it. Same idea as the entrypoints, same payoff: the wrong class never reaches the wrong side. The full mixin tour, all twelve graded examples, is Mixins.
Rule of thumb. Common code may freely mention shared classes (Block, Item, World, PlayerEntity, your own block/item classes). The moment a type is named ...client... or is a Server*, ask "which door does this belong behind?" and put the class that mentions it there.
Four doors, one ledger shared between them. Next we walk through the first door's quietest job: turning PNGs and text files into sprites, names, and sounds.