Skip to content

Entity models and animation

Status: Implemented (API 1.9). glTF 2.0 models for entity types (mod types and vanilla types), skinned meshes with up to 4 bone weights per vertex, morph targets, PBR-lite materials, vanilla block and sky light, layered animation with cross-fades, additive layers and bone masks, morph weights, event markers, root motion, bone look-at and bone transforms, all synced from the server to Celestial clients. Models for block entities and items, normal and roughness maps inside shader packs and level-of-detail meshes are Planned.

This guide takes a model from Blender into the game and makes it move: a type with a model, clips on layers, events that deal damage on the right frame, a head that follows the player, and a particle at the tip of a tail. Everything works the same from Lua, C and C++.

Reference: Rendering (blocks, entities, items), Entities, function tables model, render, entity. Custom entity types: Guide: Custom Entities.

1. How it works

  • The server decides what plays: a mod calls e:anim_play("walk") on the server, and every Celestial client with CLight that sees the entity plays the same clip at the same time (players who arrive later get the current state too). The server also loads the model’s skeleton and clips (no meshes or textures), so clip events, root motion and bone positions work there, on a dedicated server too.
  • Clients draw the model instead of the entity’s vanilla model. Vanilla clients and Celestial clients without your mod see the vanilla fallback of a custom type (or the vanilla model of a vanilla type) and never get a packet they don’t understand.
  • The model is drawn by the game’s own entity renderer: light from the blocks and the sky, the red hurt flash, name tags, glowing outlines, fire, the round shadow and shader packs work like on vanilla entities. Skinning and morphing run on the GPU in one compute pass for all models of a frame; without mod models nothing extra runs.

2. Make the model

Export glTF 2.0 (.glb, or .gltf with its files next to it) and put it in your mod’s assets: assets/<mod id>/models/entity/creature.glb is "<mod id>:models/entity/creature.glb".

Topic What the loader expects
Units and axes 1 glTF metre = 1 block, +Y up. The model faces +Z (glTF’s forward). A model that faces -Z gets yaw_offset = 180.
Meshes Triangles (strips and fans are converted). Several meshes and materials are fine. Up to 2 million vertices.
Skins One skin per mesh, JOINTS_0 / WEIGHTS_0: 4 bones per vertex. Up to 1024 bones. Meshes on plain nodes (no skin) follow their node.
Morph targets POSITION and NORMAL deltas. Name them: glTF has no names for targets, so Blender’s exporter writes extras.targetNames (shape key names), and so should other tools. Unnamed targets are morph0, morph1, …
Materials Base colour (factor and texture), normal map, metallic-roughness texture (roughness in G, metalness in B), alphaMode (OPAQUE, MASK, BLEND), doubleSided. Textures use TEXCOORD_0.
Textures PNG only (embedded or next to the .gltf). Linear filtering with mipmaps; the mipmaps of the base colour are averaged in linear light, so thin bright details don’t darken in the distance. CL_MODEL_NEAREST (Lua nearest = true) keeps pixel art crisp.
Animations Translation, rotation, scale and morph weights; STEP, LINEAR and CUBICSPLINE. Every glTF animation is a clip, named after the animation.
Not supported Draco and meshopt compression, JPEG / KTX2 / WebP images, cameras, lights. The loader refuses such files with a message that says why.

In Blender: File → Export → glTF 2.0, format glTF Binary, include Shape Keys and Animation (one action per clip, Group by NLA Track or Actions), no compression.

Clip settings and events. A JSON file next to the model, named <model>_clips.json, adds what glTF can’t say:

{
  "clips": {
    "walk":   { "seconds": 1.0, "loop": true,  "events": { "step_left": 0.25, "step_right": 0.75 } },
    "attack": { "seconds": 0.6, "loop": false, "events": { "strike": 0.3, "sound:my_mod:creature.bite": 0.25 } }
  }
}

seconds overrides the clip length, loop is the clip’s default playback, events are markers by name and time (an array [{ "time": 0.3, "name": "strike" }] works too). Markers can also live in the glTF file (animation extras: { "loop": false, "events": [...] }) or come from code (model:add_event).

Loading is sandboxed: a model only reads files inside its mod’s folder (no .., no absolute paths), every count, offset and index is checked against the file, and broken files are refused instead of crashing the game.

3. Give a type its model

Call cl.render.entity_model on both sides, right after registering the type:

