Unreal Tutorial - Part 3 - Gameplay
Need help with the tutorial? Join our Discord server!
This progressive tutorial is continued from part 2.
Spawning Food
- Rust
- C#
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.
// 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.
const FOOD_MASS_MIN: i32 = 2;
const FOOD_MASS_MAX: i32 = 4;
const TARGET_FOOD_COUNT: usize = 600;
fn mass_to_radius(mass: i32) -> 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(())
}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.
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.
// 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.
const int FOOD_MASS_MIN = 2;
const int FOOD_MASS_MAX = 4;
const int TARGET_FOOD_COUNT = 600;
public static float MassToRadius(int 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 int Range(this Random rng, int min, int max) => (int)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.
We also added two helper functions so we can get a random range as either a int 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.
- Rust
- C#
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.
#[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.
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.
[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.
- Rust
- C#
#[spacetimedb::reducer]
pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), String> {
// ...
}[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.
- Rust
- C#
Let's modify our init reducer to schedule our spawn_food reducer to be called every 500 milliseconds.
#[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.
Let's modify our Init reducer to schedule our SpawnFood reducer to be called every 500 milliseconds.
[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:
- Rust
- C#
#[spacetimedb::table(name = logged_out_player)][Table(Name = "logged_out_player")]Your struct should now look like this:
- Rust
- C#
#[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: i32,
name: String,
}[Table(Name = "player", Public = true)]
[Table(Name = "logged_out_player")]
public partial struct Player
{
[PrimaryKey]
public Identity identity;
[Unique, AutoInc]
public int 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.
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.
- Rust
- C#
Next, modify your connect reducer and add a new disconnect reducer below it:
#[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(())
}Next, modify your Connect reducer and add a new Disconnect reducer below it:
[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_inboolean to thePlayertype 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
ifstatements or branching- The
Playertype 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
playertableThis approach is more generally referred to as existence based processing 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.
- Rust
- C#
Add the following to the bottom of your file.
const START_PLAYER_MASS: i32 = 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: i32) -> Result<Entity, String> {
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: i32,
mass: i32,
position: DbVector2,
timestamp: Timestamp,
) -> Result<Entity, String> {
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.
Add the following to the end of the Module class.
const int 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, int 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, int player_id, int 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.
- Rust
- C#
#[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(())
}[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:
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.
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.
- C++
- Blueprint
Add the SetupArena and CreateBorderCube methods and properties to your GameManager.h class. Place them below the Handle{} functions in the private block:
/* Border */
UFUNCTION()
void SetupArena(int64 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:
#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:
AGameManager::AGameManager()
{
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = true;
BorderISM = CreateDefaultSubobject<UInstancedStaticMeshComponent>(TEXT("BorderISM"));
SetRootComponent(BorderISM);
if (CubeMesh != nullptr)
return;
static ConstructorHelpers::FObjectFinder<UStaticMesh> 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:
void AGameManager::SetupArena(int64 WorldSizeMeters)
{
if (!BorderISM || !CubeMesh) return;
BorderISM->ClearInstances();
BorderISM->SetStaticMesh(CubeMesh);
if (BorderMaterial)
{
BorderISM->SetMaterial(0, BorderMaterial);
}
// Convert from meters (int64) → centimeters (double for precision)
const double worldSizeCmDouble = static_cast<double>(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<float>(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:
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
int64 WorldSize = Conn->Db->Config->Id->Find(0).WorldSize;
SetupArena(WorldSize);
}Open BP_GameManager and update to the following:
- Add a Variable
- Change Variable Name to
BorderThickness - Change Variable Type to Float
- Change Default Value to
50.0 - Change Category to
Arena
- Change Variable Name to
- Add a Variable
- Change Variable Name to
BorderHeight - Change Variable Type to Float
- Change Default Value to
100.0 - Change Category to
Arena
- Change Variable Name to
- Add a Variable
- Change Variable Name to
BorderMaterial - Change Variable Type to Material Instance > Object Reference
- Change Default Value to
BasicShapeMaterial_Inst - Change Category to
Arena
- Change Variable Name to
- Add a Component
- Click Add button
- Select Instanced Static Mesh
- Rename to
BorderISM
Add the CreateBorderCube and SetupArena functions and properties to BP_GameManager:
Add Function named CreateBorderCube as follows:

- Add Input as
Positionwith Vector 2D as the type. - Add Input as
Sizewith Vector 2D as the type.
Add Function named SetupArena as follows:


- Add Input as
WorldSizeMeterswith Integer 64 as the type. - Add Local Variable as
WorldSizeCmwith Float as the type. - Add Local Variable as
HalfWorldSizewith Float as the type. - Add Local Variable as
BorderWidthwith Float as the type. - Add Local Variable as
HalfBorderwith Float as the type.
Add Function named IsConnected as follows:

- Add Output as
Resultwith Boolean as the type. - Check Pure
In OnApplied_Event, call the SetupArena function. Update OnApplied_Event as follows:

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.
- C++
- Blueprint
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):
After creating the first class, wait for Live Coding to finish before creating the next classes.
- Parent: Actor · Class Type: Public · Class Name:
Entity - Parent: All Classes -> Entity · Class Type: Public · Class Name:
Circle - Parent: All Classes -> Entity · Class Type: Public · Class Name:
Food - Parent: Pawn · Class Type: Public · Class Name:
PlayerPawn - Parent: Player Controller · Class Type: Public · Class Name:
BlackholioPlayerController - Parent: None · Class Type: Public · Class Name:
DbVector2
Next add blueprints for our these classes:

-
Circle Blueprint
- In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
- Expand All Classes, search for
Circle, highlightCircle, and click Select. - Rename the new Blueprint to
BP_Circle.
-
Food Blueprint
- In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
- Expand All Classes, search for
Food, highlightFood, and click Select. - Rename the new Blueprint to
BP_Food.
-
Player Blueprint
- In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
- Expand All Classes, search for
PlayerPawn, highlightPlayerPawn, and click Select. - Rename the new Blueprint to
BP_PlayerPawn.
-
Player Controller Blueprint
- In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
- Expand All Classes, search for
BlackholioPlayerController, highlightBlackholioPlayerController, 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.
Add blueprints for our entities:
-
Entity Blueprint
- In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
- Click Actor.
- Rename the new Blueprint to
BP_Entity.
-
Circle Blueprint
- In the Content Drawer, find and right-click BP_Entity and choose Create Child Blueprint Class.
- Rename the new Blueprint to
BP_Circle.
-
Food Blueprint
- In the Content Drawer, find and right-click BP_Entity and choose Create Child Blueprint Class.
- Rename the new Blueprint to
BP_Food.
-
Player Blueprint
- In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
- Click Pawn.
- Rename the new Blueprint to
BP_PlayerPawn.
-
Player Controller Blueprint
- In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
- Click Player Controller.
- Rename the new Blueprint to
BP_PlayerController. - Open Window -> World Settings in the top menu.
- Change Player Controller Class from PlayerController to
BP_PlayerController. - 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:
- In the Palette on the left, search for Size Box and drag it into the Hierarchy under
WBP_Nameplate. - Search the Palette for Text and drag it under the Size Box.
- 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.
- Rename it to

Finally, add Blueprint logic so the circle can update its nameplate:
- In the
WBP_Nameplateeditor, open the Graph tab (top right). - Click the + button next to My Blueprint -> Functions and name the new function
UpdateText. - Select
UpdateTextin the editor, then in Details -> Inputs, add a variable namedTextof typeString. - Drag TextBlock into the graph and choose Get TextBlock.
- Drag off TextBlock and search for Set Text.
- Connect UpdateText to Set Text, then connect UpdateText -> Text to Set Text -> Text.
- A conversion from
StringtoTextis added automatically; this is expected.
- A conversion from
- Click Save and Compile.

Set Up Circle Entity Blueprint
Import and set up the circle sprite:
-
Right-click the image below and save it locally:
-
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:
-
Select DefaultSceneRoot, add a Components -> Paper Sprite component, and rename it
Circle.- In the Details panel, set Scale to
0.4for all three axes. - Set Source Sprite to
Circle_Sprite.
- In the Details panel, set Scale to
-
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
- Widget Class to
- In the Details panel, set Location to
-
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:
- Select DefaultSceneRoot, add a Components -> Paper Sprite component, and rename it
Circle.- In the Details panel, set Scale to
0.4for all three axes. - Set Source Sprite to
Circle_Sprite.
- In the Details panel, set Scale to
- 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:
- Select DefaultSceneRoot, add a Components -> Spring Arm component.
- Select SpringArm, add a Components -> Camera component.
- Select SpringArm
- In the Details panel, set:
- Location to
0, 15000, 0 - Rotation to
0, 0, -90 - Target Arm Length to
200
- Location to
- In the Details panel, set:
- Click Save and Compile.
Make sure the Camera component's Location and Rotation are 0, 0, 0
Update Classes
- C++
- Blueprint
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:
#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);
}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:
#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:
int32 EntityId = 0;
virtual void Tick(float DeltaTime) override;
void Spawn(int32 InEntityId);
virtual void OnEntityUpdated(const FEntityType& NewVal);
virtual void OnDelete(const FEventContext& Context);
void SetColor(const FLinearColor& Color) const;
static float MassToRadius(int32 Mass) { return FMath::Sqrt(static_cast<float>(Mass)); }
static float MassToDiameter(int32 Mass) { return MassToRadius(Mass) * 2.f; }
};Update Entity.cpp as follows:
#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(int32 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<UPaperSpriteComponent>())
{
SpriteComponent->SetSpriteColor(Color);
}
}The Entity class provides helper functions and basic functionality to manage game objects based on entity updates.
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 from Gabriel Gambetta.
Circle Class
Open Circle.h and update it as follows:
#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();
int32 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<FLinearColor> ColorPalette;
private:
TWeakObjectPtr<APlayerPawn> Owner;
};Update Circle.cpp as follows:
#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<int32>(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.
ACircle inherits from AEntity, not AActor. Compilation will fail until APlayerPawn is implemented.
Food Class
Open Food.h and update it as follows:
#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<FLinearColor> ColorPalette;
};Update Food.cpp as follows:
#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<int32>(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:
#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);
int32 PlayerId = 0;
UPROPERTY(BlueprintReadOnly, Category="BH|Player")
FString Username;
UPROPERTY(BlueprintReadWrite, Category="BH|Player")
bool bIsLocalPlayer = false;
UPROPERTY()
TArray<TWeakObjectPtr<ACircle>> OwnedCircles;
UFUNCTION()
void OnCircleSpawned(ACircle* Circle);
UFUNCTION()
void OnCircleDeleted(ACircle* Circle);
int32 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:
#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!"));
}
}
int32 APlayerPawn::TotalMass() const
{
int32 Total = 0;
for (int32 Index = 0; Index < OwnedCircles.Num(); ++Index)
{
const TWeakObjectPtr<ACircle>& Weak = OwnedCircles[Index];
if (!Weak.IsValid()) continue;
const ACircle* Circle = Weak.Get();
const int32 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<ACircle>& Weak = OwnedCircles[Index];
if (!Weak.IsValid()) continue;
const ACircle* Circle = Weak.Get();
const int32 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<ACircle>& 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);
}Entity Blueprint
With the foundation in place, implement the core entity class. Edit BP_Entity add the following Variables:
- Add
LerpStartPosition- Change Variable Type to Vector
- Add
LerpTargetPosition- Change Variable Type to Vector
- Add
TargetScale- Change Variable Type to Float
- Change Default Value to
1.0
- Add
LerpTime- Change Variable Type to Float
- Add
LerpDuration- Change Variable Type to Float
- Change Default Value to
0.1
- Add
EntityId- Change Variable Type to Integer
- Add
Alpha- Change Variable Type to Float

