Chapter 8, The Aerbunny

The mob in Chapter 7 just stood there. This one rides on your head, glides you safely down, breeds, panics and flees when struck, and grabs on for dear life. To make it work you'll confront real entity AI, the client/server render-state handshake, and honest input networking all at once.

Chapter 7 built the simplest possible mob: extend LivingEntity, pick a skin, refuse to despawn. It had no brain, and on purpose, the lesson there was the spawn protocol, not behaviour. This chapter is its sequel. We're going to build the showcase's most complex creature, the Aerbunny, a full port of the floaty puffball from the Aether for Beta 1.7.3, and every problem Chapter 7 let us skip comes due here.

An Aerbunny wanders like a passive animal, then layers five behaviours on top. It rides on the player's head when you right-click it and slow-falls them, a glide, with a soft upward boost if they hold jump in the air. It breeds, with a population cap. It panics when a player hits it and pathfinds away. It can be grabbed and held, and when a grabbed one lands it makes nearby monsters turn on it. And it puffs up and tilts as it floats, render state the server computes and must publish to every client. No single piece is hard. Together they teach the three things a living entity actually needs that a static mob never does: an AI base, a render-state channel, and input networking. We'll take them one at a time.

This is a long chapter because it is a complete, reproducible build. By the end you'll have written eleven files, and the last section lists every one so you can check your work. Let's start with the class.

The class and its brain

Chapter 7's mob extended LivingEntity and stood still. The Aerbunny extends AnimalEntity, beta's base class for passive, wandering creatures, the same parent as the cow and the pig. That single choice hands us a working brain for free: idle wandering, looking around, fleeing in a generic way, the whole vanilla passive AI. We then override its tick hooks to add everything the Aether bunny does on top.

src/main/java/com/example/example_mod/AerbunnyEntity.java
public class AerbunnyEntity extends AnimalEntity implements RetroMobSpawnData {

	public static final NamespacedIdentifier ID = ExampleMod.id("aerbunny");

	/** DataTracker slots. 16 holds a packed STATE byte, 17/18 hold floats as bits. */
	private static final int STATE_SLOT = 16;
	private static final int PUFFINESS_SLOT = 17;
	private static final int VELOCITY_Y_SLOT = 18;

	public int age;
	public int mate;
	public boolean grab;
	public boolean fear;
	public boolean gotRider;
	public Entity runFrom;
	/** Local (server-authoritative) puffiness, 0..~1.15, decays each tick. */
	public float puffiness;
	/** The rider's jump key, fed in from the client (mount mixin) or the jump packet. */
	public boolean vehicleJumping;

Read those fields like a behaviour list, because that's what they are. age and mate are the breeding counters. grab and fear are the grabbed and panic flags. gotRider remembers across a save that a player was carrying this bunny. runFrom is whoever it's currently fleeing. puffiness is the render-only puff scale, and vehicleJumping is the rider's jump key, which, as we'll see, has to travel over the network to get here on a dedicated server.

The constructor sets the basics and, importantly, the texture, a real PNG you'll ship at the end of this chapter:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	public AerbunnyEntity(World world) {
		super(world);
		this.movementSpeed = 2.5F;
		this.texture = "/assets/example_mod/textures/mobs/aerbunny.png";
		this.standingEyeHeight = -0.16F;
		// Match the model's footprint so it sits ON the ground, not hovering above it.
		this.setBoundingBoxSpacing(0.4F, 0.4F);
		this.health = 6;
		// Render far: a floaty mob is easy to lose track of up close.
		if (this.renderDistanceMultiplier < 5.0) {
			this.renderDistanceMultiplier = 5.0;
		}
		this.age = this.random.nextInt(64);
		this.mate = 0;
	}

Two tick hooks carry all the behaviour, and the difference between them matters. tick() runs every tick for every entity, alive or not; the Aerbunny uses it for breeding bookkeeping and for the puffiness decay. tickLiving() runs inside the living-entity update, after the AI has decided where to move; that's where the hop, the glide, the panic, and the grab-landing live. Throughout, this.world.isRemote is the fork in the road: false on the authoritative side (a dedicated server, or the singleplayer client which is the world), true on a remote client that only watches. Keep that distinction in your head, the whole chapter turns on it.

Render state over the wire

Here is the problem a static mob never has. The Aerbunny's puff and tilt are computed from physics, vertical velocity, whether it's puffing, that only ever runs on the authoritative side. A remote client never simulates the bunny; it only knows position and rotation from the entity tracker. So how does a distant player see the same puff at the same instant the owner does? The answer is the DataTracker, the small bag of synced values the engine keeps in step automatically. We claim three slots in it.

src/main/java/com/example/example_mod/AerbunnyEntity.java
	@Override
	protected void initDataTracker() {
		super.initDataTracker();
		this.dataTracker.startTracking(STATE_SLOT, (byte) 0);
		this.dataTracker.startTracking(PUFFINESS_SLOT, 0);    // float bits
		this.dataTracker.startTracking(VELOCITY_Y_SLOT, 0);   // float bits
	}

