Maincloud is now LIVE! Get Maincloud Energy 90% off until we run out!

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 use ScheduleAt::Time() to specify a specific at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called.

Let's modify our Init reducer to schedule our SpawnFood reducer to be called every 500 milliseconds.

[Reducer(ReducerKind.Init)]
public static void Init(ReducerContext ctx)
{
    Log.Info($"Initializing...");
    ctx.Db.config.Insert(new Config { world_size = 1000 });
    ctx.Db.spawn_food_timer.Insert(new SpawnFoodTimer
    {
        scheduled_at = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(500))
    });
} 

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 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 the Player type to indicated whether the player is logged in. There's nothing incorrect about that approach, however for several reasons we recommend this two table approach:

  • We can iterate over all logged in players without any if statements or branching
  • The Player type now uses less program memory improving cache efficiency
  • We can easily check whether a player is logged in, based on whether their row exists in the player table

This approach is more generally referred to as existence based processing 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.

  1. Parent: Actor · Class Type: Public · Class Name: Entity
  2. Parent: All Classes -> Entity · Class Type: Public · Class Name: Circle
  3. Parent: All Classes -> Entity · Class Type: Public · Class Name: Food
  4. Parent: Pawn · Class Type: Public · Class Name: PlayerPawn
  5. Parent: Player Controller · Class Type: Public · Class Name: BlackholioPlayerController
  6. Parent: None · Class Type: Public · Class Name: DbVector2

Next add blueprints for our these classes:

Add Circle

  1. Circle Blueprint

    • In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
    • Expand All Classes, search for Circle, highlight Circle, and click Select.
    • Rename the new Blueprint to BP_Circle.
  2. Food Blueprint

    • In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
    • Expand All Classes, search for Food, highlight Food, and click Select.
    • Rename the new Blueprint to BP_Food.
  3. Player Blueprint

    • In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
    • Expand All Classes, search for PlayerPawn, highlight PlayerPawn, and click Select.
    • Rename the new Blueprint to BP_PlayerPawn.
  4. Player Controller Blueprint

    • In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
    • Expand All Classes, search for BlackholioPlayerController, highlight BlackholioPlayerController, and click Select.
    • Rename the new Blueprint to BP_BlackholioPlayerController.
    • Open Window -> World Settings in the top menu.
    • Change Player Controller Class from PlayerController to BP_BlackholioPlayerController.
    • Save the level.

Set Up the Nameplate Blueprint

Create a widget Blueprint for the player nameplate:

  • In the Content Drawer, right-click and choose Blueprint -> Blueprint Class.
  • Expand All Classes, search for UserWidget, highlight UserWidget, and click Select.
  • Name the new Blueprint WBP_Nameplate.

Double-click WBP_Nameplate to open it, then make the following changes:

  1. In the Palette on the left, search for Size Box and drag it into the Hierarchy under WBP_Nameplate.
  2. Search the Palette for Text and drag it under the Size Box.
  3. Select the Text widget and update its details:
    • Rename it to TextBlock.
    • Check Is Variable.
    • Set Font -> Size to 24.
    • Set Font -> Justification to Align Text Center.

WBP_Nameplate

Finally, add Blueprint logic so the circle can update its nameplate:

  1. In the WBP_Nameplate editor, open the Graph tab (top right).
  2. Click the + button next to My Blueprint -> Functions and name the new function UpdateText.
  3. Select UpdateText in the editor, then in Details -> Inputs, add a variable named Text of type String.
  4. Drag TextBlock into the graph and choose Get TextBlock.
  5. Drag off TextBlock and search for Set Text.
  6. Connect UpdateText to Set Text, then connect UpdateText -> Text to Set Text -> Text.
    • A conversion from String to Text is added automatically; this is expected.
  7. Click Save and Compile.

UpdateText Function

Set Up Circle Entity Blueprint

Import and set up the circle sprite:

  1. Right-click the image below and save it locally:
    Circle

  2. In the Content Drawer, right-click and select Import to Current Folder, then choose the saved image.

    • This imports the Circle as a texture.
    • Right-click the imported texture, select Sprite Actions -> Create Sprite, and rename it Circle_Sprite.

