Independent Project 01 · Fabric 1.20Free public projects

Broom Witch:
My guardian partner

Give your 3D model a new identity that will accompany you and protect you.

She walks near you and flies on her broom. When you encounter a zombie at night, she will send out magic to protect you. Run a successful version first, then understand and transform it in clear, small steps.

11 learning stages14 game screenshots10 mind mapsComplete Java code
Players, broom witch, woods and wooden houses in the Minecraft 1.20 game
Actual shots in the game: The original witch model on the website has become a partner around the player. Scenes, trees, blocks, and players are all rendered by Minecraft 1.20.

Get ready

Choose the Right Download to Get Started Smoothly.

Prepare a Windows computer, Minecraft Java Edition and IntelliJ IDEA. This project uses Minecraft 1.20, JDK 21, Fabric Loader 0.16.14, Fabric API 0.83.0+1.20. Please experience it in the new test world first.

First time learning

Complete learning project

Contains only the code, Gradle files, models, and textures needed for the Witch project. After decompression, open it with IDEA and you can run, modify and build it.

Download source code ZIP ↓
There is already a Simon project

Incremental update of original project

Added new witch code and resources, and provided two merged entry files. Attached are instructions for checking before updating and the compiled original project MOD.

Download update ZIP ↓
Experience the gameplay first

Independent trial installation package

Contains compiled witch JAR and Chinese installation instructions. The Fabric game for Minecraft 1.20 also requires the same version of the Fabric API.

Download trial ZIP ↓
The difference between three downloads

Learning projects are used for making from scratch; incremental updates are used for existing simon-minecraft-template-1.20; use the playtest package in a new test world. The learning project and original project share the MOD ID simon-minecraft, only one version can be shown in the modes folder of the same game.

Suggested rhythm:Complete 1-2 stages every day, and continue after acceptance. If you don’t know Java, you can review it Java basic course; does not run Fabric, look back MOD Lesson 1. The model can be found in Witch model pageRotation observation can also be downloaded to Blockbench.

Project overview from game design, 3D models, Java behavior to test delivery
Project overview from game design, 3D models, Java behavior to test delivery · Click to enlarge
Stage 01 · 15 Minutes

Play first and succeed: bring partners into the world

Your Goal for This Step:See the witch in the test world, switch between walking and flying modes.

Actual shot in the game: After entering the world, the player meets his witch partner in the forest camp.
Actual shot in the game: After entering the world, the player meets his witch partner in the forest camp. · Click to enlarge

First use the complete learning project to make the first success. Instead of writing all the code yourself just yet, make sure your computer can run it.

  1. Download and unzip.Click Complete Learning Project and extract the entire ZIP into a new folder, such as C:\MinecraftProjects\witch-companion. You should see it directly after entering the folder build.gradle,gradlew.bat and src.
  2. Open in IDEA.Choose File → Open and select that folder. The first launch needs an internet connection to download Gradle, Minecraft and dependencies. Wait for synchronization to finish.
  3. Select JDK 21.File → Project Structure → Project SDK choose 21; Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JVM also choose 21.
  4. Build first, then start.Open the Terminal at the bottom of IDEA and run the following two commands in sequence. The first item appears BUILD SUCCESSFUL After that, run the second one.
  5. Build a test world.In the game, select Singleplayer → Create New World / Create a new world, select creative mode, enable cheating, and select normal difficulty. Cheats are used for later time and zombie tests.
  6. See the witch.Wait about a second after entering the world, then look nearby. Walk forward a few steps and watch her follow. Right-click the witch to cycle through automatic, walking and flying modes.

Run PowerShell/IDEA Terminal in the project root directory

.\gradlew.bat build
.\gradlew.bat runClient

Press during game T Open the chat and enter the following command. The companion command is used to control your own witch and does not require administrator privileges.

Three commands for the first trial

/witch mode walk
/witch mode fly
/witch mode auto
I already have simon-minecraft-template-1.20, how should I add it?
  1. Close the running game and back up the project first src folder.
  2. Open the incremental update package and follow UPLOAD-README.txt Put the newly added witch Java packages, models and textures are copied to the same path.
  3. Add the following two sets of registration codes to the original entry method. Original registrations for other blocks, items, and entities are retained.
  4. If your entry file is completely consistent with the original version corresponding to the update package, you can use the merged entries in the package; if you have changed them yourself, just add the registration code.
  5. Return to the project root directory and run .\gradlew.bat build, run after success .\gradlew.bat runClient.

SimonMinecraft.java → Added in onInitialize()

com.malaoshi.simon.witch.WitchContent.register();

SimonMinecraftClient.java → Added in onInitializeClient()

com.malaoshi.simon.witch.client.WitchCompanionRenderer.registerResources();
EntityRendererRegistry.register(
    com.malaoshi.simon.witch.WitchContent.WITCH,
    com.malaoshi.simon.witch.client.WitchCompanionRenderer::new
);
Steps and checkpoints for successful play first
Steps and checkpoints for successful play first · Click to enlarge
Stage Checkpoint

See the witch in the test world, switch between walking and flying modes. Once you do that, move on to the next stage.

Stage 02 · 15 Minutes

Design the Gameplay: Give Your Companion a Task Card

Your Goal for This Step:Explain the order of following, night combat and recall.

Real shots in the game: players, witches and friendly animals. First figure out who you want to accompany and who you want to protect.
Real shots in the game: players, witches and friendly animals. First figure out who you want to accompany and who you want to protect. · Click to enlarge

NPCs are programmed characters. Our witch has three jobs:Follow you, protect you at night and find you after falling behind. Write these rules clearly first, and you will know what to do first when writing code.

Situation seenWhat is the witch going to do?
The player has just entered the worldThe server prepares a partner for this player.
When no enemies are nearbyFollow the pattern on foot, or fly nearby on a broomstick.
At night, there are zombies nearbyGive priority to looking at and attacking the zombies, and continue to accompany them after the end.
More than 28 blocks from the playerFinding a safe location near the player for recall.
Player went to another dimensionThe old partner exits and a new partner is reestablished in the new dimension.

