Skip to Content
DocsSystemsArchitecture

Design Principles

Better Ecology follows these architectural principles:

  1. Single source of truth: All behavior configuration lives in YAML/JSON profiles
  2. Minimal mixins: One Mob mixin plus one Animal mixin for all entities
  3. Handle-based systems: Each subsystem (hunger, flocking, fleeing) is an independent handle
  4. Performance-first: Parse once, cache aggressively, avoid per-tick heavy work
  5. Hot-reloadable: Configuration changes apply without restarting the game

System Architecture

The mod is organized into several interconnected systems:

┌─────────────────────────────────────────────────────────────┐ │ Data Layer │ │ ┌───────────┐ ┌────────────┐ ┌──────────────┐ │ │ │Templates │→ │Archetypes │→ │Mob Profiles │ │ │ │(YAML) │ │(YAML) │ │(JSON) │ │ │ └───────────┘ └────────────┘ └──────────────┘ │ └────────────────────────┬────────────────────────────────────┘ ↓ Reload ┌─────────────────────────────────────────────────────────────┐ │ Configuration System │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ EcologyProfileLoader │ │ │ │ - Reads YAML/JSON files │ │ │ │ - Merges templates → archetypes → profiles │ │ │ │ - Validates configuration │ │ │ └──────────────────┬───────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ EcologyProfileRegistry │ │ │ │ - Stores merged profiles by mob type │ │ │ │ - Caches handle instances │ │ │ │ - Increments generation counter on reload │ │ │ └──────────────────┬───────────────────────────────────┘ │ └────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ Runtime System │ │ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Mixins │→ │EcologyHooks │→ │ Handles │ │ │ │ (2 total) │ │ │ │(Hunger, etc) │ │ │ └────────────┘ └──────────────┘ └──────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ EcologyComponent (per entity) │ │ │ │ - Stores handle references │ │ │ │ - Caches configuration data │ │ │ │ - Manages goal registration │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ Minecraft Entity │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Mob Entity (Sheep, Cow, Pig, etc.) │ │ │ │ - Runs goals added by handles │ │ │ │ - Ticks behaviors every frame │ │ │ │ - Persists data via NBT │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘

Data Flow

Configuration Loading Flow

This diagram shows how configuration is loaded and applied to entities:

1. Server/Client Start 2. EcologyBootstrap.initialize() ├─→ Register handles (HungerHandle, FlockingHandle, etc.) └─→ Register resource reload listener 3. Datapack Reload (or /reload command) 4. EcologyResourceReloader.reload() ├─→ EcologyProfileLoader.loadProfiles() │ ├─→ Load base template (templates/mod_registry.yaml) │ ├─→ Load archetypes (archetypes/**/*.yaml) │ ├─→ Load mob profiles (mobs/**/*.json) │ └─→ Merge: template → archetypes → profile └─→ EcologyProfileRegistry.reload(profiles) ├─→ Store profiles by mob type ├─→ Resolve handles for each profile └─→ Increment generation counter 5. Entity Spawns or Ticks 6. MobEcologyMixin injects hooks 7. EcologyHooks.onRegisterGoals(mob) ├─→ Get or create EcologyComponent for mob ├─→ Look up profile for mob type ├─→ Get handles that support this profile └─→ Each handle.registerGoals(mob, goalSelector) 8. Goals run on entity tick

Entity Lifecycle Flow

When an entity is created and managed:

Entity Creation MobEcologyMixin.<init> ├─→ Create EcologyComponent └─→ Attach to entity via EcologyAccess interface MobEcologyMixin.registerGoals() [after super call] EcologyHooks.onRegisterGoals(mob) ├─→ Check if already registered (idempotent) ├─→ Look up EcologyProfile for mob type └─→ For each handle that supports profile: └─→ handle.registerGoals(mob, goalSelector, targetSelector) Every Tick MobEcologyMixin.tick() EcologyHooks.onTick(mob) └─→ For each handle: └─→ handle.tick(mob) Save to Disk MobEcologyMixin.addAdditionalSaveData(tag) EcologyHooks.onSave(mob, tag) └─→ For each handle: └─→ handle.writeNbt(mob, tag) Load from Disk MobEcologyMixin.readAdditionalSaveData(tag) EcologyHooks.onLoad(mob, tag) └─→ For each handle: └─→ handle.readNbt(mob, tag)

Component Pattern

Better Ecology uses a component-based architecture where behaviors are modular and composable.

EcologyComponent

Each mob has an attached EcologyComponent that stores per-entity state:

// Accessing the component (via mixin interface) if (mob instanceof EcologyAccess access) { EcologyComponent component = access.getEcologyComponent(); // Component stores: // - Reference to the mob's EcologyProfile // - List of active handles // - Goal registration flag // - Per-handle cached data }