Slots below 16 belong to the engine (on fire, sneaking, and friends); 16 and up are yours. Slot 16 holds a packed state byte: bit 1 is "on the ground," bit 2 is "ridden by a player," bit 4 is "grabbed." Slots 17 and 18 each hold a float, but the tracker only carries ints in those slots, so we store the float's raw bits with Float.floatToIntBits and read them back with Float.intBitsToFloat. Slot 17 is the puffiness, slot 18 is the vertical velocity. Once per tick, on the authoritative side, we publish all three:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	/** Pack everything the renderer/other clients need into the tracker, once per tick. */
	private void syncState() {
		byte state = 0;
		if (this.onGround) state |= 1;
		if (this.vehicle instanceof PlayerEntity) state |= 2;
		if (this.grab) state |= 4;
		this.dataTracker.set(STATE_SLOT, state);
		this.dataTracker.set(PUFFINESS_SLOT, Float.floatToIntBits(this.puffiness));
		this.dataTracker.set(VELOCITY_Y_SLOT, Float.floatToIntBits((float) this.velocityY));
	}

This is the publish/trust split. The server publishes; the client trusts. Two small reader methods make the trust side honest, each branching on isRemote so the same method works on both sides:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	public boolean isRidingPlayer() {
		if (this.world.isRemote) {
			return (this.dataTracker.getByte(STATE_SLOT) & 2) != 0;
		}
		return this.vehicle instanceof PlayerEntity;
	}

	/** The synced vertical velocity the renderer tilts by (client reads the tracker). */
	public float getSyncedVelocityY() {
		return Float.intBitsToFloat(this.dataTracker.getInt(VELOCITY_Y_SLOT));
	}

On the authoritative side isRidingPlayer() checks the real vehicle field; on a remote client there is no live vehicle physics to consult, so it reads the synced bit instead. And getSyncedVelocityY() only ever reads the tracker, because the tilt must match whatever the server published regardless of who's watching. The model's puff and the renderer's tilt both read these synced values, never a locally simulated field. That's the entire client/server render lesson the simpler mob couldn't teach, and we'll see the draw side pay it off later.

One subtlety on puffiness, because it shows the seam between publish and trust. The server simply decays it. The client trusts the synced value, but snaps up with a particle burst the instant the server triggers a puff, then decays at the same rate locally between updates so it looks smooth:

src/main/java/com/example/example_mod/AerbunnyEntity.java, tick()
			// Server: puffiness simply decays.
			this.puffiness = Math.max(0.0F, this.puffiness - 0.1F);
			syncState();
		} else {
			// Client: trust the synced puffiness, snapping up (with a particle burst) when
			// the server triggers a puff, and decaying locally at the same rate between updates.
			float serverPuff = Float.intBitsToFloat(this.dataTracker.getInt(PUFFINESS_SLOT));
			if (serverPuff > this.puffiness + 0.2F) {
				this.puffiness = serverPuff;
				this.cloudPoop();
			} else {
				this.puffiness = Math.max(0.0F, this.puffiness - 0.1F);
			}
		}

The DataTracker is a tiny, fixed bag, integers, bytes, a handful of values. It is exactly right for render state that changes every tick: it rides the entity tracker automatically, no channel to register, no listener to write. Reach for a custom packet (Chapter 12) only when you need something the tracker can't carry, which, as we're about to see with the jump key, does happen.

Riding the player