Automatic Mode:Alternate between walking and flying approximately every 20 seconds.Walking mode:Always following on the ground.Flying Mode:Keep flying around the player on a broomstick. Attack priority is higher than daily companionship.

Draw your task card

Draw a player and witch on paper, and then draw three arrows: walk, fly, and protect. Under each arrow write "When will it happen?" This task card is your first game design.

Gameplay Design Steps and Checkpoints
Gameplay Design Steps and Checkpoints · Click to Enlarge
Stage Checkpoint

Explain the order of following, night combat and recall. Once you can do this, move to the next stage.

Stage 03 · 20 Minutes

Connect the Model: Where Does Its Shape Come From?

Your Goal for This Step:Find the model file, 6 textures, and renderer and see how they work together.

Real shots in the game: observe hats, clothes, cloaks and brooms at close range, and check the models and textures.
Real shots in the game: observe hats, clothes, cloaks and brooms at close range, and check the models and textures. · Click to enlarge

The model tells the game "what it looks like" and the program tells the game "what it does". This project follows the witch model from the website, retaining the cat in the hat, cape, broomstick, clothes and original pixel textures.

  1. Open Witch 3D Model Page, drag and rotate to recognize body, hat, cape and broom.
  2. Found in project src/main/resources/assets/simon-minecraft/witch/mesh.json. It saves the vertices, UVs, and normals of each mapped face.
  3. Open textures/entity/witchCheck. texture_0.png to texture_5.png A total of 6 textures.
  4. Open WitchCompanionRenderer.java. It loads the mesh, selects textures for each face, and draws the walking or flying pose.
Vertex
The four corners of the square determine where the shape is.
UV
Take a small piece of pixels from the texture image, like putting a small sticker on the square.
normal
The direction this face points, allowing lighting to distinguish bright and dark areas.

The web model cannot be put directly into Java Item JSON: it has multiple textures and parts at arbitrary angles. This has been converted to Mesh of 384 texture faces. You do not need to install additional animation modules first.

View the complete file: client/WitchCompanionRenderer.java

src/main/java/com/malaoshi/simon/witch/client/WitchCompanionRenderer.java

package com.malaoshi.simon.witch.client;

import com.google.gson.*;
import com.malaoshi.simon.SimonMinecraft;
import com.malaoshi.simon.witch.WitchCompanionEntity;
import net.fabricmc.fabric.api.resource.*;
import net.minecraft.client.render.*;
import net.minecraft.client.render.entity.*;
import net.minecraft.client.util.math.MatrixStack;
import net.minecraft.resource.*;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.RotationAxis;
import org.joml.Vector3f;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;

/** 网页原模型逐面导出,保留 64 个方块、原 UV、旋转及 6 张纹理。 */
public class WitchCompanionRenderer extends EntityRenderer<WitchCompanionEntity> {
    private record Face(String part, int texture, float[][] points, float[][] uv, float[] normal) {}
    private static volatile List<Face> faces = List.of();
    private static final Identifier MESH = new Identifier(SimonMinecraft.MOD_ID, "witch/mesh.json");
    private static final Identifier[] TEXTURES = new Identifier[6];
    static { for (int i = 0; i < 6; i++) TEXTURES[i] = new Identifier(SimonMinecraft.MOD_ID, "textures/entity/witch/texture_" + i + ".png"); }

    public WitchCompanionRenderer(EntityRendererFactory.Context context) { super(context); shadowRadius = 0.4f; }
    public static void registerResources() {
        ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES).registerReloadListener(new SimpleSynchronousResourceReloadListener() {
            @Override public Identifier getFabricId() { return new Identifier(SimonMinecraft.MOD_ID, "witch_mesh"); }
            @Override public void reload(ResourceManager manager) {
                try (Reader reader = new InputStreamReader(manager.getResourceOrThrow(MESH).getInputStream(), StandardCharsets.UTF_8)) {
                    JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
                    List<Face> loaded = new ArrayList<>();
                    for (JsonElement item : json.getAsJsonArray("faces")) {
                        JsonObject face = item.getAsJsonObject();
                        loaded.add(new Face(face.get("part").getAsString(), face.get("texture").getAsInt(),
                            matrix(face.getAsJsonArray("points")), matrix(face.getAsJsonArray("uv")), array(face.getAsJsonArray("normal"))));
                    }
                    faces = List.copyOf(loaded);
                    SimonMinecraft.LOGGER.info("Witch mesh loaded: {} textured faces", faces.size());
                } catch (IOException | RuntimeException error) {
                    throw new IllegalStateException("女巫模型加载失败: " + MESH, error);
                }
            }
        });
    }
    private static float[] array(JsonArray values) {
        float[] result = new float[values.size()];
        for (int i = 0; i < values.size(); i++) result[i] = values.get(i).getAsFloat();
        return result;
    }
    private static float[][] matrix(JsonArray values) {
        float[][] result = new float[values.size()][];
        for (int i = 0; i < values.size(); i++) result[i] = array(values.get(i).getAsJsonArray());
        return result;
    }
    @Override public Identifier getTexture(WitchCompanionEntity entity) { return TEXTURES[0]; }

    @Override public void render(WitchCompanionEntity entity, float yaw, float delta, MatrixStack matrices, VertexConsumerProvider buffers, int light) {
        matrices.push();
        matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(180 - yaw));
        matrices.scale(0.0625f, 0.0625f, 0.0625f);
        matrices.translate(0, 10, 0);
        float time = entity.age + delta;
        float step = (float)Math.sin(time * 0.65) * Math.min(1, (float)entity.getVelocity().horizontalLength() * 8) * 28;
        for (Face face : faces) {
            matrices.push();
            if (face.part.equals("broom") && entity.isBroomFlying()) {
                // 源扫帚已有 -57° 旋转,再转 -33° 即成为水平扫帚。
                matrices.translate(-5.5, -3, 0);
                pivot(matrices, 5.5f, 2.7f, .775f, -33, 0, 0);
            } else if (face.part.equals("right_leg") || face.part.equals("left_leg")) {
                float side = face.part.equals("right_leg") ? 1 : -1;
                pivot(matrices, side * 1.9f, 2, 0, entity.isBroomFlying() ? -65 : step * side, 0, 0);
            } else if (face.part.equals("right_arm") || face.part.equals("left_arm")) {
                float side = face.part.equals("right_arm") ? 1 : -1;
                pivot(matrices, side * 5, 13, 0, entity.isBroomFlying() ? -40 : -step * side * .6f, 0, side * 2);
            } else if (face.part.equals("cape")) {
                pivot(matrices, 0, 15, -6, entity.isBroomFlying() ? 15 : (float)Math.sin(time * .08) * 3, 0, 0);
            }
            MatrixStack.Entry entry = matrices.peek();
            VertexConsumer consumer = buffers.getBuffer(RenderLayer.getEntityCutoutNoCull(TEXTURES[face.texture]));
            Vector3f normal = new Vector3f(face.normal[0], face.normal[1], face.normal[2]).normalize();
            for (int i = 0; i < 4; i++) {
                float[] p = face.points[i], uv = face.uv[i];
                consumer.vertex(entry.getPositionMatrix(), p[0], p[1], p[2])
                    .color(255, 255, 255, 255).texture(uv[0], uv[1])
                    .overlay(OverlayTexture.DEFAULT_UV).light(light)
                    .normal(entry.getNormalMatrix(), normal.x, normal.y, normal.z).next();
            }
            matrices.pop();
        }
        matrices.pop();
        super.render(entity, yaw, delta, matrices, buffers, light);
    }
    private static void pivot(MatrixStack matrices, float x, float y, float z, float rx, float ry, float rz) {
        matrices.translate(x, y, z);
        matrices.multiply(RotationAxis.POSITIVE_Z.rotationDegrees(rz));
        matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(ry));
        matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(rx));
        matrices.translate(-x, -y, -z);
    }
}
Model Connection Steps and Checkpoints
Model Connection Steps and Checkpoints · Click to Enlarge
Stage Checkpoint

