Testnet is now LIVE at testnet.spacetimedb.com! NOTE: This is a testnet, and all data will be wiped periodically.

1.0 RC3

Login

Unity Tutorial - Part 4 - Moving and Colliding

Need help with the tutorial? Join our Discord server!

This progressive tutorial is continued from part 3.

Moving the player

At this point, we're very close to having a working game. All we have to do is modify our server to allow the player to move around, and to simulate the physics and collisions of the game.

Module Language:

Let's start by building out a simple math library to help us do collision calculations. Create a new math.rs file in the server-rust/src directory and add the following contents. Let's also move the DbVector2 type from lib.rs into this file.

use spacetimedb::SpacetimeType;

// This allows us to store 2D points in tables.
#[derive(SpacetimeType, Debug, Clone, Copy)]
pub struct DbVector2 {
    pub x: f32,
    pub y: f32,
}

impl std::ops::Add<&DbVector2> for DbVector2 {
    type Output = DbVector2;

    fn add(self, other: &DbVector2) -> DbVector2 {
        DbVector2 {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

impl std::ops::Add<DbVector2> for DbVector2 {
    type Output = DbVector2;

    fn add(self, other: DbVector2) -> DbVector2 {
        DbVector2 {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

impl std::ops::AddAssign<DbVector2> for DbVector2 {
    fn add_assign(&mut self, rhs: DbVector2) {
        self.x += rhs.x;
        self.y += rhs.y;
    }
}

impl std::iter::Sum<DbVector2> for DbVector2 {
    fn sum<I: Iterator<Item = DbVector2>>(iter: I) -> Self {
        let mut r = DbVector2::new(0.0, 0.0);
        for val in iter {
            r += val;
        }
        r
    }
}

impl std::ops::Sub<&DbVector2> for DbVector2 {
    type Output = DbVector2;

    fn sub(self, other: &DbVector2) -> DbVector2 {
        DbVector2 {
            x: self.x - other.x,
            y: self.y - other.y,
        }
    }
}

impl std::ops::Sub<DbVector2> for DbVector2 {
    type Output = DbVector2;

    fn sub(self, other: DbVector2) -> DbVector2 {
        DbVector2 {
            x: self.x - other.x,
            y: self.y - other.y,
        }
    }
}

impl std::ops::SubAssign<DbVector2> for DbVector2 {
    fn sub_assign(&mut self, rhs: DbVector2) {
        self.x -= rhs.x;
        self.y -= rhs.y;
    }
}

impl std::ops::Mul<f32> for DbVector2 {
    type Output = DbVector2;

    fn mul(self, other: f32) -> DbVector2 {
        DbVector2 {
            x: self.x * other,
            y: self.y * other,
        }
    }
}

impl std::ops::Div<f32> for DbVector2 {
    type Output = DbVector2;

    fn div(self, other: f32) -> DbVector2 {
        if other != 0.0 {
            DbVector2 {
                x: self.x / other,
                y: self.y / other,
            }
        } else {
            DbVector2 { x: 0.0, y: 0.0 }
        }
    }
}

impl DbVector2 {
    pub fn new(x: f32, y: f32) -> Self {
        Self { x, y }
    }

    pub fn sqr_magnitude(&self) -> f32 {
        self.x * self.x + self.y * self.y
    }

    pub fn magnitude(&self) -> f32 {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    pub fn normalized(self) -> DbVector2 {
        self / self.magnitude()
    }
} 

At the very top of lib.rs add the following lines to import the moved DbVector2 from the math module.

pub mod math;

use math::DbVector2;
// ... 

Next, add the following reducer to your lib.rs file.

#[spacetimedb::reducer]
pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result<(), String> {
    let player = ctx
        .db
        .player()
        .identity()
        .find(&ctx.sender)
        .ok_or("Player not found")?;
    for mut circle in ctx.db.circle().player_id().filter(&player.player_id) {
        circle.direction = direction.normalized();
        circle.speed = direction.magnitude().clamp(0.0, 1.0);
        ctx.db.circle().entity_id().update(circle);
    }
    Ok(())
} 

This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the ctx.sender value is not set by the client. Instead ctx.sender is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called.

Let's start by building out a simple math library to help us do collision calculations. Create a new Math.cs file in the csharp-server directory and add the following contents. Let's also remove the DbVector2 type from Lib.cs.

[SpacetimeDB.Type]
public partial struct DbVector2
{
    public float x;
    public float y;

    public DbVector2(float x, float y)
    {
        this.x = x;
        this.y = y;
    }

    public float SqrMagnitude => x * x + y * y;
    public float Magnitude => MathF.Sqrt(SqrMagnitude);
    public DbVector2 Normalized => this / Magnitude;

    public static DbVector2 operator +(DbVector2 a, DbVector2 b) => new DbVector2(a.x + b.x, a.y + b.y);
    public static DbVector2 operator -(DbVector2 a, DbVector2 b) => new DbVector2(a.x - b.x, a.y - b.y);
    public static DbVector2 operator *(DbVector2 a, float b) => new DbVector2(a.x * b, a.y * b);
    public static DbVector2 operator /(DbVector2 a, float b) => new DbVector2(a.x / b, a.y / b);
} 

Next, add the following reducer to the Module class of your Lib.cs file.

[Reducer]
public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction)
{
    var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found");				
    foreach (var c in ctx.Db.circle.player_id.Filter(player.player_id))
    {
        var circle = c;
        circle.direction = direction.Normalized;
        circle.speed = Math.Clamp(direction.Magnitude, 0f, 1f);
        ctx.Db.circle.entity_id.Update(circle);
    }
		  
} 

This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the ctx.CallerIdentity value is not set by the client. Instead ctx.CallerIdentity is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called.

Finally, let's schedule a reducer to run every 50 milliseconds to move the player's circles around based on the most recently set player input.

Module Language:
#[spacetimedb::table(name = move_all_players_timer, scheduled(move_all_players))]
pub struct MoveAllPlayersTimer {
    #[primary_key]
    #[auto_inc]
    scheduled_id: u64,
    scheduled_at: spacetimedb::ScheduleAt,
}

const START_PLAYER_SPEED: u32 = 10;

fn mass_to_max_move_speed(mass: u32) -> f32 {
    2.0 * START_PLAYER_SPEED as f32 / (1.0 + (mass as f32 / START_PLAYER_MASS as f32).sqrt())
}

#[spacetimedb::reducer]
pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> {
    let world_size = ctx
        .db
        .config()
        .id()
        .find(0)
        .ok_or("Config not found")?
        .world_size;

    // Handle player input
    for circle in ctx.db.circle().iter() {
        let mut circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id).unwrap();
        let circle_radius = mass_to_radius(circle_entity.mass);
        let direction = circle.direction * circle.speed;
        let new_pos =
            circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass);
        let min = circle_radius;
        let max = world_size as f32 - circle_radius;
        circle_entity.position.x = new_pos.x.clamp(min, max);
        circle_entity.position.y = new_pos.y.clamp(min, max);
        ctx.db.entity().entity_id().update(circle_entity);
    }

    Ok(())
} 
[Table(Name = "move_all_players_timer", Scheduled = nameof(MoveAllPlayers), ScheduledAt = nameof(scheduled_at))]
public partial struct MoveAllPlayersTimer
{
    [PrimaryKey, AutoInc]
    public ulong scheduled_id;
    public ScheduleAt scheduled_at;
}

const uint START_PLAYER_SPEED = 10;

public static float MassToMaxMoveSpeed(uint mass) => 2f * START_PLAYER_SPEED / (1f + MathF.Sqrt((float)mass / START_PLAYER_MASS));

[Reducer]
public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer)
{
    var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size;

    var circle_directions = ctx.Db.circle.Iter().Select(c => (c.entity_id, c.direction * c.speed)).ToDictionary();

    // Handle player input
    foreach (var circle in ctx.Db.circle.Iter())
    {
        var circle_entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Circle has no entity");
        var circle_radius = MassToRadius(circle_entity.mass);
        var direction = circle_directions[circle.entity_id];
        var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass);
        circle_entity.position.x = Math.Clamp(new_pos.x, circle_radius, world_size - circle_radius);
        circle_entity.position.y = Math.Clamp(new_pos.y, circle_radius, world_size - circle_radius);
        ctx.Db.entity.entity_id.Update(circle_entity);
    }
} 

This reducer is very similar to a standard game "tick" or "frame" that you might find in an ordinary game server or similar to something like the Update loop in a game engine like Unity. We've scheduled it every 50 milliseconds and we can use it to step forward our simulation by moving all the circles a little bit further in the direction they're moving.

In this reducer, we're just looping through all the circles in the game and updating their position based on their direction, speed, and mass. Just basic physics.

Module Language:

Add the following to your init reducer to schedule the move_all_players reducer to run every 50 milliseconds.

ctx.db
    .move_all_players_timer()
    .try_insert(MoveAllPlayersTimer {
        scheduled_id: 0,
        scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).as_micros() as u64),
    })?; 

