Skip to main content
Version: 2.0.0

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 opportGodot to initialize the state of your database before any clients connect.

Add this new reducer above our connect reducer.

// Note the `init` parameter passed to the reducer macro.
// That indicates to SpacetimeDB that it should be called
// once upon database creation.
#[spacetimedb::reducer(init)]
pub fn init(ctx: &ReducerContext) -> Result<(), String> {
    log::info!("Initializing...");
    ctx.db.config().try_insert(Config {
        id: 0,
        world_size: 1000,
    })?;
    Ok(())
}

This reducer also demonstrates how to insert new rows into a table. Here we are adding a single Config row to the config table with the try_insert function. try_insert returns an error if inserting the row into the table would violate any constraints, like unique constraints, on the table. You can also use insert which panics on constraint violations if you know for sure that you will not violate any constraints.

Now that we've ensured that our database always has a valid world_size let's spawn some food into the map. Add the following code to the end of the file.

const FOOD_MASS_MIN: i32 = 2;
const FOOD_MASS_MAX: i32 = 4;
const TARGET_FOOD_COUNT: usize = 600;

fn mass_to_radius(mass: i32) -> f32 {
    (mass as f32).sqrt()
}

#[spacetimedb::reducer]
pub fn spawn_food(ctx: &ReducerContext) -> Result<(), String> {
    if ctx.db.player().count() == 0 {
        // Are there no logged in players? Skip food spawn.
        return Ok(());
    }

    let world_size = ctx
        .db
        .config()
        .id()
        .find(0)
        .ok_or("Config not found")?
        .world_size;

    let mut rng = ctx.rng();
    let mut food_count = ctx.db.food().count();
    while food_count < TARGET_FOOD_COUNT as u64 {
        let food_mass = rng.gen_range(FOOD_MASS_MIN..FOOD_MASS_MAX);
        let food_radius = mass_to_radius(food_mass);
        let x = rng.gen_range(food_radius..world_size as f32 - food_radius);
        let y = rng.gen_range(food_radius..world_size as f32 - food_radius);
        let entity = ctx.db.entity().try_insert(Entity {
            entity_id: 0,
            position: DbVector2 { x, y },
            mass: food_mass,
        })?;
        ctx.db.food().try_insert(Food {
            entity_id: entity.entity_id,
        })?;
        food_count += 1;
        log::info!("Spawned food! {}", entity.entity_id);
    }

    Ok(())
}

In this reducer, we are using the world_size we configured along with the ReducerContext's random number generator .rng() function to place 600 food uniformly randomly throughout the map. We've also chosen the mass of the food to be a random number between 2 and 4 inclusive.

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(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.

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> {
    // ...
}

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 time 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(accessor = logged_out_player)]

Your struct should now look like this:

#[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,
}

This creates an additional tabled called logged_out_player whose rows share the same Player type as in the player table.

note

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(())
}

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

Note that we could have added a logged_in boolean to the Player type to indicate 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: 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.

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(())
}

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.

Creating the Arena

Now that we've set up our server logic to spawn food and players, let's continue developing our Godot client to display what we have so far.

Start by adding the SetupArena method to your GameManager class:

private void SetupArena(float worldSize)
{
    var polygon = new[]
    {
        new Vector2(0, 0),
        new Vector2(worldSize, 0),
        new Vector2(worldSize, worldSize),
        new Vector2(0, worldSize),
    };
    var background = new Polygon2D
    {
        Name = "Background",
        Color = BackgroundColor,
        Position = Vector2.Zero,
        Polygon = polygon
    };
    background.AddChild(new Polygon2D
    {
        Name = "Border",
        Color = BorderColor,
        Position = Vector2.Zero,
        InvertEnabled = true,
        InvertBorder = BorderThickness,
        Polygon = polygon
    });
    AddChild(background);
}

In your HandleSubscriptionApplied let's now call SetupArena method. Modify your HandleSubscriptionApplied method as in the below.