Right-click the bunny and it climbs onto your head. The hook is interact, and the authoritative side does the work, sets the rider, plays a lift sound, gives it a little launch velocity:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	/** Right-click to pick up: the bunny mounts the player's head. */
	@Override
	public boolean interact(PlayerEntity player) {
		if (this.world.isRemote) {
			return true;
		}
		this.yaw = player.yaw;
		this.setVehicle(player);
		if (this.vehicle == null) {
			this.grab = true; // could not mount (player already has a passenger): hold instead
		} else {
			this.world.playSound(this, "example_mod:mobs.aerbunny.aerbunnylift", 1.0F,
				(this.random.nextFloat() - this.random.nextFloat()) * 0.2F + 1.0F);
		}

If the player already has a passenger, setVehicle leaves vehicle null and the bunny falls back to being grabbed (held in place) instead of riding. Otherwise it's now on your head. But beta does not auto-send mount packets to the player's client, so on a dedicated server we have to send one by hand. And that send drags in a class-loading trap nasty enough to earn its own subsection.

The class-loading trap, and the MountSync helper

The packet we need to send names ServerPlayerEntity. That class exists only on a dedicated server; the client class loader doesn't have it and refuses to load any class that references it. Now look at where we are: interact is common code. On a dedicated server it runs server-side, fine. But in singleplayer the one world is client-side, so interact also runs on the client, where isRemote is false. If this common method named ServerPlayerEntity directly, even inside a branch it never takes, the JVM would have to resolve that class the moment it loaded the method, and the singleplayer client would crash on the spot.

The fix is two-layered. First, the call site checks the class by name as a string, not with instanceof (which would itself name the type), and routes the actual send through a separate helper:

src/main/java/com/example/example_mod/AerbunnyEntity.java, interact()
		// Beta does not auto-send mount packets, so tell the player's client about it. The
		// string class-name check (not instanceof) keeps the server-only ServerPlayerEntity
		// reference out of this common method, which also runs on the singleplayer client; the
		// actual send lives in MountSync so the class is only loaded server-side. See MountSync.
		if (player.getClass().getName().endsWith("ServerPlayerEntity")) {
			MountSync.send(player, this, this.vehicle);
		}

Second, the ServerPlayerEntity reference lives alone in MountSync, a class that only loads when send is actually called, which only happens server-side because of the string check above. Read its javadoc; it states the rule plainly:

src/main/java/com/example/example_mod/MountSync.java
public final class MountSync {

	private MountSync() {
	}

	public static void send(PlayerEntity player, Entity rider, Entity vehicle) {
		((ServerPlayerEntity) player).networkHandler.sendPacket(new EntityVehicleSetS2CPacket(rider, vehicle));
	}
}

A server-only class must never be named in common code that also runs on the singleplayer client. The JVM resolves every type a method references the moment it verifies that method, so even an untaken branch that mentions ServerPlayerEntity crashes the client loader. The cure is the two-part pattern here: detect the server with a string class-name check (getClass().getName().endsWith("ServerPlayerEntity")) so no type is named, and isolate the actual server-only reference in its own helper class (MountSync) that only loads when called. This is the same sided-loading discipline that keeps LivingEntityRenderer out of the common entrypoint in Chapter 7, applied in the other direction.

The rest of interact tidies the bunny for the ride and hands it off:

src/main/java/com/example/example_mod/AerbunnyEntity.java, interact()
		this.jumping = false;
		this.forwardSpeed = 0.0F;
		this.sidewaysSpeed = 0.0F;
		this.setPath(null);
		this.velocityX = player.velocityX * 5.0;
		this.velocityY = player.velocityY / 2.0 + 0.5;
		this.velocityZ = player.velocityZ * 5.0;
		return true;
	}

One last touch lets a ridden bunny survive a save. writeNbt flags gotRider if a passenger was present, and on load tick() re-mounts the nearest player:

src/main/java/com/example/example_mod/AerbunnyEntity.java, tick()
			// A bunny saved while ridden re-mounts the nearest player on load.
			if (this.gotRider) {
				this.gotRider = false;
				if (this.vehicle == null) {
					PlayerEntity player = (PlayerEntity) this.findPlayerToRunFrom();
					if (player != null && this.getDistance(player) < 2.0F && player.passenger == null) {
						this.setVehicle(player);
					}
				}
			}

The glide, and the networking it needs

A ridden bunny slow-falls its rider, that's the glide, and holding jump in the air gives a soft upward boost. The slow-fall logic lives in tickLiving, on the authoritative side only, because the riding client predicts it locally and we must never apply it twice:

src/main/java/com/example/example_mod/AerbunnyEntity.java, tickLiving()
		} else if (this.vehicle != null) {
			// Riding a player: glide them. Authoritative side only (the riding client predicts
			// this in AerbunnyMountClientMixin), so it is never applied twice.
			if (!this.world.isRemote) {
				if (this.vehicle.dead) {
					this.setVehicle(null);
				} else if (!this.vehicle.onGround && !this.vehicle.checkWaterCollisions()) {
					((EntityFallDistanceAccessor) this.vehicle).setFallDistance(0.0F);
					this.vehicle.velocityY += 0.05;
					if (this.vehicle.velocityY < -0.225 && this.vehicle instanceof LivingEntity && this.vehicleJumping) {
						this.vehicle.velocityY = 0.125; // jump-while-gliding boost
						this.cloudPoop();
						this.puffiness = 1.15F;
					}
				}
			}
		}

Two things here cross entity boundaries, and both need help. The bunny zeroes its rider's fallDistance every tick so the player never takes fall damage when they finally land, and it reads this.vehicleJumping to fire the boost. A private field on a different entity (the rider) can't be touched directly; you reach it through an accessor mixin. Here is the one for fall distance:

src/main/java/com/example/example_mod/mixin/EntityFallDistanceAccessor.java
@Mixin(Entity.class)
public interface EntityFallDistanceAccessor {

	@Accessor("fallDistance")
	void setFallDistance(float fallDistance);
}

And the companion that reads the rider's jump flag, the private jumping boolean that's true while the jump key is held:

src/main/java/com/example/example_mod/mixin/LivingEntityJumpAccessor.java
@Mixin(LivingEntity.class)
public interface LivingEntityJumpAccessor {