Find the model file, 6 textures, and renderer and see how they work together. Once you do that, move on to the next stage.

Stage 04 · 15 Minutes

Organizing project: finding the home of each file

Your Goal for This Step:Check the Java package name, resource path and two entries.

Actual shot in the game: The witch next to the workbench in the camp. The table below lists the files that allow her to appear in the game.
Actual shot in the game: The witch next to the workbench in the camp. The table below lists the files that allow her to appear in the game. · Click to enlarge

Expand from the Project panel on the left side of IDEA src → main. Find Java first, then resources. The folder path and Java package name must correspond.

Main Documents of Project Witch

src/main/
├─ java/com/malaoshi/simon/
│  ├─ SimonMinecraft.java
│  ├─ SimonMinecraftClient.java
│  └─ witch/
│     ├─ WitchContent.java
│     ├─ WitchCompanionEntity.java
│     ├─ WitchCompanions.java
│     ├─ WitchCompanionState.java
│     └─ client/WitchCompanionRenderer.java
└─ resources/
   ├─ fabric.mod.json
   └─ assets/simon-minecraft/
      ├─ witch/mesh.json
      └─ textures/entity/witch/texture_0.png ... texture_5.png

Right click com.malaoshi.simon → New → Package, create witch;recreated inside client. The first line of the Java file package should be consistent with its location.

One name runs through the entire project

SimonMinecraft.MOD_ID is What is the value of simon-minecraft, and the resources folder is also called assets/simon-minecraft. Spelling, capitalization, and hyphens must be consistent.

Organize the steps and checkpoints of the project
Organize the steps and checkpoints of the project · Click to enlarge
Stage Checkpoint

Check the Java package name, resource path and two entries. Once you do that, move on to the next stage.

Stage 05 · 20 Minutes

Registering NPCs: Introducing new companions to the game

Your Goal for This Step:Understand the three registrations of entity types, basic attributes and client-side rendering.

In-game real shot: After the game identifies and displays the new partner, then check the three links of the registration code.
In-game real shot: After the game identifies and displays the new partner, then check the three links of the registration code. · Click to enlarge

Registration is like going through the admission procedures for new students. The game needs to know her name, body size, and basic attributes; the client also needs to know which renderer to use to draw her.

  1. Entity type.WitchContent Sign up simon-minecraft:witch_companion, and set the size of an entity's collision.
  2. Basic attributes.Use FabricDefaultAttributeRegistry Registers 40 health, 0.30 walking speed, and 32 blocks to follow the search attributes.
  3. Public entrance.SimonMinecraft.onInitialize() Call WitchContent.register().
  4. Client entrance.SimonMinecraftClient Register resource loading and entity renderers.
View full file: WitchContent.java

src/main/java/com/malaoshi/simon/witch/WitchContent.java

package com.malaoshi.simon.witch;

import com.malaoshi.simon.SimonMinecraft;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricDefaultAttributeRegistry;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricEntityTypeBuilder;
import net.minecraft.entity.*;
import net.minecraft.registry.*;
import net.minecraft.util.Identifier;

public final class WitchContent {
    public static final EntityType<WitchCompanionEntity> WITCH = Registry.register(
        Registries.ENTITY_TYPE, new Identifier(SimonMinecraft.MOD_ID, "witch_companion"),
        FabricEntityTypeBuilder.create(SpawnGroup.CREATURE, WitchCompanionEntity::new)
            .dimensions(EntityDimensions.fixed(0.65f, 1.95f))
            .trackRangeBlocks(80).trackedUpdateRate(2).build());

    public static void register() {
        FabricDefaultAttributeRegistry.register(WITCH, WitchCompanionEntity.attributes());
        WitchCompanions.register();
        SimonMinecraft.LOGGER.info("Witch companion ready: /witch help");
    }
}