Add the following to Event Tick:

Add Function named MassToRadius as follows:

- Add Input as
Masswith Integer as the type. - Add Output as
Radiuswith Float as the type.
Add Function named MassToDiameter as follows:

- Add Input as
Masswith Integer as the type. - Add Output as
Diameterwith Float as the type.
Add Function named OnUpdated as follows:

- Add Input as
NewRowwith Entity Type as the type.
Add Function named OnDeleted as follows:

- Add Input as
Contextwith Event Context as the type.
Add Function named Spawn as follows:

- Add Input as
In Entity Idwith Integer as the type.
Add Function named SetColor as follows:

- Add Input as
Colorwith Linear Color as the type.
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 from Gabriel Gambetta.
PlayerPawn Blueprint
Open BP_PlayerPawn and add the following Variables:
- Add
Username- Change Variable Type to String
- Check Instance Editable
- Add
PlayerId- Change Variable Type to an Integer
- Add
IsLocalPlayer- Change Variable Type to an Boolean
- Add
OwnedCircles- Change Variable Type to an Array BP Circle -> Object References
- Add
GameManager- Change Variable Type to an BP GameManager -> Object References
- Add
Target- Change Variable Type to an Vector

Add Function named GetGameManager as follows:

- Add Output as
GameManagerwith BP Game Manager as the type.
Add Function named Initialize as follows:

