Skip to content

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

  1. Project structure rules
  2. Rust crate rules
  3. Plugin Data Bus rules
  4. TypeScript glue layer rules
  5. Lifecycle rules
  6. Service API design rules
  7. Testing rules
  8. Performance rules
  9. Common pitfalls
  10. 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 API

Rule: wasm/ is always gitignored

gitignore
# packages/@djodjonx/gwen-plugin-{name}/wasm/.gitignore
*
!.gitignore

WASM artefacts are generated by wasm-pack and never committed.

Rule: Always declare gwen.wasmFiles in package.json

json
{
  "gwen": {
    "type": "wasm-plugin",
    "wasmId": "physics2d",
    "wasmFiles": ["wasm/gwen_physics2d.js", "wasm/gwen_physics2d_bg.wasm"]
  }
}

Rule: Follow the 4-file Rust structure

FileResponsibility
src/lib.rsRe-exports only. No logic.
src/bindings.rsAll #[wasm_bindgen] exports. The WASM boundary.
src/world.rsSimulation state and logic. Pure Rust, no WASM concern.
src/memory.rsBuffer 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 for wasm-pack to produce a .wasm binary.
  • rlib — required for cargo test to run unit tests natively.

Rule: Disable wasm-opt in release profiles

toml
[package.metadata.wasm-pack.profile.release]
wasm-opt = false

wasm-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

toml
[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::RingWriter
  • gwen_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:

rust
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

typescript
readonly sharedMemoryBytes = 0;  // No legacy SAB — use the Bus

SharedMemoryManager 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:

typescript
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

typescript
const eventsBuf = bus?.get(this.id, 'events')?.buffer
  ?? new ArrayBuffer(8 + 256 * 11);  // fallback for unit tests

bus 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

typescript
// ✅ 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.

typescript
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 entity

4. TypeScript glue layer rules

Rule: Implement _prefetch() for production plugins

typescript
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

typescript
onStep(deltaTime: number): void { this.wasmPlugin?.step(deltaTime); }

Rule: onDestroy() must free Rust resources and null all buffers

typescript
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

typescript
// ✅ 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: rendering

Your 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
HookWhenUse for
onBeforeUpdateBefore physics stepInput, movement, kinematic positions
onStep (Rust)Physics stepSimulation
onUpdateAfter physics stepCollision response, camera, game logic
onRenderRenderingCanvas, WebGL, UI

Rule: createEngine() must be await-ed

typescript
// ✅ 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

typescript
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

bash
cargo test -p gwen-plugin-my-plugin  # milliseconds, no browser

world.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

typescript
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

#ScenarioWhy
1id and name are correctTypos break service lookup
2sharedMemoryBytes = 0Confirms Bus-based approach
3channels declares expected namesMis-named channel = undefined buffer
4onInit() loads WASM with correct URLsURL mismatch = silent 404
5onInit() uses Bus buffers when providedWrong buffer = wrong memory
6onInit() falls back to fresh ArrayBuffer when bus is absentTests must work without engine
7onInit() registers the serviceMissing = crash in onUpdate
8onStep() delegates to wasm.step()Simulation never runs
9onStep() is safe before onInit()Hot-reload crash prevention
10onDestroy() calls wasm.free()Memory leak on stop/restart
11getEvents() reads binary ring bufferCore 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

rust
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 total

Rule: 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:

typescript
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:

typescript
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.
  • SharedArrayBuffer requires 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 a Uint8Array view.
  • Bus buffers are immune to memory.grow() — no onMemoryGrow() 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 via DataView.
  • 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-utils compiles to a rlib — it is inlined into each plugin's .wasm binary 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 SharedMemoryManager as a zero-overhead no-op for plugins that don't need gwen-core's linear memory.
  • The region: MemoryRegion | null signature makes the intent explicit — null means "I use the Bus, not the SAB".
  • Fully backward-compatible: existing plugins that set sharedMemoryBytes > 0 continue 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.

Released under the MPL-2.0 License.