Unity 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 module 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(())
}
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 an 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.
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).as_micros() as u64),
})?;
Ok(())
}
Note
You can use
ScheduleAt::Interval
to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also useScheduleAt::Time()
to specify a specific at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called.
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)]
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,
}
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().delete(player);
} 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")?;
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 that we could have added a
logged_in
boolean to thePlayer
type to indicated whether the player is logged in. There's nothing incorrect about that approach, however for several reasons we recommend this two table approach:
- We can iterate over all logged in players without any
if
statements or branching- The
Player
type now uses less program memory improving cache efficiency- We can easily check whether a player is logged in, based on whether their row exists in the
player
tableThis approach is more generally referred to as existence based processing and it is a common technique in data-oriented design.
Spawning Player Circles
Now that we've got our food spawning and our players set up, let's create a match and spawn player circle entities into it. The first thing we should do before spawning a player into a match is give them a name.
Add the following to the bottom of your file.
const START_PLAYER_MASS: u32 = 15;
#[spacetimedb::reducer]
pub fn enter_game(ctx: &ReducerContext, name: String) -> Result<(), String> {
log::info!("Creating player with name {}", name);
let mut player: Player = ctx.db.player().identity().find(ctx.sender).ok_or("")?;
let player_id = player.player_id;
player.name = name;
ctx.db.player().identity().update(player);
spawn_player_initial_circle(ctx, player_id)?;
Ok(())
}
fn spawn_player_initial_circle(ctx: &ReducerContext, player_id: u32) -> Result<Entity, String> {
let mut rng = ctx.rng();
let world_size = ctx
.db
.config()
.id()
.find(&0)
.ok_or("Config not found")?
.world_size;
let player_start_radius = mass_to_radius(START_PLAYER_MASS);
let x = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius));
let y = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius));
spawn_circle_at(
ctx,
player_id,
START_PLAYER_MASS,
DbVector2 { x, y },
ctx.timestamp,
)
}
fn spawn_circle_at(
ctx: &ReducerContext,
player_id: u32,
mass: u32,
position: DbVector2,
timestamp: Timestamp,
) -> Result<Entity, String> {
let entity = ctx.db.entity().try_insert(Entity {
entity_id: 0,
position,
mass,
})?;
ctx.db.circle().try_insert(Circle {
entity_id: entity.entity_id,
player_id,
direction: DbVector2 { x: 0.0, y: 1.0 },
speed: 0.0,
last_split_time: timestamp,
})?;
Ok(entity)
}
The enter_game
reducer takes one argument, the player's name
. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row.
Let's also modify our disconnect
reducer to remove the circles from the arena when the player disconnects from the 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 Unity client to display what we have so far.
Start by adding SetupArena
and CreateBorderCube
methods to your GameManager
class:
private void SetupArena(float worldSize)
{
CreateBorderCube(new Vector2(worldSize / 2.0f, worldSize + borderThickness / 2),
new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //North
CreateBorderCube(new Vector2(worldSize / 2.0f, -borderThickness / 2),
new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //South
CreateBorderCube(new Vector2(worldSize + borderThickness / 2, worldSize / 2.0f),
new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //East
CreateBorderCube(new Vector2(-borderThickness / 2, worldSize / 2.0f),
new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //West
}
private void CreateBorderCube(Vector2 position, Vector2 scale)
{
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.name = "Border";
cube.transform.localScale = new Vector3(scale.x, scale.y, 1);
cube.transform.position = new Vector3(position.x, position.y, 1);
cube.GetComponent<MeshRenderer>().material = borderMaterial;
}
In your HandleSubscriptionApplied
let's now call SetupArea
method. Modify your HandleSubscriptionApplied
method as in the below.
private void HandleSubscriptionApplied(EventContext ctx)
{
Debug.Log("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 view, select the GameManager
object. Click on the Border Material
property and choose Sprites-Default
.
Creating GameObjects
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 GameObject
s on the screen.
Let's start by making some controller scripts for each of the game objects we'd like to have in our scene. In the project window, right-click and select Create > C# Script
. Name the new script PlayerController.cs
. Repeat that process for CircleController.cs
and FoodController.cs
. We'll modify the contents of these files later.
Now let's make some prefabs for our game objects. In the scene hierarchy window, create a new GameObject
by right-clicking and selecting:
2D Object > Sprites > Circle
Rename the new game object in the scene to CirclePrefab
. Next in the Inspector
window click the Add Component
button and add the Circle Controller
script component that we just created. Finally drag the object into the Project
folder. Once the prefab file is created, delete the CirclePrefab
object from the scene. We'll use this prefab to draw the circles that a player controllers.
Next repeat that same process for the FoodPrefab
and Food Controller
component.
In the Project
view, double click the CirclePrefab
to bring it up in the scene view. Right-click anywhere in the hierarchy and navigate to:
UI > Text - Text Mesh Pro
This will add a label to the circle prefab. You may need to import "TextMeshPro Essential Resources" into Unity in order to add the TextMeshPro element. Your logs will say "[TMP Essential Resources] have been imported." if it has worked correctly. Don't forget to set the transform position of the label to Pos X: 0, Pos Y: 0, Pos Z: 0
.
Finally we need to make the PlayerPrefab
. In the hierarchy window, create a new GameObject
by right-clicking and selecting:
Create Empty
Rename the game object to PlayerPrefab
. Next in the Inspector
window click the Add Component
button and add the Player Controller
script component that we just created. Next drag the object into the Project
folder. Once the prefab file is created, delete the PlayerPrefab
object from the scene.
EntityController
Let's also create an EntityController
script which will serve as a base class for both our CircleController
and FoodController
classes since both Circle
s and Food
are entities.
Create a new file called EntityController.cs
and replace its contents with:
using SpacetimeDB.Types;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Unity.VisualScripting;
using UnityEngine;
public abstract class EntityController : MonoBehaviour
{
const float LERP_DURATION_SEC = 0.1f;
private static readonly int ShaderColorProperty = Shader.PropertyToID("_Color");
[DoNotSerialize] public uint EntityId;
protected float LerpTime;
protected Vector3 LerpStartPosition;
protected Vector3 LerpTargetPositio;
protected Vector3 TargetScale;
protected virtual void Spawn(uint entityId)
{
EntityId = entityId;
var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId);
LerpStartPosition = LerpTargetPositio = transform.position = (Vector2)entity.Position;
transform.localScale = Vector3.one;
TargetScale = MassToScale(entity.Mass);
}
public void SetColor(Color color)
{
GetComponent<SpriteRenderer>().material.SetColor(ShaderColorProperty, color);
}
public virtual void OnEntityUpdated(Entity newVal)
{
LerpTime = 0.0f;
LerpStartPosition = transform.position;
LerpTargetPositio = (Vector2)newVal.Position;
TargetScale = MassToScale(newVal.Mass);
}
public virtual void OnDelete(EventContext context)
{
Destroy(gameObject);
}
public virtual void Update()
{
// Interpolate position and scale
LerpTime = Mathf.Min(LerpTime + Time.deltaTime, LERP_DURATION_SEC);
transform.position = Vector3.Lerp(LerpStartPosition, LerpTargetPositio, LerpTime / LERP_DURATION_SEC);
transform.localScale = Vector3.Lerp(transform.localScale, TargetScale, Time.deltaTime * 8);
}
public static Vector3 MassToScale(uint mass)
{
var diameter = MassToDiameter(mass);
return new Vector3(diameter, diameter, 1);
}
public static float MassToRadius(uint mass) => Mathf.Sqrt(mass);
public static float MassToDiameter(uint mass) => MassToRadius(mass) * 2;
}
The EntityController
script just provides some helper functions and basic functionality to manage our game objects based on entity 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 UnityEngine.Vector2
. To fix this, let's also create a new Extensions.cs
script and replace the contents with:
using SpacetimeDB.Types;
using UnityEngine;
namespace SpacetimeDB.Types
{
public partial class DbVector2
{
public static implicit operator Vector2(DbVector2 vec)
{
return new Vector2(vec.X, vec.Y);
}
public static implicit operator DbVector2(Vector2 vec)
{
return new DbVector2(vec.x, vec.y);
}
}
}
This just allows us to implicitly convert between our DbVector2
type and the Unity Vector2
type.
CircleController
Now open the CircleController
script and modify the contents of the CircleController
script to be:
using System;
using System.Collections.Generic;
using SpacetimeDB;
using SpacetimeDB.Types;
using UnityEngine;
public class CircleController : EntityController
{
public static Color[] ColorPalette = new[]
{
//Yellow
(Color)new Color32(175, 159, 49, 255),
(Color)new Color32(175, 116, 49, 255),
//Purple
(Color)new Color32(112, 47, 252, 255),
(Color)new Color32(51, 91, 252, 255),
//Red
(Color)new Color32(176, 54, 54, 255),
(Color)new Color32(176, 109, 54, 255),
(Color)new Color32(141, 43, 99, 255),
//Blue
(Color)new Color32(2, 188, 250, 255),
(Color)new Color32(7, 50, 251, 255),
(Color)new Color32(2, 28, 146, 255),
};
private PlayerController Owner;
public void Spawn(Circle circle, PlayerController owner)
{
base.Spawn(circle.EntityId);
SetColor(ColorPalette[circle.PlayerId % ColorPalette.Length]);
this.Owner = owner;
GetComponentInChildren<TMPro.TextMeshProUGUI>().text = owner.Username;
}
public override void OnDelete(EventContext context)
{
base.OnDelete(context);
Owner.OnCircleDeleted(this);
}
}
At the top, we're just defining some possible colors for our circle. We've also created a spawn function which takes a Circle
(same type that's in our circle
table) and a PlayerController
which sets the color based on the circle's player ID, as well as setting the text of the Cricle to be the player's username.
Note that the CircleController
inherits from the EntityController
, not MonoBehavior
.
FoodController
Next open the FoodController.cs
file and replace the contents with:
using SpacetimeDB.Types;
using Unity.VisualScripting;
using UnityEngine;
public class FoodController : EntityController
{
public static Color[] ColorPalette = new[]
{
(Color)new Color32(119, 252, 173, 255),
(Color)new Color32(76, 250, 146, 255),
(Color)new Color32(35, 246, 120, 255),
(Color)new Color32(119, 251, 201, 255),
(Color)new Color32(76, 249, 184, 255),
(Color)new Color32(35, 245, 165, 255),
};
public void Spawn(Food food)
{
base.Spawn(food.EntityId);
SetColor(ColorPalette[EntityId % ColorPalette.Length]);
}
}
PlayerController
Open the PlayerController
script and modify the contents of the PlayerController
script to be:
using System.Collections.Generic;
using System.Linq;
using SpacetimeDB;
using SpacetimeDB.Types;
using UnityEngine;
public class PlayerController : MonoBehaviour
{
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 uint PlayerId;
private float LastMovementSendTimestamp;
private Vector2? LockInputPosition;
private List<CircleController> OwnedCircles = new List<CircleController>();
public string Username => GameManager.Conn.Db.Player.PlayerId.Find(PlayerId).Name;
public int NumberOfOwnedCircles => OwnedCircles.Count;
public bool IsLocalPlayer => this == Local;
public void Initialize(Player player)
{
PlayerId = player.PlayerId;
if (player.Identity == GameManager.LocalIdentity)
{
Local = this;
}
}
private void OnDestroy()
{
// If we have any circles, destroy them
foreach (var circle in OwnedCircles)
{
if (circle != null)
{
Destroy(circle.gameObject);
}
}
OwnedCircles.Clear();
}
public void OnCircleSpawned(CircleController circle)
{
OwnedCircles.Add(circle);
}
public void OnCircleDeleted(CircleController deletedCircle)
{
// This means we got eaten
if (OwnedCircles.Remove(deletedCircle) && IsLocalPlayer && OwnedCircles.Count == 0)
{
// DeathScreen.Instance.SetVisible(true);
}
}
public uint TotalMass()
{
return (uint)OwnedCircles
.Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId))
.Sum(e => e?.Mass ?? 0); //If this entity is being deleted on the same frame that we're moving, we can have a null entity here.
}
public Vector2? CenterOfMass()
{
if (OwnedCircles.Count == 0)
{
return null;
}
Vector2 totalPos = Vector2.zero;
float totalMass = 0;
foreach (var circle in OwnedCircles)
{
var entity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId);
var position = circle.transform.position;
totalPos += (Vector2)position * entity.Mass;
totalMass += entity.Mass;
}
return totalPos / totalMass;
}
private void OnGUI()
{
if (!IsLocalPlayer || !GameManager.IsConnected())
{
return;
}
GUI.Label(new Rect(0, 0, 100, 50), $"Total Mass: {TotalMass()}");
}
//Automated testing members
private bool testInputEnabled;
private Vector2 testInput;
public void SetTestInput(Vector2 input) => testInput = input;
public void EnableTestInput() => testInputEnabled = true;
}
Let's also add a new PrefabManager.cs
script which we can use as a factory for creating prefabs. Replace the contents of the file with:
using SpacetimeDB.Types;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PrefabManager : MonoBehaviour
{
private static PrefabManager Instance;
public CircleController CirclePrefab;
public FoodController FoodPrefab;
public PlayerController PlayerPrefab;
private void Awake()
{
Instance = this;
}
public static CircleController SpawnCircle(Circle circle, PlayerController owner)
{
var entityController = Instantiate(Instance.CirclePrefab);
entityController.name = $"Circle - {circle.EntityId}";
entityController.Spawn(circle, owner);
owner.OnCircleSpawned(entityController);
return entityController;
}
public static FoodController SpawnFood(Food food)
{
var entityController = Instantiate(Instance.FoodPrefab);
entityController.name = $"Food - {food.EntityId}";
entityController.Spawn(food);
return entityController;
}
public static PlayerController SpawnPlayer(Player player)
{
var playerController = Instantiate(Instance.PlayerPrefab);
playerController.name = $"PlayerController - {player.Name}";
playerController.Initialize(player);
return playerController;
}
}
In the scene hierarchy, select the GameManager
object and add the Prefab Manager
script as a component to the GameManager
object. Drag the corresponding CirclePrefab
, FoodPrefab
, and PlayerPrefab
prefabs we created earlier from the project view into their respective slots in the Prefab Manager
. Save the scene.
Hooking up the Data
We've now prepared our Unity project so that we can hook up the data from our tables to the Unity game objects and have them drawn on the screen.
Add a couple dictionaries at the top of your GameManager
class which we'll use to hold onto the game objects we create for our scene. Add these two lines just below your DbConnection
like so:
public static DbConnection Conn { get; private set; }
public static Dictionary<uint, EntityController> Entities = new Dictionary<uint, EntityController>();
public static Dictionary<uint, PlayerController> Players = new Dictionary<uint, PlayerController>();
Next lets add some callbacks when rows change in the database. Modify the HandleConnect
method as below.
// Called when we connect to SpacetimeDB and receive our client identity
void HandleConnect(DbConnection conn, Identity identity, string token)
{
Debug.Log("Connected.");
AuthToken.SaveToken(token);
LocalIdentity = identity;
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;
OnConnected?.Invoke();
// Request all tables
Conn.SubscriptionBuilder()
.OnApplied(HandleSubscriptionApplied)
.Subscribe("SELECT * FROM *");
}
Next add the following implementations for those callbacks to the GameManager
class.
private static void CircleOnInsert(EventContext context, Circle insertedValue)
{
var player = GetOrCreatePlayer(insertedValue.PlayerId);
var entityController = PrefabManager.SpawnCircle(insertedValue, player);
Entities.Add(insertedValue.EntityId, entityController);
}
private static void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity)
{
if (!Entities.TryGetValue(newEntity.EntityId, out var entityController))
{
return;
}
entityController.OnEntityUpdated(newEntity);
}
private static void EntityOnDelete(EventContext context, Entity oldEntity)
{
if (Entities.Remove(oldEntity.EntityId, out var entityController))
{
entityController.OnDelete(context);
}
}
private static void FoodOnInsert(EventContext context, Food insertedValue)
{
var entityController = PrefabManager.SpawnFood(insertedValue);
Entities.Add(insertedValue.EntityId, entityController);
}
private static void PlayerOnInsert(EventContext context, Player insertedPlayer)
{
GetOrCreatePlayer(insertedPlayer.PlayerId);
}
private static void PlayerOnDelete(EventContext context, Player deletedvalue)
{
if (Players.Remove(deletedvalue.PlayerId, out var playerController))
{
GameObject.Destroy(playerController.gameObject);
}
}
private static PlayerController GetOrCreatePlayer(uint playerId)
{
if (!Players.TryGetValue(playerId, out var playerController))
{
var player = Conn.Db.Player.PlayerId.Find(playerId);
playerController = PrefabManager.SpawnPlayer(player);
Players.Add(playerId, playerController);
}
return playerController;
}
Camera Controller
One of the last steps is to create a camera controller to make sure the camera moves around with the player. Create a script called CameraController.cs
and add it to your project. Replace the contents of the file with this:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CameraController : MonoBehaviour
{
public static float WorldSize = 0.0f;
private void LateUpdate()
{
var arenaCenterTransform = new Vector3(WorldSize / 2, WorldSize / 2, -10.0f);
if (PlayerController.Local == null || !GameManager.IsConnected())
{
// Set the camera to be in middle of the arena if we are not connected or
// there is no local player
transform.position = arenaCenterTransform;
return;
}
var centerOfMass = PlayerController.Local.CenterOfMass();
if (centerOfMass.HasValue)
{
// Set the camera to be the center of mass of the local player
// if the local player has one
transform.position = new Vector3
{
x = centerOfMass.Value.x,
y = centerOfMass.Value.y,
z = transform.position.z
};
} else {
transform.position = arenaCenterTransform;
}
float targetCameraSize = CalculateCameraSize(PlayerController.Local);
Camera.main.orthographicSize = Mathf.Lerp(Camera.main.orthographicSize, targetCameraSize, Time.deltaTime * 2);
}
private float CalculateCameraSize(PlayerController player)
{
return 50f + //Base size
Mathf.Min(50, player.TotalMass() / 5) + //Increase camera size with mass
Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30; //Zoom out when player splits
}
}
Add the CameraController
as a component to the Main Camera
object in the scene.
Lastly modify the GameManager.SetupArea
method to set the WorldSize
on the CameraController
.
private void SetupArena(float worldSize)
{
CreateBorderCube(new Vector2(worldSize / 2.0f, worldSize + borderThickness / 2),
new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //North
CreateBorderCube(new Vector2(worldSize / 2.0f, -borderThickness / 2),
new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //South
CreateBorderCube(new Vector2(worldSize + borderThickness / 2, worldSize / 2.0f),
new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //East
CreateBorderCube(new Vector2(-borderThickness / 2, worldSize / 2.0f),
new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //West
// Set the world size for the camera controller
CameraController.WorldSize = worldSize;
}
Entering the Game
At this point, you may need to regenerate your bindings the following command from the server-rust
directory.
spacetime generate --lang csharp --out-dir ../client/Assets/autogen
Note
BUG WORKAROUND NOTE: As of
1.0.0-rc3
you will now have a compilation error in Unity. There is currently a bug in the C# code generation that requires you to deleteautogen/LoggedOutPlayer.cs
after running this command.
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(EventContext ctx)
{
Debug.Log("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);
// Call enter game with the player name 3Blave
ctx.Reducers.EnterGame("3Blave");
}
Trying it out
At this point, 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.
Note
The label won't be centered at this point. Feel free to adjust it if you like. We just didn't want to complicate the tutorial.
Troubleshooting
If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called
autogen
If you get an error in your Unity 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.