Data Attachment Pattern

The mod uses Fabric’s Attachment API for persistent data:

// Example: Hunger system using attachments public class AnimalNeeds { // Register attachment type public static final AttachmentType<Float> HUNGER_ATTACHMENT = AttachmentRegistry.create( ResourceLocation.fromNamespaceAndPath("better-ecology", "hunger"), builder -> builder .initializer(() -> 80f) // Default value .persistent(Codec.FLOAT) // Auto-save to NBT .copyOnDeath() // Preserve on respawn ); // Read hunger value public static float getHunger(Mob mob) { return mob.getAttachedOrCreate(HUNGER_ATTACHMENT); } // Write hunger value public static void setHunger(Mob mob, float value) { mob.setAttached(HUNGER_ATTACHMENT, Math.clamp(value, 0f, 100f)); } }

Key benefits:

  • Automatic NBT serialization
  • Type-safe access
  • No manual save/load code
  • Garbage-collected when entity is removed

Profile Merge System

Configuration is merged hierarchically using EcologyMerge:

// Merge order (later overrides earlier) Map<String, Object> base = loadTemplate("mod_registry.yaml"); Map<String, Object> archetype1 = loadArchetype("passive/grazer.yaml"); Map<String, Object> archetype2 = loadArchetype("herd/diurnal.yaml"); Map<String, Object> profile = loadProfile("mobs/passive/sheep.json"); // Deep merge with special rules Map<String, Object> merged = EcologyMerge.merge( base, archetype1, archetype2, profile ); // Merge rules: // - null values do not override // - Empty lists do not override // - Non-empty lists replace completely // - Maps are merged recursively

Example merge:

# Base template hunger: enabled: true max: 100 decay_rate: 0.01 # Archetype (passive/grazer) hunger: decay_rate: 0.015 # Overrides base foraging: enabled: true # Adds new section # Mob profile (sheep.json) hunger: starting: 85 # Adds to merged hunger config # decay_rate stays 0.015 from archetype

Result:

hunger: enabled: true # from base max: 100 # from base decay_rate: 0.015 # from archetype (overrode base) starting: 85 # from profile foraging: enabled: true # from archetype

Handle System

Handles are the core abstraction for behavior subsystems.

Handle Interface

Every subsystem implements the EcologyHandle interface:

public interface EcologyHandle { /** * Returns true if this handle supports the given profile. * Checks for required configuration keys. */ boolean supports(EcologyProfile profile); /** * Register goals for this handle. * Called once during entity initialization. */ default void registerGoals(Mob mob, GoalSelector goals, GoalSelector targets) {} /** * Called every tick for entities with this handle active. */ default void tick(Mob mob) {} /** * Serialize handle state to NBT. */ default void writeNbt(Mob mob, CompoundTag tag) {} /** * Deserialize handle state from NBT. */ default void readNbt(Mob mob, CompoundTag tag) {} /** * Override vanilla food item checks. */ default boolean overrideIsFood(Mob mob, ItemStack stack) { return false; } }

Example: Hunger Handle

Here’s how a simplified hunger handle would be implemented:

public class HungerHandle implements EcologyHandle { @Override public boolean supports(EcologyProfile profile) { // Only enable if hunger config exists and is enabled return profile.has("hunger.enabled") && profile.getBool("hunger.enabled"); } @Override public void registerGoals(Mob mob, GoalSelector goals, GoalSelector targets) { // Add hunger-driven foraging goal float hungerThreshold = profile.getFloat("hunger.threshold", 50f); goals.addGoal(5, new SeekFoodGoal(mob, hungerThreshold)); } @Override public void tick(Mob mob) { // Decay hunger every tick float decayRate = profile.getFloat("hunger.decay_rate", 0.01f); AnimalNeeds.decayHunger(mob, decayRate); // Apply starvation damage if needed if (AnimalNeeds.isStarving(mob)) { float damage = profile.getFloat("hunger.starvation_damage", 1f); if (AnimalNeeds.canTakeDamage(mob, 100)) { // Every 5 seconds mob.hurt(mob.damageSources().starve(), damage); AnimalNeeds.setLastDamageTick(mob, mob.level().getGameTime()); } } } // NBT is handled by attachment API, no need to implement }

Handle Registration

Handles are registered during mod initialization:

public class EcologyBootstrap { public static void initialize() { // Register handles EcologyHandleRegistry.register("hunger", new HungerHandle()); EcologyHandleRegistry.register("flocking", new FlockingHandle()); EcologyHandleRegistry.register("fleeing", new FleeingHandle()); EcologyHandleRegistry.register("diet", new DietHandle()); // Register resource reload listener ResourceManagerHelper.get(PackType.SERVER_DATA) .registerReloadListener(new EcologyResourceReloader()); } }

Mixin Strategy

Better Ecology uses only two mixins to inject into all mob types.

MobEcologyMixin

Hooks into the base Mob class to handle all entities:

@Mixin(Mob.class) public class MobEcologyMixin implements EcologyAccess { @Unique private EcologyComponent ecologyComponent; // Create component during construction @Inject(method = "<init>", at = @At("TAIL")) private void onInit(CallbackInfo ci) { this.ecologyComponent = new EcologyComponent((Mob)(Object)this); } // Hook after goal registration @Inject(method = "registerGoals", at = @At("TAIL")) private void afterRegisterGoals(CallbackInfo ci) { EcologyHooks.onRegisterGoals((Mob)(Object)this); } // Hook every tick @Inject(method = "tick", at = @At("TAIL")) private void onTick(CallbackInfo ci) { EcologyHooks.onTick((Mob)(Object)this); } // Hook NBT save @Inject(method = "addAdditionalSaveData", at = @At("TAIL")) private void onSave(CompoundTag tag, CallbackInfo ci) { EcologyHooks.onSave((Mob)(Object)this, tag); } // Hook NBT load @Inject(method = "readAdditionalSaveData", at = @At("TAIL")) private void onLoad(CompoundTag tag, CallbackInfo ci) { EcologyHooks.onLoad((Mob)(Object)this, tag); } @Override public EcologyComponent getEcologyComponent() { return this.ecologyComponent; } }

Why this works:

