Skip to content

AI and pathfinding

Status: Implemented (API 1.12). Paths (plan one, or walk a mob along one), doors, ladders, your own path costs, looking, goals inside the vanilla goal selectors (on mod mobs and vanilla mobs), goals for a whole entity type, and targets. Brain internals (memories, sensors, behaviours of villagers and piglins) are Planned; mod goals still run on brain mobs.

This guide builds a guard: it patrols between two posts, opens doors on its way, climbs the ladder to its watchtower, keeps out of lava light, looks at players who come close and attacks the ones it hates. Everything here also works on vanilla mobs, and the same from Lua, C and C++.

Reference: Entity AI (goals, brains, pathfinding), function tables ai, entity, level. Custom mob types: Guide: Custom Entities.

1. How vanilla AI works (and where your code goes)

Most mobs run goals: small state machines (can_use, start, tick, stop) in two lists, the goal selector (moving, looking, jumping) and the target selector (who to attack). Every other tick the selector stops goals that can’t continue and starts the best ones that can: a goal with a lower priority number wins, and two goals that need the same control (a flag: move, look, jump, target) never run together. Villagers, piglins, axolotls and a few others use a brain instead; their goal selector is empty but still runs, so your goals work on them too.

CLight goals are real entries in those lists. They compete with the vanilla goals exactly like vanilla goals compete with each other, so a mod goal with priority 0 and the move flag takes over a zombie’s walking, while one with the look flag only turns its head.

Movement goes through the mob’s navigation: a path from the vanilla path finder (PathNavigation), which the move control then walks. CLight paths use the same path finder, with the mob’s own options (size, water, doors, lava), so they behave like vanilla paths.

2. Planning a path without moving

level:find_path(from, to, opts) asks the path finder and returns a path, or nil when there is none at all:

local path = world:find_path(guard.pos, cl.vec(x, y, z), { entity = guard })
if path then
  local info = path:info()        -- reaches_target, nodes, next, target, last, length
  for _, n in ipairs(path:nodes()) do cl.log(n.x, n.y, n.z) end
end

Like vanilla, a path that can’t reach the target still ends at the closest node it found (info.reaches_target is false). The options:

