Skip to content

Project: ruby tools

Lesson 11.4

Time to put it all together. In this project you build a complete mod in C++, step by step: a ruby item and a block of rubies, recipes, a welcome gift from the config, a /rubies command, a chime when a ruby block breaks, a HUD counter, and a visit counter saved to a file. Each step is small, and you can test after every one.

Step 1: the project

> clight new cpp ruby_tools --name "Ruby Tools"
> cd ruby_tools

Edit the manifest: your name in authors, a real description, and "sync": "required", because the server and players both need the new items. Turn on warnings too:

{
  "schema": 1,
  "id": "ruby_tools",
  "version": "1.0.0",
  "name": "Ruby Tools",
  "description": "Rubies, a block of rubies, a ruby counter on the HUD and a /rubies command.",
  "authors": ["Alex"],
  "license": "MIT",
  "api": 1,
  "game": ">=26.3 <26.4",
  "side": "both",
  "sync": "required",
  "runtime": { "type": "wasm", "entry": "bin/ruby_tools.wasm" },
  "permissions": [],
  "custom": { "clight_build": { "cflags": ["-Wall", "-Wextra"] } }
}

The code goes into four source files and a header, each with one job:

src/
  main.cpp       the mod class: wires everything up
  content.cpp    items, blocks and the break chime
  commands.cpp   /rubies, the welcome gift and the visit counter
  hud.cpp        the client-side counter
  ruby.hpp       shared declarations

Step 2: assets

Copy the textures from the SDK’s example (clight-sdk/examples/ruby_cpp/assets/ruby_cpp/textures/) or draw your own 16 x 16 PNGs:

assets/ruby_tools/
  textures/item/ruby.png
  textures/block/ruby_block.png
  items/ruby.json
  items/ruby_block.json
  models/item/ruby.json
  models/block/ruby_block.json
  blockstates/ruby_block.json
  sounds.json
  sounds/ruby_chime.ogg
  lang/en_us.json

The JSON files are the ones from Items and Blocks, with ruby_tools as the namespace. sounds.json has one entry, ruby_chime, as in Sound and music. The language file:

{
  "item.ruby_tools.ruby": "Ruby",
  "block.ruby_tools.ruby_block": "Block of Ruby",
  "subtitles.ruby_tools.ruby_chime": "Ruby chimes",
  "ruby_tools.welcome": "Welcome! Here is a ruby to start with."
}

Step 3: recipes

datapack/ruby_tools/recipe/ruby_block.json (nine rubies to a block) and ruby_from_block.json (back to nine), exactly as in Recipes and data packs.

Step 4: the shared header

src/ruby.hpp declares what the files share. Ids live here once, so a typo can’t creep in:

// src/ruby.hpp
#pragma once

#include <clight.hpp>

inline constexpr std::string_view kRuby = "ruby_tools:ruby";
inline constexpr std::string_view kRubyBlock = "ruby_tools:ruby_block";

// content.cpp
void content_register();
void content_init();

// commands.cpp
void commands_init();
int32_t count_rubies(clight::Entity player);

// hud.cpp
void hud_setup();

inline constexpr makes one constant that every .cpp file can use without defining it twice. #pragma once makes sure the header is read only once per file.

Step 5: content

src/content.cpp registers the item and the block, and plays the chime:

// src/content.cpp
#include "ruby.hpp"

void content_register() {
    cl_item_desc i = CL_ITEM_DESC_INIT;
    i.id = clight::s(kRuby);
    i.vanilla_fallback = CL_STR("minecraft:emerald");
    i.creative_tab = CL_STR("minecraft:ingredients");
    if (!clight::registry::item_register(&i)) clight::log::error("ruby failed to register");

    cl_block_desc b = CL_BLOCK_DESC_INIT;
    b.id = clight::s(kRubyBlock);
    b.copy_properties_from = CL_STR("minecraft:emerald_block");
    b.vanilla_fallback = CL_STR("minecraft:emerald_block");
    b.item = clight::s(kRubyBlock);
    if (!clight::registry::block_register(&b)) clight::log::error("ruby_block failed to register");
}

