diff --git a/docs/docs/nav.js b/docs/docs/nav.js index df06bbfb698..575c3efafa4 100644 --- a/docs/docs/nav.js +++ b/docs/docs/nav.js @@ -22,6 +22,12 @@ const nav = { page('2 - Connecting to SpacetimeDB', 'unity/part-2', 'unity/part-2.md'), page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), page('4 - Moving and Colliding', 'unity/part-4', 'unity/part-4.md'), + section('Unreal Tutorial - Basic Multiplayer'), + page('Overview', 'unreal', 'unreal/index.md'), + page('1 - Setup', 'unreal/part-1', 'unreal/part-1.md'), + page('2 - Connecting to SpacetimeDB', 'unreal/part-2', 'unreal/part-2.md'), + page('3 - Gameplay', 'unreal/part-3', 'unreal/part-3.md'), + page('4 - Moving and Colliding', 'unreal/part-4', 'unreal/part-4.md'), section('CLI Reference'), page('CLI Reference', 'cli-reference', 'cli-reference.md'), page( @@ -59,6 +65,7 @@ const nav = { 'sdks/typescript/quickstart.md' ), page('TypeScript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + page('Unreal Reference', 'unreal/reference', 'unreal/reference.md'), section('SQL'), page('SQL Reference', 'sql', 'sql/index.md'), section('Subscriptions'), diff --git a/docs/docs/unity/part-3.md b/docs/docs/unity/part-3.md index 4dfb8e24224..d12ef5ef8de 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/docs/unity/part-3.md @@ -610,7 +610,7 @@ The `OnApplied` callback will be called after the server synchronizes the initia In the scene view, select the `GameManager` object. Click on the `Border Material` property and choose `Sprites-Default`. -### Creating GameObjects +### Creating GameObjects Now that we have our arena all set up, we need to take the row data that SpacetimeDB syncs with our client and use it to create and draw `GameObject`s on the screen. diff --git a/docs/docs/unreal/Circle.png b/docs/docs/unreal/Circle.png new file mode 100644 index 00000000000..a819a94061e Binary files /dev/null and b/docs/docs/unreal/Circle.png differ diff --git a/docs/docs/unreal/index.md b/docs/docs/unreal/index.md new file mode 100644 index 00000000000..e328e7244fb --- /dev/null +++ b/docs/docs/unreal/index.md @@ -0,0 +1,36 @@ +# Unreal Tutorial - Overview + +Need help with the tutorial or CLI commands? [Join our Discord server](https://discord.gg/spacetimedb)! + +In this tutorial you'll learn how to build a small-scoped massive multiplayer online action game in Unreal, from scratch, using SpacetimeDB. Although, the game we're going to build is small in scope, it'll scale to hundreds of players and will help you get acquainted with all the features and best practices of SpacetimeDB, while building [a fun little game](https://github.com/ClockworkLabs/Blackholio). + +By the end, you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. + +The game is inspired by [agar.io](https://agar.io), but SpacetimeDB themed with some fun twists. If you're not familiar [agar.io](https://agar.io), it's a web game in which you and hundreds of other players compete to cultivate mass to become the largest cell in the Petri dish. + +Our game, called [Blackhol.io](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio), will be similar but space themed. It should give you a great idea of the types of games you can develop easily with SpacetimeDB. + +This tutorial assumes that you have a basic understanding of the Unreal Engine, using a command line terminal and programming in C++. We'll give you some CLI commands to execute. If you are using Windows, we recommend using Git Bash or PowerShell. For Mac, we recommend Terminal. + +We’ll keep things intentionally simple: a single “Game Manager” class, minimal error handling, and hardcoded settings where convenient. This makes the SDK flow easy to see. For production, prefer Unreal’s Subsystems, move secrets out of code, follow best practices, and add proper logging/retry. + +SpacetimeDB supports Unreal Engine version `5.6`. This tutorial has been tested only with that version. + +This tutorial is written for C++, but the SpacetimeDB Unreal client SDK also supports Blueprints! Stay tuned for a Blueprint-based tutorial. + +Please file an issue [here](https://github.com/clockworklabs/SpacetimeDB/issues) if you encounter an issue with a specific Unreal version, but please be aware that the SpacetimeDB team is unable to offer support for issues related to versions of Unreal prior to `5.6`. + +## Blackhol.io Tutorial - Basic Multiplayer + +First you'll get started with the core client/server setup. For part 2, you'll be able to choose between [Rust](/docs/modules/rust) or [C#](/docs/modules/c-sharp) for your server module language: + +- [Part 1 - Setup](/docs/unreal/part-1) +- [Part 2 - Connecting to SpacetimeDB](/docs/unreal/part-2) +- [Part 3 - Gameplay](/docs/unreal/part-3) +- [Part 4 - Moving and Colliding](/docs/unreal/part-4) + +## Blackhol.io Tutorial - Advanced + +If you already have a good understanding of the SpacetimeDB client and server, check out our completed tutorial project! + +https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio diff --git a/docs/docs/unreal/part-1-01-create-project.png b/docs/docs/unreal/part-1-01-create-project.png new file mode 100644 index 00000000000..4dcfb180fc9 Binary files /dev/null and b/docs/docs/unreal/part-1-01-create-project.png differ diff --git a/docs/docs/unreal/part-1-02-01-generate-project.png b/docs/docs/unreal/part-1-02-01-generate-project.png new file mode 100644 index 00000000000..ac8e5b0f1cc Binary files /dev/null and b/docs/docs/unreal/part-1-02-01-generate-project.png differ diff --git a/docs/docs/unreal/part-1-02-02-generate-project.png b/docs/docs/unreal/part-1-02-02-generate-project.png new file mode 100644 index 00000000000..4cd53979d86 Binary files /dev/null and b/docs/docs/unreal/part-1-02-02-generate-project.png differ diff --git a/docs/docs/unreal/part-1-03-create-blueprint.png b/docs/docs/unreal/part-1-03-create-blueprint.png new file mode 100644 index 00000000000..49040ae2cfe Binary files /dev/null and b/docs/docs/unreal/part-1-03-create-blueprint.png differ diff --git a/docs/docs/unreal/part-1.md b/docs/docs/unreal/part-1.md new file mode 100644 index 00000000000..db60c9ebd07 --- /dev/null +++ b/docs/docs/unreal/part-1.md @@ -0,0 +1,140 @@ +# Unreal Tutorial - Part 1 - Setup + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +> A completed version of the game we'll create in this tutorial is available at: +> +> https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio + +## Prepare Project Structure + +> **NOTE:** Ensure you have SpacetimeDB version 1.4.0 installed to enable Unreal Engine code generation support. + +This project is separated into two subdirectories; + +1. Server (module) code +2. Client code + +First, we'll create a project root directory (you can choose the name): + +```bash +mkdir blackholio +cd blackholio +``` + +We'll start by populating the client directory. + +## Setting up the Tutorial Unreal Project + +In this section, we will guide you through the process of setting up a Unreal Project that will serve as the starting point for our tutorial. By the end of this section, you will have a basic Unreal project and be ready to implement the server functionality. + +### Step 1: Create a Blank Unreal Project + +SpacetimeDB supports Unreal version `5.6`. See [the overview](.) for more information on specific supported versions. + +Launch Unreal 5.6 and create a new project by selecting Games from the Unreal Project Browser. + +> ⚠️ Important: Select the **Blank** template and in **Project Defaults** select **C++**. + +For **Project Name** use `client_unreal`. +For **Project Location**, use your `blackholio` directory (created in the previous step). + +Click **Create** to generate the blank project. + +![Create Blank Project](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-1-01-create-project.png) + +### Import the SpacetimeDB Unreal SDK + +While the SpacetimeDB Unreal client SDK is in preview releases, it can only be installed from GitHub: + +> https://github.com/clockworklabs/SpacetimeDB/tree/master/sdks/unreal/src + +Once the SDK is stabilized, we'll find a more ergonomic way to distribute it. + +> **Note:** Before beginning make sure to close the Unreal project and IDE. + +#### Installation steps + +1. Navigate to your Unreal project directory and create a `Plugins` folder if it doesn’t already exist: + ```bash + cd client_unreal + mkdir Plugins + ``` +2. Download or clone the SDK from GitHub and copy the SpacetimeDbSdk folder into your new Plugins directory. + - This should create `/client_unreal/Plugins/SpacetimeDbSdk`. +3. In the root of the Unreal project, right click the client_unreal.uproject and select **Generate Visual Studio project files**. On Windows 11 you may need to expand **Show more options** to select the generate option. + +![Generate project files](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-1-02-01-generate-project.png) +![Generate project files](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-1-02-02-generate-project.png) + +### Create the GameManager Actor + +1. Open the `client_unreal` project in your IDE (Visual Studio or JetBrains Rider) and run the project to launch the Unreal Editor. + - This will enable **Live Coding**, making the workflow a bit smoother. + - Unreal will prompt you to build the `SpacetimeDbSdk` plugin. Do so. +2. Open **Tools -> New C++ Class** in the top menu, select **Actor** as the parent and click **Next** +3. Select **Public** Class Type +4. Name the class `GameManager`. + +The `GameManager` class will be where we will put the high level initialization and coordination logic for our game. + +> **Note:** In a production Unreal project, you would typically implement this logic in a Subsystem. For simplicity, this tutorial uses a singleton actor. + +### Set Up the Level + +Set up the empty level, add the new `GameManager` to the level, and add lighting. + +1. **Create a new level** + - Open **File -> New Level** in the top menu, select **Empty Level**, and click **Create**. + - Save the level and name it `Blackholio`. + +2. **Create a GameManager Blueprint** + - In the **Content Drawer**, click **Add**, then select **Blueprint -> Blueprint Class**. + - Expand **All Classes**, search for **GameManager**, highlight it, and click **Select**. + - Name the blueprint `BP_GameManager`. + + ![Pick Parent Class](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-1-03-create-blueprint.png) + +3. **Update Maps & Modes** + - Open **Edit -> Project Settings** in the top menu, then select **Project -> Maps & Modes** on the left. + - Set **Editor Startup Map** to `Blackholio`. + - Set **Game Default Map** to `Blackholio`. + +4. **Add to the Level** + - Drag the `BP_GameManager` blueprint from the **Content Drawer** into the scene view. + +5. **Add a Directional Light** + - Click **Add** in the top toolbar, then select **Lights -> Directional Light**. + - Set **Rotation** to -105.0, -31.0, -14.0. + +6. **Add a Post Process Volume** + - Click **Add** in the top toolbar, then select **Volumes -> Post Process Volume**. + - Enable and set **Exposure -> Exposure Compensation** to 0.0. + - Enable and set **Exposure -> Min EV100** to 1.0. + - Enable and set **Exposure -> Max EV100** to 1.0. + - Enable **Post Process Volume Settings -> Infinite Extend (Unbounded)**. + +### Add a Simple GameMode + +Create a simple GameMode to tweak the startup settings and connect it to the World Settings. + +1. **Create the C++ class** + - Open **Tools -> New C++ Class** in the top menu, select **GameModeBase** as the parent, and click **Next**. + - Select **Public** as the class type. + - Name the class `BlackholioGameMode`. + +2. **Create a GameMode Blueprint** + - In the **Content Drawer**, click **Add**, then select **Blueprint -> Blueprint Class**. + - Expand **All Classes**, search for `BlackholioGameMode`, highlight it, and click **Select**. + - Name the blueprint `BP_BlackholioGameMode`. + +3. **Update World Settings** + - Open **Window -> World Settings** in the top menu. + - Change **GameMode Override** from **None** to `BP_BlackholioGameMode`. + - Save the level. + +At this point, the foundation of the Unreal project is set up. Pressing Play will show a blank screen, but the game should start without errors. Next, we’ll create the SpacetimeDB server module so we have something to connect to. + +### Create the Server Module + +We've now got the very basics set up. In [part 2](part-2) you'll learn the basics of how to create a SpacetimeDB server module and how to connect to it from your client. diff --git a/docs/docs/unreal/part-2.md b/docs/docs/unreal/part-2.md new file mode 100644 index 00000000000..da7148589c9 --- /dev/null +++ b/docs/docs/unreal/part-2.md @@ -0,0 +1,695 @@ +# Unreal Tutorial - Part 2 - Connecting to SpacetimeDB + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from [part 1](/docs/unreal/part-1). + +## Create a Server Module + +If you have not already installed the `spacetime` CLI, check out our [Getting Started](/docs/getting-started) guide for instructions on how to install. + +In your `blackholio` directory, run the following command to initialize the SpacetimeDB server module project with your desired language: + +:::server-rust +Run the following command to initialize the SpacetimeDB server module project with Rust as the language: + +```bash +spacetime init --lang=rust server-rust +``` + +This command creates a new folder named `server-rust` alongside your Unreal project `client_unreal` directory and sets up the SpacetimeDB server project with Rust as the programming language. +::: +:::server-csharp +Run the following command to initialize the SpacetimeDB server module project with C# as the language: + +```bash +spacetime init --lang=csharp server-csharp +``` + +This command creates a new folder named `server-csharp` alongside your Unreal project `client-unreal` directory and sets up the SpacetimeDB server project with C# as the programming language. +::: + +### SpacetimeDB Tables + +:::server-rust +In this section we'll be making some edits to the file `server-rust/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. + +**Important: Open the `server-rust/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** +::: +:::server-csharp +In this section we'll be making some edits to the file `server-csharp/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. + +**Important: Open the `server-csharp/Lib.cs` file and delete its contents. We will be writing it from scratch here.** +::: + +First we need to add some imports at the top of the file. Some will remain unused for now. + +:::server-rust +**Copy and paste into lib.rs:** + +```rust +use std::time::Duration; +use spacetimedb::{rand::Rng, Identity, SpacetimeType, ReducerContext, ScheduleAt, Table, Timestamp}; +``` +::: +:::server-csharp +**Copy and paste into Lib.cs:** + +```csharp +using SpacetimeDB; + +public static partial class Module +{ + +} +``` +::: + +We are going to start by defining a SpacetimeDB *table*. A *table* in SpacetimeDB is a relational database table which stores rows, similar to something you might find in SQL. SpacetimeDB tables differ from normal relational database tables in that they are stored fully in memory, are blazing fast to access, and are defined in your module code, rather than in SQL. + +:::server-rust +Each row in a SpacetimeDB table is associated with a `struct` type in Rust. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code to `lib.rs`. + +```rust +// We're using this table as a singleton, so in this table +// there only be one element where the `id` is 0. +#[spacetimedb::table(name = config, public)] +pub struct Config { + #[primary_key] + pub id: u32, + pub world_size: u64, +} +``` + +Let's break down this code. This defines a normal Rust `struct` with two fields: `id` and `world_size`. We have decorated the struct with the `spacetimedb::table` macro. This procedural Rust macro signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. + +The `spacetimedb::table` macro takes two parameters, a `name` which is the name of the table and what you will use to query the table in SQL, and a `public` visibility modifier which ensures that the rows of this table are visible to everyone. + +The `#[primary_key]` attribute, specifies that the `id` field should be used as the primary key of the table. +::: +:::server-csharp +Each row in a SpacetimeDB table is associated with a `struct` type in C#. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code inside the `Module` class in `Lib.cs`. + +```csharp +// We're using this table as a singleton, so in this table +// there will only be one element where the `id` is 0. +[Table(Name = "config", Public = true)] +public partial struct Config +{ + [PrimaryKey] + public uint id; + public ulong world_size; +} +``` + +Let's break down this code. This defines a normal C# `struct` with two fields: `id` and `world_size`. We have added the `[Table(Name = "config", Public = true)]` attribute the struct. This attribute signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. + +> Although we're using `lower_snake_case` for our column names to have consistent column names across languages in this tutorial, you can also use `camelCase` or `PascalCase` if you prefer. See [#2168](https://github.com/clockworklabs/SpacetimeDB/issues/2168) for more information. + +The `Table` attribute takes two parameters, a `Name` which is the name of the table and what you will use to query the table in SQL, and a `Public` visibility modifier which ensures that the rows of this table are visible to everyone. + +The `[PrimaryKey]` attribute, specifies that the `id` field should be used as the primary key of the table. +::: + +> NOTE: The primary key of a row defines the "identity" of the row. A change to a row which doesn't modify the primary key is considered an update, but if you change the primary key, then you have deleted the old row and inserted a new one. + +:::server-rust +You can learn more the `table` macro in our [Rust module reference](/docs/modules/rust). +::: +:::server-csharp +You can learn more the `Table` attribute in our [C# module reference](/docs/modules/c-sharp). +::: + +### Creating Entities + +:::server-rust +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of lib.rs:** + +```rust +// This allows us to store 2D points in tables. +#[derive(SpacetimeType, Clone, Debug)] +pub struct DbVector2 { + pub x: f32, + pub y: f32, +} +``` + +Let's create a few tables to represent entities in our game. + +```rust +#[spacetimedb::table(name = entity, public)] +#[derive(Debug, Clone)] +pub struct Entity { + // The `auto_inc` attribute indicates to SpacetimeDB that + // this value should be determined by SpacetimeDB on insert. + #[auto_inc] + #[primary_key] + pub entity_id: u32, + pub position: DbVector2, + pub mass: u32, +} + +#[spacetimedb::table(name = circle, public)] +pub struct Circle { + #[primary_key] + pub entity_id: u32, + #[index(btree)] + pub player_id: u32, + pub direction: DbVector2, + pub speed: f32, + pub last_split_time: Timestamp, +} + +#[spacetimedb::table(name = food, public)] +pub struct Food { + #[primary_key] + pub entity_id: u32, +} +``` +::: +:::server-csharp +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a `[SpacetimeDB.Type]` and a `[SpacetimeDB.Table]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of Lib.cs:** + +```csharp +// This allows us to store 2D points in tables. +[SpacetimeDB.Type] +public partial struct DbVector2 +{ + public float x; + public float y; + + public DbVector2(float x, float y) + { + this.x = x; + this.y = y; + } +} +``` + +Let's create a few tables to represent entities in our game by adding the following to the end of the `Module` class. + +```csharp +[Table(Name = "entity", Public = true)] +public partial struct Entity +{ + [PrimaryKey, AutoInc] + public uint entity_id; + public DbVector2 position; + public uint mass; +} + +[Table(Name = "circle", Public = true)] +public partial struct Circle +{ + [PrimaryKey] + public uint entity_id; + [SpacetimeDB.Index.BTree] + public uint player_id; + public DbVector2 direction; + public float speed; + public SpacetimeDB.Timestamp last_split_time; +} + +[Table(Name = "food", Public = true)] +public partial struct Food +{ + [PrimaryKey] + public uint entity_id; +} +``` +::: + +The first table we defined is the `entity` table. An entity represents an object in our game world. We have decided, for convenience, that all entities in our game should share some common fields, namely `position` and `mass`. + +We can create different types of entities with additional data by creating new tables with additional fields that have an `entity_id` which references a row in the `entity` table. + +We've created two types of entities in our game world: `Food`s and `Circle`s. `Food` does not have any additional fields beyond the attributes in the `entity` table, so the `food` table simply represents the set of `entity_id`s that we want to recognize as food. + +The `Circle` table, however, represents an entity that is controlled by a player. We've added a few additional fields to a `Circle` like `player_id` so that we know which player that circle belongs to. + +### Representing Players + +Next, let's create a table to store our player data. + +:::server-rust +```rust +#[spacetimedb::table(name = player, public)] +#[derive(Debug, Clone)] +pub struct Player { + #[primary_key] + identity: Identity, + #[unique] + #[auto_inc] + player_id: u32, + name: String, +} +``` + +There's a few new concepts we should touch on. First of all, we are using the `#[unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. +::: +:::server-csharp +```csharp +[Table(Name = "player", Public = true)] +public partial struct Player +{ + [PrimaryKey] + public Identity identity; + [Unique, AutoInc] + public uint player_id; + public string name; +} +``` + +There are a few new concepts we should touch on. First of all, we are using the `[Unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. We are also using the `[AutoInc]` attribute on the `player_id` field, which indicates "this field should get automatically assigned an auto-incremented value". +::: + +We also have an `identity` field which uses the `Identity` type. The `Identity` type is an identifier that SpacetimeDB uses to uniquely assign and authenticate SpacetimeDB users. + +### Writing a Reducer + +Next, we write our very first reducer. A reducer is a module function which can be called by clients. Let's write a simple debug reducer to see how they work. + +:::server-rust +```rust +#[spacetimedb::reducer] +pub fn debug(ctx: &ReducerContext) -> Result<(), String> { + log::debug!("This reducer was called by {}.", ctx.sender); + Ok(()) +} +``` +::: +:::server-csharp + +Add this function to the `Module` class in `Lib.cs`: + +```csharp +[Reducer] +public static void Debug(ReducerContext ctx) +{ + Log.Info($"This reducer was called by {ctx.Sender}"); +} +``` +::: + +This reducer doesn't update any tables, it just prints out the `Identity` of the client that called it. + +--- + +**SpacetimeDB Reducers** + +"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" a set of inserts and deletes into the database state. The term derives from functional programming and is closely related to [similarly named concepts](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow#reducers) in other frameworks like React Redux. Reducers can be called remotely using the CLI, client SDK or can be scheduled to be called at some future time from another reducer call. + +All reducers execute *transactionally* and *atomically*, meaning that from within the reducer it will appear as though all changes are being applied to the database immediately, however from the outside changes made in a reducer will only be applied to the database once the reducer completes successfully. If you return an error from a reducer or panic within a reducer, all changes made to the database will be rolled back, as if the function had never been called. If you're unfamiliar with atomic transactions, it may not be obvious yet just how useful and important this feature is, but once you build a somewhat complex application it will become clear just how invaluable this feature is. + +--- + +### Publishing the Module + +Now that we have some basic functionality, let's publish the module to SpacetimeDB and call our debug reducer. + +In a new terminal window, run a local version of SpacetimeDB with the command: + +```sh +spacetime start +``` + +This following log output indicates that SpacetimeDB is successfully running on your machine. + +``` +Starting SpacetimeDB listening on 127.0.0.1:3000 +``` + +:::server-rust +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-rust` directory. +::: +:::server-csharp +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-csharp` directory. +::: + +If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command to log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. + +If the publish completed successfully, you will see something like the following in the logs: + +``` +Build finished successfully. +Uploading to local => http://127.0.0.1:3000 +Publishing module... +Created new database with name: blackholio, identity: c200d2c69b4524292b91822afac8ab016c15968ac993c28711f68c6bc40b89d5 +``` + +> If you sign into `spacetime login` via GitHub, the token you get will be issued by `auth.spacetimedb.com`. This will also ensure that you can recover your identity in case you lose it. On the other hand, if you do `spacetime login --server-issued-login local`, you will get an identity which is issued directly by your local server. Do note, however, that `--server-issued-login` tokens are not recoverable if lost, and are only recognized by the server that issued them. + +:::server-rust + +```sh +spacetime call blackholio debug +``` +::: +:::server-csharp +Next, use the `spacetime` command to call our newly defined `Debug` reducer: + +```sh +spacetime call blackholio Debug +``` +::: + +If the call completed successfully, that command will have no output, but we can see the debug logs by running: + +```sh +spacetime logs blackholio +``` + +You should see something like the following output: + +```sh +2025-01-09T16:08:38.144299Z INFO: spacetimedb: Creating table `circle` +2025-01-09T16:08:38.144438Z INFO: spacetimedb: Creating table `config` +2025-01-09T16:08:38.144451Z INFO: spacetimedb: Creating table `entity` +2025-01-09T16:08:38.144470Z INFO: spacetimedb: Creating table `food` +2025-01-09T16:08:38.144479Z INFO: spacetimedb: Creating table `player` +2025-01-09T16:08:38.144841Z INFO: spacetimedb: Database initialized +2025-01-09T16:08:47.306823Z INFO: src/lib.rs:68: This reducer was called by c200e1a6494dbeeb0bbf49590b8778abf94fae4ea26faf9769c9a8d69a3ec348. +``` + +### Connecting our Client + +:::server-rust +Next let's connect our client to our database. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and add `client_connected` in parentheses after `spacetimedb::reducer`. The end result should look like this: + +```rust +#[spacetimedb::reducer(client_connected)] +pub fn connect(ctx: &ReducerContext) -> Result<(), String> { + log::debug!("{} just connected.", ctx.sender); + Ok(()) +} +``` + +The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. +> - `client_connected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. +> - `client_disconnected` - Called when a user disconnects from the SpacetimeDB database. +::: +:::server-csharp +Next let's connect our client to our database. Let's start by modifying our `Debug` reducer. Rename the reducer to be called `Connect` and add `ReducerKind.ClientConnected` in parentheses after `SpacetimeDB.Reducer`. The end result should look like this: + +```csharp +[Reducer(ReducerKind.ClientConnected)] +public static void Connect(ReducerContext ctx) +{ + Log.Info($"{ctx.Sender} just connected."); +} +``` + +The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. +> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `Sender` value of the `ReducerContext`. +> - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB database. +::: + +Publish your module again by running: + +```sh +spacetime publish --server local blackholio +``` + +### Generating the Client + +The `spacetime` CLI has built in functionality to let us generate Unreal C++ types that correspond to our tables, types, and reducers that we can use from our Unreal client. + +:::server-rust +Let's generate our types for our module. In the `blackholio/server-rust` directory run the following command: +::: +:::server-csharp +Let's generate our types for our module. In the `blackholio/server-csharp` directory run the following command: +::: + +```sh +spacetime generate --lang unrealcpp --uproject-dir ../client_unreal --project-path ./ --module-name client_unreal +``` + +This will generate a set of files in the `client_unreal/Source/client_unreal/Private/ModuleBindings` and `client_unreal/Source/client_unreal/Public/ModuleBindings` directories which contain the code generated types and reducer functions that are defined in your module, but usable on the client. + +> **Note:** `--uproject-dir` is straightforward as the path to the .uproject file. `--module-name` is the name of the Unreal module which in most projects is the name of the project, in this case `client_unreal`. + +``` +├── Reducers +│ └── Connect.g.h +├── Tables +│ ├── CircleTable.g.h +│ ├── ConfigTable.g.h +│ ├── EntityTable.g.h +│ ├── FoodTable.g.h +│ └── PlayerTable.g.h +├── Types +│ ├── CircleType.g.h +│ ├── ConfigType.g.h +│ ├── DbVector2Type.g.h +│ ├── EntityType.g.h +│ ├── FoodType.g.h +│ └── PlayerType.g.h +└── ReducerBase.g.h +└── SpacetimeDBClient.g.h +``` + +This will also generate a file in the `client_unreal/Source/client_unreal/Private/ModuleBindings/SpacetimeDBClient.g.h` directory with a type aware `UDbConnection` class. We will use this class to connect to your database from Unreal. + +### Connecting to the Database + +Update `client_unreal.Build.cs` to include the `SpacetimeDbSdk`. Add `SpacetimeDbSdk` and `Paper2D` to `PublicDependencyModuleNames`, and confirm that `PrivateDependencyModuleNames` includes the following modules for current and future needs: + +```cpp + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", + "InputCore", + "EnhancedInput", + "SpacetimeDbSdk", + "Paper2D" + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + "UMG", + "SlateCore", + "Slate" + }); +``` + +Update `GameManager.h` as follows to set up the Unreal client connection to the server: + +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "ModuleBindings/SpacetimeDBClient.g.h" +#include "GameManager.generated.h" + +class UDbConnection; + +UCLASS() +class CLIENT_UNREAL_API AGameManager : public AActor +{ + GENERATED_BODY() + +public: + AGameManager(); + static AGameManager* Instance; + + UPROPERTY(EditAnywhere, Category="BH|Connection") + FString ServerUri = TEXT("127.0.0.1:3000"); + UPROPERTY(EditAnywhere, Category="BH|Connection") + FString ModuleName = TEXT("blackholio"); + UPROPERTY(EditAnywhere, Category="BH|Connection") + FString TokenFilePath = TEXT(".spacetime_blackholio"); + + UPROPERTY(BlueprintReadOnly, Category="BH|Connection") + FSpacetimeDBIdentity LocalIdentity; + UPROPERTY(BlueprintReadOnly, Category="BH|Connection") + UDbConnection* Conn = nullptr; + + UFUNCTION(BlueprintPure, Category="BH|Connection") + bool IsConnected() const + { + return Conn != nullptr && Conn->IsActive(); + } + + UFUNCTION(BlueprintCallable, Category="BH|Connection") + void Disconnect() + { + if (Conn != nullptr) + { + Conn->Disconnect(); + Conn = nullptr; + } + } + +protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + +public: + // Called every frame + virtual void Tick(float DeltaTime) override; + +private: + UFUNCTION() + void HandleConnect(UDbConnection* InConn, FSpacetimeDBIdentity Identity, const FString& Token); + UFUNCTION() + void HandleConnectError(const FString& Error); + UFUNCTION() + void HandleDisconnect(UDbConnection* InConn, const FString& Error); + UFUNCTION() + void HandleSubscriptionApplied(FSubscriptionEventContext& Context); +}; +``` + +Next, update GameManager.cpp to finalize the setup: + +```cpp +#include "GameManager.h" +#include "Connection/Credentials.h" + +AGameManager* AGameManager::Instance = nullptr; + +AGameManager::AGameManager() +{ + PrimaryActorTick.bCanEverTick = true; + PrimaryActorTick.bStartWithTickEnabled = true; +} + +void AGameManager::BeginPlay() +{ + Super::BeginPlay(); + Instance = this; + + FOnConnectDelegate ConnectDelegate; + BIND_DELEGATE_SAFE(ConnectDelegate, this, AGameManager, HandleConnect); + FOnDisconnectDelegate DisconnectDelegate; + BIND_DELEGATE_SAFE(DisconnectDelegate, this, AGameManager, HandleDisconnect); + FOnConnectErrorDelegate ConnectErrorDelegate; + BIND_DELEGATE_SAFE(ConnectErrorDelegate, this, AGameManager, HandleConnect); + + UCredentials::Init(*TokenFilePath); + FString Token = UCredentials::LoadToken(); + + UDbConnectionBuilder* Builder = UDbConnection::Builder() + ->WithUri(ServerUri) + ->WithModuleName(ModuleName) + ->OnConnect(ConnectDelegate) + ->OnDisconnect(DisconnectDelegate) + ->OnConnectError(ConnectErrorDelegate); + + if (!Token.IsEmpty()) + { + Builder->WithToken(Token); + } + + Conn = Builder->Build(); +} + +void AGameManager::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + Disconnect(); + if (Instance == this) + { + Instance = nullptr; + } + Super::EndPlay(EndPlayReason); +} + +void AGameManager::Tick(float DeltaTime) +{ + if (IsConnected()) + { + Conn->FrameTick(); + } +} + +void AGameManager::HandleConnect(UDbConnection* InConn, FSpacetimeDBIdentity Identity, const FString& Token) +{ + UE_LOG(LogTemp, Log, TEXT("Connected.")); + UCredentials::SaveToken(Token); + LocalIdentity = Identity; + + FOnSubscriptionApplied AppliedDelegate; + BIND_DELEGATE_SAFE(AppliedDelegate, this, AGameManager, HandleSubscriptionApplied); + Conn->SubscriptionBuilder() + ->OnApplied(AppliedDelegate) + ->SubscribeToAllTables(); +} + +void AGameManager::HandleConnectError(const FString& Error) +{ + UE_LOG(LogTemp, Log, TEXT("Connection error %s"), *Error); +} + +void AGameManager::HandleDisconnect(UDbConnection* InConn, const FString& Error) +{ + UE_LOG(LogTemp, Log, TEXT("Disconnected.")); + if (!Error.IsEmpty()) + { + UE_LOG(LogTemp, Log, TEXT("Disconnect error %s"), *Error); + } +} + +void AGameManager::HandleSubscriptionApplied(FSubscriptionEventContext& Context) +{ + UE_LOG(LogTemp, Log, TEXT("Subscription applied!")); +} +``` + +Here we configure the connection to the database, by passing it some callbacks in addition to providing the `SERVER_URI` and `MODULE_NAME` to the connection. When the client connects, the SpacetimeDB SDK will call the `HandleConnect` method, allowing us to start up the game. + +In our `HandleConnect` callback we build a subscription and are calling `Subscribe` and subscribing to all data in the database. This will cause SpacetimeDB to synchronize the state of all your tables with your Unreal client's SpacetimeDB SDK's "client cache". You can also subscribe to specific tables using SQL syntax, e.g. `SELECT * FROM my_table`. Our [SQL documentation](/docs/sql) enumerates the operations that are accepted in our SQL syntax. + +--- + +**SDK Client Cache** + +The "SDK client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. SpacetimeDB ensures that the results of subscription queries are automatically updated and pushed to the client cache as they change which allows efficient access without unnecessary server queries. + +--- + +Now we're ready to connect the client and server. Press the play button in Unreal. + +If all went well you should see the below output in your Unreal logs. + +``` +UWebsocketManager::Connect: Connecting to ws://127.0.0.1:3000/v1/database/blackholio/subscribe? +... +Connected. +Subscription applied! +``` + +Subscription applied indicates that the SpacetimeDB SDK has evaluated your subscription queries and synchronized your local cache with your database's tables. + +We can also see that the server has logged the connection as well. + +```sh +spacetime logs blackholio +... +2025-01-10T03:51:02.078700Z DEBUG: src/lib.rs:63: c200fb5be9524bfb8289c351516a1d9ea800f70a17a9a6937f11c0ed3854087d just connected. +``` + +### Next Steps + +You've learned how to setup a Unreal project with the SpacetimeDB SDK, write a basic SpacetimeDB server module, and how to connect your Unreal client to SpacetimeDB. That's pretty much all there is to the setup. You're now ready to start building the game. + +In the [next part](/docs/unreal/part-3), we'll build out the functionality of the game and you'll learn how to access your table data and call reducers in Unreal. diff --git a/docs/docs/unreal/part-3-01-create-blueprint.png b/docs/docs/unreal/part-3-01-create-blueprint.png new file mode 100644 index 00000000000..57fb0f8d03e Binary files /dev/null and b/docs/docs/unreal/part-3-01-create-blueprint.png differ diff --git a/docs/docs/unreal/part-3-02-create-nameplate.png b/docs/docs/unreal/part-3-02-create-nameplate.png new file mode 100644 index 00000000000..30d2bf3ba7d Binary files /dev/null and b/docs/docs/unreal/part-3-02-create-nameplate.png differ diff --git a/docs/docs/unreal/part-3-03-update-text-function.png b/docs/docs/unreal/part-3-03-update-text-function.png new file mode 100644 index 00000000000..477c9dfb1ba Binary files /dev/null and b/docs/docs/unreal/part-3-03-update-text-function.png differ diff --git a/docs/docs/unreal/part-3-04-nameplate-change.png b/docs/docs/unreal/part-3-04-nameplate-change.png new file mode 100644 index 00000000000..27e58582bb4 Binary files /dev/null and b/docs/docs/unreal/part-3-04-nameplate-change.png differ diff --git a/docs/docs/unreal/part-3.md b/docs/docs/unreal/part-3.md new file mode 100644 index 00000000000..d4974d7640a --- /dev/null +++ b/docs/docs/unreal/part-3.md @@ -0,0 +1,1744 @@ +# Unreal Tutorial - Part 3 - Gameplay + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from [part 2](/docs/unreal/part-2). + +### Spawning Food + +:::server-rust +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `init` reducer. SpacetimeDB calls the `init` reducer automatically when first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. + +Add this new reducer above our `connect` reducer. + +```rust +// Note the `init` parameter passed to the reducer macro. +// That indicates to SpacetimeDB that it should be called +// once upon database creation. +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Initializing..."); + ctx.db.config().try_insert(Config { + id: 0, + world_size: 1000, + })?; + Ok(()) +} +``` + +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `try_insert` function. `try_insert` returns an error if inserting the row into the table would violate any constraints, like unique constraints, on the table. You can also use `insert` which panics on constraint violations if you know for sure that you will not violate any constraints. + +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the file. + +```rust +const FOOD_MASS_MIN: u32 = 2; +const FOOD_MASS_MAX: u32 = 4; +const TARGET_FOOD_COUNT: usize = 600; + +fn mass_to_radius(mass: u32) -> f32 { + (mass as f32).sqrt() +} + +#[spacetimedb::reducer] +pub fn spawn_food(ctx: &ReducerContext) -> Result<(), String> { + if ctx.db.player().count() == 0 { + // Are there no logged in players? Skip food spawn. + return Ok(()); + } + + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + let mut rng = ctx.rng(); + let mut food_count = ctx.db.food().count(); + while food_count < TARGET_FOOD_COUNT as u64 { + let food_mass = rng.gen_range(FOOD_MASS_MIN..FOOD_MASS_MAX); + let food_radius = mass_to_radius(food_mass); + let x = rng.gen_range(food_radius..world_size as f32 - food_radius); + let y = rng.gen_range(food_radius..world_size as f32 - food_radius); + let entity = ctx.db.entity().try_insert(Entity { + entity_id: 0, + position: DbVector2 { x, y }, + mass: food_mass, + })?; + ctx.db.food().try_insert(Food { + entity_id: entity.entity_id, + })?; + food_count += 1; + log::info!("Spawned food! {}", entity.entity_id); + } + + Ok(()) +} +``` +::: +:::server-csharp +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `Init` reducer. SpacetimeDB calls the `Init` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. + +Add this new reducer above our `Connect` reducer. + +```csharp +// Note the `init` parameter passed to the reducer macro. +// That indicates to SpacetimeDB that it should be called +// once upon database creation. +[Reducer(ReducerKind.Init)] +public static void Init(ReducerContext ctx) +{ + Log.Info($"Initializing..."); + ctx.Db.config.Insert(new Config { world_size = 1000 }); +} +``` + +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `Insert` function. + +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the `Module` class. + +```csharp +const uint FOOD_MASS_MIN = 2; +const uint FOOD_MASS_MAX = 4; +const uint TARGET_FOOD_COUNT = 600; + +public static float MassToRadius(uint mass) => MathF.Sqrt(mass); + +[Reducer] +public static void SpawnFood(ReducerContext ctx) +{ + if (ctx.Db.player.Count == 0) //Are there no players yet? + { + return; + } + + var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var rng = ctx.Rng; + var food_count = ctx.Db.food.Count; + while (food_count < TARGET_FOOD_COUNT) + { + var food_mass = rng.Range(FOOD_MASS_MIN, FOOD_MASS_MAX); + var food_radius = MassToRadius(food_mass); + var x = rng.Range(food_radius, world_size - food_radius); + var y = rng.Range(food_radius, world_size - food_radius); + var entity = ctx.Db.entity.Insert(new Entity() + { + position = new DbVector2(x, y), + mass = food_mass, + }); + ctx.Db.food.Insert(new Food + { + entity_id = entity.entity_id, + }); + food_count++; + Log.Info($"Spawned food! {entity.entity_id}"); + } +} + +public static float Range(this Random rng, float min, float max) => rng.NextSingle() * (max - min) + min; + +public static uint Range(this Random rng, uint min, uint max) => (uint)rng.NextInt64(min, max); +``` +::: + +In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. + +:::server-csharp +We also added two helper functions so we can get a random range as either a `uint` or a `float`. + +::: +Although, we've written the reducer to spawn food, no food will actually be spawned until we call the function while players are logged in. This raises the question, who should call this function and when? + +We would like for this function to be called periodically to "top up" the amount of food on the map so that it never falls very far below our target amount of food. SpacetimeDB has built in functionality for exactly this. With SpacetimeDB you can schedule your module to call itself in the future or repeatedly with reducers. + +:::server-rust +In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the file, below your imports. + +```rust +#[spacetimedb::table(name = spawn_food_timer, scheduled(spawn_food))] +pub struct SpawnFoodTimer { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} +``` + +Note the `scheduled(spawn_food)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. Each scheduled table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. +::: +:::server-csharp +In order to schedule a reducer to be called we have to create a new table which specifies when an how a reducer should be called. Add this new table to the top of the `Module` class. + +```csharp +[Table(Name = "spawn_food_timer", Scheduled = nameof(SpawnFood), ScheduledAt = nameof(scheduled_at))] +public partial struct SpawnFoodTimer +{ + [PrimaryKey, AutoInc] + public ulong scheduled_id; + public ScheduleAt scheduled_at; +} +``` + +Note the `Scheduled = nameof(SpawnFood)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `SpawnFood` reducer should be called. Each scheduled table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. +::: + +You can create, delete, or change a schedule by inserting, deleting, or updating rows in this table. + +You will see an error telling you that the `spawn_food` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `spawn_food` reducer to take the scheduled row as an argument. + +:::server-rust +```rust +#[spacetimedb::reducer] +pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), String> { + // ... +} +``` +::: +:::server-csharp +```csharp +[Reducer] +public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer _timer) +{ + // ... +} +``` +::: + +In our case we aren't interested in the data on the row, so we name the argument `_timer`. + +:::server-rust +Let's modify our `init` reducer to schedule our `spawn_food` reducer to be called every 500 milliseconds. + +```rust +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Initializing..."); + ctx.db.config().try_insert(Config { + id: 0, + world_size: 1000, + })?; + ctx.db.spawn_food_timer().try_insert(SpawnFoodTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(500).into()), + })?; + Ok(()) +} +``` + +> You can use `ScheduleAt::Interval` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt::Time()` to specify a specific at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. +::: +:::server-csharp +Let's modify our `Init` reducer to schedule our `SpawnFood` reducer to be called every 500 milliseconds. + +```csharp +[Reducer(ReducerKind.Init)] +public static void Init(ReducerContext ctx) +{ + Log.Info($"Initializing..."); + ctx.Db.config.Insert(new Config { world_size = 1000 }); + ctx.Db.spawn_food_timer.Insert(new SpawnFoodTimer + { + scheduled_at = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(500)) + }); +} +``` + +> You can use `ScheduleAt.Interval` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt.Time()` to specify a specific at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. +::: + +### Logging Players In + +Let's continue building out our server module by modifying it to log in a player when they connect to the database, or to create a new player if they've never connected before. + +Let's add a second table to our `Player` struct. Modify the `Player` struct by adding this above the struct: + +:::server-rust +```rust +#[spacetimedb::table(name = logged_out_player)] +``` +::: +:::server-csharp +```csharp +[Table(Name = "logged_out_player")] +``` +::: + +Your struct should now look like this: + +:::server-rust +```rust +#[spacetimedb::table(name = player, public)] +#[spacetimedb::table(name = logged_out_player)] +#[derive(Debug, Clone)] +pub struct Player { + #[primary_key] + identity: Identity, + #[unique] + #[auto_inc] + player_id: u32, + name: String, +} +``` +::: +:::server-csharp +```csharp +[Table(Name = "player", Public = true)] +[Table(Name = "logged_out_player")] +public partial struct Player +{ + [PrimaryKey] + public Identity identity; + [Unique, AutoInc] + public uint player_id; + public string name; +} +``` +::: + +This line creates an additional tabled called `logged_out_player` whose rows share the same `Player` type as in the `player` table. + +> IMPORTANT! Note that this new table is not marked `public`. This means that it can only be accessed by the database owner (which is almost always the database creator). In order to prevent any unintended data access, all SpacetimeDB tables are private by default. +> +> If your client isn't syncing rows from the server, check that your table is not accidentally marked private. + +:::server-rust +Next, modify your `connect` reducer and add a new `disconnect` reducer below it: + +```rust +#[spacetimedb::reducer(client_connected)] +pub fn connect(ctx: &ReducerContext) -> Result<(), String> { + if let Some(player) = ctx.db.logged_out_player().identity().find(&ctx.sender) { + ctx.db.player().insert(player.clone()); + ctx.db + .logged_out_player() + .identity() + .delete(&player.identity); + } else { + ctx.db.player().try_insert(Player { + identity: ctx.sender, + player_id: 0, + name: String::new(), + })?; + } + Ok(()) +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender) + .ok_or("Player not found")?; + let player_id = player.player_id; + ctx.db.logged_out_player().insert(player); + ctx.db.player().identity().delete(&ctx.sender); + + Ok(()) +} +``` +::: +:::server-csharp +Next, modify your `Connect` reducer and add a new `Disconnect` reducer below it: + +```csharp +[Reducer(ReducerKind.ClientConnected)] +public static void Connect(ReducerContext ctx) +{ + var player = ctx.Db.logged_out_player.identity.Find(ctx.Sender); + if (player != null) + { + ctx.Db.player.Insert(player.Value); + ctx.Db.logged_out_player.identity.Delete(player.Value.identity); + } + else + { + ctx.Db.player.Insert(new Player + { + identity = ctx.Sender, + name = "", + }); + } +} + +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + ctx.Db.logged_out_player.Insert(player); + ctx.Db.player.identity.Delete(player.identity); +} +``` +::: + +Now when a client connects, if the player corresponding to the client is in the `logged_out_player` table, we will move them into the `player` table, thus indicating that they are logged in and connected. For any new unrecognized client connects we will create a `Player` and insert it into the `player` table. + +When a player disconnects, we will transfer their player row from the `player` table to the `logged_out_player` table to indicate they're offline. + +> Note that we could have added a `logged_in` boolean to the `Player` type to indicated whether the player is logged in. There's nothing incorrect about that approach, however for several reasons we recommend this two table approach: +> - We can iterate over all logged in players without any `if` statements or branching +> - The `Player` type now uses less program memory improving cache efficiency +> - We can easily check whether a player is logged in, based on whether their row exists in the `player` table +> +> This approach is more generally referred to as [existence based processing](https://www.dataorienteddesign.com/dodmain/node4.html) and it is a common technique in data-oriented design. + +### Spawning Player Circles + +Now that we've got our food spawning and our players set up, let's create a match and spawn player circle entities into it. The first thing we should do before spawning a player into a match is give them a name. + +:::server-rust +Add the following to the bottom of your file. + +```rust +const START_PLAYER_MASS: u32 = 15; + +#[spacetimedb::reducer] +pub fn enter_game(ctx: &ReducerContext, name: String) -> Result<(), String> { + log::info!("Creating player with name {}", name); + let mut player: Player = ctx.db.player().identity().find(ctx.sender).ok_or("")?; + let player_id = player.player_id; + player.name = name; + ctx.db.player().identity().update(player); + spawn_player_initial_circle(ctx, player_id)?; + + Ok(()) +} + +fn spawn_player_initial_circle(ctx: &ReducerContext, player_id: u32) -> Result { + let mut rng = ctx.rng(); + let world_size = ctx + .db + .config() + .id() + .find(&0) + .ok_or("Config not found")? + .world_size; + let player_start_radius = mass_to_radius(START_PLAYER_MASS); + let x = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius)); + let y = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius)); + spawn_circle_at( + ctx, + player_id, + START_PLAYER_MASS, + DbVector2 { x, y }, + ctx.timestamp, + ) +} + +fn spawn_circle_at( + ctx: &ReducerContext, + player_id: u32, + mass: u32, + position: DbVector2, + timestamp: Timestamp, +) -> Result { + let entity = ctx.db.entity().try_insert(Entity { + entity_id: 0, + position, + mass, + })?; + + ctx.db.circle().try_insert(Circle { + entity_id: entity.entity_id, + player_id, + direction: DbVector2 { x: 0.0, y: 1.0 }, + speed: 0.0, + last_split_time: timestamp, + })?; + Ok(entity) +} +``` + +The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. +::: +:::server-csharp +Add the following to the end of the `Module` class. + +```csharp +const uint START_PLAYER_MASS = 15; + +[Reducer] +public static void EnterGame(ReducerContext ctx, string name) +{ + Log.Info($"Creating player with name {name}"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + player.name = name; + ctx.Db.player.identity.Update(player); + SpawnPlayerInitialCircle(ctx, player.player_id); +} + +public static Entity SpawnPlayerInitialCircle(ReducerContext ctx, uint player_id) +{ + var rng = ctx.Rng; + var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var player_start_radius = MassToRadius(START_PLAYER_MASS); + var x = rng.Range(player_start_radius, world_size - player_start_radius); + var y = rng.Range(player_start_radius, world_size - player_start_radius); + return SpawnCircleAt( + ctx, + player_id, + START_PLAYER_MASS, + new DbVector2(x, y), + ctx.Timestamp + ); +} + +public static Entity SpawnCircleAt(ReducerContext ctx, uint player_id, uint mass, DbVector2 position, SpacetimeDB.Timestamp timestamp) +{ + var entity = ctx.Db.entity.Insert(new Entity + { + position = position, + mass = mass, + }); + + ctx.Db.circle.Insert(new Circle + { + entity_id = entity.entity_id, + player_id = player_id, + direction = new DbVector2(0, 1), + speed = 0f, + last_split_time = timestamp, + }); + return entity; +} +``` + +The `EnterGame` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. +::: + +Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the database server. + +:::server-rust +```rust +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender) + .ok_or("Player not found")?; + let player_id = player.player_id; + ctx.db.logged_out_player().insert(player); + ctx.db.player().identity().delete(&ctx.sender); + + // Remove any circles from the arena + for circle in ctx.db.circle().player_id().filter(&player_id) { + ctx.db.entity().entity_id().delete(&circle.entity_id); + ctx.db.circle().entity_id().delete(&circle.entity_id); + } + + Ok(()) +} +``` +::: +:::server-csharp +```csharp +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + // Remove any circles from the arena + foreach (var circle in ctx.Db.circle.player_id.Filter(player.player_id)) + { + var entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Could not find circle"); + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.circle.entity_id.Delete(entity.entity_id); + } + ctx.Db.logged_out_player.Insert(player); + ctx.Db.player.identity.Delete(player.identity); +} +``` +::: + +Finally, publish the new module to SpacetimeDB with this command: + +```sh +spacetime publish --server local blackholio --delete-data +``` + +Deleting the data is optional in this case, but in case you've been messing around with the module we can just start fresh. + +> **Note:** When using `--delete-data`, SpacetimeDB will prompt you to confirm the deletion. Enter **y** and press **Enter** to proceed. + +### Creating the Arena + +With the server logic in place to spawn food and players, extend the Unreal client to display the current state. + +Add the `SetupArena` and `CreateBorderCube` methods and properties to your `GameManager.h` class. Place them below the `Handle{}` functions in the private block: + +```cpp + /* Border */ + UFUNCTION() + void SetupArena(uint64 WorldSizeMeters); + UFUNCTION() + void CreateBorderCube(const FVector2f Position, const FVector2f Size) const; + + UPROPERTY(VisibleAnywhere, Category="Arena") + UInstancedStaticMeshComponent* BorderISM; + UPROPERTY(EditDefaultsOnly, Category="Arena", meta=(ClampMin="1.0")) + float BorderThickness = 50.0f; + UPROPERTY(EditDefaultsOnly, Category="Arena", meta=(ClampMin="1.0")) + float BorderHeight = 100.0f; + UPROPERTY(EditDefaultsOnly, Category="Arena") + UMaterialInterface* BorderMaterial = nullptr; + UPROPERTY(EditDefaultsOnly, Category="Arena") + UStaticMesh* CubeMesh = nullptr; // defaults as /Engine/BasicShapes/Cube.Cube + /* Border */ +``` + +Next, we'll need to make a few updates in `GameManager.cpp`. + +First, update the includes: + +```cpp +#include "GameManager.h" +#include "Components/InstancedStaticMeshComponent.h" +#include "Connection/Credentials.h" +#include "ModuleBindings/Tables/ConfigTable.g.h" +``` + +The `AGameManager()` constructor in `GameManager.cpp` includes an `InstancedStaticMeshComponent` to set up the cube. Update the constructor as follows: + +```cpp +AGameManager::AGameManager() +{ + PrimaryActorTick.bCanEverTick = true; + PrimaryActorTick.bStartWithTickEnabled = true; + + BorderISM = CreateDefaultSubobject(TEXT("BorderISM")); + SetRootComponent(BorderISM); + + if (CubeMesh != nullptr) + return; + + static ConstructorHelpers::FObjectFinder CubeAsset(TEXT("/Engine/BasicShapes/Cube.Cube")); + if (CubeAsset.Succeeded()) + { + CubeMesh = CubeAsset.Object; + } +} +``` + +Add the implementations of `SetupArena` and `CreateBorderCube` to the end of `GameManager.cpp`: + +```cpp +void AGameManager::SetupArena(uint64 WorldSizeMeters) +{ + if (!BorderISM || !CubeMesh) return; + + BorderISM->ClearInstances(); + BorderISM->SetStaticMesh(CubeMesh); + if (BorderMaterial) + { + BorderISM->SetMaterial(0, BorderMaterial); + } + + // Convert from meters (uint64) → centimeters (double for precision) + const double worldSizeCmDouble = static_cast(WorldSizeMeters) * 100.0; + + // Clamp to avoid float overflow in transforms + const double clampedWorldSizeCmDouble = FMath::Clamp( + worldSizeCmDouble, + 0.0, + FLT_MAX * 0.25 // safe margin + ); + + // Convert to float for actual Unreal math + const float worldSizeCm = static_cast(clampedWorldSizeCmDouble); + + const float borderThicknessCm = BorderThickness; // already cm + + // Create four borders + CreateBorderCube( + FVector2f(worldSizeCm * 0.5f, worldSizeCm + borderThicknessCm * 0.5f), // North + FVector2f(worldSizeCm + borderThicknessCm * 2.0f, borderThicknessCm) + ); + + CreateBorderCube( + FVector2f(worldSizeCm * 0.5f, -borderThicknessCm * 0.5f), // South + FVector2f(worldSizeCm + borderThicknessCm * 2.0f, borderThicknessCm) + ); + + CreateBorderCube( + FVector2f(worldSizeCm + borderThicknessCm * 0.5f, worldSizeCm * 0.5f), // East + FVector2f(borderThicknessCm, worldSizeCm + borderThicknessCm * 2.0f) + ); + + CreateBorderCube( + FVector2f(-borderThicknessCm * 0.5f, worldSizeCm * 0.5f), // West + FVector2f(borderThicknessCm, worldSizeCm + borderThicknessCm * 2.0f) + ); +} + +void AGameManager::CreateBorderCube(const FVector2f Position, const FVector2f Size) const +{ + // Scale from the 100cm default cube to desired size (in cm) + const FVector Scale(Size.X / 100.0f, BorderHeight / 100.0f, Size.Y / 100.0f); + + // Place so the bottom sits on Z=0 (cube is centered) + const FVector Location(Position.X, BorderHeight * 0.5f, Position.Y); + + const FTransform Transform(FRotator::ZeroRotator, Location, Scale); + BorderISM->AddInstance(Transform); +} +``` + +In `HandleSubscriptionApplied`, call the `SetupArena` method. Update `HandleSubscriptionApplied` as follows: + +```cpp +void AGameManager::HandleSubscriptionApplied(FSubscriptionEventContext& Context) +{ + UE_LOG(LogTemp, Log, TEXT("Subscription applied!")); + + // Once we have the initial subscription sync'd to the client cache + // Get the world size from the config table and set up the arena + uint64 WorldSize = Conn->Db->Config->Id->Find(0).WorldSize; + SetupArena(WorldSize); +} +``` + +The `OnApplied` callback is called after the server synchronizes the initial state of your tables with the client. After the sync, look up the world size from the `config` table and use it to set up the arena. + +### Create Entity Blueprints + +With the arena set up, use the row data that SpacetimeDB syncs with the client to create and display **Blueprints** on the screen. + +Start by making a C++ class for each entity you want in the scene. If the Unreal project is not running, start it now. From the top menu, choose **Tools -> New C++ Class...** to create the following classes (you’ll modify these later): + +> **Note:** After creating the first class, wait for **Live Coding** to finish before creating the next classes. + +1. **Parent:** **Actor** · **Class Type:** **Public** · **Class Name:** `Entity` +2. **Parent:** **All Classes -> Entity** · **Class Type:** **Public** · **Class Name:** `Circle` +3. **Parent:** **All Classes -> Entity** · **Class Type:** **Public** · **Class Name:** `Food` +4. **Parent:** **Pawn** · **Class Type:** **Public** · **Class Name:** `PlayerPawn` +5. **Parent:** **Player Controller** · **Class Type:** **Public** · **Class Name:** `BlackholioPlayerController` +6. **Parent:** **None** · **Class Type:** **Public** · **Class Name:** `DbVector2` + +Next add blueprints for our these classes: + +![Add Circle](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-3-01-create-blueprint.png) + +1. **Circle Blueprint** + - In the **Content Drawer**, right-click and choose **Blueprint -> Blueprint Class**. + - Expand **All Classes**, search for `Circle`, highlight `Circle`, and click **Select**. + - Rename the new Blueprint to `BP_Circle`. + +2. **Food Blueprint** + - In the **Content Drawer**, right-click and choose **Blueprint -> Blueprint Class**. + - Expand **All Classes**, search for `Food`, highlight `Food`, and click **Select**. + - Rename the new Blueprint to `BP_Food`. + +3. **Player Blueprint** + - In the **Content Drawer**, right-click and choose **Blueprint -> Blueprint Class**. + - Expand **All Classes**, search for `PlayerPawn`, highlight `PlayerPawn`, and click **Select**. + - Rename the new Blueprint to `BP_PlayerPawn`. + +4. **Player Controller Blueprint** + - In the **Content Drawer**, right-click and choose **Blueprint -> Blueprint Class**. + - Expand **All Classes**, search for `BlackholioPlayerController`, highlight `BlackholioPlayerController`, and click **Select**. + - Rename the new Blueprint to `BP_BlackholioPlayerController`. + - Open **Window -> World Settings** in the top menu. + - Change **Player Controller Class** from **PlayerController** to `BP_BlackholioPlayerController`. + - Save the level. + +### Set Up the Nameplate Blueprint + +Create a widget Blueprint for the player nameplate: + +- In the **Content Drawer**, right-click and choose **Blueprint -> Blueprint Class**. +- Expand **All Classes**, search for **UserWidget**, highlight **UserWidget**, and click **Select**. +- Name the new Blueprint `WBP_Nameplate`. + +Double-click `WBP_Nameplate` to open it, then make the following changes: + +1. In the **Palette** on the left, search for **Size Box** and drag it into the **Hierarchy** under `WBP_Nameplate`. +2. Search the **Palette** for **Text** and drag it under the **Size Box**. +3. Select the **Text** widget and update its details: + - Rename it to `TextBlock`. + - Check **Is Variable**. + - Set **Font -> Size** to `24`. + - Set **Font -> Justification** to **Align Text Center**. + +![WBP_Nameplate](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-3-02-create-nameplate.png) + +Finally, add Blueprint logic so the circle can update its nameplate: + +1. In the `WBP_Nameplate` editor, open the **Graph** tab (top right). +2. Click the **+** button next to **My Blueprint -> Functions** and name the new function `UpdateText`. +3. Select `UpdateText` in the editor, then in **Details -> Inputs**, add a variable named `Text` of type `String`. +4. Drag **TextBlock** into the graph and choose **Get TextBlock**. +5. Drag off **TextBlock** and search for **Set Text**. +6. Connect **UpdateText** to **Set Text**, then connect **UpdateText -> Text** to **Set Text -> Text**. + - A conversion from `String` to `Text` is added automatically; this is expected. +7. Click **Save** and **Compile**. + +![UpdateText Function](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-3-03-update-text-function.png) + +### Set Up Circle Entity Blueprint + +Import and set up the circle sprite: + +1. Right-click the image below and save it locally: +![Circle](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/Circle.png) + +2. In the **Content Drawer**, right-click and select **Import to Current Folder**, then choose the saved image. + - This imports the Circle as a texture. + - Right-click the imported texture, select **Sprite Actions -> Create Sprite**, and rename it `Circle_Sprite`. + +Next, open `BP_Circle` and configure it: + +1. Select **DefaultSceneRoot**, add a **Components -> Paper Sprite** component, and rename it `Circle`. + - In the **Details** panel, set **Scale** to `0.4` for all three axes. + - Set **Source Sprite** to `Circle_Sprite`. + +2. Select **DefaultSceneRoot**, add a **Components -> Widget** component, and rename it `NameplateWidget`. + - In the **Details** panel, set **Location** to `0, 10, -45`. + - Set **Rotation** to `0, 0, 90`. + - Under **User Interface**, update: + - **Widget Class** to `WBP_Nameplate` + - **Draw Size** to `300, 60` + - **Pivot** to `0.5, 1.0` +3. Click **Save** and **Compile**. + +### Set Up the Food Entity Blueprint + +The food entity is a simple collectible. Open `BP_Food` and configure it as follows: + +1. Select **DefaultSceneRoot**, add a **Components -> Paper Sprite** component, and rename it `Circle`. + - In the **Details** panel, set **Scale** to `0.4` for all three axes. + - Set **Source Sprite** to `Circle_Sprite`. +2. Click **Save** and **Compile**. + +### Set Up the PlayerPawn Blueprint + +The PlayerPawn owns the circles and controls the camera by following the center of mass. This setup provides the initial functionality; additional behavior will be added in the C++ class. + +Open `BP_PlayerPawn` and make the following changes: + +1. Select **DefaultSceneRoot**, add a **Components -> Spring Arm** component. +2. Select **SpringArm**, add a **Components -> Camera** component. +3. Select **SpringArm** + - In the **Details** panel, set: + - **Location** to `0, 15000, 0` + - **Rotation** to `0, 0, -90` + - **Target Arm Length** to `200` +4. Click **Save** and **Compile**. + +> **Note:** Make sure the **Camera** component's **Location** and **Rotation** are `0, 0, 0` + +### Update Classes + +With the Blueprints set up, return to the source code behind the entities. First, add helper functions to translate server-side vectors to Unreal vectors. + +Open `DbVector2.h` and update it as follows: + +```cpp +#pragma once + +#include "ModuleBindings/Types/DbVector2Type.g.h" + +FORCEINLINE FDbVector2Type ToDbVector(const FVector2D& Vec) +{ + FDbVector2Type Out; + Out.X = Vec.X; + Out.Y = Vec.Y; + return Out; +} + +FORCEINLINE FDbVector2Type ToDbVector(const FVector& Vec) +{ + FDbVector2Type Out; + Out.X = Vec.X; + Out.Y = Vec.Y; + return Out; +} + +FORCEINLINE FVector2D ToFVector2D(const FDbVector2Type& Vec) +{ + return FVector2D(Vec.X * 100.f, Vec.Y * 100.f); +} + +FORCEINLINE FVector ToFVector(const FDbVector2Type& Vec, float Z = 0.f) +{ + return FVector(Vec.X * 100.f, Z, Vec.Y * 100.f); +} +``` + +> **Note:** Delete `DbVector2.cpp` (not needed), or clear its contents so compilation succeeds. + +#### Entity Class + +With the foundation in place, implement the core entity class. Edit `Entity.h` as follows: + +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "Entity.generated.h" + +struct FEventContext; +struct FEntityType; + +UCLASS() +class CLIENT_UNREAL_API AEntity : public AActor +{ + GENERATED_BODY() + +public: + AEntity(); + +protected: + UPROPERTY(EditDefaultsOnly, Category="BH|Entity") + float LerpTime = 0.f; + UPROPERTY(EditDefaultsOnly, Category="BH|Entity") + float LerpDuration = 0.10f; + + FVector LerpStartPosition = FVector::ZeroVector; + FVector LerpTargetPosition = FVector::ZeroVector; + float TargetScale = 1.f; + +public: + uint32 EntityId = 0; + virtual void Tick(float DeltaTime) override; + + void Spawn(uint32 InEntityId); + virtual void OnEntityUpdated(const FEntityType& NewVal); + virtual void OnDelete(const FEventContext& Context); + + void SetColor(const FLinearColor& Color) const; + + static float MassToRadius(uint32 Mass) { return FMath::Sqrt(static_cast(Mass)); } + static float MassToDiameter(uint32 Mass) { return MassToRadius(Mass) * 2.f; } +}; +``` + +Update `Entity.cpp` as follows: + +```cpp +#include "Entity.h" +#include "DbVector2.h" +#include "GameManager.h" +#include "PaperSpriteComponent.h" +#include "ModuleBindings/Tables/EntityTable.g.h" + +AEntity::AEntity() +{ + PrimaryActorTick.bCanEverTick = true; + LerpTime = 0.f; +} + +void AEntity::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); + + // Interpolate the position and scale + LerpTime = FMath::Min(LerpTime + DeltaTime, LerpDuration); + const float Alpha = (LerpDuration > 0.f) ? (LerpTime / LerpDuration) : 1.f; + SetActorLocation(FMath::Lerp(LerpStartPosition, LerpTargetPosition, Alpha)); + const float NewScale = FMath::FInterpTo(GetActorScale3D().X, TargetScale, DeltaTime, 8.f); + SetActorScale3D(FVector(NewScale)); +} + +void AEntity::Spawn(uint32 InEntityId) +{ + EntityId = InEntityId; + + const FEntityType EntityRow = AGameManager::Instance->Conn->Db->Entity->EntityId->Find(InEntityId); + + LerpStartPosition = LerpTargetPosition = ToFVector(EntityRow.Position); + TargetScale = MassToDiameter(EntityRow.Mass); + SetActorScale3D(FVector::OneVector); +} + +void AEntity::OnEntityUpdated(const FEntityType& NewVal) +{ + LerpStartPosition = GetActorLocation(); + LerpTargetPosition = ToFVector(NewVal.Position); + TargetScale = MassToDiameter(NewVal.Mass); + LerpTime = 0.f; +} + +void AEntity::OnDelete(const FEventContext& Context) +{ + Destroy(); +} + +void AEntity::SetColor(const FLinearColor& Color) const +{ + if (UPaperSpriteComponent* SpriteComponent = FindComponentByClass()) + { + SpriteComponent->SetSpriteColor(Color); + } +} +``` + +The `Entity` class provides helper functions and basic functionality to manage game objects based on entity updates. + +> **Note:** One notable feature is linear interpolation (lerp) between the server-reported entity position and the client-drawn position. This technique produces smoother movement. +> +> If you're interested in learning more checkout [this demo](https://gabrielgambetta.com/client-side-prediction-live-demo.html) from Gabriel Gambetta. + +#### Circle Class + +Open `Circle.h` and update it as follows: + +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "Entity.h" +#include "Circle.generated.h" + +struct FCircleType; +class APlayerPawn; + +UCLASS() +class CLIENT_UNREAL_API ACircle : public AEntity +{ + GENERATED_BODY() + +public: + ACircle(); + + uint32 OwnerPlayerId = 0; + UPROPERTY(BlueprintReadOnly, Category="BH|Circle") + FString Username; + + void Spawn(const FCircleType& Circle, APlayerPawn* InOwner); + virtual void OnDelete(const FEventContext& Context) override; + + UFUNCTION(BlueprintCallable, Category="BH|Circle") + void SetUsername(const FString& InUsername); + + DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnUsernameChanged, const FString&, NewUsername); + UPROPERTY(BlueprintAssignable, Category="BH|Circle") + FOnUsernameChanged OnUsernameChanged; + +protected: + UPROPERTY(EditDefaultsOnly, Category="BH|Circle") + TArray ColorPalette; + +private: + TWeakObjectPtr Owner; +}; +``` + +Update `Circle.cpp` as follows: + +```cpp +#include "Circle.h" +#include "PlayerPawn.h" +#include "ModuleBindings/Types/CircleType.g.h" + +ACircle::ACircle() +{ + ColorPalette = { + // Yellow + FLinearColor::FromSRGBColor(FColor(175, 159, 49, 255)), + FLinearColor::FromSRGBColor(FColor(175, 116, 49, 255)), + + // Purple + FLinearColor::FromSRGBColor(FColor(112, 47, 252, 255)), + FLinearColor::FromSRGBColor(FColor(51, 91, 252, 255)), + + // Red + FLinearColor::FromSRGBColor(FColor(176, 54, 54, 255)), + FLinearColor::FromSRGBColor(FColor(176, 109, 54, 255)), + FLinearColor::FromSRGBColor(FColor(141, 43, 99, 255)), + + // Blue + FLinearColor::FromSRGBColor(FColor(2, 188, 250, 255)), + FLinearColor::FromSRGBColor(FColor(7, 50, 251, 255)), + FLinearColor::FromSRGBColor(FColor(2, 28, 146, 255)), + }; +} + +void ACircle::Spawn(const FCircleType& Circle, APlayerPawn* InOwner) +{ + Super::Spawn(Circle.EntityId); + + const int32 Index = ColorPalette.Num() ? static_cast(InOwner->PlayerId % ColorPalette.Num()) : 0; + const FLinearColor Color = ColorPalette.IsValidIndex(Index) ? ColorPalette[Index] : FLinearColor::Green; + SetColor(Color); + + this->Owner = InOwner; + SetUsername(InOwner->Username); +} + +void ACircle::OnDelete(const FEventContext& Context) +{ + Super::OnDelete(Context); + Owner->OnCircleDeleted(this); +} + +void ACircle::SetUsername(const FString& InUsername) +{ + if (Username.Equals(InUsername, ESearchCase::CaseSensitive)) + return; + + Username = InUsername; + OnUsernameChanged.Broadcast(Username); +} +``` + +At the top of the file, define possible colors for the circle. A spawn function creates an `ACircle` (the same type stored in the `circle` table) and an `APlayerPawn`. The function sets the circle’s color based on the player ID and updates the circle’s text with the player’s username. + +> **Note:** `ACircle` inherits from `AEntity`, not `AActor`. Compilation will fail until `APlayerPawn` is implemented. + +#### Food Class + +Open `Food.h` and update it as follows: + +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "Entity.h" +#include "Food.generated.h" + +struct FFoodType; + +UCLASS() +class CLIENT_UNREAL_API AFood : public AEntity +{ + GENERATED_BODY() + +public: + AFood(); + void Spawn(const FFoodType& FoodEntity); +protected: + UPROPERTY(EditDefaultsOnly, Category="BH|Food") + TArray ColorPalette; +}; +``` + +Update `Food.cpp` as follows: + +```cpp +#include "Food.h" +#include "ModuleBindings/Types/FoodType.g.h" + +AFood::AFood() +{ + ColorPalette = { + // Greenish + FLinearColor::FromSRGBColor(FColor(119, 252, 173, 255)), + FLinearColor::FromSRGBColor(FColor(76, 250, 146, 255)), + FLinearColor::FromSRGBColor(FColor(35, 246, 120, 255)), + + // Aqua / Teal + FLinearColor::FromSRGBColor(FColor(119, 251, 201, 255)), + FLinearColor::FromSRGBColor(FColor(76, 249, 184, 255)), + FLinearColor::FromSRGBColor(FColor(35, 245, 165, 255)), + }; +} + +void AFood::Spawn(const FFoodType& FoodEntity) +{ + Super::Spawn(FoodEntity.EntityId); + + const int32 Index = ColorPalette.Num() ? static_cast(EntityId % ColorPalette.Num()) : 0; + const FLinearColor Color = ColorPalette.IsValidIndex(Index) ? ColorPalette[Index] : FLinearColor::Green; + SetColor(Color); +} +``` + +#### PlayerPawn Class + +Open `PlayerPawn.h` and update it as follows: + +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Pawn.h" +#include "PlayerPawn.generated.h" + +class ACircle; +struct FPlayerType; + +UCLASS() +class CLIENT_UNREAL_API APlayerPawn : public APawn +{ + GENERATED_BODY() + +public: + APlayerPawn(); + void Initialize(FPlayerType Player); + + uint32 PlayerId = 0; + UPROPERTY(BlueprintReadOnly, Category="BH|Player") + FString Username; + UPROPERTY(BlueprintReadWrite, Category="BH|Player") + bool bIsLocalPlayer = false; + + UPROPERTY() + TArray> OwnedCircles; + + UFUNCTION() + void OnCircleSpawned(ACircle* Circle); + UFUNCTION() + void OnCircleDeleted(ACircle* Circle); + + uint32 TotalMass() const; + UFUNCTION(BlueprintPure, Category="BH|Player") + FVector CenterOfMass() const; + +protected: + virtual void Destroyed() override; + +public: + virtual void Tick(float DeltaTime) override; + +private: + UPROPERTY(EditDefaultsOnly, Category="BH|Net") + float SendUpdatesFrequency = 0.0333f; +}; +``` + +Next, add the implementation to `PlayerPawn.cpp`. +In the Blueprint we've set the `PlayerPawn` with a spring arm and camera, simplifying camera controls since the camera automatically follows the pawn. +You can see this behavior in the `Tick` function below: + +```cpp +#include "PlayerPawn.h" +#include "Circle.h" +#include "GameManager.h" +#include "Kismet/GameplayStatics.h" +#include "ModuleBindings/Tables/EntityTable.g.h" +#include "ModuleBindings/Types/EntityType.g.h" +#include "ModuleBindings/Types/PlayerType.g.h" + +APlayerPawn::APlayerPawn() +{ + PrimaryActorTick.bCanEverTick = true; +} + +void APlayerPawn::Initialize(FPlayerType Player) +{ + PlayerId = Player.PlayerId; + Username = Player.Name; + + if (Player.Identity == AGameManager::Instance->LocalIdentity) + { + bIsLocalPlayer = true; + if (APlayerController* PC = UGameplayStatics::GetPlayerController(GetWorld(), 0)) + { + PC->Possess(this); + } + } +} + +void APlayerPawn::OnCircleSpawned(ACircle* Circle) +{ + if (ensure(Circle)) + { + OwnedCircles.AddUnique(Circle); + } +} + +void APlayerPawn::OnCircleDeleted(ACircle* Circle) +{ + if (Circle) + { + for (int32 i = OwnedCircles.Num() - 1; i >= 0; --i) + { + if (!OwnedCircles[i].IsValid() || OwnedCircles[i].Get() == Circle) + { + OwnedCircles.RemoveAt(i); + } + } + } + + if (OwnedCircles.Num() == 0 && bIsLocalPlayer) + { + UE_LOG(LogTemp, Log, TEXT("Player has died!")); + } +} + +uint32 APlayerPawn::TotalMass() const +{ + uint32 Total = 0; + for (int32 Index = 0; Index < OwnedCircles.Num(); ++Index) + { + const TWeakObjectPtr& Weak = OwnedCircles[Index]; + if (!Weak.IsValid()) continue; + + const ACircle* Circle = Weak.Get(); + const uint32 Id = Circle->EntityId; + + const FEntityType Entity = AGameManager::Instance->Conn->Db->Entity->EntityId->Find(Id); + Total += Entity.Mass; + } + return Total; +} + +FVector APlayerPawn::CenterOfMass() const +{ + if (OwnedCircles.Num() == 0) + { + return FVector::ZeroVector; + } + + FVector WeightedPosition = FVector::ZeroVector; // Σ (pos * mass) + double TotalMass = 0.0; // Σ mass + + const int32 Count = OwnedCircles.Num(); + + for (int32 Index = 0; Index < Count; ++Index) + { + const TWeakObjectPtr& Weak = OwnedCircles[Index]; + if (!Weak.IsValid()) continue; + + const ACircle* Circle = Weak.Get(); + const uint32 Id = Circle->EntityId; + + const FEntityType Entity = AGameManager::Instance->Conn->Db->Entity->EntityId->Find(Id); + const double Mass = Entity.Mass; + + const FVector Loc = Circle->GetActorLocation(); + + if (Mass <= 0.0) continue; + + WeightedPosition += (Loc * Mass); + TotalMass += Mass; + } + + const FVector ActorLoc = GetActorLocation(); + + FVector Result = FVector::ZeroVector; + if (TotalMass > 0.0) + { + const FVector CalculatedCenter = WeightedPosition / TotalMass; + // Keep Z at the player's Z, per your original intent + Result = FVector(CalculatedCenter.X, ActorLoc.Y, CalculatedCenter.Z); + } + + return Result; +} + +void APlayerPawn::Destroyed() +{ + Super::Destroyed(); + for (TWeakObjectPtr& CirclePtr : OwnedCircles) + { + if (ACircle* Circle = CirclePtr.Get()) + { + Circle->Destroy(); + } + } + OwnedCircles.Empty(); +} + +void APlayerPawn::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); + + if (!bIsLocalPlayer || OwnedCircles.Num() == 0) + return; + + const FVector ArenaCenter(0.f, 1.f, 0.f); + FVector Target = ArenaCenter; + if (AGameManager::Instance->IsConnected()) + { + const FVector CoM = CenterOfMass(); + if (!CoM.ContainsNaN()) + { + Target = { CoM.X, 1.f, CoM.Z }; + } + } + const FVector NewLoc = FMath::VInterpTo(GetActorLocation(), Target, DeltaTime, 120.f); + SetActorLocation(NewLoc); +} +``` + +### Spawning Blueprints + +Update `GameManager.h` to support spawning Blueprints. +Make the following edits to the file: + +Add the code below after the `UDbConnection` forward declaration: + +```cpp +// ... +class UDbConnection; +class AEntity; +class ACircle; +class AFood; +class APlayerPawn; + +UCLASS() +class CLIENT_UNREAL_API AGameManager : public AActor +// ... +``` + +Add in public below the TokenFilePath: + +```cpp +class CLIENT_UNREAL_API AGameManager : public AActor +{ + GENERATED_BODY() + +public: + // ... + + UPROPERTY(EditAnywhere, Category="BH|Classes") + TSubclassOf CircleClass; + UPROPERTY(EditAnywhere, Category="BH|Classes") + TSubclassOf FoodClass; + UPROPERTY(EditAnywhere, Category="BH|Classes") + TSubclassOf PlayerClass; + + // ... +``` + +Below the `/* Border */` section, add code to link the SpacetimeDB tables to the `GameManager` and handle entity spawning: + +```cpp + // ... + /* Border */ + + /* Data Bindings */ + UPROPERTY() + TMap> EntityMap; + UPROPERTY() + TMap> PlayerMap; + + APlayerPawn* SpawnOrGetPlayer(const FPlayerType& PlayerRow); + ACircle* SpawnCircle(const FCircleType& CircleRow); + AFood* SpawnFood(const FFoodType& Food); + + UFUNCTION() + void OnCircleInsert(const FEventContext& Context, const FCircleType& NewRow); + UFUNCTION() + void OnEntityUpdate(const FEventContext& Context, const FEntityType& OldRow, const FEntityType& NewRow); + UFUNCTION() + void OnEntityDelete(const FEventContext& Context, const FEntityType& RemovedRow); + UFUNCTION() + void OnFoodInsert(const FEventContext& Context, const FFoodType& NewFood); + UFUNCTION() + void OnPlayerInsert(const FEventContext& Context, const FPlayerType& NewRow); + UFUNCTION() + void OnPlayerDelete(const FEventContext& Context, const FPlayerType& RemovedRow); + /* Data Bindings */ + + // ... +``` + +With the header updated, add the wiring for spawning entities with data from SpacetimeDB in `GameManager.cpp`. +As with the header, edit only the relevant parts of the file. + +First, update the includes: + +```cpp +#include "GameManager.h" +#include "Circle.h" +#include "Entity.h" +#include "Food.h" +#include "PlayerPawn.h" +#include "Components/InstancedStaticMeshComponent.h" +#include "Connection/Credentials.h" +#include "ModuleBindings/Tables/CircleTable.g.h" +#include "ModuleBindings/Tables/ConfigTable.g.h" +#include "ModuleBindings/Tables/EntityTable.g.h" +#include "ModuleBindings/Tables/FoodTable.g.h" +#include "ModuleBindings/Tables/PlayerTable.g.h" +``` + +Next, update `HandleConnect` to register the table-change handlers: + +```cpp +void AGameManager::HandleConnect(UDbConnection* InConn, FSpacetimeDBIdentity Identity, const FString& Token) +{ + UE_LOG(LogTemp, Log, TEXT("Connected.")); + UCredentials::SaveToken(Token); + LocalIdentity = Identity; + + Conn->Db->Circle->OnInsert.AddDynamic(this, &AGameManager::OnCircleInsert); + Conn->Db->Entity->OnUpdate.AddDynamic(this, &AGameManager::OnEntityUpdate); + Conn->Db->Entity->OnDelete.AddDynamic(this, &AGameManager::OnEntityDelete); + Conn->Db->Food->OnInsert.AddDynamic(this, &AGameManager::OnFoodInsert); + Conn->Db->Player->OnInsert.AddDynamic(this, &AGameManager::OnPlayerInsert); + Conn->Db->Player->OnDelete.AddDynamic(this, &AGameManager::OnPlayerDelete); + + FOnSubscriptionApplied AppliedDelegate; + BIND_DELEGATE_SAFE(AppliedDelegate, this, AGameManager, HandleSubscriptionApplied); + Conn->SubscriptionBuilder() + ->OnApplied(AppliedDelegate) + ->SubscribeToAllTables(); +} +``` + +Finally, add the new functions at the end of `GameManager.cpp` to handle entity spawning: + +```cpp +void AGameManager::OnCircleInsert(const FEventContext& Context, const FCircleType& NewRow) +{ + if (EntityMap.Contains(NewRow.EntityId)) return; + SpawnCircle(NewRow); +} + +void AGameManager::OnEntityUpdate(const FEventContext& Context, const FEntityType& OldRow, const FEntityType& NewRow) +{ + if (TWeakObjectPtr* WeakEntity = EntityMap.Find(NewRow.EntityId)) + { + if (!WeakEntity->IsValid()) + { + return; + } + if (AEntity* Entity = WeakEntity->Get()) + { + Entity->OnEntityUpdated(NewRow); + } + } +} + +void AGameManager::OnEntityDelete(const FEventContext& Context, const FEntityType& RemovedRow) +{ + TWeakObjectPtr EntityPtr; + const bool bHadEntry = EntityMap.RemoveAndCopyValue(RemovedRow.EntityId, EntityPtr); + const bool bIsValid =EntityPtr.IsValid(); + if (!bHadEntry || !bIsValid) + { + return; + } + + if (AEntity* Entity = EntityPtr.Get()) + { + Entity->OnDelete(Context); + } +} + +void AGameManager::OnFoodInsert(const FEventContext& Context, const FFoodType& NewRow) +{ + if (EntityMap.Contains(NewRow.EntityId)) return; + SpawnFood(NewRow); +} + +void AGameManager::OnPlayerInsert(const FEventContext& Context, const FPlayerType& NewRow) +{ + SpawnOrGetPlayer(NewRow); +} + +void AGameManager::OnPlayerDelete(const FEventContext& Context, const FPlayerType& RemovedRow) +{ + TWeakObjectPtr PlayerPtr; + const bool bHadEntry = PlayerMap.RemoveAndCopyValue(RemovedRow.PlayerId, PlayerPtr); + + if (!bHadEntry || !PlayerPtr.IsValid()) + { + return; + } + + if (APlayerPawn* Player = PlayerPtr.Get()) + { + Player->Destroy(); + } +} + +APlayerPawn* AGameManager::SpawnOrGetPlayer(const FPlayerType& PlayerRow) +{ + TWeakObjectPtr WeakPlayer = PlayerMap.FindRef(PlayerRow.PlayerId); + if (WeakPlayer.IsValid()) + { + return WeakPlayer.Get(); + } + + if (!PlayerClass) + { + UE_LOG(LogTemp, Error, TEXT("GameManager - PlayerClass not set.")); + return nullptr; + } + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + APlayerPawn* Player = GetWorld()->SpawnActor(PlayerClass, FVector::ZeroVector, FRotator::ZeroRotator, Params); + if (Player) + { + Player->Initialize(PlayerRow); + PlayerMap.Add(PlayerRow.PlayerId, Player); + } + return Player; +} + +ACircle* AGameManager::SpawnCircle(const FCircleType& CircleRow) +{ + if (!CircleClass) + { + UE_LOG(LogTemp, Error, TEXT("GameManager - CircleClass not set.")); + return nullptr; + } + // Need player row for username + const FPlayerType PlayerRow = Conn->Db->Player->PlayerId->Find(CircleRow.PlayerId); + APlayerPawn* OwningPlayer = SpawnOrGetPlayer(PlayerRow); + + FActorSpawnParameters Params; + auto* Circle = GetWorld()->SpawnActor(CircleClass, FVector::ZeroVector, FRotator::ZeroRotator, Params); + if (Circle) + { + Circle->Spawn(CircleRow, OwningPlayer); + EntityMap.Add(CircleRow.EntityId, Circle); + if (OwningPlayer) + OwningPlayer->OnCircleSpawned(Circle); + } + return Circle; +} + +AFood* AGameManager::SpawnFood(const FFoodType& FoodEntity) +{ + if (!FoodClass) + { + UE_LOG(LogTemp, Error, TEXT("GameManager - FoodClass not set.")); + return nullptr; + } + + FActorSpawnParameters Params; + AFood* Food = GetWorld()->SpawnActor(FoodClass, FVector::ZeroVector, FRotator::ZeroRotator, Params); + if (Food) + { + Food->Spawn(FoodEntity); + EntityMap.Add(FoodEntity.EntityId, Food); + } + return Food; +} +``` + +### Player Controller + +In most Unreal projects, proper input handling depends on setting up the PlayerController. +We’ll finish that setup in the next part of the tutorial. For now, add the possession logic. + +Edit `BlackholioPlayerController.h` as follows: + +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "PlayerPawn.h" +#include "GameFramework/PlayerController.h" +#include "BlackholioPlayerController.generated.h" + +class APlayerPawn; + +UCLASS() +class CLIENT_UNREAL_API ABlackholioPlayerController : public APlayerController +{ + GENERATED_BODY() + +public: + ABlackholioPlayerController(); + +protected: + virtual void Tick(float DeltaSeconds) override; + virtual void OnPossess(APawn* InPawn) override; + FVector2D ComputeDesiredDirection() const; + +private: + UPROPERTY() + TObjectPtr LocalPlayer; + + UPROPERTY() + float SendUpdatesFrequency = 0.0333f; + float LastMovementSendTimestamp = 0.f; + + TOptional LockInputPosition; +}; +``` + +Update `BlackholioPlayerController.cpp` (the movement logic will be added in the next part): + +```cpp +#include "BlackholioPlayerController.h" +#include "DbVector2.h" +#include "GameManager.h" +#include "PlayerPawn.h" + +ABlackholioPlayerController::ABlackholioPlayerController() +{ + bShowMouseCursor = true; + bEnableClickEvents = true; + bEnableMouseOverEvents = true; + PrimaryActorTick.bCanEverTick = true; +} + +void ABlackholioPlayerController::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); +} + +void ABlackholioPlayerController::OnPossess(APawn* InPawn) +{ + Super::OnPossess(InPawn); + LocalPlayer = Cast(InPawn); +} + +FVector2D ABlackholioPlayerController::ComputeDesiredDirection() const +{ + return FVector2D::ZeroVector; +} +``` + +### Entering the Game + +:::server-rust +At this point, you may need to regenerate your bindings the following command from the `server-rust` directory. +::: +:::server-csharp +At this point, you may need to regenerate your bindings the following command from the `server-csharp` directory. +::: + +```sh +spacetime generate --lang unrealcpp --uproject-dir ../client_unreal --project-path ./ --module-name client_unreal +``` + +The last step is to call the `enter_game` reducer on the server, passing in a username for the player. +For simplicity, call `enter_game` from the `HandleSubscriptionApplied` callback with the name `TestPlayer`. + +Open up `GameManager.cpp` and edit `HandleSubscriptionApplied` to match the following: + +```cpp +void AGameManager::HandleSubscriptionApplied(FSubscriptionEventContext& Context) +{ + UE_LOG(LogTemp, Log, TEXT("Subscription applied!")); + + // Once we have the initial subscription sync'd to the client cache + // Get the world size from the config table and set up the arena + uint64 WorldSize = Conn->Db->Config->Id->Find(0).WorldSize; + SetupArena(WorldSize); + + Context.Reducers->EnterGame("TestPlayer"); +} +``` + +> **Reminder:** Be sure to rebuild your project after making changes to the code. + +### Trying It Out + +Almost everything is ready to play. Before launching, set up the spawning classes: + +1. Open `BP_GameManager`. +2. Update the spawning classes: + - **Circle Class** → `BP_Circle` + - **Food Class** → `BP_Food` + - **Player Class** → `BP_PlayerPawn` + +> **Reminder:** Compile and save your changes. + +Next, wire up `SetUsername` to update the Nameplate: + +1. Open `BP_Circle`. +2. In **Event BeginPlay**, add the following: + +![Nameplate Update](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-3-04-nameplate-change.png) + +--- + +After publishing the module, press **Play** to see it in action. +You should see your player’s circle with its username label, surrounded by food. + +### Next Steps + +It's pretty cool to see our player in game surrounded by food, but there's a problem! We can't move yet. In the next part, we'll explore how to get your player moving and interacting with food and other objects. diff --git a/docs/docs/unreal/part-4-01-maincloud.png b/docs/docs/unreal/part-4-01-maincloud.png new file mode 100644 index 00000000000..a4ddb519970 Binary files /dev/null and b/docs/docs/unreal/part-4-01-maincloud.png differ diff --git a/docs/docs/unreal/part-4.md b/docs/docs/unreal/part-4.md new file mode 100644 index 00000000000..257d7cfafc1 --- /dev/null +++ b/docs/docs/unreal/part-4.md @@ -0,0 +1,661 @@ +# Unreal Tutorial - Part 4 - Moving and Colliding + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from [part 3](/docs/unreal/part-3). + +### Moving the player + +At this point, we're very close to having a working game. All we have to do is modify our server to allow the player to move around, and to simulate the physics and collisions of the game. + +:::server-rust +Let's start by building out a simple math library to help us do collision calculations. Create a new `math.rs` file in the `server-rust/src` directory and add the following contents. Let's also move the `DbVector2` type from `lib.rs` into this file. + +```rust +use spacetimedb::SpacetimeType; + +// This allows us to store 2D points in tables. +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub struct DbVector2 { + pub x: f32, + pub y: f32, +} + +impl std::ops::Add<&DbVector2> for DbVector2 { + type Output = DbVector2; + + fn add(self, other: &DbVector2) -> DbVector2 { + DbVector2 { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::Add for DbVector2 { + type Output = DbVector2; + + fn add(self, other: DbVector2) -> DbVector2 { + DbVector2 { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::AddAssign for DbVector2 { + fn add_assign(&mut self, rhs: DbVector2) { + self.x += rhs.x; + self.y += rhs.y; + } +} + +impl std::iter::Sum for DbVector2 { + fn sum>(iter: I) -> Self { + let mut r = DbVector2::new(0.0, 0.0); + for val in iter { + r += val; + } + r + } +} + +impl std::ops::Sub<&DbVector2> for DbVector2 { + type Output = DbVector2; + + fn sub(self, other: &DbVector2) -> DbVector2 { + DbVector2 { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +impl std::ops::Sub for DbVector2 { + type Output = DbVector2; + + fn sub(self, other: DbVector2) -> DbVector2 { + DbVector2 { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +impl std::ops::SubAssign for DbVector2 { + fn sub_assign(&mut self, rhs: DbVector2) { + self.x -= rhs.x; + self.y -= rhs.y; + } +} + +impl std::ops::Mul for DbVector2 { + type Output = DbVector2; + + fn mul(self, other: f32) -> DbVector2 { + DbVector2 { + x: self.x * other, + y: self.y * other, + } + } +} + +impl std::ops::Div for DbVector2 { + type Output = DbVector2; + + fn div(self, other: f32) -> DbVector2 { + if other != 0.0 { + DbVector2 { + x: self.x / other, + y: self.y / other, + } + } else { + DbVector2 { x: 0.0, y: 0.0 } + } + } +} + +impl DbVector2 { + pub fn new(x: f32, y: f32) -> Self { + Self { x, y } + } + + pub fn sqr_magnitude(&self) -> f32 { + self.x * self.x + self.y * self.y + } + + pub fn magnitude(&self) -> f32 { + (self.x * self.x + self.y * self.y).sqrt() + } + + pub fn normalized(self) -> DbVector2 { + self / self.magnitude() + } +} +``` + +At the very top of `lib.rs` add the following lines to import the moved `DbVector2` from the `math` module. + +```rust +pub mod math; + +use math::DbVector2; +// ... +``` + +Next, add the following reducer to your `lib.rs` file. + +```rust +#[spacetimedb::reducer] +pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender) + .ok_or("Player not found")?; + for mut circle in ctx.db.circle().player_id().filter(&player.player_id) { + circle.direction = direction.normalized(); + circle.speed = direction.magnitude().clamp(0.0, 1.0); + ctx.db.circle().entity_id().update(circle); + } + Ok(()) +} +``` + +This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender` value is not set by the client. Instead `ctx.sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +::: +:::server-csharp +Let's start by building out a simple math library to help us do collision calculations. Create a new `Math.cs` file in the `csharp-server` directory and add the following contents. Let's also remove the `DbVector2` type from `Lib.cs`. + +```csharp +[SpacetimeDB.Type] +public partial struct DbVector2 +{ + public float x; + public float y; + + public DbVector2(float x, float y) + { + this.x = x; + this.y = y; + } + + public float SqrMagnitude => x * x + y * y; + public float Magnitude => MathF.Sqrt(SqrMagnitude); + public DbVector2 Normalized => this / Magnitude; + + public static DbVector2 operator +(DbVector2 a, DbVector2 b) => new DbVector2(a.x + b.x, a.y + b.y); + public static DbVector2 operator -(DbVector2 a, DbVector2 b) => new DbVector2(a.x - b.x, a.y - b.y); + public static DbVector2 operator *(DbVector2 a, float b) => new DbVector2(a.x * b, a.y * b); + public static DbVector2 operator /(DbVector2 a, float b) => new DbVector2(a.x / b, a.y / b); +} +``` + +Next, add the following reducer to the `Module` class of your `Lib.cs` file. + +```csharp +[Reducer] +public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) +{ + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + foreach (var c in ctx.Db.circle.player_id.Filter(player.player_id)) + { + var circle = c; + circle.direction = direction.Normalized; + circle.speed = Math.Clamp(direction.Magnitude, 0f, 1f); + ctx.Db.circle.entity_id.Update(circle); + } +} +``` + +This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.Sender` value is not set by the client. Instead `ctx.Sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +::: + +Finally, let's schedule a reducer to run every 50 milliseconds to move the player's circles around based on the most recently set player input. + +:::server-rust +```rust +#[spacetimedb::table(name = move_all_players_timer, scheduled(move_all_players))] +pub struct MoveAllPlayersTimer { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} + +const START_PLAYER_SPEED: u32 = 10; + +fn mass_to_max_move_speed(mass: u32) -> f32 { + 2.0 * START_PLAYER_SPEED as f32 / (1.0 + (mass as f32 / START_PLAYER_MASS as f32).sqrt()) +} + +#[spacetimedb::reducer] +pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> { + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + // Handle player input + for circle in ctx.db.circle().iter() { + let circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id); + if !circle_entity.is_some() { + // This can happen if a circle is eaten by another circle + continue; + } + let mut circle_entity = circle_entity.unwrap(); + let circle_radius = mass_to_radius(circle_entity.mass); + let direction = circle.direction * circle.speed; + let new_pos = + circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + let min = circle_radius; + let max = world_size as f32 - circle_radius; + circle_entity.position.x = new_pos.x.clamp(min, max); + circle_entity.position.y = new_pos.y.clamp(min, max); + ctx.db.entity().entity_id().update(circle_entity); + } + + Ok(()) +} +``` +::: +:::server-csharp +```csharp +[Table(Name = "move_all_players_timer", Scheduled = nameof(MoveAllPlayers), ScheduledAt = nameof(scheduled_at))] +public partial struct MoveAllPlayersTimer +{ + [PrimaryKey, AutoInc] + public ulong scheduled_id; + public ScheduleAt scheduled_at; +} + +const uint START_PLAYER_SPEED = 10; + +public static float MassToMaxMoveSpeed(uint mass) => 2f * START_PLAYER_SPEED / (1f + MathF.Sqrt((float)mass / START_PLAYER_MASS)); + +[Reducer] +public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) +{ + var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + + var circle_directions = ctx.Db.circle.Iter().Select(c => (c.entity_id, c.direction * c.speed)).ToDictionary(); + + // Handle player input + foreach (var circle in ctx.Db.circle.Iter()) + { + var check_entity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (check_entity == null) + { + // This can happen if the circle has been eaten by another circle. + continue; + } + var circle_entity = check_entity.Value; + var circle_radius = MassToRadius(circle_entity.mass); + var direction = circle_directions[circle.entity_id]; + var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); + circle_entity.position.x = Math.Clamp(new_pos.x, circle_radius, world_size - circle_radius); + circle_entity.position.y = Math.Clamp(new_pos.y, circle_radius, world_size - circle_radius); + ctx.Db.entity.entity_id.Update(circle_entity); + } +} +``` +::: + +This reducer is very similar to a standard game "tick" or "frame" that you might find in an ordinary game server or similar to something like the `Tick` loop in a game engine like Unreal. We've scheduled it every 50 milliseconds and we can use it to step forward our simulation by moving all the circles a little bit further in the direction they're moving. + +In this reducer, we're just looping through all the circles in the game and updating their position based on their direction, speed, and mass. Just basic physics. + +:::server-rust +Add the following to your `init` reducer to schedule the `move_all_players` reducer to run every 50 milliseconds. + +```rust +ctx.db + .move_all_players_timer() + .try_insert(MoveAllPlayersTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()), + })?; +``` +::: +:::server-csharp +Add the following to your `Init` reducer to schedule the `MoveAllPlayers` reducer to run every 50 milliseconds. + +```csharp +ctx.Db.move_all_players_timer.Insert(new MoveAllPlayersTimer +{ + scheduled_at = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(50)) +}); +``` +::: + + +Republish your module with: + +```sh +spacetime publish --server local blackholio --delete-data +``` + +Regenerate your server bindings with: + +```sh +spacetime generate --lang unrealcpp --uproject-dir ../client_unreal --project-path ./ --module-name client_unreal +``` + +### Moving on the Client + +The final step is to update `BlackholioPlayerController` on the client to call the `update_player_input` reducer. +Open `BlackholioPlayerController.cpp` and replace the stubbed function added earlier with the following: + +```cpp +FVector2D ABlackholioPlayerController::ComputeDesiredDirection() const +{ + int32 SizeX = 0, SizeY = 0; + GetViewportSize(SizeX, SizeY); + if (SizeX <= 0 || SizeY <= 0) + { + return FVector2D::ZeroVector; + } + + const FVector2D ViewportCenter(static_cast(SizeX) * 0.5f, static_cast(SizeY) * 0.5f); + + FVector2D MousePos = ViewportCenter; + if (!LockInputPosition.IsSet()) + { + float MouseX = 0.f, MouseY = 0.f; + if (!GetMousePosition(MouseX, MouseY)) + { + return FVector2D::ZeroVector; + } + MousePos = FVector2D(MouseX, MouseY); + } + else + { + MousePos = LockInputPosition.GetValue(); + } + + if (MousePos.X < 0.f || MousePos.X >= SizeX || MousePos.Y < 0.f || MousePos.Y >= SizeY) + { + return FVector2D::ZeroVector; + } + + const float Denominator = FMath::Max(1.f, static_cast(SizeY) / 3.f); + const FVector2D DesiredDir = (MousePos - ViewportCenter) / Denominator; + return DesiredDir; +} +``` + +Finally, update the `Tick()` function to use this logic and trigger the reducer: + +```cpp +void ABlackholioPlayerController::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + + if (WasInputKeyJustPressed(EKeys::Q)) + { + if (LockInputPosition.IsSet()) + { + LockInputPosition.Reset(); + } + else + { + float X, Y; + if (GetMousePosition(X, Y)) + { + LockInputPosition = FVector2D(X, Y); + } + } + } + + const float Now = GetWorld() ? GetWorld()->GetTimeSeconds() : 0.f; + if ((Now - LastMovementSendTimestamp) >= SendUpdatesFrequency) + { + LastMovementSendTimestamp = Now; + FVector2D LatestDesiredDirection = ComputeDesiredDirection(); + // Send whatever the controller last told us. + if ((LatestDesiredDirection.X != 0 && LatestDesiredDirection.Y != 0)) + { + const FVector2D ConvertDirection = FVector2D(LatestDesiredDirection.X, LatestDesiredDirection.Y*-1); + AGameManager::Instance->Conn->Reducers->UpdatePlayerInput(ToDbVector(ConvertDirection)); + } + } +} +``` + +> **Reminder:** Be sure to rebuild your project after making changes to the code. + +Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. + +### Collisions and Eating Food + +Well this is pretty fun, but wouldn't it be better if we could eat food and grow our circle? Surely, that's going to be a pain, right? + +:::server-rust +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_player` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. + +Sometimes simple is best! Add the following code to your `lib.rs` file and make sure to replace the existing `move_all_players` reducer. + +```rust +const MINIMUM_SAFE_MASS_RATIO: f32 = 0.85; + +fn is_overlapping(a: &Entity, b: &Entity) -> bool { + let dx = a.position.x - b.position.x; + let dy = a.position.y - b.position.y; + let distance_sq = dx * dx + dy * dy; + + let radius_a = mass_to_radius(a.mass); + let radius_b = mass_to_radius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + let max_radius = f32::max(radius_a, radius_b); + distance_sq <= max_radius * max_radius +} + +#[spacetimedb::reducer] +pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> { + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + // Handle player input + for circle in ctx.db.circle().iter() { + let circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id); + if !circle_entity.is_some() { + // This can happen if a circle is eaten by another circle + continue; + } + let mut circle_entity = circle_entity.unwrap(); + let circle_radius = mass_to_radius(circle_entity.mass); + let direction = circle.direction * circle.speed; + let new_pos = + circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + let min = circle_radius; + let max = world_size as f32 - circle_radius; + circle_entity.position.x = new_pos.x.clamp(min, max); + circle_entity.position.y = new_pos.y.clamp(min, max); + + // Check collisions + for entity in ctx.db.entity().iter() { + if entity.entity_id == circle_entity.entity_id { + continue; + } + if is_overlapping(&circle_entity, &entity) { + // Check to see if we're overlapping with food + if ctx.db.food().entity_id().find(&entity.entity_id).is_some() { + ctx.db.entity().entity_id().delete(&entity.entity_id); + ctx.db.food().entity_id().delete(&entity.entity_id); + circle_entity.mass += entity.mass; + } + + // Check to see if we're overlapping with another circle owned by another player + let other_circle = ctx.db.circle().entity_id().find(&entity.entity_id); + if let Some(other_circle) = other_circle { + if other_circle.player_id != circle.player_id { + let mass_ratio = entity.mass as f32 / circle_entity.mass as f32; + if mass_ratio < MINIMUM_SAFE_MASS_RATIO { + ctx.db.entity().entity_id().delete(&entity.entity_id); + ctx.db.circle().entity_id().delete(&entity.entity_id); + circle_entity.mass += entity.mass; + } + } + } + } + } + ctx.db.entity().entity_id().update(circle_entity); + } + + Ok(()) +} +``` +::: +:::server-csharp +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `IsOverlapping` helper function which does some basic math based on mass radii, and modify our `MoveAllPlayers` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. + +Sometimes simple is best! Add the following code to the `Module` class of your `Lib.cs` file and make sure to replace the existing `MoveAllPlayers` reducer. + +```csharp +const float MINIMUM_SAFE_MASS_RATIO = 0.85f; + +public static bool IsOverlapping(Entity a, Entity b) +{ + var dx = a.position.x - b.position.x; + var dy = a.position.y - b.position.y; + var distance_sq = dx * dx + dy * dy; + + var radius_a = MassToRadius(a.mass); + var radius_b = MassToRadius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + var max_radius = radius_a > radius_b ? radius_a: radius_b; + return distance_sq <= max_radius * max_radius; +} + +[Reducer] +public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) +{ + var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + + // Handle player input + foreach (var circle in ctx.Db.circle.Iter()) + { + var check_entity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (check_entity == null) + { + // This can happen if the circle has been eaten by another circle. + continue; + } + var circle_entity = check_entity.Value; + var circle_radius = MassToRadius(circle_entity.mass); + var direction = circle.direction * circle.speed; + var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); + circle_entity.position.x = Math.Clamp(new_pos.x, circle_radius, world_size - circle_radius); + circle_entity.position.y = Math.Clamp(new_pos.y, circle_radius, world_size - circle_radius); + + // Check collisions + foreach (var entity in ctx.Db.entity.Iter()) + { + if (entity.entity_id == circle_entity.entity_id) + { + continue; + } + if (IsOverlapping(circle_entity, entity)) + { + // Check to see if we're overlapping with food + if (ctx.Db.food.entity_id.Find(entity.entity_id).HasValue) { + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.food.entity_id.Delete(entity.entity_id); + circle_entity.mass += entity.mass; + } + + // Check to see if we're overlapping with another circle owned by another player + var other_circle = ctx.Db.circle.entity_id.Find(entity.entity_id); + if (other_circle.HasValue && + other_circle.Value.player_id != circle.player_id) + { + var mass_ratio = (float)entity.mass / circle_entity.mass; + if (mass_ratio < MINIMUM_SAFE_MASS_RATIO) + { + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.circle.entity_id.Delete(entity.entity_id); + circle_entity.mass += entity.mass; + } + } + } + } + ctx.Db.entity.entity_id.Update(circle_entity); + } +} +``` +::: + +For every circle, we look at all other entities. If they are overlapping then for food, we add the mass of the food to the circle and delete the food, otherwise if it's a circle we delete the smaller circle and add the mass to the bigger circle. + +That's it. We don't even have to do anything on the client. + +```sh +spacetime publish --server local blackholio +``` + +Just update your module by publishing and you're on your way eating food! Try to see how big you can get! + +We didn't even have to update the client, because our client's `OnDelete` callbacks already handled deleting entities from the scene when they're deleted on the server. SpacetimeDB just synchronizes the state with your client automatically. + +Notice that the food automatically respawns as you vaccuum them up. This is because our scheduled reducer is automatically replacing the food 2 times per second, to ensure that there is always 600 food on the map. + +## Connecting to Maincloud + +- Publish to Maincloud `spacetime publish -s maincloud --delete-data` + - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). +- Update the URL in the Unreal project to: `https://maincloud.spacetimedb.com` +- Update the module name in the Unreal project to ``. +- Your `BP_GameManager` should look something like this: + +![Maincloud Setup](https://tmp-unreal-engine-tutorial-images.nyc3.digitaloceanspaces.com/part-4-01-maincloud.png) + +To delete your Maincloud database, you can run: `spacetime delete -s maincloud ` + +## Conclusion + +:::server-rust +So far you've learned how to configure a new Unreal project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `client_connected` and `init` and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module. +::: +:::server-csharp +So far you've learned how to configure a new Unreal project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `ClientConnected` and `Init` and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module. +::: + +You've also learned how view module logs and connect your client to your database server, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game! + +And all of that completely from scratch! + +Our game is still pretty limited in some important ways. The biggest limitation is that the client assumes your username is "TestPlayer" and doesn't give you a menu or a window to set your username before joining the game. Notably, we do not have a unique constraint on the `name` column, so that does not prevent us from connecting multiple clients to the same server. + +In fact, if you build what we have and run multiple clients you already have a (very simple) MMO! You can connect hundreds of players to this arena with SpacetimeDB. + +There's still plenty more we can do to build this into a proper game though. For example, you might want to also add + +- Username chooser +- Chat +- Leaderboards +- Nice animations +- Nice shaders +- Space theme! + +Fortunately, we've done that for you! If you'd like to check out the completed tutorial game, with these additional features, you can download it on GitHub: + +https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio + +If you have any suggestions or comments on the tutorial, either [open an issue](https://github.com/clockworklabs/SpacetimeDB/issues/new), or join our Discord (https://discord.gg/SpacetimeDB) and chat with us! diff --git a/docs/docs/unreal/reference.md b/docs/docs/unreal/reference.md new file mode 100644 index 00000000000..cea24e20b8b --- /dev/null +++ b/docs/docs/unreal/reference.md @@ -0,0 +1,745 @@ +# The SpacetimeDB Unreal client SDK + +The SpacetimeDB client for Unreal Engine contains all the tools you need to build native clients for SpacetimeDB modules using C++ and Blueprint. + +| Name | Description | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [Project setup](#project-setup) | Configure an Unreal project to use the SpacetimeDB Unreal client SDK. | +| [Generate module bindings](#generate-module-bindings) | Use the SpacetimeDB CLI to generate module-specific types and interfaces. | +| [DbConnection type](#type-dbconnection) | A connection to a remote database. | +| [Context interfaces](#context-interfaces) | Context objects for interacting with the remote database in callbacks. | +| [Access the client cache](#access-the-client-cache) | Access to your local view of the database. | +| [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | +| [Subscriptions](#subscriptions) | Subscribe to queries and manage subscription lifecycle. | +| [Identify a client](#identify-a-client) | Types for identifying users and client connections. | + +## Project setup + +### Using the Unreal Engine Plugin + +Add the SpacetimeDB Unreal SDK to your project as a plugin. The SDK provides both C++ and Blueprint APIs for connecting to SpacetimeDB modules. + +### Generate module bindings + +Each SpacetimeDB client depends on some bindings specific to your module. Generate the Unreal interface files using the Spacetime CLI. From your project directory, run: + +```bash +spacetime generate --lang unrealcpp --uproject-dir --project-path --module-name +``` + +Replace: + +- `` with the path to your Unreal project directory (containing the `.uproject` file) +- `` with the path to your SpacetimeDB module +- `` with the name of your Unreal module, typically the name of the project + +**Example:** + +```bash +spacetime generate --lang unrealcpp --uproject-dir /path/to/MyGame --project-path /path/to/quickstart-chat --module-name QuickstartChat +``` + +This generates module-specific bindings in your project's `ModuleBindings` directory. + +## Type `DbConnection` + +A connection to a remote database is represented by the `UDbConnection` class. This class is generated per module and contains information about the types, tables, and reducers defined by your module. + +| Name | Description | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| [Connect to a database](#connect-to-a-database) | Construct a UDbConnection instance. | +| [Advance the connection](#advance-the-connection-and-process-messages) | The connection processes messages automatically via WebSocket callbacks. | +| [Access tables and reducers](#access-tables-and-reducers) | Access the client cache, request reducer invocations, and register callbacks. | + +### Connect to a database + +```cpp +class UDbConnection +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + static UDbConnectionBuilder* Builder(); +}; +``` + +Construct a `UDbConnection` by calling `UDbConnection::Builder()`, chaining configuration methods, and finally calling `.Build()`. At a minimum, you must specify `WithUri` to provide the URI of the SpacetimeDB instance, and `WithModuleName` to specify the database's name or identity. + +| Name | Description | +| --------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [WithUri method](#method-withuri) | Set the URI of the SpacetimeDB instance hosting the remote database. | +| [WithModuleName method](#method-withmodulename) | Set the name or identity of the remote database. | +| [WithToken method](#method-withtoken) | Supply a token to authenticate with the remote database. | +| [WithCompression method](#method-withcompression) | Set the compression method for WebSocket communication. | +| [OnConnect callback](#callback-onconnect) | Register a callback to run when the connection is successfully established. | +| [OnConnectError callback](#callback-onconnecterror) | Register a callback to run if the connection is rejected or the host is unreachable. | +| [OnDisconnect callback](#callback-ondisconnect) | Register a callback to run when the connection ends. | +| [Build method](#method-build) | Finalize configuration and open the connection. | + +#### Method `WithUri` + +```cpp +class UDbConnectionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + UDbConnectionBuilder* WithUri(const FString& InUri); +}; +``` + +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module and database. + +#### Method `WithModuleName` + +```cpp +class UDbConnectionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + UDbConnectionBuilder* WithModuleName(const FString& InName); +}; +``` + +Configure the SpacetimeDB domain name or `Identity` of the remote database which identifies it within the SpacetimeDB instance or cluster. + +#### Method `WithToken` + +```cpp +class UDbConnectionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + UDbConnectionBuilder* WithToken(const FString& InToken); +}; +``` + +Chain a call to `.WithToken(token)` to your builder to provide an OpenID Connect compliant JSON Web Token to authenticate with, or to explicitly select an anonymous connection. + +#### Method `WithCompression` + +```cpp +class UDbConnectionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + UDbConnectionBuilder* WithCompression(const ESpacetimeDBCompression& InCompression); +}; +``` + +Set the compression method for WebSocket communication. Available options: + +- `ESpacetimeDBCompression::None` - No compression +- `ESpacetimeDBCompression::Gzip` - Gzip compression (default) +- `ESpacetimeDBCompression::Brotli` - Brotli compression (not implemented, defaults to Gzip) + +#### Callback `OnConnect` + +```cpp +class UDbConnectionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + UDbConnectionBuilder* OnConnect(FOnConnectDelegate Callback); +}; +``` + +Chain a call to `.OnConnect(callback)` to your builder to register a callback to run when your new `UDbConnection` successfully initiates its connection to the remote database. The callback accepts three arguments: a reference to the `UDbConnection`, the `FSpacetimeDBIdentity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to `WithToken` to authenticate the same user in future connections. + +#### Callback `OnConnectError` + +```cpp +class UDbConnectionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + UDbConnectionBuilder* OnConnectError(FOnConnectErrorDelegate Callback); +}; +``` + +Chain a call to `.OnConnectError(callback)` to your builder to register a callback to run when your connection fails. + +#### Callback `OnDisconnect` + +```cpp +class UDbConnectionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + UDbConnectionBuilder* OnDisconnect(FOnDisconnectDelegate Callback); +}; +``` + +Chain a call to `.OnDisconnect(callback)` to your builder to register a callback to run when your `UDbConnection` disconnects from the remote database, either as a result of a call to `Disconnect` or due to an error. + +#### Method `Build` + +```cpp +class UDbConnectionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + UDbConnection* Build(); +}; +``` + +Finalize configuration and open the connection. This creates a WebSocket connection to `ws:///v1/database//subscribe?compression=` and begins processing messages. + +### Advance the connection and process messages + +The Unreal SDK processes messages automatically via WebSocket callbacks and with UDbConnection which ultimately inherits from FTickableGameObject. No manual polling or advancement is required. Events are dispatched through the registered delegates. + +### Access tables and reducers + +```cpp +class UDbConnection +{ + UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") + URemoteTables* Db; + + UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") + URemoteReducers* Reducers; + + UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") + USetReducerFlags* SetReducerFlags; +}; +``` + +The `Db` property provides access to the client cache, the `Reducers` property allows invoking reducers, and the `SetReducerFlags` property configures reducer behavior. + +## Context interfaces + +Context objects provide access to the database and reducers within callback functions. All context types inherit from `FContextBase`. + +| Name | Description | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [FContextBase](#type-fcontextbase) | Base context providing access to Db and Reducers. | +| [FEventContext](#type-feventcontext) | Context for table row event callbacks. | +| [FReducerEventContext](#type-freducereventcontext) | Context for reducer event callbacks. | +| [FSubscriptionEventContext](#type-fsubscriptioneventcontext) | Context for subscription lifecycle callbacks. | +| [FErrorContext](#type-ferrorcontext) | Context for error callbacks. | + +### Type `FContextBase` + +```cpp +USTRUCT(BlueprintType) +struct FContextBase +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "SpacetimeDB") + URemoteTables* Db; + + UPROPERTY(BlueprintReadOnly, Category = "SpacetimeDB") + URemoteReducers* Reducers; + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + USubscriptionBuilder* SubscriptionBuilder(); + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + bool TryGetIdentity(FSpacetimeDBIdentity& OutIdentity) const; + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + FSpacetimeDBConnectionId GetConnectionId() const; +}; +``` + +Base context providing access to the client cache, reducers, subscription builder, and connection information. + +### Type `FEventContext` + +```cpp +USTRUCT(BlueprintType) +struct FEventContext : public FContextBase +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") + FSpacetimeDBEvent Event; +}; +``` + +Context passed to table row event callbacks (OnInsert, OnUpdate, OnDelete). + +### Type `FReducerEventContext` + +```cpp +USTRUCT(BlueprintType) +struct FReducerEventContext : public FContextBase +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") + FReducerEvent Event; +}; +``` + +Context passed to reducer event callbacks, containing information about the reducer execution. + +### Type `FSubscriptionEventContext` + +```cpp +USTRUCT(BlueprintType) +struct FSubscriptionEventContext : public FContextBase +{ + GENERATED_BODY() +}; +``` + +Context passed to subscription lifecycle callbacks (OnApplied, OnError). + +### Type `FErrorContext` + +```cpp +USTRUCT(BlueprintType) +struct FErrorContext : public FContextBase +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") + FString Error; +}; +``` + +Context passed to error callbacks, containing error information. + +## Access the client cache + +All context types provide access to the client cache through the `.Db` property, which contains generated table classes for each table defined by your module. + +Each table defined by a module has a corresponding generated class (e.g., `UUserTable`, `UMessageTable`) that inherits from `URemoteTable` and provides methods for accessing subscribed rows. + +| Name | Description | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [URemoteTable](#type-uremotetable) | Base class for all generated table classes. | +| [Unique constraint index access](#unique-constraint-index-access) | Seek a subscribed row by the value in its unique or primary key column. | +| [BTree index access](#btree-index-access) | Seek subscribed rows by the value in its indexed column. | + +### Type `URemoteTable` + +Generated table classes inherit from `URemoteTable` and provide the following interface: + +| Name | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------ | +| [Count method](#method-count) | The number of subscribed rows in the table. | +| [Iter method](#method-iter) | Iterate over all subscribed rows in the table. | +| [OnInsert callback](#callback-oninsert) | Register a callback to run whenever a row is inserted into the client cache. | +| [OnDelete callback](#callback-ondelete) | Register a callback to run whenever a row is deleted from the client cache. | +| [OnUpdate callback](#callback-onupdate) | Register a callback to run whenever a subscribed row is replaced with a new version. | + +#### Method `Count` + +```cpp +UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") +int32 Count() const; +``` + +The number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. + +#### Method `Iter` + +```cpp +UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") +TArray Iter() const; +``` + +An iterator over all the subscribed rows in the client cache, i.e. those which match any subscribed query. + +#### Callback `OnInsert` + +```cpp +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( + FOnTableInsert, + const FEventContext&, Context, + const RowType&, NewRow); + +UPROPERTY(BlueprintAssignable, Category = "SpacetimeDB Events") +FOnTableInsert OnInsert; +``` + +The `OnInsert` callback runs whenever a new row is inserted into the client cache, either when applying a subscription or being notified of a transaction. + +#### Callback `OnDelete` + +```cpp +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( + FOnTableDelete, + const FEventContext&, Context, + const RowType&, DeletedRow); + +UPROPERTY(BlueprintAssignable, Category = "SpacetimeDB Events") +FOnTableDelete OnDelete; +``` + +The `OnDelete` callback runs whenever a previously-resident row is deleted from the client cache. + +#### Callback `OnUpdate` + +```cpp +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams( + FOnTableUpdate, + const FEventContext&, Context, + const RowType&, OldRow, + const RowType&, NewRow); + +UPROPERTY(BlueprintAssignable, Category = "SpacetimeDB Events") +FOnTableUpdate OnUpdate; +``` + +The `OnUpdate` callback runs whenever an already-resident row in the client cache is updated, i.e. replaced with a new row that has the same primary key. + +### Unique constraint index access + +For each unique constraint on a table, its table class has a property which is a unique index handle. This unique index handle has a method `.Find(Column value)`. If a `Row` with `value` in the unique column is resident in the client cache, `.Find` returns it. Otherwise it returns null. + +#### Example + +Given the following module-side `User` definition: + +```cpp +USTRUCT() +struct FUserType +{ + GENERATED_BODY() + + UPROPERTY() + FSpacetimeDBIdentity Identity; // Unique constraint + // ... other fields +}; +``` + +a client would lookup a user as follows: + +```cpp +FUserType* FindUser(URemoteTables* Tables, FSpacetimeDBIdentity Id) +{ + return Tables->User->Identity->Find(Id); +} +``` + +### BTree index access + +For each btree index defined on a remote table, its corresponding table class has a property which is a btree index handle. This index handle has a method `TArray Filter(Column value)` which will return `Row`s with `value` in the indexed `Column`, if there are any in the cache. + +#### Example + +Given the following module-side `Player` definition: + +```cpp +USTRUCT() +struct FPlayerType +{ + GENERATED_BODY() + + UPROPERTY() + FSpacetimeDBIdentity Id; // Primary key + + UPROPERTY() + uint32 Level; // BTree index + // ... other fields +}; +``` + +a client would count the number of `Player`s at a certain level as follows: + +```cpp +int32 CountPlayersAtLevel(URemoteTables* Tables, uint32 Level) +{ + return Tables->Player->Level->Filter(Level).Num(); +} +``` + +## Observe and invoke reducers + +All context types provide access to reducers through the `.Reducers` property, which contains generated methods for invoking reducers defined by the module and registering callbacks. + +Each reducer defined by the module has methods on the `.Reducers`: + +* An invoke method, whose name matches the reducer's name (e.g., `SendMessage`, `SetName`). This requests that the module run the reducer. +* A callback registration delegate, whose name is prefixed with `On` (e.g., `OnSendMessage`, `OnSetName`). This registers a callback to run whenever we are notified that the reducer ran. + +### Invoke reducers + +```cpp +class URemoteReducers +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + void SendMessage(const FString& Text); + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + void SetName(const FString& Name); +}; +``` + +### Observe reducer events + +```cpp +class URemoteReducers +{ + DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( + FOnSendMessage, + const FReducerEventContext&, Context, + const FString&, Text); + + UPROPERTY(BlueprintAssignable, Category = "SpacetimeDB Events") + FOnSendMessage OnSendMessage; + + DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( + FOnSetName, + const FReducerEventContext&, Context, + const FString&, Name); + + UPROPERTY(BlueprintAssignable, Category = "SpacetimeDB Events") + FOnSetName OnSetName; +}; +``` + +### Reducer flags + +```cpp +class USetReducerFlags +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + void SendMessage(ECallReducerFlags Flag); + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + void SetName(ECallReducerFlags Flag); +}; +``` + +Configure how much data to receive when a reducer runs: + +- `ECallReducerFlags::FullUpdate` - Receive all table updates (default) +- `ECallReducerFlags::NoUpdate` - Don't receive table updates +- `ECallReducerFlags::LightUpdate` - Receive minimal table updates + +## Subscriptions + +Create subscriptions to receive updates for specific queries using the `USubscriptionBuilder` and `USubscriptionHandle` classes. + +| Name | Description | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [USubscriptionBuilder](#type-usubscriptionbuilder) | Build and configure subscriptions. | +| [USubscriptionHandle](#type-usubscriptionhandle) | Manage subscription lifecycle. | + +### Type `USubscriptionBuilder` + +```cpp +class USubscriptionBuilder +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + USubscriptionBuilder* OnApplied(FOnSubscriptionApplied Callback); + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + USubscriptionBuilder* OnError(FOnSubscriptionError Callback); + + UFUNCTION(BlueprintCallable, Category="SpacetimeDB") + USubscriptionHandle* Subscribe(const TArray& SQL); + + UFUNCTION(BlueprintCallable, Category="SpacetimeDB") + USubscriptionHandle* SubscribeToAllTables(); +}; +``` + +#### Method `OnApplied` + +```cpp +USubscriptionBuilder* OnApplied(FOnSubscriptionApplied Callback); +``` + +Register a callback to run when the subscription is successfully applied. + +#### Method `OnError` + +```cpp +USubscriptionBuilder* OnError(FOnSubscriptionError Callback); +``` + +Register a callback to run if the subscription fails. + +#### Method `Subscribe` + +```cpp +USubscriptionHandle* Subscribe(const TArray& SQL); +``` + +Subscribe to the provided SQL queries and return a handle for managing the subscription. + +#### Method `SubscribeToAllTables` + +```cpp +USubscriptionHandle* SubscribeToAllTables(); +``` + +Subscribe to all tables in the module (equivalent to `Subscribe({ "SELECT * FROM *" })`). + +### Type `USubscriptionHandle` + +```cpp +class USubscriptionHandle +{ + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + void Unsubscribe(); + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + void UnsubscribeThen(FSubscriptionEventDelegate OnEnd); + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + bool IsEnded() const; + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + bool IsActive() const; + + UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") + TArray GetQuerySqls() const; +}; +``` + +#### Method `Unsubscribe` + +```cpp +void Unsubscribe(); +``` + +Immediately cancel the subscription. + +#### Method `UnsubscribeThen` + +```cpp +void UnsubscribeThen(FSubscriptionEventDelegate OnEnd); +``` + +Cancel the subscription and invoke the provided callback when complete. + +#### Method `IsEnded` + +```cpp +bool IsEnded() const; +``` + +True once the subscription has ended. + +#### Method `IsActive` + +```cpp +bool IsActive() const; +``` + +True while the subscription is active. + +#### Method `GetQuerySqls` + +```cpp +TArray GetQuerySqls() const; +``` + +Get the SQL queries associated with this subscription. + +## Identify a client + +### Type `FSpacetimeDBIdentity` + +A unique public identifier for a client connected to a database. This is a 256-bit value. + +```cpp +USTRUCT(BlueprintType, Category = "SpacetimeDB") +struct FSpacetimeDBIdentity +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FSpacetimeDBUInt256 Value; + + // Comparison operators, constructors, etc. +}; +``` + +### Type `FSpacetimeDBConnectionId` + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same Identity. This is a 128-bit value. + +```cpp +USTRUCT(BlueprintType, Category = "SpacetimeDB") +struct FSpacetimeDBConnectionId +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FSpacetimeDBUInt128 Value; + + // Comparison operators, constructors, etc. +}; +``` + +### Type `FSpacetimeDBTimestamp` + +A point in time, measured in microseconds since the Unix epoch. + +```cpp +USTRUCT(BlueprintType, Category = "SpacetimeDB") +struct FSpacetimeDBTimestamp +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + int64 MicrosSinceEpoch; + + // Comparison operators, constructors, etc. +}; +``` + +## Example usage + +Here's a complete example of connecting to SpacetimeDB, subscribing to tables, and handling events: + +```cpp +// In your Actor's BeginPlay() +void AMyActor::BeginPlay() +{ + Super::BeginPlay(); + + // Setup connection callbacks + FOnConnectDelegate ConnectDelegate; + ConnectDelegate.BindDynamic(this, &AMyActor::OnConnected); + + FOnDisconnectDelegate DisconnectDelegate; + DisconnectDelegate.BindDynamic(this, &AMyActor::OnDisconnected); + + // Build and connect + Conn = UDbConnection::Builder() + ->WithUri(TEXT("127.0.0.1:3000")) + ->WithModuleName(TEXT("my-module")) + ->OnConnect(ConnectDelegate) + ->OnDisconnect(DisconnectDelegate) + ->Build(); + + // Register table callbacks + Conn->Db->User->OnInsert.AddDynamic(this, &AMyActor::OnUserInsert); + Conn->Db->User->OnUpdate.AddDynamic(this, &AMyActor::OnUserUpdate); + Conn->Db->User->OnDelete.AddDynamic(this, &AMyActor::OnUserDelete); + + // Register reducer callbacks + Conn->Reducers->OnSendMessage.AddDynamic(this, &AMyActor::OnSendMessage); + Conn->SetReducerFlags->SendMessage(ECallReducerFlags::FullUpdate); +} + +void AMyActor::OnConnected(UDbConnection* Connection, FSpacetimeDBIdentity Identity, const FString& Token) +{ + // Save token for future connections + UCredentials::SaveToken(Token); + + // Subscribe to all tables + USubscriptionHandle* Handle = Connection->SubscriptionBuilder() + ->OnApplied(FOnSubscriptionApplied::CreateUObject(this, &AMyActor::OnSubscriptionApplied)) + ->SubscribeToAllTables(); +} + +void AMyActor::OnUserInsert(const FEventContext& Context, const FUserType& NewRow) +{ + UE_LOG(LogTemp, Log, TEXT("User inserted: %s"), *NewRow.Name); +} + +void AMyActor::OnSendMessage(const FReducerEventContext& Context, const FString& Text) +{ + UE_LOG(LogTemp, Log, TEXT("Message sent: %s"), *Text); +} + +void AMyActor::SendMessage(const FString& Text) +{ + if (Conn && Conn->Reducers) + { + Conn->Reducers->SendMessage(Text); + } +} +``` + +Previous[Unreal Quickstart](unreal-quickstart.md)Next[Rust Quickstart](../rust/quickstart.md) diff --git a/docs/nav.ts b/docs/nav.ts index a1024b43279..8a2c9be138c 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -48,6 +48,13 @@ const nav: Nav = { page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), page('4 - Moving and Colliding', 'unity/part-4', 'unity/part-4.md'), + section('Unreal Tutorial - Basic Multiplayer'), + page('Overview', 'unreal', 'unreal/index.md'), + page('1 - Setup', 'unreal/part-1', 'unreal/part-1.md'), + page('2 - Connecting to SpacetimeDB', 'unreal/part-2', 'unreal/part-2.md'), + page('3 - Gameplay', 'unreal/part-3', 'unreal/part-3.md'), + page('4 - Moving and Colliding', 'unreal/part-4', 'unreal/part-4.md'), + section('CLI Reference'), page('CLI Reference', 'cli-reference', 'cli-reference.md'), page( @@ -87,6 +94,7 @@ const nav: Nav = { 'sdks/typescript/quickstart.md' ), page('TypeScript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + page('Unreal Reference', 'unreal/reference', 'unreal/reference.md'), section('SQL'), page('SQL Reference', 'sql', 'sql/index.md'),