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 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_visits has the handler shape, so it can be subscribed directly.
  • strcmp and strtol come 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:

  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.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 = 1 and a recipe.
  • A shop menu with cl_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 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_c 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 and a few small source files with one job each; build it one step at a time and test each step in the game.