Entity Tutorial
This guide walks you through creating a custom animated entity from scratch — from BlockBench model to in-game mob with walking animations.
Overview
Creating a custom entity in Katton requires four pieces:
| Piece | Where | Format |
|---|---|---|
| Model | BlockBench export → .kt class | Java-style EntityModel subclass |
| Animation | BlockBench export → .kt class | AnimationDefinition definitions |
| Entity Class | Your script | Monster subclass with AnimationState fields |
| Textures | Resource pack or kattonpacks | .png file |
Step 1: Create Your Model in BlockBench
- Design your entity model in BlockBench
- File → Export → Export Java Entity Model
- Choose Mojang mappings, Minecraft version 1.19+
- Save the
.javafile, right click it in IDEA and choose "Convert Java File to Kotlin File" to.kt(Katton uses.ktextension for better IDE support)
The export produces a class like this (renamed to .kt):
class Zombie1Model<T : EntityRenderState>(root: ModelPart) : EntityModel<T>(root) {
companion object {
val LAYER_LOCATION = ModelLayerLocation(id("test", "zombie1"), "main")
fun createBodyLayer(): LayerDefinition { ... }
}
}TIP
You may manually need to import some classes (e.g., ModelPart, ModelLayerLocation, LayerDefinition) and fix any syntax issues from the Java → Kotlin conversion (e.g. EntityModel<T> instead of EntityModel<T?>).
IMPORTANT
Change the namespace in LAYER_LOCATION from "modid" to your actual mod namespace (e.g., "test"). This ID must match what you use when registering the entity.
Step 2: Export Animations
- In BlockBench, switch to Animate mode
- Create your animations (idle, walk, attack, etc.)
- File → Export → Export Java Animation
- Save as
.javafile. There is no need to convert to Kotlin — Katton will compile it as-is and you can reference the definitions from your scripts. - Fix any syntax issues from the Java export.
TIP
Java in Katton
Katton can compile both .kt and .java files in your script folders. This allows you to write your main logic in Kotlin while you can still use Java if needed (e.g., for BlockBench exports or if you prefer Java for certain tasks). Katton will compile all java files first with Java Compiler, and then compile Kotlin files with access to the compiled Java classes, which means you can reference Java classes from Kotlin, but not the other way around.
CAUTION
Bone name mismatch is a common crash. If your animation references a bone name that doesn't exist in the model (e.g., "item_display" from BlockBench locators), bake() will throw an exception. Either add empty placeholder bones to your model, or remove those channels from the animation.
Step 3: Write Your Entity Class
class Zombie1Entity(type: EntityType<out Monster>, level: Level) : Monster(type, level) {
val idle = AnimationState()
val walk = AnimationState()
init { idle.start(tickCount) }
override fun tick() {
super.tick()
if (level().isClientSide) {
// Publish animation states via the cross-ClassLoader bridge
KattonBridge["anim:${id}:idle"] = idle
KattonBridge["anim:${id}:walk"] = walk
// Control which animation plays
if (deltaMovement.horizontalDistanceSqr() > 1.0e-7) {
walk.startIfStopped(tickCount); idle.stop()
} else {
idle.startIfStopped(tickCount); walk.stop()
}
}
}
}CAUTION
Critical: ClassLoader Isolation
Katton compiles server scripts and client scripts in separate ClassLoaders. This means:
- You cannot cast between the server and client versions of your entity class
- Static fields in script classes are ClassLoader-local
Solution: Use KattonBridge to share data between ClassLoaders. The bridge lives in Katton's mod ClassLoader, so both sides see the same map.
// Entity side (server or client script):
KattonBridge["anim:${entity.id}:idle"] = idleAnimationState
// Renderer side (client script — handled automatically by registerAnimatedEntityRenderer):
val state = KattonBridge["anim:${entityId}:idle"] as? AnimationStateStep 4: Register Entity (Server Side)
@ServerScriptEntrypoint
fun initZombie() {
registerNativeEntity("test:zombie1", RegisterMode.RELOADABLE,
configure = {
dimensions(0.6f, 1.95f)
maxHealth(20.0)
movementSpeed(0.23)
attackDamage(3.0)
withSpawnEgg()
followRange(64.0)
}
) { props ->
EntityType.Builder.of(::Zombie1Entity, MobCategory.MONSTER)
.sized(props.dimensions.width, props.dimensions.height)
.build(ResourceKey.create(Registries.ENTITY_TYPE, props.id))
}
}Step 5: Register Renderer + Animations (Client Side)
Use the high-level registerAnimatedEntityRenderer — one call handles model layer, renderer construction, and animation wiring:
@ClientScriptEntrypoint
fun initZombieRenderer() {
registerAnimatedEntityRenderer<LivingEntityRenderState, Zombie1Model<LivingEntityRenderState>>(
entityTypeId = "test:zombie1",
modelLayer = Zombie1Model.LAYER_LOCATION,
bodyLayer = { Zombie1Model.createBodyLayer() },
modelFactory = { root -> Zombie1Model(root) },
texture = id("test", "textures/entity/zombie1.png"),
animations = mapOf(
"idle" to Zombie1Animation.idle,
"walk" to Zombie1Animation.walkforward
)
)
}NOTE
The animations map is fully flexible — add as many animations as you want. The default logic plays "walk" when the entity moves and "idle" otherwise. To customize, pass an animate callback:
animations = mapOf("idle" to idleDef, "walk" to walkDef, "attack" to attackDef),
animate = { model, entity, state, baked ->
model.resetPose()
// Your custom animation logic here...
val state = KattonBridge["anim:${entity.id}:attack"] as? AnimationState
baked["attack"]?.apply(state, state.ageInTicks)
}Step 6: Place Your Texture
The texture path is specified in registerAnimatedEntityRenderer:
texture = id("test", "textures/entity/zombie1.png")This expects the file at assets/test/textures/entity/zombie1.png in your resource pack or mod resources.
MC 26.1 Animation API
If you need to write a custom renderer (without registerAnimatedEntityRenderer), be aware that MC 26.1 changed the animation API:
| Old API (1.21.11) | New API (26.1) |
|---|---|
EntityModel.animate(state, def, age) | def.bake(model.root()).apply(animState, age) |
AnimationDefinition used directly | Must bake() into KeyframeAnimation first |
animate() on model | apply() on KeyframeAnimation |
And always call model.resetPose() before applying animations each frame — otherwise transforms accumulate across frames, producing chaotic motion.
Common Pitfalls
"My entity is invisible"
- Client scripts didn't run — Katton only runs client scripts on explicit
/katton reloador first singleplayer world join (after our recent fix). Try/katton reload. - ClassLoader cast crash — Check the log for
ClassCastException. UseregisterAnimatedEntityRendererwhich handles this automatically, or useMonster(not your entity class) as the renderer's type parameter. - Texture missing — Check the path:
assets/<ns>/textures/entity/<name>.png. - Model layer mismatch —
LAYER_LOCATIONnamespace must match the registration.
"Animations are messy / chaotic"
- Missing
resetPose()— Callmodel.resetPose()before every animation application. - Layered animations — Only apply ONE animation per frame. Applying idle AND walk simultaneously accumulates transforms.
bake()fails silently — If animation references bones not in the model,bake()throws. Catch it withrunCatching.
"Crash on /katton reload"
- Delete the cache —
<gameDir>/.katton/compiled-script-cache/may contain stale compiled scripts. - Kill old entities — After reload, existing entities are from the old ClassLoader. Kill and respawn them.
"Animation doesn't play"
- AnimationState not started — Call
animationState.start(tickCount)in your entity's constructor ortick(). - Bridge key mismatch — Ensure
KattonBridge["anim:${id}:idle"]on the entity side matches what the renderer expects.
Complete Example
Here's the full script for a custom zombie entity with idle + walk animations (assuming model classes are in the model package):
package qwq
import model.Zombie1Animation
import model.Zombie1Model
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState
import net.minecraft.core.registries.Registries
import net.minecraft.resources.ResourceKey
import net.minecraft.world.entity.AnimationState
import net.minecraft.world.entity.EntityType
import net.minecraft.world.entity.MobCategory
import net.minecraft.world.entity.monster.Monster
import net.minecraft.world.level.Level
import top.katton.api.ClientScriptEntrypoint
import top.katton.api.ServerScriptEntrypoint
import top.katton.api.registry.registerAnimatedEntityRenderer
import top.katton.api.registry.registerNativeEntity
import top.katton.bridge.KattonBridge
import top.katton.registry.RegisterMode
import top.katton.registry.id
class Zombie1Entity(type: EntityType<out Monster>, level: Level) : Monster(type, level) {
val idle = AnimationState()
val walk = AnimationState()
init { idle.start(tickCount) }
override fun tick() {
super.tick()
if (level().isClientSide) {
KattonBridge["anim:${id}:idle"] = idle
KattonBridge["anim:${id}:walk"] = walk
if (deltaMovement.horizontalDistanceSqr() > 1.0e-7) {
walk.startIfStopped(tickCount); idle.stop()
} else {
idle.startIfStopped(tickCount); walk.stop()
}
}
}
}
@ServerScriptEntrypoint
fun initZombie() {
registerNativeEntity("test:zombie1", RegisterMode.RELOADABLE,
configure = {
dimensions(0.6f, 1.95f); maxHealth(20.0); movementSpeed(0.23)
attackDamage(3.0); withSpawnEgg(); followRange(64.0)
}
) { p -> EntityType.Builder.of(::Zombie1Entity, MobCategory.MONSTER)
.sized(p.dimensions.width, p.dimensions.height)
.build(ResourceKey.create(Registries.ENTITY_TYPE, p.id)) }
}
@ClientScriptEntrypoint
fun initZombieRenderer() {
registerAnimatedEntityRenderer<LivingEntityRenderState, Zombie1Model<LivingEntityRenderState>>(
entityTypeId = "test:zombie1",
modelLayer = Zombie1Model.LAYER_LOCATION,
bodyLayer = { Zombie1Model.createBodyLayer() },
modelFactory = { root -> Zombie1Model(root) },
texture = id("test", "textures/entity/zombie1.png"),
animations = mapOf(
"idle" to Zombie1Animation.idle,
"walk" to Zombie1Animation.walkforward
)
)
}