The entity behavior is determined on the server, and the appearance is drawn on the client. Both parts also exist in the single-player game.

The height of the collision body is 1.95 blocks, and it can pass through the ordinary two-block high channel. Pointed hats are exterior decoration and may be covered by the ceiling in low-rise rooms.

Check registration first, then behavior

If the system crashes, first look for the attribute registration; if the code is running but the model cannot be seen, first look for the client rendering registration and resource path.

Steps and Checkpoints for Registering NPCs
Steps and checkpoints for registering NPC · Click to enlarge
Stage Checkpoint

Understand the three registrations of entity types, basic attributes and client-side rendering. Once you do that, move on to the next stage.

Stage 06 · 20 Minutes

Follow on foot: Find the player and start pathfinding

Your Goal for This Step:Move ten spaces and watch your partner catch up and stop nearby.

Actual shot in the game: The witch stands near the player. After switching to walk, walk forward ten blocks and watch her follow.
Actual shot in the game: The witch stands near the player. After switching to walk, walk forward ten blocks and watch her follow. · Click to enlarge

A witch cannot just recognize the nearest person as her master. She saves the player's UUIDAnd with this unique number, find the true owner.

  1. Input in game /witch mode walk.
  2. Walk forward about ten spaces, then stop.
  3. Watch her move behind and to your side, then stop when she gets close.
  4. Place a few blocks on the path and see if she can get around them. When blocked for about five seconds, she will attempt to safely recall near you.

Pathfinding core in WitchCompanionEntity.java

navigation.startMovingTo(
    destination.x, destination.y, destination.z, 1.1
);

destination is the destination, the final 1.1 is the pathfinding speed multiplier. The destination is usually about three blocks away from the player. The location is updated every ten game ticks and pathfinding stops when approaching.

What is a game moment?

At normal speed, there are approximately 20 game ticks per second. It checks every 10 ticks, about every half a second; when the machine is stuck, the waiting time in reality will be longer.

View full file: WitchCompanionEntity.java

src/main/java/com/malaoshi/simon/witch/WitchCompanionEntity.java

package com.malaoshi.simon.witch;

import net.minecraft.entity.*;
import net.minecraft.entity.attribute.*;
import net.minecraft.entity.data.*;
import net.minecraft.entity.mob.PathAwareEntity;
import net.minecraft.entity.mob.ZombieEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.entity.ai.goal.SwimGoal;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.particle.ParticleTypes;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.sound.SoundEvents;
import net.minecraft.text.Text;
import net.minecraft.util.*;
import net.minecraft.util.math.*;
import net.minecraft.world.World;
import java.util.*;

public class WitchCompanionEntity extends PathAwareEntity {
    public static final double GUARD_RADIUS = 16.0;
    public static final float MAGIC_DAMAGE = 6.0f;
    public static final int ATTACK_INTERVAL = 20; // 20 游戏刻约等于 1 秒
    private static final TrackedData<Boolean> FLYING = DataTracker.registerData(WitchCompanionEntity.class, TrackedDataHandlerRegistry.BOOLEAN);
    private static final TrackedData<Integer> MODE = DataTracker.registerData(WitchCompanionEntity.class, TrackedDataHandlerRegistry.INTEGER);
    private UUID ownerUuid;
    private int attackCooldown;
    private int stuckTicks;
    private Vec3d lastPosition = Vec3d.ZERO;

    public WitchCompanionEntity(EntityType<? extends PathAwareEntity> type, World world) {
        super(type, world);
        setPersistent(); setCustomName(Text.literal("扫帚女巫 · 守护伙伴"));
        setCustomNameVisible(true);
    }
    public static DefaultAttributeContainer.Builder attributes() {
        return createMobAttributes().add(EntityAttributes.GENERIC_MAX_HEALTH, 40)
            .add(EntityAttributes.GENERIC_MOVEMENT_SPEED, 0.30)
            .add(EntityAttributes.GENERIC_FOLLOW_RANGE, 32);
    }
    @Override protected void initGoals() { goalSelector.add(0, new SwimGoal(this)); }
    @Override protected void initDataTracker() {
        super.initDataTracker(); dataTracker.startTracking(FLYING, false); dataTracker.startTracking(MODE, 0);
    }
    public UUID getOwnerUuid() { return ownerUuid; }
    public void setOwnerUuid(UUID owner) { ownerUuid = owner; }
    public boolean isBroomFlying() { return dataTracker.get(FLYING); }
    public int getMode() { return dataTracker.get(MODE); }
    public void setMode(int mode) { dataTracker.set(MODE, MathHelper.clamp(mode, 0, 2)); }
    public static String modeName(int mode) { return new String[]{"自动陪伴", "步行跟随", "扫帚飞行"}[MathHelper.clamp(mode, 0, 2)]; }

    /** 找有地板、无液体、身体放得下的位置;若没有就悬停,避免卡进墙。 */
    public void recall(PlayerEntity owner) {
        navigation.stop(); setVelocity(Vec3d.ZERO);
        BlockPos center = owner.getBlockPos();
        for (int dy = 1; dy >= -2; dy--) for (int dx = -3; dx <= 3; dx++) for (int dz = -3; dz <= 3; dz++) {
            if (Math.abs(dx) + Math.abs(dz) < 2) continue;
            BlockPos feet = center.add(dx, dy, dz);
            if (!getWorld().getBlockState(feet.down()).isSolidBlock(getWorld(), feet.down())) continue;
            Vec3d pos = Vec3d.ofBottomCenter(feet);
            Box box = getDimensions(getPose()).getBoxAt(pos);
            if (getWorld().isSpaceEmpty(this, box) && !getWorld().containsFluid(box)) {
                refreshPositionAndAngles(pos.x, pos.y, pos.z, owner.getYaw(), 0);
                fallDistance = 0; stuckTicks = 0; return;
            }
        }
        Box box = getDimensions(getPose()).getBoxAt(owner.getPos());
        if (getWorld().isSpaceEmpty(this, box)) refreshPositionAndAngles(owner.getX(), owner.getY(), owner.getZ(), owner.getYaw(), 0);
        dataTracker.set(FLYING, true); setNoGravity(true); fallDistance = 0;
    }

