Mixins
Status: Partly implemented. The mixin core (API 1.2:
cl.mixin.*,CL_MIXIN, manifest"mixins", ordering, conflicts, invokers) and 93 targets with 50 named points and values work: 56 game targets (41 since API 1.5; player movement and destroy progress, item wear and item entities, inventories and menus, hoppers, projectiles and loot since API 1.14), 19 client targets (rendering, the HUD, screens, input, chat, sounds, particles, raw packets, the attack cooldown) plus the local player’s prediction sites since API 1.16, and 18 world and data targets (world generation, chunks, commands, recipes, loot tables, tags, /reload, the registry freeze) since API 1.17.cl.has("mixins")istrue. Planned:call:sites, accessor fields. The engineering design isdocs/clight/MIXINS.md.
Events and behaviour overrides (Events, behaviours and mixins) are the simple path. They cover most mods. When you need to change how the game does something inside one of its own functions, mixins are the powerful path: you can run code at the start or end of a game function, at named places inside it, change a value it uses, wrap it, or replace it.
CLight mixins work like the SpongePowered Mixin you may know from Java modding, with two differences:
- They are safe. Your mixin code still runs in the sandbox (Lua or WebAssembly). Nothing patches the game’s machine code. The engine compiles named targets into its own functions, and your callback attaches to a target.
- They don’t break on updates. A target has a permanent name like
entity.living.hurt, not a C++ function name or a line number. When Celestial’s code changes, the target moves with it. A mod written today keeps working on later builds.
A target nobody uses costs the game practically nothing (one memory read and a branch; the C5 A/B run measured no difference in tick time), and a mixin that changes nothing leaves the game exactly vanilla: the identity tests hook every target and sub-site with pass-through callbacks and get a bit-identical world (the server), bit-identical generated chunks (world generation), bit-identical packets and player state (the client game) and pixel-identical frames (rendering, the HUD and screens).
The courses teach mixins step by step, with a project each: the Lua, C and C++ courses on the Celestial website, chapter “Mixins”.
Your first mixin
Halve fall damage for every living entity:
cl.mixin.inject("entity.living.hurt", "head", function(ci)
if ci.args.source.type == "minecraft:fall" then
ci.args.amount = ci.args.amount / 2
end
end)
static cl_ident g_fall; /* "minecraft:fall", looked up once in mod_main */
static void half_fall(cl_mixin_ci* ci, void* user) {
(void)user;
cl_mx_entity_living_hurt_args* a = cl_mx_entity_living_hurt_args_of(ci);
if (a->source.type == g_fall) a->amount *= 0.5f;
}
CL_MIXIN(entity_living_hurt, head, half_fall);
static cl_result mod_main(void) {
g_fall = cl_ident_intern(CL_STR("minecraft:fall"));
return CL_OK;
}
CLIGHT_MOD_ENTRY(mod_main)
static cl_ident g_fall; // set in on_construct: g_fall = clight::core::ident_intern("minecraft:fall");
CL_MIXIN(entity_living_hurt, head, [](clight::mx::EntityLivingHurt::Ci& ci) {
if (ci.args().source.type == g_fall) ci.args().amount *= 0.5f;
});
ci is the callback info. It carries the function’s arguments (ci.args), the owning object (ci.self, here the entity being hurt), and controls such as ci:cancel(). In Lua, ids arrive as strings ("minecraft:fall"); in C and C++ they are cl_ident numbers, so compare against an id you looked up once with cl_ident_intern.
Where you can inject
The second argument says where your code runs:
at |
Runs | You can | Java Mixin equivalent |
|---|---|---|---|
"head" |
Before the function body | Change the arguments marked !, or cancel the call (on cancellable targets) and choose the result |
@Inject(at = HEAD) |
"return" |
After the body | Read and change the result (ci.ret) |
@Inject(at = RETURN) |
"point:<name>" |
At a named place inside the body | Read the local values there, change the ones marked !, and skip the next step where the point allows it |
@Inject(at = @At(...)) |
"value:<name>" |
When the body uses a named value | Replace the value (ci.value) |
@ModifyVariable, @ModifyArg, @ModifyConstant |
"call:<name>" |
When the body makes a named call | Change the call’s arguments, call it (ci:call_original()) or replace it. Planned: no shipped target has a call site yet. |
@Redirect, @WrapOperation |
"wrap" |
Around the whole body | Run code before and after ci:call_original() |
@WrapMethod |
"overwrite" |
Instead of the body | Replace the function. Only one mod can do this per target. | @Overwrite |
Every target has a reference entry with its arguments, points and values in Mixin targets, generated from client/clight/idl/mixins.idl. ! marks what a mixin may change.
More examples:
-- no knockback from any damage (the point's cancel skips only the knockback)
cl.mixin.inject("entity.living.hurt", "point:before_knockback", function(ci) ci:cancel() end)
-- critical hits do double damage instead of 1.5x
cl.mixin.inject("player.attack", "value:crit_multiplier", function(ci) ci.value = 2.0 end)
-- mobs drop twice the experience
cl.mixin.inject("entity.mob.xp_reward", "return", function(ci) ci.ret = ci.ret * 2 end)
-- only zombies: a filter is checked before your code runs, so it's cheap
cl.mixin.inject("entity.living.tick", "head", on_zombie_tick, { filter = "minecraft:zombie" })
The named points and values
| Target | Points | Values |
|---|---|---|
entity.living.hurt |
after_blocking {amount!, blocked}, before_knockback {strength!, dx, dz} (cancel skips the knockback), before_death |
invulnerability_ticks (20), knockback_strength (0.4) |
entity.living.actually_hurt |
after_armor, after_magic |
|
entity.living.cause_fall_damage |
safe_distance |
|
entity.mob.check_despawn |
far_distance (128), random_chance (800) |
|
player.attack |
before_sweep (cancel skips the sweep) |
crit_multiplier (1.5) |
player.food_tick |
exhaustion_threshold (4.0) |
|
player.respawn |
before_place {x!, y!, z!, yaw!, pitch!} |
|
block.place_item |
placement_state |
|
player.move (1.14) |
moved_wrongly_threshold (0.0625) |
|
item.entity_tick (1.14) |
despawn_age (6000) |
|
loot.block_drops (1.14) |
each_drop {item!, count!} once per dropped stack (count 0 removes it) |
|
loot.entity_drops (1.14) |
each_drop {item!, count!} |
|
client.render.world (1.16) |
before_entities (cancel: no entities, block entities, particles or weather this frame) |
|
client.render.fog (1.16) |
computed {environmental_start!, environmental_end!, render_distance_start!, render_distance_end!, sky_end!, cloud_end!, red!, green!, blue!} |
|
client.camera.setup (1.16) |
computed {x!, y!, z!, yaw!, pitch!, fov!} |
|
client.render.hud (1.16) |
one skip point per layer: camera_overlays, crosshair, hotbar, player_health, vehicle_health, contextual_bar, selected_item_name, overlay_message, title, chat, effects, boss_bars, scoreboard, tab_list, subtitles |
| worldgen.biome.fill (1.17) | each_cell {qx, qy, qz, biome!} once per 4 x 4 x 4 cell | |
| worldgen.terrain.build (1.17) | skip points before_fill, before_surface, before_carvers | |
| command.suggest (1.17) | each_suggestion {suggestion!} (cancel: dropped) | |
| recipe.match (1.17) | matched {recipe} (cancel: refused) | |
| loot.table.roll (1.17) | each_drop {item!, count!} | |
Client targets (API 1.16)
client.tick, client.render.world, client.render.sky, client.render.fog, client.camera.setup, client.render.hud, client.render.entity, client.screen.init / render / key, client.input.key / mouse_move, client.chat.receive / send, client.sound.play, client.particle.add, client.player.attack_cooldown, net.packet.receive / send, plus the local_player variants of entity.living.travel / jump / jump_power, player.move (the movement packet the client sends) and player.destroy_progress (the client’s mining prediction). The target list has each one’s arguments and what cancelling means.
-- hide the hotbar and the hearts (needs "ui.inject")
cl.mixin.inject("client.render.hud", "point:hotbar", function(ci) ci:cancel() end)
cl.mixin.inject("client.render.hud", "point:player_health", function(ci) ci:cancel() end)
-- a softer fog on this client only
cl.mixin.inject("client.render.fog", "point:computed", function(ci) ci.locals.render_distance_start = ci.locals.render_distance_start * 0.5 end)
-- no flame particles
cl.mixin.inject("client.particle.add", "head", function(ci) ci:cancel() end, { filter = "minecraft:flame" })
-- read every time packet (needs "packets.raw"); ci.args.body is a buffer
cl.mixin.inject("net.packet.receive", "head", function(ci) cl.log(ci.args.id, ci.args.body:size()) end, { filter = "minecraft:set_time" })
World and data targets (API 1.17)
- World generation (run on the generator’s worker threads, WebAssembly mods only):
chunk.generate.stage(one status of one chunk),worldgen.structure.start,worldgen.biome.fill,worldgen.terrain.build,worldgen.feature.place,worldgen.structure.place. - Chunks:
chunk.load,chunk.unload,chunk.save. - Commands:
command.execute(variantstopfor players, the console and command blocks,functionfor function lines),command.register(the vanilla commands at server start),command.suggest(tab completion). - Recipes, loot, tags:
recipe.match(variantscrafting,cooking),recipe.assemble,loot.table.roll(every loot table: chests, fishing, bartering, vaults, /loot, and the block and mob tables),tag.contains. - Data:
data.reload(/reload),data.registry_freeze.
-- /seed doesn't exist on this server
cl.mixin.inject("command.register", "head", function(ci) ci:cancel() end, { filter = "minecraft:seed" })
-- "/spawn" runs "/tp @s 0 80 0"
cl.mixin.inject("command.execute", "head", function(ci)
ci.args.command = cl.text.literal("tp @s 0 80 0")
end, { filter = "minecraft:spawn" })
-- crafting sticks gives 8 instead of 4
cl.mixin.inject("recipe.assemble", "head", function(ci) ci.args.count = 8 end, { filter = "minecraft:stick" })
-- fishing catches only cod
cl.mixin.inject("loot.table.roll", "point:each_drop", function(ci)
ci.locals.item = "minecraft:cod"
end, { filter = "minecraft:gameplay/fishing" })
World generation needs C or C++ (WebAssembly): Lua can’t attach to a worker-thread target (CLL-7012). The same chunk can be generated more than once (the generator drops far generation chunks and makes them again), so a worldgen callback must give the same answer for the same frame every time: decide from the arguments (and your settings), never from a counter or the clock.
/* no glowstone blobs in the Nether */
static void cancel(cl_mixin_ci* ci, void* user) { (void)user; cl_mixin_cancel(ci); }
CL_MIXIN(worldgen_feature_place, head, cancel, .filter = "minecraft:glowstone");
CL_MIXIN(worldgen_feature_place, head, cancel, .filter = "minecraft:glowstone_extra");
/* a void End: no noise fill and no surface */
CL_MIXIN_SITE(worldgen_terrain_build, point, before_fill, cancel, .filter = "minecraft:the_end");
CL_MIXIN_SITE(worldgen_terrain_build, point, before_surface, cancel, .filter = "minecraft:the_end");
Sub-sites are append-only per target: later builds add more (planned, for example: the call sites and the totem’s before_consume), and the existing ones never change.
Several mods on one target
Mods share targets. Everything is a chain:
- Callbacks run in priority order (
priority = "early", a number, …; the same scale as events:first-2000,early-1000,normal0,late1000,last2000,monitor10000), then in the order yourbefore/afteroptions ask for, then in mod load order. The order is the same on every computer. - Each callback sees what the previous one left. Two mods that halve damage give a quarter.
- After a head cancels, the heads after it are skipped unless they pass
receive_cancelled = true(then they are read-only).monitorcallbacks are always read-only. wrapmixins getci:call_original(), which runs the rest of the chain. Always call it unless your mod’s purpose is to replace the behaviour. Otherwise you hide the mods after you, and the Mods screen and/clight mixins conflictssay so (“mod A hides mod B”).overwriteis the only exclusive kind. If two mods overwrite the same target, the earlier one wins and only the loser’s overwrite is turned off (CLL-7005); the rest of that mod keeps running. Mark yoursoptional = trueif your mod can live without it. Preferwrap, which composes. Overwrites must be made before the registries freeze (at load time).
Invokers and accessors
- Invokers call a target as a function:
cl.mixin.invoke("entity.living.heal", entity, 4.0). Other mods’ mixins on that target run too, just like calling the game’s method. The invokable targets in API 1.5 areentity.living.hurt,entity.living.heal,entity.living.knockback,player.exhaustandplayer.give_xp. - Accessors read and write named engine fields that have no normal API function, such as
cl.accessor("entity.living.invulnerable_time"):set(entity, 0). The accessor API exists (cl.accessor,cl_accessor_lookup/get/set,clight::accessor<V>), but no accessor fields ship yet: they are Planned. Until thencl.accessor(...)raisesnot_found, andcl_accessor_lookupreturnsCL_E_NOT_FOUND.
Declaring mixins up front
You can also list mixins in clight.mod.json. They are registered before your code starts, and clight check and the Celestial website can verify them (the project page shows what your mod changes):
"mixins": [
{ "target": "entity.living.hurt", "at": "head", "handler": "scripts/combat.lua#on_hurt" },
{ "target": "player.attack", "at": "overwrite", "handler": "attack", "optional": true }
]
For C and C++, CL_MIXIN(...) records the same information inside your .wasm file automatically, and clight check reads it from there.
clight check reports an unknown target (CLL-7001, with a “did you mean”), an unknown point or value (CLL-7002, with the list the target has), a target newer than your API (CLL-7003) and a missing permission (CLL-7007).
C and C++ details
CL_MIXIN(target, head|return|wrap|overwrite, fn, opts...)andCL_MIXIN_SITE(target, point|value|call, name, fn, opts...)declare a mixin statically. Options are designated initializers in this order:.priority,.flags,.filter,.before,.after, for exampleCL_MIXIN_SITE(player_attack, value, crit_multiplier, crit, .priority = CL_PRIORITY_LATE)orCL_MIXIN(entity_living_hurt, head, fn, .filter = "minecraft:rabbit"). They are registered after your module is instantiated and beforeclight_mod_mainruns.- At runtime: fill a
cl_mixin_desc(CL_MIXIN_DESC_INIT) and callcl_mixin_inject(&desc, &handle);cl_mixin_remove(handle)removes it. The runtimefilteris acl_ident(cl_ident_intern(CL_STR("minecraft:zombie"))). - Inside a callback:
cl_mx_<target>_args_of(ci),cl_mx_<target>_<point>_of(ci),cl_mx_<target>_<value>_of(ci)andcl_mx_<target>_ret_of(ci)give typed pointers;cl_mixin_cancel(ci),cl_mx_<target>_set_return(ci, v)andcl_mixin_call_original(ci)control the call. Only the fields markedmutin the header can be changed, and only by the kinds that may change them; other writes are dropped.ci->selfis the owning handle as a number:cl_entity e = { ci->self };. cl_mixin_info(name, &info)tells you the target’s id, flags (CL_MXT_CANCELLABLE,CL_MXT_INVOKABLE,CL_MXT_INERT, …) and since-version.cl_mixin_invoke(target, self, &args, &ret)calls an invokable target (the args frame starts with itsstruct_size).- C++:
clight::mixin::inject<clight::mx::EntityLivingHurt>(clight::at::head, [](auto& ci) { ci.args().amount *= 0.5f; })returns anExpectedwith a handle that removes the mixin when it goes out of scope (detach()keeps it). Values and points are typed by the target’s nested traits:ci.value<float>()forclight::at::value("crit_multiplier"),ci.locals<clight::mx::EntityLivingHurt::BeforeKnockback>().strengthforclight::at::point("before_knockback").
Debugging
/clight mixins(operators, also from the server console) lists every target that has mixins, with each injection’s kind, mod, priority and number of calls, in the order they run./clight mixins <target>shows one target,/clight mixins conflictsthe conflicts, disabled mixins and hidden mods,/clight mixins statsthe calls, cancels and errors per injection.- With
--clight-dev, the game writes the same ordered table plus the conflicts tologs/clight-mixins.txt, rewritten on the next server tick after a change. - A mixin that errors is turned off for the session with CLL-7009 in the log (“disabled for <target> after an error: …”); the rest of your mod keeps running.
Good to know
- Threads: your callback runs on the thread of the game function: the server tick of that dimension for server targets, the client’s main thread for client targets (particles can also be created from the particle tick job), and the world generator’s worker threads for
worldgen.*andchunk.generate.stage(one mod’s callbacks still run one at a time). Dimensions tick in parallel, so don’t rely on the order between dimensions in your global state. - Errors: if your callback errors or traps, its changes are dropped, that one mixin is turned off for the session, and the game carries on with the vanilla behaviour. The Mods screen and the log say why. Repeated errors disable the mod, as with events.
- Timeouts: the usual callback time limits apply (Threading model).
- Client and server: server targets change the server, and your players need nothing. Client targets (
client.*,net.packet.*, API 1.16) change only the client they run on; the logical server never fires them. In singleplayer both kinds run in the one game. - Predicted targets:
entity.living.travel,jump_power,jump,player.moveandplayer.destroy_progressrun on both sides: for mobs and players on the server, and for the local player’s own prediction on the client (variantlocal_player), likeclient.player.attack_cooldown. Changing a prediction on the client alone makes the server disagree (rubber-banding, anticheat flags), so client-side injections on predicted targets only run on a server that runs the same mod: the client and a Celestial server swap their mod lists in the CLight handshake (theclight:hellopayload) when you join. On a vanilla or Paper server, or a Celestial server without your mod, they stay installed but inactive, and/clight mixinsand the Mods screen show “inactive on this server”. Singleplayer always counts as matching. - Advantage targets:
client.render.entity,client.render.fog,client.camera.setup,client.input.key,client.input.mouse_moveandclient.player.attack_cooldowncan give a player an edge (seeing more, aiming for them). The client reports its injections on client targets in the handshake, and a Celestial server can refuse the join with a readable list of mods:clight-client-mixins=allow(default),deny-advantageordenyinserver.properties. This is cooperation with honest clients, not anticheat. - Permissions: the client targets that touch other people’s code or your input need a capability the player grants:
client.render.hudandclient.screen.*needui.inject,client.input.*needinput.synthesize,net.packet.*needpackets.raw. Declare them in"permissions". See Capabilities and permissions. - Versions: check optional targets with
cl.has("mixin:entity.living.hurt"). A target is never removed. If vanilla itself drops a mechanic in a game update, its target stays and simply stops firing (cl.mixin.info(name).fires == false). - Randomness: cancelling or replacing functions that use the world’s random numbers (
spawn.check_rules,entity.mob.check_despawn,entity.mob.finalize_spawn,block.random_tick,level.explode,loot.table.roll, and the world generation targets:worldgen.*,chunk.generate.stage) changes what happens next in that world, or the terrain the seed makes. That’s fine when it’s what you want. Just know that the world then differs from vanilla with the same seed.
Which path should I use?
| You want to | Use |
|---|---|
| React to something, or stop it | An event |
| Change how your block or item, or one vanilla one, behaves | A behaviour override |
| Change a step inside a game function, a value it uses, or the whole function | A mixin |