  • All entities inherit from Mob class
  • Single mixin covers sheep, cows, wolves, etc.
  • No need for per-entity mixins
  • Compatible with other mods that extend Mob

AnimalEcologyMixin

Redirects food item checks for breeding:

@Mixin(Animal.class) public class AnimalEcologyMixin { // Redirect isFood() check during player interaction @Redirect( method = "mobInteract", at = @At( value = "INVOKE", target = "Lnet/minecraft/world/entity/animal/Animal;isFood(Lnet/minecraft/world/item/ItemStack;)Z" ) ) private boolean redirectIsFood(Animal animal, ItemStack stack) { return EcologyHooks.overrideIsFood(animal, stack); } }

This allows handles to define custom diets without hardcoding items.

NBT Data Format

Entity data is stored under a single root key to avoid conflicts:

Entity NBT Structure: { "BetterEcology": { "hunger": <float>, # Attachment-managed "thirst": <float>, # Attachment-managed "last_damage_tick": <long>, # Attachment-managed "initialized": <boolean> # Attachment-managed } }

Since the mod uses Fabric’s Attachment API, all NBT serialization is automatic. You don’t need to manually write NBT handling code.

Performance Optimizations

Parse Once, Cache Forever

Configuration is parsed during /reload and cached:

// Bad: Parse every tick public void tick(Mob mob) { float threshold = parseYaml("hunger.threshold"); // Slow! } // Good: Cache at reload private float hungerThreshold; public void initialize(EcologyProfile profile) { this.hungerThreshold = profile.getFloat("hunger.threshold", 50f); } public void tick(Mob mob) { float hunger = AnimalNeeds.getHunger(mob); if (hunger < hungerThreshold) { // Fast lookup // ... } }

Interval-Based Updates

Expensive checks run on intervals:

@Override public void tick(Mob mob) { // Only update every 20 ticks (1 second) if (mob.tickCount % 20 == 0) { updateExpensiveCalculation(mob); } // Cheap checks can run every tick if (AnimalNeeds.isHungry(mob)) { // ... } }

Spatial Partitioning

Neighbor queries use spatial indexing:

// Bad: Check all entities for (Entity e : level.getAllEntities()) { // O(n) if (e.distanceTo(mob) < radius) { // ... } } // Good: Use spatial index List<Entity> nearby = spatialIndex.getNeighbors( mob.position(), radius, mob.getType() ); // O(log n) or O(1) with grid

Key Classes Reference

ClassPackagePurpose
BetterEcologyme.javavirtualenvMain mod entry point
AnimalNeedsbehavior.coreHunger/thirst attachment utilities
EcologyHooksecology.coreCentralized mixin dispatch
MobEcologyMixinmixinMain mixin for all mobs
AnimalEcologyMixinmixinFood override for animals
SeekFoodGoalbehavior.coreForaging AI goal
FleeFromPredatorGoalbehavior.coreFleeing AI goal
HerdCohesionGoalbehavior.coreHerding AI goal

Dependencies

DependencyPurposeRequired
Fabric APIMod loader frameworkYes
Fabric Attachments APIPersistent entity dataYes
Minecraft 1.21.1Game versionYes
Java 21+Runtime environmentYes

See Also

Last updated on