    @Override public void tick() {
        super.tick();
        if (!(getWorld() instanceof ServerWorld world) || ownerUuid == null) return;
        var state = WitchCompanionState.get(world.getServer());
        // 被卸载的旧伙伴重新加载时退出,避免远距离传送后出现重复伙伴。
        if (state.disabled.contains(ownerUuid) || !getUuid().equals(state.companions.get(ownerUuid))) { discard(); return; }
        ServerPlayerEntity owner = world.getServer().getPlayerManager().getPlayer(ownerUuid);
        if (owner == null || owner.getWorld() != world || !owner.isAlive() || owner.isSpectator()) {
            navigation.stop(); setVelocity(Vec3d.ZERO); setTarget(null); return;
        }
        if (squaredDistanceTo(owner) > 28 * 28) recall(owner);
        if (attackCooldown > 0) attackCooldown--;
        if (age % 10 == 0) {
            ZombieEntity nearest = null; double best = Double.MAX_VALUE;
            if (world.isNight()) for (ZombieEntity zombie : world.getEntitiesByClass(ZombieEntity.class,
                owner.getBoundingBox().expand(GUARD_RADIUS), z -> z.isAlive() && z.squaredDistanceTo(owner) <= GUARD_RADIUS * GUARD_RADIUS)) {
                double d = squaredDistanceTo(zombie);
                if (d < best) { nearest = zombie; best = d; }
            }
            setTarget(nearest);
        }
        LivingEntity enemy = getTarget();
        if (enemy != null && (!world.isNight() || !enemy.isAlive() || enemy.squaredDistanceTo(owner) > GUARD_RADIUS * GUARD_RADIUS)) {
            setTarget(null); enemy = null;
        }
        boolean fly = getMode() == 2 || (getMode() == 0 && (age / 400) % 2 == 1);
        dataTracker.set(FLYING, fly); setNoGravity(fly); fallDistance = 0;
        Vec3d destination;
        if (enemy != null) {
            destination = enemy.getPos().add(0, fly ? 2.5 : 0, 0);
            getLookControl().lookAt(enemy, 30, 30);
            if (squaredDistanceTo(enemy) <= 12 * 12 && canSee(enemy) && attackCooldown == 0) {
                attackCooldown = ATTACK_INTERVAL;
                enemy.damage(getDamageSources().indirectMagic(this, this), MAGIC_DAMAGE);
                Vec3d from = getPos().add(0, 1.5, 0), to = enemy.getPos().add(0, 1, 0);
                for (int i = 0; i <= 16; i++) {
                    Vec3d p = from.lerp(to, i / 16.0);
                    world.spawnParticles(ParticleTypes.WITCH, p.x, p.y, p.z, 1, 0.03, 0.03, 0.03, 0.01);
                }
                playSound(SoundEvents.ENTITY_EVOKER_CAST_SPELL, 0.55f, 1.3f);
            }
        } else {
            double angle = fly ? age * 0.018 : Math.toRadians(owner.getYaw() + 110);
            destination = owner.getPos().add(Math.cos(angle) * 3, fly ? 2.5 + Math.sin(age * 0.08) * 0.25 : 0, Math.sin(angle) * 3);
            getLookControl().lookAt(owner, 30, 30);
        }
        if (fly) {
            navigation.stop();
            Vec3d delta = destination.subtract(getPos());
            Vec3d desired = delta.length() < 0.3 ? Vec3d.ZERO : delta.normalize().multiply(Math.min(0.32, delta.length() * 0.15));
            if (horizontalCollision) desired = desired.add(0, 0.18, 0);
            setVelocity(getVelocity().lerp(desired, 0.25)); velocityDirty = true;
            if (desired.horizontalLengthSquared() > 0.002) {
                setYaw((float)(Math.toDegrees(Math.atan2(desired.z, desired.x)) - 90)); bodyYaw = getYaw();
            }
            if (age % 6 == 0) world.spawnParticles(ParticleTypes.END_ROD, getX(), getY() + 0.2, getZ(), 1, 0.1, 0.05, 0.1, 0);
        } else if (age % 10 == 0) {
            if (getPos().squaredDistanceTo(destination) > 2.5 && (enemy == null || squaredDistanceTo(enemy) > 7 * 7 || !canSee(enemy)))
                navigation.startMovingTo(destination.x, destination.y, destination.z, 1.1);
            else navigation.stop();
        }
        if (age % 20 == 0) {
            boolean wantsMove = getPos().squaredDistanceTo(destination) > 16;
            stuckTicks = wantsMove && getPos().squaredDistanceTo(lastPosition) < 0.04 ? stuckTicks + 20 : 0;
            lastPosition = getPos();
            if (stuckTicks >= 100) recall(owner);
            if (getHealth() < getMaxHealth()) heal(1);
        }
    }

    @Override public void travel(Vec3d input) {
        if (isBroomFlying()) { move(MovementType.SELF, getVelocity()); setVelocity(getVelocity().multiply(0.95)); }
        else super.travel(input);
    }
    @Override public boolean handleFallDamage(float distance, float multiplier, net.minecraft.entity.damage.DamageSource source) { return false; }
    @Override protected ActionResult interactMob(PlayerEntity player, Hand hand) {
        if (!player.getUuid().equals(ownerUuid)) return ActionResult.PASS;
        if (player instanceof ServerPlayerEntity serverPlayer) {
            WitchCompanions.setMode(serverPlayer, (getMode() + 1) % 3);
            player.sendMessage(Text.literal("伙伴模式:" + modeName(getMode())), true);
        }
        return ActionResult.success(getWorld().isClient);
    }
    @Override public void writeCustomDataToNbt(NbtCompound nbt) {
        super.writeCustomDataToNbt(nbt);
        if (ownerUuid != null) nbt.putUuid("WitchOwner", ownerUuid);
        nbt.putInt("WitchMode", getMode());
    }
    @Override public void readCustomDataFromNbt(NbtCompound nbt) {
        super.readCustomDataFromNbt(nbt);
        if (nbt.containsUuid("WitchOwner")) ownerUuid = nbt.getUuid("WitchOwner");
        setMode(nbt.getInt("WitchMode"));
    }
}
Steps and Checkpoints for Walking Following
Steps and checkpoints to follow on foot · Click to enlarge
Stage Checkpoint

