Write a kit
A kit of game logic of your own, in Rust: kit.json and its source, the common build tool (gripforge kit build on the build base: pinned toolchain, locked crates, structures checked), the two ways a game loads it — isolated in a memory of its own, or trusted in the common memory (trust, kit by kit) —, declaring it (gripforge kit add|verify|list|remove, gripforge_third_party_kit_*: source, sha256, trust, lock), try-kit, web export, what the page says, versions, limits.
Writing a third-party kit — a kit logic module of your own, step by step #
A third-party kit is a kit of game logic that is not one of GripForge's: you write it in Rust, the build tool compiles it on the base GripForge fixes, and a game plays it together with GripForge's kits. Neither the core nor any other kit is recompiled. This page is the whole path — write, build, declare, verify, export, play — and can be followed without reading the code of the core. Readable offline: gripforge docs kit-third-party, or the MCP tool gripforge_docs (topic kit-third-party). The commands of this page — gripforge kit build|socle|add|verify|list| remove and the MCP tools gripforge_third_party_kit_* — are those of CLI 0.3.44 / MCP 0.1.61 or later (gripforge --version; gripforge update). The model behind it (the core, the interfaces, the costs): topic kit-modules; every structure shared in memory: topic kit-structures.
Use the existing kits first. A game is made by picking GripForge's kits and adapting them (config, mods, hooks of game.engine): MCP gripforge_gamekit_search, gripforge kit api <id>, and the topic third-party (mods, presentation modules). Write a kit of your own only for a rule no kit has and no mod of game.engine can express — and make it small, on top of the others (listen to their events, call their services), as the example does.
The worked example of this page is in the repository: examples/kit-third-party (acme.streak). It listens to an event of collect.tokens, reads and writes a component on the player, and calls a service of economy.money.
| Step | What | Command |
|---|---|---|
| 1 | Write the kit | two files: kit.json, src/lib.rs |
| 2 | Build it on the build base | gripforge kit build <kit folder> (MCP: gripforge_third_party_kit_build) |
| 3 | Declare it in the game, and say how far you trust it | gripforge kit add <game> --kit <build folder> [--trust common] (MCP: gripforge_third_party_kit_add) |
| 4 | Verify, list, remove | gripforge kit verify …, kit list, kit remove (MCP: …_verify, …_verify_build, …_list, …_remove) |
| 5 | Check what the game declares before it is played | gripforge kit verify <game>, gripforge kit list <game> |
| 6 | Export the game and play it in a page | gripforge game-export <gkp_id> <folder>, gripforge kit add <folder> …, then npm run dev |
Two rules.
- A third-party module is loaded only when the game declares it. The declaration names where the module comes from and its SHA-256; the lock of the game freezes the version, that fingerprint and the trust; the export and the page refuse a binary that is not the declared one.
- A third-party kit is isolated unless the maker of the game says otherwise, kit by kit. Isolated, it keeps a memory of its own: it sees the shared zone and the interfaces of the core, nothing else, and cannot corrupt the game. Trusted (
--trust common), it is placed in the memory of the core and of the other kits, as a kit of GripForge is — faster, and no longer isolated. §2 has the two side by side.
0. What you need #
- The GripForge CLI (
gripforge, 0.3.44 or later). It ships the kit SDK — the Rust side of the contract a kit is compiled against — in itskit-sdkfolder: no GripForge checkout is needed to build a kit. (The MCP client@gripforgeai/mcp0.1.61 or later has the same tools; it uses the kit SDK of the CLI found onPATH, or the one you name:sdk,GRIPFORGE_KIT_SDK. A GripForge checkout is a kit SDK too:--sdk <checkout>.) None of these commands needs a login: they work on the files of the kit and of the game. rustup (https://rustup.rs). The build tool uses the Rust toolchain the base pins, with its components and its target; it does not use your default toolchain and never changes it. What is missing is named with the one command that installs it — or installed for you with
--install:gripforge kit socle # the build base, and whether this machine is ready for it gripforge kit socle --install # installs the pinned toolchain beside yours (rustup toolchain install …)- Node 22.19 or later (the CLI's own requirement).
- A game: a file
game.json({ "project": …, "lock": … }), or the folder that holds it. An exported web game (gripforge game-export <gkp_id> <folder>) is such a folder;examples/kit-third-party/game.jsonis such a file. - A folder of kit modules to play the game: the core and the GripForge kits your game uses, each as a
.wasm, withkit-modules.json. An exported web game has one when its player package has them (runtime-rust/kit-modules/), a player package too (kit-modules/). The core and GripForge's kits come compiled: adding your kit recompiles none of them.
1. Write the kit #
my-kit/
kit.json what the kit is: its id, its version, what it serves and what it needs
src/lib.rs the kit
No Cargo.toml, no export macro, no flags: the build tool writes the crate around your source, once per mode (§2), so the same source gives both builds.
kit.json
{
"schema": "gripforge.kit-module/1",
"id": "acme.streak",
"version": "1.0.0",
"description": "Rewards collecting streaks.",
"services": ["[email protected]"],
"requires": ["collect.tokens@^1.1"],
"optional": ["economy.money@^1"]
}
id: dotted, lowercase, under a prefix of your own (acme.…); never the id of a GripForge kit. It is the idfn id()returns and the one the game declares.version:major.minor.patchof the kit. It is the version the binary carries and the lock freezes.services: the capabilities offn services(), with the version of their API. That version is the one of the service, not of the kit: it stays1.0.0while you only fix the kit, gets a new minor when you add a method.requires: the kits your kit cannot work without (it listens to them, calls them without a test).optional: the kits it uses only when the game has them (kx.host.has_service(…)first). The requirement is matched against the kit version ofkit-modules.json(economy.money1.3.0), not the version of the service API thatkit apiprints.- Optional:
"source"(the file of the kit,src/lib.rsby default),"module"(the function that gives the kit,moduleby default),"crates": { "smallvec": "1" }(crates of crates.io your kit needs; the crates of the base keep the versions of the base),"events"and"hooks"(what the kit declares for the game schema: §7b). - Not written by you:
provides,consumes,memory,structs,sha256. The build tool reads them from the binary it made and writes them in the manifest that goes with it.
src/lib.rs — a kit is a type that implements KitModule, given to the build tool by a public function. A module is stateless: what it keeps between hooks goes in kx.put(…) and is read back with kx.data::<T>().
use gripforge_kit_guest::bus::GameEvent;
use gripforge_kit_guest::host::{KitModule, Kx};
use gripforge_kit_guest::shared::current_player;
use serde_json::{json, Value as Json};
use std::rc::Rc;
struct Streak;
struct Settings { every: u64, bonus: f64 }
impl KitModule for Streak {
fn id(&self) -> &'static str { "acme.streak" } // the id of kit.json
fn services(&self) -> &'static [&'static str] { &["acme.streak"] } // what it serves to the other kits
fn setup(&self, kx: &mut Kx) {
let cfg = kx.config().clone(); // its `config` in the project
kx.put(Settings { every: cfg["every"].as_u64().unwrap_or(3), bonus: cfg["bonus"].as_f64().unwrap_or(25.0) });
kx.define_var("streak.count", "number", "Streak", None, Some(json!(0)));
kx.on("tokens.collected", "collected"); // an event of collect.tokens
}
fn on_event(&self, kx: &mut Kx, tag: &str, ev: &GameEvent) {
if tag != "collected" { return; }
let Some(player) = current_player(kx.host) else { return };
// Read a component, write it back.
let count = kx.host.entities.component(&player, "streak").and_then(|c| c["count"].as_u64()).unwrap_or(0) + 1;
kx.host.entities.set(&player, "streak", json!({ "count": count, "kind": ev.payload["kind"] }));
kx.set_num("streak.count", count as f64);
let (every, bonus) = { let s = kx.data::<Settings>(); (s.every, s.bonus) };
if count % every == 0 && kx.host.has_service("economy.money") {
// A service of another kit.
let balance = kx.call("economy.money", "add", &[json!(bonus), json!("streak")]).ok();
kx.emit("streak.bonus", json!({ "count": count, "bonus": bonus, "balance": balance }));
}
}
fn call(&self, kx: &mut Kx, _capability: &str, method: &str, _args: &[Json]) -> Result<Json, String> {
match method {
"count" => Ok(json!(current_player(kx.host)
.and_then(|p| kx.host.entities.component(&p, "streak").and_then(|c| c["count"].as_u64()))
.unwrap_or(0))),
_ => Err(format!("acme.streak has no method {method}")),
}
}
}
/// What the build tool takes.
pub fn module() -> Rc<dyn KitModule> { Rc::new(Streak) }
The complete file with comments: examples/kit-third-party/src/lib.rs. The two crates a kit always has are gripforge-kit-guest (the kit side of the interfaces) and serde_json (the value model of the core: 3 and 3.0 stay distinct, the keys keep their order).
The API of a kit (Kx) #
Everything goes through the kx: &mut Kx each hook receives. The ones you need most:
| You want to | Call |
|---|---|
| read your config, a data collection of the project | kx.config(), kx.data_collection("token_kinds"), kx.project() |
| keep state between hooks | kx.put(value) once in setup, then kx.data::<T>() — a &mut T: kx.data::<Local>().ticks += 1 |
| the clock, random numbers | kx.elapsed() (seconds of game time), kx.tick(), kx.dt() (a fixed tick: 1/60 s), kx.rng() (deterministic, forked per kit) |
| listen to an event | kx.on("topic", "tag") in setup ("family.*" and "*" work) → fn on_event(&self, kx: &mut Kx, tag: &str, ev: &GameEvent); ev.topic, ev.payload (a serde_json::Value: ev.payload["amount"].as_f64(), ev.payload["kind"].as_str()), ev.entity, ev.source |
| emit an event | kx.emit("topic", json!({ … })) |
| a var (shown by HUDs, saved) | kx.define_var(path, "number" | "string" | "boolean", label, format, Some(default)) (format: None, or a display hint such as Some("money")), kx.get_var(path), kx.set_var(path, value), kx.set_num(path, n), kx.bind_var(path, "tag") → fn on_var(&self, kx: &mut Kx, tag: &str, path: &str, value: Option<&Json>, prev: Option<&Json>) |
| the player in scope | current_player(kx.host) → Option<String> (its entity id) |
| read an entity | kx.host.entities.get(id) (.kind, .tags, .has_tag(t), .component(name)), kx.host.entities.component(id, name), kx.host.entities.transform(id) (Pos { x, y, z, yaw }) |
| find entities | kx.host.entities.by_tag("enemy"), kx.host.entities.query(&Query { kind, all, any, none }), kx.host.entities.player() |
| write an entity | kx.host.entities.set(id, component, value), kx.host.entities.set_transform(id, &json!({ "x": 1.0 })), kx.host.entities.tag(id, &["hidden"]), untag |
| create, destroy | kx.create_entity(kind, &tags, Some(&components)) → id, kx.host.destroy_entity(id) |
| call another kit | kx.host.has_service("economy.money"), kx.call("economy.money", "add", &[json!(25)]) → Result<Json, String> |
| serve the others | fn services() + fn call(&self, kx: &mut Kx, capability: &str, method: &str, args: &[Json]) -> Result<Json, String> |
| a timer | kx.after(0.5, "tag", Json::Null), kx.every(0.5, "tag", json!({ … })) (seconds of game time; the data is a serde_json::Value) → fn on_timer(&self, kx: &mut Kx, tag: &str, data: &Json) |
| a trigger verb usable from the project's data | kx.register_action("verb") → fn action(&self, kx: &mut Kx, verb: &str, args: &Json, ev: Option<&GameEvent>), kx.register_condition("verb") → fn condition(…same…) -> bool |
| every tick | fn update(&self, kx: &mut Kx, dt: f64), fn late_update(&self, kx: &mut Kx, dt: f64), fn update_idle(…) — the build tool finds which ones your source implements and tells the core: a kit without them is never called on a tick |
| input, physics | kx.input().pressed("jump"), kx.input().axis("move_x"), kx.physics().raycast(…) |
| save and load | fn save(&self, kx: &mut Kx) -> Option<Json>, fn load(&self, kx: &mut Kx, data: &Json) (components and vars are already saved by the core) |
| log | kx.log("warn", "message") |
The other hooks (ready, teardown, spawn, scene_enter, component, mission steps…) and the cost of each call are in the topic kit-modules §3. Which events a kit emits, which services it serves, with which arguments: gripforge kit api <kit-id> --section events,services, or the MCP tool gripforge_gamekit_api.
Rules that save time:
- A read returns a copy (
component,get,get_var): change it, then write it back withset/set_var. - Do not hold a reference read from the world across a call that writes (
emit,set,call): Rust refuses it; clone what you need first (let cfg = kx.config().clone();). - An event emitted in a hook is delivered after the hook, in the next round of the same tick.
- An isolated kit that panics is left out (its hooks are logged as failed); the game goes on without it. A kit in the common memory that panics stops the game (§2).
2. Build it on the build base #
gripforge kit build my-kit # both builds, in my-kit/build/
gripforge kit build my-kit --out <dir> --mode isolated|common|both --install --jobs 4 --json
(MCP: gripforge_third_party_kit_build { kit, out?, mode?, install? }; gripforge_third_party_kit_socle for the base.)
Built [email protected] on the build base [email protected] (structures 1.0, rustc 1.99.0 (…))
isolated my-kit/build/acme.streak-1.0.0.isolated.wasm
sha256 0730139b… (168099 bytes)
manifest my-kit/build/acme.streak-1.0.0.isolated.kit.json
common my-kit/build/acme.streak-1.0.0.common.wasm
sha256 6a5a9843… (179749 bytes)
manifest my-kit/build/acme.streak-1.0.0.common.kit.json
The build base is what makes a binary built by anyone fit the contract. It is fixed by GripForge, in the kit SDK (socle.json; gripforge kit socle prints it), and it is not a choice of the kit:
| the Rust toolchain | one pinned version, with the component rust-src and the target wasm32-unknown-unknown; run through rustup beside your own toolchains |
| the crates | the lock of the base (serde_json, indexmap, … at the versions the core computes with); the build is --locked. A kit that names crates of its own resolves them on top, and the build fails — expected / found — if one of them moved a crate of the base |
| the options | the profile (size first, whole-program LTO, panic = "abort"), and for the common build the recipe of a relocatable module (position-independent code, the standard library rebuilt once) |
| the contract | [email protected], the interfaces a kit consumes, and the structures of the contract with the size and the offset every field must have |
After the build the tool checks the binary, and stops with what was expected and what was found:
- it is the build of the mode asked (isolated, or relocatable for the common memory);
- its structures table (the custom section
gripforge.structs, written by the compiler from the types of the contract) is the one of the base — the version of the contract, then each structure, field by field; - its contracts section is the one of the mode; an isolated build links only the functions of the core that carry numbers; nothing asks the system (file, clock, random: a crate that does is named).
Then it writes, per build, the .wasm and its manifest .kit.json — your kit.json completed with what the binary says: provides, consumes, memory (the mode), structs, sha256, bytes, built (the base, the toolchain) — and build.json, which names the two.
The two builds, the two ways a game loads your kit:
isolated (the default) | common (trusted) | |
|---|---|---|
| its memory | its own | the memory of the core and of the other kits: the host places it there |
| what it can reach | the interfaces of the core, the shared zone (read; it writes only the event queue and the regions it asked) | everything in that memory |
| a fault in it | stays in it: the kit is left out, the game goes on | can corrupt the core and the other kits; a trap stops the game |
| how a call crosses | the host copies the message once into the other memory; lists and dictionaries cross as fixed-layout blocks, read where they land | by address, nothing is copied |
| who decides | nobody has to: it is what a game gets | the maker of the game, for this kit, by name (--trust common) |
| cost | one copy per call, and a function of the host on each crossing | the cost of a GripForge kit |
Both speak the same contract: the functions of the interfaces (kithost.world@1 …) by their numbers, and in memory the structures of gripforge docs kit-structures — FixedValue, FixedPair, FixedBlock for any list or dictionary, the records of the shared zone, the event queue. Nothing of a compiler's own layout crosses, which is why a kit built by another Rust than the core's loads. These two builds are the only ways a kit module is loaded: a binary that is neither (not relocatable, no entry across memories — a kit module with a memory of its own reached by the calls bridge, as they were once built) is refused by every tool and by the page, by name (the binary is not a kit module a game can declare: it is neither relocatable … nor isolated … build the kit from its source with "gripforge kit build <kit folder>").
Build both and let each game choose: the source is the same.
| What the build says | Cause |
|---|---|
the build base needs the component rust-src of the toolchain 1.99.0. Install it …: rustup toolchain install … | run that command, or the build again with --install |
gripforge kit build needs rustup … | install rustup first |
…/src/lib.rs: no "pub fn module()" | the function that gives the kit must be public (or name another with "module") |
the source invokes an export macro (export_kit!, …) — remove it | a source written for an older guide: the tool writes the exports |
the build failed (cargo exit 101) — the compiler's errors are above | a Rust error in the kit; with --json the last lines are in the message |
(the crates are locked to the build base: a crate the kit needs goes in "crates" of kit.json) | your source uses a crate the base does not have |
crate serde_json: expected 1.0.151 (the build base), found 1.0.160 | a crate of yours asks for another version of a crate of the base: pin yours lower |
it imports env.…, which no host gives a kit module | a crate of the kit asks the system for something (a clock, a file, random numbers): a kit takes them from kx |
the kit SDK was not found | the CLI is older than 0.3.44, or the MCP client finds no CLI on PATH: update, or pass --sdk |
3. Declare it in the game — and say how far you trust it #
gripforge kit add <game> --kit my-kit/build # isolated (the default)
gripforge kit add <game> --kit my-kit/build --trust common # in the common memory: only a kit you trust
gripforge kit add <game> --wasm <file|url> --manifest <.kit.json|url> [--trust common] [--config '{"every":3}'] [--modules <dir>] [--check]
(MCP: gripforge_third_party_kit_add { game, kit | wasm + manifest, trust?, config?, modules?, check? }.)
<game> is a game.json or the folder that holds it. --kit takes the output folder of kit build and picks the build of the trust you give; --wasm + --manifest name one build yourself (files, or http(s) URLs: a module declared by URL is not copied, its SHA-256 is what holds it). The command reads the module and its manifest, checks them, and — unless --check — writes:
// project.thirdPartyKits[]
{ "id": "acme.streak", "version": "1.0.0", "source": "./third-party-kits/acme.streak-1.0.0.wasm",
"sha256": "0730139b…", "bytes": 168099, "trust": "isolated", "manifest": { … } }
// project.installed[] { "id": "acme.streak", "version": "1.0.0", "enabled": true, "config": { "every": 3 } }
// lock.order […, "acme.streak"] (a new kit mounts last: after the kits it calls)
// lock.kits["acme.streak"] { "version": "1.0.0", "enabled": true }
// lock.thirdParty["acme.streak"] { "version": "1.0.0", "sha256": "0730139b…", "bytes": 168099, "trust": "isolated" }
and copies the module beside the game (third-party-kits/) and, when the game has kit modules, into them (kit-modules.json then says "memory": "isolated" or "common" for it: how the page loads it).
trust is the word of the maker of the game, for this kit:
- absent or
"isolated": the kit keeps a memory of its own. Use it for any kit you did not write. "common": the kit enters the memory of the game. The command warns you each time (… is NOT isolated — a fault in it can corrupt the game). The lock freezes it like the version and the SHA-256: a trust edited by hand is refused byverify, the export and the page; it is changed by declaring the kit again.- The build declared must be the one of the trust: an isolated binary is never placed in the common memory, a common binary never run isolated. There is no fallback from one to the other.
What is checked, each with a message that says what to change:
| Message | Cause |
|---|---|
the binary of acme.streak is built for the common memory, where it can read and corrupt the game and the other kits, and the game does not trust it with it: declare its isolated build …, or say you trust this kit (--trust common) | you gave the .common.wasm without --trust common |
the game trusts acme.streak with the common memory ("trust": "common") but the binary is its isolated build | --trust common with the .isolated.wasm: give the common build (--kit picks it) |
the binary is not a kit module a game can declare: it is neither relocatable (the build for the common memory) nor isolated (it does not export gfx_iso_enter) — build the kit from its source with "gripforge kit build <kit folder>" | a binary that is neither build (made before the build tool, or by hand) |
structures contract: expected 2.0 (the core [email protected]), found 1.0 — another major … | the kit and the core of the game are of two structures contracts: build the kit with the CLI of that core |
structure [email protected]: field data expected at 8 (8 bytes), found at 12 (8 bytes) | the binary does not lay a structure out as the contract says (a hand-made or damaged binary) |
the kit consumes kithost.world@^1.3, the core provides [email protected]: another major version — rebuild the kit for this core | the core of the game is of another major of an interface |
the kit consumes kithost.iso@^1.0, which the core [email protected] does not provide | the kit modules of the game are older than the isolated mode: export the game again |
the core … of these kit modules owns its memory (an older build) | the same: the kit modules of the game must be rebuilt |
kit.json "consumes" is not what the binary declares / kit.json says memory "common", the binary is the isolated build | the manifest is not the one of this binary: take the .kit.json written beside it |
acme.streak: requires collect.tokens@^1.1, which the game does not install | add that kit to the game first |
"economy.money" is the id of a GripForge kit: choose an id under a prefix of your own | the id |
this game has no kit modules beside it … the kit was checked against its manifest only (a warning) | pass --modules <folder> to check against a core now; the export checks in any case |
Its config is the config of its entry in project.installed (--config, kept when the kit is added again).
4. Verify, list, remove #
gripforge kit verify --kit my-kit/build [--trust common] --modules <kit modules> # one build against a core, no game
gripforge kit verify <game> [kit-id] [--modules <dir>] # what a game declares
gripforge kit list <game>
gripforge kit remove <game> <kit-id>
(MCP: gripforge_third_party_kit_verify_build, …_verify, …_list, …_remove.)
One build against a core — before you hand a kit to someone, or when a core changes:
ok [email protected] isolated sha256 0730139b43cd5fcc… (168099 bytes)
contract expected [email protected], found [email protected]
structures expected 1.0, found 1.0
It compares the version of the contract and of every interface the kit consumes with what the core provides, then the structures of the contract one by one with the core's (kit-modules.json carries them). A refusal names the structure or the interface, what was expected and what was found; the exit code is 2.
What a game declares: each module is read again from its source and must have the SHA-256 the game declares and its lock froze; its manifest must still be what the binary says; the binary must be the build of the trust declared; the declaration, installed, the lock and the kit modules of the game must agree on the version, the fingerprint and the trust. Exit code 2 when a kit does not verify. list shows, per kit, isolated or COMMON MEMORY (trusted), its place in the mount order, its config, what it requires and serves. remove takes the declaration, the installed entry, the lock lines, the copy of the module and its kit module away.
5. Check what the game declares before it is played #
gripforge kit verify <game>
gripforge kit list <game>
verify reads every declared module again and compares it with the declaration and the lock (§4); list shows the mount order, the trust of each kit, what it requires and serves. The game is then played in a page (§6): the page loads the kit modules of the game exactly as declared and, when something does not fit, names the kit and the cause instead of starting. The services of a kit are called by the names of its API (gripforge kit api <kit>), its events are the topics that API lists.
| What you see | Cause | | the kit modules did not link: … was compiled against kithost.world@^1.3; the game provides [email protected] | the binary and the core are of different majors: build the kit again with the CLI of that core | | the kit modules did not link: … (memory "isolated"): structures contract 2.0, the owner (kithost.core) provides 1.0 … | the kit and the core are not of the same structures contract: build the kit again (§2), check with gripforge kit verify --kit … --modules … | | … (memory "common"): declared memory "common" but the binary is not relocatable / … the binary is relocatable … but it is declared memory "isolated" | the folder holds the build of the other mode: gripforge kit add again with the trust you mean (§3) | | … is in project.installed but not in lock.order | add the id to lock.order | | game.engine is in lock.order but not in project.installed | add its entry to installed: every id of lock.order has one, game.engine included | | your kit is mounted but nothing happens | the event has another topic or payload than you think (gripforge kit api <kit>; the line events lists every topic that was emitted), or the service is not served (has_service) because that kit is missing or mounted later | | <kit>.<method> is not served by <kit> / acme.streak has no method … | the method name (they are the names of the TypeScript API: canAfford, not can_afford) |
Both builds of the example (isolated and common) give the same game: same results, events, vars and components.
6. Export the game and play it in a page #
A game folder that already has its kit modules (an exported web game with runtime-rust/kit-modules/): after gripforge kit add <folder> … it is ready — npm run dev in the folder, open the address it prints.
A game of the site: gripforge game-export <gkp_id> <folder> downloads the game with its player and its kit modules; gripforge kit add <folder> --kit <build folder> then declares your kit in that folder (§3: the module and its manifest are read and checked, the declaration and the lock are written, the module goes with the kit modules of the game) and gripforge kit verify <folder> checks the result. A third-party kit has no TypeScript runtime and no view: nothing else is built for it. Both modes take the same path.
The page plays it like any kit. The kit modules are the Rust logic of every game (?logic=wasm and ?logic=kits mean the same): the page loads the core and the kits of the game's lock — yours with them — and nothing else. The console says it:
[gripforge] kit logic modules: the core and 5 kits (third-party: acme.streak)
Before a module is compiled the page compares it with the declaration: the version of kit-modules.json with the declared and locked one, the SHA-256 of the bytes with the declared one, the way the package loads it with the trust declared and locked. When it links the modules, the loader compares the structures table of your kit with the core's. There is no other wasm behind the kit modules: when something does not fit, the game does not start and the page shows the error, naming the kit, its version, its mode and the cause (topic kit-modules §1). It never plays the game without your kit in silence, and never loads it another way than declared.
| What the page says | Cause |
|---|---|
acme.streak: … its module has sha256 …, the game declares … (not the binary that was declared) | the module served is not the declared one: export again, or gripforge kit add then kit verify |
acme.streak: the kit module is version 1.1.0, the game declares 1.0.0 | kit-modules.json and game.json are of two different builds |
acme.streak: … was compiled against kithost.world@^2.0; the game provides [email protected] | the kit and the core are of different majors: build the kit again, export again |
acme.streak: the kit modules load it with memory "common", the game declares trust "isolated" | the package would let the kit into the common memory and the game did not say so: export again, or gripforge kit add with the trust you mean |
acme.streak: the lock froze trust "isolated", the project declares "common" | the trust was edited by hand: it is changed by declaring the kit again (gripforge kit add --trust …) |
[email protected] (memory "isolated"): structures contract 2.0, the owner (kithost.core) provides 1.0 — … | the kit was built on another structures contract than this core: build it again with the CLI of this core |
kit-modules/kit-modules.json: the manifest of the kit modules was not read (HTTP 404) | the folder served has no kit modules: export the game again (gripforge game-export) |
[email protected]: no module in the kits package | the kit modules do not hold the kit: gripforge kit add on this folder, or export again |
This game runs on the TypeScript KitHost (logic ts), WITHOUT its third-party kit … | the page was opened with ?logic=ts, or the project asks for logic: "ts" (a red line at the top of the page) |
The export refuses, with the reason, a game it could only ship broken:
| Message of the export | What to do |
|---|---|
acme.streak: installed, but it is neither a GripForge kit nor a declared third-party kit | gripforge kit add (the kit was written in installed by hand) |
7. Versions: what stays compatible #
- The interfaces. Your binary names what it was built against:
consumes kithost.world@^1.3. A core that provides[email protected]accepts it; a core at2.0.0refuses it at load with a message. Within a major an interface only grows at the end: a function keeps its number, its name and its types, new ones get new numbers. The schemas of the interfaces are compared with their 1.0 snapshot in GripForge's CI. - The structures. What is shared in memory is versioned apart (
structures 1.0). A structure added, or a field added in reserved bytes, is a minor: a kit built on 1.0 loads in a core at 1.1. A size, an offset or a meaning changed is a major: the two do not load together, and every tool says so before the page does (structures contract: expected 2.0 …, found 1.0). Nothing about the compiler is compared. - The build base moves with the CLI. A new CLI may pin another Rust or other crate versions: a kit built on the older base still loads while the contract and the structures are the same majors. Build again to follow.
- Your own versions. Change
versioninkit.json, build, thengripforge kit addagain: it moves the version and the SHA-256 in the declaration,installedand the lock. The lock is what a game is checked against: the export and the page refuse a module of another version or with other bytes than the lock froze. The services you serve to other kits follow the rule of the interfaces: add methods, never change one. - A SHA-256 names a binary, not a source. Two builds of the same source by the same CLI installed at the same place are the same bytes; built from an SDK at another path they differ (the compiler's symbol names depend on it). Ship the binary you verified.
7b. Changing and stopping what another kit decides: interceptable hooks #
An event cannot be modified or cancelled. The bus notifies after the fact, every subscriber in a fixed order. What a kit lets others change or stop is an interceptable hook of game.engine (engine.hooks, topic mods-engine): a decision the kit asks before it acts, and waits for. If you want to change a decision of another kit, tap its hook; if you want your own decisions to be changeable, declare a hook and run it before you act (then emit your event). Worked example: examples/kit-third-party-hooks (acme.tax).
Tap the hook of another kit, in code. The hooks of a kit are in its API (gripforge kit api economy.money): name, payload, which fields are mutable, whether it is cancellable.
fn setup(&self, kx: &mut Kx) {
// priority: higher runs first, among the taps in code and the taps in data of the game and its mods.
let opts = json!({ "priority": 0, "owner": "acme.tax", "id": "acme.tax.earn" });
let _ = kx.call("engine.hooks", "tap", &[json!("money.earn"), kx.callback("earn"), opts]);
}
/// Called synchronously each time economy.money is about to credit: args[0] is the payload as the taps before
/// left it. Answer nothing (Json::Null), `{ set }` on mutable fields, or `{ cancel }` on a cancellable hook.
fn callback(&self, kx: &mut Kx, name: &str, args: &[Json]) -> Result<Json, String> {
let amount = args[0]["amount"].as_f64().unwrap_or(0.0);
if amount > 500.0 {
return Ok(json!({ "cancel": true, "reason": "over_cap" }));
}
Ok(json!({ "set": { "amount": amount * 0.8 }, "reason": "tax" }))
}
A field that is not mutable, a value its schema refuses or a cancel of a hook that is not cancellable is refused and announced (engine.hook.refused); the game can also refuse a hook to mods (mods.deny.hooks).
Declare a hook of your own, in kit.json — the game schema then carries it, and a game, a mod or another module taps it like any hook:
"hooks": { "tax.rate": { "mutable": ["rate"], "cancellable": false,
"fields": { "rate": { "type": "number", "minimum": 0, "maximum": 1 } } } }
let asked = kx.call("engine.hooks", "run", &[json!("tax.rate"), json!({ "amount": amount, "rate": rate })])?;
if asked["cancelled"] == json!(true) { return Ok(Json::Null); } // only when the hook is cancellable
let rate = asked["payload"]["rate"].as_f64().unwrap_or(rate); // what the taps left
// … act, THEN notify:
kx.emit("tax.taken", json!({ "amount": amount, "rate": rate }));
See who did what: logic.hooks.trace() in the page or in a test lists every run — the taps in order, the fields each one set, who cancelled and why.
Both builds tap and declare hooks the same way (the test plays acme.tax isolated and in the common memory and compares the decisions).
8. Limits today #
- Kit modules only. A third-party kit exists only as its own module, which is how every game is played in Rust logic; the TypeScript KitHost cannot play it. What it means for you: the game needs kit modules for every kit it installs, so every GripForge kit of the game must have a Rust module (
gripforge game-logic <gkp_id>says when one has not). - Isolation is of the memory. An isolated kit cannot read or write the memory of the game, cannot take a link that hands an address, and writes in the shared zone only the event queue and its own regions. It is still code the game runs: it can emit events, set components and vars, call services, spend time. Read the source of a kit before you install it, isolated or not;
commonis for a kit you would have written yourself. - No shared-memory build. The build tool does not make the build for a memory shared between threads: a game with a trusted third-party kit runs on the plain build of the kit modules (the page picks it by itself).
- Hosted creation. Create game can generate and retain project-local isolated kits after compilation and integration checks. The global search/pickers still list stock kits; arbitrary third-party uploads use an exported game folder (§3). Native exports of generated kits are not yet supported.
- The binary carries neither its id nor its version as a field the tools compare. What is compared is the manifest written by the build, the declaration, the lock and
kit-modules.json; the SHA-256 ties them to the bytes. Keepfn id()andkit.jsonin step yourself; play the game (§6) after each add — the line of the console (kit logic modules: … (third-party: …)) must name your kit. - Updating an exported game from the site (
gripforge game-export <gkp_id> <folder>again) knows GripForge's kits only: it reportsgame.jsonand the kit modules as edited locally. After it,gripforge kit verify <folder>, andgripforge kit addagain if the kit no longer verifies (its module is still inthird-party-kits/). - Logic only. A kit module computes; what is drawn comes from the presentation modules of the player (
gripforge docs porting). Your kit shows itself through vars (HUD), components and events that existing views already read. - Rust only. The kit side of the contract is the Rust crate of the kit SDK. The contract itself is not Rust's — numbered functions and structures with a declared layout — but there is no binding for another language yet.
- Cost. A kit module pays for each call to the core; an isolated one also pays one copy of the message each way. Read once per hook, prefer one
queryto manyget, keep per-tick hooks for what needs them (topickit-modules§3 and §6).
Hosted Create game now supports project-local, isolated generated Rust kits with bounded compilation, integration tests and admin history. Local CLI/MCP commands remain available for exported games.