- Add Input as
PlayerRowwith Player Type as the type.
Add Function named OnCircleSpawned as follows:

- Add Input as
Circlewith BP Circle -> Object Reference as the type.
Add Function named OnCircleDeleted as follows:

- Add Input as
Circlewith BP Circle -> Object Reference as the type.
Add Function named CenterOfMass as follows:

- Add Output as
Centerwith Vector as the type. - Add Local Variable as
WeightedPositionwith Vector as the type. - Add Local Variable as
TotalMasswith Float as the type.
Add Function named UpdateTargetLocation as follows:

Add Function named GetUsername as follows:

- Add Output as
Outputwith String as the type. - Check Pure
Update Event Tick to:

Update Event Destroyed to:

Circle Blueprint
Open BP_Circle and add the following Variables:
- Add
OwningPlayer- Change Variable Type to BP Player Pawn -> Object Reference
- Add
ColorPalette- Change Variable Type to an Array of Linear Color

Override Function OnDeleted as follows:

- Add Input as
Contextwith Entity Context as the type.
Add Function named SpawnCircle as follows:

- Add Input as
Circlewith Circle Type as the type. - Add Input as
InOwnerwith BP Player Pawn -> Object Reference as the type
Food Blueprint
Open BP_Food and add the following Variables:
- Add
ColorPalette- Change Variable Type to an Array of Linear Color