cl.on("lifecycle.register", function()
  cl.entities.register{ id = "creature", kind = "mob", fallback = "minecraft:zombie", width = 0.8, height = 1.2 }
  local model = cl.render.entity_model("my_mod:creature", "my_mod:models/entity/creature.glb", {
    scale = 1.0,          -- model units to blocks
    yaw_offset = 0,       -- 180 for a model that faces -Z
    default_clip = "idle" -- every creature loops "idle" until you play something else
  })
  print(model:get_info().bones, model:clip_length("walk"))
end)
Option Means
scale Model units to blocks (0 < scale ≤ 64).
yaw_offset Degrees the model is turned so its front faces where the entity looks.
offset A shift in blocks after the scale, cl.vec(0, 0.1, 0).
shadow_radius The round shadow under the entity (default: the fallback’s).
default_clip A clip every entity of the type loops on layer 0 until you play something. Each entity starts at a different point of the clip, so a herd doesn’t move in step.
no_shadow, nearest, double_sided, no_death_tilt Flags (C: CL_MODEL_NO_SHADOW, CL_MODEL_NEAREST, CL_MODEL_DOUBLE_SIDED, CL_MODEL_NO_DEATH_TILT). Dying entities fall over like vanilla mobs unless no_death_tilt.

Vanilla types work too, for client mods that restyle a vanilla mob: cl.render.entity_model("minecraft:cow", "my_mod:models/entity/fluffy_cow.glb"). The entity keeps its vanilla behaviour; only its look changes on the clients with your mod. Players keep their skins.

cl.model.load(path) loads a model without binding it (to read its clips and bones).

4. Play clips

e:anim_play("walk")                                    -- layer 0, the clip's own loop setting, 0.2 s fade
e:anim_play("run", { fade = 0.3, speed = 1.4 })        -- cross-fades from walk
e:anim_play("attack", { layer = 2, loop = false })     -- a one-shot on top, then it fades away
e:anim_stop(2, 0.15)                                   -- fade a layer out (no layer: every layer)
Option Default Means
layer 0 0 to 7. Higher layers are applied on top of lower ones.
loop the clip’s true / false.
fade 0.2 Seconds of cross-fade from the clip that played on the layer (or of fade-in on an empty layer).
speed 1 0 freezes, negative plays backwards.
weight 1 How strongly the layer applies, 0 to 1.
additive false Adds the clip’s difference to the rest pose instead of replacing the pose (section 5).
mask all bones The bones the layer moves, each with its children: { "spine" }. "!tail" leaves a bone out.
start 0 Seconds into the clip.
root_motion false The root bone’s walk moves the entity (section 8).
hold false A one-shot keeps its last frame instead of fading out at the end.

e:anim_clip(layer) is the clip a layer plays ("" when none), e:anim_state(layer) gives playing, time, length, weight, speed, loop and additive.

5. Layers, masks and additive clips

Layers let one creature do several things at once. A typical stack:

e:anim_play("walk")                                                            -- layer 0: the body
e:anim_play("look_around", { layer = 1, mask = { "neck" } })                  -- layer 1: only the neck and head
e:anim_play("breathe", { layer = 2, additive = true, weight = 0.6 })          -- layer 2: on top of everything
e:anim_layer(1, { weight = 0, fade = 0.5 })                                    -- later: fade the head layer out

An additive clip is authored as a change from the rest pose (a breathing chest, a flinch, a limp). It is added on top of whatever the layers below did, so the same breathing works while walking, running or standing. e:anim_layer(layer, { weight, fade, additive, mask, speed }) changes a layer without restarting its clip.

6. Morph targets

Clips can animate morph weights (glTF weights tracks). From code, e:set_morph("inhale", 0.7) sets a weight over what the clips say; e:set_morph("inhale", -1) hands it back to the clips. Weights above 1 exaggerate.

7. Events on the right frame

cl.on("entity.anim_event", function(event)
  if event.client then
    if event.name == "step_left" or event.name == "step_right" then
      cl.sound.play_local(event.entity:position(), "my_mod:creature.step", "hostile", 0.6, 1.0, false)
    end
  elseif event.name == "strike" then
    -- the server deals the damage when the claw actually lands
    local target = cl.entity.server_players()[1]
    if target then target:set_health(target.health - 4) end
  end
end, { filter = "my_mod:creature" })