	@Accessor("jumping")
	boolean getJumping();
}

(Accessor mixins are Chapter 13's subject; here they're just the only legal way to touch another entity's private fields.)

Why the jump key has to be shipped

Now the hard part. The boost depends on this.vehicleJumping, the rider's jump key. On the singleplayer client the rider is the local player, so we can read the key directly. But on a dedicated server the server has no idea whether the player is holding jump, input lives on the client. So the client must ship the jump key up every tick, and the boost must fire authoritatively from that shipped value. This is precisely the case the DataTracker can't cover, and where a custom client-to-server packet earns its place.

We declare the channel in the common ExampleNetworking alongside the welcome/ping pair from Chapter 12. The two booleans are direction: (false, true) means server-to-client off, client-to-server on:

src/main/java/com/example/example_mod/ExampleNetworking.java
	// Client to server: the rider's jump-key state, so the Aerbunny's glide boost can fire
	// authoritatively on a dedicated server (where the server can't see the player's input).
	// One boolean per tick while riding; the listener lives in ExampleModServer.
	public static final NamespacedIdentifier AERBUNNY_JUMP = ChannelRegistry.register(
		ChannelIdentifiers.from(ExampleMod.MOD_ID, "aerbunny_jump"), false, true);

The server registers a listener for it in ExampleModServer.initServer(). It reads the boolean, hops onto the main thread because it's about to touch live entity state, and hands the key to whatever bunny the sending player is carrying:

src/main/java/com/example/example_mod/ExampleModServer.java, initServer()
		// The rider's jump key, shipped by the client every tick while riding an Aerbunny.
		// We hand it to the Aerbunny the player is carrying so the glide boost fires on the
		// authoritative side. ensureOnMainThread because we touch live entity state.
		ServerPlayNetworking.registerListener(ExampleNetworking.AERBUNNY_JUMP, (ctx, buf) -> {
			boolean jumping = buf.readBoolean();
			ctx.ensureOnMainThread();
			ServerPlayerEntity player = ctx.player();
			if (player != null && player.passenger instanceof AerbunnyEntity bunny) {
				bunny.vehicleJumping = jumping;
			}
		});

And the client half is a mixin on PlayerEntity.tick. Injected at the TAIL, it runs every tick the player is carrying a bunny. Read its branch carefully, it's the whole networking pattern in one method:

src/main/java/com/example/example_mod/mixin/AerbunnyMountClientMixin.java
	@Inject(method = "tick", at = @At("TAIL"))
	private void example_mod$aerbunnyRide(CallbackInfo ci) {
		PlayerEntity self = (PlayerEntity) (Object) this;
		if (!(self.passenger instanceof AerbunnyEntity bunny)) {
			return;
		}
		boolean jumping = ((LivingEntityJumpAccessor) self).getJumping();
		if (self.world.isRemote) {
			ClientPlayNetworking.send(ExampleNetworking.AERBUNNY_JUMP, buf -> buf.writeBoolean(jumping));
			if (!self.onGround) {
				((EntityFallDistanceAccessor) self).setFallDistance(0.0F);
				self.velocityY += 0.05;
				if (self.velocityY < -0.225 && jumping) {
					self.velocityY = 0.125; // jump while gliding: a soft upward boost
					bunny.cloudPoop();
					bunny.puffiness = 1.15F;
				}
			}
		} else {
			bunny.vehicleJumping = jumping;
		}
	}

The fork is the lesson. On a dedicated server (self.world.isRemote) the client does two things: it ships the jump key up the aerbunny_jump channel so the server's authoritative glide can use it, and it predicts the glide locally so the player feels it instantly instead of a network round trip later. In singleplayer (the else branch) the one world is already authoritative, so the client doesn't predict at all, it just feeds the jump key straight into bunny.vehicleJumping and lets the bunny's own tickLiving apply the glide once. Beta simulates the player's movement on the client, so the slow-fall a rider feels has to be applied client-side; the server can't move the player for them. Predict on the remote client, apply once authoritatively, never double-apply: that's the rule.

Breeding

Breeding is a pair of counters and a population cap, all in tick() on the authoritative side. age climbs to 1023 (the bunny grows up), then mate climbs to 127 (it gets ready), then it looks for a partner. First the cap: count Aerbunnies within sixteen blocks, and if there are more than twelve, give up this attempt entirely:

src/main/java/com/example/example_mod/AerbunnyEntity.java, tick()
			if (this.age < 1023) {
				++this.age;
			} else if (this.mate < 127) {
				++this.mate;
			} else {
				List<?> crowd = this.world.getEntities(this, this.boundingBox.expand(16.0, 16.0, 16.0));
				int nearby = 0;
				for (Object o : crowd) {
					if (o instanceof AerbunnyEntity) ++nearby;
				}
				if (nearby > 12) {
					this.proceed();
					super.tick();
					return;
				}

If the area isn't crowded, look for an adult neighbour within one block, with no rider, and spawn a baby at this bunny's position. Spawning is the same three-line move from Chapter 7: construct from the world, position, hand to spawnEntity. Both parents then reset their counters via proceed():

src/main/java/com/example/example_mod/AerbunnyEntity.java, tick()
				List<?> close = this.world.getEntities(this, this.boundingBox.expand(1.0, 1.0, 1.0));
				boolean mated = false;
				for (Object o : close) {
					if (o instanceof AerbunnyEntity other && other != this
							&& other.vehicle == null && other.age >= 1023) {
						AerbunnyEntity baby = new AerbunnyEntity(this.world);
						baby.setPosition(this.x, this.y, this.z);
						this.world.spawnEntity(baby);
						this.world.playSound(this, "mob.chickenplop", 1.0F,
							(this.random.nextFloat() - this.random.nextFloat()) * 0.2F + 1.0F);
						this.proceed();
						other.proceed();
						mated = true;
						break;
					}
				}
				if (!mated) {
					this.mate = this.random.nextInt(16);
				}

proceed() just resets the counters so the cycle can start over:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	/** Reset the breeding counters after a birth (or a suppressed attempt). */
	public void proceed() {
		this.mate = 0;
		this.age = this.random.nextInt(64);
	}

Fear and flight

Hit the bunny as a player and it panics. The trigger is a one-line override of damage, setting the fear flag whenever the source is a player:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	@Override
	public boolean damage(Entity source, int amount) {
		boolean hit = super.damage(source, amount);
		if (hit && source instanceof PlayerEntity) {
			this.fear = true; // a player hit me: panic from now on
		}
		return hit;
	}

Once fear is set, tickLiving bolts the bunny away from the nearest player every few ticks. It picks a flee target, pathfinds away with runLikeHell, and looks back at whatever it's fleeing until that thing dies or gets far enough away:

src/main/java/com/example/example_mod/AerbunnyEntity.java, tickLiving()
			// Panic: once hit by a player, periodically bolt away from the nearest one.
			if (this.fear && this.random.nextInt(4) == 0) {
				if (this.runFrom != null) {
					this.runLikeHell();
					this.world.addParticle("splash", this.x, this.y, this.z, 0.0, 0.0, 0.0);
					if (!this.hasPath()) {
						this.lookAt(this.runFrom, 30.0F, 30.0F);
					}
					if (this.runFrom.dead || this.getDistance(this.runFrom) > 16.0F) {
						this.runFrom = null;
					}
				} else {
					this.runFrom = this.findPlayerToRunFrom();
				}
			}

runLikeHell is the actual escape pathing. It computes a direction directly away from the feared entity, jitters it randomly, then tries up to sixteen nearby open spots and pathfinds to the first valid one:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	/** Pathfind to a random nearby open spot, away from whatever is being feared. */
	public void runLikeHell() {
		double dx = this.x - this.runFrom.x;
		double dz = this.z - this.runFrom.z;
		double angle = Math.atan2(dx, dz) + (this.random.nextFloat() - this.random.nextFloat()) * 0.75;
		int tx = MathHelper.floor(this.x + Math.sin(angle) * 8.0);
		int ty = MathHelper.floor(this.boundingBox.minY);
		int tz = MathHelper.floor(this.z + Math.cos(angle) * 8.0);
		for (int tries = 0; tries < 16; ++tries) {
			int i = tx + this.random.nextInt(4) - this.random.nextInt(4);
			int j = ty + this.random.nextInt(4) - this.random.nextInt(4) - 1;
			int k = tz + this.random.nextInt(4) - this.random.nextInt(4);
			if (j > 4 && (this.world.getBlockId(i, j, k) == 0 || this.world.getBlockId(i, j, k) == Block.SNOW.id)
					&& this.world.getBlockId(i, j - 1, k) != 0) {
				this.setPath(this.world.findPath(this, i, j, k, 16.0F));
				break;
			}
		}
	}

The flee target comes from findPlayerToRunFrom, the nearest player within twelve blocks that the bunny can actually see:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	protected Entity findPlayerToRunFrom() {
		PlayerEntity player = this.world.getClosestPlayer(this, 12.0);
		return player != null && this.canSee(player) ? player : null;
	}

Grab and aggro

If you right-click a bunny while you already have a passenger, recall, it can't mount, so interact sets grab instead. A grabbed bunny holds in place: its glide and free-fall code is gated behind !this.grab, so it just hangs. The interesting part is what happens when a grabbed bunny finally touches ground. It releases itself, plays the land sound, and makes every monster within twelve blocks turn on it:

src/main/java/com/example/example_mod/AerbunnyEntity.java, tickLiving()
		} else if (this.onGround) {
			// A grabbed bunny that touches down is released, and every nearby monster aggros it.
			this.grab = false;
			this.world.playSound(this, "example_mod:mobs.aerbunny.aerbunnyland", 1.0F,
				(this.random.nextFloat() - this.random.nextFloat()) * 0.2F + 1.0F);
			List<?> list = this.world.getEntities(this, this.boundingBox.expand(12.0, 12.0, 12.0));
			for (Object o : list) {
				if (o instanceof MonsterEntity monster) {
					monster.setTarget(this);
				}
			}
		}

That whole else if (this.onGround) is the partner of the if (!this.grab) that wraps the normal panic logic: a bunny is either behaving normally and possibly fleeing, or it's grabbed and waiting to land. The state byte we published in slot 16 carries the grabbed bit (value 4) so remote clients can reflect it too.

The model and renderer

Chapter 7's mob borrowed the biped model. The Aerbunny needs a custom shape, twelve cubes: a head with two ears and two cheeks, a body, a tail, the puff cube, and four legs. Each cube is one call to a small part helper that builds a ModelPart, adds a cuboid, and sets its pivot:

src/main/java/com/example/example_mod/AerbunnyModel.java
	public AerbunnyModel() {
		int y = 16;
		this.head = part(0, 0, -2.0F, -1.0F, -4.0F, 4, 4, 6, 0.0F, y - 1, -4.0F);
		this.earLeft = part(14, 0, -2.0F, -5.0F, -3.0F, 1, 4, 2, 0.0F, y - 1, -4.0F);
		this.earRight = part(14, 0, 1.0F, -5.0F, -3.0F, 1, 4, 2, 0.0F, y - 1, -4.0F);
		this.cheekLeft = part(20, 0, -4.0F, 0.0F, -3.0F, 2, 3, 2, 0.0F, y - 1, -4.0F);
		this.cheekRight = part(20, 0, 2.0F, 0.0F, -3.0F, 2, 3, 2, 0.0F, y - 1, -4.0F);
		this.body = part(0, 10, -3.0F, -4.0F, -3.0F, 6, 8, 6, 0.0F, y, 0.0F);
		this.tail = part(0, 24, -2.0F, 4.0F, -2.0F, 4, 3, 4, 0.0F, y, 0.0F);
		this.puff = part(29, 0, -3.5F, -3.5F, -3.5F, 7, 7, 7, 0.0F, 0.0F, 0.0F);
		this.frontLegLeft = part(24, 16, -2.0F, 0.0F, -1.0F, 2, 2, 2, 3.0F, y + 3, -3.0F);
		this.frontLegRight = part(24, 16, 0.0F, 0.0F, -1.0F, 2, 2, 2, -3.0F, y + 3, -3.0F);
		this.backLegLeft = part(16, 24, -2.0F, 0.0F, -4.0F, 2, 2, 4, 3.0F, y + 3, 4.0F);
		this.backLegRight = part(16, 24, 0.0F, 0.0F, -4.0F, 2, 2, 4, -3.0F, y + 3, 4.0F);
	}

The helper itself is the reusable bit; the first two ints are the texture UV corner, then the cuboid offset and size, then the pivot:

src/main/java/com/example/example_mod/AerbunnyModel.java
	private static ModelPart part(int tx, int ty, float fx, float fy, float fz,
			int w, int h, int d, float px, float py, float pz) {
		ModelPart p = new ModelPart(tx, ty);
		p.addCuboid(fx, fy, fz, w, h, d, 0.0F);
		p.setPivot(px, py, pz);
		return p;
	}

The payoff is in render(). Every part draws at normal scale except the puff, which is wrapped in a GL push/pop and scaled by the synced puffiness. This is the only frame-to-frame visual in the whole showcase that depends on server state, and it's driven entirely by the DataTracker value we read into this.puffiness:

src/main/java/com/example/example_mod/AerbunnyModel.java, render()
		// The puff: scale the body cube by the synced puffiness, centred so it grows
		// in every direction. This is the only frame-to-frame visual that depends on
		// server state, and it is driven entirely by the DataTracker value. The
		// translate of 1.0 lifts the puff (pivot 0,0,0) onto the body (pivot 0,16,0);
		// it is a whole GL unit because the surrounding matrix is at entity scale.
		GL11.glPushMatrix();
		float s = 1.0F + this.puffiness * 0.5F;
		GL11.glTranslatef(0.0F, 1.0F, 0.0F);
		GL11.glScalef(s, s, s);
		this.puff.render(scale);
		GL11.glPopMatrix();

setAngles poses the model from the animation inputs: the head group tracks where the bunny is looking, and the four legs paddle as it moves. Note the body, tail, and puff are all pitched a quarter turn so the sheet's vertical body reads as a horizontal one:

src/main/java/com/example/example_mod/AerbunnyModel.java, setAngles()
		float headPitchRad = -(headPitch / 57.29578F);
		float headYawRad = headYaw / 57.29578F;
		for (ModelPart p : new ModelPart[]{head, earLeft, earRight, cheekLeft, cheekRight}) {
			p.pitch = headPitchRad;
			p.yaw = headYawRad;
		}
		this.body.pitch = 1.570796F;
		this.tail.pitch = 1.570796F;
		this.puff.pitch = 1.570796F;
		// Legs paddle as the bunny moves.
		this.frontLegLeft.pitch = MathHelper.cos(limbAngle * 0.6662F) * limbDistance;
		this.frontLegRight.pitch = MathHelper.cos(limbAngle * 0.6662F) * limbDistance;
		this.backLegLeft.pitch = MathHelper.cos(limbAngle * 0.6662F + 3.141593F) * 1.2F * limbDistance;
		this.backLegRight.pitch = MathHelper.cos(limbAngle * 0.6662F + 3.141593F) * 1.2F * limbDistance;

The renderer extends LivingEntityRenderer, which already draws the model, binds the texture from entity.texture, and applies the shadow. We override exactly one method, applyScale, to add the two things that depend on synced state. It reads getSyncedVelocityY() from the tracker, smooths it per entity so velocity jitter doesn't make the bunny twitch, rotates the whole model by that tilt, then hands the synced puffiness to the model:

src/main/java/com/example/example_mod/AerbunnyRenderer.java
	@Override
	protected void applyScale(LivingEntity entity, float partialTick) {
		AerbunnyEntity bunny = (AerbunnyEntity) entity;

		float velocityY = bunny.getSyncedVelocityY();
		float target;
		if (velocityY > 0.5F) {
			target = 15.0F;
		} else if (velocityY < -0.5F) {
			target = -15.0F;
		} else {
			target = velocityY * 30.0F;
		}
		float prev = smoothTilt.getOrDefault(bunny, 0.0F);
		float tilt = prev + (target - prev) * 0.3F;
		if (Math.abs(tilt) < 0.5F) {
			tilt = 0.0F;
		}
		smoothTilt.put(bunny, tilt);
		GL11.glRotatef(tilt, -1.0F, 0.0F, 0.0F);

		// Hand the synced puffiness to the model for this frame.
		this.bunnyModel.puffiness = bunny.puffiness;
	}

Both numbers it draws from, getSyncedVelocityY() and bunny.puffiness, came off the wire, never from a field the client simulated. That is the whole point: a remote client never runs the bunny's physics, so the only honest source for the render is what the server told it. The publish/trust split from earlier in the chapter ends here, at the GL call that finally uses it.

AerbunnyModel and AerbunnyRenderer name GL11 and LivingEntityRenderer, client-only graphics classes. Both must be registered from initClient() and never touched in common code, the same wall that kept the biped renderer client-side in Chapter 7. The entity class is common; the model and renderer are client. Don't let them cross.

Registering every piece

Eleven files only do something once they're wired in. Here is every registration, in the right entrypoint.

The entity, in the common init(), with the MobFactory cast that marks it as a living entity (the rule from Chapter 7):

src/main/java/com/example/example_mod/ExampleMod.java, init()
		AERBUNNY = RetroEntities.register(AerbunnyEntity.ID, AerbunnyEntity.class)
			.factory((MobFactory) AerbunnyEntity::new);

The renderer, in the client initClient(), passing a fresh AerbunnyModel and a half-block shadow:

src/main/java/com/example/example_mod/ExampleModClient.java, initClient()
		// The Aerbunny gets a CUSTOM model + renderer (not the biped). The renderer
		// reads the entity's server-synced puffiness and velocity to drive the puff
		// and tilt, the payoff of the DataTracker sync in AerbunnyEntity.
		RetroEntityRenderers.register(AerbunnyEntity.class,
			new AerbunnyRenderer(new AerbunnyModel(), 0.5F));

The jump channel is declared in ExampleNetworking (shown earlier) and registered by that class's static initializer, which ExampleMod.init() already forces to load via registerChannels() (Chapter 12). The server listener for it is in ExampleModServer.initServer() (also shown earlier). The client sender is the mount mixin.

The three mixin entries. The two accessors go in the common "mixins" list (they touch only common classes), and the client mount mixin goes in "client" so it never loads on a dedicated server, where ClientPlayNetworking doesn't exist:

src/main/resources/example_mod.mixins.json
	"mixins": [
		"LivingEntityJumpMixin",
		"PlayerLoadMixin",
		// ... example mixins ...
		"EntityFallDistanceAccessor",
		"LivingEntityJumpAccessor"
	],
	"client": [
		"PlayerTickMixin",
		// ... example mixins ...
		"AerbunnyMountClientMixin"
	],

The spawn. The bunny spawns in the Noisy dimension's two biomes, added through the mod-friendly RetroBiomes API, which is additive and de-dupes so it never clobbers another mod's spawns:

src/main/java/com/example/example_mod/ExampleMod.java
		// RetroBiomes.addPassiveSpawn is ADDITIVE and de-dupes, so it never clobbers
		// another mod's spawns; here it drops Aerbunnies into both Noisy biomes.
		RetroBiomes.addPassiveSpawn(NOISY_AMPLIFIED_BIOME, AerbunnyEntity.class, 4);
		RetroBiomes.addPassiveSpawn(NOISY_LUSH_BIOME, AerbunnyEntity.class, 4);

The biome list is only half the gate. The entity's own canSpawn() refuses to spawn anywhere but the Noisy dimension, and then only on grass:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	@Override
	public boolean canSpawn() {
		if (ExampleMod.NOISY_DIMENSION == null
				|| this.world.dimension.id != ExampleMod.NOISY_DIMENSION.getSerialId()) {
			return false;
		}
		int bx = MathHelper.floor(this.x);
		int by = MathHelper.floor(this.boundingBox.minY);
		int bz = MathHelper.floor(this.z);
		return this.world.getBlockId(bx, by - 1, bz) == Block.GRASS_BLOCK.id && super.canSpawn();
	}

(The Noisy dimension and its biomes are Chapter 10's and Chapter 20's subjects; here we're just placing the bunny into them.)

The texture and sounds

The skin is a single 64x32 sheet. The model's UV corners (the first two ints of every part call) index into it: the head block at 0,0, the ears at 14,0, the puff cube at 29,0, the legs lower down. Ship it at the path the constructor names:

aerbunny
aerbunny.png
download

src/main/resources/assets/example_mod/textures/mobs/aerbunny.png

The sounds are five OGG files, loaded by the autoloader purely from their folder, no JSON to write (Chapter 19 covers the autoloader in full). They live nested under the mob's own folder:

src/main/resources/assets/example_mod/sounds/sound/mobs/aerbunny/

  • aerbunnylift.ogg, played when the bunny mounts a player (event example_mod:mobs.aerbunny.aerbunnylift).
  • aerbunnyland.ogg, played when a grabbed bunny touches down (event example_mod:mobs.aerbunny.aerbunnyland).
  • aerbunnyhurt1.ogg and aerbunnyhurt2.ogg, two variants that collapse to one hurt event (example_mod:mobs.aerbunny.aerbunnyhurt); the engine picks one at random and the autoloader strips the trailing digit for you.
  • aerbunnydeath.ogg, the death sound (event example_mod:mobs.aerbunny.aerbunnydeath).

The entity wires the hurt and death events through the two sound overrides; lift, land, and the birth plop are played inline where the action happens:

src/main/java/com/example/example_mod/AerbunnyEntity.java
	@Override
	protected String getHurtSound() {
		// Variant sounds: aerbunnyhurt1.ogg and aerbunnyhurt2.ogg collapse to one event id
		// and the engine picks one at random. The autoloader handles the nesting + the digit.
		return "example_mod:mobs.aerbunny.aerbunnyhurt";
	}

	@Override
	protected String getDeathSound() {
		return "example_mod:mobs.aerbunny.aerbunnydeath";
	}

Every file you created

That's the whole bunny. If you followed along from nothing, you wrote eleven files and edited three. Check them off:

FileWhat it is
AerbunnyEntity.javaThe common entity: AI base, DataTracker publish, riding, glide, breeding, fear, grab, spawn gate, sounds.
AerbunnyModel.javaThe twelve-cube model with the synced puff scale.
AerbunnyRenderer.javaThe client renderer: synced tilt + puffiness handoff.
MountSync.javaThe server-only mount-packet helper that keeps ServerPlayerEntity out of common code.
mixin/EntityFallDistanceAccessor.javaAccessor for the rider's private fallDistance.
mixin/LivingEntityJumpAccessor.javaAccessor for the rider's private jumping flag.
mixin/AerbunnyMountClientMixin.javaClient tick mixin: predicts the glide and ships the jump key.
textures/mobs/aerbunny.pngThe 64x32 skin sheet.
sounds/sound/mobs/aerbunny/*.oggThe five sounds: lift, land, hurt1, hurt2, death.
ExampleNetworking.java (edit)Added the aerbunny_jump client-to-server channel.
ExampleModServer.java (edit)Added the jump-key listener.
ExampleMod.java / ExampleModClient.java (edit)Registered the entity, the renderer, and the spawn.
example_mod.mixins.json (edit)Added the two accessors to "mixins" and the mount mixin to "client".

Look back at how far the entity foundation stretched. The same spawn protocol from Chapter 7 carried this mob to clients; the DataTracker carried its render state; one custom channel carried the one input the tracker couldn't; and a careful helper class kept a server-only type out of common code. Each piece was small. Together they're a living creature that rides on your head and floats you home, ported faithfully from the Aether for Beta 1.7.3.

Our world now has machines, a wandering creature, and a floaty companion with a real brain. Next we'll reward the player for finding them: toasts that pop, an achievements page, and granting them from gameplay. On to Chapter 9.