Unreal Tutorial - Part 3 - Gameplay
Need help with the tutorial? Join our Discord server!
This progressive tutorial is continued from part 2.
Spawning Food
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: 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(())
}
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 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.
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.
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.
#[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
.
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(())
}
Note
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 useScheduleAt::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))
});
}
Note
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 useScheduleAt.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:
#[spacetimedb::table(name = logged_out_player)]
[Table(Name = "logged_out_player")]
Your struct should now look like this:
#[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,
}
[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.
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_in
boolean to thePlayer
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
tableThis 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.
Add the following to the bottom of your file.
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<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: u32,
mass: u32,
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 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.
#[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.
Note
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:
/* 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:
#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(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<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
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
Note: 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.
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_Nameplate
editor, open the Graph tab (top right). - Click the + button next to My Blueprint -> Functions and name the new function
UpdateText
. - Select
UpdateText
in the editor, then in Details -> Inputs, add a variable namedText
of 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
String
toText
is 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.4
for 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.4
for 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.
Note
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:
#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
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:
#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<float>(Mass)); }
static float MassToDiameter(uint32 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(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<UPaperSpriteComponent>())
{
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 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();
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<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.
Note
Note:
ACircle
inherits fromAEntity
, notAActor
. Compilation will fail untilAPlayerPawn
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);
uint32 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);
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:
#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<ACircle>& 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<ACircle>& 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<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);
}
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:
// ...
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<uint32, TWeakObjectPtr<AEntity>> EntityMap;
UPROPERTY()
TMap<uint32, 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;
}
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:
#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;
}
Entering the Game
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
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
uint64 WorldSize = Conn->Db->Config->Id->Find(0).WorldSize;
SetupArena(WorldSize);
Context.Reducers->EnterGame("TestPlayer");
}
Note
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:
- Open
BP_GameManager
. - Update the spawning classes:
- Circle Class →
BP_Circle
- Food Class →
BP_Food
- Player Class →
BP_PlayerPawn
- Circle Class →
Note
Reminder: Compile and save your changes.
Next, wire up SetUsername
to update the Nameplate:
- Open
BP_Circle
. - In Event BeginPlay, add the following:
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.