Move ten spaces and watch your partner catch up and stop nearby. Once you do that, move on to the next stage.

Stage 07 · 25 Minutes

Broom flight: let the position change slowly

Your Goal for This Step:Observe the horizontal broom, bent legs and circling flight.

Actual shot in the game: The witch leaves the ground and flies over the camp on a horizontal broomstick.
Actual shot in the game: The witch leaves the ground and flies over the camp on a horizontal broomstick. · Click to enlarge

Flying Has Two Parts:Entities really move through the airAt the same timeThe model is really changed into the posture of riding a broom. As long as the appearance changes, the witch will still stand on the ground.

  1. Enter /witch mode flyWait two or three seconds.
  2. Look up and observe: the witch flies in a circle of about three blocks around the player, and the target height is about 2.5 blocks above the player's feet.
  3. Look at the broom becoming horizontal with legs and arms bent.
  4. Walk toward a tree and observe her movement. She collides with obstacles rather than passing through walls, tries to rise over obstructions, and attempts recall if stuck.

Core Ideas Behind Flight Position and Velocity

double angle = age * 0.018;
Vec3d destination = owner.getPos().add(
    Math.cos(angle) * 3,
    2.5 + Math.sin(age * 0.08) * 0.25,
    Math.sin(angle) * 3
);
setNoGravity(true);
setVelocity(getVelocity().lerp(desired, 0.25));

cos and sin work together to trace a circle;2.5 is the target height;lerp gradually moves the velocity toward the new target for smoother turns. The code above illustrates the idea; the complete variables and branches are in the entity file.

In actual operation of Minecraft 1.20, the witch flies on a horizontal broom
Minecraft in-game real shots · Observe the horizontal broom and riding posture at the forest camp.
Steps and Checkpoints of Broom Flight
Steps and checkpoints for broom flying · Click to enlarge
Stage Checkpoint

Observe the horizontal broom, bent legs and circling flight. Once this works, move to the next stage.

Stage 08 · 25 Minutes

Night Guard: Only attack nearby zombies

Your Goal for This Step:Does not actively attack during the day, emits magic at night, and friendly animals are not injured.

Real shot in the game: Zombies appear in the camp at night, and the witch casts spells on the target; observe that the player has turned on night vision.
Real shot in the game: Zombies appear in the camp at night, and the witch casts spells on the target; observe that the player has turned on night vision. · Click to enlarge

There must be clear boundaries to protect players. The witch only looks for witches within 16 blocks of the player.Zombie familyShe does not use pigs, villagers or players as objects of magic.

  1. In the newly created creative mode test world, confirm that the difficulty is normal and enable cheating.
  2. First enter the following daytime test command. The zombies wear helmets to avoid being caught on fire by the sun and affecting observation.
  3. Wait for five seconds and observe that the witch does not actively cast spells. Then change the time to night.
  4. The Witch emits purple magic when within 12 tiles and can see a zombie, up to once every 20 ticks, dealing 6 points of damage.
  5. Summon another pig and make sure the magic doesn't lock onto it. After the test is completed, it will be changed back to daytime.

Enter one by one in game chat

/difficulty normal
/time set day
/summon minecraft:zombie ~4 ~ ~ {NoAI:1b,ArmorItems:[{},{},{},{id:"minecraft:netherite_helmet",Count:1b}]}
/time set night
/summon minecraft:pig ~2 ~ ~
/time set day

Conditions that must be met simultaneously before casting (excerpt of ideas)

world.isNight()
&& enemy.isAlive()
&& squaredDistanceTo(enemy) <= 12 * 12
&& canSee(enemy)
&& attackCooldown == 0

Cooldown is like an hourglass: after casting a spell, set the hourglass to 20 and decrease it by one every tick until it reaches zero before you are allowed to cast the spell again. Line of sight checks prevent damage from being done through solid walls.

In the actual Minecraft game, witches use purple magic to attack zombies at night
Real game screenshots · To see the model clearly, test and observe the player with night vision effect; the world time is still night.
Steps and checkpoints of night guarding
Night guarding steps and checkpoints · Click to enlarge
Stage Checkpoint

Does not actively attack during the day, emits magic at night, and friendly animals are not injured. Once you do that, move on to the next stage.

Stage 09 · 20 Minutes

Save the relationship: I can follow you even if you change the world

Your Goal for This Step:Keep the mode after rejoining and maintain one companion across dimensions.

Actual shot in the game: Players and partners in front of the wooden house. Follow the steps below to test whether the mode is retained after exiting and re-entering.
Actual shot in the game: Players and partners in front of the wooden house. Follow the steps below to test whether the mode is retained after exiting and re-entering. · Click to enlarge

If you only put the master number in a temporary variable, it will be forgotten after the game is closed.NBT Is the format of game save data;PersistentState It is a small notepad that is kept with the world.

Physical small notepad
WitchOwner Save owner UUID;WitchMode Save Auto, Walk or Fly.
The little notepad of the world
Records each player's companion and whether the companion is disabled. It is saved in the world's simon_witch_companions in the data.
Across Dimensions
It is found that the master has gone to another dimension, the old partner exits, and the new partner is regenerated using the original model.
Old partner reload
World records will check partner UUIDs; old partners that are no longer part of the current record will exit to avoid having two partners.

Check for online players every second. Partners will automatically regenerate after death; cross-dimensional reconstruction will restore health and preserve mode. input /witch remove , the closed state will be saved; enter /witch summon Can be reopened.