Option Meaning
entity plan for this mob (its size, node evaluator, water and fire maluses, doors)
size = { w, h } another body (default: the entity’s current hitbox, set_dimensions included; without an entity 0.6 x 1.8)
mode "walk", "swim", "fly", "amphibious" (default: the entity’s own)
can_open_doors closed wooden doors (#mob_interactable_doors) count as open
can_climb also up and down #climbable blocks (ladders, vines, scaffolding)
avoid a function (pos, level) returning an extra cost for a position, false (impossible) or true / nil (fine)
reach how close to the target counts as arrived (default 1, like vanilla moveTo)
max_nodes the search budget (default: follow range x 16, 64…768 like vanilla; at most 4096)
max_fall the highest drop (default 3)
water the water malus (-1 never through water, 0 free)

In C the path is an owned handle:

cl_path_opts o = CL_PATH_OPTS_INIT;
o.entity = guard;
o.doors = 1;
cl_path p = cl_level_find_path(level, from, to, &o);
if (p.h) {
    cl_path_info info;
    cl_path_get_info(p, &info);
    cl_path_release(p);
}

The search runs on the tick thread inside the call, so keep max_nodes modest for paths you plan every tick.

3. Walking

e:path_to(pos, speed, opts) plans and walks: speed multiplies the mob’s movement speed like vanilla goals do (1.0 walks, 1.25 hurries). It returns false when there is no path at all.

if guard:path_to(post_a, 1.0, { can_open_doors = true, can_climb = true }) then
  -- guard:path_state() is "moving", then "done" (or "stuck" when it gave up)
end
  • e:path_done() is vanilla’s isDone: no path, arrived or stuck. e:path_stop() stops.
  • e:path() gives a copy of the current path; e:follow_path(path, speed) walks a path you planned with find_path.
  • API 1.13: e:path_to_entity(target, speed, repath, opts) chases a moving entity: the path is planned again whenever the target moved more than repath blocks (default 1, checked every 4 ticks); within opts.reach the mob waits, and it lasts until e:path_stop() or the target is gone.
  • With can_open_doors, the mob also gets vanilla’s door goal: it opens a door when it bumps into it and closes it behind itself.
  • A mob’s own goals may give it another path the next time they run (a zombie that sees a player). For full control, clear its goals (section 5) or use a mod mob type without goals.

4. Your own path costs

avoid (Lua) or a registered path cost (every language) changes where paths go. The cost is added per node; a negative cost (Lua: false) forbids the node.

cl.ai.path_cost("avoid_light", function(level, pos)
  local light = level:light(pos)
  if light.block >= 12 then return false end    -- never through bright light
  return light.block * 0.5
end)
guard:path_to(tower, 1.0, { cost = "avoid_light" })
-- or one-off: guard:path_to(tower, 1.0, { avoid = function(pos) return pos.y > 60 end })
static float avoid_light(cl_level level, cl_block_pos pos, void* user) {
    cl_light_info li;
    cl_level_light(level, pos, &li);
    return li.block >= 12 ? -1.0f : li.block * 0.5f;
}
cl_path_cost_desc d = CL_PATH_COST_DESC_INIT;
d.id = CL_STR("my_mod:avoid_light");
d.cost = avoid_light;
cl_ai_path_cost_register(&d);

The cost function runs for every node the search looks at, so keep it cheap.

5. Goals

A goal is a table of callbacks. Each one gets the mob, the goal handle and the goal’s own state table:

local function watch(guard)
  return cl.ai.add_goal(guard, 2, {
    flags = { "look" },
    state = { target = nil },
    can_use = function(e, goal, s)
      s.target = e.world:nearest_player(e.pos, 8)
      return s.target ~= nil
    end,
    tick = function(e, goal, s) e:look_at(s.target) end,
    stop = function(e, goal, s) s.target = nil end,
  })
end
Field Meaning
can_use required: start now? (asked every other tick while the goal is not running)
can_continue keep running? (default: can_use)
start, tick, stop the goal’s life; tick runs every other tick like vanilla goals, every tick with every_tick = true
removed the goal left the mob (removed, goals cleared, the mob died or unloaded): free your state here
flags the controls it needs: "move", "look", "jump", "target"
targets put it into the target selector (goals that pick e:set_target(...))
interruptable false: a lower priority number may not take its flags while it runs

The goal handle: goal:running(), goal:remove(), goal:entity(). Changes to goal lists (adding, removing, clearing) take effect at the end of the server tick, never in the middle of a selector run.

Goals are not saved. Add them again when mobs load, or let CLight do it for a whole type:

cl.on("lifecycle.common_setup", function()
  cl.ai.type_goal("minecraft:pig", 4, { can_use = function(e) return e.world:nearest_player(e.pos, 16) ~= nil end,
                                        tick = function(e) e:look_at(cl.vec(0, 80, 0)) end })
  cl.ai.type_goal("my_mod:guard", 1, "my_mod:patrol")   -- a goal type registered with cl.ai.goal_type{ id = "patrol", ... }
end)

Every mob of that type gets the goal when it sets up its goals, also after loading from disk.

e:clear_goals("goals" | "targets" | "all") removes every goal of a list, vanilla ones included (they come back when the mob loads again). Brains are not goal lists and keep running.

From C, a goal type is a struct of callbacks; then cl_ai_add_goal adds it to mobs:

static bool hunt_can_use(cl_entity e, cl_goal g, void* user) { return has_enemy(e); }
static void hunt_tick(cl_entity e, cl_goal g, void* user) { chase(e); }

cl_goal_type_desc d = CL_GOAL_TYPE_DESC_INIT;
d.id = CL_STR("my_mod:hunt");
d.flags = CL_GOAL_MOVE | CL_GOAL_LOOK;
d.can_use = hunt_can_use;
d.tick = hunt_tick;
cl_ai_goal_type_register(&d, NULL);
cl_goal g;
cl_ai_add_goal(guard, 1, CL_STR("my_mod:hunt"), &g);

6. Looking and targets

  • e:look_at(pos_or_entity, speed) turns the head (vanilla LookControl#setLookAt, degrees per tick; default: the mob’s own head speed). It lasts two ticks, so call it from a goal’s tick.
  • e:set_target(player) / e:target() are Mob#setTarget / getTarget. Vanilla attack goals (a zombie’s melee) chase whatever is the target, so a target goal of yours plus the vanilla attack goal makes a mob attack who you choose.

7. Size and pathfinding

Paths use the mob’s current hitbox. A mob that grows with e:set_dimensions(1.9, 2.5) stops fitting through doors and one-wide tunnels at once, and plans around them; e:reset_dimensions() gives the old size back.

Performance

  • Vanilla mobs pay nothing: the CLight path extras (climbing, costs) are off for vanilla navigation, and a mob without mod goals has no extra code in its AI step.
  • A goal callback is a call into your mod every other tick per mob: fine for dozens of mobs, but keep can_use cheap (cache what you can in state).
  • cl.has("ai.paths"), "ai.find_path", "ai.path_doors", "ai.path_climb", "ai.path_cost", "ai.look_at", "ai.goals", "ai.type_goals" and "ai.target" tell a mod these exist.