Next, open BP_Circle and configure it:

  1. Select DefaultSceneRoot, add a Components -> Paper Sprite component, and rename it Circle.

    • In the Details panel, set Scale to 0.4 for all three axes.
    • Set Source Sprite to Circle_Sprite.
  2. Select DefaultSceneRoot, add a Components -> Widget component, and rename it NameplateWidget.

    • In the Details panel, set Location to 0, 10, -45.
    • Set Rotation to 0, 0, 90.
    • Under User Interface, update:
      • Widget Class to WBP_Nameplate
      • Draw Size to 300, 60
      • Pivot to 0.5, 1.0
  3. Click Save and Compile.

Set Up the Food Entity Blueprint

The food entity is a simple collectible. Open BP_Food and configure it as follows:

  1. Select DefaultSceneRoot, add a Components -> Paper Sprite component, and rename it Circle.
    • In the Details panel, set Scale to 0.4 for all three axes.
    • Set Source Sprite to Circle_Sprite.
  2. Click Save and Compile.

Set Up the PlayerPawn Blueprint

The PlayerPawn owns the circles and controls the camera by following the center of mass. This setup provides the initial functionality; additional behavior will be added in the C++ class.

Open BP_PlayerPawn and make the following changes:

  1. Select DefaultSceneRoot, add a Components -> Spring Arm component.
  2. Select SpringArm, add a Components -> Camera component.
  3. Select SpringArm
    • In the Details panel, set:
      • Location to 0, 15000, 0
      • Rotation to 0, 0, -90
      • Target Arm Length to 200
  4. Click Save and Compile.

Note

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 from AEntity, not AActor. Compilation will fail until APlayerPawn is implemented.

Food Class

Open Food.h and update it as follows:

#pragma once

#include "CoreMinimal.h"
#include "Entity.h"
#include "Food.generated.h"

struct FFoodType;

UCLASS()
class CLIENT_UNREAL_API AFood : public AEntity
{
    GENERATED_BODY()

public:
    AFood();
    void Spawn(const FFoodType& FoodEntity);
protected:
    UPROPERTY(EditDefaultsOnly, Category="BH|Food")
    TArray<FLinearColor> ColorPalette;
}; 

Update Food.cpp as follows:

#include "Food.h"
#include "ModuleBindings/Types/FoodType.g.h"

AFood::AFood()
{
    ColorPalette = {
        // Greenish
        FLinearColor::FromSRGBColor(FColor(119, 252, 173, 255)),
        FLinearColor::FromSRGBColor(FColor(76,  250, 146, 255)),
        FLinearColor::FromSRGBColor(FColor(35,  246, 120, 255)),
        
        // Aqua / Teal
        FLinearColor::FromSRGBColor(FColor(119, 251, 201, 255)),
        FLinearColor::FromSRGBColor(FColor(76,  249, 184, 255)),
        FLinearColor::FromSRGBColor(FColor(35,  245, 165, 255)),
    };
}

void AFood::Spawn(const FFoodType& FoodEntity)
{
    Super::Spawn(FoodEntity.EntityId);
    
    const int32 Index = ColorPalette.Num() ? static_cast<int32>(EntityId % ColorPalette.Num()) : 0;
    const FLinearColor Color = ColorPalette.IsValidIndex(Index) ? ColorPalette[Index] : FLinearColor::Green;
    SetColor(Color);
} 
PlayerPawn Class

Open PlayerPawn.h and update it as follows:

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "PlayerPawn.generated.h"

class ACircle;
struct FPlayerType;

UCLASS()
class CLIENT_UNREAL_API APlayerPawn : public APawn
{
    GENERATED_BODY()

public:
    APlayerPawn();
    void Initialize(FPlayerType Player);
    
    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:

  1. Open BP_GameManager.
  2. Update the spawning classes:
    • Circle ClassBP_Circle
    • Food ClassBP_Food
    • Player ClassBP_PlayerPawn

Note

Reminder: Compile and save your changes.

Next, wire up SetUsername to update the Nameplate:

  1. Open BP_Circle.
  2. In Event BeginPlay, add the following:

Nameplate Update


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.

Edit On Github