Errors and expected
Lesson 4.3
Mods are built with C++ exceptions turned off. When an API call can fail, it tells you with its return value, and it is your job to look. This lesson shows the error codes, the wrapper’s clight::Expected, and what happens to standard library functions that would normally throw.
cl_result: the error codes
Underneath, every failing call has a cl_result, a number:
CL_OK(0) means it worked.- Anything else is an error code. They are all negative and start with
CL_E_.
| Code | Number | Usually means |
|---|---|---|
CL_E_INVALID_ARGUMENT |
-2 | A wrong value: an empty id, a number out of range, a missing callback. |
CL_E_INVALID_HANDLE |
-3 | A handle of the wrong kind, or one that never existed. |
CL_E_STALE_HANDLE |
-4 | The thing is gone: the player left, the entity died. |
CL_E_NOT_FOUND |
-6 | An item, block, event or option that doesn’t exist (often a typo). |
CL_E_ALREADY_EXISTS |
-7 | You registered the same id or command twice. |
CL_E_WRONG_PHASE |
-8 | Too early or too late, like registering an item after startup. |
CL_E_WRONG_SIDE |
-10 | A server function on the client, or the other way round. |
CL_E_PERMISSION_DENIED |
-11 | A missing capability, or another mod’s namespace. |
CL_E_BUFFER_TOO_SMALL |
-12 | A string buffer was too short (the wrapper handles this for you). |
CL_E_NOT_LOADED |
-14 | A block in a part of the world that isn’t loaded. |
CL_E_LIMIT |
-18 | Something is too big, like a payload over its size limit. |
The full list is in clight.h, right at the top.
clight::Expected: a value or an error
Wrapper functions that can fail return a clight::Expected<T>. It holds either the value you asked for, or the cl_result that says why there is none. It is the standard std::expected from C++23, with cl_result as its error type.
auto dim = level.dimension(); // clight::Expected<std::string>
if (dim) {
clight::log::info("in {}", *dim); // the value
} else {
clight::log::warn("no dimension: {}", dim.error()); // the cl_result, like -4
}
| You write | Means |
|---|---|
if (r) or r.has_value() |
Did it work? |
*r, r->field |
The value (only after you checked). |
r.error() |
The cl_result (only when it failed). |
r.value_or(fallback) |
The value, or fallback when it failed. |
value_or is the easiest one when a default is fine:
cl_vec3 at = player.position().value_or(cl_vec3{0, 64, 0});
std::string args = ctx.args().value_or("");
Functions that do something and have no value to give back return clight::Expected<void>. Check them the same way, or ignore them:
if (auto r = player.teleport({0.5, 100, 0.5}); !r) {
clight::log::warn("teleport failed: {}", r.error());
}
The if (auto r = ...; !r) form declares r only for the if, which keeps names from piling up.
Easy helpers and try_ versions
The easy helpers from The C++ wrapper are made for code that doesn’t care why something failed:
| Easy | Gives | The full one | Gives |
|---|---|---|---|
player.name() |
std::string, "" on failure |
player.try_name() |
Expected<std::string> |
player.pos() |
cl_vec3, {0, 0, 0} on failure |
player.position() |
Expected<cl_vec3> |
player.give("id", n) |
bool |
player.give(stack) |
Expected<void> |
player.send_message("text") |
bool |
player.send_message(text) |
Expected<void> |
level.set_block(pos, "id") |
bool |
level.set_block(pos, state) |
Expected<void> |
Use the easy one when a failure doesn’t matter (a message to a player who just left). Use the full one when you need to know.
if (!player.give("minecraft:diamon")) {
clight::log::warn("give failed"); // there is no item called minecraft:diamon
}
Getters that can’t return a code
A few functions return a plain value or an empty object. player.health() returns a float, and clight::item("my_mod:rubby") returns an empty ItemStack for an unknown id. When those fail, they leave the reason in the last error:
clight::ItemStack s = clight::item("my_mod:rubby");
if (!s) {
clight::log::warn("no stack: {}", cl_last_error());
if (auto why = clight::core::last_error_message()) clight::log::warn("because: {}", *why);
}
The last error is overwritten by the next failing call, so read it right away.
No exceptions
C++ normally reports errors with exceptions. Mods are compiled with -fno-exceptions, so:
throw,tryandcatchdon’t compile:error: cannot use 'throw' with exceptions disabled.- Standard library functions that would throw stop your callback instead. The library writes a short line to the log and the call ends with a trap (see Debugging). The game keeps running, but your callback didn’t finish.
These are the ones to watch for:
| Throws normally | Use instead |
|---|---|
r.value() on an Expected that holds an error |
if (r) ... *r, or r.value_or(...) |
std::stoi("abc"), std::stod(...) |
std::from_chars, which returns an error code |
list.at(99) past the end |
check the index, then list[i] |
new or a growing std::vector when memory is full |
keep big data small; see Memory in the sandbox |
std::from_chars (from <charconv>) is the safe way to read numbers from text:
#include <charconv>
#include <optional>
std::optional<int> parse_int(std::string_view s) {
int v = 0;
auto [end, err] = std::from_chars(s.data(), s.data() + s.size(), v);
if (err != std::errc{} || end != s.data() + s.size()) return std::nullopt; // not a whole number
return v;
}
It gives back where it stopped reading and an error code, so "5x" and "five" are refused without any exception. std::optional (from <optional>) is “a value or nothing”, the little sibling of Expected.
If you read W3Schools’ page on Exceptions, keep it for programs outside the game. In a mod, errors are values.
Development mode helps
When you start the game with clight run, it runs in development mode. Then many misuses are logged by the game itself, with the function’s name, even if your code doesn’t check:
[clight] [my_mod] cl_level_set_block failed with -10
That catches a lot of mistakes early. Players don’t run in development mode, so your code should still check the results that matter.
A simple habit
You don’t have to check every single call. A good rule:
- Always check registrations and subscriptions. If they fail, your mod is broken, and a log line tells you right away.
- Check calls whose result you use: a lookup, a position, a dimension.
- Don’t bother with calls where failing does no harm, like sending a message to a player who just left.
clight::on gives back an empty subscription when subscribing failed, and the registry functions return an Expected:
void on_construct() override {
auto sub = clight::on<clight::ev::PlayerJoin>([](cl_ev_player& e) { clight::Entity(e.player).give("minecraft:bread"); });
if (!sub) clight::log::error("could not subscribe to player.join");
sub.detach();
}
Try it
Write a function bool give_or_tell(clight::Entity player, std::string_view item_id, int count) that gives the item, and if that fails, logs a warning with the item id, sends the player “Sorry, something went wrong.”, and returns false.
Show a solution
bool give_or_tell(clight::Entity player, std::string_view item_id, int count) {
if (player.give(item_id, count)) return true;
clight::log::warn("could not give {} x {}", count, item_id);
player.send_message("Sorry, something went wrong.");
return false;
}
The player only sees a friendly sentence; the details go into the log, where you look when you fix it. give with an id returns false for an unknown id or a player who is gone.
Recap: errors are values: wrapper calls return clight::Expected<T> (check with if (r), read with *r, fall back with value_or), easy helpers return plain values or bool, exceptions are off, and standard functions that would throw end your callback, so use from_chars and checked indexes.