WASM Plugin Best Practices
This document captures the concrete rules, pitfalls, and design decisions learned while building
@djodjonx/gwen-plugin-physics2d— the first official GWEN WASM plugin. Every section describes why a rule exists, not just what it is.
Table of Contents
- Project structure rules
- Rust crate rules
- Plugin Data Bus rules
- TypeScript glue layer rules
- Lifecycle rules
- Service API design rules
- Testing rules
- Performance rules
- Common pitfalls
- Decision log
1. Project structure rules
Rule: One crate + one npm package per plugin
Each WASM plugin lives in two places:
crates/gwen-plugin-{name}/ ← Rust simulation logic
packages/@djodjonx/gwen-plugin-{name}/ ← TypeScript glue + public APIRule: wasm/ is always gitignored
# packages/@djodjonx/gwen-plugin-{name}/wasm/.gitignore
*
!.gitignoreWASM artefacts are generated by wasm-pack and never committed.
Rule: Always declare gwen.wasmFiles in package.json
{
"gwen": {
"type": "wasm-plugin",
"wasmId": "physics2d",
"wasmFiles": ["wasm/gwen_physics2d.js", "wasm/gwen_physics2d_bg.wasm"]
}
}Rule: Follow the 4-file Rust structure
| File | Responsibility |
|---|---|
src/lib.rs | Re-exports only. No logic. |
src/bindings.rs | All #[wasm_bindgen] exports. The WASM boundary. |
src/world.rs | Simulation state and logic. Pure Rust, no WASM concern. |
src/memory.rs | Buffer layout helpers (stride, write helpers). |
This separation ensures world.rs is testable natively with cargo test.
2. Rust crate rules
Rule: Always use crate-type = ["cdylib", "rlib"]
cdylib— required forwasm-packto produce a.wasmbinary.rlib— required forcargo testto run unit tests natively.
Rule: Disable wasm-opt in release profiles
[package.metadata.wasm-pack.profile.release]
wasm-opt = falsewasm-opt can trip on bulk-memory or SIMD opcodes from Rapier and similar libraries. Disabling it avoids subtle correctness bugs.
Rule: Use gwen-wasm-utils for all buffer and ring-buffer I/O
[dependencies]
gwen-wasm-utils = { path = "../../crates/gwen-wasm-utils" }Never write raw get_index / set_index loops inline. Use the typed helpers:
gwen_wasm_utils::buffer::{read_u32, write_u32, write_u16, write_u8, flush_local_to_js}gwen_wasm_utils::ring::RingWritergwen_wasm_utils::debug::{write_sentinel, check_sentinel}
Rule: Use #[wasm_bindgen] only on the public contract
Only mark structs and methods that TypeScript actually calls. Internal helpers and state structs must not carry the attribute — it increases binary size.
Rule: Return scalars or Vec<f32> across the WASM boundary
Avoid Vec<JsValue> — it allocates a JS Array + individual objects per element and triggers the GC on the hot path. Return a Vec<f32> for position data, or write events directly to the ring buffer.
Rule: Respect the declared channel buffer size — never write beyond it
PluginDataBus places a 0xDEADBEEF sentinel at the end of every buffer. An overrun causes an immediate named error in debug mode. Compute your max write offset at construction time:
let max_transform_bytes = max_entities as usize * STRIDE;
assert!(transform_buf.length() as usize >= max_transform_bytes,
"transform_buf too small");3. Plugin Data Bus rules
Rule: Set sharedMemoryBytes = 0 for all new plugins
readonly sharedMemoryBytes = 0; // No legacy SAB — use the BusSharedMemoryManager skips allocation and passes region = null to onInit(). The Bus provides JS-native ArrayBuffers that are immune to memory.grow().
Rule: Always declare channels statically
Channels are declared before createEngine() runs so PluginDataBus can pre-allocate before onInit() is called:
readonly channels: PluginChannel[] = [
{ name: 'transform', direction: 'read', strideBytes: 20, bufferType: 'f32' },
{ name: 'events', direction: 'write', bufferType: 'ring', capacityEvents: 256 },
];Rule: Provide a fallback ArrayBuffer when bus is undefined
const eventsBuf = bus?.get(this.id, 'events')?.buffer
?? new ArrayBuffer(8 + 256 * 11); // fallback for unit testsbus is undefined in unit tests that don't construct a full engine.
Rule: Do NOT implement onMemoryGrow() for Bus-based plugins
Bus buffers are plain JS ArrayBuffers — they are never invalidated by a memory.grow() in gwen-core. onMemoryGrow() is only needed by legacy plugins that read directly from gwen-core's linear memory.
Rule: Call readEventChannel once per frame, cache the result
// ✅ One read per frame
const events = readEventChannel(this._eventsBuf!);
for (const ev of events) { /* … */ }
// ❌ Multiple reads — 2nd+ calls return [] (buffer already consumed)
for (const ev of readEventChannel(this._eventsBuf!)) { /* … */ }
for (const ev of readEventChannel(this._eventsBuf!)) { /* always empty */ }Rule: slotA / slotB are raw slot indices — not packed EntityIds
Rapier and other simulation engines store raw ECS slot indices (0–maxEntities), not the 64-bit EntityId (bigint) used by the GWEN TypeScript ECS.
import { createEntityId } from '@djodjonx/gwen-engine-core';
// ✅ Correct — reconstruct EntityId before using with ECS API
for (const ev of physics.getCollisionEvents()) {
const entityA = createEntityId(ev.slotA, api.getEntityGeneration(ev.slotA));
const tag = api.getComponent(entityA, Tag);
}
// ❌ Wrong — ev.slotA is a raw slot index, NOT a valid EntityId
api.getComponent(ev.slotA as any, Tag); // undefined or wrong entity4. TypeScript glue layer rules
Rule: Implement _prefetch() for production plugins
async _prefetch(): Promise<void> {
this._wasmModule = await loadWasmPlugin<MyWasmModule>({
jsUrl: '/wasm/gwen_my_plugin.js', wasmUrl: '/wasm/gwen_my_plugin_bg.wasm', name: this.name,
});
}
async onInit(_bridge, _region, api, bus) {
const wasm = this._wasmModule ?? await loadWasmPlugin(…); // fallback
}createEngine() runs all _prefetch() calls in parallel — O(max_time) instead of O(sum).
Rule: Use loadWasmPlugin() — never raw import() or fetch()
loadWasmPlugin() handles Vite dev-mode restrictions, caches by URL, and supports both default() async and initSync() init forms.
Rule: onInit() is for init only — no simulation logic
onInit() must: load WASM, construct Rust struct with Bus buffers, register service. Never run game logic or start loops here.
Rule: Call api.services.register() inside onInit(), never in provides
provides is a type-declaration hint only — its runtime values are never read.
Rule: Guard onStep() against pre-init calls
onStep(deltaTime: number): void { this.wasmPlugin?.step(deltaTime); }Rule: onDestroy() must free Rust resources and null all buffers
onDestroy(): void {
this.wasmPlugin?.free?.();
this.wasmPlugin = null;
this._eventsBuf = null; // release the ArrayBuffer reference
}Rule: Expose a typed service API — never expose the raw wasm instance
// ✅ Correct
api.services.register('physics', this._createAPI());
// ❌ Wrong — leaks snake_case Rust methods into game code
api.services.register('physics', this.wasmPlugin);5. Lifecycle rules
The 8-step game loop (do not reorder)
1. dispatchBeforeUpdate() TsPlugins: capture inputs, kinematic positions
2. syncTransformsToBuffer() ECS → SAB (legacy)
2b. resetEventChannels() Bus ring buffers → heads reset to 0
3. dispatchWasmStep() Rust: simulate + write events to Bus
4. checkSentinels() [debug] SAB + Bus buffer overrun detection
5. syncTransformsFromBuffer() SAB → ECS (legacy)
6. wasmBridge.tick() Rust game-loop heartbeat
7. dispatchUpdate() TsPlugins: read events, react, apply game logic
8. dispatchRender() TsPlugins: renderingYour plugin's onStep() runs at step 3. Event reading happens at step 7.
Rule: Systems that feed physics MUST use onBeforeUpdate
setKinematicPosition() and applyImpulse() schedule changes for the next step(). If step() has already run (step 3), calls have no effect until the next frame.
✅ onBeforeUpdate → MovementSystem → setKinematicPosition()
Rapier.step() → detects collisions at current positions ✅
onUpdate → CollisionSystem → reads events ✅
❌ onUpdate → MovementSystem → setKinematicPosition()
[already ran] Rapier.step() → worked with last frame's positions| Hook | When | Use for |
|---|---|---|
onBeforeUpdate | Before physics step | Input, movement, kinematic positions |
onStep (Rust) | Physics step | Simulation |
onUpdate | After physics step | Collision response, camera, game logic |
onRender | Rendering | Canvas, WebGL, UI |
Rule: createEngine() must be await-ed
// ✅ Correct
const { engine, scenes } = await createEngine(gwenConfig, registerScenes, mainScene);6. Service API design rules
Rule: Type your service API as an interface, not a class
An interface can be mocked trivially in tests and makes the contract independent of the implementation.
Rule: Use numeric enums for types that cross the WASM boundary
export const BODY_TYPE: Record<RigidBodyType, number> = {
fixed: 0, dynamic: 1, kinematic: 2,
} as const;Passing a number is zero-cost across the WASM boundary; passing a string forces Rust to do string comparison.
Rule: Return null for "not found", never throw
Throwing from a service API inside onUpdate() stops the game loop.
Rule: getCollisionEvents() returns a snapshot — call it once per frame
readEventChannel advances read_head after reading. Subsequent calls within the same frame return [].
7. Testing rules
Rule: Test Rust world logic with native tests, not browser tests
cargo test -p gwen-plugin-my-plugin # milliseconds, no browserworld.rs is pure Rust — no WASM compilation needed. Reserve wasm-pack test --chrome for testing the WASM boundary itself.
Rule: Test the TypeScript glue with mocked WASM and mocked Bus
const mockBus = {
get: (pluginId, name) => {
if (name === 'events') return { buffer: new ArrayBuffer(8 + 256 * 11) };
return undefined;
},
};
await plugin.onInit(bridge, null, api, mockBus);Rule: Test at least these scenarios for every plugin
| # | Scenario | Why |
|---|---|---|
| 1 | id and name are correct | Typos break service lookup |
| 2 | sharedMemoryBytes = 0 | Confirms Bus-based approach |
| 3 | channels declares expected names | Mis-named channel = undefined buffer |
| 4 | onInit() loads WASM with correct URLs | URL mismatch = silent 404 |
| 5 | onInit() uses Bus buffers when provided | Wrong buffer = wrong memory |
| 6 | onInit() falls back to fresh ArrayBuffer when bus is absent | Tests must work without engine |
| 7 | onInit() registers the service | Missing = crash in onUpdate |
| 8 | onStep() delegates to wasm.step() | Simulation never runs |
| 9 | onStep() is safe before onInit() | Hot-reload crash prevention |
| 10 | onDestroy() calls wasm.free() | Memory leak on stop/restart |
| 11 | getEvents() reads binary ring buffer | Core of the Bus contract |
8. Performance rules
Rule: Iterate over entities in Rust, not TypeScript
Each JS → WASM call has overhead. A tight loop over 10,000 entities with one call per entity costs 1–5 ms/frame. The same loop in Rust: microseconds.
Rule: Build a local Rust buffer and flush in one copy_from() call
let mut local = vec![0u8; max_entities as usize * STRIDE];
for each entity: write_position_rotation_local(&mut local, i, x, y, rot);
flush_local_to_js(buf, &local); // 1 FFI call totalRule: Binary ring buffer for events, not JSON
JSON is acceptable for stats() (infrequent, bounded). For collision events written every frame, the binary ring buffer protocol eliminates allocation and parsing overhead entirely.
9. Common pitfalls
Pitfall: createEngine() not awaited
Symptom: api.services.get('physics') returns undefined. Fix: const { engine } = await createEngine(…).
Pitfall: bus not passed to onInit() in custom integration
Symptom: bus?.get(…) returns undefined, fallback buffer used in production. Fix: Ensure your createEngine() usage is the standard GWEN one — it always passes the bus.
Pitfall: Reading events multiple times per frame
Symptom: Second getCollisionEvents() call returns []. Cause: readEventChannel advances read_head — buffer appears empty. Fix: Call once and cache: const events = physics.getCollisionEvents().
Pitfall: Passing packed EntityId to physics API
Symptom: addRigidBody warns "entity_index >= max_entities". Cause: Passing the 64-bit EntityId (bigint) directly instead of the raw slot index. Fix: Use unpackEntityId(entityId).index to extract the raw slot index:
import { unpackEntityId } from '@djodjonx/gwen-engine-core';
const { index } = unpackEntityId(entityId);
const handle = physics.addRigidBody(index, 'dynamic', x, y);Pitfall: maxEntities mismatch between engine and plugin
Symptom: Entities with index > plugin's maxEntities are ignored by simulation. Fix:
const MAX = 10_000;
defineConfig({
engine: { maxEntities: MAX },
wasmPlugins: [physics2D({ maxEntities: MAX })], // must match
});Pitfall: COOP/COEP headers missing in production
Symptom: Works in vite dev, may fail or warn in production. Fix: Configure your production server to send the isolation headers.
Pitfall: free() not called on onDestroy()
Symptom: Memory leak after repeated engine stop/restart. Fix: this.wasmPlugin?.free?.(); this.wasmPlugin = null;
Pitfall: add_rigid_body() without a collider (Rapier-specific)
Symptom: applyImpulse() has no effect. Body never moves. Cause: Rapier dynamic body without collider has zero mass → immediately sleeps. Fix: Always add at least one collider after creating a body.
10. Decision log
D1: Plugin Data Bus instead of SAB or raw shared pointers
Decision: Plugins communicate via JS-native ArrayBuffers allocated by PluginDataBus, not via a raw pointer into gwen-core's linear memory.
Rationale:
- Raw pointers between separate WASM modules are invalid — each module has its own isolated linear memory. Dereferencing a foreign pointer causes
RuntimeError: memory access out of bounds. SharedArrayBufferrequires COOP/COEP headers which break loading of third-party CDN assets in many production environments.- JS-native
ArrayBuffers are the spec-correct bridge: they're accessible to both JS and any WASM module that receives aUint8Arrayview. - Bus buffers are immune to
memory.grow()— noonMemoryGrow()hook, no view invalidation, simpler plugin code.
Trade-off: One bulk copy_from() per channel per frame (~10–20 µs at 500 entities). Negligible vs. the simulation itself.
D2: Binary ring buffer for events instead of JSON strings
Decision: Collision events are written to a binary ring buffer by Rust and read by TypeScript, replacing the previous get_collision_events() -> String JSON approach.
Rationale:
- JSON parsing allocates a new array + object per event on every call. At 60 FPS with ~50 events/frame this creates measurable GC pressure.
- The binary format (11 bytes per event) is zero-allocation on both ends: Rust writes via
set_index, TypeScript reads viaDataView. - The ring buffer is reset by the engine at the start of each frame — no stale data, no accumulation, no manual clearing needed by plugins.
- The protocol is simple enough to implement in a few lines on both sides.
Trade-off: Less debuggable than JSON during development. Mitigated by the named sentinel guards and the readEventChannel helper.
D3: gwen-wasm-utils shared crate
Decision: Buffer I/O helpers, ring-buffer writer, and sentinel canaries live in a shared gwen-wasm-utils crate, not copy-pasted into each plugin.
Rationale:
- Every GWEN WASM plugin needs the same primitives: LE read/write helpers, a ring-buffer writer, and sentinel canaries.
- A shared crate ensures all plugins use the same protocol — a bug fix or layout change propagates everywhere automatically.
gwen-wasm-utilscompiles to arlib— it is inlined into each plugin's.wasmbinary by the linker. No runtime dependency, zero overhead.
D4: sharedMemoryBytes = 0 for Bus-based plugins
Decision: New plugins set sharedMemoryBytes = 0, causing SharedMemoryManager to skip allocation and pass region = null to onInit().
Rationale:
- Keeps
SharedMemoryManageras a zero-overhead no-op for plugins that don't need gwen-core's linear memory. - The
region: MemoryRegion | nullsignature makes the intent explicit —nullmeans "I use the Bus, not the SAB". - Fully backward-compatible: existing plugins that set
sharedMemoryBytes > 0continue to work unchanged.
D5: Two-phase plugin initialisation (_prefetch + onInit)
Decision: createEngine() separates loading into a parallel fetch phase (_prefetch) and a sequential init phase (onInit).
Rationale:
- Parallel fetch reduces load time from O(n × fetch_time) to O(max_fetch_time).
- Sequential init is required because Bus allocation mutates shared state and service registration must be ordered.
- The split is clean:
_prefetch= pure network I/O,onInit= memory + DI.
D6: 0xDEADBEEF sentinel guards — debug mode only
Decision: Sentinel checks run only when engine: { debug: true }.
Rationale:
- In production, the check is skipped entirely — zero overhead, zero risk.
- In development, an overrun produces an immediate named error instead of silent heap corruption that surfaces frames later as a physics glitch.