Add the following to your Init reducer to schedule the MoveAllPlayers reducer to run every 50 milliseconds.

ctx.Db.move_all_players_timer.Insert(new MoveAllPlayersTimer
{
    scheduled_at = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(50))
}); 

Republish your module with:

spacetime publish --server local blackholio --delete-data 

Regenerate your server bindings with:

spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen 

Note

BUG WORKAROUND NOTE: You may have to delete LoggedOutPlayer.cs again.

Moving on the Client

All that's left is to modify our PlayerController on the client to call the update_player_input reducer. Open PlayerController.cs and add an Update function:

    public void Update()
    {
        if (!IsLocalPlayer || NumberOfOwnedCircles == 0)
        {
            return;
        }

        if (Input.GetKeyDown(KeyCode.Q))
        {
            if (LockInputPosition.HasValue)
            {
                LockInputPosition = null;
			}
            else
            {
				LockInputPosition = (Vector2)Input.mousePosition;
            }
        }

        // Throttled input requests
        if (Time.time - LastMovementSendTimestamp >= SEND_UPDATES_FREQUENCY)
        {
            LastMovementSendTimestamp = Time.time;

            var mousePosition = LockInputPosition ?? (Vector2)Input.mousePosition;
            var screenSize = new Vector2
            {
                x = Screen.width,
                y = Screen.height,
            };
            var centerOfScreen = screenSize / 2;

			var direction = (mousePosition - centerOfScreen) / (screenSize.y / 3);
            if (testInputEnabled) { direction = testInput; }
            GameManager.Conn.Reducers.UpdatePlayerInput(direction);
        }
	} 

Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas.

