TipTap Games

Multiplayer

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.

A schema is the complete list of things your game lets a player send

Every typed message is an instance of a schema: a named record with a fixed set of fields, each drawn from a closed set of types. You write the schemas as a JSON document and submit them with the game. Nothing else can be sent — there is no escape hatch, no free-form field and no 'just this once'. Design the schema first: it is the whole vocabulary of a non-host player, and a schema that cannot express the move is a move the player cannot make.

The nine field types: bool, enum, uint, int, fixed, peer, preset, array, struct

That list is closed and complete. bool is a flag. enum is a choice from a fixed list you ship, with 2 to 65536 variants, and only the index travels. uint and int are whole numbers and each REQUIRES bits (8, 16 or 32) and a min and a max — an undeclared range is a field nobody range-checks, and it is refused. fixed is the only fractional type: bits 16 or 32 plus a scale, min and max, and it exists because there is no float on this wire. peer is a player in this room. preset is an index into a platform table. array needs a max — always, no default — and an element. struct is a record with at least one named field, and every schema document is a struct at the root.

Absent on purpose: string, bytes, map, any / variant, float, array without `max`, recursive struct

No string, bounded or otherwise — a free string between strangers is an unmoderated chat channel that passes review looking like a player label. No bytes, which is an image channel with extra steps. No map, whose keys are strings the sender picks. No any or variant, which is every other exclusion in disguise. No float, because determinism is settled as fixed-point and a float desync reproduces on one device and nowhere else. No array without a max, and no unbounded nesting. There is NO EXTENSION HOOK and no flag that turns one of these on: adding a leaf type is a safety change that goes through the review the type set exists to enforce. If your design needs one of these, the design needs changing, not the schema.

peer and preset — how to reference a person or a phrase without carrying either

These two exist because the honest cases do. When a message has to name a player, it carries peer: the id the platform issued for this room, never a name and never anything about them. When a message has to say something, it carries preset: a 16-bit index into a table the platform supplies and moderates centrally (quickchat, ping, palette), so the words are ours and the choice is the player's. Quick chat is built out of preset and out of enum, not out of a chat API — send the index of a phrase from a list you ship and render your own copy of the text at the other end. There is no text field in a public room's messaging and there is not going to be one.

schemaId 0–255 is the platform's; yours start at 256

Every schema document carries a schemaId, a name and a schemaVersion. Ids below 256 are reserved for platform schemas — tiptap.draw.stroke lives there — and a game's own ids start at 256. Two different games both using 256 is normal and correct: ids are scoped to one game's approved set, not to the platform. The name is a dotted registry key in lowercase (yourgame.move), not a label and not somewhere to put a sentence. sendTyped takes either the name or the id.

At most 32 schemas per game, nesting at most 8 deep

A game's whole approved set holds at most 32 schemas, structs nest at most 8 deep, and each schema's maximum encoded size must come out at 512 bytes or under. That size is computable from the schema alone and you can work it out before you submit: tiptap.draw.stroke, the platform's drawing schema, is an array of at most 64 points of two 16-bit fixed values, plus a 16-variant enum and one byte of width — (1 + 64×4) + 1 + 1 = 259 bytes, whatever anyone draws. That is the property the whole rule turns on: the size depends on the schema and never on the data. A set that breaks any of these is refused whole, with a reason per document, rather than one refusal per resubmission.

Declaring a schema is not the same as having it approved

Two separate steps, and the gap between them is where creators get stuck. Submitting the game stores what you declared: it is checked against the type rule and kept, and an inadmissible schema does not fail the upload — the game uploads with no schemas and the reasons come back with the result, so read them. Approval is a second, human decision recorded separately, and until it exists the game has no typed capability: sendTyped returns false for every name, and a public match is refused outright because a public room with no typed channel would leave three players in four unable to act. IF YOUR GAME WORKS FOR YOU AND NOT FOR OTHER PLAYERS, THIS IS ALMOST ALWAYS WHY. It is not a bug in your code and there is nothing in the game to fix — the set is waiting on review. Changing a declared schema after approval ends the approval, because a review is of a specific set; reordering or reformatting the same schemas changes nothing and keeps it.

What the type rule buys, and what it does not

It is total for FORM. No player can transmit a byte you did not declare a slot for, every field is range-checked before it leaves the sender and again at the relay, and the maximum size of every message is known before anyone plays. That is worth having and it is the whole of it. It is not moderation and does not pretend to be: someone can still draw something vile with 64 perfectly legal points, and two players who agree offline that bid 7 means something can carry that meaning through a bounded integer. What the rule buys is that every byte on this path is structured, size-bounded, decodable and retainable as evidence — which is exactly what an opaque blob is not. Design as though a determined player will misuse whatever you declare, because they can, and keep what you declare to what the game needs.

The nine field types

Closed and complete. Anything not on this list is refused.

typerequired attributesencoded byteswhat it is for
bool(none)1A flag. Ready, folded, passed.
enumvariants 2–655361 (≤ 256 variants), otherwise 2A 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.
uintbits 8/16/32, min, maxbits / 8A whole number that cannot be negative. The range is REQUIRED — a field nobody declared a range for is a field nobody range-checks.
intbits 8/16/32, min, maxbits / 8The same, signed. Deltas, offsets, a score that can go down.
fixedbits 16/32, scale, min, maxbits / 8A 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)2A player in this room, as the id the PLATFORM issued. This is how a message refers to a person without carrying anything about them.
presettable — quickchat, ping, palette2An index into a table the PLATFORM supplies and moderates centrally. This is how a phrase crosses the wire without the text crossing it.
arraymax (REQUIRED, 1–64), element1 + max × elementA 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.
structfields — at least one, each with a namethe sum of its fieldsA 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

absentrefused aswhy
stringtype_not_admissibleA 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.
bytestype_not_admissibleA 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.
maptype_not_admissibleKeys 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 / varianttype_not_admissibleA 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.
floattype_not_admissibleAbsent 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 maxunbounded_arrayA 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 structtoo_deepRecursion 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.