Skip to content

What is a mixin?

Lesson 12.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 calls your function too, at the place you asked for:

#include <clight.hpp>

class Mod final : public clight::Mod {
public:
    void on_construct() override {
        // Every mob drops twice the experience.
        auto h = clight::mixin::inject<clight::mx::EntityMobXpReward>(clight::at::ret, [](auto& ci) {
            ci.ret() *= 2;
        });
        if (h) h->detach();   // keep it for the mod's lifetime
    }
};

CLIGHT_MOD(Mod)

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

  • The type between <> names the target. Each target has a type in clight::mx, made from its name: entity.mob.xp_reward becomes clight::mx::EntityMobXpReward.
  • clight::at::ret says where your function runs: after the game’s code, when the result is known. The next lessons show the others: head, point(...), value(...), wrap and overwrite.
  • ci is the callback info, a clight::mx::Ci<T>: ci.args() is the target’s arguments as a struct, ci.ret() the result, ci.self() the object it belongs to, and ci.cancel() stops the call.
  • inject returns an Expected with a clight::mixin::Handle. Like an event subscription, the handle removes the mixin when it is destroyed, so detach() it or keep it in a member.

There is also a static way, at the top level of a .cpp file:

CL_MIXIN(entity_mob_xp_reward, return, [](clight::mx::EntityMobXpReward::Ci& ci) {
    ci.ret() *= 2;
});

Here the target is written with _ instead of ., and the place as a plain word (head, return, wrap, overwrite). Static mixins are written into your .wasm file and registered before on_construct runs, and clight check can see them before the mod even starts. They stay for the whole session.

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 WebAssembly sandbox with the same rules as every event callback. 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 compiled today keeps working on later builds.

Events or mixins?

Many things can be done both ways. Damage is an example: the clight::ev::EntityDamage event lets you change the 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

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.
  • 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. In clight.h, each target’s type in clight::mx names its argument struct (Args), its result type (Ret) and its points and values as nested types.

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 build:

void on_construct() override {
    if (!clight::core::has("mixins")) return;   // a build without mixins at all

    if (clight::mixin::target_lookup("entity.living.knockback")) {
        cl_mixin_target_info info = CL_MIXIN_TARGET_INFO_INIT;
        clight::mixin::info("entity.living.knockback", &info);
        clight::log::info("knockback: cancellable={}, since API 1.{}", (info.flags & CL_MXT_CANCELLABLE) != 0, info.since_minor);
    } else {
        clight::log::info("entity.living.knockback isn't in this build");
    }
}

target_lookup returns an Expected with the target’s number, or CL_E_NOT_FOUND. clight::mixin::info fills a cl_mixin_target_info: its flags (CL_MXT_CANCELLABLE, CL_MXT_INVOKABLE, …) and since which API version it exists. clight::core::has("mixin:entity.living.knockback") is a shorter yes/no.

Try it

Your mod wants to double the experience mobs drop. Write on_construct so that the mod loads on every build, logs whether the target entity.mob.xp_reward is there, and only then adds the ret mixin.

Show a solution
void on_construct() override {
    const bool ready = clight::core::has("mixins") && clight::mixin::target_lookup("entity.mob.xp_reward").has_value();
    clight::log::info("xp_reward target {}", ready ? "found" : "not available");
    if (!ready) return;
    auto h = clight::mixin::inject<clight::mx::EntityMobXpReward>(clight::at::ret, [](auto& ci) { ci.ret() *= 2; });
    if (h) h->detach();
}

Both checks are needed: core::has("mixins") is false on builds without the mixin core, and target_lookup fails on builds older than API 1.5.

Recap: a mixin runs your function inside a named game target; clight::mixin::inject<clight::mx::EntityMobXpReward>(clight::at::ret, fn) adds one at run time and CL_MIXIN(target, at, fn) statically.