Skip to content
yuozas pixel-art Yuozas in his pink sweater

games · pixels · music · code — from Vilnius with love

← back to the shelf

one-library-every-engine.mdx

One Library, Every Engine

Opaque handle, structured result, out-params, and a hard rule: exceptions never cross the ABI. Plus the six defects — three of them latent ABI bugs — that a '30-minute fix' turned out to be hiding.

2026-07-21 cppc-apiabigame-devreceipts

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:

  1. 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.
  2. 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.
  3. 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 in INVENTORY_UNKNOWN_ERROR, and the mapping’s contract is never silently succeed.
  4. 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.
  5. using namespace made the C typedefs ambiguous against the C++ types — a compile error with an architecture lesson inside: the shim must name its world explicitly.
  6. Includes inside extern "C", size_t without <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.

← back to the shelf

every claim checkable — that's the covenant

run.exe
  • go: home ✦
  • go: now
  • go: the shelf
  • go: creative corner
  • go: about me
  • go: cv (for the serious)
  • go: the 2020 site
  • open: a random project
  • do: copy email
  • do: print / download cv
  • do: sparkle burst
  • do: sign the guestbook