docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md
import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import { CppModuleVersionNotice } from "@site/src/components/CppModuleVersionNotice";
Need help with the tutorial? Join our Discord server!
This progressive tutorial is continued from part 2.
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.
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.
Add this new reducer above our connect reducer.
// Note the SPACETIMEDB_INIT macro.
// This indicates to SpacetimeDB that it should be called
// once upon database creation.
SPACETIMEDB_INIT(init, ReducerContext ctx) {
LOG_INFO("Initializing...");
ctx.db[config].insert(Config{0, 1000});
return 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 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 file.
const int32_t FOOD_MASS_MIN = 2;
const int32_t FOOD_MASS_MAX = 4;
const size_t TARGET_FOOD_COUNT = 600;
float mass_to_radius(int32_t mass) {
return std::sqrt(static_cast<float>(mass));
}
SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx) {
// Check if there are any players logged in
bool has_players = false;
for (const auto& _ : ctx.db[player]) {
has_players = true;
break;
}
if (!has_players) {
// Are there no logged in players? Skip food spawn.
return Ok();
}
auto config_opt = ctx.db[config_id].find(0);
if (!config_opt.has_value()) {
return Err("Config not found");
}
int64_t world_size = config_opt.value().world_size;
auto& rng = ctx.rng();
// Count current food
uint64_t food_count = 0;
for (const auto& _ : ctx.db[food]) {
food_count++;
}
while (food_count < TARGET_FOOD_COUNT) {
int32_t food_mass = rng.gen_range(FOOD_MASS_MIN, FOOD_MASS_MAX);
float food_radius = mass_to_radius(food_mass);
float x = rng.gen_range(food_radius, static_cast<float>(world_size) - food_radius);
float y = rng.gen_range(food_radius, static_cast<float>(world_size) - food_radius);
auto inserted_entity = ctx.db[entity].insert(Entity{0, {x, y}, food_mass});
ctx.db[food].insert(Food{inserted_entity.entity_id});
food_count += 1;
LOG_INFO("Spawned food! " + std::to_string(inserted_entity.entity_id));
}
return 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.
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.
<Tabs groupId="server-language" defaultValue="rust"> <TabItem value="csharp" label="C#"> 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(Accessor = "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 schedule 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.
</TabItem>
<TabItem value="rust" label="Rust">
In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the file, below your imports.
#[spacetimedb::table(accessor = 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 schedule 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.
</TabItem>
<TabItem value="cpp" label="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 the Player table.
struct SpawnFoodTimer {
uint64_t scheduled_id;
ScheduleAt scheduled_at;
};
SPACETIMEDB_STRUCT(SpawnFoodTimer, scheduled_id, scheduled_at);
SPACETIMEDB_TABLE(SpawnFoodTimer, spawn_food_timer, Private);
FIELD_PrimaryKeyAutoInc(spawn_food_timer, scheduled_id);
SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food);
Note the SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food) call. This tells SpacetimeDB that the rows in this table specify a schedule for when the spawn_food reducer should be called. The second parameter 1 is the 0-based column index of the scheduled_at field. Each schedule 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.
</TabItem>
</Tabs>
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.
[Reducer]
public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer _timer)
{
// ...
}
#[spacetimedb::reducer]
pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), String> {
// ...
}
SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx, SpawnFoodTimer _timer) {
// ...
}
In our case we aren't interested in the data on the row, so we name the argument _timer.
[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 use new ScheduleAt.Time(...) to schedule a reducer once at a specific time. SpacetimeDB will remove that row automatically after the reducer has been called.
:::
#[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 use ScheduleAt::Time() to specify a specific time at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called.
:::
SPACETIMEDB_INIT(init, ReducerContext ctx) {
LOG_INFO("Initializing...");
ctx.db[config].insert(Config{
0,
1000,
});
ctx.db[spawn_food_timer].insert(SpawnFoodTimer{
0,
ScheduleAt(TimeDuration::from_millis(500)),
});
return Ok();
}
:::note
You can use ScheduleAt(TimeDuration::from_millis(...)) 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(Timestamp::from_millis_since_epoch(...)) to specify a specific time at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called.
:::
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:
[Table(Accessor = "logged_out_player")]
#[spacetimedb::table(accessor = logged_out_player)]
SPACETIMEDB_TABLE(Player, logged_out_player, Private);
Your struct should now look like this:
<Tabs groupId="server-language" defaultValue="rust"> <TabItem value="csharp" label="C#">[Table(Accessor = "player", Public = true)]
[Table(Accessor = "logged_out_player")]
public partial struct Player
{
[PrimaryKey]
public Identity identity;
[Unique, AutoInc]
public int player_id;
public string name;
}
#[spacetimedb::table(accessor = player, public)]
#[spacetimedb::table(accessor = logged_out_player)]
#[derive(Debug, Clone)]
pub struct Player {
#[primary_key]
identity: Identity,
#[unique]
#[auto_inc]
player_id: i32,
name: String,
}
struct Player {
Identity identity;
int32_t player_id;
std::string name;
};
SPACETIMEDB_STRUCT(Player, identity, player_id, name);
SPACETIMEDB_TABLE(Player, player, Public);
SPACETIMEDB_TABLE(Player, logged_out_player, Private);
FIELD_PrimaryKey(player, identity);
FIELD_UniqueAutoInc(player, player_id);
FIELD_PrimaryKey(logged_out_player, identity);
FIELD_UniqueAutoInc(logged_out_player, player_id);
:::note
In C++, since we're creating two separate tables from the same struct, we need to apply the field constraints (FIELD_PrimaryKey and FIELD_UniqueAutoInc) to both player and logged_out_player independently. Each table maintains its own indexes and constraints.
:::
This line creates an additional tabled called logged_out_player whose rows share the same Player type as in the player table.
:::danger
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. :::
<Tabs groupId="server-language" defaultValue="rust"> <TabItem value="csharp" label="C#">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);
}
#[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(())
}
SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) {
// Check if this player was previously logged out
auto logged_out_player_opt = ctx.db[logged_out_player_identity].find(ctx.sender());
if (logged_out_player_opt.has_value()) {
// Move player from logged_out_player to player table
ctx.db[player].insert(logged_out_player_opt.value());
ctx.db[logged_out_player_identity].delete_by_key(logged_out_player_opt.value().identity);
} else {
// New player - create and insert into player table
ctx.db[player].insert(Player{ctx.sender(), 0, ""});
}
return Ok();
}
SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) {
// Find the player in the player table
auto player_opt = ctx.db[player_identity].find(ctx.sender());
if (!player_opt.has_value()) {
return Err("Player not found");
}
Player player = player_opt.value();
// Move player from player to logged_out_player table
ctx.db[logged_out_player].insert(player);
ctx.db[player_identity].delete_by_key(player.identity);
return Ok();
}
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.
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.
<Tabs groupId="server-language" defaultValue="rust"> <TabItem value="csharp" label="C#"> 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.
</TabItem>
<TabItem value="rust" label="Rust">
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.
</TabItem>
<TabItem value="cpp" label="C++">
Add the following to the bottom of your file.
const int32_t START_PLAYER_MASS = 15;
// Helper function to spawn a circle at a specific location
Entity spawn_circle_at(ReducerContext& ctx, int32_t player_id, int32_t mass, DbVector2 position, Timestamp timestamp) {
auto inserted_entity = ctx.db[entity].insert(Entity{0, position, mass});
ctx.db[circle].insert(Circle{
inserted_entity.entity_id,
player_id,
DbVector2{0.0f, 1.0f}, // direction
0.0f, // speed
timestamp // last_split_time
});
return inserted_entity;
}
// Helper function to spawn a player's initial circle
Entity spawn_player_initial_circle(ReducerContext& ctx, int32_t player_id) {
auto config_opt = ctx.db[config_id].find(0);
if (!config_opt.has_value()) {
// This shouldn't happen, but handle it gracefully
return Entity{0, {0.0f, 0.0f}, 0};
}
int64_t world_size = config_opt.value().world_size;
auto& rng = ctx.rng();
float player_start_radius = mass_to_radius(START_PLAYER_MASS);
float x = rng.gen_range(player_start_radius, static_cast<float>(world_size) - player_start_radius);
float y = rng.gen_range(player_start_radius, static_cast<float>(world_size) - player_start_radius);
return spawn_circle_at(ctx, player_id, START_PLAYER_MASS, DbVector2{x, y}, ctx.timestamp);
}
SPACETIMEDB_REDUCER(enter_game, ReducerContext ctx, std::string name) {
LOG_INFO("Creating player with name " + name);
// Find the player
auto player_opt = ctx.db[player_identity].find(ctx.sender());
if (!player_opt.has_value()) {
return Err("Player not found");
}
// Update the player's name
Player updated_player = player_opt.value();
int32_t player_id = updated_player.player_id;
updated_player.name = name;
ctx.db[player_identity].update(updated_player);
// Spawn initial circle for the player
spawn_player_initial_circle(ctx, player_id);
return Ok();
}
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.
</TabItem>
</Tabs>
Let's also modify our disconnect reducer to remove the circles from the arena when the player disconnects from the database server.
[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);
}
#[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(())
}
SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) {
// Find the player in the player table
auto player_opt = ctx.db[player_identity].find(ctx.sender());
if (!player_opt.has_value()) {
return Err("Player not found");
}
Player player = player_opt.value();
int32_t player_id = player.player_id;
// Move player from player to logged_out_player table
ctx.db[logged_out_player].insert(player);
ctx.db[player_identity].delete_by_key(player.identity);
// Remove any circles from the arena
for (const Circle& circle : ctx.db[circle_player_id]) {
if (circle.player_id == player_id) {
ctx.db[entity_entity_id].delete_by_key(circle.entity_id);
ctx.db[circle_entity_id].delete_by_key(circle.entity_id);
}
}
return Ok();
}
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
When using --delete-data, SpacetimeDB will prompt you to confirm the deletion. Enter y and press Enter to proceed.
:::
With the server logic in place to spawn food and players, extend the Unreal client to display the current state.
<Tabs groupId="client-language" defaultValue="cpp"> <TabItem value="cpp" label="C++"> 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);
}
BorderThickness50.0ArenaBorderHeight100.0ArenaBorderMaterialBasicShapeMaterial_InstArenaBorderISMAdd the CreateBorderCube and SetupArena functions and properties to BP_GameManager:
Add Function named CreateBorderCube as follows:
Position with Vector 2D as the type.Size with Vector 2D as the type.Add Function named SetupArena as follows:
WorldSizeMeters with Integer 64 as the type.WorldSizeCm with Float as the type.HalfWorldSize with Float as the type.BorderWidth with Float as the type.HalfBorder with Float as the type.Add Function named IsConnected as follows:
Result with Boolean as the type.In OnApplied_Event, call the SetupArena function. Update OnApplied_Event as follows:
</TabItem> </Tabs>
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.
With the arena set up, use the row data that SpacetimeDB syncs with the client to create and display Blueprints on the screen.
<Tabs groupId="client-language" defaultValue="cpp"> <TabItem value="cpp" label="C++"> Start by making a C++ class for each entity you want in the scene. If the Unreal project is not running, start it now. From the top menu, choose **Tools -> New C++ Class...** to create the following classes (you’ll modify these later)::::note After creating the first class, wait for Live Coding to finish before creating the next classes. :::
EntityCircleFoodPlayerPawnBlackholioPlayerControllerDbVector2Next add blueprints for our these classes:
Circle Blueprint
Circle, highlight Circle, and click Select.BP_Circle.Food Blueprint
Food, highlight Food, and click Select.BP_Food.Player Blueprint
PlayerPawn, highlight PlayerPawn, and click Select.BP_PlayerPawn.Player Controller Blueprint
BlackholioPlayerController, highlight BlackholioPlayerController, and click Select.BP_BlackholioPlayerController.BP_BlackholioPlayerController.Entity Blueprint
BP_Entity.Circle Blueprint
BP_Circle.Food Blueprint
BP_Food.Player Blueprint
BP_PlayerPawn.Player Controller Blueprint
BP_PlayerController.BP_PlayerController.Create a widget Blueprint for the player nameplate:
WBP_Nameplate.Double-click WBP_Nameplate to open it, then make the following changes:
WBP_Nameplate.TextBlock.24.Finally, add Blueprint logic so the circle can update its nameplate:
WBP_Nameplate editor, open the Graph tab (top right).UpdateText.UpdateText in the editor, then in Details -> Inputs, add a variable named Text of type String.String to Text is added automatically; this is expected.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.
Circle_Sprite.Next, open BP_Circle and configure it:
Select DefaultSceneRoot, add a Components -> Paper Sprite component, and rename it Circle.
0.4 for all three axes.Circle_Sprite.Select DefaultSceneRoot, add a Components -> Widget component, and rename it NameplateWidget.
0, 10, -45.0, 0, 90.WBP_Nameplate300, 600.5, 1.0Click Save and Compile.
The food entity is a simple collectible. Open BP_Food and configure it as follows:
Circle.
0.4 for all three axes.Circle_Sprite.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:
0, 15000, 00, 0, -90200:::note
Make sure the Camera component's Location and Rotation are 0, 0, 0
:::
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);
}
:::warning
Delete DbVector2.cpp (not needed), or clear its contents so compilation succeeds.
::::
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 BLACKHOLIO_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.
:::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. :::
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 BLACKHOLIO_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.
:::note
ACircle inherits from AEntity, not AActor. Compilation will fail until APlayerPawn is implemented.
:::
Open Food.h and update it as follows:
#pragma once
#include "CoreMinimal.h"
#include "Entity.h"
#include "Food.generated.h"
struct FFoodType;
UCLASS()
class BLACKHOLIO_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);
}
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 BLACKHOLIO_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);
}
With the foundation in place, implement the core entity class. Edit BP_Entity add the following Variables:
LerpStartPosition
LerpTargetPosition
TargetScale
1.0LerpTime
LerpDuration
0.1EntityId
Alpha
Add the following to Event Tick:
Add Function named MassToRadius as follows:
Mass with Integer as the type.Radius with Float as the type.Add Function named MassToDiameter as follows:
Mass with Integer as the type.Diameter with Float as the type.Add Function named OnUpdated as follows:
NewRow with Entity Type as the type.Add Function named OnDeleted as follows:
Context with Event Context as the type.Add Function named Spawn as follows:
In Entity Id with Integer as the type.Add Function named SetColor as follows:
Color with 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.
Open BP_PlayerPawn and add the following Variables:
Username
PlayerId
IsLocalPlayer
OwnedCircles
GameManager
Target
Add Function named GetGameManager as follows:
GameManager with BP Game Manager as the type.Add Function named Initialize as follows:
PlayerRow with Player Type as the type.Add Function named OnCircleSpawned as follows:
Circle with BP Circle -> Object Reference as the type.Add Function named OnCircleDeleted as follows:
Circle with BP Circle -> Object Reference as the type.Add Function named CenterOfMass as follows:
Center with Vector as the type.WeightedPosition with Vector as the type.TotalMass with Float as the type.Add Function named UpdateTargetLocation as follows:
Add Function named GetUsername as follows:
Output with String as the type.Update Event Tick to:
Update Event Destroyed to:
Open BP_Circle and add the following Variables:
OwningPlayer
ColorPalette
Override Function OnDeleted as follows:
Context with Entity Context as the type.Add Function named SpawnCircle as follows:
Circle with Circle Type as the type.InOwner with BP Player Pawn -> Object Reference as the typeOpen BP_Food and add the following Variables:
ColorPalette
Add Function named SpawnFood as follows:
Food Entity with Food Type as the type.
</TabItem>
Add the code below after the UDbConnection forward declaration:
// ...
class UDbConnection;
class AEntity;
class ACircle;
class AFood;
class APlayerPawn;
UCLASS()
class BLACKHOLIO_API AGameManager : public AActor
// ...
Add in public below the TokenFilePath:
class BLACKHOLIO_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;
}
CircleClass
ClassesBP_CircleFoodClass
ClassesBP_FoodPlayerClass
ClassesBP_PlayerPawnEntityMap
PlayerMap
Add Function named SpawnOrGetPlayer as follows:
PlayerRow with Player Type as the type.PlayerPawn with 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: </TabItem> </Tabs>
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 BLACKHOLIO_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:
GameManger
LocalPlayer
LastMovementSendTime
SendUpdateFrequency
0.0333Add Function named GetGameManager as follows:
GameManager with BP Game Manager as the type.Override Function -> On Possess as follows:
Update Event BeginPlay as follows:
</TabItem> </Tabs>At this point, you may need to regenerate your bindings the following command from the blackholio directory.
spacetime generate --lang unrealcpp --uproject-dir .. --unreal-module-name blackholio
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");
}
:::warning Be sure to rebuild your project after making changes to the code. :::
</TabItem> <TabItem value="blueprint" label="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 OnApplied_Event callback with the name TestPlayer.
Open up BP_GameManager and edit OnApplied_Event to match the following:
</TabItem> </Tabs>
Almost everything is ready to play. Before launching, set up the spawning classes:
BP_GameManager.BP_CircleBP_FoodBP_PlayerPawn:::warning Compile and save your changes. :::
Next, wire up SetUsername to update the Nameplate:
BP_Circle.</TabItem> <TabItem value="blueprint" label="Blueprint"> Almost everything is ready to play. Before launching, set up the spawning classes:
BP_GameManager.BP_CircleBP_FoodBP_PlayerPawnAfter publishing the module, press Play to see it in action.
You should see your player’s circle with its username label, surrounded by food.
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.