View full file: WitchCompanionState.java

src/main/java/com/malaoshi/simon/witch/WitchCompanionState.java

package com.malaoshi.simon.witch;

import net.minecraft.nbt.NbtCompound;
import net.minecraft.nbt.NbtList;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import java.util.*;

/** 每个玩家只保留一个伙伴编号;关闭和模式选择也会随世界保存。 */
public final class WitchCompanionState extends PersistentState {
    public final Map<UUID, UUID> companions = new HashMap<>();
    public final Map<UUID, Integer> modes = new HashMap<>();
    public final Set<UUID> disabled = new HashSet<>();

    public static WitchCompanionState get(MinecraftServer server) {
        return server.getOverworld().getPersistentStateManager().getOrCreate(
            WitchCompanionState::read, WitchCompanionState::new, "simon_witch_companions");
    }

    private static WitchCompanionState read(NbtCompound nbt) {
        WitchCompanionState state = new WitchCompanionState();
        NbtList list = nbt.getList("Players", 10);
        for (int i = 0; i < list.size(); i++) {
            NbtCompound row = list.getCompound(i);
            if (!row.containsUuid("Owner")) continue;
            UUID owner = row.getUuid("Owner");
            if (row.containsUuid("Companion")) state.companions.put(owner, row.getUuid("Companion"));
            state.modes.put(owner, Math.max(0, Math.min(2, row.getInt("Mode"))));
            if (row.getBoolean("Disabled")) state.disabled.add(owner);
        }
        return state;
    }

    @Override public NbtCompound writeNbt(NbtCompound nbt) {
        NbtList list = new NbtList();
        Set<UUID> owners = new HashSet<>(companions.keySet());
        owners.addAll(disabled); owners.addAll(modes.keySet());
        for (UUID owner : owners) {
            NbtCompound row = new NbtCompound();
            row.putUuid("Owner", owner);
            if (companions.containsKey(owner)) row.putUuid("Companion", companions.get(owner));
            row.putInt("Mode", modes.getOrDefault(owner, 0));
            row.putBoolean("Disabled", disabled.contains(owner));
            list.add(row);
        }
        nbt.put("Players", list);
        return nbt;
    }
}
View full file: WitchCompanions.java

src/main/java/com/malaoshi/simon/witch/WitchCompanions.java

package com.malaoshi.simon.witch;

import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.entity.Entity;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.text.Text;
import java.util.UUID;
import static net.minecraft.server.command.CommandManager.literal;

/** 服务器负责生成、跨维度重建和指令;客户端只负责显示。 */
public final class WitchCompanions {
    public static void register() {
        ServerTickEvents.END_SERVER_TICK.register(server -> {
            if (server.getTicks() % 20 != 0) return;
            for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
                if (player.isAlive() && !player.isSpectator()) ensure(player);
            }
        });
        CommandRegistrationCallback.EVENT.register((dispatcher, registry, environment) -> {
            var root = literal("witch").requires(source -> source.getEntity() instanceof ServerPlayerEntity)
                .executes(ctx -> help(ctx.getSource().getPlayerOrThrow()));
            root.then(literal("help").executes(ctx -> help(ctx.getSource().getPlayerOrThrow())));
            root.then(literal("summon").executes(ctx -> {
                var player = ctx.getSource().getPlayerOrThrow();
                var state = WitchCompanionState.get(player.getServer());
                state.disabled.remove(player.getUuid()); state.markDirty();
                ensure(player); tell(player, "女巫伙伴已开启。右键她切换模式。"); return 1;
            }));
            root.then(literal("recall").executes(ctx -> {
                var player = ctx.getSource().getPlayerOrThrow();
                var witch = ensure(player);
                if (witch != null) witch.recall(player);
                tell(player, witch == null ? "伙伴已关闭,请先输入 /witch summon。" : "伙伴回到你身边了。"); return 1;
            }));
            root.then(literal("remove").executes(ctx -> {
                var player = ctx.getSource().getPlayerOrThrow();
                var state = WitchCompanionState.get(player.getServer());
                state.disabled.add(player.getUuid());
                var witch = find(player.getServer(), state.companions.remove(player.getUuid()));
                if (witch != null) witch.discard();
                state.markDirty(); tell(player, "伙伴已关闭;/witch summon 可以重新开启。"); return 1;
            }));
            var mode = literal("mode");
            String[] names = {"auto", "walk", "fly"};
            for (int i = 0; i < names.length; i++) {
                final int selected = i;
                mode.then(literal(names[i]).executes(ctx -> {
                    var player = ctx.getSource().getPlayerOrThrow();
                    setMode(player, selected);
                    tell(player, "伙伴模式:" + WitchCompanionEntity.modeName(selected)); return 1;
                }));
            }
            root.then(mode); dispatcher.register(root);
        });
    }

    private static int help(ServerPlayerEntity player) {
        tell(player, "女巫伙伴:/witch mode auto|walk|fly;/witch recall;/witch remove;/witch summon。夜晚会攻击你附近的僵尸。");
        return 1;
    }
    private static void tell(ServerPlayerEntity player, String message) {
        player.sendMessage(Text.literal(message), false);
    }
    public static void setMode(ServerPlayerEntity player, int mode) {
        var state = WitchCompanionState.get(player.getServer());
        state.modes.put(player.getUuid(), mode); state.markDirty();
        var witch = ensure(player);
        if (witch != null) witch.setMode(mode);
    }
    public static WitchCompanionEntity find(MinecraftServer server, UUID uuid) {
        if (uuid == null) return null;
        for (ServerWorld world : server.getWorlds()) {
            Entity entity = world.getEntity(uuid);
            if (entity instanceof WitchCompanionEntity witch && witch.isAlive()) return witch;
        }
        return null;
    }
    public static WitchCompanionEntity ensure(ServerPlayerEntity player) {
        var state = WitchCompanionState.get(player.getServer());
        UUID owner = player.getUuid();
        if (state.disabled.contains(owner)) return null;
        var witch = find(player.getServer(), state.companions.get(owner));
        if (witch != null && witch.getWorld() != player.getWorld()) {
            witch.discard(); witch = null;
        }
        if (witch == null) {
            witch = WitchContent.WITCH.create(player.getServerWorld());
            if (witch == null) return null;
            witch.setOwnerUuid(owner);
            witch.setMode(state.modes.getOrDefault(owner, 0));
            // 初始位置必须在主人身边;召回找不到空位时也不会留在 (0,0,0)。
            witch.refreshPositionAndAngles(player.getX(), player.getY(), player.getZ(), player.getYaw(), 0);
            witch.recall(player);
            if (!player.getServerWorld().spawnEntity(witch)) return null;
            state.companions.put(owner, witch.getUuid()); state.markDirty();
        }
        return witch;
    }
}
  1. Switch to airplane mode, exit the world, re-enter, and confirm that the mode is retained.
  2. Enter /witch remove, leave and rejoin, and confirm that it does not spawn automatically again.
  3. Enter /witch summonConfirm the witch reappears.
  4. Enter the lower bound when conditions permit, and check that there is still only one partner of your own in the new dimension.
