C and C++
Games can be written in C++ (or plain C) as well as Rust. They use the same engine: the same ECS, game loop, renderer, spatial grid and control endpoint for agents. Every chapter of this guide shows the C++ version of its examples next to the Rust one.
include/crabby.hpp: the C++17 wrapper, header-only.crabby::game,crabby::ctxandcrabby::world_viewmirror Rust’sGame,CtxandWorld.include/crabby.h: the C API underneath, generated from theponko_fficrate with cbindgen.- One shared library,
ponko_ffi(libponko_ffi.dylib/.so,ponko_ffi.dll).
Loops over entities run in your C++ code, over whole component arrays,
so a million entities never mean a million calls across the language
boundary, and each_par runs them on all cores.
Build and run the examples
From the repository root, with Rust, CMake, a C11 compiler and a C++17 compiler:
cargo build -p ponko_ffi --release
cmake -S examples/cpp -B target/native -DCMAKE_BUILD_TYPE=Release -DCRABBY_LIBRARY_DIR="$PWD/target/release"
cmake --build target/native --config Release
ctest --test-dir target/native -C Release --output-on-failure
./target/native/crabby_cpp_boids # window
HEADLESS=1 BOIDS=1000000 TICKS=300 ./target/native/crabby_cpp_boids
./target/native/crabby_cpp_battle # the battle demo
HEADLESS=1 UNITS=1000000 TICKS=600 ./target/native/crabby_cpp_battle
In PowerShell, replace $PWD with the repository’s absolute path, using
forward slashes; Visual Studio builds use --config Release and put programs
in target/native/Release/.
For your own game, copy the recipe in examples/cpp/CMakeLists.txt: include
the two headers, link ponko_ffi, ship its shared library next to your
executable, and put your textures in an assets/ folder next to it (the
example copies the repository’s assets/). crabby_abi_version() returns 2;
rebuild against the matching header when you update the engine.
A C++ game
static void my_game() {
crabby::texture crab{};
crabby::vec2 pos{400, 300};
crabby::game("My Game", 800, 600)
.system([&](crabby::ctx& ctx) {
if (ctx.key_down(CRABBY_KEY_RIGHT)) pos.x += 200 * ctx.dt();
if (ctx.key_down(CRABBY_KEY_LEFT)) pos.x -= 200 * ctx.dt();
if (ctx.key_pressed(CRABBY_KEY_ESCAPE)) ctx.quit();
ctx.draw(crab, pos);
})
.run([&](crabby::ctx& ctx) { crab = ctx.load_texture("crab.png"); });
}
- Systems are anything callable as
void(crabby::ctx&). Lambdas can capture your game state by reference: the game runs insiderun(), so it outlives nothing it captures. run(setup)opens the window;run_headless(ticks, setup)runs the simulation alone and returns the world (see Headless runs).- Errors become exceptions. An exception thrown by your system stops the game
and comes out of
run(); it never crosses into the engine.
Components
C++ components are plain structs: standard-layout, trivially copyable and
trivially destructible. No std::string, std::vector or owning pointers in
components; store ids or indices and keep other objects outside the ECS.
struct Position { float x, y; };
struct Velocity { float x, y; };
There’s nothing to register: calls find a component by its C++ type, and
the engine learns about the type the first time it sees one. It’s named
after the type, namespaces included (game::Health), which is how tools and
saves show it; world.reflect<T>("Name", fields) picks another name.
The built-ins are C++ types too: crabby::transform2d is Transform2D
(the same bytes as Rust), crabby::attached is Attached,
crabby::animation is Animation, crabby::velocity is Velocity, and
crabby::sprite is Sprite (spawn, get and set, but not a query parameter,
since the engine stores sprites in a Rust layout; the compiler tells you).
Physics bodies and colliders, crabby::rigid_body and
crabby::collider, work like sprites.
crabby::world world;
auto player = world.spawn(Position{0, 0}, Velocity{1, 2});
world.each([](Position& p, const Velocity& v) {
p.x += v.x;
p.y += v.y;
});
auto result = world.get<Position>(player);
Queries and threads
world.each(fn) calls your function for every entity with the components
it takes (see Queries); world.query<A, B>(fn) hands you
whole arrays instead. While a loop runs, the world and context are busy:
other world or context calls throw (C: return CRABBY_BUSY), except
- commands:
despawn_later,spawn_later,set_later,remove_later, applied when the system returns (ineachandquery, not in the_parversions); - events:
sendandread(also not in the_parversions; see Events); - spatial queries, and the clock values
dt(),sim_tick(),random().
each_par and query_par split archetypes into slices and run your function
on all cores at once. It must only touch its own entity and read shared
data; that is the same rule as Rust’s par_for_each.
each is as fast as a hand-written loop over the arrays: 1M entities,
position += velocity, take 0.15 ms either way
(examples/cpp/bench.cpp).
Everything else stays on the thread that runs the game.
Speed
The battle demo is a whole game, written twice: examples/battle.rs and
examples/cpp/battle.cpp. Both have flow fields, per-team grids, combat on
all cores, reinforcements and rounds. Same seed, same map, same armies
(the C++ rng gives the same numbers as Rust’s), release builds,
HEADLESS=1 UNITS=… TICKS=1200:
| Units | Rust | C++ |
|---|---|---|
| 20k | 0.57 ms/tick | 0.57 ms/tick |
| 200k | 4.04 ms/tick | 4.24 ms/tick |
| 1M | 18.85 ms/tick | 19.97 ms/tick |
The hot loops use views (grid.view(), flow.view()) that the header reads
inline, so nothing crosses into the engine per unit. The gap for big armies
is in rebuilding the per-team grids: Rust feeds them straight from the
units, C++ copies each team’s units into an array first.
HEADLESS=1 BOIDS=… ./crabby_cpp_boids and HEADLESS=1 BOIDS=… cargo run --release --example boids run the same flocking, release builds, on the
development Mac (arm64, 2026-11-03):
| Boids | Rust | C++ |
|---|---|---|
| 100k | 3.5 ms/tick | 3.9 ms/tick |
| 1M | 31.3 ms/tick | 34.5 ms/tick |
The remaining gap is mostly the C++ version copying velocities into its own
array each tick before rebuilding its grid. Plain column updates (one million
p += v, cargo run --release --example ffi_bench vs crabby_cpp_bench,
which times each and query) run at the same speed in both: about 0.15 ms.
Grid queries in hot loops should use views (ctx.spatial_view(),
grid.view()): the header walks the grid inline, as Rust does. Through the
query functions, C++ boids took 41.5 ms/tick at 1M.
Plain C
C uses the same functions without the wrapper: status codes instead of
exceptions, a user pointer instead of captures.
CrabbyWorld* world = crabby_world_new();
if (!world) { fprintf(stderr, "%s\n", crabby_last_error()); return 1; }
uint32_t position, velocity;
check(crabby_component_register(world, "Position", sizeof(Position), _Alignof(Position), &position));
check(crabby_component_register(world, "Velocity", sizeof(Velocity), _Alignof(Velocity), &velocity));
Position p = {0, 0};
Velocity v = {1, 2};
CrabbyValue values[] = {{position, &p, sizeof(p)}, {velocity, &v, sizeof(v)}};
uint64_t entity;
check(crabby_spawn(world, values, 2, &entity));
uint32_t columns[] = {position, velocity};
check(crabby_query(world, columns, 2, update, NULL));
check(crabby_get(world, entity, position, &p, sizeof(p)));
check(crabby_world_free(world));
static void update(const CrabbyChunk* chunk, void* user) {
(void)user;
Position* positions = chunk->columns[0];
const Velocity* velocities = chunk->columns[1];
for (size_t i = 0; i < chunk->count; ++i) {
positions[i].x += velocities[i].x;
positions[i].y += velocities[i].y;
}
}
A headless game in C:
typedef struct { uint32_t position, velocity; uint64_t agent; } Game;
static void setup(CrabbyCtx* ctx, void* user) {
Game* g = user;
CrabbyWorld* world = crabby_ctx_world(ctx);
check(crabby_component_register(world, "Position", sizeof(Position), _Alignof(Position), &g->position));
check(crabby_component_register(world, "Velocity", sizeof(Velocity), _Alignof(Velocity), &g->velocity));
Position p = {0, 0};
Velocity v = {1, 2};
CrabbyValue values[] = {{g->position, &p, sizeof(p)}, {g->velocity, &v, sizeof(v)}};
check(crabby_spawn(world, values, 2, &g->agent));
}
static void movement_chunk(const CrabbyChunk* chunk, void* user) {
float dt = *(const float*)user;
Position* positions = chunk->columns[0];
const Velocity* velocities = chunk->columns[1];
for (size_t i = 0; i < chunk->count; ++i) {
positions[i].x += velocities[i].x * dt;
positions[i].y += velocities[i].y * dt;
}
}
static void movement(CrabbyCtx* ctx, void* user) {
Game* g = user;
float dt = crabby_ctx_dt(ctx);
uint32_t columns[] = {g->position, g->velocity};
check(crabby_query(crabby_ctx_world(ctx), columns, 2, movement_chunk, &dt));
}
Game g = {0};
CrabbyGame* game = crabby_game_new("C headless", 1, 1);
check(crabby_game_sim_system(game, "movement", 1, movement, &g));
CrabbyWorld* sim = NULL;
check(crabby_game_run_headless(game, 60, setup, &g, &sim)); // frees `game`
Position final;
check(crabby_get(sim, g.agent, g.position, &final, sizeof(final)));
check(crabby_world_free(sim));
Every call returns CrabbyStatus; on failure crabby_last_error() has the
message (per thread, until the next failed call). Null arguments, wrong
sizes, unknown ids, duplicate query columns and busy worlds are rejected.
Pointers you pass must still be valid: the library can’t recognize a
dangling pointer or a buffer smaller than you say. Callbacks must return
normally; never let a C++ exception or longjmp cross them.
Still Rust-only
C and C++ resources and components are plain structs (no std::string
or std::vector inside), copied in and out. Everything else in this guide
works from C++, and each chapter shows both.