Skip to content

What is a mixin?

Lesson 17.1

Events tell you about the moments the game announces: a block breaks, a player joins, an entity takes damage. That covers most mods. But some changes happen inside the game’s own code, where no event looks: how far a hit knocks a sheep back, how much a critical hit multiplies the damage, how much experience a spider drops. For those, CLight has mixins.

The idea

A mixin attaches your function to a target: a named piece of the game’s behaviour. entity.living.hurt is “a living entity is asked to take damage”. entity.mob.xp_reward is “how much experience this mob drops”. When the game runs that behaviour, it runs your function too, at the place you asked for:

-- Every mob drops twice the experience.
cl.mixin.inject("entity.mob.xp_reward", "return", function(ci)
  ci.ret = ci.ret * 2
end)

Read it as: “when the game has worked out a mob’s experience (return), take the result (ci.ret) and double it”.

  • The first argument is the target.
  • The second says where your function runs. "return" means “after the game’s code, when the result is known”. The next lessons show the others.
  • The third is your function. It gets ci, the callback info: the target’s arguments, its result, the object it belongs to, and ways to cancel or change things.

Mixins are safe. Nothing in the game is patched or rewritten: the game has its targets built in, and your function still runs in the Lua sandbox with the same rules as every event handler. A target nobody hooks costs the game next to nothing, and a mixin that changes nothing leaves the game exactly vanilla.

Target names describe what happens, not how the game’s code is written, so they stay the same from one Celestial build to the next. A mod written today keeps working on later builds.

Events or mixins?

Many things can be done both ways. Damage is an example: the entity.damage event lets you change event.amount too. Pick the simplest tool that does the job:

You want to Use
React when something happens, or stop it An event
Change a field the event offers (amount, message, drop_items) An event
Change a value the game uses inside its code (knockback strength, critical multiplier, despawn distance) A mixin
Change a result that has no event (experience reward, jump power, fall damage) A mixin
Do the job differently, or skip one step of it (no sweep attack, no knockback) A mixin

Events are easier to read and harder to get wrong. Reach for a mixin when no event can do it.

Which targets exist?

Since CLight API 1.17, the game has 93 targets. 56 game-logic targets run on the server, so your players need nothing installed (41 since API 1.5, 15 more in API 1.14):

  • Entities: entity.living.hurt, entity.living.actually_hurt, entity.living.damage_after_armor, entity.living.damage_after_magic, entity.living.heal, entity.living.check_totem, entity.living.die, entity.living.knockback, entity.living.cause_fall_damage, entity.living.add_effect, entity.living.tick_effects, entity.living.tick, entity.living.travel, entity.living.jump_power, entity.living.jump, entity.mob.ai_step, entity.mob.check_despawn, entity.mob.finalize_spawn, entity.mob.do_hurt_target, entity.mob.xp_reward, entity.mob.pick_up_items, spawn.check_rules.
  • Players: player.attack, player.attack_damage, player.interact_entity, player.food_tick, player.exhaust, player.give_xp, player.respawn, player.teleport, player.destroy_block, player.move (1.14), player.destroy_progress (1.14).
  • Blocks and the world: block.player_destroy, block.place_item, block.use_without_item, block.use_with_item, block.random_tick, level.set_block, level.explode.
  • Items: item.use, item.use_on, item.finish_using, item.hurt (1.14), item.entity_pickup (1.14), item.entity_tick (1.14).
  • Inventories and menus (1.14): inventory.add_item, menu.open, menu.click, menu.quick_move, menu.may_place, container.hopper_transfer.
  • Projectiles and loot (1.14): projectile.shoot, projectile.hit, loot.block_drops, loot.entity_drops.

19 more run on the client (API 1.16) and change only the game they run in:

  • Rendering: client.render.world, client.render.sky, client.render.fog, client.render.entity, client.camera.setup.
  • HUD and screens: client.render.hud (with a point for every HUD layer), client.screen.init, client.screen.render, client.screen.key.
  • Input, chat, sound, particles: client.input.key, client.input.mouse_move, client.chat.receive, client.chat.send, client.sound.play, client.particle.add.
  • The client game and the network: client.tick, client.player.attack_cooldown, net.packet.receive, net.packet.send.

entity.living.travel, entity.living.jump, entity.living.jump_power, player.move and player.destroy_progress also fire on the client for your own player (variant local_player). Those are predicted: the server simulates the same thing, so on a server they only run when the server has your mod too (in singleplayer always). Some client targets need a permission the player grants: ui.inject for the HUD and screens, input.synthesize for input, packets.raw for packets.

18 more change the world and its data (API 1.17), on the server:

  • World generation: chunk.generate.stage, worldgen.structure.start, worldgen.biome.fill, worldgen.terrain.build, worldgen.feature.place, worldgen.structure.place. They run on the world generator’s worker threads, so only C and C++ (WebAssembly) mods can use them: a Lua mod gets the error CLL-7012.
  • Chunks: chunk.load, chunk.unload, chunk.save.
  • Commands: command.execute, command.register, command.suggest.
  • Recipes, loot and tags: recipe.match, recipe.assemble, loot.table.roll, tag.contains.
  • Data: data.reload, data.registry_freeze.

The mixin target list describes each one: its arguments, its result, what cancelling means, and the named places inside it.

Planned

call: sites (redirecting one call inside a target) are Planned. New targets and new points are only ever added; the ones above never change.

Asking the game

A mod can ask whether this build has a target, so it still loads on an older one:

if cl.has("mixin:entity.mob.xp_reward") then
  cl.mixin.inject("entity.mob.xp_reward", "return", function(ci) ci.ret = ci.ret * 2 end)
else
  cl.log.warn("this build has no entity.mob.xp_reward target, experience stays vanilla")
end

cl.mixin.info(name) tells you more. It returns nil for an unknown name, or a table:

local info = cl.mixin.info("entity.living.hurt")
cl.log("cancellable=%s invokable=%s since API %s", tostring(info.cancellable), tostring(info.invokable), info.since)
--> cancellable=true invokable=true since API 1.5

Try it

Write a mod that logs, when it loads, whether entity.living.knockback exists, whether it can be cancelled, and since which API version it exists. On a build without it, the mod must still load.

Show a solution
local info = cl.mixin.info("entity.living.knockback")
if info then
  cl.log("knockback target: cancellable=%s, since API %s", tostring(info.cancellable), info.since)
else
  cl.log("this build has no knockback target")
end

cl.mixin.info returns nil instead of raising an error for unknown names, so a plain if is enough.

Recap: a mixin runs your function inside a named game target; use events first, and mixins for values and steps no event reaches.