@zylem/runtime
v0.1.1
Published
Rust + wasm simulation runtime for Zylem (prebuilt zylem_runtime.wasm + thin TS loader and typed FFI exports).
Maintainers
Readme
@zylem/runtime
Rust-side Zylem simulation behind a Shipyard ECS and a Rapier3D
physics world, compiled to wasm32-unknown-unknown and shipped as a
prebuilt artifact with a thin TypeScript loader and typed FFI exports.
The TypeScript engine remains the source of truth for authoring,
rendering, and orchestration. This package moves simulation-oriented
work (collision-derived state, behavior FSMs, batched render updates)
into Rust while keeping per-frame coupling cheap: each frame the host
fills an input buffer, calls zylem_runtime_step (or the
zylem_stage_* equivalents), then reads render and summary
buffers — no per-entity wasm calls in the hot loop.
Install
# Public npm package — no auth required to install.
pnpm add @zylem/runtimeThe published tarball ships exactly:
dist/index.js # ESM entry
dist/index.d.ts # type definitions
dist/zylem_runtime.wasm # release-mode wasm binary
README.md
LICENSE
package.jsonUsage
The default loader resolves the bundled .wasm via import.meta.url,
so it works under Node, Vite, esbuild, Webpack, and any bundler that
preserves asset URLs:
import { loadStageFfi, STAGE_RENDER_STRIDE } from '@zylem/runtime';
const { exports, memory } = await loadStageFfi();
exports.zylem_stage_create(64);
exports.zylem_stage_set_gravity(0, -9.81, 0);
const slot = exports.zylem_stage_create_entity();
exports.zylem_stage_attach_body(
slot,
/* kind */ 0,
/* pos */ 0, 5, 0,
/* rot */ 0, 0, 0, 1,
/* damping */ 0, 0, /* gravityScale */ 1,
/* canSleep */ 1, /* ccd */ 0,
/* lockRot xyz */ 0, 0, 0,
/* lockTrans xyz */ 0, 0, 0,
);
exports.zylem_stage_add_collider_box(
slot, 0.5, 0.5, 0.5, 0, 0, 0, 0.5, 0.0, /* sensor */ 0, /* groups */ 0xffffffff,
);
for (let i = 0; i < 30; i++) exports.zylem_stage_step(1 / 60);Or use the legacy batched-runtime FFI:
import {
loadRuntimeFfi,
RUNTIME_INPUT_STRIDE,
RUNTIME_RENDER_STRIDE,
} from '@zylem/runtime';
const { exports, memory } = await loadRuntimeFfi();
exports.zylem_runtime_init(/* capacity */ 256, /* initialActive */ 16);
const inputView = new Float32Array(
memory.buffer,
exports.zylem_runtime_input_ptr(),
exports.zylem_runtime_input_len(),
);
// fill inputView with [posXYZ, rotXYZW, contacts, speed] per slot…
exports.zylem_runtime_step(1 / 60);
const renderView = new Float32Array(
memory.buffer,
exports.zylem_runtime_render_ptr(),
exports.zylem_runtime_render_len(),
);Custom wasm source
import { loadStageFfi } from '@zylem/runtime';
// Bring your own bytes (e.g. fetched + cached, or a Vite `?url` import):
const bytes = await fetch('/assets/zylem_runtime.wasm').then(r => r.arrayBuffer());
const { exports } = await loadStageFfi({ source: bytes });source accepts ArrayBuffer | Uint8Array | URL | string | Request | Response
and any Promise of the above.
Higher-level wrappers
This package deliberately stays low-level. The richer
WasmStageRuntime class (entity helpers, behavior attach/query, render
view caching) lives in @zylem/game-lib's runtime subpath:
import { createWasmStageRuntime } from '@zylem/game-lib/runtime';
import { loadStageFfi } from '@zylem/runtime';
const { exports } = await loadStageFfi();
const stage = await createWasmStageRuntime(exports, { initialCapacity: 256 });Local development
pnpm install
# One-shot install of the wasm32 toolchain (only required once):
rustup target add wasm32-unknown-unknown
pnpm build # cargo wasm release + tsup + copy artifact into dist/
pnpm test # cargo tests
pnpm typecheck # tsc --noEmit && cargo check
pnpm demo:node # smoke-test the dist/ artifact under Node
pnpm demo:browser # serve the HTML demo at http://localhost:5173pnpm build is also wired up as a prepack hook so npm pack /
npm publish always rebuild from source.
Privacy model
This package uses an asymmetric privacy model: source private, artifact public.
| Layer | Visibility | Where it's set |
|-------|------------|----------------|
| GitHub source repo | Private | github.com → repo Settings → General → Visibility → Private. The CI workflow assumes a private repo; actions/checkout@v4 + the auto-provisioned GITHUB_TOKEN is sufficient. |
| npm package | Public | package.json → publishConfig.access: "public", explicit --access public in CI, plus a prepublishOnly guard (scripts/check-publish-access.mjs). |
| npm scope default | unrestricted | npmjs.com → @zylem org → Default package access. Either "Public" or "Private" works; the per-package publishConfig.access: "public" overrides. |
What ships in the npm tarball: only dist/index.js, dist/index.d.ts, dist/zylem_runtime.wasm, plus package.json and README.md. The Rust source, the TypeScript wrapper source, the demo, and CI workflow files are all excluded by the files allowlist.
What stays private (in this GitHub repo): the Rust crate (src/), the TS wrapper sources (src-ts/), Cargo.toml/Cargo.lock, tsconfig.json/tsup.config.ts, the demo, and the publish workflow.
Notes:
"private": trueis deliberately not set inpackage.json. It would block all publishes.- npm package provenance via OIDC is disabled (
publishConfig.provenance: false, noid-token: writein CI). Enabling it would publicly attest the build to this private source repo, which we'd rather not expose. - TS sourcemaps are disabled in
tsup.config.tsso the public tarball contains no TypeScript source — only the compiled JS loader, type definitions, and the wasm binary. - The committed
.npmrcreferences${NPM_TOKEN}via env-var substitution. No real secret is in the repo. CI injectsNPM_TOKENasNODE_AUTH_TOKENfromsecrets.NPM_TOKEN. Consumers do not need a token — installs are anonymous.
Publishing
The package is published to the public npm registry under the @zylem
scope.
From CI (recommended)
.github/workflows/publish.yml runs on every v* tag push:
- Installs Rust +
wasm32-unknown-unknown+ Node 20 + pnpm. - Runs
pnpm install,pnpm run build,pnpm run test. - Runs
npm packfor visibility, runs the access guard, thennpm publish --access public.
Add an NPM_TOKEN repository secret (an "automation" token from
npmjs.com that has publish permissions on the @zylem scope). Tag a
release with:
git tag v0.1.1
git push origin v0.1.1You can also trigger the workflow manually from the Actions tab with a dry run toggle.
From a workstation
export NPM_TOKEN=npm_… # automation token with publish on @zylem
pnpm publish --access public --no-git-checks(prepublishOnly will rebuild wasm + TS and run cargo tests before
the actual publish.)
FFI contract
Stage FFI (zylem_stage_*, used by WasmStageRuntime)
One simulation per wasm module instance — the exported functions use a
thread-local StageSimulation singleton. Instantiate one
WebAssembly.Module per logical world.
| Constant | Value | Where |
|----------|-------|-------|
| STAGE_RENDER_STRIDE | 12 floats per slot | render buffer |
| STAGE_EVENT_STRIDE | 6 floats per event | event buffer |
| STAGE_POSE_LEN | 7 floats [pos.xyz, rot.xyzw] | pose scratch |
| STAGE_INVALID_SLOT | 0xffffffff | sentinel for "no slot" |
See src/stage_ffi.rs for the canonical export list, and
dist/index.d.ts (StageWasmExports) for the TypeScript surface.
Runtime FFI (zylem_runtime_*, batched/legacy)
| Symbol | Returns | Notes |
|--------|---------|-------|
| zylem_runtime_init(capacity, initial_active) | usize | Allocates input/render/summary buffers; activates initial_active slots. Returns active count, 0 on capacity == 0. |
| zylem_runtime_step(dt) | usize | dt clamped >= 0. Runs sync → collision state → render build; increments tick. Returns active_count. |
| zylem_runtime_input_ptr / _len / _stride | — | Host writes per-slot [pos, rot, contacts, speed] (9 floats). |
| zylem_runtime_render_ptr / _len / _stride | — | Host reads per-slot [pos, rot, scale, rgb, heat] (12 floats). |
| zylem_runtime_summary_ptr / _len | — | 6 floats: [entityCount, colliding, totalContacts, avgHeat, maxHeat, maxContacts]. |
Input buffer layout (per slot, 9 floats)
| Index | Field |
|-------|-------|
| 0–2 | Position x, y, z |
| 3–6 | Rotation quaternion x, y, z, w |
| 7 | Contact count (truncated u32) |
| 8 | Speed |
Render buffer layout (per slot, 12 floats)
| Index | Field | |-------|-------| | 0–2 | Position | | 3–6 | Rotation quaternion | | 7 | Scale | | 8–10 | RGB color | | 11 | Heat |
Summary buffer (6 floats)
| Index | Meaning | |-------|---------| | 0 | Active entity count | | 1 | Count of entities with contacts | | 2 | Total contacts | | 3 | Average heat | | 4 | Max heat | | 5 | Max contacts |
License
MIT