private void HandleSubscriptionApplied(SubscriptionEventContext ctx)
{
    GD.Print("Subscription applied!");
    OnSubscriptionApplied?.Invoke();

    // 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
    var worldSize = Conn.Db.Config.Id.Find(0).WorldSize;
    SetupArena(worldSize);
}

The OnApplied callback will be called after the server synchronizes the initial state of your tables with your client. Once the sync has happened, we can look up the world size from the config table and use it to set up our arena.

In the Scene dock, in Godot, select the Main node with the GameManager script attached to it and set your background color, border thickness and border color to your preference.

Instantiating Nodes

Now that we have our arena all set up, we need to take the row data that SpacetimeDB syncs with our client and use it to create and draw nodes on the screen.

Let's start by making some controller scripts for each of the nodes we'd like to have in our scene. In the FileSystem dock, right-click and select New Script. Select C# and name the new script PlayerController.cs. Repeat that same process for CircleController.cs and FoodController.cs. We'll modify the contents of these files later.

Circle2D

To render both Circle and Food entities we need a way to draw circles on the screen. Right-click in the FileSystem dock, and create a new Circle2D C# script:

using Godot;

public abstract partial class Circle2D : Node2D
{
    private float _radius = 10.0f;
    [Export]
    public float Radius
    {
        get => _radius;
        set
        {
            if (Mathf.IsEqualApprox(_radius, value)) return;

            _radius = value;
            QueueRedraw();
        }
    }

    private Color _color = Colors.Brown;
    [Export]
    public Color Color
    {
        get => _color;
        set
        {
            if (_color == value) return;

            _color = value;
            QueueRedraw();
        }
    }
    
    public override void _Draw() => DrawCircle(Vector2.Zero, Radius, Color);
}

EntityController

Let's also create an EntityController script which will serve as a base class for both our CircleController and FoodController classes since both Circles and Food are entities.

Create a new C# script called EntityController.cs and replace its contents with:

using Godot;
using SpacetimeDB.Types;

public abstract partial class EntityController : Circle2D
{
	private const float LerpDurationSec = 0.1f;

	public int EntityId { get; private set; }

	private float LerpTime { get; set; }
	private Vector2 LerpStartPosition { get; set; }
	private Vector2 TargetPosition { get; set; }
	private float TargetRadius { get; set; }

	protected EntityController(int entityId, Color color)
	{
		EntityId = entityId;
		Color = color;

		var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId);
		var position = (Vector2)entity.Position;
		LerpStartPosition = position;
		TargetPosition = position;
		GlobalPosition = position;
		Radius = 0;
		TargetRadius = MassToRadius(entity.Mass);
	}

	public void OnEntityUpdated(Entity newRow)
	{
		LerpTime = 0.0f;
		LerpStartPosition = GlobalPosition;
		TargetPosition = (Vector2)newRow.Position;
		TargetRadius = MassToRadius(newRow.Mass);
	}

	public virtual void OnDelete() => QueueFree();

	public override void _Process(double delta)
	{
		var frameDelta = (float)delta;
		LerpTime = Mathf.Min(LerpTime + frameDelta, LerpDurationSec);
		GlobalPosition = LerpStartPosition.Lerp(TargetPosition, LerpTime / LerpDurationSec);
		Radius = Mathf.Lerp(Radius, TargetRadius, frameDelta * 8.0f);
	}

	private static float MassToRadius(int mass) => Mathf.Sqrt(mass);
}

The EntityController script inherits from Circle2D and it just provides some helper functions and basic functionality to manage and update our client-side entities based on server-side updates.

One notable feature is that we linearly interpolate (lerp) between the position where the server says the entity is, and where we actually draw it. This is a common technique which provides for smoother movement.

If you're interested in learning more checkout this demo from Gabriel Gambetta.

At this point you'll have a compilation error because we can't yet convert from SpacetimeDB.Types.DbVector2 to Godot.Vector2. To fix this, let's also create a new Extensions.cs script and replace the contents with:

using Godot;

