Using the Bindings
elevator-core is a Rust library, but the simulation is also exposed through several non-Rust binding crates so games and tools written in other languages can drive it without a Rust toolchain in their build pipeline:
| Crate | Surface | Audience |
|---|---|---|
elevator-wasm | wasm-bindgen exports + auto-generated TypeScript types | Browser playgrounds, JS/TS games, web dashboards |
elevator-ffi | C ABI shared library + auto-generated elevator_ffi.h | Unity (P/Invoke), .NET, native C/C++ harnesses |
elevator-gdext | gdext-registered Godot Node (ElevatorSim) | Godot games / GDScript prototypes |
| GameMaker GML wrapper | Generated GML scripts wrapping elevator-ffi | GameMaker Studio 2 projects |
All bindings share the same simulation core: same physics, same dispatch, same event ordering. The split is purely about how the host language calls in. This chapter covers the contracts each binding exposes and the patterns consumers need to follow to use them safely.
Picking a host
If you already know which language / engine you’re building in, the table above is a one-step decision. The matrix below maps common starting points to the right binding when the choice isn’t obvious:
| You’re building… | Use | Why |
|---|---|---|
| A Rust game with a renderer | Bevy Integration or Headless / macroquad / eframe | Direct Rust API, no binding layer; lowest friction. |
| A browser playground or JS/TS frontend | elevator-wasm | TypeScript types auto-generated; no Rust toolchain on the consumer side. |
| A Unity, .NET, or native C/C++ project | elevator-ffi | C ABI shared library plus generated elevator_ffi.h; P/Invoke or direct linking. |
| A Godot game (GDScript or GDExtension) | elevator-gdext | Registers an ElevatorSim node; calls return Godot Variant dictionaries. |
| A GameMaker Studio 2 game | GameMaker GML wrapper | Pre-generated GML scripts wrap elevator-ffi so the GMS project sees idiomatic functions. |
| An analytics / batch / replay tool | Use elevator-core directly | The Rust crate is headless. The wasm and FFI surfaces exist for non-Rust consumers; reach for them only when language is the constraint. |
Each binding is a thin translation layer — there’s no behavioral difference between simulating in Rust vs. via wasm vs. via FFI given the same config and inputs. Pick by language ergonomics, not by feature set.
Coverage and stability
The set of Simulation methods exposed through each binding is enumerated in bindings.toml at the workspace root. CI verifies the file is in sync with pub fn declarations, so a new core method cannot ship without an explicit decision about whether it is bound and how.
Three statuses are valid per cell (one column per host: wasm, ffi, tui, gms, gdext, bevy):
- An exported name (
stepMany,ev_sim_step) means the method is bound under that name. skip:<reason>means the method is intentionally not bound. The reason is mandatory and documents why (lifetime, internal detail, covered by another binding, etc.).todo:<phase>is a temporary status during phased rollout.
At the time of this chapter the wasm and FFI columns are at full coverage of the public surface; the gdext and bevy columns carry todo: markers for methods queued under the future-binding and plugin-layer phases respectively (see Binding Coverage Manifest for the per-phase breakdown). Any future additions to Simulation will surface there before they ship.
elevator-wasm
The wasm binding is built once, copied into your project, and consumed as ES modules. The TypeScript types are auto-generated by tsify-next from the Rust DTOs, so adding a field on the Rust side propagates to TS without hand-editing.
Building and consuming
The build script in this repository wraps wasm-pack and wasm-bindgen-cli with the right targets:
scripts/build-wasm.sh
This produces a pkg/ directory under playground/public/ containing:
elevator_wasm.js— the JS shimelevator_wasm_bg.wasm— the compiled moduleelevator_wasm.d.ts— TypeScript types for every DTO
For a non-playground consumer, copy pkg/ into your project and import the constructor:
import init, { WasmSim } from "./pkg/elevator_wasm.js";
await init();
const sim = WasmSim.fromConfigJson(myConfigJson);
for (let i = 0; i < 60; i++) {
sim.stepMany(1);
}
const snapshot = sim.snapshot();
console.log(`tick ${snapshot.tick}, ${snapshot.cars.length} cars`);
DTO shape and naming
Every method that returns structured data returns a tsify-generated DTO. The naming convention is *Dto on the Rust side, and the same struct name without the Dto suffix on the TS side is not used — TypeScript consumers see the full Rust name (CarDto, StopDto, MetricsDto, RouteDto, HallCallDto, CarCallDto, TaggedMetricDto, EventDto).
Method names are camelCase via #[wasm_bindgen(js_name = ...)]. Every getter has the same shape on both sides:
| Rust | TypeScript |
|---|---|
sim.snapshot() | sim.snapshot() |
sim.metrics_for_tag("vip") | sim.metricsForTag("vip") |
sim.shortest_route(from, to) | sim.shortestRoute(from, to) |
Errors
Most fallible methods return a Result-shaped object — a discriminated union with a string kind discriminator — instead of throwing. Three concrete result types cover the surface:
| Type | Used by |
|---|---|
WasmVoidResult | Mutators that return () on success (most methods) |
WasmU64Result | Methods that return an entity id |
WasmU32Result | Methods that return a count or code |
Each materializes on the TS side as:
type WasmU64Result =
| { kind: "ok"; value: bigint }
| { kind: "err"; error: string }
Usage:
const r = sim.spawnRider(originId, destId, 75);
if (r.kind === "err") {
console.error("spawn failed:", r.error);
}
The string discriminator narrows r.value and r.error per branch without a manual cast. There is no null / undefined “error sentinel” pattern to special-case.
A small set of methods still throw rather than returning a Result-shape:
- The
WasmSimconstructor (matches the JS-idiomatic “constructors throw” pattern) sim.assignedCarsByLine(stop, direction)andsim.bestEta(stop, direction)— these returnbigint[]and reusing the Result-shape would require introducing a fourth result type for one call site each. They throw on bad direction strings; wrap intry/catchif you can’t statically guarantee"up" / "down".
Events
sim.drainEvents() returns an array of EventDto objects. Each has a kind discriminator ("rider-spawned", "door-opened", etc., kebab-case) plus the variant-specific fields:
for (const ev of sim.drainEvents()) {
switch (ev.kind) {
case "rider-spawned":
console.log(`rider ${ev.rider} spawned at stop ${ev.origin}`);
break;
case "door-opened":
playSfx("door-open", ev.elevator);
break;
case "unknown":
// Forward-compat: future Event variants surface here.
break;
}
}
The TypeScript discriminated union is the auto-generated type, so the compiler narrows fields per kind without manual casting.
elevator-ffi
The FFI binding is a cdylib produced by cargo build -p elevator-ffi --release and a C header at crates/elevator-ffi/include/elevator_ffi.h regenerated automatically by cbindgen on every build. CI guards against header drift.
The C-side public API is small and uniform: every entry point starts with ev_sim_*, takes an EvSim* handle as the first argument, and returns an EvStatus code. The exception is constructors (ev_sim_create*) which return the handle directly (or null on failure).
ABI version handshake
Every shared library carries an integer ABI version. Consumers MUST check it on startup and refuse to proceed if it doesn’t match the version they were built against:
#include "elevator_ffi.h"
if (ev_abi_version() != EV_ABI_VERSION) {
fprintf(stderr, "elevator-ffi ABI mismatch: lib %u, header %u\n",
ev_abi_version(), EV_ABI_VERSION);
return 1;
}
The constant in the header (EV_ABI_VERSION) bumps on every layout change to a repr(C) struct or any other breaking change to the C surface. A bump is a feat!: commit and triggers a major version bump on elevator-ffi itself; consumers see it as a SemVer-major rebuild signal.
Status codes
Every fallible function returns EvStatus:
| Code | Meaning |
|---|---|
EvStatusOk | Success |
EvStatusNullArg | A required pointer argument was null |
EvStatusInvalidUtf8 | A C string argument was not valid UTF-8 |
EvStatusConfigLoad / EvStatusConfigParse | Config file I/O / parse failure |
EvStatusBuildFailed | SimulationBuilder::build returned an error |
EvStatusNotFound | Entity, group, or resource not found |
EvStatusInvalidArg | Argument structurally valid but semantically rejected |
EvStatusPanic | A Rust panic was caught at the FFI boundary |
On any non-Ok status, ev_last_error() returns a thread-local null-terminated description. The pointer is valid until the next FFI call on the same thread:
EvStatus s = ev_sim_step(sim);
if (s != EvStatusOk) {
fprintf(stderr, "step failed: %s\n", ev_last_error());
}
The buffer pattern for variable-length output
C has no native dynamic arrays, so any FFI export that returns a list takes a caller-owned buffer with explicit capacity. Two-step usage: probe to size, then allocate and read:
// Step 1 — probe with capacity 0 to learn the required size.
uint32_t needed = 0;
EvStatus s = ev_sim_shortest_route(sim, from, to, NULL, 0, &needed);
if (s == EvStatusNotFound) {
// No route exists.
return;
}
// `needed` is now the slot count.
// Step 2 — allocate, read, use.
uint64_t* stops = malloc(needed * sizeof(uint64_t));
uint32_t written = 0;
s = ev_sim_shortest_route(sim, from, to, stops, needed, &written);
assert(s == EvStatusOk && written == needed);
for (uint32_t i = 0; i < written; i++) {
/* ... handle stops[i] ... */
}
free(stops);
The same pattern applies to ev_sim_hall_calls_snapshot, ev_sim_car_calls_snapshot, ev_sim_drain_events, ev_sim_destination_queue, the population queries (ev_sim_waiting_at, ev_sim_residents_at, ev_sim_abandoned_at), and ev_sim_all_tags.
Sentinel encoding for optional values
Where the Rust API returns Option<T>, the FFI flat layout uses sentinels chosen to round-trip safely:
| Rust type | FFI sentinel for None |
|---|---|
Option<f64> | f64::NAN (test with isnan()) |
Option<u64> | 0 (entity ids never use slot 0) |
Option<bool> | false |
These are the same sentinels used inside the engine’s own repr(C) stuctures (e.g. EvElevatorParams.bypass_load_up_pct), so the encoding is uniform across input parameters and output buffers.
EvEvent and the event mirror
ev_sim_drain_events writes a sequence of EvEvent records into a caller-owned buffer. Each record has a kind discriminator and 13 payload fields shared by every event kind; ev_event_kind provides named constants for every variant the Rust core emits.
The struct is wide on purpose — the wasm side hands consumers a discriminated union with variant-specific fields, while the FFI side keeps every event the same shape so a C consumer can iterate over a single typed array. Field semantics depend on kind; for example:
EvEvent buf[256];
uint32_t written = 0;
ev_sim_drain_events(sim, buf, 256, &written);
for (uint32_t i = 0; i < written; i++) {
EvEvent* e = &buf[i];
switch (e->kind) {
case ev_event_kind_RIDER_SPAWNED:
printf("rider %llu at stop %llu, dest %llu\n", e->rider, e->stop, e->floor);
break;
case ev_event_kind_DOOR_COMMAND_QUEUED:
// code1 = command (Open / Close / HoldOpen / CancelHold)
// count = hold ticks for HoldOpen, 0 otherwise
printf("door cmd %u on car %llu, hold %llu\n", e->code1, e->car, e->count);
break;
case ev_event_kind_UNKNOWN:
// Forward-compat: future variants surface here.
break;
}
}
The full per-kind field map lives in the rustdoc on ev_event_kind.
Thread-safety
Each EvSim handle is not Sync. Callers must serialize all calls on a single handle. Multiple handles created from ev_sim_create are independent and may be driven from different threads.
ev_last_error is thread-local — different threads see different last-error strings. This means a status check must happen on the same thread that made the call.
Memory ownership
EvSim*handles are owned by the caller. Create withev_sim_create(orev_sim_create_from_config); destroy withev_sim_destroy. Failing to destroy leaks the underlying simulation.ev_sim_framereturns pointers into an internal buffer owned by the handle. Those pointers are valid only until the next call toev_sim_frameon the same handle. Do not retain them across frames; copy what you need.- The string from
ev_last_erroris valid only until the next FFI call on the same thread.
elevator-gdext
The gdext binding registers an ElevatorSim node that GDScript can use directly — no FFI ceremony, no manual handle management. Build with cargo build -p elevator-gdext --release; the resulting cdylib is loaded by Godot via the standard .gdextension manifest.
ABI handshake against elevator-ffi
elevator-gdext is pinned against a specific elevator-ffi ABI generation. The pin is exposed as a public constant (ABI_VERSION) and watched by scripts/check-abi-pins.sh in CI: any drift between this constant and EV_ABI_VERSION in the FFI header fails the gate, surfacing a stale gdext pin before runtime. GDScript callers that want to verify the binding’s ABI generation can read the constant via the registered Node API.
Node API
ElevatorSim is a Godot Node subclass. Attach it to your scene tree, set the exported properties, and the sim ticks itself in _process:
extends Node
@export var elevator: ElevatorSim
func _ready():
elevator.config_path = "/path/to/default.ron"
elevator.speed_multiplier = 1
elevator.auto_spawn = true
| Exported property | Type | Description |
|---|---|---|
config_path | String | Filesystem path to a RON config |
speed_multiplier | i32 | Sim steps per frame (0 = paused) |
auto_spawn | bool | Whether to auto-spawn riders |
spawn_interval_ticks | i32 | Mean ticks between auto-spawns |
weight_min | f64 | Minimum auto-spawn rider weight |
weight_max | f64 | Maximum auto-spawn rider weight |
Methods exposed to GDScript fall into a small set of groups (full per-method coverage lives in bindings.toml):
- Rider management —
spawn_rider,spawn_rider_ex,despawn_rider - Strategy —
set_strategy - Per-frame reads —
current_tick,stop_count,elevator_count,rider_count,get_stop,get_elevator,get_rider,get_metrics,eta_to_stop,drain_events
The set is intentionally narrower than wasm/FFI: methods that have no idiomatic GDScript shape (Option<T> returns, raw byte snapshots, custom dispatch trait objects) are listed as todo:future-binding in bindings.toml. Picking up a future-binding row is the way to broaden gdext coverage.
Memory ownership
The Godot scene tree owns the ElevatorSim node; the underlying Simulation is dropped automatically when the node leaves the tree. There is no caller-side destroy call — adopting the gdext binding into an existing C-style flow generally means letting Godot manage lifetimes.
elevator-ffi-gms (GameMaker Studio 2 wrapper)
The GameMaker binding is a thin GML wrapper around elevator-ffi. The C entry points (ev_sim_*) are declared via external_define and exposed to GML under the same names; the codegen step in crates/elevator-layout-codegen emits the matching GML record-readers from the same MultiHostLayout metadata that drives the Unity / .NET shapes.
The GMS column in bindings.toml typically mirrors the FFI column 1:1 — when an ev_sim_* is bound under FFI, the GML wrapper exists. The few skip: entries on GMS exist where GameMaker’s type system can’t represent the underlying shape (e.g. raw byte buffers).
A worked example, calling ev_sim_step from GameMaker:
// In a Create event, after external_define has registered ev_sim_step:
var status = ev_sim_step(global.sim_handle);
if (status != 0) {
show_debug_message("step failed: " + ev_last_error());
}
Because the GML side calls into elevator-ffi directly, the same memory-ownership and thread-safety rules from the FFI section apply unchanged.
Common patterns across both bindings
Some idioms apply to both surfaces:
-
Build once, drive many ticks. The simulation handle is a heavy object; create it at startup and keep it for the lifetime of the application.
stepandstepManyare designed to be called every frame. -
Drain events every tick. Both bindings emit events into an internal queue; if you don’t drain, the queue grows unbounded. The drain operations are non-blocking — call them after every step in your tick loop.
-
Entity ids are stable across ticks but not across simulation builds. Save them as numbers between calls, but don’t persist them across
ev_sim_destroy/WasmSimrebuilds. -
Snapshots round-trip the full simulation state. Both bindings can serialize a snapshot to bytes, ship it across a network or persist it to disk, then restore it later. See Snapshots and Determinism for the determinism guarantees.
Next steps
- Headless and Non-Bevy Usage — the same integration patterns from the Rust side; useful for understanding what each binding’s exports map back to.
- Events and Metrics — the
Eventenum and metric accumulators that drive UI updates in either binding. - Stability and Versioning — how API breaks are signaled across crates, and what the ABI version handshake protects against.