Quickstart
From nothing to a connected game that replicates player positions between clients. Steps 1 to 3 run in order. Skip step 1 if you already have a module published. Steps 4 onward stand alone, so read whichever one you need.
If you have not added the SDK to a project yet, do Installation first. It is two dependency URLs and no patching.
1. Write and publish a module
A SpacetimeDB module is the server. It declares the tables and the reducers that write them, and it runs inside the database. Nothing in this SDK creates one, so start here if you do not have a module yet. The server language can be TypeScript, Rust, C# or C++.
spacetime init --lang typescript --server-only arena
cd arena/spacetimedb && npm install
That writes a working module at spacetimedb/src/index.ts. Replace its body with
one table of players and two reducers that write it.
import { schema, table, t } from 'spacetimedb/server';
const spacetimedb = schema({
player: table(
{ name: 'player', public: true },
{
identity: t.identity().primaryKey(),
name: t.string(),
x: t.f32(),
y: t.f32(),
}
),
});
export default spacetimedb;
export const joinGame = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
ctx.db.player.insert({ identity: ctx.sender, name, x: 0, y: 0 });
}
);
export const movePlayer = spacetimedb.reducer(
{ x: t.f32(), y: t.f32() },
(ctx, { x, y }) => {
const me = ctx.db.player.identity.find(ctx.sender);
if (!me) throw new Error('call join_game first');
ctx.db.player.identity.update({ ...me, x, y });
}
);
public: true is what makes a table subscribable at all. A private table stays
invisible no matter what a client asks for, and the export default is how the
host finds the schema in the first place. The primary key matters to the client
too. Without one, a player moving arrives as a delete followed by an insert
instead of as an update.
Now start a local server and publish.
spacetime start # in another terminal
spacetime publish arena
The server snake_cases function names, so joinGame and movePlayer arrive at
the client as join_game and move_player, and those are the names you call.
arena is the database name, which every URL below repeats.
That is the whole server side. For indexes, views, scheduled reducers and permissions, see The Database Module, Tables, Reducers and spacetime publish.
This repository also ships a server and a module of its own, driven by
dev/tools/dev-server.sh, which is what the test suite and both conformance
tiers run against. Development covers it. It is a checkout of
this repo rather than something to build a game on.
2. Generate the schema
The wire is binary and carries no column names, so the client needs its own Lua
copy of the module's shape before it can decode a row or encode a reducer call.
You generate that copy from the running server. It comes out as a table this SDK
calls gen.
return {
tables = {
player = {
columns = { "identity", "name", "x", "y" },
types = { "identity", "string", "f32", "f32" },
pk = "identity",
},
},
reducers = {
join_game = { params = { "name" }, types = { "string" } },
move_player = { params = { "x", "y" }, types = { "f32", "f32" } },
},
procedures = {},
-- Named types only, meaning the enums and structs a module declares. Empty
-- when every column is a primitive, as above.
typespace = {},
}
Note that the reducer names are the snake_cased wire names from step 1. You never
write any of this by hand. To generate it, declare the database once in
game.project.
[spacetimedb]
base_url = http://127.0.0.1:3000
database = arena
Both keys live under [spacetimedb]. database is required, and base_url
defaults to http://127.0.0.1:3000 when you leave it out.
Then run Project ▸ SpacetimeDB: Regenerate schema, which writes
/my_schema.lua.
local gen = require("my_schema")
The command runs curl, because the editor's Lua runtime has no HTTP client of
its own. On a machine without curl, fetch at runtime instead.
stdb.schema.to_lua(gen) is the same conversion as a plain function, for
writing the file from a build script of your own. Outside the editor,
dev/tools/gen-schema.sh http://127.0.0.1:3000 arena > my_schema.lua does the
same job from a checkout of this repo.
While you are still changing the module, stdb.schema.fetch skips the generate
step and reads the same table off the server at launch. It costs a round trip,
and the game will not start if the schema endpoint is unreachable.
stdb.schema.fetch({ base_url = "http://127.0.0.1:3000", database = "arena" }, function(err, gen)
assert(not err, err)
-- connect here; this `gen` is identical to the generated module's table
end)
That takes the http:// form of the host, not the ws:// one, because it is an
HTTP GET rather than a socket. err is a string and nothing raises from inside
the callback, so a server you forgot to start reports
no response from http://127.0.0.1:3000/v1/database/arena/schema?version=9
rather than killing the script. The fetch defers the connect by a few frames,
so self.db is nil until the callback runs and anything touching it has to
check. On a pinned module the connection is live in the same frame.
A hand-written schema is legal, but it has to carry types. A column list and a
pk alone describe no widths, and a BSATN row has nothing in it to recover them
from, so such a relation is skipped whole and reported through an undescribed
event rather than guessed at. That reads as a silently empty table if you are
not listening. A generated schema always carries types, which is why either
route above is what to start from.
Regenerate whenever the module changes. A stale gen leaves bytes the decoder
never read, and the SDK raises telling you to regenerate instead of handing you a
plausible wrong row.
3. Connect
local stdb = require("stdb.init")
local gen = require("my_schema")
function init(self)
self.db = stdb.connect({
url = stdb.url("ws://127.0.0.1:3000", "arena"),
schema = gen,
on_event = function(evt)
print(evt.type)
end,
})
end
function final(self)
if self.db then
self.db:close()
end
end
schema = gen is the whole generated module. connect reads the four sections
off it, so there is nothing for you to take apart.
stdb.url("ws://127.0.0.1:3000", "arena") builds
ws://127.0.0.1:3000/v1/database/arena/subscribe. The base URL has to carry the
scheme. Nothing in the SDK adds one, and a base URL without ws:// or wss://
is rejected.
connect returns immediately, but the socket is not open yet, so anything you
send belongs after the "connected" event. The SDK also handles identity for
you. It saves the token the server issues and sends it again on the next launch,
so a player keeps the same identity across restarts.
4. Subscribe to a query
Whatever a query selects becomes a live local cache. Subscribe once the
connection is up, filling in the on_event from step 3.
on_event = function(evt)
if evt.type == "connected" then
local sub = self.db:subscribe_query({ "SELECT * FROM player" })
sub:on_applied(function()
print(self.db.tables.player:count() .. " players in the arena")
self.db.reducers.join_game({ "player-one" })
end)
sub:on_error(function(err)
print("subscription failed: " .. tostring(err))
end)
end
end
Read the cache inside on_applied rather than beside the subscribe. Until the
applied frame lands the table is legitimately empty, and "not filled yet" looks
exactly like "no rows match".
There is no db:subscribe, only subscribe_query. The v2 ClientMessage has no
legacy whole-set subscribe, and the query-set form is the one to reach for
anyway, since it can be dropped one set at a time. sub:unsubscribe() drops the
set later.
When a filter uses a value the player supplied, use the typed builder rather than pasting the value into SQL yourself. The builder quotes it and checks the column against the schema.
local tables = stdb.query.tables(gen)
self.db:subscribe_query({ tables.player:where(function(r) return r.name:eq(entered) end) })
5. React to row changes
self.db.tables.player:on_insert(function(ctx, row)
spawn_player(row.identity, row.x, row.y)
end)
self.db.tables.player:on_update(function(ctx, old, new)
move_sprite(new.identity, new.x, new.y)
end)
self.db.tables.player:on_delete(function(ctx, row)
despawn_player(row.identity)
end)
on_update works here because player has a primary key. On a table without
one, a change arrives as a delete and an insert instead, and registering
on_update raises an error saying exactly that.
Callbacks fire once the whole batch is in the cache, so a callback reading
iter, count or find always sees a complete transaction.
Detach in final, or the closure outlives the self it captured. This is not
optional. A game object that registers a closure over self and is then deleted
has the first row after that call into a corpse.
function final(self)
if self.db then
self.db.tables.player:off_insert(self.on_row)
end
end
Keep a reference to the function you registered, since that is what off_insert
matches on. The if self.db guard covers the object being deleted before a
runtime schema fetch came back.
Count from the cache rather than from callbacks. A row matched by two different
queries fires on_insert twice.
6. Read the cache directly
local me = self.db.tables.player:find("identity", self.db.identity)
local n = self.db.tables.player:count() -- O(n), so do not call it per frame
for row in self.db.tables.player:iter() do
print(row.name, row.x, row.y)
end
find works on the primary key or any unique column. On any other column it
raises rather than scanning, since a silent O(n) walk per lookup is worse than
being told to add an index.
A misspelled table raises too, and the message lists the table names the schema actually has.
7. Call a reducer
self.db.reducers.move_player({ 12.5, 40.0 }, function(err)
if err then
print("move rejected: " .. tostring(err))
end
end)
The accessor raises at the callsite on a misspelled name or a wrong argument
count, which db:call_reducer("move_player", ...) cannot do. Both still work.
Your own move does not come back as a local echo. It lands the same way every
other player's does, through on_update once the server commits it.
8. Or write it straight-line
Every one-shot call also has a coroutine form, so a sequence reads top to bottom instead of nesting.
coroutine.wrap(function()
stdb.await_event(self.db, "connected")
stdb.await(self.db.subscribe_query, self.db, { "SELECT * FROM player" })
stdb.await(self.db.reducers.join_game, { "player-one" })
print("joined, and the arena is cached")
end)()
await_event(db, "connected") returns immediately when the connection is
already up, so it is safe to run after a connect that has landed.
A failure raises inside the coroutine. Catch it with a pcall around the
individual awaited step, not around the whole coroutine.wrap body. Lua 5.1
cannot yield across a pcall, and the HTML5 target is plain Lua 5.1.
Row callbacks stay callbacks, because an insert fires for as long as the game runs and there is nothing to await.
Next
- Example Project, a small, complete, openable Defold project doing roughly the above, to run rather than assemble by hand
- Usage Guide, per-table callbacks, droppable query sets, handles, reconnect, identity persistence, provider auth, event tables, views, and what each column decodes to
- API Reference, every module, method, option and special type with its units
spacetimedb-defold
Using the SDK
Working on the SDK