namespace SpacetimeDB.Types
{
    public partial class DbVector2
    {
        public static implicit operator Vector2(DbVector2 vec) => new(vec.X, vec.Y);
        public static implicit operator DbVector2(Vector2 vec) => new(vec.X, vec.Y);
    }
}

This just allows us to implicitly convert between our DbVector2 type and the Godot Vector2 type.

CircleController

Now open the CircleController script and modify the contents of the CircleController script to be:

using Godot;
using SpacetimeDB.Types;

public partial class CircleController : EntityController
{
	private static readonly Color[] ColorPalette =
	[
		//Yellow
		new(175 / 255.0f, 159 / 255.0f, 49 / 255.0f),
		new(175 / 255.0f, 116 / 255.0f, 49 / 255.0f),
		//Purple
		new(112 / 255.0f, 47 / 255.0f, 252 / 255.0f),
		new(51 / 255.0f, 91 / 255.0f, 252 / 255.0f),
		//Red
		new(176 / 255.0f, 54 / 255.0f, 54 / 255.0f),
		new(176 / 255.0f, 109 / 255.0f, 54 / 255.0f),
		new(141 / 255.0f, 43 / 255.0f, 99 / 255.0f),
		//Blue
		new(2 / 255.0f, 188 / 255.0f, 250 / 255.0f),
		new(7 / 255.0f, 50 / 255.0f, 251 / 255.0f),
		new(2 / 255.0f, 28 / 255.0f, 146 / 255.0f)
	];

	private static CanvasLayer _labelLayer;
	private static CanvasLayer LabelLayer
	{
		get
		{
			if (_labelLayer == null)
			{

				if (Engine.GetMainLoop() is not SceneTree sceneTree) return null;
				var root = sceneTree.Root;
				if (root == null) return null;
				_labelLayer = new CanvasLayer { Name = "CircleLabelLayer" };
				root.AddChild(_labelLayer);
			}
			return _labelLayer;
		}
	}
	private static Control _labelRoot;
	private static Control LabelRoot
	{
		get
		{
			if (_labelRoot == null)
			{
				_labelRoot = new Control
				{
					Name = "CircleLabelRoot",
					MouseFilter = Control.MouseFilterEnum.Ignore
				};
				_labelRoot.SetAnchorsPreset(Control.LayoutPreset.FullRect);

				LabelLayer.AddChild(_labelRoot);
			}
			return _labelRoot;
		}
	}

	private Label _label;
	private Label Label
	{
		get
		{
			if (_label == null)
			{
				_label = new Label
				{
					Name = $"{Name}_Label",
					TopLevel = false,
					MouseFilter = Control.MouseFilterEnum.Ignore
				};
				LabelRoot.AddChild(_label);
			}
			return _label;
		}
	}

	private PlayerController OwnerPlayer { get; set; }
	
	public CircleController(Circle circle, PlayerController ownerPlayer) : base(circle.EntityId, ColorPalette[circle.PlayerId % ColorPalette.Length])
	{
		OwnerPlayer = ownerPlayer;
		Label.Text = ownerPlayer.Username;
		
		ownerPlayer.OnCircleSpawned(this);
	}

	public override void _Process(double delta)
	{
		base._Process(delta);
		UpdateScreenLabelPosition();
	}

	public override void OnDelete()
	{
		base.OnDelete();

		if (IsInstanceValid(Label))
		{
			Label.QueueFree();
		}

		OwnerPlayer?.OnCircleDeleted(this);
	}

	private void UpdateScreenLabelPosition()
	{
		Label.Size = Label.GetCombinedMinimumSize();
		var screenPosition = GetGlobalTransformWithCanvas().Origin;
		var offset = new Vector2(0.0f, Radius + 8.0f);
		Label.Position = screenPosition + offset - (Label.Size / 2.0f);
	}
}