The event fires on the server (client false) and on every Celestial client that shows the entity (client true): sounds and particles belong on the client side, damage on the server side. The payload has entity, level, type, name, clip, layer, time and client. A looping clip fires its markers every cycle; a marker is never fired twice for one pass. The default_clip fires no events until your code plays something on the entity.

8. Root motion

With root_motion = true the horizontal movement of the root bone (the top bone of the skeleton) stops moving the model and moves the entity instead: the server turns it into the entity’s velocity every tick, so collisions, slopes and water behave like any movement. The vertical bob stays in the animation. e:anim_root_motion() is the last tick’s movement in blocks, for mods that move the entity themselves.

e:anim_play("lunge", { root_motion = true, loop = false })

9. Looking at things

e:anim_look_at({ "neck", "head" }, player, 180, { yaw = 70, pitch = 40 })   -- follow a player's eyes
e:anim_look_at({ "head" }, cl.vec(10, 70, 10))                             -- look at a position
e:anim_look_stop()                                                         -- turn back

The chain is listed parent first; the turn is shared over it, so a neck and a head turn together. speed is in degrees per second, the limits are the total turn left and right (yaw) and up and down (pitch). In C and C++ it is one call with cl_look_at_opts (bones, target, target_entity, speed, max_yaw, max_pitch, weight, enabled).

10. Bones in the world

e:bone_transform("tail_tip") gives where a bone is right now: pos (world position), forward, up and right (unit vectors) and scale. Use it to spawn particles at a mouth, to place a hit box on a swinging arm, or to attach things:

cl.on("entity.tick", function(event)
  if event.client then return end
  local jaw = event.entity:bone_transform("jaw")
  -- a bite reaches 0.6 blocks past the jaw bone
  local reach = jaw.pos + jaw.forward * 0.6
  for _, p in ipairs(cl.entity.server_players()) do
    local d = p:eye_position() - reach
    if d.x * d.x + d.y * d.y + d.z * d.z < 0.5 then cl.log("bitten: %s", p.name) end
  end
end, { filter = "my_mod:creature" })

On the server the pose is the current tick’s; on a client it is the pose at the current time. Bone positions on the server follow the entity’s yaw; on a client, the rendered body yaw.

11. C and C++

static void on_register(const cl_ev_lifecycle* ev, void* user) {
    cl_model_opts mo = CL_MODEL_OPTS_INIT;
    mo.default_clip = CL_STR("idle");
    cl_model model;
    cl_render_entity_model(CL_STR("my_mod:creature"), CL_STR("my_mod:models/entity/creature.glb"), &mo, &model);
}

static void start_walking(cl_entity e) {
    cl_anim_play_opts po = CL_ANIM_PLAY_OPTS_INIT;
    po.root_motion = true;
    cl_entity_anim_play(e, CL_STR("walk"), &po);
}
clight::Entity e = ...;
cl_anim_play_opts po = CL_ANIM_PLAY_OPTS_INIT;
po.layer = 1;
po.additive = true;
po.mask = CL_STR("neck");
if (!e.anim_play("breathe", &po)) clight::log::warn("no breathe clip");
auto tip = e.bone_transform("tail_tip");   // Expected<cl_bone_transform>

12. Shaders, light and speed

  • Light: the vanilla entity light (two directional lights, ambient) from the per-pixel normal of the normal map, the block and sky lightmap, fog and the hurt overlay. Roughness adds a soft highlight that the lightmap dims, so caves stay dark. Models without normal or roughness maps use exactly the vanilla entity shading.
  • Shader packs draw models through the pack’s entity programs (gbuffers_entities) and its shadow pass; since API 1.15 normal and roughness maps reach them as LabPBR normals / specular, and the material’s alpha mode is kept.
  • Speed: one compute dispatch skins and morphs every visible model of the frame on the GPU (the CPU only samples the clips: microseconds per entity); the models are then ordinary instanced entity draws. Nothing runs and nothing is allocated per frame without mod models. Keep game models lean (a few thousand triangles, under ~100 bones): 50 such creatures cost well under a millisecond.

13. Items, blocks and LOD

The same models also work for items and blocks / block entities, with level-of-detail meshes (MSFT_lod or _lodN names) and the file’s alpha mode (API 1.15): see Item and Block Models. Material variants and per-entity parameters are in Post-processing, Fog, Sky and Camera.