Introduction: Bringing Games to Life with SpaceTimeDB
Welcome to Chapter 14! In this exciting chapter, we’re going to put all our SpaceTimeDB knowledge to the test by building a simple, yet engaging, real-time multiplayer game. Imagine a canvas where multiple players can move their unique cursors or avatars around, and everyone sees everyone else’s movements instantly. That’s the magic we’re aiming for!
This project is more than just a game; it’s a practical demonstration of how SpaceTimeDB’s core strengths—its unified database and backend logic, real-time synchronization, and deterministic reducers—make it an ideal platform for collaborative and interactive applications. By the end of this chapter, you’ll have a clear understanding of how to manage shared game state, process player actions, and update the game world in real-time across all connected clients.
Before we dive in, make sure you’re comfortable with the SpaceTimeDB CLI, defining schemas, writing reducers, and connecting a basic JavaScript client from previous chapters. We’ll be building on those foundations to create our multiplayer masterpiece!
Core Concepts for Multiplayer Games
Building a multiplayer game with SpaceTimeDB feels remarkably intuitive because its architecture naturally aligns with game development needs. Let’s break down the key concepts we’ll leverage.
Game State Management with SpaceTimeDB
In a multiplayer game, the “game state” is everything that describes the current situation of the game: player positions, scores, item locations, and so on. The challenge is keeping this state consistent and synchronized across all players and the server.
SpaceTimeDB excels here. It acts as the single source of truth for your entire game state. When a player makes a move, it’s not just updating their local screen; it’s submitting a “transaction” (a reducer call) to SpaceTimeDB. SpaceTimeDB processes this, updates its internal state, and then automatically pushes those changes out to all subscribed clients. This real-time, consistent shared state is the bedrock of our multiplayer experience.
Player Representation: The Player Table
Every player in our game needs to be represented in our database. We’ll create a Player table to store essential information for each active participant. What kind of information might a player need? At a minimum, an ID, a name, and their position on the game board. We might also want a color to distinguish them visually.
Game Actions as Reducers
How do players interact with the game? By performing actions! Moving their avatar, clicking a button, picking up an item—these are all actions. In SpaceTimeDB, these actions map perfectly to reducers.
A reducer takes the current game state and an action’s parameters, then produces a new game state. Because reducers are deterministic and executed on the SpaceTimeDB server, they ensure that every client’s view of the game state remains consistent. If two players try to move at the same time, SpaceTimeDB handles the order of operations, and all clients see the final, consistent result.
Real-time UI Updates
Once SpaceTimeDB updates its state based on a reducer, how do clients know to update their screens? Through subscriptions! Our frontend client will subscribe to the Player table. Any time a player’s position changes (because another player moved them via a reducer), SpaceTimeDB pushes that update to our client. Our client then receives this data and re-renders the game world to reflect the latest state. This push-based, event-driven synchronization is key to a smooth real-time experience.
Client-Side Framework Choice
For this simple example, we’ll use plain HTML, CSS, and JavaScript. This keeps the focus squarely on SpaceTimeDB’s integration without introducing the complexities of a frontend framework like React or Vue. However, the principles we learn here apply directly to any frontend framework you might choose for a more complex game.
Architectural Flow
Let’s visualize the flow of data and actions in our multiplayer game:
- Client A takes user input (e.g., mouse movement).
- It calls a SpaceTimeDB reducer (e.g.,
move_player) with new coordinates. - The SpaceTimeDB Backend receives the reducer call, executes the
move_playerlogic, and updates thePlayertable in its database core. - Upon a state change, SpaceTimeDB’s synchronization layer automatically pushes these updates to all connected clients, including Client A and Client B.
- Clients A and B receive the updates and re-render their game UI to reflect the new player positions.
Step-by-Step Implementation: Building Our Multiplayer Cursor Game
Let’s get our hands dirty and build this!
Step 1: Initialize Your SpaceTimeDB Project
First, ensure you have the SpaceTimeDB CLI installed. As of 2026-03-14, we’re using SpaceTimeDB CLI v2.x.
Open your terminal and create a new project:
stdb new multiplayer-game
cd multiplayer-game
This command creates a new directory multiplayer-game with the basic SpaceTimeDB project structure.
Step 2: Define the Game Schema
Now, let’s define our Player table. Open stdb/schema.stdb and add the following:
// stdb/schema.stdb
// Define the Player table to store information about each connected player.
// `#[primarykey]` ensures each player has a unique ID.
// `#[autoinc]` automatically assigns a new ID for new players.
#[primarykey(id)]
#[autoinc(id)]
table Player {
id: u64, // Unique identifier for the player
name: String, // Player's display name
x: f32, // X-coordinate on the game board
y: f32, // Y-coordinate on the game board
color: String, // A color string (e.g., "#RRGGBB") for their avatar
}
Explanation:
#[primarykey(id)]: Designatesidas the primary key, ensuring uniqueness and efficient lookups.#[autoinc(id)]: SpaceTimeDB will automatically assign a new, incrementingu64value toidwhen a newPlayerrow is inserted without specifying anid. This is incredibly useful for new players joining.table Player { ... }: Defines ourPlayertable with fields forid,name,x(position),y(position), andcolor. We usef32for coordinates to allow for fractional positions, which can make movement smoother.
Step 3: Implement Reducers for Player Actions
Next, we’ll create a module for our player-related reducers. Create a new file stdb/modules/player_actions.stdb:
// stdb/modules/player_actions.stdb
// Import the Player table definition from our schema.
use crate::Player;
// Define a reducer to create a new player when they join the game.
#[reducer]
fn create_player(ctx: ReducerContext, name: String, initial_x: f32, initial_y: f32, color: String) -> Player {
// Insert a new row into the Player table.
// The 'id' field will be automatically assigned due to #[autoinc] in the schema.
Player::insert(Player {
id: 0, // Placeholder, will be ignored due to #[autoinc]
name,
x: initial_x,
y: initial_y,
color,
})
}
// Define a reducer to update a player's position.
#[reducer]
fn move_player(ctx: ReducerContext, player_id: u64, new_x: f32, new_y: f32) {
// Find the player by their ID.
// `Player::filter_by_id()` returns an iterator. `.next()` gets the first match.
// `.expect()` will panic if the player is not found, which is okay for this simple example.
let mut player = Player::filter_by_id(player_id).next().expect("Player not found.");
// Update the player's x and y coordinates.
player.x = new_x;
player.y = new_y;
// Save the updated player back to the table.
player.update();
}
Explanation:
use crate::Player;: Imports ourPlayertable definition so we can interact with it.#[reducer] fn create_player(...): This reducer handles adding new players.- It takes
ReducerContext(standard for all reducers),name,initial_x,initial_y, andcolor. Player::insert(...): Creates a newPlayerentry. We passid: 0as a placeholder because#[autoinc]will provide the actual ID.- It returns the newly created
Playerobject.
- It takes
#[reducer] fn move_player(...): This reducer updates a player’s position.- It takes the
player_idand thenew_x,new_ycoordinates. Player::filter_by_id(player_id).next().expect(...): This is how we retrieve a specific player by their primary key.player.x = new_x; player.y = new_y;: We modify the retrievedplayerobject.player.update();: This persists the changes back to thePlayertable. Withoutupdate(), the changes would not be saved.
- It takes the
Step 4: Run SpaceTimeDB and Generate Client Libraries
Now that our schema and reducers are defined, let’s compile our SpaceTimeDB module and start the local server.
First, from your project root, generate the client libraries (including JavaScript for our frontend):
stdb generate
This command compiles your Rust modules and generates client-side code in the target/ directory. You’ll find a JavaScript client in target/multiplayer-game.js.
Next, run the SpaceTimeDB development server:
stdb run
Keep this terminal window open. Your SpaceTimeDB instance is now running and listening for client connections, typically on ws://localhost:9000.
Step 5: Build the Frontend (HTML & JavaScript)
Let’s create a simple HTML page and a JavaScript file to connect to SpaceTimeDB and visualize our game.
Create index.html in the root of your multiplayer-game directory:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multiplayer Cursor Game</title>
<style>
body { margin: 0; overflow: hidden; background-color: #222; font-family: sans-serif; }
#game-area {
position: relative;
width: 100vw;
height: 100vh;
cursor: none; /* Hide default cursor */
}
.player-cursor {
position: absolute;
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid white;
box-sizing: border-box;
transform: translate(-50%, -50%); /* Center the cursor on its (x,y) */
transition: transform 0.05s linear; /* Smooth movement */
pointer-events: none; /* Allow mouse events to pass through */
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: white;
text-shadow: 0 0 3px black;
}
</style>
</head>
<body>
<div id="game-area"></div>
<!-- The generated SpacetimeDB client library -->
<script type="module" src="./target/multiplayer-game.js"></script>
<!-- Our game logic -->
<script type="module" src="./game.js"></script>
</body>
</html>
Explanation:
- We define a
#game-areadiv that will serve as our canvas. .player-cursorstyles are for the visual representation of each player.- Crucially, we include
target/multiplayer-game.js(our generated SpaceTimeDB client) andgame.js(our custom game logic). Note thetype="module"for both.
Now, create game.js in the root of your multiplayer-game directory:
// game.js
import { SpacetimeDBClient, multiplayer_game } from './target/multiplayer-game.js';
const gameArea = document.getElementById('game-area');
const SPDB_SERVER_URL = 'ws://localhost:9000'; // SpaceTimeDB server address
let myPlayerId = null;
const activePlayers = new Map(); // Map to store player DOM elements by ID
// Function to generate a random hex color
function getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
// Connect to SpacetimeDB
async function connectToSpacetimeDB() {
console.log(`Connecting to SpaceTimeDB at ${SPDB_SERVER_URL}...`);
await SpacetimeDBClient.connect(SPDB_SERVER_URL);
console.log('Connected to SpaceTimeDB!');
// Generate a random name and color for this player
const playerName = `Player_${Math.floor(Math.random() * 1000)}`;
const playerColor = getRandomColor();
// Call the create_player reducer to add ourselves to the game
// Initial position is center of the screen
multiplayer_game.create_player(playerName, window.innerWidth / 2, window.innerHeight / 2, playerColor)
.then(newPlayer => {
myPlayerId = newPlayer.id; // Store our player ID
console.log(`My player ID is: ${myPlayerId}`);
// We don't need to add our own cursor here; the subscription will handle it.
})
.catch(error => {
console.error('Failed to create player:', error);
});
// Subscribe to the Player table to get real-time updates for all players
SpacetimeDBClient.subscribe([
{ tableName: 'Player' }
]);
// Listen for changes to the Player table
multiplayer_game.onPlayerUpdate((player, oldPlayer) => {
if (player) {
// Player created or updated
let playerElement = activePlayers.get(player.id);
if (!playerElement) {
// New player, create their cursor element
playerElement = document.createElement('div');
playerElement.classList.add('player-cursor');
playerElement.id = `player-${player.id}`;
gameArea.appendChild(playerElement);
activePlayers.set(player.id, playerElement);
}
// Update position and color
playerElement.style.left = `${player.x}px`;
playerElement.style.top = `${player.y}px`;
playerElement.style.backgroundColor = player.color;
playerElement.textContent = player.name;
} else if (oldPlayer) {
// Player deleted (e.g., disconnected or removed)
const playerElement = activePlayers.get(oldPlayer.id);
if (playerElement) {
playerElement.remove();
activePlayers.delete(oldPlayer.id);
}
}
});
// Handle mouse movement to update our player's position
gameArea.addEventListener('mousemove', (event) => {
if (myPlayerId !== null) {
// Call the move_player reducer
multiplayer_game.move_player(myPlayerId, event.clientX, event.clientY);
}
});
}
connectToSpacetimeDB();
// Handle window resize to adjust initial position for new players
window.addEventListener('resize', () => {
if (myPlayerId !== null) {
// Optionally, update own position on resize or just let new players spawn correctly
}
});
Explanation:
- Import Client:
import { SpacetimeDBClient, multiplayer_game } from './target/multiplayer-game.js';imports the necessary client library.multiplayer_gameis the object containing our generated reducer functions. connectToSpacetimeDB():SpacetimeDBClient.connect(SPDB_SERVER_URL): Establishes a WebSocket connection to your running SpaceTimeDB server.multiplayer_game.create_player(...): After connecting, we immediately call ourcreate_playerreducer. This adds our player to thePlayertable in SpaceTimeDB. We store the returnedidasmyPlayerId.SpacetimeDBClient.subscribe([{ tableName: 'Player' }]): This is the magic! We tell SpaceTimeDB we want to receive real-time updates for any changes to thePlayertable.multiplayer_game.onPlayerUpdate((player, oldPlayer) => { ... }): This event listener fires whenever aPlayerrow is inserted, updated, or deleted.- If
playeris present, it’s a new player or an update. We create a newdivif it’s a new player, or update the existing one’s position and color. - If
oldPlayeris present andplayeris null, it means a player was deleted (e.g., disconnected). We remove their cursor element.
- If
gameArea.addEventListener('mousemove', ...): When our mouse moves, we callmultiplayer_game.move_player()with ourmyPlayerIdand the new mouse coordinates. This sends the update to SpaceTimeDB.
Step 6: Test Your Multiplayer Game
- Ensure your
stdb runterminal is still active. - Open
index.htmlin your web browser. You can typically do this by dragging the file into your browser or using a simple local web server (likepython -m http.serverif you have Python installed). - You should see a colored circle representing your player.
- Open
index.htmlin a second browser tab or window. - Voila! You should now see two distinct cursors moving independently, each controlled by a different browser tab. Each tab will see the other player’s movements in real-time.
Congratulations! You’ve just built a real-time multiplayer game with SpaceTimeDB!
Mini-Challenge: Adding a Score Counter
Let’s make our game a bit more interactive. How about adding a score to each player and a way to increment it?
Challenge:
- Update the
PlayerSchema: Add ascore: u32field to thePlayertable, initialized to0. - Create a New Reducer: Implement a reducer called
increment_score(ctx: ReducerContext, player_id: u64)that takes a player’s ID and increments theirscoreby 1. - Modify the Frontend:
- Display the
scorenext to the player’s name in their cursor element. - Add an event listener (e.g., a click event on the
gameAreaor a specific key press) that, when triggered, calls your newincrement_scorereducer for your own player.
- Display the
Hint:
- Remember to run
stdb generateand restartstdb runafter changingstdb/schema.stdbandstdb/modules/player_actions.stdb. - The
onPlayerUpdatecallback ingame.jswill automatically give you the updatedPlayerobject with the newscorevalue.
What to Observe/Learn:
- How seamlessly SpaceTimeDB handles schema evolution and new reducer logic.
- The instant propagation of score updates to all connected clients, demonstrating shared state beyond just position.
Common Pitfalls & Troubleshooting
Even simple projects can hit snags. Here are a few common issues and how to resolve them:
SpaceTimeDB Server Not Running or Not Accessible:
- Symptom: Your browser console shows “WebSocket connection failed” or “Connection refused”.
- Fix: Ensure you have
stdb runactively running in a terminal. Check the port (default is 9000). If you changed it, updateSPDB_SERVER_URLingame.js. Firewall issues could also block the connection.
Schema or Reducer Changes Not Reflected:
- Symptom: Reducer calls fail with “reducer not found” errors, or new fields are missing from
Playerobjects. - Fix: Did you run
stdb generateafter modifyingstdb/schema.stdborstdb/modules/player_actions.stdb? And did you restartstdb run? These steps are crucial to recompile and reload your SpaceTimeDB module.
- Symptom: Reducer calls fail with “reducer not found” errors, or new fields are missing from
Client-Side Subscription Issues:
- Symptom: Players can move, but you don’t see other players’ movements, or new players don’t appear.
- Fix: Double-check
SpacetimeDBClient.subscribe([{ tableName: 'Player' }])ingame.js. Is the table name correct? Also, ensuremultiplayer_game.onPlayerUpdateis correctly implemented and processing bothplayer(new/updated) andoldPlayer(deleted) cases.
Frontend Rendering Problems:
- Symptom: Data is coming in, but nothing appears on screen or elements are mispositioned.
- Fix: Use your browser’s developer tools. Inspect the HTML elements for
player-cursordivs. Are they being created? Do theirleftandtopstyles have validpxvalues? Check for JavaScript errors in the console related to DOM manipulation.
Reducer Logic Errors (Non-Deterministic):
- Symptom: Very rare in this simple example, but in more complex games, if a reducer’s outcome depends on external factors (like
Math.random()or current time withoutctx), different SpaceTimeDB replicas could produce different states. - Fix: Always ensure reducers are pure functions of their inputs and the current SpaceTimeDB state. If randomness is needed, generate it once and pass it as a reducer parameter or use SpaceTimeDB’s
ReducerContextfor any time-related operations if available (though typically, time-sensitive game logic is handled by clients or a dedicated game server that then updates SpaceTimeDB).
- Symptom: Very rare in this simple example, but in more complex games, if a reducer’s outcome depends on external factors (like
Summary: Your First Multiplayer Game!
Phew! What an exciting journey. You’ve just built a fully functional, real-time multiplayer game powered by SpaceTimeDB. Let’s recap the key takeaways:
- Unified Backend: SpaceTimeDB seamlessly combines your database and backend game logic into a single, cohesive system.
- Deterministic Reducers: Player actions are transformed into deterministic reducers, ensuring consistent state updates across all clients.
- Real-time Synchronization: Subscriptions to tables like
Playerallow SpaceTimeDB to automatically push state changes to all connected clients, enabling instant updates for everyone. - Simplified Game Development: By abstracting away much of the complex networking and synchronization, SpaceTimeDB lets you focus on game logic.
- Practical Application: This project demonstrated how SpaceTimeDB is perfectly suited for interactive, collaborative, and real-time applications, from games to dashboards.
You now have a solid foundation for building more complex multiplayer experiences. In the next chapter, we’ll delve into more advanced topics like concurrency handling and transactions, which are essential for robust game logic and preventing race conditions.
References
- SpaceTimeDB Official Documentation
- SpaceTimeDB GitHub Repository
- MDN Web Docs: WebSockets
- MDN Web Docs: EventTarget
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.