-
v0.1.0
Stablereleased this
2026-08-19 10:04:01 +00:00 | 82 commits to main since this releaseFirst tagged release of the SpacetimeDB 2.x client SDK for Defold, in Lua. Every capability below
was exercised against a liveclockworklabs/spacetime:v2.8.2server from inside a real Defold
engine, and the wire contract it depends on is captured rather than inferred:test/fixtures/session.jsonl
holds 21 real frames andtest/test_fixtures.luareplays them, so a server bump shows up as a
fixture diff instead of a runtime surprise.Features
Protocol rewritten against captured 2.8.2 wire traffic
The first implementation was written from the docs and was wrong in four ways, each found by
publishing a JS chat module to a live 2.8.2 server and capturing the session.v1.json.spacetimedb
carries the v1 message schema, not v2 - the v2 schema (InitialConnection,QuerySetUpdate,
ReducerResult) exists only on the v2 BSATN wire, so the first frame on the v1 JSON wire is
IdentityToken. UnderJsonFormatrows and reducer arguments are double-encoded (ByteString:
JSON strings containing JSON). Row shape depends on the message:InitialSubscriptionsends named
objects,TransactionUpdatesends positional arrays, which is whystdb/schema.luarequires
columns- the declared order is what names the fields. And reducers are addressed by their
canonical snake_case name, so a JSsendMessageissend_messageon the wire.stdb/protocol.lua,stdb/cache.luaandstdb/connection.luawere rewritten against those
findings.protocol.unwrapnormalises wrapped scalars ({"__identity__":"0x…"},
{"__timestamp_micros_since_unix_epoch__":n}) and options ({"some":v}/{"none":[]}).
Reaching the v2 BSATN wire needs a BSATN codec and is not part of this release.Reconnect with backoff, cache drop and subscription replay
stdb.connectnow owns a reconnect state machine rather than leavingresubscribe_all()
unreachable. On socket close it retries with exponential backoff (backoff_baseand
backoff_capare options,reconnect = falseopts out), reuses the server-issued token so
identity survives, and on the new socket'sIdentityTokenclears the cache and replays every
live subscription. Two pure helpers carry the logic and are unit tested:connection.backoff_delay
(doubling with a clamp) andconnection.set_send, which swaps the transport and drops requests
owed by the dead socket so their callbacks can never fire late.The engine test proves it end to end: the harness runs
docker restartunder a live client, and
the run only passes if the client reconnects, replays its subscription and reads the pre-restart
row back out of the cache.Identity and token persistence across restarts
SpacetimeDB issues an identity and token on first connect.
stdb/connection.luagained an
injectable{ load, save }storage seam, written through onIdentityToken;stdb/init.lua
defaults it tostdb.sys_storage()oversys.save/sys.loadand prefers, in order, the live
token, an explicitopts.token, then the persisted one.storage = falseopts out.Verified in-engine, not asserted: conformance runs the headless client twice, the second time with
no token in the environment, and fails unless both runs report the same identity.Droppable subscription query sets
The legacy
Subscribemessage replaces the entire subscription set and cannot be undone one query
at a time.db:subscribe_query(queries)returns a query id overSubscribeMultiand
db:unsubscribe(qid)drops it overUnsubscribeMulti.Three server behaviours are pinned as fixtures. An unsubscribe reply carries the now-unmatched rows
as deletes, so the cache path that fills on subscribe drains on unsubscribe. The server computes
the real delta: dropping one of two overlapping subscriptions deletes nothing because the other
still matches, so a caller needs no query-to-row bookkeeping. An overlapping subscribe returns
tables:[], contradicting upstream's doc comment, so a UI that repopulates from a subscribe
payload renders empty - read the cache instead. The single-query forms nest rows as
.rows.table_rows(a bareTableUpdate) while the multi forms use.update(aDatabaseUpdate
with atablesarray); decoding one as the other silently applies nothing, so both are normalised
in one place. Reconnect replays query sets under their originalquery_idso caller-held handles
stay valid, and a set the server rejected is dropped from the replay list.Procedure calls with the return value decoded
db:call_procedurecompletes the path to the existingProcedureResulthandler and hands the
callback a decoded value rather than the raw message. Procedure returns are double-encoded by the
sameByteStringrule as reducer arguments -status.Returnedis"2"for au642 and
'"echo: from lua"'for a string - soprotocol.decode_payloadunwraps it. Captured from three
test procedures (u64return, string return with args, and one that throws).Two limits worth knowing. A thrown error and an unknown procedure name produce the same status,
InternalError, differing only in message, so there is no distinct "no such procedure" variant to
branch on;request_idcorrelates directly and is notOption-wrapped. And the HTTP schema
endpoint has noprocedureskey at all, sotools/gen-schema.shcannot emit procedure names even
thoughspacetime describe --jsonlists them.Schema generation from a live server
Column order is load-bearing, and hand-maintaining it is silent: reorder a column in the module and
every client mis-maps fields with no error anywhere.tools/gen-schema.shreads
/v1/database/<db>/schemaand emits a Lua module with columns in declaration order, the primary
key, and canonical reducer names with params. It is wired in rather than optional -
tools/dev-server.shregeneratestest/fixtures/chat_schema.luaon every publish and
test/test_fixtures.luaconsumes the generated file instead of a hand-typed copy, so a column
reorder fails the suite withexpected id,sender,text, got id,text,sender.Headless Defold conformance test
The
extension-websocketbinding instdb/init.luahas no unit coverage and cannot get any. The
repo is therefore also a Defold library project (game.projectwithinclude_dirs=stdb) whose
bootstrap collection runsmain/smoke.script, a headless client, against a live server:
tools/dev-server.shbrings the server up and publishes the module,tools/conformance.shbuilds
headless withboband assertsSMOKE: PASSacross four phases (round trip, restart survival,
query set drop, procedure round trip).It caught two engine-only bugs on its first run.
extension-websocketdefaults to binary frames,
so the server tried to BSATN-decode the JSON and dropped the connection with
unknown tag 0x7b for sum type ClientMessage; frames are now sent with
{ type = websocket.DATA_TYPE_TEXT }. Andrequire("websocket")fails at build time because
native extensions register globals, so the binding uses_G.websocket. Running Defold's generic
Linux binaries on NixOS needs help:defold-patchrewrites the interpreter and rpath, with a
buildFHSEnvwrapper as fallback, because the FHS env needs user namespaces that unprivileged job
containers usually deny. On the project's own Forgejo runner the conformance job stays out of the
blocking gates - it lives inconformance.ymlonworkflow_dispatchplus a weekly schedule and
self-reports to issue #7 - while
stylua, the Lua suites andnix flake checkgate every push.Consumable as a Defold library dependency
Forgejo serves
/archive/<tag>.zipfor any tag, so the dependency URL works from the tag alone,
and the release workflow publishes notes from the tag annotation read over the API (curlonly, no
toolchain). Verified by building a separate consumer project with no local copy ofstdb/,
resolving the release URL withboband running the bundle in a real engine.Two facts that project came back with, both now in the README. Defold does not resolve dependencies
transitively, so a consumer that lists only this SDK compiles and then fails atconnectwith
extension-websocket missing: add it to game.project dependencies. And Forgejo refuses basic auth
for archive downloads, which is allbobspeaks: measured,https://user:token@host/...returns
404 and?token=returns 200, so a private repo needs the token as a query parameter.What's Changed
- release: read the tag annotation from the API, publish a reachable URL in
8768170 - release: ship as a Defold library dependency, verified by consuming it in
394c228 - procedures: add call_procedure and decode the returned value in
a20f026 - subscriptions: add droppable query sets (subscribe_query / unsubscribe) in
3528c2b - schema: generate table descriptors from the live server in
ad3779c - identity: persist the server-issued token across app restarts in
7c0fac9 - reconnect: wire the state machine into the websocket binding in
4c0682a - ci: split conformance into a manual workflow in
c870e63 - conformance: patchelf the engine binary instead of bubblewrap in
3c14fdb - ci: stop gating conformance on a step output in
a3043a7 - ci: attach server to the job's netns when running containerised in
b30a36a - ci: grant issues:write and capture runner docker topology in
8989aa8 - ci: probe a reachable server address instead of assuming localhost in
9e8988d - ci: conformance job non-blocking, self-reports to issue #7 in
3be6593 - engine: headless conformance test — SDK verified inside real Defold in
2b32580 - protocol: rewrite against captured 2.8.2 wire traffic (v1 schema) in
ee90548 - ci: lean ci devShell, provision json.lua in shellHook in
0a6602c - ci: unit tests + stylua + flake check on push and PR in
57a3012 - spacetimedb-defold: SpacetimeDB 2.x client SDK for Defold — scaffold in
d0583b2
Full Changelog: v0.1.0
Use as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v0.1.0.zip https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zipextension-websocket is required: Defold does not resolve dependencies
transitively, so without itconnectfails at runtime.Private repo? Forgejo rejects basic auth for archive downloads; append
?token=<access-token>to the URL instead.Downloads
-
Source code (ZIP)
16 downloads
-
Source code (TAR.GZ)
1 download
- release: read the tag annotation from the API, publish a reachable URL in