Skip to content

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") is true. Planned: call: sites, accessor fields. The engineering design is docs/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 (variants top for players, the console and command blocks, function for function lines), command.register (the vanilla commands at server start), command.suggest (tab completion).
  • Recipes, loot, tags: recipe.match (variants crafting, 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, normal 0, late 1000, last 2000, monitor 10000), then in the order your before/after options 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). monitor callbacks are always read-only.
  • wrap mixins get ci: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 conflicts say so (“mod A hides mod B”).
  • overwrite is 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 yours optional = true if your mod can live without it. Prefer wrap, 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 are entity.living.hurt, entity.living.heal, entity.living.knockback, player.exhaust and player.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 then cl.accessor(...) raises not_found, and cl_accessor_lookup returns CL_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...) and CL_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 example CL_MIXIN_SITE(player_attack, value, crit_multiplier, crit, .priority = CL_PRIORITY_LATE) or CL_MIXIN(entity_living_hurt, head, fn, .filter = "minecraft:rabbit"). They are registered after your module is instantiated and before clight_mod_main runs.
  • At runtime: fill a cl_mixin_desc (CL_MIXIN_DESC_INIT) and call cl_mixin_inject(&desc, &handle); cl_mixin_remove(handle) removes it. The runtime filter is a cl_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) and cl_mx_<target>_ret_of(ci) give typed pointers; cl_mixin_cancel(ci), cl_mx_<target>_set_return(ci, v) and cl_mixin_call_original(ci) control the call. Only the fields marked mut in the header can be changed, and only by the kinds that may change them; other writes are dropped. ci->self is 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 its struct_size).
  • C++: clight::mixin::inject<clight::mx::EntityLivingHurt>(clight::at::head, [](auto& ci) { ci.args().amount *= 0.5f; }) returns an Expected with 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>() for clight::at::value("crit_multiplier"), ci.locals<clight::mx::EntityLivingHurt::BeforeKnockback>().strength for clight::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 conflicts the conflicts, disabled mixins and hidden mods, /clight mixins stats the calls, cancels and errors per injection.
  • With --clight-dev, the game writes the same ordered table plus the conflicts to logs/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.* and chunk.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.move and player.destroy_progress run on both sides: for mobs and players on the server, and for the local player’s own prediction on the client (variant local_player), like client.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 (the clight:hello payload) when you join. On a vanilla or Paper server, or a Celestial server without your mod, they stay installed but inactive, and /clight mixins and 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_move and client.player.attack_cooldown can 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-advantage or deny in server.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.hud and client.screen.* need ui.inject, client.input.* need input.synthesize, net.packet.* need packets.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