Serverless Multiplayer Game with Three.js and Rogue Engine
In this tutorial we’re gonna take the third person character controller from my previous tutorial and make it multiplayer.
If you didn’t read that, I recommend you do that now. We’ll start from where we left off.
This is a text version of a video tutorial, so if that’s your kind of thing you can watch it here:
Initial Setup
The first thing we need to do is hit alt+M
or option+M
to open the Marketplace and we’ll install Rogue Croquet.
This package is an officially supported integration of Croquet, a platform that allows you to create multiplayer games, without the need for a server. It works by synchronizing clients through the Multisynq reflector network.
This is, naturally, way more affordable and convenient than writing and running servers.
So we’ll go to croquet.io and sign up to get an API key and an App ID.
Now, as it’s explained in the Github README, we need to create a json file with the following structure in the Static
folder of our project:
{
"appId": "[your app ID here]",
"apiKey": "[your API key here]"
}
Go ahead and create the /Static/croquet.json
file. Paste the code and replace those fields with your actual appId
and apiKey
.
Now we’ll head back to the editor, select the Scene
object and we’re gonna add the CroquetConfig
component. This will basically start a multiplayer session when we play the scene. For the purpose of this tutorial, we’re just gonna add everyone to the main session.
Important: Drag it all the way to the top to make sure it has priority over the physics.
Then we’ll set the appName to Test v1
. Changing the name will give you a fresh new main session.
The Player Prefab
Now, for every player that joins, we need to instantiate a Player Character like the one we created in the previous tutorial.
The way we do this is by creating a Prefab
that will represent what we’ll call an Actor
. I know it sounds a bit obscure right now but it’ll be SUPER clear later so make sure you stick all the way to the end.
Currently our player is controlled by the RapierKinnematicCharacterController
. This is excellent to control it locally but, when it’s representing a remote player, we need to freely set its transform without the restrictions of the controller. So to make this very simple we’ll add a RapierBody
, set its type
to fixed
, and drag it all the way to the top.
Then We’ll disable the RapierKinnematicCharacterController
and the RapierThirdPersonController
.
So if it’s us controlling the player we’ll remove the RapierBody
and enable the other two.
Then, we’ll drag and drop our object into the Icon View to create the Prefab
, and finally, we can delete the instance from our Scene.
GameLogic
Next, we’ll create a Component and call it GameLogic
.
Open it in your code editor and add the following:
import { RogueCroquet } from '@RE/RogueEngine/rogue-croquet';
import { BaseModel } from '@RE/RogueEngine/rogue-croquet/BaseModel';
import CroquetView from '@RE/RogueEngine/rogue-croquet/CroquetView.re';
import * as RE from 'rogue-engine';
@RogueCroquet.Model
export class GameLogicModel extends BaseModel {
isStaticModel = true;
}
@RE.registerComponent
export default class GameLogic extends CroquetView {
isStaticModel = true;
}
Here we start by creating a Model
for our component. We do this by giving the Model decorator to a class which we’ll name exactly like the component but adding the word “Model” in front and extending BaseModel
.
This is what Croquet
actually synchronizes between clients internally. Models need to be identical. If a bad actor modifies the model, they just won’t be joining the party.
The component itself needs to extend from CroquetView
, as it will act as the View
, providing the representation of whatever the model is synchronizing.
In this case, we’ll also need to set the isStaticModel
property to true
in both the Model
and View
to tell the RootModel
that we only want a single instance of this one.
Keep in mind that each player will recreate the session locally, so this is the way we ensure they don’t create duplicates.
Now, GameLogic
will be in charge of spawning our players so let’s go ahead and complete our Model and View:
import { RogueCroquet } from '@RE/RogueEngine/rogue-croquet';
import { BaseModel } from '@RE/RogueEngine/rogue-croquet/BaseModel';
import CroquetView from '@RE/RogueEngine/rogue-croquet/CroquetView.re';
import RapierBody from '@RE/RogueEngine/rogue-rapier/Components/RapierBody.re';
import RapierKinematicCharacterController from '@RE/RogueEngine/rogue-rapier/Components/RapierKinematicCharacterController.re';
import RapierThirdPersonController from '@RE/RogueEngine/rogue-rapier/Components/Controllers/RapierThirdPersonController.re';
import * as RE from 'rogue-engine';
@RogueCroquet.Model
export class GameLogicModel extends BaseModel {
isStaticModel = true;
// We create an array of previously selected spawn points.
spawnPoints = [
[-10, 10, 0], [-10, 10, -10], [-20, 10, -10],
[-20, 10, 0], [-15, 10, 0],
];
/*
Select a random spawn point from the list. we'll use this later in the
Player Model
*/
selectSpawnPoint() {
return this.spawnPoints[Math.floor(Math.random() * this.spawnPoints.length)];
}
}
@RE.registerComponent
export default class GameLogic extends CroquetView {
// We also need to set isStaticModel to true in the View.
isStaticModel = true;
// We add a Prefab field where we'll drop our player prefab in the inspector.
@RE.props.prefab() player: RE.Prefab;
// The init method will be executed when the `GameLogicModel` is initialized.
init() {
// Instantiate the player prefab. This is our local player.
const player = this.player.instantiate();
// Get the RapierBody from the player instance and remove it.
const body = RapierBody.get(player);
if (body) RE.removeComponent(body);
// Get the RapierKinematicCharacterController and disable it.
const controller = RapierKinematicCharacterController.get(player);
if (controller) controller.enabled = true;
// Get the RapierThirdPersonController and enable it.
const tpc = RapierThirdPersonController.get(player);
if (tpc) tpc.enabled = true;
}
}
Back in the editor, we’ll select the Scene
object and add the GameLogic
component.
You can see that it has the Prefab
field we created so we’ll drop our ThirdPersonCharacter
prefab there.
PlayerController
Next we need to take care of the PlayerController
so, we’ll open it in the editor.
Same as with the GameLogic, we need a Model/View
pair, but since we’re representing our players, We’ll need a subtype Actor/Pawn
pair.
So instead of BaseModel
, we’ll extend its subtype Actor
, and instead of CroquetView
our Component will extend its subtype CroquetPawn
.
import { RogueCroquet } from '@RE/RogueEngine/rogue-croquet';
import { Actor } from '@RE/RogueEngine/rogue-croquet/Actor';
import CroquetPawn from '@RE/RogueEngine/rogue-croquet/CroquetPawn.re';
import * as RE from 'rogue-engine';
@RogueCroquet.Model
export class PlayerControllerModel extends Actor {
}
@RE.registerComponent
export default class PlayerController extends CroquetPawn {
}
These have a special treatment by RogueCroquet. Basically, when a player joins, the RootModel will look internally for all the actors in the session and spawn them for you.
If you’re using typescript like me, you’ll need to set the type of the model property to PlayerControllerModel.
Now, in the editor, If you bring the player prefab back, you’ll see that the PlayerController now has this pawnPrefab field. This is the prefab that will be instantiated to represent other players. In our case, we’ll be using the same prefab so go ahead and drop it there…
Then drop the prefab in the icon view again to save it and delete the instance from our scene
Back in the PlayerController
, to synchronize our players we’ll use what I think is the best approach for most use-cases as it also allows you to get started easily and scale in complexity as needed.
The Actor
will be in charge of selecting our spawn point, and the Pawn
will broadcast our local isGrounded
status and transform
to all players. If it’s the view representing a remote player, it’ll apply the received isGrounded
or the transform
.
Now, the Actor model will need to keep track if we’re grounded or not, to set the correct animations and also the transform, which we’ll synchronize as an object containing the position, rotation and directionLength of our input, also useful for the animations.
For this we’ll use the @Model.prop()
decorator. This will create a link between the property of the same name in the Model. Super useful!
...
@RogueCroquet.Model
export class PlayerControllerModel extends Actor {
// We need to keep track of the grounded status.
isGrounded = false;
}
@RE.registerComponent
export default class PlayerController extends CroquetPawn {
// We link with isGrounded in the Actor model.
@PlayerControllerModel.prop(true)
isGrounded = false;
}
In this case we’re passing true
as a parameter. This will create a two way bind link, allowing us to send an update to the model, using updateProp('isGrounded')
.
We can also pass in a number to set the maximum update rate.
If we pass no parameters, the bound property will only read the value from the model but won’t be able to send updates. Super useful in many situations but, we won’t be needing this today.
Let’s go ahead and complete our Actor/Pawn
pair:
import RogueAnimator from '@RE/RogueEngine/rogue-animator/RogueAnimator.re';
import { RogueCroquet } from '@RE/RogueEngine/rogue-croquet';
import { Actor } from '@RE/RogueEngine/rogue-croquet/Actor';
import CroquetPawn from '@RE/RogueEngine/rogue-croquet/CroquetPawn.re';
import RapierKinematicCharacterController from '@RE/RogueEngine/rogue-rapier/Components/RapierKinematicCharacterController.re';
import { GameLogicModel } from './GameLogic.re';
import * as THREE from 'three';
import * as RE from 'rogue-engine';
@RogueCroquet.Model
export class PlayerControllerModel extends Actor {
isGrounded = false;
/*
Besides the position and rotation, the transform will keep
track of the input direction Length, to help sync animations
when moving with an analog stick.
*/
transform = {
pos: new THREE.Vector3(),
rot: new THREE.Quaternion(),
dirLength: 0
};
// Internal listener called when the model is initialized.
onInit() {
/*
Retrieve the GameLogicModel. All static Models are
automatically registerd to be retrievable.
*/
const gameLogicModel = this.wellKnownModel("GameLogic") as GameLogicModel;
// Use the method we created before to get a random spawn point
const spawnPoint = gameLogicModel.selectSpawnPoint();
// Set the spawn to the transform's position.
this.transform.pos.fromArray(spawnPoint);
}
}
@RE.registerComponent
export default class PlayerController extends CroquetPawn {
// When using TypeScript you need to define the model type.
model: PlayerControllerModel;
// Get the kinematic controller in this.object3d.
@RapierKinematicCharacterController.require()
controller: RapierKinematicCharacterController;
// Get the RogueAnimator in this.object3d.
@RogueAnimator.require()
animator: RogueAnimator;
// We'll use these fields to set our received transform values.
networkPos = new THREE.Vector3();
networkRot = new THREE.Quaternion();
networkDirLength = 0;
// Define a property to retrieve our local input direction length.
get localDirLength() {
return this.controller.movementDirection.length();
}
/*
Link to the transform property in the Actor, and we set a
maximum update rate of 55.
*/
@PlayerControllerModel.prop(55)
get transform() {
// We return the corresponding our local values.
return {
pos: this.object3d.position,
rot: this.object3d.quaternion,
dirLength: this.localDirLength,
}
}
set transform(v: {pos: THREE.Vector3, rot: THREE.Quaternion, dirLength: number}) {
// We set the received transform values.
this.networkPos.copy(v.pos);
this.networkRot.copy(v.rot);
this.networkDirLength = v.dirLength;
// If this is our local player
if (this.isMe) {
// We apply the received position directly to the
// kinnematic controller
this.controller.body.setNextKinematicTranslation(v.pos);
}
}
private _isGrounded = false;
@PlayerControllerModel.prop(true)
get isGrounded() {
/*
If this is NOT our local player we want to use the received value, which
we've set in a private field. Otherwise, provide the local value
*/
return !this.isMe ? this._isGrounded : this.controller.isGrounded;
}
set isGrounded(value: boolean) {
// Set the received value to the private field.
this._isGrounded = value;
}
// When the model initializes
init() {
// We want to set the latest know position
this.transform = this.model.transform;
// and the latest know grounded status.
this.isGrounded = this.model.isGrounded;
}
update() {
// If the model has initialized
if (!this.initialized) return;
// If it's our local player
if (this.isMe) {
// If the grounded status has changed, we send the update.
this.isGrounded !== this.model.isGrounded && this.updateProp("isGrounded");
// Check local position and rotation for changes.
const posChanged = !this.transform.pos.equals(this.model.transform.pos);
const rotChanged = !this.transform.rot.equals(this.model.transform.rot);
// If the position or rotation have changed send the updates
if (posChanged || rotChanged) this.updateProp("transform");
} else {
/*
If this is not our local player We apply our received position
and rotation with interpolation. Without interpolation, it looks like
they're teleporting.
*/
this.object3d.quaternion.slerp(this.networkRot, 30 * RE.Runtime.deltaTime);
// We apply the position with interpolation to make it smooth.
this.dampV3(this.object3d.position, this.networkPos, 20);
}
// This remains practically the same as in the previous tutorial...
if (this.isGrounded) {
// ... only here we check if it's our local player to choose the dirLength.
const dirLength = this.isMe ? this.localDirLength : this.networkDirLength;
if (dirLength > 0) {
this.animator.setBaseAction("idle");
this.animator.mix("run", 0.1, dirLength);
}
else {
this.animator.mix("idle");
}
} else {
this.animator.mix("falling");
}
}
}
To test this, simply navigate to the address in the top right corner of the editor on any device within your network.
IMPORTANT: To play within the editor, make sure that the editor is the last client to enter the session, otherwise, it WILL break so keep that in mind.
As it is, this is perfect for casual or co-op games, but now I’ll show you how to run an authoritative check to prevent players from moving more than they should.
Authoritative Checks
If you check out our the RapierKinematicCharacterController
in our player in the inspector, its speed field is set to 5. This is how much it moves every frame before computing delta time.
As a rule of thumb, the more that we compute in the Model, the harder it gets for someone to cheat.
So let’s go back to the player Actor
class and there we’ll add the following:
...
@RogueCroquet.Model
export class PlayerControllerModel extends Actor {
// Define the update rate we game the transform
moveRate = 55;
// Define the minimum ping we want to account for
minPing = 20;
/*
Define the maximum allowed speed as the moveRate
plus the ping, to account for the network roundtrip
and divide by 1000 to get the fraction of a second.
*/
speed = 5 * ((this.moveRate + this.minPing)/1000);
...
/*
This listener gets called when our model receives a
value and intends to update the given key.
*/
onBeforeUpdateProp(key: string, value: any) {
// If we're updating the transform
if (key === "transform") {
// Clone the position ignoring the Y
const pos = this.transform.pos.clone().setY(0);
// Clone new position ignoring the Y
const newPos = value.pos.clone().setY(0);
// Subtract them to get the direction
const dir = pos.sub(newPos);
// Define how much we've moved
const moveAmt = dir.length();
// If we've moved more than the speed.
if (moveAmt > this.speed) {
// We correct the received value
value.pos.addScaledVector(dir.normalize(), this.speed);
// And we return to send the correction to the player.
return value;
}
}
}
}
...
Now if we set the speed in the Actor to a lower value, like 4, we can see it in action.
Of course each game is different and you may need different levels of safety checks.
How would you go about enforcing the jump and falling?
But don’t get too hung up on this. Focus on making an incredibly fun game, first and foremost. because the worst thing that could happen, and sadly the most common, is that nobody cares.
The End
I hope you enjoyed this tutorial, join our Discord and let me know what kind of game you’d like to make with this.
For an example of a complete game, check out Robot Deatchmatch on github.
Alright, see you in the next one! 🧉