Protocol Notes
Nothing on this page is needed to ship, because the SDK already implements all of it. Read it when a frame surprises you, or when you are reading raw messages yourself. To get something running instead, go to Quickstart.
This page is the wire archive. It records what was captured, what the bytes are,
what the encodings cost, and which upstream code decides a behaviour. The
contract here was not read off the documentation. It was captured against a live
clockworklabs/spacetime:v2.8.2 server and is pinned by committed fixtures, and
two conformance tiers run the same client against a real server, one as a
headless engine build and one as a wasm bundle in headless Chromium. That is why
the sections below can say what a frame contains rather than what it ought to.
Protocol reality check
The wire contract here is not guesswork. It was captured from a live
clockworklabs/spacetime:v2.8.2 server (dev/test/fixtures/session.jsonl). The
findings below shaped the implementation, and they are easy to get wrong from
the docs alone.
Findings 1 to 7, this section and the Defold traps after it, describe the
v1.json.spacetimedb wire, which the SDK no longer speaks. 4.0.0 deleted it.
They stay because they are still true of the server, because the committed
fixtures were captured over that wire, and because the decoder that reads them
survives in the test tree as dev/test/crosscheck_v1.lua, a second opinion on
the BSATN reader rather than a transport. BSATN is opaque, so a wrong belief
about a column's type returns a plausible number rather than raising; the
differential in dev/test/test_protocol_v2.lua decodes the same captured frame
both ways and asserts the two Lua values are equal. The wire the SDK actually
negotiates has its own section below, v2 over BSATN, captured
the same way.
- The JSON subprotocol speaks the v1 message schema rather than v2.
v1.json.spacetimedbmaps toWsVersion::V1in the server'sroutes/subscribe.rs. The v2 schema (InitialConnection,QuerySetUpdate,ReducerResult) is reachable only overv2.bsatn.spacetimedb. So the first frame isIdentityToken, notInitialConnection. Reaching the v2 schema meant writing a BSATN codec, which is now the only wire the SDK runs on. - Rows and reducer args are double-encoded. Under
JsonFormat,Single = ByteStringandList = Vec<ByteString>, so every row and argument tuple is a JSON string containing JSON. - Row shape depends on the message and on whether the relation is a table or
a view, never on the table alone. Every subscription readback
(
InitialSubscription,SubscribeApplied,SubscribeMultiAppliedand both unsubscribe forms), plusOneOffQueryResponse, sends table rows as named objects ({"id":3,"sender":"yuris"}).TransactionUpdatesends them as positional arrays ([3,"yuris",...]), inserts and deletes alike. A view projects positionally in all of those, subscription readbacks included, and in its own declared order rather than the underlying table's; see Views. Decoding a positional row needs the column order, which is whyschema.luarequirescolumns. This was captured off a live 2.8.2 server rather than inferred. The split runs one level down too. A positional row is an untypedProductValue, so field names are dropped recursively and aTimestampcolumn arrives as the bare array[micros]rather than the named wrapper a named row carries. - Reducers are addressed by canonical snake_case name. A JS module
exporting
sendMessageis called over the wire assend_message(spacetime describe --json <db>shows the mapping underExplicitNames).
Scalars are wrapped ({"__identity__":"0x…"},
{"__timestamp_micros_since_unix_epoch__":n}); the v1 decoder's unwrap strips
them. Sums in rows are positional, always [tag, payload] with exactly two
elements, so an Option column is [0, v] for Some, and for None it is
[1, {}] in a named readback or [1, []] in a positional row. The named
{"some":v} / {"none":[]} form shows up only in message envelopes and schema
JSON, because a row layout carries variant tags but not variant names.
Defold-specific traps
The first two were found by running the SDK inside a real engine and are not reachable from unit tests. The third is reachable, and still bit us, because it is silently wrong rather than loud.
- A v1 frame had to be sent as text.
extension-websocketdefaults to binary frames. Send JSON as binary and the server tries to BSATN-decode it, then drops the connection withunknown tag 0x7b for sum type ClientMessage(0x7bis{).websocket.sendneeds{ type = websocket.DATA_TYPE_TEXT }. The SDK now sends binary unconditionally, since its frames are BSATN. The trap is recorded because the failure mode is a dropped connection rather than a bad value. websocketis a global rather than a module.require("websocket")fails at build time with "the Lua module '/websocket.lua' can't be found", because native extensions register globals. Use_G.websocket.TimestampandTimeDurationcolumns are microseconds. Every clock a Defold game touches is seconds (os.time,socket.gettime), soos.date("%H:%M", row.created_at)is off by a factor of 1e6, and it does not error. It prints a date about 56 million years out. Divide by 1e6 first. There is no conversion helper in the SDK, since thestdb.protocolmodule that carried one went with the v1 wire in 4.0.0. See special types and their units. Lua 5.1 has no integer type either, which is where both ceilings in that section come from.
v2 over BSATN
Everything above describes v1.json.spacetimedb, which 4.0.0 removed. This
section is v2.bsatn.spacetimedb, the only wire connect negotiates. The
Usage Guide opens with what that means for a consumer. It was
captured the same way the v1 notes were, against a live
clockworklabs/spacetime:v2.8.2 server, and committed as
dev/test/fixtures/session_v2.jsonl (33 frames, 15 client and 18 server,
recorded by dev/tools/capture-v2.mjs).
Frame envelope
The two directions are not symmetric, and getting either wrong drops the connection rather than producing a bad value.
- Server to client, a frame is one compression byte, then exactly one BSATN
ServerMessage, or, when the byte is not0, a gzip stream whose plaintext is thatServerMessage. The byte is0none,1brotli,2gzip, and it replaces the message's leading byte rather than prefixing the stream, so the gzip magic sits at byte 2. The SDK putscompression=Gzipin the connect URL and inflates with Defold's builtinzlib.inflate; brotli raises, because no Defold build can decode one. Both shapes arrive on the same socket, since the server only compresses above 1KiB, so small frames stay plain. - Client to server, a frame is the raw BSATN
ClientMessagewith no compression byte. The server begins decoding at the sum tag, so a leading byte becomes an unknown variant ordinal. The capture confirms that client frames sent with no tag are accepted. - Client frames are binary,
websocket.DATA_TYPE_BINARY, the exact mirror of trap 5 above.unknown tag 0x7bis what JSON-over-binary produces on v1, and BSATN-over-text fails for the same reason in the other direction.
A v2 frame carries exactly one message. v3 (v3.bsatn.spacetimedb) is the same
message schema with several coalesced per frame; that is the whole difference,
and it is why frame handling here is unchanged.
Message map
Variant order is the wire ordinal. Fields are listed in encoding order, since a BSATN product carries neither names nor a count.
| ordinal | ClientMessage |
fields |
|---|---|---|
| 0 | Subscribe |
request_id u32, query_set_id u32, query_strings array<string> |
| 1 | Unsubscribe |
request_id u32, query_set_id u32, flags sum (0 Default, 1 SendDroppedRows) |
| 2 | OneOffQuery |
request_id u32, query_string string |
| 3 | CallReducer |
request_id u32, flags plain u8, reducer string, args bytes |
| 4 | CallProcedure |
request_id u32, flags plain u8, procedure string, args bytes |
UnsubscribeFlags is a SATS sum and the two call flags are raw u8. Both are
one byte of 0 in practice; the distinction only bites if a flag ever gains a
payload, which is why the SDK writes them through different writer methods.
| ordinal | ServerMessage |
fields |
|---|---|---|
| 0 | InitialConnection |
identity u256, connection_id u128, token string |
| 1 | SubscribeApplied |
request_id u32, query_set_id u32, rows QueryRows |
| 2 | UnsubscribeApplied |
request_id u32, query_set_id u32, rows Option<QueryRows> |
| 3 | SubscriptionError |
request_id Option<u32>, query_set_id u32, error string |
| 4 | TransactionUpdate |
query_sets array<QuerySetUpdate> |
| 5 | OneOffQueryResult |
request_id u32, result Result<QueryRows, string> |
| 6 | ReducerResult |
request_id u32, timestamp i64, result ReducerOutcome |
| 7 | ProcedureResult |
status ProcedureStatus, timestamp i64, total_host_execution_duration i64, request_id u32 |
ReducerOutcome is a sum of 0 Ok { ret_value bytes, transaction_update }, 1
OkEmpty (unit), 2 Err(bytes) and 3 InternalError(string).
ProcedureStatus is 0 Returned(bytes), 1 InternalError(string). Rows travel
as a BsatnRowList, which is a size hint plus one rows_data blob. The hint is
either 0 FixedSize(u16), rows being consecutive equal-width slices, or 1
RowOffsets(array<u64>), each offset a start.
ReducerOutcome::Ok is the separation that makes v2 worth having. The reducer's
result and the transaction it caused arrive as one frame addressed to the caller,
where v1 broadcast a whole reducer_call to every subscriber. The consequence is
that an ordinal-4 TransactionUpdate is always another caller's commit,
measured against the capture and never once your own, so a v2 client applies
updates from two places rather than one.
What the capture settled
The sources left these ambiguous, or state them wrongly. Each is pinned at its
decode site in stdb/protocol_v2.lua or stdb/connection.lua.
ReducerResult.timestampis microseconds. Upstreamv2.rs:378documents the field as nanoseconds and is wrong. The captured value is1787288613878948, three orders of magnitude short of nanoseconds and exactly the unit v1's envelope uses. Row-levelTimestampcolumns are micros on both protocols, so nothing here divides differently per protocol.- Identity hex text is the byte-reverse of the wire bytes. An
Identityis a little-endian 32-byte number in BSATN, and the server renders hex from the big-endian array, so the 64 digits a v1 frame carries, and the JWT's ownhex_identity, hold the wire bytes reversed. Both were compared against the same identity to confirm it. Decode without the reversal and you get a plausible hex string that matches nothing. - Option ordinals are 0
Someand 1None; Result is 0Okand 1Err. These are the same ordinals v1's positional sums use, so[0, v]/[1, {}]on JSON and tag0/1on BSATN say the same thing. - A client frame with no compression byte is accepted. The asymmetry in the envelope above is the protocol rather than something to work around. The server reads the sum tag first on the frames it receives and writes a compression byte on the frames it sends.
Hostile input
BSATN is self-describing only in the sense that a decoder knows what it expects next. There are no lengths to cross-check, no field names and no terminator, so a malformed frame is indistinguishable from a valid one until the numbers stop making sense. Three checks make that survivable:
- The decoder validates
RowOffsetsas a partition ofrows_data, non-descending with the last offset in bounds, while the offset array is being read and before a single row is sliced. Total decoded bytes then cannot exceed the frame. Unvalidated, an offset table is an amplifier rather than a nuisance, since 232 KB of offsets naming overlapping spans expands to 388 MB of Lua strings. The same check keeps an offset past 2^53 away fromstring.sub, whose double-to-ptrdiff_tcast is undefined and platform-divergent, so the same bytes would decode differently on x86-64 and on wasm32. - Array counts are bounded by the bytes remaining in the frame. A
u32element count is four bytes that can ask for four billion elements. Every element of a real array costs at least one byte, so the bound rejects nothing a server sends. The one shape it does reject is a vector of a zero-width element type, a unit or an empty product, which no generated schema produces. - A decoded frame must be fully consumed. Cursor exhaustion is the only structural self-check the format admits. If a message decodes but bytes are left over, the layout the decoder assumed was not the layout on the wire, and the values it produced are not to be trusted.
Bytes and latency, v1 against v2
Every figure here was measured on one workstation, a Ryzen 7 8845HS running
headless Defold against a local spacetime:v2.8.2, so read them as local
numbers. Older CI figures on this SDK came off a different machine and are not
comparable to these.
Measured in wire bytes, a v2 subscription readback is 46% of v1's. The traffic
is identical over both wires, back to back against one server, driven by a
purpose-built probe rather than by the two committed captures, which drive
different sequences and are not byte-comparable.
Each data point runs clear_bulk, fill_bulk(N),
subscribe "SELECT * FROM bulk" and unsubscribe. The bytes counted are the
subscription readback, the frame PHASE6 times.
| rows | v1 bytes | v2 bytes | v2 as % of v1 | v1 B/row | v2 B/row |
|---|---|---|---|---|---|
| 100 | 9,818 | 4,621 | 47.1% | 98.2 | 46.2 |
| 1,000 | 100,406 | 46,921 | 46.7% | 100.4 | 46.9 |
| 10,000 | 1,042,187 | 478,921 | 46.0% | 104.2 | 47.9 |
| 50,000 | 5,360,120 | 2,438,921 | 45.5% | 107.2 | 48.8 |
A v1 row costs more per row as the table grows and a v2 row barely moves, because JSON pays for the digits it prints. Across the whole 10k-row session, server → client is 2,085,999 B on v1 against 958,329 B on v2 (45.9%); the same four client messages are 309 B against 96 B (31.1%). "Roughly half the bytes" was the estimate this work was justified with, and it turns out to have been slightly conservative.
The committed captures give a second, weaker reading. The two sessions are not
the same traffic, so this is a scale check and nothing more. session.jsonl is
50 server frames / 26,238 B (525 B mean, client half not captured);
session_v2.jsonl is 16 server frames / 2,296 B (144 B mean) plus 13 client
frames / 446 B.
On apply latency, v2 is faster at every row count, but the absolutes below are not a stable baseline. PHASE6 times the single frame that applies a first subscription. Read this whole subsection as one machine's snapshot rather than a regression baseline. The paragraph after the table explains why that distinction matters here.
| rows | v1 frame ms | v2 frame ms |
|---|---|---|
| 100 | 0.4, 0.4 | 0.4, 0.4 |
| 1,000 | 2.1, 2.3 | 1.3, 1.3 |
| 5,000 | 9.9, 10.4 | 6.3, 5.4 |
| 10,000 | 20.0, 20.1 | 10.5, 13.6 |
| 25,000 | 53.6, 54.3 | 35.5, 31.1 |
| 50,000 | 105.7, 113.7 | 86.3, 81.9 |
Both sweeps are given because conformance.sh runs the smoke twice and the
large row counts are noisy. Read the pair rather than the better half of it.
The measurement conditions matter more than the digits. Those figures are a
median of 3 on a Ryzen 7 8845HS, and nobody recorded the load on the box at the
time. A later re-run of the same commit measured every native absolute 2-3x
higher (v1 median 61.7ms at 10k rows against the 20.0ms above, v2 44.6ms against
10.5ms, over 5 runs) with /proc/loadavg at 11.06 on 16 cores from concurrent
work, and the harness flagging up to 47% spread on two of the sweeps. The ratio
survived in direction but not in value, 0.59-0.65 re-measured against the 0.53
the table above implies. That is a material difference rather than a rounding
one, and it is the honest headline. v2 is consistently faster, and the size of
the win is not pinned down to two digits.
A clean re-measure on a quiesced box is still owed, and Roadmap is
where it is tracked. Until it happens, do not read a slower number off this
table as a regression. Check loadavg and the harness spread warning first.
Ambiguity of exactly this kind is what forced this correction.
What is not load-dependent is the wire-byte counts above. They are byte counts and they stand unchanged. So do the two structural claims below, both of which reproduce regardless of what else the machine is doing.
Where the time goes, at 10,000 rows, native, with the same caveat on the absolutes. The shape is the point.
| bucket | v1 | v2 |
|---|---|---|
| pre | 0.5 | 0.3 |
| decode | 11.4 (json.decode) |
8.4 (BSATN) |
| big-integer quoting | 0.0 | structurally absent |
| row construction | 6.8 | 1.9 |
| post | 0.1 | 0.1 |
| frame | 20.0 ms | 10.5 ms |
Two things in that split are structural. They follow from the encodings rather than from the clock.
The big-integer quoting bucket cannot exist on v2. The quoting pass is v1
rewriting wide integer literals as strings before json.decode can round them
(described under Row value types); it exists at
all only because JSON cannot carry a u128 exactly. A binary wire has no text
to rescue, so there is nothing to scan. This is now enforced rather than
observed. The v2 path asserts zero quoting attempts, a claim about
guard_calls/wide_calls that would fail if a per-row string.find scan ever
came back on the binary wire with its time billed nowhere. The three hit-based
assertions beside it are 0 == 0 on v2 and would have passed unchanged.
Row construction falls with decoding, 6.8ms to 1.9ms, because a BSATN row builds fewer intermediate Lua values on its way to the same table, so the win is not confined to the decode bucket.
HTML5 (the wasm bundle in Chromium), 10,000 rows, one sample each. v1 is 112.0ms
total, made of 68.0 pre, 21.0 json.decode and 23.0 build, against v2's 70.0ms,
made of 17.0 pre, 45.0 BSATN decode and 8.0 build. The structural fact here is
the collapse of pre, which is frame delivery. It is the single largest bucket
on v1 at 61% of the frame, and on v2 it is 24% and no longer dominant. That is
the larger v1 payload showing up as delivery cost, and it dominates the browser
tier in a way it never does natively. The collapse is the load-independent part;
the millisecond totals carry the same caveat as the native table.
One methodology note, since a bucket split is only worth reading if both sides
were billed the same way. PHASE6 bills both wires into those same five buckets
and the decode call counts match exactly (one json.decode for the envelope
plus one per row on v1, one protocol_v2.decode_server plus one
decode_typed_bsatn per row on v2), so decode_calls == rows + 1 is asserted
identically on both protocols. The columns are comparable rather than merely
adjacent.
Where the confirmed_reads default comes from
connect({ confirmed_reads = nil }) sends no parameter, and what the server
then does depends on which protocol negotiated. The rule a consumer needs is in
The confirmed_reads tri-state;
this is where it was read from, so a later server version can be checked against
the same two places.
Upstream resolves the parameter in resolve_confirmed_reads_default
(crates/client-api/src/routes/subscribe.rs:103-111). An explicit true or
false is honoured as sent, and an absent parameter becomes false on v1 but
DEFAULT_CONFIRMED_READS, which is true, on v2 and v3. The upstream test
confirmed_reads_default_depends_on_ws_version (subscribe.rs:2838-2844)
asserts both defaults and both overrides, which is why the SDK sends nothing
unless asked rather than picking a default of its own.
spacetimedb-defold
Using the SDK
Working on the SDK