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_rubiesandcommands_initare outside it, because the header promises them to the other files. clight::Configis a borrowed handle, so a global one is fine; it is filled in oncecommands_initruns.
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:
- Join with an empty inventory: you get a ruby and the welcome message.
- The counter shows
x1. Pick up more rubies from the creative inventory and watch it change. - Craft a block, place it, break it: the chime plays.
- Type
/rubiesand/rubies visits. - Close the world, start the game again, and check that
/rubies visitskept counting. - Look at
logs/latest.logfor 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 = 1and a recipe. - A shop menu with
player.menu_openthat 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.hitself 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_cppfolder 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.