Collisions and Eating Food

Well this is pretty fun, but wouldn't it be better if we could eat food and grow our circle? Surely, that's going to be a pain, right?

Module Language:

Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an is_overlapping helper function which does some basic math based on mass radii, and modify our move_all_player reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing spatial hashing might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB.

Sometimes simple is best! Add the following code to your lib.rs file and make sure to replace the existing move_all_players reducer.

const MINIMUM_SAFE_MASS_RATIO: f32 = 0.85;

fn is_overlapping(a: &Entity, b: &Entity) -> bool {
    let dx = a.position.x - b.position.x;
    let dy = a.position.y - b.position.y;
    let distance_sq = dx * dx + dy * dy;

    let radius_a = mass_to_radius(a.mass);
    let radius_b = mass_to_radius(b.mass);

    // If the distance between the two circle centers is less than the 
    // maximum radius, then the center of the smaller circle is inside
    // the larger circle. This gives some leeway for the circles to overlap
    // before being eaten.
    let max_radius = f32::max(radius_a, radius_b);
    distance_sq <= max_radius * max_radius
}

#[spacetimedb::reducer]
pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> {
    let world_size = ctx
        .db
        .config()
        .id()
        .find(0)
        .ok_or("Config not found")?
        .world_size;

    // Handle player input
    for circle in ctx.db.circle().iter() {
        let mut circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id).unwrap();
        let circle_radius = mass_to_radius(circle_entity.mass);
        let direction = circle.direction * circle.speed;
        let new_pos =
            circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass);
        let min = circle_radius;
        let max = world_size as f32 - circle_radius;
        circle_entity.position.x = new_pos.x.clamp(min, max);
        circle_entity.position.y = new_pos.y.clamp(min, max);

        // Check collisions
        for entity in ctx.db.entity().iter() {
            if entity.entity_id == circle_entity.entity_id {
                continue;
            }
            if is_overlapping(&circle_entity, &entity) {
                // Check to see if we're overlapping with food
                if ctx.db.food().entity_id().find(&entity.entity_id).is_some() {
                    ctx.db.entity().entity_id().delete(&entity.entity_id);
                    ctx.db.food().entity_id().delete(&entity.entity_id);
                    circle_entity.mass += entity.mass;
                }

                // Check to see if we're overlapping with another circle owned by another player
                let other_circle = ctx.db.circle().entity_id().find(&entity.entity_id);
                if let Some(other_circle) = other_circle {
                    if other_circle.player_id != circle.player_id {
                        let mass_ratio = entity.mass as f32 / circle_entity.mass as f32;
                        if mass_ratio < MINIMUM_SAFE_MASS_RATIO {
                            ctx.db.entity().entity_id().delete(&entity.entity_id);
                            ctx.db.circle().entity_id().delete(&entity.entity_id);
                            circle_entity.mass += entity.mass;
                        }
                    }
                }
            }
        }
        ctx.db.entity().entity_id().update(circle_entity);
    }

    Ok(())
} 

Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an IsOverlapping helper function which does some basic math based on mass radii, and modify our MoveAllPlayers reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing spatial hashing might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB.