At the top, we're just defining some possible colors for our circle. We've also created a constructor which takes a Circle (same type that's in our circle table) and a PlayerController which selects a color based on the circle's player Id, as well as setting up the text to show the player's username.

To show crisp text underneath each cirlce, we lazyly create a global CanvasLayer and a Control node to have a UI context where we can add labels for each circle. In the _Process method, we call UpdateScreenLabelPosition to update the Label's position relative to the circle position and radius.

Note that the CircleController inherits from the EntityController.

FoodController

Next open the FoodController.cs file and replace the contents with:

using Godot;
using SpacetimeDB.Types;

public partial class FoodController : EntityController
{
    private static readonly Color[] ColorPalette =
    [
        new(119 / 255.0f, 252 / 255.0f, 173 / 255.0f),
        new(76 / 255.0f, 250 / 255.0f, 146 / 255.0f),
        new(35 / 255.0f, 246 / 255.0f, 120 / 255.0f),
        new(119 / 255.0f, 251 / 255.0f, 201 / 255.0f),
        new(76 / 255.0f, 249 / 255.0f, 184 / 255.0f),
        new(35 / 255.0f, 245 / 255.0f, 165 / 255.0f),
    ];

    public FoodController(Food food) : base(food.EntityId, ColorPalette[food.EntityId % ColorPalette.Length]) { }
}

This is a much simpler script that only picks a color and calls the base EntityController's constructor.

PlayerController

Open the PlayerController script and modify the contents of the PlayerController script to be:

using System.Collections.Generic;
using System.Linq;
using Godot;
using SpacetimeDB.Types;

public partial class PlayerController : Node
{
    const int SEND_UPDATES_PER_SEC = 20;
    const float SEND_UPDATES_FREQUENCY = 1f / SEND_UPDATES_PER_SEC;

    public static PlayerController Local { get; private set; }

    private int _playerId;
    private float _lastMovementSendTimestamp;
    private Vector2? _lockInputPosition;
    private readonly List<CircleController> _ownedCircles = new();

    private bool _lockInputTogglePressed;

    public string Username => GameManager.Conn.Db.Player.PlayerId.Find(_playerId).Name;
    public int NumberOfOwnedCircles => _ownedCircles.Count;
    public bool IsLocalPlayer => this == Local;

    public PlayerController(Player player)
    {
        _playerId = player.PlayerId;
        if (player.Identity == GameManager.LocalIdentity)
        {
            Local = this;
        }
    }

    public override void _ExitTree()
    {
        foreach (var circle in _ownedCircles.ToList())
        {
            if (IsInstanceValid(circle))
            {
                circle.QueueFree();
            }
        }

        _ownedCircles.Clear();
        if (Local == this)
        {
            Local = null;
        }
    }

    public void OnCircleSpawned(CircleController circle)
    {
        _ownedCircles.Add(circle);
    }

    public void OnCircleDeleted(CircleController deletedCircle)
    {
        _ownedCircles.Remove(deletedCircle);
    }

    public int TotalMass() => _ownedCircles
            .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId))
            .Sum(entity => entity?.Mass ?? 0);

	public bool TryGetCenterOfMass(out Vector2 centerOfMass)
	{
		if (_ownedCircles.Count == 0)
		{
			centerOfMass = Vector2.Zero;
			return false;
		}

		var totalPos = Vector2.Zero;
		var totalMass = 0.0f;
		foreach (var circle in _ownedCircles)
		{
			var entity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId);
			if (entity == null) continue;

			totalPos += circle.GlobalPosition * entity.Mass;
			totalMass += entity.Mass;
		}

		if (totalMass <= 0)
		{
			centerOfMass = Vector2.Zero;
			return false;
		}
		
		centerOfMass = totalPos / totalMass;
		return true;
	}
}

Let's also create a new Instantiator.cs script that we can use as a factory to instanciate and update nodes when our database changes. Replace the contents of the file with:

using System.Collections.Generic;
using Godot;
using SpacetimeDB.Types;

