SpacetimeDB Tutorial: Multiplayer Backend in 13 Steps [2026]

Every multiplayer game backend used to mean the same miserable stack: a game server written in one language, a Postgres database for persistence, a Redis layer bolted on for real-time state, and a pile of custom WebSocket code to keep clients in sync. SpacetimeDB throws that stack out. It merges the database and the server into a single process, lets you write game logic that runs inside a WebAssembly module, and pushes state changes to every connected client automatically. This tutorial walks through building a small real-time multiplayer arena backend with SpacetimeDB 2.8.3, from CLI install to a production deployment on Maincloud, in 13 concrete steps.

By the end you will have a working server module with tables for players and world state, reducers that handle movement and chat, a scheduled tick reducer for game logic, and a TypeScript client that subscribes to live changes and calls into the server. You’ll also get the pricing math, the failure modes nobody warns you about, and a troubleshooting list for the errors you’re most likely to hit in your first week.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

What Is SpacetimeDB and Why It’s Different From a Normal Game Backend

SpacetimeDB, built by Clockwork Labs, describes itself in its own repository as a relational database that is also a server. According to the project’s official GitHub repository, “SpacetimeDB is a relational database that is also a server.” The practical effect of that sentence is bigger than it sounds: instead of running a Node.js process that reads and writes to a separate Postgres instance and pushes updates through Redis pub/sub, you write your game logic directly as functions that live inside the database. The GitHub README puts it plainly: “You upload your application logic directly into the database, and clients connect to it without any server in between.”

The architecture is built around three concepts. Tables hold your authoritative game state, the same way rows in a SQL database would. Reducers are transactional functions that mutate those tables, invoked either by clients or by a schedule. Modules bundle tables and reducers together and compile to WebAssembly for Rust, C#, and C++, or run directly on V8 for TypeScript. Clockwork Labs sums this up in its documentation: “SpacetimeDB modules define tables (your data) and reducers (your logic).” When a reducer commits a change, SpacetimeDB streams the delta to every subscribed client in real time, without you writing any pub/sub plumbing.

This isn’t a hypothetical toy for tech demos. BitCraft Online, a persistent multiplayer survival-crafting MMORPG built by Clockwork Labs’ own studio, runs its entire backend as a single SpacetimeDB module handling chat, item state, terrain, and player positions for thousands of concurrent players. That’s a meaningfully different pitch from most indie backend-as-a-service products, which tend to top out around chat demos and turn-based prototypes.

As of early September 2026, the current stable release is SpacetimeDB v2.8.3, tagged on the project’s GitHub releases page on August 25, 2026. SpacetimeDB 2.0 shipped earlier in 2026 and expanded the project beyond games into general web development, but the game-backend use case documented here still works the same way it did in earlier 2.x releases: tables, reducers, modules, real-time subscriptions.

The GitHub repository’s tagline, “Multiplayer at the speed of light,” points at the specific problem SpacetimeDB was built to solve: the network hop between your game server and a separate database is usually where multiplayer latency budgets go to die. By running game logic inside the same process that owns the data, a reducer call never has to round-trip to an external database before it can commit a state change. Rust and C++ modules get compiled ahead of time into WebAssembly bytecode, which the SpacetimeDB runtime then executes directly, while TypeScript modules run on the V8 engine Node.js itself is built on. Both paths skip the network call to an external datastore that a traditional Node-plus-Postgres backend would need for every write.

Prerequisites: Tools, Accounts, and Versions

