Modding Surviving Day! From Zero to Shipped Mod
A single-page course that takes you from an empty .cpp file to a working native mod — blocks, items, recipes, commands, GUI panels, NPCs, and every one of the engine's mod hooks explained with what fires them and what you can do in response. No prior engine knowledge assumed.
How Mods Work
A mod is a native shared library — .so on Linux, .dll on Windows — that the engine loads at startup with dlopen (Linux) or LoadLibrary (Windows). It looks for one exported C function:
extern "C" void mod_init(ModAPI* api);
The engine allocates a ModAPI struct, fills in about fifty engine-provided function pointers (things like getBlock, spawnNPC, giveItem), and passes it to your mod_init. Your job inside mod_init is two-fold:
- Call the engine functions you need — e.g.
api->registerBlock(&def)— to register content. - Fill in the mod-provided function pointers on the same struct — e.g.
api->onBlockPlaced = myHandler;— so the engine can call back into your mod when something happens.
Because it's a flat C struct of function pointers, the ABI is compiler-agnostic: you can build with GCC, Clang, or MSVC and it'll still load correctly, as long as the struct layout matches field-for-field what the engine expects (more on this later — it's the single most common way mods break).
Content registration
Blocks, items, and crafting recipes — called once, during mod_init.
Event hooks
~30 callbacks covering blocks, players, NPCs, chat, weather, chunks, crafting, and multiplayer — the bulk of this tutorial.
World & entity queries
Read/write blocks, move NPCs, adjust player HP/mana/XP, manage inventory — call these any time from a hook.
Terminal commands & GUI
Register commands players type into in-game Computer blocks, or draw a custom panel with onDraw.
Toolchain Setup
You need a C++17 compiler and one header file — include/ModLoader.h from the game's source tree. You do not link against the game binary or any other engine source file.
| Platform | Compiler | Output |
|---|---|---|
| Linux | g++ or clang++ | .so |
| Windows (cross-compile from Linux) | x86_64-w64-mingw32-g++ | .dll |
| Windows (native) | MinGW/MSYS2 g++ or MSVC cl.exe | .dll |
Either #include "ModLoader.h" and point -I at the game's include/ directory (pulls in the real struct — always correct), or vendor a copy of the structs into your own file if you want a zero-dependency single-file mod. If you vendor it, the field order, types, and count must match the engine's header exactly — see Gotchas for what happens when they drift.
Your First Mod
A minimal mod that registers one block and logs a message whenever it's placed:
#include "ModLoader.h"
static uint16_t gGlowStoneId = 0;
static ModAPI* gApi = nullptr;
static void onPlaced(const ModBlockEvent* ev) {
if (ev->blockType == gGlowStoneId) {
char buf[64];
snprintf(buf, sizeof(buf), "Glowstone placed at %d,%d,%d",
ev->x, ev->y, ev->z);
gApi->logMsg(buf);
}
}
extern "C" void mod_init(ModAPI* api) {
gApi = api;
api->modName = "My First Mod";
api->modVersion = "1.0";
api->modDescription = "Adds a glowing block";
ModBlockDef def{};
def.name = "Glowstone";
def.isSolid = true;
def.texturePath = ""; // "" = engine picks a placeholder texture
gGlowStoneId = api->registerBlock(&def);
api->onBlockPlaced = onPlaced;
}
Compile it as a shared library:
g++ -shared -fPIC -std=c++17 \
-o MyFirstMod.so my_first_mod.cpp \
-I/path/to/VoxelGame/include
registerBlock/registerItem return the runtime-assigned ID for your content — vanilla block IDs occupy the low range, so mod IDs start after BlockType::COUNT. Always store the returned ID (as this example does with gGlowStoneId) rather than hard-coding a number, since it can shift depending on load order of other installed mods.
Installing & Testing
| Platform | Primary mod path | Fallback |
|---|---|---|
| Linux | ~/.config/VoxelGame/mods/*.so | ./mods/*.so |
| Windows | %APPDATA%\VoxelGame\mods\*.dll | .\mods\*.dll |
- Copy the built library
Drop
MyFirstMod.so(or.dll) into the mods directory for your platform. - Launch the game
The modloader scans the directory on startup and calls
mod_initfor every library it finds. Check the game log for your mod's name to confirm it loaded. - Find your block in creative
Registered blocks appear automatically in the creative inventory — no extra registration step needed.
- Place it and watch the log
Your
logMsgcalls show up in the game's console/log output in real time — this is your primary debugging tool since there's no attached debugger for a loaded.soby default.
Mods run in-process with no sandboxing. A null-pointer deref or out-of-bounds write in your onUpdate takes down the entire game the same as a bug in the engine itself. Guard every pointer argument, and treat this like writing engine code — because it is.
Custom Blocks
| Field | Type | Meaning |
|---|---|---|
name | const char* | Display name shown in the creative inventory and tooltips. |
isSolid | bool | Whether the block is a full solid cube for collision and culling purposes. |
texturePath | const char* | Path to a 48×16 PNG (top/side/bottom columns), or "" for a placeholder texture. |
ModBlockDef def{};
def.name = "Reinforced Stone";
def.isSolid = true;
def.texturePath = "textures/reinforced_stone.png";
uint16_t id = api->registerBlock(&def);
Custom Items
| Field | Type | Meaning |
|---|---|---|
name | const char* | Display name. |
texturePath | const char* | Atlas sprite path, or "" for placeholder. |
maxStack | float | Max stack size — use 1 for tools/weapons that shouldn't stack. |
damage | int | Melee damage if the item is wielded as a weapon; 0 means it isn't one. |
ModItemDef sword{};
sword.name = "Obsidian Blade";
sword.texturePath = "textures/obsidian_blade.png";
sword.maxStack = 1;
sword.damage = 14;
uint16_t swordId = api->registerItem(&sword);
Crafting Recipes
registerRecipe takes a shaped 3×3 grid pattern (9 block/item IDs, row-major, 0 = empty slot), an output type, an output count, and a display name.
uint16_t pattern[9] = {
swordId, 0, 0,
0, (uint16_t)BlockType::Stick, 0,
0, 0, 0
};
api->registerRecipe(pattern, swordId, 1, "Obsidian Blade");
Pattern slots accept any registered block or item ID — vanilla BlockType values and your own mod-returned IDs are interchangeable in the same recipe.
Terminal Commands
registerCommand adds a command players can type into any in-game Computer block. Your handler receives the raw argument string and returns a heap or static string to print back to the terminal.
static char* cmdHeal(const char* args) {
gApi->setPlayerHP(100.f);
static char buf[32] = "Healed to full.";
return buf;
}
// In mod_init:
api->registerCommand("heal", cmdHeal);
Don't return a pointer to a stack-local buffer — use static storage, a heap allocation, or a string literal. The terminal reads the returned pointer after your function returns.
Custom GUI Panels
ModGuiPanel lets your mod draw a custom on-screen panel. The engine calls your onDraw every frame the panel is open (using the engine's own 2D draw calls), and onClose once when the player dismisses it.
static void drawPanel(int w, int h) {
// Issue engine draw calls here using w/h as the panel size in pixels
}
static void closePanel() { /* cleanup */ }
static ModGuiPanel gPanel { "Shop", 320, 240, drawPanel, closePanel };
// In mod_init:
api->registerGuiPanel("shop_panel", &gPanel);
// Later, e.g. from a command handler or NPC interact hook:
api->openGuiPanel("shop_panel");
api->closeGuiPanel("shop_panel");
Reading & Writing Blocks
| Function | Signature | Use |
|---|---|---|
getBlock | uint16_t(int x,int y,int z) | Read the block type at a world position. Safe to call any time. |
setBlock | void(int x,int y,int z,uint16_t type) | Write a block. Safe from the main thread only. |
getBlockGen | uint16_t(int x,int y,int z) | Gen-thread-safe read — use only inside onChunkGenerate. |
writeBlockGen | void(int x,int y,int z,uint16_t type) | Gen-thread-safe write — use only inside onChunkGenerate. |
spawnHologram | void(float x,y,z,const char* text) | Spawns a floating text hologram at a world position. |
explodeAt | void(float x,y,z,float radius,bool griefing) | Triggers an explosion: damages nearby NPCs, fires onExplosion to all mods. Set griefing to also destroy terrain. |
onChunkGenerate runs on a background world-generation thread. Calling regular getBlock/setBlock from there races with the main thread and can corrupt world state. Always use the *Gen variants inside that one hook, and never call them anywhere else.
Spawning & Controlling NPCs
| Function | Signature | Use |
|---|---|---|
spawnNPC | int(int kind,float x,y,z) | Spawns a mob by kind ID at a position; returns its npcIndex. |
killNPC | void(int npcIndex) | Sets HP to 0 and marks it dead. |
getNPCCount | int() | Total live NPC count — useful for bounding a loop over indices. |
getNPCPos / setNPCPos | void(int,float*,*,*) / void(int,float,float,float) | Read or teleport an NPC's position. |
getNPCHP / setNPCHP | float(int) / void(int,float) | Read or directly set an NPC's current HP. |
setNPCCompanion | void(int,bool) | Marks an NPC as a player companion (follows, fights alongside). |
int idx = api->spawnNPC(KIND_WOLF, px, py, pz);
if (idx >= 0) api->setNPCCompanion(idx, true);
npcIndex refers to a live slot, not a stable entity ID. If you hold onto an index across frames, re-validate it against getNPCCount/getNPCHP before acting on it — a dead NPC's slot can be recycled by a later spawn.
Player State API
| Function | Signature | Use |
|---|---|---|
getPlayerPos | void(float* x,y,z) | Local player's eye position. |
setPlayerPos | void(float x,y,z) | Teleports the local player and zeroes fall/impulse velocity so they don't immediately keep sliding. |
getPlayerHP / setPlayerHP | float() / void(float) | Clamped to [0, maxHp] on write. |
getPlayerMana / setPlayerMana | float() / void(float) | Clamped to [0, maxMana] on write. |
getSkillLevel | int(int skill) | Current level for a skill ID. |
addXP | void(int skill,float xp) | Grants XP; triggers normal level-up logic (and onLevelUp) if it crosses a threshold. |
getTotalLevel | int() | Sum of all skill levels. |
The engine has one first-person camera that doubles as the player's world position. getPlayerPos/setPlayerPos read and write that camera directly, including on teleport.
Inventory API
| Function | Signature | Use |
|---|---|---|
giveItem | void(uint16_t itemType,int count) | Adds items/blocks to the hotbar. |
removeItem | void(uint16_t itemType,int count) | Removes up to count across hotbar then bag slots. |
getItemCount | int(uint16_t itemType) | Total count across hotbar + bag. |
Adding items via giveItem (or any in-game pickup) fires onInventoryChange to every loaded mod with the item's new total count — see Player Hooks. If your own mod reacts to that hook, make sure your handler doesn't call giveItem again for the same item, or you'll get infinite recursion.
UI, Sound & Waypoints
| Function | Signature | Use |
|---|---|---|
broadcastChat | void(const char* message) | Sends a chat/game-event message to all connected clients. |
showTitle | void(const char* l1,l2,float dur) | Shows a two-line centered title card for dur seconds. |
playSound | void(const char* soundId,float x,y,z) | Plays one of the engine's built-in one-shot sounds — see the ID table below. |
openGuiPanel / closeGuiPanel | void(const char* panelId) | Opens/closes a panel registered with registerGuiPanel. |
createWaypoint | void(name,x,y,z,r,g,b) | Adds a named, colored waypoint marker. |
removeWaypoint | void(const char* name) | Removes a waypoint by name. |
playSound ID reference
playSound maps a small set of documented string IDs onto the engine's procedural audio system — it does not accept arbitrary sample files:
| ID | Sound |
|---|---|
"block_break" | Generic block-break crunch |
"block_place" | Generic block-place thud |
"pickup" | Item pickup chime |
"eat" | Eating/consuming sound |
"jump" | Jump grunt |
"splash" | Water splash |
"lava_sizzle" | Lava sizzle |
"melee_hit" | Melee weapon connecting |
"melee_crit" | Melee critical hit |
"victory" | Short victory fanfare |
"thunder" | Lightning crack + rumble |
Unrecognized IDs are silently ignored, so it's safe to call speculatively.
Time, Season & Weather
| Function | Signature | Use |
|---|---|---|
getDayTime | float() | 0.0–1.0, where 0 = midnight, 0.5 = noon. |
getSeason | int() | 0=spring, 1=summer, 2=autumn, 3=winter. |
getWeather | int() | 0=clear, 1=rain, 2=storm, 3=snow. |
setWeather | void(int weatherType) | Forces the world's weather state. |
Saving Mod Data
| Function | Signature | Use |
|---|---|---|
saveData | void(const char* key,const char* value) | Persists a string value under a key, scoped to your mod and the current save. |
loadData | const char*(const char* key) | Reads back a previously saved value, or nullptr if unset. |
This is intentionally a flat string key/value store — for structured data, serialize to JSON (or your own simple format) into a single string yourself.
const char* raw = api->loadData("kills");
int kills = raw ? atoi(raw) : 0;
kills++;
char buf[16];
snprintf(buf, sizeof(buf), "%d", kills);
api->saveData("kills", buf);
Multiplayer API
| Function | Signature | Use |
|---|---|---|
isMultiplayer | bool() | True if connected to or hosting a multiplayer session. |
isServer | bool() | True if this instance is the authoritative server (or singleplayer host). |
sendModPacket | void(const char* channel,const void* data,int len) | Sends an arbitrary byte payload on a named channel to the other side; received via onModPacket. |
getLocalUsername | const char*() | The local player's username. |
See Multiplayer Hooks for the receiving side and lifecycle events (join/leave/server tick).
Lifecycle Hooks
Every hook below is a field on the same ModAPI struct you fill in during mod_init. Leave any you don't need as nullptr — the engine checks before calling.
Block Hooks
ev->x/y/z is the position, ev->blockType the new type. Placement cannot be cancelled from this hook.ev->blockType is the type that was there — the world position is already Air by the time this fires.ev->button: 0=left, 1=right, 2=E-key.Example — react only to your own block
static void onBroken(const ModBlockEvent* ev) {
if (ev->blockType == gGlowStoneId) {
// spawn particles, drop a custom item, etc.
}
}
// api->onBlockBroken = onBroken;
Player Hooks
ev->hp is a pointer to the player's live HP float, ev->dmg is the incoming damage amount before it's subtracted. Write to *ev->hp or mutate the semantics you need — e.g. add back HP to simulate a shield, or leave it untouched to observe damage without changing it.ev->itemType plus the target position.ev->skill and ev->newLevel. Fired for any skill, including via addXP calls from other mods.itemCaught type ID.Example — 50% damage shield
static void shieldDamage(ModDamageEvent* ev) {
*ev->hp += ev->dmg * 0.5f; // refund half the incoming damage
}
// api->onPlayerDamage = shieldDamage;
Chat Hook
false to swallow the message (it won't be shown/broadcast), or true to let it through.static bool filterChat(const char* user, const char* msg) {
if (strstr(msg, "badword")) return false; // swallow it
return true;
}
// api->onChatMessage = filterChat;
NPC Hooks
ev->hp for custom resistance, or read ev->npcIndex/kind to react per-species.spawnNPC.ev->button: 1=right, 2=E-key.World Hooks
ev->weatherType: 0=clear, 1=rain, 2=storm, 3=snow.explodeAt.getBlockGen/writeBlockGen inside this hook — regular block calls or any other engine API are unsafe here.It's the only hook the engine calls from a background thread. Every other hook — including onChunkLoad — runs on the main thread and can safely call any engine API. Mixing this up is the single most common source of hard-to-reproduce crashes in world-gen mods.
Progression Hooks
ev->outputType and ev->count — the output is pulled from the recipe table, including mod-registered recipes.Multiplayer Hooks
onUpdate, but only fires on the server (or singleplayer host) — use this for authoritative game-logic that must not run duplicated on clients.sendModPacket. Use channel to route between multiple message types in the same mod.There's no server-authoritative fallback for missing mods — if the host sends a mod packet or spawns mod-registered content and a connecting client doesn't have the same mod loaded, that client will desync or reject the unknown IDs. Version your mod and check compatibility in onPlayerJoin if this matters to you.
Project: Damage Shield Mod
Combines onPlayerDamage, getPlayerMana/setPlayerMana, and showTitle: while the player has mana, incoming damage is absorbed from mana instead of HP.
#include "ModLoader.h"
static ModAPI* gApi = nullptr;
static void onDamage(ModDamageEvent* ev) {
float mana = gApi->getPlayerMana();
if (mana <= 0.f) return; // no mana left — full damage applies
float absorbed = std::min(mana, ev->dmg);
gApi->setPlayerMana(mana - absorbed);
*ev->hp += absorbed; // refund the absorbed portion
if (absorbed > 0.f)
gApi->showTitle("Shield Absorbed", "", 0.6f);
}
extern "C" void mod_init(ModAPI* api) {
gApi = api;
api->modName = "Mana Shield";
api->modVersion = "1.0";
api->onPlayerDamage = onDamage;
}
Project: Tameable Companion
Combines item registration, onItemUsed, spawnNPC/setNPCCompanion, and onNPCInteract — feed a wolf a custom treat item to tame it.
static uint16_t gTreatId = 0;
static const int KIND_WOLF = 7; // example MobKind value
static void onNPCInteract(const ModNPCInteractEvent* ev) {
if (ev->kind != KIND_WOLF || ev->button != 2) return; // only on E-key
if (gApi->getItemCount(gTreatId) > 0) {
gApi->removeItem(gTreatId, 1);
gApi->setNPCCompanion(ev->npcIndex, true);
gApi->showTitle("Wolf Tamed!", "", 1.5f);
}
}
Project: Persistent Economy
Combines saveData/loadData, registerCommand, and onCraft — a per-save currency that's earned by crafting and spendable via a terminal command.
static int getGold() {
const char* raw = gApi->loadData("gold");
return raw ? atoi(raw) : 0;
}
static void setGold(int g) {
char buf[16]; snprintf(buf, sizeof(buf), "%d", g);
gApi->saveData("gold", buf);
}
static void onCraft(const ModCraftEvent* ev) {
setGold(getGold() + ev->count); // 1 gold per crafted item
}
static char* cmdBalance(const char*) {
static char buf[32];
snprintf(buf, sizeof(buf), "Gold: %d", getGold());
return buf;
}
Threading & Gotchas
- Struct layout drift is the #1 way mods break.
ModAPIis a flat sequence of function pointers with no per-field versioning — onlyapiVersionat the top. If you vendor your own copy of the struct instead of includingModLoader.hdirectly, and your copy's field order, count, or types diverge even slightly from the engine's, every field after the divergence point silently reads as the wrong function pointer. This does not crash immediately — it corrupts behavior in ways that are hard to trace back to the cause. Always diff your vendored struct against the engine'sinclude/ModLoader.hafter any engine update, or just include the header directly. - onChunkGenerate runs off the main thread. It's the only hook that does. Use
getBlockGen/writeBlockGenthere, never regulargetBlock/setBlock, and never touch any other engine API from that hook — including your own mod's shared state without a lock, ifonUpdatealso touches it. - onPlayerMove and onUpdate can fire very frequently. Keep these handlers allocation-free and branch-light; heavy work here is felt as frame stutter.
- NPC indices are not stable IDs. Re-validate before acting on a stored
npcIndexacross frames. - Terminal command handlers must return stable pointers. Use
static, heap, or literal storage — never a stack buffer. - Mods share one process with the engine and each other. There's no sandboxing — a crash in your mod is a crash of the whole game, and two mods registering conflicting global state (e.g. the same save-data key) can interfere with each other.
- onChatMessage's return value matters. It's the only hook whose return value changes engine behavior — returning
falseswallows the message for every other mod and the player, not just yours.
Cross-Compiling for Windows
From Linux, using MinGW-w64
# Debian/Ubuntu
sudo apt install mingw-w64
x86_64-w64-mingw32-g++ -shared -fPIC -std=c++17 \
-o mymod.dll mymod.cpp \
-I/path/to/VoxelGame/include \
-static-libgcc -static-libstdc++ \
-Wl,--out-implib,mymod.lib
Native Windows — MSVC
cl.exe /std:c++17 /LD /EHsc ^
/I"C:\VoxelGame\include" ^
mymod.cpp ^
/Fe:mymod.dll
Native Windows — MSYS2/MinGW
g++ -shared -std=c++17 \
-o mymod.dll mymod.cpp \
-I/c/VoxelGame/include
If you cross-compile or use MinGW natively, either add -static-libgcc -static-libstdc++ or ship libstdc++-6.dll/libgcc_s_seh-1.dll alongside your .dll — players without MinGW installed will otherwise fail to load your mod with a missing-DLL error.
See the reference Modding Guide for the full ModAPI field table, permissions/roles, F3 admin console, portals, and world-gen documentation not covered in this tutorial.