void content_init() {
    clight::on<clight::ev::BlockBreak>([](cl_ev_block_break& e) {
        if (!clight::registry::block_state_is(e.state, kRubyBlock)) return;
        const cl_vec3 at{e.pos.x + 0.5, e.pos.y + 0.5, e.pos.z + 0.5};
        clight::Level(e.level).sound_play(at, "ruby_tools:ruby_chime", CL_SOUND_BLOCKS, 1.0f, 1.0f);
    }).detach();
}

clight::s(kRuby) turns the constant into the cl_str the descriptor wants. The constant lives for the whole program, so the descriptor may point at it.

Step 6: the mod class

src/main.cpp only wires the parts together:

// src/main.cpp
#include "ruby.hpp"

class RubyTools final : public clight::Mod {
public:
    void on_construct() override {
        content_init();
        commands_init();
        clight::log::info("Ruby Tools {} loaded", clight::mod_version());
    }

    void on_register() override { content_register(); }

    void on_client_setup() override { hud_setup(); }
};

CLIGHT_MOD(RubyTools)

Add empty commands_init and hud_setup functions for now (and a count_rubies that returns 0), then clight run. Look for the rubies in the creative inventory, craft a block, break it and listen.

Step 7: the welcome gift and the command

src/commands.cpp has the gift (with a config option), the visit counter and the command:

// src/commands.cpp
#include "ruby.hpp"

#include <fstream>

namespace {

clight::Config g_cfg;
int g_visits = 0;

void load_visits() {
    std::ifstream in("/data/visits.txt");
    if (!(in >> g_visits)) g_visits = 0;
}

void save_visits() {
    std::ofstream out("/data/visits.txt");
    if (out) out << g_visits << "\n";
}

// Replies in the mod's colours. (ctx.reply("...") / ctx.send_failure("...") take plain strings too.)
void reply(clight::CmdCtx ctx, bool ok, std::string_view message) {
    clight::Text t = clight::literal(message);
    t.color(ok ? 0xE0115F : 0xFF5555);
    if (ok) ctx.send_success(t);
    else ctx.send_failure(t);
}

// /rubies: how many you have.  /rubies visits: how many joins so far.
int32_t cmd_rubies(clight::CmdCtx ctx) {
    std::string args = ctx.args().value_or("");
    if (args == "visits") {
        reply(ctx, true, std::format("{} visits so far.", g_visits));
        return 1;
    }
    clight::Entity p = ctx.player();
    if (!p) {
        reply(ctx, false, "Only players have pockets.");
        return 0;
    }
    int32_t n = count_rubies(p);
    reply(ctx, true, std::format("You have {} {}.", n, n == 1 ? "ruby" : "rubies"));
    return n > 0 ? n : 1;
}

}  // namespace

int32_t count_rubies(clight::Entity player) {
    return player.count_item(kRuby);
}

void commands_init() {
    if (auto cfg = clight::config::open(CL_CONFIG_SERVER)) {
        g_cfg = *cfg;
        g_cfg.define_i32("gift.rubies", 1, 0, 64);
    } else {
        clight::log::error("no config: {}", cfg.error());
    }
    load_visits();

    clight::on<clight::ev::PlayerJoin>([](cl_ev_player& e) {
        clight::Entity p = e.player;
        g_visits += 1;

        int32_t gift = g_cfg.get_i32("gift.rubies");
        if (gift > 0 && count_rubies(p) == 0) {
            p.give(kRuby, gift);
            p.send_message(clight::text::translatable("ruby_tools.welcome"));
        }
    }).detach();

    clight::on<clight::ev::ServerCommandRegister>([](cl_ev_command_register&) {
        clight::cmd::add("rubies", cmd_rubies, 0, "/rubies [visits]");
    }).detach();

    clight::on<clight::ev::LifecycleServerStopping>([](cl_ev_lifecycle&) { save_visits(); }).detach();
}

