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:

flowchart LR subgraph Client_A["Client A "] A_UI[Game UI] -->|\1| A_Call[Call Reducer: move_player] end subgraph SpacetimeDB_Backend["SpaceTimeDB Backend"] DB_Core[Database Core] Reducer_Engine[Reducer Engine] Sync_Layer[Sync Layer] A_Call --> Reducer_Engine Reducer_Engine -->|Updates Player Table| DB_Core DB_Core -->|State Change Event| Sync_Layer end subgraph Client_B["Client B "] B_Sync[SpacetimeDB Client Sync] -->|\1| B_UI[Game UI] end Sync_Layer --> B_Sync Sync_Layer --> A_Sync[SpacetimeDB Client Sync] A_Sync -->|\1| A_UI
  • 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_player logic, and updates the Player table 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)]: Designates id as the primary key, ensuring uniqueness and efficient lookups.
  • #[autoinc(id)]: SpaceTimeDB will automatically assign a new, incrementing u64 value to id when a new Player row is inserted without specifying an id. This is incredibly useful for new players joining.
  • table Player { ... }: Defines our Player table with fields for id, name, x (position), y (position), and color. We use f32 for 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 our Player table 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, and color.
    • Player::insert(...): Creates a new Player entry. We pass id: 0 as a placeholder because #[autoinc] will provide the actual ID.
    • It returns the newly created Player object.
  • #[reducer] fn move_player(...): This reducer updates a player’s position.
    • It takes the player_id and the new_x, new_y coordinates.
    • 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 retrieved player object.
    • player.update();: This persists the changes back to the Player table. Without update(), the changes would not be saved.

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-area div that will serve as our canvas.
  • .player-cursor styles are for the visual representation of each player.
  • Crucially, we include target/multiplayer-game.js (our generated SpaceTimeDB client) and game.js (our custom game logic). Note the type="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:

  1. Import Client: import { SpacetimeDBClient, multiplayer_game } from './target/multiplayer-game.js'; imports the necessary client library. multiplayer_game is the object containing our generated reducer functions.
  2. connectToSpacetimeDB():
    • SpacetimeDBClient.connect(SPDB_SERVER_URL): Establishes a WebSocket connection to your running SpaceTimeDB server.
    • multiplayer_game.create_player(...): After connecting, we immediately call our create_player reducer. This adds our player to the Player table in SpaceTimeDB. We store the returned id as myPlayerId.
    • SpacetimeDBClient.subscribe([{ tableName: 'Player' }]): This is the magic! We tell SpaceTimeDB we want to receive real-time updates for any changes to the Player table.
    • multiplayer_game.onPlayerUpdate((player, oldPlayer) => { ... }): This event listener fires whenever a Player row is inserted, updated, or deleted.
      • If player is present, it’s a new player or an update. We create a new div if it’s a new player, or update the existing one’s position and color.
      • If oldPlayer is present and player is null, it means a player was deleted (e.g., disconnected). We remove their cursor element.
    • gameArea.addEventListener('mousemove', ...): When our mouse moves, we call multiplayer_game.move_player() with our myPlayerId and the new mouse coordinates. This sends the update to SpaceTimeDB.

Step 6: Test Your Multiplayer Game

  1. Ensure your stdb run terminal is still active.
  2. Open index.html in your web browser. You can typically do this by dragging the file into your browser or using a simple local web server (like python -m http.server if you have Python installed).
  3. You should see a colored circle representing your player.
  4. Open index.html in a second browser tab or window.
  5. 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:

  1. Update the Player Schema: Add a score: u32 field to the Player table, initialized to 0.
  2. Create a New Reducer: Implement a reducer called increment_score(ctx: ReducerContext, player_id: u64) that takes a player’s ID and increments their score by 1.
  3. Modify the Frontend:
    • Display the score next to the player’s name in their cursor element.
    • Add an event listener (e.g., a click event on the gameArea or a specific key press) that, when triggered, calls your new increment_score reducer for your own player.

Hint:

  • Remember to run stdb generate and restart stdb run after changing stdb/schema.stdb and stdb/modules/player_actions.stdb.
  • The onPlayerUpdate callback in game.js will automatically give you the updated Player object with the new score value.

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:

  1. SpaceTimeDB Server Not Running or Not Accessible:

    • Symptom: Your browser console shows “WebSocket connection failed” or “Connection refused”.
    • Fix: Ensure you have stdb run actively running in a terminal. Check the port (default is 9000). If you changed it, update SPDB_SERVER_URL in game.js. Firewall issues could also block the connection.
  2. Schema or Reducer Changes Not Reflected:

    • Symptom: Reducer calls fail with “reducer not found” errors, or new fields are missing from Player objects.
    • Fix: Did you run stdb generate after modifying stdb/schema.stdb or stdb/modules/player_actions.stdb? And did you restart stdb run? These steps are crucial to recompile and reload your SpaceTimeDB module.
  3. 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' }]) in game.js. Is the table name correct? Also, ensure multiplayer_game.onPlayerUpdate is correctly implemented and processing both player (new/updated) and oldPlayer (deleted) cases.
  4. 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-cursor divs. Are they being created? Do their left and top styles have valid px values? Check for JavaScript errors in the console related to DOM manipulation.
  5. 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 without ctx), 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 ReducerContext for 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).

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 Player allow 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

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.