Skip to content

Architecture Overview

Brennan Hatton edited this page Jun 7, 2026 · 1 revision

Architecture Overview

Read this before adding any registered content (entities, items, menus, attributes) or trying to share code across loaders. It explains the one non-obvious decision the whole codebase is shaped around.


The common + loader split

PlayerMob is an Architectury project with four Gradle modules:

Module Role
common All gameplay logic. Compiles against vanilla Minecraft + the Fabric loader's mixin/annotation deps only.
fabric Fabric ModInitializer entrypoint + client.
forge Forge @Mod entrypoint + client.
neoforge NeoForge @Mod entrypoint + client + optional Dungeon Train integration.

The entity, AI goals, personality system, skin system, and menus all live in common. The loader modules are thin — they register things and back-fill shared references, then hand off to common.


Why registration is per-loader (the important part)

Normally Architectury's API runtime abstracts registration so you write it once in common. PlayerMob does not use the Architectury API runtime, because Architectury API 13.x (the 1.21 line) ships Fabric + NeoForge only — Forge was dropped upstream. To keep all three loaders alive, registration is performed in each loader's entry class with that loader's native API:

Fabric Forge NeoForge
Registry calls Registry.register(...) DeferredRegister DeferredRegister
Attributes FabricDefaultAttributeRegistry EntityAttributeCreationEvent EntityAttributeCreationEvent
Creative tab ItemGroupEvents BuildCreativeModeTabContentsEvent BuildCreativeModeTabContentsEvent
Menu type ExtendedScreenHandlerType IForgeMenuType IMenuTypeExtension

This mirrors the AdventureItemNames pattern. The three entry classes are nearly identical apart from these loader-flavoured calls:

Implication for you: to add a new registered object, you must register it in all three entry classes (or behind a single shared factory like entityTypeBuilder() below), then store the result somewhere common can read it.


The static-holder + back-fill pattern

Because common can't perform registration but still needs the registered EntityType, Item, and MenuType (for goal predicates, the renderer, spawn-egg data, etc.), the resulting references are parked on a shared holder: PlayerMobRegistry.

public final class PlayerMobRegistry {
    public static final ResourceLocation PLAYER_MOB_ID = /* playermob:player_mob */;

    // Set once by each loader entry on boot; read everywhere in common.
    public static EntityType<PlayerMobEntity> PLAYER_MOB;
    public static Item                        PLAYER_MOB_SPAWN_EGG;
    public static MenuType<PlayerMobMenu>     PLAYER_MOB_MENU;
    public static PlayerMobMenuOpener         MENU_OPENER;

    // The single EntityType.Builder every loader uses, so size / category /
    // tracking range stay identical across loaders.
    public static EntityType.Builder<PlayerMobEntity> entityTypeBuilder() { ... }
}

Each loader entry registers its objects, assigns them onto these static fields, then calls PlayerMob.init() — the shared post-registration landing point.

Boot-order caveat. These fields are null until the loader's boot assigns them. On Fabric they're set inline in onInitialize; on Forge/NeoForge they're back-filled in FMLCommonSetupEvent (after every DeferredRegister has run). Any common code that reads them must run after boot — never from a static initializer that could fire earlier.

The same back-fill trick powers two extension seams:

  • MENU_OPENER (PlayerMobMenuOpener) — opens the inventory menu with a loader-specific call.
  • TrainConfinement.install(...) — installs an optional cross-mod integration.

Both are documented in Soft-Dependency Integrations.


Client / server split

The renderer is client-only (PlayerMobRenderer, annotated @Environment(CLIENT)). To avoid accidentally classloading it on a dedicated server, constants the server needs are defined on the entity, not the renderer — e.g. PlayerMobEntity.SKIN_COUNT is public so server-side finalizeSpawn can roll a skin index without touching the renderer. Keep this in mind when adding shared constants: if the server reads it, it doesn't belong on a client-only class.

Client registration (entity renderer, model layer, menu screen) happens in each loader's *Client entrypoint.


Mixins

A mixin config is wired up and ready but currently empty: playermob.mixins.json points at the package games.brennan.playermob.mixin with empty mixins, client, and server lists. The config is referenced from fabric.mod.json, neoforge.mods.toml, and mods.toml, so if you need a vanilla-class hook, drop a mixin into that package and add it to the config — no new wiring required.

PlayerMob deliberately ships zero mixins today; all behaviour is achieved with vanilla entity AI, overrides, and events.


Where to go next

Clone this wiki locally