Entity gameplay
Status: Implemented (API 1.10). Entity queries (box, nearest player, UUID) with type, type tag, tag and category filters; entity tags; attributes with stackable modifiers; mob effects; synced per-entity data for Celestial clients; hurting with damage types, mod damage types with vanilla-style death messages, knockback control; natural spawn rules from code; passengers, with seats on model bones. Entity AI (pathfinding, goals) is Planned (Entity AI (goals, brains, pathfinding)).
This guide builds a small boss mob: it finds nearby players, buffs itself with attributes and effects, tells clients how charged it is, zaps players with its own damage type, ignores knockback, spawns in dark forests and carries a rider on its back. Everything works on vanilla entities too, and the same from Lua, C and C++.
Reference: Entities, function tables entity, level, registry, events. Custom types: Guide: Custom Entities. Models and bones: Guide: Entity Models and Animation.
1. Finding entities
level:entities_in_box(min, max, query) returns every entity whose hitbox touches the box (vanilla’s Level#getEntities). The query filters:
| Field | Meaning |
|---|---|
type |
an entity type id ("minecraft:zombie", "my_mod:brute") or a type tag ("#minecraft:undead"; mod types are in no vanilla tag) |
tag |
an entity tag (/tag, e:add_tag) |
category |
"monster", "creature", "ambient", "axolotls", "underground_water_creature", "water_creature", "water_ambient", "misc" or "player" |
exclude |
an entity to leave out (usually yourself) |
limit |
at most this many (with nearest, the nearest ones) |
| flags | nearest (sorted by distance to the box centre), living, no_players, spectators (included; left out by default), dead (dying entities included) |
local victims = world:entities_near(boss.pos, 16, { category = "player", nearest = true })
local undead = world:entities_in_box(cl.vec(x - 8, y - 4, z - 8), cl.vec(x + 8, y + 4, z + 8), { type = "#minecraft:undead" })
local nearest = world:nearest_player(boss.pos, 32) -- nil when nobody is in range (spectators never count)
local same = cl.entity.by_uuid(saved_uuid) -- any level of the server; "8-4-4-4-12" text or { hi, lo }
In C the flags are CL_QUERY_* and the result is the usual two-call array:
cl_entity_query q = CL_ENTITY_QUERY_INIT;
q.category = CL_STR("player");
q.flags = CL_QUERY_NEAREST;
cl_entity found[16];
size_t n = 0;
cl_level_entities_in_box(level, lo, hi, &q, found, 16, &n); // n = how many there are (may be more than 16)
The box is at most 1024 blocks on a side. On a client the query sees the client’s copies; entity tags, type tags and vanilla categories are unknown there (only "player" works as a category).
e:category() names an entity’s MobCategory ("player" for players), e:add_tag / remove_tag / has_tag work like /tag.
2. Attributes and effects
Attributes are vanilla’s (/attribute): e:attribute(id) is the current value, e:attribute_base(id) the base, e:set_attribute_base(id, v) changes the base. Modifiers have their own ids, so several stack and each can be removed alone; the same id replaces the old modifier:
boss:set_attribute_base("minecraft:max_health", 200) -- the health follows a lower max health at once
boss:add_attribute_modifier("movement_speed", "rage", 0.5, "add_multiplied_base")
boss:add_attribute_modifier("attack_damage", "rage", 4) -- "add_value" by default
boss:remove_attribute_modifier("movement_speed", "rage")
Bare modifier ids get your mod’s namespace ("rage" is "my_mod:rage"). The value is calculated like vanilla (ADD_VALUE, then ADD_MULTIPLIED_BASE on that sum, then ADD_MULTIPLIED_TOTAL) and clamped to the attribute’s range; syncable attributes reach clients as usual. Errors: not_found for an unknown attribute, unsupported when the entity does not have it (pigs have no flying_speed), like /attribute.
Effects are LivingEntity#addEffect: e:add_effect(id, ticks, amplifier_or_opts) returns false when nothing changed (the entity is immune, or a stronger or longer effect is already on). ticks defaults to 600, -1 is infinite, instant effects apply at once.
boss:add_effect("minecraft:resistance", -1, { amplifier = 1, particles = false, icon = false })
local e = boss:effect("resistance") -- { amplifier, duration, ambient, particles, icon } or nil
boss:remove_effect("resistance")
boss:clear_effects() -- like milk; returns how many
3. Synced data
Your client code often needs state only the server knows: how charged an attack is, which mode a boss is in. e:set_synced(key, value) keeps a small value on the entity (players too) and sends it to every Celestial client with CLight that sees the entity, at once and to players who see it later. Values are booleans, numbers or strings; nil clears.
-- server
boss:set_synced("charge", 0.75)
boss:set_synced("mode", "enraged")
-- client
cl.on("entity.synced", function(ev)
if ev.entity:synced("mode") == "enraged" then ev.entity:anim_play("roar", { layer = 1, loop = false }) end
end, { filter = "my_mod:mode" })
- Keys are ids; bare keys get your mod’s namespace. At most 64 keys per entity, strings at most 1024 bytes.
- Setting the same value again sends nothing, so it is fine to set it every tick.
- The data is saved with the entity (
clight:synced, vanilla ignores it); a player’s data lasts for the session. - Vanilla clients get nothing: the data goes only to clients that registered the
clight:entitieschannel, which Celestial clients with CLight do when they join.
In C: cl_entity_set_synced_bool / _number / _string, cl_entity_clear_synced, and on either side cl_entity_synced_type, cl_entity_synced_bool / _number / _string.
4. Combat
target:hurt(amount, damage_type, opts) sends damage through the whole vanilla pipeline: invulnerability frames, armor, enchantments, resistance, shields, totems, entity.damage, death and its message. It returns true when the target took damage.
victim:hurt(6, "my_mod:zap", { attacker = boss }) -- or simply victim:hurt(6, "my_mod:zap", boss)
victim:hurt(2, "minecraft:magic", { position = boss.pos, knockback = 0 })
opts: attacker (the responsible entity: kill credit, death message, last hurt by), direct (the entity that hit, such as a projectile; default the attacker), position (where the damage comes from) and knockback (a multiplier on this hit’s knockback: 0 none, 1 vanilla). Non-living targets react like to any hit (end crystals explode, item frames drop their item).
Your own damage types
cl.on("lifecycle.register", function()
cl.damage_types.register{
id = "zap",
death_message = "%1$s was zapped",
death_message_player = "%1$s was zapped by %2$s",
tags = { "minecraft:bypasses_armor" },
vanilla_fallback = "minecraft:lightning_bolt",
}
end)
Death messages work like vanilla’s: the key is death.attack.<message_id> (default my_mod.zap) with the attacker as %2$s, or death.attack.<message_id>.player when only an earlier attacker gets the credit. Put the real text into your language file; the two death_message strings are sent as the translation’s fallback, so players without your mod (vanilla clients too) read the English text. tags puts the type into damage type tags (minecraft:bypasses_armor, minecraft:is_fire, minecraft:no_knockback, …), scaling, effects and exhaustion match the vanilla damage type fields. Mod damage types are not in the synced registry: the damage event packet names vanilla_fallback (default minecraft:generic), so vanilla clients play the right hurt animation.
Knockback
e:knockback(strength, dx, dz) is LivingEntity#knockback: it pushes away from the direction (dx, dz) (the attacker minus the target, like vanilla; hits use strength 0.4) against the knockback resistance. Every knockback (hits, hurt, knockback, not explosions) first fires entity.knockback: change strength, dx, dz, or cancel it.
cl.on("entity.knockback", function(ev) ev:cancel() end, { filter = "my_mod:brute" }) -- the brute stands its ground
5. Natural spawning
cl.registry.spawn_rule{...} adds your mob to the biome spawn lists, exactly like a data pack entry, so vanilla’s rules decide the rest: the category’s mob cap, pack sizes, the 24-block minimum distance, despawning.
cl.on("lifecycle.register", function()
cl.registry.spawn_rule{
type = "brute", category = "monster",
biomes = { "minecraft:dark_forest", "#minecraft:is_taiga" },
weight = 20, min_group = 1, max_group = 2,
max_light = 7, min_player_distance = 32, cap = 4,
condition = function(level, type, pos) return pos.y > 60 end,
}
end)
| Field | Meaning |
|---|---|
type |
a mob type, mod or vanilla (bare ids get your namespace) |
biomes |
biome ids and #tags; empty: every biome |
category |
the MobCategory it spawns and counts in (default: the type’s) |
weight, min_group, max_group |
the weighted list entry and pack size |
placement |
"on_ground", "in_water", "in_lava", "anywhere"; default by category (water categories in water) |
check |
the base check: "mob" (a valid floor), "monster" (dark enough, like monsters), "animal" (grass and light), "none"; default by category |
min_light, max_light |
raw brightness 0…15 the position needs |
min_player_distance |
more than vanilla’s 24 blocks |
cap |
at most this many of the type in the level’s loaded chunks |
condition |
your last word on a position; called on the server thread |
placement and check belong to the type (the first rule that sets them); the other fields belong to each rule. Register rules in lifecycle.register, before the server starts. Spawners and /summon work for mod mob types too.
6. Passengers
boss:set_passenger(skeleton) -- the skeleton rides the boss
skeleton:ride(boss) -- the same, from the rider's side
boss:set_passenger(player, { force = false }) -- fails ("conflict") when the boss already carries someone
for _, p in ipairs(boss:passengers()) do ... end -- the first one controls the vehicle
skeleton:vehicle() -- nil when not riding
skeleton:dismount() -- to the vehicle's dismount spot
boss:eject_passengers()
force (default true) rides even where the vehicle would refuse, like /ride and saved passengers. Players can ride, but they can’t be vehicles. Riding yourself or your own passenger fails with conflict.
Bone seats: with a glTF model on the vehicle (Guide: Entity Models and Animation) a rider can sit on a bone, following the animation:
boss:set_passenger(skeleton, { bone = "back", offset = { 0, 0.4, -0.1 } }) -- bone space: +X right, +Y up, +Z forward
The server places the rider at the bone every tick, and Celestial clients with the mod do the same on their copy. Vanilla clients see the fallback’s normal seat. Player riders always use the vanilla seat (their client moves them).
7. C and C++
Every function above is a plain C function (cl_level_entities_in_box, cl_entity_add_attribute_modifier, cl_entity_hurt, cl_damage_type_register, cl_spawn_rule_register, cl_entity_set_passenger, …) with a *_INIT descriptor where it takes options; the C++ wrappers return Expected<>:
clight::Entity boss = ...;
cl_hurt_opts h = CL_HURT_OPTS_INIT;
h.attacker = boss.raw();
for (clight::Entity p : level.entities_in_box(lo, hi, &q).value_or({})) p.hurt(6.0F, "my_mod:zap", &h);
boss.set_synced_number("my_mod:charge", 0.75);
The spawn condition is a cl_spawn_condition_fn (bool (*)(cl_level, cl_str type, cl_block_pos, void* user)).
8. Costs
Queries walk only the chunk columns the box touches. Synced data costs nothing until a mod sets it, and a set that does not change the value sends nothing. Spawn rules only add list entries (none without mods). The knockback hook is one check per knockback while no mod listens.