State that belongs to a group of players, not to one of them.
A shared space is an access boundary with its own versioned JSON documents. Put the lobby, the party, the room, the match or the world state in one, decide who can read and who can write, and let clients be told when it changes instead of asking over and over.
spaces/{space_id}
members: player -> can_write
documents/state {"phase":"lobby", ...}
documents/state/wait?after_version=7
→ blocks until version 8 One boundary, many documents
A space holds as many keys as you need. Keep state, roster and chat separate instead of rewriting one blob every time anything moves.
Membership is the ACL
Members are explicit, and each carries can_write. A read-only member writing gets 403. Only the owner manages membership, and the owner is always writable.
Told, not asked
Every shared document has a /wait endpoint. Pass the last version you saw; it blocks until there is a newer one, then hands you the document and the next cursor.
The backend owns the room. The clients watch it.
This is the shape that works today across every engine: something trusted creates the space and decides membership, and each client watches the documents it is allowed to see.
# A trusted backend creates the space and decides who is in it.
# A publishable key cannot: shared spaces need a player Bearer token,
# a server token, or a secret API key.
curl -X POST "$CRUX/v1/projects/$PROJECT/environments/$ENV/spaces" \
-H "Authorization: ServerToken $SERVER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"key":"match-4821","metadata":{"mode":"coop","map":"harbour"}}'
# key is optional and unique per environment, so it doubles as
# "get me the space for match 4821" - a second create answers 409.
# Add each player. can_write:false makes a spectator.
curl -X PUT "$CRUX/v1/projects/$PROJECT/environments/$ENV/spaces/$SPACE/members/$PLAYER" \
-H "Authorization: ServerToken $SERVER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"can_write":true}' Watching costs one parked request.
A long poll returns as soon as the version advances, or empty-handed when its timeout expires. Either way you get the current cursor back, so the loop never misses a write and never re-reads history.
// The client watches. It never re-requests the document on a timer.
let cursor = 0; // 0 means "wait for the first write"
while (running) {
const { document, version } =
await crux.waitSharedDocument(spaceId, "state", cursor);
cursor = version; // always feed the cursor back
if (document) applyLobbyState(document.value);
// document === null means nothing changed before the timeout,
// or the document was deleted. The cursor is live either way.
} What this is not
It is not realtime messaging. There is no WebSocket and no server push;
/wait is a long poll. It tells you a document changed. It does not give you
ordering across concurrent writers, message history, pagination, fan-out to thousands of
subscribers, retention or moderation. You can absolutely store a message list in a shared
document and watch it, and for a lobby or a co-op session that is often all you need. For
a chat product, those other problems are yours and they are the hard part.
It is not a per-player key you poll. Writing into one player's document
and reading it from another is the pattern this replaces. A plain GET of a key
nobody has written answers 404 forever, so a polling loop against it never starts working,
however long it runs. after_version=0 on /wait is the fix: it
blocks for the first write instead.
Limits and access, as the API actually enforces them
| Who can use spaces | A player Bearer token, a server token, or a secret API key. A publishable key cannot: log the player in first, or call from trusted server-side code. |
| Player-owned spaces | 100 per environment, per player. Spaces created with a server token or secret key are unowned and not counted against any player. |
| Members per space | 128 |
| Space key | Optional, up to 255 characters, unique per environment. Creating a second space with the same key answers 409, which makes it a natural idempotency key for "the space for match X". |
| Space metadata | A JSON object, up to 4096 bytes. |
| Revoking access | Takes effect immediately, including on parked long polls: a wait re-checks membership every time it wakes, so removing a player ends their in-flight watch rather than letting it run to timeout. |
| SDK coverage | JavaScript, Godot and Unity all wrap the lifecycle: create a space, manage members, read and write shared documents, and wait on them. One gap to know about: a watcher whose membership is revoked mid-wait currently sees the same empty result as an idle timeout, because the SDKs collapse the backend's 404 into it. |
Check the contract before you build on it.
Crux is small and new. The useful trust signals are the parts you can inspect yourself: the running API, its public contract, the client source, and your exit path.
Live API status
A live check of the hosted API right now. No made-up uptime percentage and no hidden demo environment.
Check current statusPublic API contract
The hosted HTTP surface is documented as OpenAPI. Inspect the wire format before choosing an SDK or writing an adapter.
Open the specMIT client SDKs
The JavaScript, Godot, Unity, and Roblox client SDK source is public. The hosted backend itself remains a managed proprietary service.
Inspect the SDK sourceDocumented exit path
Read what is exportable today and the written commitment for advance notice plus an export window if the hosted service winds down.
Read the exit guaranteeStart with a space and one document.
The free tier covers every feature. Create a project, make a space, write
state, and watch it from a second client.