At Rune, we want you to be able to use game development tools that you love with our platform. With this in mind, we’ve adapted the tutorial game from the popular framework Phaser to be multiplayer on Rune.
Approach
Phaser is wonderfully powerful as a game library, and one of its key concepts is putting everything into the scene graph. This is fantastic for a single player game since the physics/collision can happen on the client side where the scene graph lives. However, when you approach multiplayer (with any framework) the game needs to be able to run its physics both on clients and validating server. With this in mind in this tech demo we’ll move the physics into the logic of the game and use a separate library to manage it.
Outside of this the Phaser framework can be used as normal.
Client Side
To anyone who's used Phaser before this will look pretty familiar. For those who haven't this is setting up a Phaser runtime and renderer and loading the assets that will be used to render the game:
export default class TutorialGame extends Phaser.Scene {
preload() {
// preload our assets with phaser
this.load.image("sky", "assets/sky.png")
this.load.image("ground", "assets/platform.png")
this.load.image("star", "assets/star.png")
this.load.image("bomb", "assets/bomb.png")
this.load.spritesheet("dude", "assets/dude.png", {
frameWidth: 32,
frameHeight: 48,
})
}
}
const config = {
type: Phaser.AUTO,
width: window.innerWidth,
height: window.innerHeight,
scene: TutorialGame,
scale: {
mode: Phaser.Scale.ScaleModes.FIT,
},
}
new Phaser.Game(config)
Here's the first difference to a normal Phaser application. Since we're going to be using Phaser for the rendering only (the physics will be happening in the game logic) we're going to add a mapping table that will convert physics object on the server to the client side scene graph elements:
physicsToPhaser: Record<number, Phaser.GameObjects.Sprite> = {}
lastSentControls: Controls = {
left: false,
right: false,
up: false,
}
You can also see lastSentControls
above. Since Phaser is providing the input from the player and we need to send that to the logic, we'll record the controls we sent last time. We want to avoid
sending the controls more often than needed to avoid wasted networking communications by making sure we only send the inputs when they change.
Next up we have the Rune integration. We initialize the Rune SDK with a call back function that tells us when game state is changing. In this case this means when our physics objects have been created, updated or deleted. When we get this notification, we're going to scan through the state and update the Phaser rendering to match. First, we locate each physics body in the phaser world:
// for all the bodies in the game, make sure the visual representation
// exists and is synchornized with the physics running in the game logic
for (const body of physics.allBodies(game.world)) {
const rect = body.shapes[0] as physics.Rectangle
const x = Math.ceil(
(body.center.x / PHYSICS_WIDTH) * window.innerWidth
)
const y = Math.ceil(
(body.center.y / PHYSICS_HEIGHT) * window.innerHeight
)
const width = Math.ceil(
(rect.width / PHYSICS_WIDTH) * window.innerWidth
)
const height = Math.ceil(
(rect.height / PHYSICS_HEIGHT) * window.innerHeight
)
let sprite = this.physicsToPhaser[body.id]
If we don't have a sprite for the body yet, we create the right one based on the type of body we've been given:
// if a sprite isn't already created, create one based on the type
// of body
if (!sprite) {
if (body.data && body.data.star) {
const size = Math.ceil(
(rect.bounds / PHYSICS_WIDTH) * window.innerWidth
)
sprite = this.physicsToPhaser[body.id] = this.add
.sprite(x, y, "star")
.setDisplaySize(size * 2, size * 2)
} else if (body.data && body.data.player) {
// create the player and associated animations
sprite = this.physicsToPhaser[body.id] = this.add
.sprite(x, y, "dude")
.setDisplaySize(width, height)
this.anims.create({
key: "left",
frames: this.anims.generateFrameNumbers("dude", {
start: 0,
end: 3,
}),
frameRate: 10,
repeat: -1,
})
this.anims.create({
key: "turn",
frames: [{ key: "dude", frame: 4 }],
frameRate: 20,
})
this.anims.create({
key: "right",
frames: this.anims.generateFrameNumbers("dude", {
start: 5,
end: 8,
}),
frameRate: 10,
repeat: -1,
})
} else {
sprite = this.physicsToPhaser[body.id] = this.add
.sprite(x, y, "ground")
.setDisplaySize(width, height)
}
}
Finally, once the sprite is definitely in the world we update it to match the body position based on what the logic has given us:
// update the sprites position and if its a player the animation
sprite.x = x
sprite.y = y
if (body.data?.player) {
const controls = game.controls[body.data?.playerId ?? ""]
if (controls) {
if (controls.left) {
sprite.anims.play("left", true)
} else if (controls.right) {
sprite.anims.play("right", true)
} else {
sprite.anims.play("turn", true)
}
}
}
The final step is pass the input from the phaser side into the logic so we can update the physics model. First we record the input, we have on screen controls which we can listen to:
const left = document.getElementById("left") as HTMLImageElement
const right = document.getElementById("right") as HTMLImageElement
const jump = document.getElementById("jump") as HTMLImageElement
left.addEventListener("touchstart", () => {
gameInputs.left = true
})
right.addEventListener("touchstart", () => {
gameInputs.right = true
})
left.addEventListener("touchend", () => {
gameInputs.left = false
})
right.addEventListener("touchend", () => {
gameInputs.right = false
})
jump.addEventListener("touchstart", () => {
gameInputs.up = true
})
jump.addEventListener("touchend", () => {
gameInputs.up = false
})
Then in the Phaser update if the inputs have changed, we pass them to our logic through a Rune action:
update() {
// As with the physics we don't want the controls to be processed directly in the
// the client code. Instead we want to schedule an action immediately that will update
// the game logic (and in turn the physics engine) with the new state of the player's
// controls.
const stateLeft = gameInputs.left
const stateRight = gameInputs.right
const stateUp = gameInputs.up
if (
this.lastSentControls.left !== stateLeft ||
this.lastSentControls.right !== stateRight ||
this.lastSentControls.up !== stateUp
) {
this.lastSentControls = {
left: stateLeft,
right: stateRight,
up: stateUp,
}
Rune.actions.controls(this.lastSentControls)
}
}
And that's our client done!
Logic Side
On the logic side, we're going to maintain a propel-js physics models that represents our world in the game state. We'll update this each loop and that state will be passed back to the Phaser client to render.
First, we'll setup some game state containing the physical world and state of each players controls, essentially what we need to update the world.
export const PHYSICS_WIDTH = 480
export const PHYSICS_HEIGHT = 800
export interface GameState {
world: physics.World
controls: Record<PlayerId, Controls>
}
export type Controls = {
left: boolean
right: boolean
up: boolean
}
type GameActions = {
controls: (controls: Controls) => void
}
declare global {
const Rune: RuneClient<GameState, GameActions>
}
Next we'll initialize the Rune SDK and configure the world to have our players, platforms and stars:
Rune.initLogic({
minPlayers: 1,
maxPlayers: 4,
setup: (allPlayerIds) => {
const initialState: GameState = {
world: physics.createWorld({ x: 0, y: 800 }),
controls: {},
}
// phasers setup world but in propel-js physics
physics.addBody(
initialState.world,
physics.createRectangle(
initialState.world,
{ x: 0 * PHYSICS_WIDTH, y: 0.2 * PHYSICS_HEIGHT },
0.5 * PHYSICS_WIDTH,
0.05 * PHYSICS_HEIGHT,
0,
1,
1
)
)
physics.addBody(
initialState.world,
physics.createRectangle(
initialState.world,
{ x: 0.75 * PHYSICS_WIDTH, y: 0.4 * PHYSICS_HEIGHT },
0.5 * PHYSICS_WIDTH,
0.05 * PHYSICS_HEIGHT,
0,
1,
1
)
)
physics.addBody(
initialState.world,
physics.createRectangle(
initialState.world,
{ x: 0.5 * PHYSICS_WIDTH, y: 0.6 * PHYSICS_HEIGHT },
0.5 * PHYSICS_WIDTH,
0.05 * PHYSICS_HEIGHT,
0,
1,
1
)
)
physics.addBody(
initialState.world,
physics.createRectangle(
initialState.world,
{ x: 0.5 * PHYSICS_WIDTH, y: 0.9 * PHYSICS_HEIGHT },
1 * PHYSICS_WIDTH,
0.3 * PHYSICS_HEIGHT,
0,
1,
1
)
)
// create a player body for each player in the game
for (const playerId of allPlayerIds) {
const rect = physics.createRectangleShape(
initialState.world,
{ x: 0.5 * PHYSICS_WIDTH, y: 0.5 * PHYSICS_HEIGHT },
0.1 * PHYSICS_WIDTH,
0.1 * PHYSICS_HEIGHT
)
const footSensor = physics.createRectangleShape(
initialState.world,
{ x: 0.5 * PHYSICS_WIDTH, y: 0.55 * PHYSICS_HEIGHT },
0.05 * PHYSICS_WIDTH,
0.005 * PHYSICS_HEIGHT,
0,
true
)
const player = physics.createRigidBody(
initialState.world,
{ x: 0.5 * PHYSICS_WIDTH, y: 0.5 * PHYSICS_HEIGHT },
1,
0,
0,
[rect, footSensor]
) as physics.DynamicRigidBody
player.fixedRotation = true
player.data = { player: true, playerId }
physics.addBody(initialState.world, player)
initialState.controls[playerId] = {
left: false,
right: false,
up: false,
}
}
// create a few stars to play with
for (let i = 0; i < 5; i++) {
const rect = physics.createCircleShape(
initialState.world,
{ x: i * 0.2 * PHYSICS_WIDTH, y: 0.15 * PHYSICS_HEIGHT },
0.04 * PHYSICS_WIDTH
)
const star = physics.createRigidBody(
initialState.world,
{ x: i * 0.2 * PHYSICS_WIDTH, y: 0.15 * PHYSICS_HEIGHT },
10,
1,
1,
[rect],
{ star: true }
) as physics.DynamicRigidBody
physics.addBody(initialState.world, star)
}
return initialState
}
As seen above, for each body, we set user data indicating the type of body it should be rendered as. This game state will immediately be sent back to our client, which will create sprites in the Phaser scene graph and position them accordingly..
Next, we need to process the input action we provided from the client. This is as simple updating our game state to know which controls a player is pressing:
actions: {
controls: (controls, { game, playerId }) => {
game.controls[playerId] = controls
},
},
The final step of our update loop is update the physics model based on the controls provided from the player clients:
update: ({ game, allPlayerIds }) => {
// each loop process the player inputs and adjust velocities of bodies accordingly
for (const playerId of allPlayerIds) {
const body = game.world.dynamicBodies.find(
(b) => b.data?.playerId === playerId
)
if (body) {
if (game.controls[playerId].left && !game.controls[playerId].right) {
body.velocity.x = -100
} else if (
game.controls[playerId].right &&
!game.controls[playerId].left
) {
body.velocity.x = 100
} else {
body.velocity.x = 0
}
// check if we're on the ground
if (body.shapes[1].sensorColliding) {
if (game.controls[playerId].up) {
body.velocity.y = -600
}
}
} else {
console.log("Body not found")
}
}
// propel-js likes a 60fps game loop since it keeps the iterations high so run it
// twice since the game logic is configured to run at 30fps
physics.worldStep(60, game.world)
physics.worldStep(60, game.world)
}
Above we can see that we apply velocities directly to the bodies in propel-js based on the controls the player have provided. We're also using a foot sensor to determine if the player is on the ground and hence if they can jump. One other note here is a nuance of propel-js, our game logic is running at 30fps but the physics model works best at 60fps so we simply run two updates.
There you have it, a multiplayer version of the Phaser sample with the Rune SDK. It takes a little bit of rethinking of the model but we can make use of a lot of power of Phaser!
Want to know more? Why not drop by the Discord and have a chat?