Before you start, get these in place. None of them cost anything to set up.

  • SpacetimeDB CLI 2.8.x — installed via the official installer script, works on macOS, Linux, and Windows (WSL2 recommended on Windows)
  • Rust 1.79 or newer — for the server module in this tutorial (the CLI can scaffold C#, TypeScript, or C++ modules instead if you prefer)
  • Node.js 20 LTS or newer — for the TypeScript client and generated bindings
  • A code editor — VS Code with the rust-analyzer extension is the path of least resistance
  • Docker is NOT required — SpacetimeDB runs a local server as a native process, not a container
  • A free Maincloud account — needed only for Step 10, when you deploy off your machine; sign up at spacetimedb.com
  • Basic familiarity with async/await in TypeScript and ownership in Rust — you don’t need to be an expert in either, but you should be comfortable reading both

Budget about 90 minutes for the full walkthrough if you’re typing the code yourself rather than copy-pasting, plus another 20-30 minutes if you want to complete the optional load-testing step near the end.

Step 1: Install the SpacetimeDB CLI and Choose a Module Language

Install the CLI with the single-line installer published on the SpacetimeDB homepage:

curl -sSf https://install.spacetimedb.com | sh
spacetime --version

You should see output confirming version 2.8.3 or later. If the command isn’t found after installation, restart your shell so your PATH picks up the new binary location.

SpacetimeDB modules can currently be written in four languages, according to the project’s language support documentation: Rust and C++ compile to WebAssembly and are positioned for performance-critical logic, C# also compiles to WebAssembly and is the natural fit if your client is a Unity game, and TypeScript runs on V8 and suits teams coming from a Node.js background. This tutorial uses Rust for the server module because it has the most mature tooling and the most complete official documentation, but the table and reducer concepts translate directly if you pick C# or TypeScript instead.

Step 2: Scaffold Your Module and Learn the Project Layout

Create a new project directory and initialize a Rust server module inside it:

mkdir arena-backend && cd arena-backend
spacetime init --lang rust server

This generates a server/ directory with a Cargo.toml already wired to the spacetimedb crate and a src/lib.rs stub. Everything you write for the backend in this tutorial lives in that single file, though in a real project you’d split it into modules once it grows past a few hundred lines.

Open server/Cargo.toml and confirm the dependency line looks roughly like this (versions may differ slightly by the time you run this):

[dependencies]
spacetimedb = "1.0.0"

That’s the entire dependency tree for the server. No ORM, no separate driver for a cache layer, no message broker client.

Step 3: Define Player and World State With Tables

Replace the contents of server/src/lib.rs with your table definitions. Tables are declared as plain Rust structs annotated with the #[table] macro. Here’s a player table for a small top-down arena game, holding position, health, and a display name:

use spacetimedb::{table, reducer, Identity, ReducerContext, Table};

#[table(name = player, public)]
pub struct Player {
    #[primary_key]
    identity: Identity,
    name: String,
    x: f32,
    y: f32,
    health: i32,
    online: bool,
}

#[table(name = chat_message, public)]
pub struct ChatMessage {
    #[primary_key]
    #[auto_inc]
    id: u64,
    sender: Identity,
    text: String,
    sent_at: u64,
}

The public flag means every connected client can subscribe to this table’s contents (you’ll lock that down more precisely in Step 11). #[primary_key] marks the unique row identifier, and #[auto_inc] on the chat table gives every message an incrementing ID without you managing a counter by hand. There’s no separate schema migration tool to configure here: the table struct is the schema, and spacetime publish applies it directly.

Step 4: Write Reducers for Movement, Chat, and Combat

Reducers are the only way state changes in SpacetimeDB. A reducer is a plain function annotated with #[reducer], taking a ReducerContext as its first argument. The context exposes ctx.db for table access and ctx.sender() to identify the caller. Add the following to lib.rs:

#[reducer]
pub fn move_player(ctx: &ReducerContext, x: f32, y: f32) -> Result<(), String> {
    let sender = ctx.sender();
    if let Some(mut p) = ctx.db.player().identity().find(sender) {
        p.x = x;
        p.y = y;
        ctx.db.player().identity().update(p);
        Ok(())
    } else {
        Err("Player not registered".to_string())
    }
}

#[reducer]
pub fn send_chat(ctx: &ReducerContext, text: String) -> Result<(), String> {
    if text.trim().is_empty() || text.len() > 280 {
        return Err("Message must be 1-280 characters".to_string());
    }
    ctx.db.chat_message().insert(ChatMessage {
        id: 0, // auto_inc assigns the real value
        sender: ctx.sender(),
        text,
        sent_at: ctx.timestamp().to_micros_since_unix_epoch() as u64,
    });
    Ok(())
}

Notice what’s missing compared to a typical Express or Fastify handler: there’s no request parsing, no manual broadcast call, no separate step to tell other clients a move happened. Once move_player commits, SpacetimeDB’s subscription engine diffs the table and streams the row update to every client subscribed to the player table. Validation happens the same way it would in any backend, by returning an Err and letting the caller handle the rejection.

Step 5: Add a Scheduled Reducer for Game Ticks

Real-time games need logic that runs on a clock, not just in response to player input: regenerating health, expiring buffs, checking win conditions. SpacetimeDB supports scheduled reducers backed by a dedicated schedule table. Add a simple health-regeneration tick that runs once per second:

use spacetimedb::TimeDuration;

#[table(name = tick_schedule, scheduled(regen_tick))]
pub struct TickSchedule {
    #[primary_key]
    #[auto_inc]
    scheduled_id: u64,
    scheduled_at: spacetimedb::ScheduleAt,
}

#[reducer]
pub fn regen_tick(ctx: &ReducerContext, _arg: TickSchedule) -> Result<(), String> {
    for mut p in ctx.db.player().iter() {
        if p.online && p.health < 100 {
            p.health = (p.health + 2).min(100);
            ctx.db.player().identity().update(p);
        }
    }
    Ok(())
}

#[reducer(init)]
pub fn init(ctx: &ReducerContext) {
    ctx.db.tick_schedule().insert(TickSchedule {
        scheduled_id: 0,
        scheduled_at: TimeDuration::from_micros(1_000_000).into(),
    });
}

The #[reducer(init)] function runs exactly once, the first time the module is published, and seeds the schedule row. From then on, SpacetimeDB itself calls regen_tick on the interval you configured, without any external cron job, sidecar, or worker queue. This is the piece that would otherwise require a separate scheduled Lambda or a BullMQ worker backed by Redis in a conventional stack.

Step 6: Run and Publish Your Module Locally

Start a local SpacetimeDB instance in a separate terminal tab:

spacetime start

Leave that running, then publish your module against it from the project root:

spacetime publish --server local --project-path server arena-backend

A clean publish prints a build summary and the module's identity hash. Expect output similar to this:

Build finished successfully.
Uploading to local => http://127.0.0.1:3000
Created new database with name: arena-backend, identity: c200a2...
Publishing module for identity c200a2...
Module publish finished successfully.

Tail the logs from another terminal to watch reducer calls as you test them:

spacetime logs --server local arena-backend -f

You can call a reducer directly from the CLI before writing any client code at all, which is the fastest way to sanity-check your logic:

spacetime call --server local arena-backend send_chat 'testing from the cli'
spacetime sql --server local arena-backend "SELECT * FROM chat_message"

That spacetime sql command matters: even though there's no separate database server to connect to, you can run ad hoc SQL queries straight against your live module state for debugging.

Step 7: Generate Client Bindings for TypeScript

SpacetimeDB doesn't ask you to hand-write a REST client or a WebSocket protocol handler. Instead it generates typed bindings directly from your published module. Create a client project and generate the bindings into it:

mkdir client && cd client
npm init -y
npm install @clockworklabs/spacetimedb-sdk

spacetime generate --lang typescript \
  --out-dir src/module_bindings \
  --project-path ../server

This produces TypeScript classes for Player and ChatMessage, plus typed reducer-call functions like movePlayer(x, y) and sendChat(text). Whenever you change a table or reducer signature on the server, you re-run spacetime generate and the client types update automatically, which removes an entire category of bug where the client and server silently drift out of sync. If your client is bundled with Vite rather than a plain ts-node script, the generated bindings drop straight into a standard TypeScript project without any special loader configuration; see our Vite vs. Next.js comparison if you're deciding which frontend tooling to pair with a real-time backend like this one.

Step 8: Connect a Client and Subscribe to Live State

With bindings generated, connect to your local module and subscribe to the tables your UI needs. Create src/index.ts:

import { DbConnection } from './module_bindings';

const conn = DbConnection.builder()
  .withUri('ws://127.0.0.1:3000')
  .withModuleName('arena-backend')
  .onConnect((connection, identity) => {
    console.log('connected as', identity.toHexString());
    connection
      .subscriptionBuilder()
      .onApplied(() => console.log('subscription applied'))
      .subscribe(['SELECT * FROM player', 'SELECT * FROM chat_message']);
  })
  .onConnectError((_conn, err) => console.error('connect error', err))
  .build();

conn.db.player.onInsert((_ctx, row) => {
  console.log('player joined:', row.name, row.x, row.y);
});

conn.db.player.onUpdate((_ctx, oldRow, newRow) => {
  console.log(`${newRow.name} moved to (${newRow.x}, ${newRow.y})`);
});

conn.db.chatMessage.onInsert((_ctx, row) => {
  console.log('chat:', row.text);
});

The onInsert and onUpdate callbacks fire in real time as any player's reducer calls change the table, whether that player is you or someone else connected to the same module. There's no polling interval to tune and no separate event bus to wire up: the subscription itself is the real-time channel.

Step 9: Call Reducers and Handle Errors From the Client

Trigger server-side logic by calling the generated reducer functions. Wire up basic keyboard movement and a chat input:

function handleMove(dx: number, dy: number) {
  const self = [...conn.db.player.iter()].find(
    p => p.identity.isEqual(conn.identity)
  );
  if (!self) return;
  conn.reducers.movePlayer(self.x + dx, self.y + dy);
}

conn.reducers.onMovePlayer((ctx, x, y) => {
  if (ctx.event.status.tag === 'Failed') {
    console.warn('move rejected:', ctx.event.status.value);
  }
});

function handleChatSubmit(text: string) {
  conn.reducers.sendChat(text);
}

Reducer calls are fire-and-forget from the client's perspective, but every call produces an event you can inspect through the reducer's status callback, which is how you surface validation errors (like the 280-character chat limit from Step 4) back to the player without a separate error-response protocol.

Step 10: Deploy to SpacetimeDB Maincloud

Once the local version behaves correctly, publish the same module to Maincloud, Clockwork Labs' managed hosting service, instead of your local instance. Log in first:

spacetime login
spacetime publish --project-path server arena-backend

Omitting --server local targets Maincloud by default once you're logged in. Update your client's withUri call from ws://127.0.0.1:3000 to the Maincloud endpoint printed after publish (it follows the pattern wss://maincloud.spacetimedb.com), and your existing client code works unchanged since the subscription and reducer-call API is identical between local and cloud deployments.

According to SpacetimeDB's current pricing page, the Maincloud Free tier costs $0 a month and includes a 2,500 TeV energy credit, which the company estimates at roughly 3 million reducer calls per month, along with up to 20 projects, 5 databases per project, and unlimited monthly active users. That's enough headroom to run this tutorial's arena backend and a small playtest group without paying anything.

Step 11: Lock Down Access With Row-Level Security and Auth

A public table is visible to any connected client by default, which is fine for a prototype but wrong for anything shipping. Two changes tighten this up. First, mark tables that shouldn't be broadcast to everyone as private, and expose only the rows a given player needs through a dedicated reducer or view instead of a blanket subscription. Second, use ctx.sender() as the source of truth for identity rather than trusting any player-supplied ID field, exactly as the move_player reducer already does in Step 4 by looking up the row via ctx.sender() instead of an argument.

For authentication itself, SpacetimeDB issues each connecting client a persistent Identity, and clients typically pair that with an OpenID Connect token (from a provider like Google or Discord's OAuth flow) to bind a durable game account to that identity, rather than treating the raw Identity as a login system on its own. Store any sensitive per-player fields (inventory contents, purchase history, hidden stats used for anti-cheat) in tables that are not marked public, and only ever mutate them from reducers that verify ctx.sender() against the row being changed.

How SpacetimeDB Handles Identity, Sessions, and Reconnects

One question that trips up developers coming from a traditional stack: what happens when a player's WiFi drops mid-match? In a hand-rolled Node.js and Redis setup, you'd typically write your own session-timeout logic, tracking last-seen timestamps and evicting stale connections on a timer. SpacetimeDB handles the connection lifecycle itself. Every client gets a persistent Identity value the first time it connects, generated from its credentials, and that same Identity is reused on every subsequent reconnect as long as the client holds onto its local credentials file (the TypeScript SDK stores this automatically between runs).

Practically, that means your player row keyed by Identity in Step 3 survives a dropped connection without any extra code. When a client reconnects, it calls the same subscription query it used before, and SpacetimeDB replays the current table state so the client's local view catches up immediately rather than waiting for the next incremental update. What your module logic does need to handle explicitly is the online/offline flag: add an `on_connect` and `on_disconnect` reducer pair that flips the `online` field on the Player table, since SpacetimeDB tells your module when a client's connection opens and closes but won't infer game-specific state like "this player should now respawn at their last checkpoint" on your behalf.

#[reducer(client_connected)]
pub fn on_connect(ctx: &ReducerContext) {
    if let Some(mut p) = ctx.db.player().identity().find(ctx.sender()) {
        p.online = true;
        ctx.db.player().identity().update(p);
    }
}

#[reducer(client_disconnected)]
pub fn on_disconnect(ctx: &ReducerContext) {
    if let Some(mut p) = ctx.db.player().identity().find(ctx.sender()) {
        p.online = false;
        ctx.db.player().identity().update(p);
    }
}

This is also why the `regen_tick` reducer from Step 5 checks `p.online` before healing a player: without that check, a disconnected player sitting in the table would keep regenerating health while genuinely offline, which is a bug you'd catch in playtesting but is worth avoiding from the start.

When SpacetimeDB Is (and Isn't) the Right Call

SpacetimeDB is a strong fit for synchronous multiplayer where a lot of players need to see the same evolving world state at once: arena shooters, social spaces, persistent-world crafting and survival games in the mold of BitCraft Online, and real-time strategy titles where unit positions and resource counts change continuously. The built-in subscription model does the heaviest lifting exactly where a conventional stack usually needs the most custom code.

It's a weaker fit for a few specific cases. If your game is turn-based with long gaps between moves and low concurrency, the operational simplicity of a plain REST API backed by a managed SQL database may be less work overall than learning a new reducer-based programming model. If you need dedicated, isolated game server processes per match for anti-cheat or third-party engine licensing reasons, a session-based hosting platform like Amazon GameLift, which our GameLift deployment tutorial covers, or a Kubernetes-native option like Agones on Kubernetes, is closer to what studios already use for that pattern. And if your team's existing infrastructure is deeply invested in a specific cloud provider's ecosystem, such as an EKS cluster already set up with autoscaling as described in our EKS Auto Mode walkthrough, the migration cost of moving state and logic into SpacetimeDB's model is a real factor to weigh against the sync benefits.

Step 12: Load-Test and Watch Your Energy Budget

Before you invite real players, simulate load. Write a small script that spins up 50-100 concurrent client connections, has each one call move_player on an interval, and watches for dropped connections or reducer errors in your spacetime logs output. Because Maincloud bills by energy consumption rather than by provisioned server hours, the load test also tells you your real per-player cost, which you can't get from reading the pricing page alone.

Check consumption directly from the CLI:

spacetime describe --project-path server arena-backend
spacetime logs arena-backend --num-lines 200

Watch specifically for reducers that scan every row of a large table on every call (like a naive leaderboard query), since bytes-scanned is one of the metered resources in Maincloud's energy model and an unbounded scan in a hot-path reducer is the single most common way a testing budget gets burned faster than expected.

SpacetimeDB vs. Node.js + Redis + Postgres: What Actually Changes

It's worth being concrete about what you gain and give up compared to assembling your own stack.

AspectSpacetimeDBNode.js + Redis + Postgres
Data + logic locationTables and reducers live together in one moduleSplit across app server code, cache layer, and SQL database
Real-time syncAutomatic via table subscriptionsHand-rolled WebSocket/Socket.IO broadcast logic
Server languagesRust, C#, TypeScript, C++ (one module type)Usually one app language plus separate DB/cache config
Scheduled jobsBuilt-in scheduled reducersExternal cron, BullMQ, or a serverless scheduler
Billing modelUsage-based energy (TeV/eV): rows, storage, bandwidth, computeProvisioned compute plus separate DB and cache instance costs
Client type safetyGenerated bindings from the module schemaManual DTOs or a separate schema-sharing tool
Operational surfaceOne deployable artifact (the module)App server, cache, database, and message broker to patch and monitor separately

The trade-off cuts both ways. A conventional stack gives you the freedom to swap Postgres for a different SQL engine, replace Redis with a different cache, or scale each tier independently, and it plugs into a much larger pool of hosting providers and ops tooling your team may already know. SpacetimeDB trades that flexibility for an integrated runtime where the real-time sync problem, which is usually the hardest part of a multiplayer backend, is handled by the platform instead of your team.

Maincloud Pricing Tiers at a Glance

TierMonthly CostEnergy CreditBest For
Free$02,500 TeV (~3M reducer calls)Prototypes, tutorials, side projects
Pro$25100,000 TeV (~120M reducer calls)Small live games, early access launches
Team$250250,000 TeV (~625M reducer calls)Studios running production titles
EnterpriseCustomCustomLarge-scale or bring-your-own-cloud deployments

Two pricing details are worth flagging for anyone budgeting a project. According to SpacetimeDB's pricing announcement, table storage cost was cut from $10 per GB per month to $1 per GB per month in a December 2025 pricing overhaul, and energy-based billing on Maincloud only started counting toward paid invoices from January 2026 onward, meaning the platform's current cost model is still relatively new and worth re-checking against the live pricing page before you commit a production budget to it.

Common Pitfalls When Building Your First SpacetimeDB Game

These are the mistakes that show up most often in a first SpacetimeDB project, roughly in the order you're likely to hit them.

  1. Trusting client-supplied identity instead of ctx.sender(). If a reducer takes a player ID as an argument and uses it to look up which row to modify, any client can pass someone else's ID. Always resolve the acting player from ctx.sender().
  2. Forgetting to re-run spacetime generate after changing a table. The TypeScript bindings don't auto-refresh. A schema change on the server without regenerating bindings produces confusing runtime type mismatches on the client, not a compile error.
  3. Marking every table public by default. It's the path of least resistance during prototyping, but it means every connected client can subscribe to and read the full contents of tables that should be private, like hidden matchmaking ratings or inventory data.
  4. Writing reducers that scan an entire table on every call. A for p in ctx.db.player().iter() loop is fine for a regen tick running once a second on a few hundred players, but the same pattern inside a per-move reducer called dozens of times a second per player will both slow down and burn through your energy budget quickly.
  5. Not handling the onConnectError and reducer-failure callbacks. Because reducer calls are fire-and-forget from the client, a rejected call (like the chat length validation from Step 4) fails silently unless you explicitly subscribe to the reducer's status event.
  6. Running the load test against Maincloud instead of the local server. Spinning up 100 test connections against your production Maincloud deployment burns real energy credit for no reason. Do load testing against spacetime start locally first.
  7. Skipping the #[reducer(init)] step for scheduled reducers. If you add a scheduled reducer table but never insert the first schedule row, the tick simply never fires and there's no error message telling you why.

Troubleshooting: Errors You'll Hit and How to Fix Them

These are the specific errors and symptoms most likely to come up while working through this tutorial, and what actually fixes each one.

  • "spacetime: command not found" after installation. The installer added the binary to a PATH entry your current shell session hasn't loaded yet. Close and reopen your terminal, or manually source your shell profile.
  • "Failed to connect to local instance" when publishing. spacetime start isn't running in another terminal, or it crashed. Check that terminal for a stack trace and restart it.
  • Publish succeeds but the client's onConnectError fires immediately. The most common cause is a mismatched module name between what you passed to spacetime publish and what your client's .withModuleName() call uses. They must match exactly, including case.
  • Table updates never reach the client despite a successful reducer call. You published the module but forgot to call .subscribe([...]) with the right SQL query, or the subscription query doesn't match the table name. Double-check the exact table name against your #[table(name = ...)] declaration.
  • TypeScript compiler errors referencing missing fields after a server change. Stale generated bindings. Re-run the spacetime generate command from Step 7 after every table or reducer signature change.
  • "Reducer call failed: Player not registered" when calling move_player. You're calling a reducer before the corresponding player row exists. Add a register_player reducer called once on connect, before any movement calls.
  • Scheduled reducer never fires. Either the init reducer never ran (common after a manual re-publish that skips migration of scheduled rows) or the scheduled_at interval was set in the wrong unit. TimeDuration::from_micros expects microseconds, not milliseconds.
  • Maincloud login hangs or fails silently. spacetime login opens a browser-based auth flow; if you're on a headless machine or SSH session, it can't complete. Run the login step on a machine with a browser available, then copy the resulting credentials file to your target machine.
  • Energy usage climbing faster than expected in testing. Check for reducers doing full-table scans on a hot path, as flagged in the pitfalls list above, and confirm your load test is pointed at local and not Maincloud.
  • Row updates arrive out of order on the client. This usually means two reducers are racing to update the same row from different code paths. Consolidate the mutation into a single reducer rather than calling two reducers back-to-back from the client for what should be one atomic change.

Advanced Tips for Scaling Past Your Prototype

Once the basic loop works, a few patterns make the difference between a demo and something you'd actually ship.

Split subscriptions by relevance instead of subscribing every client to the entire player table. In an arena with hundreds of concurrent players spread across multiple game rooms, a client only needs rows for players in its own room. Add a room_id column and scope your subscription SQL query to SELECT * FROM player WHERE room_id = X, which cuts both client-side rendering work and the bandwidth SpacetimeDB has to push per connection.

Use indexes on any column you filter or join on frequently. SpacetimeDB supports index annotations directly on table fields, and an unindexed filter on a table with tens of thousands of rows is the most common source of a reducer that works fine in testing and then slows down noticeably once real player data accumulates.

Keep reducers small and single-purpose rather than writing one large reducer that handles multiple unrelated state changes. Because every reducer call is a metered, atomic transaction, splitting logic into focused reducers makes both your energy accounting and your debugging output easier to reason about, and reduces the blast radius if one code path has a bug.

For teams building on Unity or Unreal instead of a web client, generate C# or C++ (unrealcpp) bindings instead of TypeScript using the same spacetime generate --lang command from Step 7, pointing the --out-dir at your engine project's script or plugin folder. The server module and its tables don't change at all between a web client and a game-engine client, since the wire protocol is identical.

Finally, if you eventually outgrow Maincloud's managed hosting, or need to run inside a specific compliance boundary, the Enterprise tier documented on the pricing page supports bring-your-own-cloud deployment, and because SpacetimeDB itself is open source on GitHub, self-hosting the runtime on your own infrastructure is an option even outside that tier for teams with the operational capacity to run it. Teams already running a general-purpose app platform for other services, such as one of the options in our Render vs. Railway vs. Fly.io comparison, can still run a self-hosted SpacetimeDB node alongside those services rather than treating Maincloud as the only deployment path.

The Complete Working Project

Putting every step together, the finished project has this structure:

arena-backend/
├── server/
│   ├── Cargo.toml
│   └── src/
│       └── lib.rs          # Player + ChatMessage tables, reducers, scheduled tick
└── client/
    ├── package.json
    └── src/
        ├── module_bindings/ # generated by `spacetime generate`
        └── index.ts         # connection, subscriptions, reducer calls

Running it end to end is three commands once both halves are written: spacetime start in one terminal, spacetime publish --server local --project-path server arena-backend to push the module, and npm run dev (or ts-node src/index.ts) in the client directory to connect and start receiving live player and chat updates. From here, the natural next additions are a register_player reducer called on connect, a simple HTML canvas renderer that draws each row from the player table subscription, and a room_id field once you're ready to support more than one arena at a time.

Frequently Asked Questions

Is SpacetimeDB free to use?
Yes, for small projects. The Maincloud Free tier costs $0 a month and includes a 2,500 TeV energy credit, which SpacetimeDB estimates at roughly 3 million reducer calls per month, along with up to 20 projects and 5 databases per project. The runtime itself is also open source on GitHub, so self-hosting is free beyond your own infrastructure costs.

Which language should I pick for my server module?
Rust and C++ compile to WebAssembly and suit performance-sensitive logic; C# also compiles to WebAssembly and is the natural choice if your client is built in Unity; TypeScript runs on V8 and fits teams already comfortable with a Node.js-style workflow. All four support the same table and reducer concepts.

Do I need a separate database for player accounts or inventory?
No. Tables inside your SpacetimeDB module serve as your entire persistence layer. There's no separate Postgres or MySQL instance to provision, back up, or patch alongside it.

How does SpacetimeDB handle real-time updates without a WebSocket library?
The generated client SDK maintains a persistent connection under the hood and streams row-level table changes to any client that has an active subscription matching that table, so you write onInsert/onUpdate/onDelete callbacks instead of parsing raw WebSocket messages yourself.

Can I use SpacetimeDB with Unity or Unreal Engine?
Yes. Generate C# bindings for Unity or C++ (unrealcpp) bindings for Unreal using the same spacetime generate command shown in this tutorial, and connect using the corresponding client SDK instead of the TypeScript one.

What happens if I exceed my Maincloud Free tier energy credit?
Consult the current pricing page for the exact behavior at the time you're reading this, since SpacetimeDB's billing model changed materially in a December 2025 overhaul and is still evolving; as a rule, budget a test pass against the Pro tier's usage-based billing before assuming Free-tier limits will cover a real playtest with outside users.

Is SpacetimeDB production-ready for a real multiplayer game?
Clockwork Labs' own MMORPG, BitCraft Online, runs its full backend, including chat, terrain, and player state for thousands of concurrent players, on a single SpacetimeDB module, which is the strongest available evidence that the platform holds up under real production load rather than just tutorial-scale traffic.

How is this different from Amazon GameLift or Azure PlayFab?
GameLift and PlayFab are managed hosting and matchmaking layers that sit in front of a server you still write yourself, typically backed by a separate database. SpacetimeDB collapses the server and database into one deployable module with built-in real-time sync, trading some of the flexibility of a traditional cloud stack for a simpler, more integrated runtime.

Related Coverage

Nadia Dubois

Nadia Dubois

AI & Innovation Editor

Nadia Dubois is the AI & Innovation Editor at Tech Insider, where she tracks the rapid evolution of artificial intelligence, from foundation models to real-world enterprise deployment. She previously covered AI and startups for La Tribune and contributed to MIT Technology Review's European coverage. Nadia specializes in generative AI, AI regulation, and the intersection of technology and European industrial policy. She holds a dual degree in Computational Linguistics and Journalism from Sciences Po Paris.

View all articles