public partial class Instantiator : Node
{
    private DbConnection _conn;
    private DbConnection Conn
    {
        get => _conn;
        set
        {
            if (value == _conn) return;

            if (_conn != null)
            {
                _conn.Db.Circle.OnInsert -= CircleOnInsert;
                _conn.Db.Entity.OnUpdate -= EntityOnUpdate;
                _conn.Db.Entity.OnDelete -= EntityOnDelete;
                _conn.Db.Food.OnInsert -= FoodOnInsert;
                _conn.Db.Player.OnInsert -= PlayerOnInsert;
                _conn.Db.Player.OnDelete -= PlayerOnDelete;
            }
            
            _conn = value;

            if (value != null)
            {
                value.Db.Circle.OnInsert += CircleOnInsert;
                value.Db.Entity.OnUpdate += EntityOnUpdate;
                value.Db.Entity.OnDelete += EntityOnDelete;
                value.Db.Food.OnInsert += FoodOnInsert;
                value.Db.Player.OnInsert += PlayerOnInsert;
                value.Db.Player.OnDelete += PlayerOnDelete;
            }
        }
    }
    
    private static Dictionary<int, EntityController> Entities { get; } = new();
    private static Dictionary<int, PlayerController> Players { get; } = new();
    
    public Instantiator(DbConnection conn)
    {
        Conn = conn;
    }

    public override void _ExitTree()
    {
        GD.PrintErr("Instantiator Exit Tree");
        Conn = null;
    }

    private void CircleOnInsert(EventContext context, Circle insertedValue)
    {
        var player = GetOrCreatePlayer(insertedValue.PlayerId);
        var entityController = SpawnCircle(insertedValue, player);
        Entities[insertedValue.EntityId] = entityController;
    }

    private void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity)
    {
        if (Entities.TryGetValue(newEntity.EntityId, out var entityController))
        {
            entityController.OnEntityUpdated(newEntity);
        }
    }

    private void EntityOnDelete(EventContext context, Entity oldEntity)
    {
        if (Entities.Remove(oldEntity.EntityId, out var entityController))
        {
            entityController.OnDelete();
        }
    }

    private void FoodOnInsert(EventContext context, Food insertedValue)
    {
        var entityController = SpawnFood(insertedValue);
        Entities[insertedValue.EntityId] = entityController;
    }

    private void PlayerOnInsert(EventContext context, Player insertedPlayer)
    {
        GetOrCreatePlayer(insertedPlayer.PlayerId);
    }

    private void PlayerOnDelete(EventContext context, Player deletedValue)
    {
        if (Players.Remove(deletedValue.PlayerId, out var playerController))
        {
            playerController.QueueFree();
        }
    }

    private PlayerController GetOrCreatePlayer(int playerId)
    {
        if (!Players.TryGetValue(playerId, out var playerController))
        {
            var player = Conn.Db.Player.PlayerId.Find(playerId);
            playerController = SpawnPlayer(player);
            Players[playerId] = playerController;
        }

        return playerController;
    }

    private CircleController SpawnCircle(Circle circle, PlayerController owner)
    {
        var entityController = new CircleController(circle, owner)
        {
            Name = $"Circle - {circle.EntityId}",
        };
        
        AddChild(entityController);
        
        return entityController;
    }

    private FoodController SpawnFood(Food food)
    {
        var entityController = new FoodController(food)
        {
            Name = $"Food - {food.EntityId}",
        };
        
        AddChild(entityController);
        
        return entityController;
    }

    private PlayerController SpawnPlayer(Player player)
    {
        var playerController = new PlayerController(player)
        {
            Name = $"Player - {player.Name}"
        };
        
        AddChild(playerController);
        
        return playerController;
    }
}

In the Instantiator's constructor, we pass down the DbConnection and we subscribe to all the relevant changes to the database that we care about. When the Instatiator is destroyed and leaves the tree, the _ExitTree method is called and we unsubscribe from database changes.

Hooking up the Data

We've now prepared our Godot project so that we can hook up the data from our tables to the Godot game objects and have them drawn on the screen.

Next lets add an Instantiator to our scene when we succesffully connect to SpacetimeDB.Modify the HandleConnect method as below.

