docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md
import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import { CppModuleVersionNotice } from "@site/src/components/CppModuleVersionNotice";
Need help with the tutorial? Join our Discord server!
This progressive tutorial is continued from part 3.
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.
<Tabs groupId="server-language" defaultValue="rust"> <TabItem value="csharp" label="C#" > Let's start by building out a simple math library to help us do collision calculations. Create a new `Math.cs` file in the `blackholio-server/spacetimedb` 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.Sender) ?? 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 it to all circles that the 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.
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 it to all circles that the 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.h file in the blackholio/spacetimedb/src directory and add the following contents. We'll also move the DbVector2 type from lib.cpp into this file.
#pragma once
#include "spacetimedb.h"
#include <cmath>
using namespace SpacetimeDB;
// This allows us to store 2D points in tables.
struct DbVector2 {
float x;
float y;
// Helper methods
float sqr_magnitude() const {
return x * x + y * y;
}
float magnitude() const {
return std::sqrt(x * x + y * y);
}
DbVector2 normalized() const {
float mag = magnitude();
if (mag != 0.0f) {
return DbVector2{x / mag, y / mag};
}
return DbVector2{0.0f, 0.0f};
}
// Operator overloads
DbVector2 operator+(const DbVector2& other) const {
return DbVector2{x + other.x, y + other.y};
}
DbVector2& operator+=(const DbVector2& other) {
x += other.x;
y += other.y;
return *this;
}
DbVector2 operator-(const DbVector2& other) const {
return DbVector2{x - other.x, y - other.y};
}
DbVector2& operator-=(const DbVector2& other) {
x -= other.x;
y -= other.y;
return *this;
}
DbVector2 operator*(float scalar) const {
return DbVector2{x * scalar, y * scalar};
}
DbVector2 operator/(float scalar) const {
if (scalar != 0.0f) {
return DbVector2{x / scalar, y / scalar};
}
return DbVector2{0.0f, 0.0f};
}
};
SPACETIMEDB_STRUCT(DbVector2, x, y);
At the very top of lib.cpp add the following line to include the DbVector2 from the math.h header, and remove the DbVector2 struct definition from lib.cpp:
#include "spacetimedb.h"
#include "math.h"
// ...
Next, add the following reducer to the end of your lib.cpp file.
SPACETIMEDB_REDUCER(update_player_input, ReducerContext ctx, DbVector2 direction) {
// Find the player
auto player_opt = ctx.db[player_identity].find(ctx.sender());
if (!player_opt.has_value()) {
return Err("Player not found");
}
int32_t player_id = player_opt.value().player_id;
// Update all circles owned by this player
for (Circle circle : ctx.db[circle_player_id].filter(player_id)) {
circle.direction = direction.normalized();
circle.speed = std::clamp(direction.magnitude(), 0.0f, 1.0f);
ctx.db[circle_entity_id].update(circle);
}
return Ok();
}
This is a simple reducer that takes the movement input from the client and applies it to all circles that the 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.
</TabItem>
</Tabs>
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.
<Tabs groupId="server-language" defaultValue="rust"> <TabItem value="csharp" label="C#" >[Table(Accessor = "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 int START_PLAYER_SPEED = 10;
public static float MassToMaxMoveSpeed(int mass) => 2f * START_PLAYER_SPEED / (1f + MathF.Sqrt((float)mass / START_PLAYER_MASS));
[Reducer]
public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer)
{
var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size;
// var circleDirections = 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 checkEntity = ctx.Db.entity.entity_id.Find(circle.entity_id);
if (!checkEntity.HasValue)
{
// This can happen if the circle has been eaten by another circle.
continue;
}
var circleEntity = checkEntity.Value;
var circleRadius = MassToRadius(circleEntity.mass);
var direction = circle.direction * circle.speed;
var newPosition = circleEntity.position + direction * MassToMaxMoveSpeed(circleEntity.mass);
circleEntity.position.x = Math.Clamp(newPosition.x, circleRadius, worldSize - circleRadius);
circleEntity.position.y = Math.Clamp(newPosition.y, circleRadius, worldSize - circleRadius);
ctx.Db.entity.entity_id.Update(circleEntity);
}
}
#[spacetimedb::table(accessor = 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: i32 = 10;
fn mass_to_max_move_speed(mass: i32) -> 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 circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id);
if !circle_entity.is_some() {
// This can happen if a circle is eaten by another circle
continue;
}
let mut circle_entity = circle_entity.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(())
}
struct MoveAllPlayersTimer {
uint64_t scheduled_id;
ScheduleAt scheduled_at;
};
SPACETIMEDB_STRUCT(MoveAllPlayersTimer, scheduled_id, scheduled_at);
SPACETIMEDB_TABLE(MoveAllPlayersTimer, move_all_players_timer, Private);
FIELD_PrimaryKeyAutoInc(move_all_players_timer, scheduled_id);
SPACETIMEDB_SCHEDULE(move_all_players_timer, 1, move_all_players);
const int32_t START_PLAYER_SPEED = 10;
float mass_to_max_move_speed(int32_t mass) {
return 2.0f * START_PLAYER_SPEED / (1.0f + std::sqrt(static_cast<float>(mass) / static_cast<float>(START_PLAYER_MASS)));
}
SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer) {
// Get world size from config
auto config_opt = ctx.db[config_id].find(0);
if (!config_opt.has_value()) {
return Err("Config not found");
}
int64_t world_size = config_opt.value().world_size;
// Handle player input
for (const Circle& circle : ctx.db[circle]) {
auto circle_entity_opt = ctx.db[entity_entity_id].find(circle.entity_id);
if (!circle_entity_opt.has_value()) {
// This can happen if a circle is eaten by another circle
continue;
}
Entity circle_entity = circle_entity_opt.value();
float circle_radius = mass_to_radius(circle_entity.mass);
DbVector2 direction = circle.direction * circle.speed;
DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass);
float min_bound = circle_radius;
float max_bound = static_cast<float>(world_size) - circle_radius;
circle_entity.position.x = std::clamp(new_pos.x, min_bound, max_bound);
circle_entity.position.y = std::clamp(new_pos.y, min_bound, max_bound);
ctx.db[entity_entity_id].update(circle_entity);
}
return Ok();
}
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 Godot. 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.
<Tabs groupId="server-language" defaultValue="rust"> <TabItem value="csharp" label="C#" > 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))
});
ctx.db
.move_all_players_timer()
.try_insert(MoveAllPlayersTimer {
scheduled_id: 0,
scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()),
})?;
ctx.db[move_all_players_timer].insert(MoveAllPlayersTimer{
0,
ScheduleAt(TimeDuration::from_millis(50)),
});
Republish your module with:
spacetime publish --server local blackholio --delete-data
Regenerate your server bindings with:
spacetime generate --lang csharp --out-dir ../../module_bindings
All that's left is to modify our PlayerController on the client to call the update_player_input reducer. Open PlayerController.cs and add a _Process method:
public override void _Process(double delta)
{
if (!IsLocalPlayer || NumberOfOwnedCircles == 0 || !GameManager.IsConnected()) return;
var lockTogglePressed = Input.IsPhysicalKeyPressed(Key.Q);
if (lockTogglePressed && !_lockInputTogglePressed)
{
if (_lockInputPosition.HasValue)
{
_lockInputPosition = null;
}
else
{
_lockInputPosition = GetViewport().GetMousePosition();
}
}
_lockInputTogglePressed = lockTogglePressed;
var nowSeconds = Time.GetTicksMsec() / 1000.0f;
if (nowSeconds - _lastMovementSendTimestamp < SEND_UPDATES_FREQUENCY) return;
_lastMovementSendTimestamp = nowSeconds;
var mousePosition = _lockInputPosition ?? GetViewport().GetMousePosition();
var screenSize = GetViewport().GetVisibleRect().Size;
var centerOfScreen = screenSize / 2.0f;
var direction = (mousePosition - centerOfScreen) / (screenSize.Y / 3.0f);
GameManager.Conn.Reducers.UpdatePlayerInput(direction);
}
Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas.
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?
<Tabs groupId="server-language" defaultValue="rust"> <TabItem value="csharp" label="C#" > 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](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze. 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.
private 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 distanceSq = dx * dx + dy * dy;
var aRadius = MassToRadius(a.mass);
var bRadius = 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 maxRadius = aRadius > bRadius ? aRadius: bRadius;
return distanceSq <= maxRadius * maxRadius;
}
[Reducer]
public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer)
{
var worldSize = (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 checkEntity = ctx.Db.entity.entity_id.Find(circle.entity_id);
if (checkEntity == null)
{
// This can happen if the circle has been eaten by another circle.
continue;
}
var circleEntity = checkEntity.Value;
var circleRadius = MassToRadius(circleEntity.mass);
var direction = circle.direction * circle.speed;
var newPosition = circleEntity.position + direction * MassToMaxMoveSpeed(circleEntity.mass);
circleEntity.position.x = Math.Clamp(newPosition.x, circleRadius, worldSize - circleRadius);
circleEntity.position.y = Math.Clamp(newPosition.y, circleRadius, worldSize - circleRadius);
// Check collisions
foreach (var entity in ctx.Db.entity.Iter())
{
if (entity.entity_id == circleEntity.entity_id || !IsOverlapping(circleEntity, entity)) continue;
// 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);
circleEntity.mass += entity.mass;
continue;
}
// Check to see if we're overlapping with another circle owned by another player
var otherCircle = ctx.Db.circle.entity_id.Find(entity.entity_id);
if (otherCircle.HasValue && otherCircle.Value.player_id != circle.player_id)
{
var massRatio = (float)entity.mass / circleEntity.mass;
if (massRatio < MINIMUM_SAFE_MASS_RATIO)
{
ctx.Db.entity.entity_id.Delete(entity.entity_id);
ctx.Db.circle.entity_id.Delete(entity.entity_id);
circleEntity.mass += entity.mass;
}
}
}
ctx.Db.entity.entity_id.Update(circleEntity);
}
}
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 circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id);
if !circle_entity.is_some() {
// This can happen if a circle is eaten by another circle
continue;
}
let mut circle_entity = circle_entity.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(())
}
Add the following code to your lib.cpp file and make sure to replace the existing move_all_players reducer.
const float MINIMUM_SAFE_MASS_RATIO = 0.85f;
bool is_overlapping(const Entity& a, const Entity& b) {
float dx = a.position.x - b.position.x;
float dy = a.position.y - b.position.y;
float distance_sq = dx * dx + dy * dy;
float radius_a = mass_to_radius(a.mass);
float 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.
float max_radius = std::max(radius_a, radius_b);
return distance_sq <= max_radius * max_radius;
}
SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer) {
// Get world size from config
auto config_opt = ctx.db[config_id].find(0);
if (!config_opt.has_value()) {
return Err("Config not found");
}
int64_t world_size = config_opt.value().world_size;
// Handle player input
for (const Circle& circle : ctx.db[circle]) {
auto circle_entity_opt = ctx.db[entity_entity_id].find(circle.entity_id);
if (!circle_entity_opt.has_value()) {
// This can happen if a circle is eaten by another circle
continue;
}
Entity circle_entity = circle_entity_opt.value();
float circle_radius = mass_to_radius(circle_entity.mass);
DbVector2 direction = circle.direction * circle.speed;
DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass);
float min_bound = circle_radius;
float max_bound = static_cast<float>(world_size) - circle_radius;
circle_entity.position.x = std::clamp(new_pos.x, min_bound, max_bound);
circle_entity.position.y = std::clamp(new_pos.y, min_bound, max_bound);
// Check collisions
for (const Entity& entity : ctx.db[entity]) {
if (entity.entity_id == circle_entity.entity_id) {
continue;
}
if (is_overlapping(circle_entity, entity)) {
// Check to see if we're overlapping with food
auto food_opt = ctx.db[food_entity_id].find(entity.entity_id);
if (food_opt.has_value()) {
ctx.db[entity_entity_id].delete_by_key(entity.entity_id);
ctx.db[food_entity_id].delete_by_key(entity.entity_id);
circle_entity.mass += entity.mass;
}
// Check to see if we're overlapping with another circle owned by another player
auto other_circle_opt = ctx.db[circle_entity_id].find(entity.entity_id);
if (other_circle_opt.has_value()) {
const Circle& other_circle = other_circle_opt.value();
if (other_circle.player_id != circle.player_id) {
float mass_ratio = static_cast<float>(entity.mass) / static_cast<float>(circle_entity.mass);
if (mass_ratio < MINIMUM_SAFE_MASS_RATIO) {
ctx.db[entity_entity_id].delete_by_key(entity.entity_id);
ctx.db[circle_entity_id].delete_by_key(entity.entity_id);
circle_entity.mass += entity.mass;
}
}
}
}
}
ctx.db[entity_entity_id].update(circle_entity);
}
return Ok();
}
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 close to 600 food on the map.
spacetime publish --server maincloud <your database name> --delete-data
<your database name> This name should be unique and cannot contain any special characters other than internal hyphens (-). You will have to update the database name in blackholio-server/spacetime.local.json to match.https://maincloud.spacetimedb.com<your database name>.To delete your Maincloud database, you can run: spacetime delete --server maincloud <your database name>
You've also learned how to view module logs and connect your client to your database server, 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 players to use the same username on 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. Note that you would need to run the client in different computers to use different auth tokens.
There's still plenty more we can do to build this into a proper game though. For example, you might want to also add
If you have any suggestions or comments on the tutorial, either open an issue, or join our Discord (https://discord.gg/SpacetimeDB) and chat with us!