A few things to notice:

  • The gift only comes when the player has no rubies yet, so rejoining isn’t a ruby farm.
  • The visit counter is loaded once and saved when the world closes, not on every join.
  • Everything only this file needs sits in an unnamed namespace { }, so other files can’t see it and the names can’t clash. count_rubies and commands_init are outside it, because the header promises them to the other files.
  • clight::Config is a borrowed handle, so a global one is fine; it is filled in once commands_init runs.

Step 8: the HUD counter

src/hud.cpp runs on the client only. It counts in the client tick and draws in a HUD layer that is skipped while you have no rubies:

// src/hud.cpp
#include "ruby.hpp"

namespace {

int32_t g_count = 0;
std::string g_label;

}  // namespace

void hud_setup() {
    clight::on<clight::ev::TickClientPost>([](cl_ev_tick&) {
        clight::Entity me = clight::entity::client_player();
        int32_t n = me ? count_rubies(me) : 0;
        if (n != g_count) {
            g_count = n;
            g_label = "x" + std::to_string(n);
        }
    }).detach();

    auto layer = clight::hud::add_layer("ruby_tools:counter", [](clight::Gui g, float) {
        int32_t x = 4, y = g.height() - clight::hud::left_height() - 20;
        g.item_by_name(kRuby, x, y);
        g.text(g_label, x + 18, y + 4, 0xFFFFFFFF, true);
    }, {.visible = [] { return g_count > 0; }});
    if (!layer) clight::log::error("the ruby counter failed to register");
}

The counter sits in the bottom-left corner, just above the hearts and armour, wherever they end. On a dedicated server, on_client_setup never runs, so none of this code is used there. g_count and g_label are globals of this file, so the lambdas need no captures; .visible keeps the layer from being drawn at all while you have no rubies.

Step 9: test and publish

Run through it in a singleplayer world:

  1. Join with an empty inventory: you get a ruby and the welcome message.
  2. The counter shows x1. Pick up more rubies from the creative inventory and watch it change.
  3. Craft a block, place it, break it: the chime plays.
  4. Type /rubies and /rubies visits.
  5. Close the world, start the game again, and check that /rubies visits kept counting.
  6. Look at logs/latest.log for warnings from your mod or [clight].

Then follow Publishing: icon, licence, clight check --strict, clight build --release, upload.

Try it

Add an operator-only command /giverubies <amount> that gives the player who typed it 1 to 64 rubies.

Show a solution

In commands.cpp, inside the unnamed namespace (add #include <charconv> at the top):

int32_t cmd_giverubies(clight::CmdCtx ctx) {
    clight::Entity p = ctx.player();
    if (!p) {
        reply(ctx, false, "Players only.");
        return 0;
    }
    std::string args = ctx.args().value_or("");
    int n = 0;
    auto [end, err] = std::from_chars(args.data(), args.data() + args.size(), n);
    if (err != std::errc{} || end != args.data() + args.size() || n < 1 || n > 64) {
        reply(ctx, false, "Pick 1 to 64.");
        return 0;
    }
    p.give(kRuby, n);
    reply(ctx, true, std::format("Gave you {} rubies.", n));
    return n;
}

And add it next to /rubies, inside the ServerCommandRegister handler:

clight::cmd::add("giverubies", cmd_giverubies, 2, "/giverubies <1-64>");

It is a separate command because the permission level applies to a whole command. Players who aren’t operators get “Unknown command”.

More ideas:

  • A ruby pickaxe: an item with max_stack_size = 1 and a recipe.
  • A shop menu with player.menu_open that hands out rubies (see Screens and menus).
  • A keybind that asks the server for your ruby count with a payload (see Payloads and buffers).

What’s next

You can now build complete Celestial mods in C++. From here:

  • The reference docs describe every function, event and file format in detail. And clight.h itself is a good read: every C function has its side, context and version next to it, and the wrapper method with the same name does the same.
  • Mixins are safe hooks into the game’s own code, for changes that no event covers. The next chapter teaches them, starting with What is a mixin?
  • Read other people’s mods. The SDK’s examples/ruby_cpp folder is a complete mod much like this one, all in one file.

Have fun, and share what you make.

Recap: a complete C++ mod is a manifest, assets, data-pack files, a small mod class and a few source files with one job each; build it one step at a time and test each step in the game.