Years ago I wrote a UDP microphone library. Standalone, it was flawless — I tested it across machines, simple voice chat, clean buffers, done. Then I integrated it into Unity through Photon, and everything I owned stopped being mine: lag I didn’t write, packet-size ceilings I didn’t choose, buffers that were suddenly unhappy on someone else’s schedule. The library hadn’t changed. The boundary had.
That’s the lesson this page is about: the boundary is the product. If you want one library to serve every engine — Unity through P/Invoke, Unreal through its build system, Godot through GDExtension, a headless server through nothing at all — then the C ABI is the one door they all fit through, and that door has to be engineered like it will be slammed.
The pattern
The contract, straight from the shipping header — and note that half of it is a comment, because a comment’s job is to state what the code cannot:
/*
* Pattern (per unified coding standards):
* - opaque handle (InventoryHandle)
* - every fallible function returns a structured InventoryResult
* - data comes back through out-parameters
* - C++ exceptions NEVER cross this ABI: every entry point catches
* internally and reports through the structured result instead.
*
* Error string lifetime: error_message / error_context point either to
* static string literals or to thread-local storage owned by the library.
* They are valid until the next inventory C API call on the same thread.
* Copy them if you need to keep them longer. Never free them.
*/
The consumer side is deliberately boring — and boring is the feature:
InventoryHandle* inv = Inventory_Create(100);
InventoryResult r = Inventory_AddItem(inv, 1, 5);
if (r.error_code != INVENTORY_SUCCESS)
fprintf(stderr, "AddItem failed: %s (%s)\n", r.error_message, r.error_context);
Quantity count = 0;
r = Inventory_GetItemCount(inv, 1, &count); /* data via out-param, status via result */
An engine that can speak C can consume this. Every engine can speak C.
The confession: six defects behind a “30-minute fix”
Here’s the part most architecture posts skip. When this C shim was audited before publication, the estimate on file said thirty minutes — it just doesn’t compile. The compile errors were real. They were also the shallow end. Six distinct defects, and the three worst were latent — they’d have shipped the moment the compile errors were patched:
- It could never have linked. The static core wasn’t position-independent,
so the shared C library was unbuildable on Linux from day one.
POSITION_INDEPENDENT_CODE ON— the least glamorous line in the repo, and the one that makes everything else exist. - Error strings pointed into a dead stack frame. The shim returned
c_str()of a local — a dangling pointer handed across the ABI, the kind of bug that works in every demo and detonates in production. Now: thread-local, library-owned buffers, with the lifetime contract written at the top of the header. - Most errors were reported as success. The error-mapping switch covered
11 of ~40 codes; everything unmapped fell through as
INVENTORY_SUCCESS. The enum now ends inINVENTORY_UNKNOWN_ERROR, and the mapping’s contract is never silently succeed. - Exceptions could cross the ABI from every entry point but one. Catch-all at every door now — the header’s NEVER is enforced, not aspirational.
using namespacemade the C typedefs ambiguous against the C++ types — a compile error with an architecture lesson inside: the shim must name its world explicitly.- Includes inside
extern "C",size_twithout<stddef.h>— the boring crust on top.
The receipt: a pure-C test now exercises the shim through the C surface only, both example consumers build in the workspace’s default target — receipts that can’t quietly rot — and the whole suite reads 100% tests passed, 0 tests failed out of 8. One of the examples exists specifically to trigger a domain error and show the structured result crossing the boundary intact.
One decision worth stealing
While writing the C test I assumed GetItemCount on a missing item was an
error. The library disagreed, and the library is right: absence is a valid
answer — count zero, success. The domain error belongs to RemoveItem,
where absence actually blocks the operation. Put errors where decisions live,
not where questions are asked; your API stops crying wolf.
Why this is a heresy
Game engines don’t want you here. The gravity of the ecosystem pulls toward engine-native everything — assets wired in editors, logic in engine scripts, state in engine-blessed formats — and the price appears two months later, when returning to your own code means relearning someone else’s world. The C boundary is how a library stays yours: the core doesn’t know the engine exists, the shim treats the engine as a guest, and the guest can be swapped.
The workspace this lives in has its own page. The receipt for both is public: github.com/Yuozas/game-dev-toolkit.
△ the boundary is the product. ▽