Skip to content

Creating a Game Module

Step-by-step guide to building a new QS-Bridge game module for any supported engine.


Prerequisites

Before creating a game module, ensure you have:

  • Rust toolchain (stable, with wasm32-unknown-unknown target)
  • SpacetimeDB CLI (spacetime command)
  • C++17 compiler (GCC 11+ or Clang 14+) for bridge plugins
  • The target game's dedicated server binary (Linux)
  • Access to a running SpacetimeDB instance
# Install WASM target for SpacetimeDB modules
rustup target add wasm32-unknown-unknown

# Verify SpacetimeDB CLI
spacetime version

Step 1 — Scaffold the STDB Module

Create a new Rust crate for your game's SpacetimeDB module:

cargo new --lib my-game-module
cd my-game-module

Cargo.toml:

[package]
name = "my-game-module"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
spacetimedb = "1.1"
qs-bridge-module-sdk = { path = "../qs-bridge-module-sdk" }
log = "0.4"

Step 2 — Implement the GameModule Trait

src/lib.rs:

use qs_bridge_module_sdk::prelude::*;
use spacetimedb::{spacetimedb, ReducerContext, Table, Timestamp};

// ---------- Tables ----------

#[spacetimedb::table(name = player_state, public)]
pub struct PlayerState {
    #[primary_key]
    pub player_id: String,
    pub server_id: String,
    pub x: f32,
    pub y: f32,
    pub z: f32,
    pub health: f32,
    pub last_update: Timestamp,
}

// Add more game-specific tables as needed...

// ---------- Module Registration ----------

#[qs_game_module]
struct MyGame;

impl GameModule for MyGame {
    const MODULE_NAME: &'static str = "my-game";

    fn on_player_join(ctx: &ReducerContext, player_id: &str, server_id: &str) {
        // Create initial player state
        PlayerState::insert(PlayerState {
            player_id: player_id.to_string(),
            server_id: server_id.to_string(),
            x: 0.0, y: 0.0, z: 0.0,
            health: 100.0,
            last_update: Timestamp::now(),
        }).unwrap();

        log::info!("Player {} joined server {}", player_id, server_id);

        // Report updated count to platform
        let count = PlayerState::iter()
            .filter(|p| p.server_id == server_id)
            .count();
        bridge::report_online_count(ctx, server_id, count as u32);
    }

    fn on_player_leave(ctx: &ReducerContext, player_id: &str) {
        // Clean up player state
        if let Some(player) = PlayerState::filter_by_player_id(player_id) {
            let server_id = player.server_id.clone();
            PlayerState::delete_by_player_id(player_id);

            let count = PlayerState::iter()
                .filter(|p| p.server_id == server_id)
                .count();
            bridge::report_online_count(ctx, &server_id, count as u32);
        }
    }

    fn on_heartbeat(ctx: &ReducerContext, server_id: &str) {
        // Periodic maintenance
        let count = PlayerState::iter()
            .filter(|p| p.server_id == server_id)
            .count();
        bridge::report_online_count(ctx, server_id, count as u32);
        bridge::report_server_status(ctx, server_id, ServerStatus::Running);
    }
}

Step 3 — Build and Publish

# Build the WASM module
spacetime build

# Publish to your SpacetimeDB instance
spacetime publish my-game-module --server your-stdb-host

Step 4 — Create the Bridge Plugin (C++)

The bridge plugin runs inside the game server process. You need an engine adapter for your target engine.

Supported Engine Adapters

Engine Adapter Method Status
UE4 4.27 engine-ue4 vtable hooking, LD_PRELOAD ✅ Production
UE5 engine-ue5 vtable hooking, LD_PRELOAD 🔜 Planned
Unity (IL2CPP) engine-unity IL2CPP function hooking 🔜 Planned
Godot engine-godot GDExtension API 🔜 Planned

Bridge Hook Structure

// my_game_hooks.h
#include <qs-bridge/game_interface.h>

class MyGameModule : public GameInterface {
public:
    const char* module_name() override { return "my-game"; }

    void on_tick(float delta_time) override {
        // Called every engine tick
        // Read game state from memory, send to STDB
    }

    void on_rpc(const char* function_name, const void* params) override {
        // Called when game server receives an RPC
        // Parse params, call appropriate STDB reducer
    }

    void register_hooks(EngineAdapter* adapter) override {
        // Register which engine functions to intercept
        adapter->hook_function("APlayerController::ServerMove", &on_player_move);
        adapter->hook_function("AGameMode::PostLogin", &on_player_login);
    }
};

Step 5 — Configure the Bridge

Create a configuration file for your module:

{
    "module_name": "my-game",
    "stdb_host": "ws://localhost:3000",
    "stdb_module": "my-game-module",
    "server_id": "server-01",
    "heartbeat_interval_s": 30,
    "worker_threads": {
        "ai": { "frequency_hz": 2 },
        "world": { "frequency_hz": 2 }
    }
}

Step 6 — Test Locally

# Start SpacetimeDB
spacetime start

# Publish the STDB module
spacetime publish my-game-module

# Start game server with bridge
LD_PRELOAD=/path/to/libqsbridge.so ./GameServer-Linux-Shipping

# Verify registration
spacetime sql my-game-module "SELECT * FROM player_state"

Step 7 — Add Game-Specific Tables

Design your tables based on what state needs to be persistent and queryable:

Category Guideline
Player State Positions, vitals, inventory — updated frequently, queried by server_id
World State Buildings, vehicles, containers — updated on interaction, persistent across restarts
AI State Zombie/NPC positions, brain state — high frequency, can be reconstructed
Social Factions, chat, mail — low frequency, long retention
Events Combat logs, deaths, security — append-only, used for analytics

Table Design Best Practices

  1. Always include server_id for per-server tables
  2. Use #[primary_key] on every table — SpacetimeDB requires it
  3. Prefer flat schemas — JSON blobs for game-specific data the platform doesn't need to query
  4. Separate hot and cold data — frequently-updated position data in one table, rarely-changed config in another
  5. Use SpacetimeDB enums for type-safe state machines (e.g., PlayerStatus::Alive)

Step 8 — Add Reducers

#[spacetimedb::reducer]
fn update_position(ctx: &ReducerContext, player_id: String, x: f32, y: f32, z: f32) {
    if let Some(mut player) = PlayerState::filter_by_player_id(&player_id) {
        player.x = x;
        player.y = y;
        player.z = z;
        player.last_update = Timestamp::now();
        PlayerState::update_by_player_id(&player_id, player);
    }
}

Step 9 — Publish as a Mod

Once your module is tested:

  1. Build the .so file and WASM module
  2. Create a mod manifest (see Mod Manifest)
  3. Upload to the mod registry
  4. Document on wiki.qs-zuq.com

Server operators can then install your module via the admin panel's mod management interface.

Module Checklist

  • GameModule trait implemented with all required callbacks
  • STDB module builds and publishes successfully
  • Bridge plugin compiles and loads via LD_PRELOAD
  • Player join/leave properly tracked
  • Heartbeat updates server status and player count
  • Tables designed for multi-server (include server_id where needed)
  • Unit tests for all reducers (aim for >80% coverage)
  • Integration test: full server → bridge → STDB → panel flow
  • Documentation in wiki