Shared memory

Usage guide for kit developers: one memory and one allocator for the core and every kit module, state read in place in the zone's entity rows and declared columns, published columns instead of service calls, page and HUD subscriptions (host.shared), Workers that never wait, the fallback without SharedArrayBuffer, allowed exceptions, and how to check for copies (F3, loader counters).

Source: module-kits:docs/usage/shared-memory/en.mdView Markdown source

Also read offline — CLI: grip-forge docs shared-memoryMCP tool: gripforge_docs { "topic": "shared-memory" }

Shared memory and kit modules #

GripForge runs the logic of every kit as one WebAssembly module per kit, next to a core module (kithost.core). On the web they all live in one memory, and everything that moves every tick or every frame is read where it was written: no per-frame copy, no per-frame JSON, no thread waiting for another. This guide shows what that means when you write a kit.

The model in one picture #

one WebAssembly.Memory (a SharedArrayBuffer on a cross-origin isolated page)
├─ the core: owns the world, the one allocator (kithost.mem@1), the zone
│    zone: INPUT | STATE | EVENTS | WORLD (one row per entity + declared columns) | REGIONS
├─ your kit (relocatable wasm, placed by the loader, linked by pointer)
├─ other kits, the physics module
└─ the player and its presentation modules
  • One allocator. Your kit's #[global_allocator] is the core's. Bytes handed between modules are owned and freed by whoever receives them; nothing is copied.
  • The zone is a block with a fixed layout, sized by the game schema. Entity rows hold the base pose (core.position@1, core.rotation@1) and every column that kits or the game declare, with read/write rights and write phases per module.
  • Fixed layouts. Structures shared in memory are repr(C), declared once and versioned; the loader refuses a module built against another major, by name.

Writing a kit #

Write your kit against the kit SDK (gripforge-kit-guest); gripforge kit build <kit folder> produces both builds: the relocatable module for the common memory and the isolated one (--mode common|isolated|both).

Read and write columns in place:

let mana = kx.column(&id, "game.mana@1")?;          // a few loads in the entity row, no call
kx.set_column(&id, "game.mana@1", [mana.unwrap_or([0.0; 3])[0] - 1.0, 0.0, 0.0])?;

A write the game schema does not allow (wrong module, wrong phase) is an error that names your module and the column.

Publish state others read, instead of answering calls. A provider that publishes columns lets its clients read them in place:

if kx.published("combat.health", &["combat.health_hp@1", "combat.health_max@1"]) {
    // read two fields of the row: no service call
}

Ask the physics, do not wait for it. Queries are orders with a ticket, answered about one tick later:

let ticket = kx.physics().ask_move_character(&player, delta);
// next ticks:
if let Poll::Ready(moved) = kx.physics().poll_moved(ticket) { /* use it */ }

Run heavy systems every N ticks (kx.schedule(system, Every::new(...))), never every frame. Kit logic runs only at the game tick.

Reading state from a page, a HUD or a tool #

The page reads the logic in place through host.shared and subscribes to changes. A subscription carries no data: it tells you something changed, you read again.

const shared = host.shared;
const off = shared.subscribe(['var:player.hp', 'phase'], () => {
  render(shared.var('player.hp'), shared.phase);
}, { everyTicks: 6, name: 'my.hud' });
shared.eachRow((row) => { /* row.id, row.x, row.y, row.z, row.yaw: views, not copies */ });

host.snapshot(), state.snapshot() and entities.all() return JSON: use them for tools, saves and debugging on demand, never on a timer or per frame.

Threads and fallbacks #

  • Rendering and physics run on their own Workers by default. Threads never wait for each other: each one publishes its latest complete state (triple buffer, held frames with a sequence number) and orders go through lock-free rings.
  • Without a SharedArrayBuffer (no COOP/COEP headers) there is no copy-based Worker: rendering and physics run on the main thread and the reason is shown in the debug panel (F3: "Render: main (reason)", "Physics: main (reason)"). Navigation is the one exception: it keeps its Worker and exchanges rare queries by postMessage.
  • To compare, ?workers=0, ?worker.render=0 or ?worker.physics=0 turn a Worker off.

Allowed exceptions #

  • Assets (models, meshes, textures, sounds) are loaded once per thread that needs them; a single copy at load time is fine (for instance reading a mesh once to build a collision box).
  • JSON is for rare exchanges: configuration, tools, debug reads on demand.
  • A third-party kit the project marks isolated keeps its own memory; each message is copied once each way. That is the price of isolation.

Do / don't #

DoDon't
Keep kit state in declared columnsRewrite a whole JSON component every tick
Publish state as columns, check kx.publishedCall a service per entity per tick
Subscribe with host.shared.subscribePoll host.snapshot() on a timer
Hand another thread an address, a length and a sequencePost a copy of the frame to a Worker
Send physics orders and poll ticketsBlock a tick waiting for a Worker

Checking your kit #

  • Open the debug panel (F3) or read the gripforge.debug/1 snapshot: render and physics threads with their reason, milliseconds of logic per tick, frame timings. Quote those numbers, before and after, for any performance claim.
  • Loader counters on the page: host.cost.wireCopiedBytes and host.cost.physicsCopiedBytes stay at 0 per frame, calls.memoryChoice says which memory was chosen, calls.threadFallbacks lists modules kept on the main thread.