Typed schemas — what a player is able to send at all
Read this before you design the game rather than after. State writes are host-only, so sendTyped is the only way a non-host player acts — which makes your schemas the entire vocabulary of three players in four.
The nine field types
Closed and complete. Anything not on this list is refused.
| type | required attributes | encoded bytes | what it is for |
|---|---|---|---|
bool | (none) | 1 | A flag. Ready, folded, passed. |
enum | variants 2–65536 | 1 (≤ 256 variants), otherwise 2 | A choice from a fixed list YOU ship. The index crosses the wire; the meaning stays in your game. This is what a quick-chat phrase, a colour and a card suit all are. |
uint | bits 8/16/32, min, max | bits / 8 | A whole number that cannot be negative. The range is REQUIRED — a field nobody declared a range for is a field nobody range-checks. |
int | bits 8/16/32, min, max | bits / 8 | The same, signed. Deltas, offsets, a score that can go down. |
fixed | bits 16/32, scale, min, max | bits / 8 | A fractional number, quantised. There is no float anywhere on this wire — fixed-point is what keeps two clients agreeing exactly. Normalise coordinates to 0–1 and your schema stops depending on your canvas size. |
peer | (none) | 2 | A player in this room, as the id the PLATFORM issued. This is how a message refers to a person without carrying anything about them. |
preset | table — quickchat, ping, palette | 2 | An index into a table the PLATFORM supplies and moderates centrally. This is how a phrase crosses the wire without the text crossing it. |
array | max (REQUIRED, 1–64), element | 1 + max × element | A bounded list. max is not advice and not a default: a list without one has no computable size, which is exactly the property the whole rule is about. |
struct | fields — at least one, each with a name | the sum of its fields | A record. Every schema document is one at the root. Names are identifiers, unique within the struct, and never prose — the encoder goes by declaration order and never reads them. |
What is excluded, and why there is no way around it
| absent | refused as | why |
|---|---|---|
| string | type_not_admissible | A free string is a chat channel, bounded or not. Exactly one free-text field exists anywhere in this protocol — the display name inside a signed ticket, already filtered and aliased before it is signed. A second one is a safety decision, not a schema. Use enum or preset and ship the words yourself. |
| bytes | type_not_admissible | A byte array is an image channel with extra steps, and it will pass review looking like a board state. This is the one the drawing carve-out exists to route around: strokes cross the wire, the host rasterises, pixels never travel. |
| map | type_not_admissible | Keys chosen by the sender are strings chosen by the sender, and the size is chosen by the sender too. A struct says what the fields are; a map says whoever is sending will decide. |
| any / variant | type_not_admissible | A field whose type is decided at send time has no computable size and no reviewable content. It is the shape every other exclusion arrives disguised as. |
| float | type_not_admissible | Absent twice over: not in the type set, and floating-point drift is exactly what determinism is settled as fixed-point to avoid. A desync caused by a float reproduces on one player's device and nowhere else. Use fixed. |
array without max | unbounded_array | A growable list makes the encoded length a function of the DATA rather than of the schema, which is the precise negation of the admissibility rule. The bound cannot be computed, so the computation is not total and the schema is refused rather than guessed at. |
| recursive struct | too_deep | Recursion cannot be written in JSON directly, so it arrives as depth — and a decoder that follows unbounded depth has a state space no fuzzer can cover. Nesting is allowed; unbounded nesting is what is not. |
There is no extension hook. No flag turns one of these on and no capability grants one — a new leaf type is a safety change that goes through the same review the type set exists to enforce. If a design needs a string on the wire, the design needs changing. The two honest cases have proper answers instead: a message that must refer to a person carries peer, the id the platform issued for this room and never a name, and a message that must say something carries preset or an enumover a phrase list you ship and render locally. The choice is the player's; the text never crosses the wire.
A whole schema document, and it is a real one
This is undercut.bid — one struct, two bounded integers, and the whole of what one Undercut player can say to another. Copy the shape, not the fields.
{
"schemas": [
{
"schemaId": 256,
"name": "undercut.bid",
"schemaVersion": 1,
"type": "struct",
"fields": [
{ "name": "value", "type": "uint", "bits": 8, "min": 1, "max": 9 },
{ "name": "roundNo", "type": "uint", "bits": 8, "min": 0, "max": 31 }
]
}
]
}The round trip it supports: a non-host calls sendTyped('undercut.bid', { value: 7, roundNo: 3 }), the host receives it in onTyped, checks it against the rules of the round — the schema proved the shape and proved nothing about whether the move is legal — and writes the result with state.set inside runOnHost. Everyone else learns the outcome as replicated state. That is the shape of every non-host action in every multiplayer game here.
Declaring a schema is not the same as having it approved
Submitting the game stores what you declared. Approval is a separate human decision, and until it is recorded the game has no typed capability at all: sendTyped returns false for every name and a public match is refused outright. If your game works when you test it and not for other players, this is almost always why — the set is waiting on review and there is nothing in the game to fix. An inadmissible schema does not fail the upload either: the game uploads with no schemas and the reasons come back with the result, so read them.