private void HandleConnect(DbConnection conn, Identity identity, string token)
{
	GD.Print("Connected.");
	AuthToken.SaveToken(token);
	LocalIdentity = identity;

	OnConnected?.Invoke();

	AddChild(new Instantiator(conn));

	// Request all tables
	Conn.SubscriptionBuilder()
		.OnApplied(HandleSubscriptionApplied)
		.SubscribeToAllTables();
}

Camera Controller

One of the last steps is to create a camera controller to make sure the camera follows the local player around. Create a new script called CameraController.cs. Replace the contents of the file with this:

using Godot;

public partial class CameraController : Camera2D
{
	[Export]
	public float BaseVisibleRadius { get; set; } = 50.0f;

	[Export]
	public float FollowLerpSpeed { get; set; } = 8.0f;

	[Export]
	public float ZoomLerpSpeed { get; set; } = 2.0f;
	
	private float WorldSize { get; }

	public CameraController(float worldSize)
	{
		WorldSize = worldSize;
	}

	public override void _Process(double delta)
	{
		Vector2 targetPosition;
		if (GameManager.IsConnected() && PlayerController.Local != null && PlayerController.Local.TryGetCenterOfMass(out var centerOfMass))
		{
			targetPosition = centerOfMass;
		}
		else
		{
			var hWorldSize = WorldSize * 0.5f;
			targetPosition = new Vector2(hWorldSize, hWorldSize);
		}

		GlobalPosition = GlobalPosition.Lerp(targetPosition, (float)delta * FollowLerpSpeed);

		if (PlayerController.Local == null)
		{
			return;
		}

		var targetCameraSize = CalculateCameraSize(PlayerController.Local);
		var desiredZoom = Vector2.One * (BaseVisibleRadius / Mathf.Max(targetCameraSize, 1.0f));
		Zoom = Zoom.Lerp(desiredZoom, (float)delta * ZoomLerpSpeed);
	}

	private static float CalculateCameraSize(PlayerController player) => 10.0f
            + Mathf.Min(10.0f, player.TotalMass() / 5.0f)
			+ Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30.0f;
}

Lastly, let's add the CameraController to our main scene when we setup the arena. Modify the SetupArena method in GameManager as follows:

private void SetupArena(float worldSize)
{
    var polygon = new[]
    {
        new Vector2(0, 0),
        new Vector2(worldSize, 0),
        new Vector2(worldSize, worldSize),
        new Vector2(0, worldSize),
    };
    var background = new Polygon2D
    {
        Name = "Background",
        Color = BackgroundColor,
        Position = Vector2.Zero,
        Polygon = polygon,
        ZIndex = -1000
    };
    background.AddChild(new Polygon2D
    {
        Name = "Border",
        Color = BorderColor,
        Position = Vector2.Zero,
        InvertEnabled = true,
        InvertBorder = BorderThickness,
        Polygon = polygon
    });
    AddChild(background);

    AddChild(new CameraController(worldSize));
}

Note that we defined a negative ZIndex to background. This is so it gets render behind all the other siblings (instantiated by the Instantiator that we added earlier).

Entering the Game

At this point, you will need to regenerate your bindings. Run the following command from the blackholio-server/spacetimedb directory.

spacetime generate --lang csharp --out-dir ../../module_bindings

The last step is to call the enter_game reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the enter_game reducer from the HandleSubscriptionApplied callback with the name "3Blave".

    private void HandleSubscriptionApplied(SubscriptionEventContext ctx)
    {
        GD.Print("Subscription applied!");
        OnSubscriptionApplied?.Invoke();

        // 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
        var worldSize = Conn.Db.Config.Id.Find(0).WorldSize;
        SetupArena(worldSize);
        
        ctx.Reducers.EnterGame(DefaultPlayerName);
    }

Trying it out

After publishing our module we can press the play button to see the fruits of our labor! You should be able to see your player's circle, with its username label, surrounded by food.

Player on screen

Troubleshooting

  • If you get an error when running the generate command, make sure you have an empty subfolder in your Godot project Assets folder called module_bindings

  • If you get an error in your Godot console when starting the game, double check that you have published your module and you have the correct module name specified in your GameManager.

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.