A repertoire only grows if other people can add to it. This is the whole contract: two required exports, one calling convention, and a signed artifact.
A new opening move usually means a new client release. Release cycles are measured in weeks; a censor's response is measured in days. That asymmetry is the whole problem.
So the moves live outside the binary. A module is a small WebAssembly program, signed, that the client loads at runtime and runs in a sandbox with no filesystem, no network, and no clock — it sees bytes and returns bytes. Adding a move is publishing an artifact, not shipping an app.
The sandbox is what makes accepting outside contributions tractable. A module cannot open a socket, read a file, or learn where it is running. The worst a hostile module can do is corrupt its own connection and burn its fuel budget.
The host discovers what a module does by looking at what it exports. There is no manifest and no registration call — the exports are the declaration.
| Export | Signature | Meaning |
|---|---|---|
memory | required | Your linear memory, exported so the host can write inputs into it and read outputs back out. |
alloc | (i32) → i32 required |
Hand back an address with room for len bytes. Called exactly once per invocation, before the work function. |
transform_out | (i32,i32) → i64 |
Reshape bytes on their way out. Export this and you are a byte transform. |
transform_in | (i32,i32) → i64 |
The inverse, on the way in. |
compute_gambit | (i32,i32) → i64 |
Compute an opening from the per-connection context. Export this and you are a gambit module. |
handshake_step | (i32,i32) → i64 |
Drive an interactive opening. Export this and the client runs a handshake before steady state. |
init | (i32,i32) → () optional |
One-time setup with configuration bytes. |
reset | () → () optional |
Called after every invocation once output has been copied out. Rewind your scratch arena here. |
The three work roles are independent, and a module may be exactly one of them. A pure byte transform exports memory and alloc — always required — plus the two transform_* functions, and no other work-role export; the client sees no handshake_step, so it dials straight through without asking. Nothing in the wiring knows which protocol you implement.
All four work functions share one calling convention, so learning it once covers everything.
alloc(len) and gets back an address.len input bytes into your memory at that address.(ptr, len).i64: the output address in the high 32 bits, the output length in the low 32.reset if you export it.Two roles add a little structure on top. handshake_step receives the bytes that arrived from the peer — empty on the very first call, which is your chance to speak first — and frames its reply as a status byte followed by the wire bytes to send: [status][outbound…], where a status of 1 means the handshake is finished. compute_gambit returns an encoded genome, which the client passes through without interpreting; only the engine that consumes it looks inside.
reset. Writing to a stack address that has already been popped is the most common way a first module fails, and it fails intermittently.A module that misbehaves is stopped, never quietly accommodated. Knowing the edges up front means you size your buffers once and stop thinking about it.
| Limit | Value | Why |
|---|---|---|
| Input / output | 1 MiB |
The largest chunk the host will hand you, and the largest it will read back. Size your arena to match and no valid call is ever rejected. |
| Linear memory | 16 MiB |
Fuel bounds compute, not allocation — without a ceiling a module could grow memory until the host died, cheaply. |
| Table size | 4096 |
The same bound for function references. |
| Fuel | 5,000,000 + 1024/byte |
Refilled per call, so bulk work is never penalized. It bounds a spin, not throughput. |
The fuel formula is deliberately generous: a legitimate transform of a full 1 MiB chunk has over a billion units to work with. It exists so that a module with an accidental infinite loop traps instead of hanging the connection forever.
The reference module XORs every byte with a constant — deliberately trivial as a transform, and the same shape a serious module has. The excerpts below are excerpts, not a compilable listing; the complete module is about eighty lines of no_std Rust with no dependencies.
Abridged — the elided bodies, the memory export and the two transforms are in the module's source.
Three decisions in that fragment are worth copying. The arena is static, not stack, so the region you return is still valid when the host reads it. alloc traps rather than clamps on an impossible request — a truncated buffer would corrupt a connection silently, and silence is the enemy. And the module is no_std with no dependencies, which is why the compiled artifact is measured in kilobytes.
The transform itself is the boring part: read len bytes at ptr, XOR each with the key, write them back, return the packed region. Because XOR is involutive, the same code serves as both transform_out and transform_in — a real module would have two different functions here.
Runtime-loadable code is a supply chain. The client will not run a module it cannot verify, and there is no flag to turn that off.
wasm32-unknown-unknown, release.The build script does all three, and it is the only step in the whole project that needs the wasm toolchain — ordinary builds and CI consume the committed artifacts rather than rebuilding them.
Development artifacts are signed with a development key that ships in the repo, so you can iterate without touching anything sensitive. That key is only a fallback for builds with no production key pinned: a release pins its own verification key, the production private half never leaves its vault, and CI is public-key only. So a module signed with the repo key is loadable by a development build and by nothing you would ship — which is the property that makes shipping the dev key in the open safe.
The contract above is small on purpose. The hard part was never the ABI — it is knowing which opening is worth playing.
The most useful contributions tend not to be clever ciphers. They are openings that resemble something a censor cannot afford to block, transports that already have a large innocent population, and moves that fail in a boring way when they fail. A module that works beautifully until it is blocked, and then looks obviously anomalous, has made things worse than one that never worked.
If you are exploring a direction, the shape of the discussion is more valuable than a finished artifact — what population you are hiding in, what a censor would have to give up to block it, and how you would know it stopped working.