Stage Checkpoint

Keep the mode after rejoining and maintain one companion across dimensions. Once this works, move to the next stage.

Stage 10 · 20 Minutes

Check Each Requirement: Narrow an Error Down to One Step

Your Goal for This Step:Complete acceptance checklist for walking, flying, night fighting, archiving, and recall.

Actual shot in the game: The witch and the pig are in the same camp. Friendly animals not being attacked is also a must-check.
Actual shot in the game: The witch and the pig are in the same camp. Friendly animals not being attacked is also a must-check. · Click to enlarge

First determine at which layer the error occurs. By testing one effect at a time, it's easier to find the cause.

My Acceptance Checklist

Use this record after testing the project yourself. Check each box only after confirming that it works. For any failed item, start with the troubleshooting table below.

0 / 10 items completed

Check to save only in the current browser to facilitate learning next time.

Issue EncounteredCheck this place first
gradlew.bat not foundCan the Terminal see the project root directory of build.gradle? Has the ZIP been fully extracted?
Java compiled version errorAre both Project SDK and Gradle JVM JDK 21?
Dependency download failedCheck the network and resynchronize Gradle; the first run requires downloading Minecraft resources.
No Witch Appears in the WorldWait one second, enter /witch summon; check whether the public entry calls WitchContent.register().
The model is invisible or has a purple-black textureCheck the client's two lines of registration and the grid and 6 PNGs under assets/simon-minecraft.
No zombies at nightDifficulty must not be Peaceful. The zombie must be within 16 blocks of the player and 12 blocks of the witch, with a clear line of sight.
There are two MODs with the same nameThe mods folder contains only one version of simon-minecraft. Do not install the trial version and the original engineering version at the same time.
Partner falls behind or gets stuckGo to the open ground first, and then enter /witch recall. A low room may not accommodate the model.

When IDEA reports an error when running, look at the first output at the bottom and witch Related errors; the game log is in the project run/logs/latest.log. First record the error text and the operation just now, and then modify the corresponding file.

Verification Steps and Checkpoints
Verification Steps and Checkpoints · Click to Enlarge
Stage Checkpoint

Complete acceptance checklist for walking, flying, night fighting, archiving, and recall. Once you do that, move on to the next stage.

Stage 11 · 20 Minutes

Make your own version: only change one number at a time

Your Goal for This Step:Modify the damage or guard radius, and record the difference before and after modification.

Actual shot in the game: The broom witch in the camp in the evening. Modify a value and return to the game to observe the difference.
Actual shot in the game: The broom witch in the camp in the evening. Modify a value and return to the game to observe the difference. · Click to enlarge

Complete the acceptance first, and then change it into your own work. Don't change many values ​​at once: run it once and record it once.

Small experimentWhere to modifyWhat to Observe
Lighter magicMAGIC_DAMAGE of entity file: 6.0f changed to 4.0fZombies with the same health require more casts.
Slower coolingATTACK_INTERVAL: 20 changed to 30Cast interval changed from approximately one second to approximately 1.5 seconds.
Smaller guard circleGUARD_RADIUS: 16.0 changed to 10.0Only actively seek out zombies closer to the player.
Lower flight altitudeChange 2.5 in destination to 1.8Watch whether flying closer to the ground makes the broom hit obstacles more often.
My project record

______ was modified today. The phenomenon before modification is ______. The modified phenomenon is ______. I decided to keep/withdraw this edit because ______.

Run after completion .\gradlew.bat build. The installable JAR is in build/libs, choose the regular version; files marked -sources A JAR is a source code package. Install into the mods folder of the corresponding Fabric 1.20 game, and put in the matching Fabric API.

Bring three things with you when sharing your work

Compiled JAR, editable source project, gameplay and version description. Your own archives, account information and logs do not need to be put into the sharing package.

Stage Checkpoint

Modify the damage or guard radius, and record the difference before and after modification. Once you do that, move on to the next stage.

Project notes

Take your ideas to the next project.

You have completed a complete route from gameplay design, model connection, registration, behavior to debugging and release. You can also use this project map the next time you make a robot, elf, or pet companion.

Project version: 2026-10-04. In-game checks cover automatic spawning, duplicate prevention, walking, flying, day and night attacks, friendly animals, saved ownership, long-distance recall, dimension changes, disabling and restarting the companion, plus solid-wall obstruction. Every scene image on this page was captured in an independent Minecraft 1.20 test world. Some shots hold the character in place to make the motion easier to see, and the observing player has night vision in the night-combat shots. Mind maps explain the program's steps.

Other model resource prompts in the original project belong to existing materials; the learning project of this project only loads the resources that the witch needs. The testing and running steps are based on version 1.20 specified on this page.

Further Reading:Fabric's Minecraft 1.20 Release Notes · 1.20 Pathfinding Entity API · 1.20 World Archive Status API