Sometimes simple is best! Add the following code to the Module class of your Lib.cs file and make sure to replace the existing MoveAllPlayers reducer.

const float MINIMUM_SAFE_MASS_RATIO = 0.85f;

public static bool IsOverlapping(Entity a, Entity b)
{
    var dx = a.position.x - b.position.x;
    var dy = a.position.y - b.position.y;
    var distance_sq = dx * dx + dy * dy;

    var radius_a = MassToRadius(a.mass);
    var radius_b = MassToRadius(b.mass);
    
    // If the distance between the two circle centers is less than the
    // maximum radius, then the center of the smaller circle is inside
    // the larger circle. This gives some leeway for the circles to overlap
    // before being eaten.
    var max_radius = radius_a > radius_b ? radius_a: radius_b;
    return distance_sq <= max_radius * max_radius;
}

[Reducer]
public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer)
{
    var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size;

    // Handle player input
    foreach (var circle in ctx.Db.circle.Iter())
    {
        var circle_entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Circle has no entity");
        var circle_radius = MassToRadius(circle_entity.mass);
        var direction = circle.direction * circle.speed;
        var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass);
        circle_entity.position.x = Math.Clamp(new_pos.x, circle_radius, world_size - circle_radius);
        circle_entity.position.y = Math.Clamp(new_pos.y, circle_radius, world_size - circle_radius);

        // Check collisions
        foreach (var entity in ctx.Db.entity.Iter())
        {
            if (entity.entity_id == circle_entity.entity_id)
            {
                continue;
            }
            if (IsOverlapping(circle_entity, entity))
            {
                // Check to see if we're overlapping with food
                if (ctx.Db.food.entity_id.Find(entity.entity_id).HasValue) {
                    ctx.Db.entity.entity_id.Delete(entity.entity_id);
                    ctx.Db.food.entity_id.Delete(entity.entity_id);
                    circle_entity.mass += entity.mass;
                }
                
                // Check to see if we're overlapping with another circle owned by another player
                var other_circle = ctx.Db.circle.entity_id.Find(entity.entity_id);
                if (other_circle.HasValue &&
                    other_circle.Value.player_id != circle.player_id)
                {
                    var mass_ratio = (float)entity.mass / circle_entity.mass;
                    if (mass_ratio < MINIMUM_SAFE_MASS_RATIO)
                    {
                        ctx.Db.entity.entity_id.Delete(entity.entity_id);
                        ctx.Db.circle.entity_id.Delete(entity.entity_id);
                        circle_entity.mass += entity.mass;
                    }
                }
            }
        }
        ctx.Db.entity.entity_id.Update(circle_entity);
    }
} 

For every circle, we look at all other entities. If they are overlapping then for food, we add the mass of the food to the circle and delete the food, otherwise if it's a circle we delete the smaller circle and add the mass to the bigger circle.

That's it. We don't even have to do anything on the client.

spacetime publish --server local blackholio 

Just update your module by publishing and you're on your way eating food! Try to see how big you can get!

We didn't even have to update the client, because our client's OnDelete callbacks already handled deleting entities from the scene when they're deleted on the server. SpacetimeDB just synchronizes the state with your client automatically.

Notice that the food automatically respawns as you vaccuum them up. This is because our scheduled reducer is automatically replacing the food 2 times per second, to ensure that there is always 600 food on the map.

Conclusion

Module Language:

So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like client_connected and init and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module.

So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like ClientConnected and Init and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module.

You've also learned how view module logs and connect your client to your server module, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game!

And all of that completely from scratch!

Our game is still pretty limited in some important ways. The biggest limitation is that the client assumes your username is "3Blave" and doesn't give you a menu or a window to set your username before joining the game. Notably, we do not have a unique constraint on the name column, so that does not prevent us from connecting multiple clients to the same server.

In fact, if you build what we have and run multiple clients you already have a (very simple) MMO! You can connect hundreds of players to this arena with SpacetimeDB.

There's still plenty more we can do to build this into a proper game though. For example, you might want to also add

  • Username chooser
  • Chat
  • Leaderboards
  • Nice animations
  • Nice shaders
  • Space theme!

Fortunately, we've done that for you! If you'd like to check out the completed tutorial game, with these additional features, you can download it on GitHub:

https://github.com/ClockworkLabs/Blackholio

If you have any suggestions or comments on the tutorial, either open an issue in our docs repo, or join our Discord (https://discord.gg/SpacetimeDB) and chat with us!

Edit On Github