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 c 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 files, each with one job:
src/
main.c the entry point: wires everything up
content.c items, blocks and the break chime
commands.c /rubies and the welcome gift
hud.c the client-side counter
ruby.h shared declarations
Step 2: assets
Copy the textures from the SDK’s example (clight-sdk/examples/ruby_c/assets/ruby_c/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.h declares what the files share. Ids live here once, so a typo can’t creep in:
#ifndef RUBY_H
#define RUBY_H
#include <clight_easy.h>
#define RUBY_ITEM "ruby_tools:ruby"
#define RUBY_BLOCK "ruby_tools:ruby_block"
// content.c
cl_result content_init(void);
// commands.c
cl_result commands_init(void);
int32_t count_rubies(cl_entity player);
// hud.c
cl_result hud_init(void);
#endif
#define gives a name to a string, and #ifndef RUBY_H makes sure the header is only read once per file. W3Schools explains both in Macros and Organizing code.
Step 5: content
src/content.c registers the item and the block, and plays the chime:
#include "ruby.h"
static void on_register(void* payload, void* user) {
(void)payload;
(void)user;
cl_item_desc i = CL_ITEM_DESC_INIT;
i.id = CL_STR(RUBY_ITEM);
i.vanilla_fallback = CL_STR("minecraft:emerald");
i.creative_tab = CL_STR("minecraft:ingredients");
if (cl_item_register(&i, NULL) != CL_OK) CL_LOGF(CL_LOG_ERROR, "ruby failed to register");
cl_block_desc b = CL_BLOCK_DESC_INIT;
b.id = CL_STR(RUBY_BLOCK);
b.copy_properties_from = CL_STR("minecraft:emerald_block");
b.vanilla_fallback = CL_STR("minecraft:emerald_block");
b.item = CL_STR(RUBY_BLOCK);
if (cl_block_register(&b, NULL) != CL_OK) CL_LOGF(CL_LOG_ERROR, "ruby_block failed to register");
}
static void on_block_break(void* payload, void* user) {
(void)user;
cl_ev_block_break* e = CL_EVENT(cl_ev_block_break, payload);
if (!cl_block_state_is(e->state, CL_STR(RUBY_BLOCK))) return;
cl_vec3 at = {e->pos.x + 0.5, e->pos.y + 0.5, e->pos.z + 0.5};
cl_sound_play(e->level, at, CL_STR("ruby_tools:ruby_chime"), CL_SOUND_BLOCKS, 1.0f, 1.0f);
}
cl_result content_init(void) {
cl_result r = CL_ON(CL_EV_LIFECYCLE_REGISTER, on_register);
if (r == CL_OK) r = CL_ON(CL_EV_BLOCK_BREAK, on_block_break);
return r;
}
CL_STR(RUBY_ITEM) works because the macro turns into a string literal before CL_STR sees it.
Step 6: the entry point
src/main.c only wires the parts together:
#include "ruby.h"
static cl_result mod_main(void) {
cl_result r = CL_OK;
if (r == CL_OK) r = content_init();
if (r == CL_OK) r = commands_init();
if (r == CL_OK) r = hud_init();
if (r == CL_OK) CL_LOG("Ruby Tools loaded");
return r;
}
CLIGHT_MOD_ENTRY(mod_main)
Add empty commands_init and hud_init functions that return CL_OK; for now, 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.c has the gift (with a config option), the visit counter and the command:
#include "ruby.h"
#include <stdlib.h>
#include <string.h>
static cl_config g_cfg;
static int g_visits = 0;
int32_t count_rubies(cl_entity player) {
return cl_easy_count(player, RUBY_ITEM);
}
static void load_visits(void) {
FILE* f = fopen("/data/visits.txt", "r");
if (!f) return;
if (fscanf(f, "%d", &g_visits) != 1) g_visits = 0;
fclose(f);
}
static void save_visits(void* payload, void* user) {
(void)payload;
(void)user;
FILE* f = fopen("/data/visits.txt", "w");
if (!f) return;
fprintf(f, "%d\n", g_visits);
fclose(f);
}
static void on_player_join(void* payload, void* user) {
(void)user;
cl_entity p = CL_EVENT(cl_ev_player, payload)->player;
g_visits += 1;
int32_t gift = cl_config_get_i32(g_cfg, CL_STR("gift.rubies"));
if (gift > 0 && count_rubies(p) == 0) {
cl_easy_give(p, RUBY_ITEM, gift);
cl_text t = cl_text_translatable(CL_STR("ruby_tools.welcome"));
cl_player_send_message(p, t);
cl_text_release(t);
}
}
static void reply(cl_cmd_ctx ctx, bool ok, const char* message) {
cl_text t = cl_text_literal(CL_STR_C(message));
cl_text_color(t, ok ? 0xE0115F : 0xFF5555);
if (ok) cl_cmd_send_success(ctx, t, false);
else cl_cmd_send_failure(ctx, t);
cl_text_release(t);
}
// /rubies: how many you have. /rubies visits: how many joins so far.
static int32_t cmd_rubies(cl_cmd_ctx ctx, void* user) {
(void)user;
char args[32], msg[96];
size_t len = 0;
cl_cmd_ctx_args(ctx, args, sizeof args, &len);
if (strcmp(args, "visits") == 0) {
snprintf(msg, sizeof msg, "%d visits so far.", g_visits);
reply(ctx, true, msg);
return 1;
}
cl_entity p = cl_cmd_ctx_player(ctx);
if (CL_IS_NULL(p)) {
reply(ctx, false, "Only players have pockets.");
return 0;
}
int32_t n = count_rubies(p);
snprintf(msg, sizeof msg, "You have %d %s.", (int)n, n == 1 ? "ruby" : "rubies");
reply(ctx, true, msg);
return n > 0 ? n : 1;
}
static void on_command_register(void* payload, void* user) {
(void)payload;
(void)user;
cl_cmd_desc d = CL_CMD_DESC_INIT;
d.name = CL_STR("rubies");
d.usage = CL_STR("/rubies [visits]");
d.execute = cmd_rubies;
cl_cmd_register(&d);
}
cl_result commands_init(void) {
cl_result r = cl_config_open(CL_CONFIG_SERVER, &g_cfg);
if (r == CL_OK) r = cl_config_define_i32(g_cfg, CL_STR("gift.rubies"), 1, 0, 64);
if (r != CL_OK) return r;
load_visits();
r = CL_ON(CL_EV_PLAYER_JOIN, on_player_join);
if (r == CL_OK) r = CL_ON(CL_EV_SERVER_COMMAND_REGISTER, on_command_register);
if (r == CL_OK) r = CL_ON(CL_EV_LIFECYCLE_SERVER_STOPPING, save_visits);
return r;
}
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.
save_visitshas the handler shape, so it can be subscribed directly.strcmpandstrtolcome from<string.h>and<stdlib.h>, included at the top.
Step 8: the HUD counter
src/hud.c runs on the client only. It counts in the client tick and draws in the HUD layer:
#include "ruby.h"
static int32_t g_count = 0;
static char g_label[24] = "";
static void on_client_tick(void* payload, void* user) {
(void)payload;
(void)user;
cl_entity me = cl_client_player();
int32_t n = CL_IS_NULL(me) ? 0 : count_rubies(me);
if (n != g_count) {
g_count = n;
snprintf(g_label, sizeof g_label, "x%d", (int)n);
}
}
static bool hud_visible(void* user) {
(void)user;
return g_count > 0;
}
static void hud_render(cl_gui g, float partial_tick, void* user) {
(void)partial_tick;
(void)user;
int32_t x = 4, y = cl_gui_height(g) - cl_hud_left_height() - 20;
cl_gui_item_by_name(g, CL_STR(RUBY_ITEM), x, y);
cl_gui_text(g, CL_STR_C(g_label), x + 18, y + 4, 0xFFFFFFFF, true);
}
static void on_client_setup(void* payload, void* user) {
(void)payload;
(void)user;
CL_ON(CL_EV_TICK_CLIENT_POST, on_client_tick);
cl_hud_layer_desc d = CL_HUD_LAYER_DESC_INIT;
d.id = CL_STR("ruby_tools:counter");
d.render = hud_render;
d.visible = hud_visible;
cl_hud_layer_register(&d, NULL);
}
cl_result hud_init(void) {
return CL_ON(CL_EV_LIFECYCLE_CLIENT_SETUP, on_client_setup);
}
The counter sits in the bottom-left corner, just above the hearts and armour, wherever they end. On a dedicated server, lifecycle.client_setup never runs, so none of this code is used there.
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.c:
static int32_t cmd_giverubies(cl_cmd_ctx ctx, void* user) {
(void)user;
cl_entity p = cl_cmd_ctx_player(ctx);
if (CL_IS_NULL(p)) {
reply(ctx, false, "Players only.");
return 0;
}
char args[16];
size_t len = 0;
cl_cmd_ctx_args(ctx, args, sizeof args, &len);
char* end = NULL;
long n = strtol(args, &end, 10);
if (end == args || *end != '\0' || n < 1 || n > 64) {
reply(ctx, false, "Pick 1 to 64.");
return 0;
}
cl_easy_give(p, RUBY_ITEM, (int32_t)n);
char msg[48];
snprintf(msg, sizeof msg, "Gave you %ld rubies.", n);
reply(ctx, true, msg);
return (int32_t)n;
}
And register it next to /rubies, inside on_command_register:
cl_cmd_desc g = CL_CMD_DESC_INIT;
g.name = CL_STR("giverubies");
g.usage = CL_STR("/giverubies <1-64>");
g.permission_level = 2;
g.execute = cmd_giverubies;
cl_cmd_register(&g);
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
cl_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 function has its side, context and version next to it. - 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_cfolder 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 and a few small source files with one job each; build it one step at a time and test each step in the game.