Add Function named SpawnFood as follows:

- Add Input as
Food Entitywith Food Type as the type.
Spawning Blueprints
- C++
- Blueprint
Update GameManager.h to support spawning Blueprints.
Make the following edits to the file:
Add the code below after the UDbConnection forward declaration:
// ...
class UDbConnection;
class AEntity;
class ACircle;
class AFood;
class APlayerPawn;
UCLASS()
class CLIENT_UNREAL_API AGameManager : public AActor
// ...Add in public below the TokenFilePath:
class CLIENT_UNREAL_API AGameManager : public AActor
{
GENERATED_BODY()
public:
// ...
UPROPERTY(EditAnywhere, Category="BH|Classes")
TSubclassOf<ACircle> CircleClass;
UPROPERTY(EditAnywhere, Category="BH|Classes")
TSubclassOf<AFood> FoodClass;
UPROPERTY(EditAnywhere, Category="BH|Classes")
TSubclassOf<APlayerPawn> PlayerClass;
// ...Below the /* Border */ section, add code to link the SpacetimeDB tables to the GameManager and handle entity spawning:
// ...
/* Border */
/* Data Bindings */
UPROPERTY()
TMap<int32, TWeakObjectPtr<AEntity>> EntityMap;
UPROPERTY()
TMap<int32, TWeakObjectPtr<APlayerPawn>> 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:
#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:
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:
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<AEntity>* 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<AEntity> 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<APlayerPawn> 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<APlayerPawn> 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<APlayerPawn>(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<ACircle>(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<AFood>(FoodClass, FVector::ZeroVector, FRotator::ZeroRotator, Params);
if (Food)
{
Food->Spawn(FoodEntity);
EntityMap.Add(FoodEntity.EntityId, Food);
}
return Food;
}Open BP_GameManager and add the following Variables:
- Add
CircleClass- Change Variable Type to BP Circle -> Class Reference
- Check Instance Editable
- Change Category to
Classes - Change Default Value to
BP_Circle
- Add
FoodClass- Change Variable Type to BP Food -> Class Reference
- Check Instance Editable
- Change Category to
Classes - Change Default Value to
BP_Food
- Add
PlayerClass- Change Variable Type to BP Player Pawn -> Class Reference
- Check Instance Editable
- Change Category to
Classes - Change Default Value to
BP_PlayerPawn
- Add
EntityMap- Change Variable Type to an Integer
- Change Variable Type to Map and set value type to BP Entity -> Object Reference
- Add
PlayerMap- Change Variable Type to an Integer
- Change Variable Type to Map and set value type to BP Player Pawn -> Object Reference

Add Function named SpawnOrGetPlayer as follows:

- Add Input as
PlayerRowwith Player Type as the type. - Add Output as
PlayerPawnwith BP Player Pawn -> Object Reference as the type.
With the functions and variables in place next we'll expand the EventGraph:
Extened OnConnect_Event as follows:

Note: For the events the naming scheme for this tutorial is
<Type>_<Event>_Eventfor exampleCircle_OnInsert_Event.
Update Circle_OnInsert_Event as follows:

Update Entity_OnUpdate_Event as follows:

Update Entity_OnDelete_Event as follows:

Update Food_OnInsert_Event as follows:

Update Player_OnInsert_Event and Player_OnDelete_Event as follows:

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.
- C++
- Blueprint
Edit BlackholioPlayerController.h as follows:
#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<APlayerPawn> LocalPlayer;
UPROPERTY()
float SendUpdatesFrequency = 0.0333f;
float LastMovementSendTimestamp = 0.f;
TOptional<FVector2D> LockInputPosition;
};Update BlackholioPlayerController.cpp (the movement logic will be added in the next part):
#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<APlayerPawn>(InPawn);
}
FVector2D ABlackholioPlayerController::ComputeDesiredDirection() const
{
return FVector2D::ZeroVector;
}Last update BP_PlayerController for the basics by adding the following Variables:
- Add
GameManger- Change Variable Type to BP Game Manager -> Object Reference
- Add
LocalPlayer- Change Variable Type to BP Player Pawn -> Object Reference
- Add
LastMovementSendTime- Change Variable Type to Float
- Add
SendUpdateFrequency- Change Variable Type to Float
- Change Default Value to
0.0333
Add Function named GetGameManager as follows:

- Add Output as
GameManagerwith BP Game Manager as the type.
Override Function -> On Possess as follows:

Update Event BeginPlay as follows:

Entering the Game
- Rust
- C#
At this point, you may need to regenerate your bindings the following command from the server-rust directory.
At this point, you may need to regenerate your bindings the following command from the server-csharp directory.
spacetime generate --lang unrealcpp --uproject-dir ../client_unreal --project-path ./ --module-name client_unreal
- C++
- Blueprint
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:
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
int64 WorldSize = Conn->Db->Config->Id->Find(0).WorldSize;
SetupArena(WorldSize);
Context.Reducers->EnterGame("TestPlayer");
}Be sure to rebuild your project after making changes to the code.
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 OnApplied_Event callback with the name TestPlayer.
Open up BP_GameManager and edit OnApplied_Event to match the following:

Trying It Out
- C++
- Blueprint
Almost everything is ready to play. Before launching, set up the spawning classes:
- Open
BP_GameManager. - Update the spawning classes:
- Circle Class →
BP_Circle - Food Class →
BP_Food - Player Class →
BP_PlayerPawn
- Circle Class →
Compile and save your changes.
Next, wire up SetUsername to update the Nameplate:
- Open
BP_Circle. - In Event BeginPlay, add the following:

Almost everything is ready to play. Before launching, set up the spawning classes:
- Open
BP_GameManager. - Update the spawning classes:
- Circle Class →
BP_Circle - Food Class →
BP_Food - Player Class →
BP_PlayerPawn
- Circle Class →
Compile and save your changes.
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.