Skip to main content

Building a Multiplayer Platformer

· 10 min read
Kevin Glass

In the second tech-demo, we look at making a platform game multiplayer using the Rune SDK. For anyone following along the base code is the same as the last technical demo, but we'll cover most of it in this article.

You can give it a go on the tech demos page.

First a re-cap of the architecture of a Rune game. We separate the rendering and logic like so:

It’s good practice to separate your data model and logic from the rendering, i.e. the MVC pattern. With multiplayer this isn’t just best practice, it’s absolutely required to let us run copies of the game logic on both the server and client.

The logic should contain the data that is required to update the game and how winning/losing can be evaluated - i.e. the game state. We want to try and keep it fast since in the predict-rollback network model (that Rune uses) we will be running multiple copies of the logic. The logic is implemented to maintain determinism and to allow it to be executed both on the browser and server.

The renderer, or client, is the code the renders to the game for the player and accept their input. The client can be implemented using any library or framework that can run in the browser.

Let's get to the code. If you need directions on creating a game project they're in the docs. In this demo we’re going to have a map and some players. So first let's declare some types to describe those:

// the extra data for the player
export type Player = {
x: number
y: number
sprite: string
playerId?: PlayerId
// the state of the controls for this player - this
// is the bit thats actually sent regularly across
// the network
controls: Controls
animation: Animation
vx: number
vy: number
// true if the player is facing left instead of right
// as the sprites are designed
flipped: boolean
}

// the controls that we're applying to the game state
// based on which inputs the player is currently pressing
export type Controls = {
left: boolean
right: boolean
jump: boolean
}

For Rune to synchronize the clients we'll need to define the shared data, in Rune that’s as easy as this:

// this is the core of what we're trying to keep
// in sync across the network. It'll be held on clients
// and server and the Rune platform will keep it
// in sync by applying deterministic actions
export interface GameState {
players: Player[]
}

Next we can initialize the logic for the game which all clients will start from before applying changes they receive from clients:

Rune.initLogic({
setup: (allPlayerIds) => {
const initialState: GameState = {
// for each of the players Rune says are in the game
// create a new player entity. We'll initialize their
// location to place them in the world
players: allPlayerIds.map((p, index) => {
return {
x: 20 + (index + 1) * 32,
y: 260,
playerId: p,
type: "PLAYER",
sprite: PLAYER_TYPES[index % PLAYER_TYPES.length],
animation: Animation.IDLE,
controls: {
left: false,
right: false,
jump: false,
},
flipped: false,
vx: 0,
vy: 0,
}
}),
}

return initialState
},

In the game logic we need to declare what the clients can do and how the game should update each frame. In Rune, the game update is defined as part of setting up the Rune SDK like so:

update: ({ game }) => {
// go through all the players and update them
for (const player of game.players) {
player.animation = Animation.IDLE

if (player.controls.left) {
player.vx = Math.max(-MOVE_SPEED, player.vx - MOVE_ACCEL)
player.flipped = true
} else if (player.controls.right) {
player.vx = Math.min(MOVE_SPEED, player.vx + MOVE_ACCEL)
player.flipped = false
} else {
if (player.vx < 0) {
player.vx = Math.max(0, player.vx + MOVE_ACCEL)
} else if (player.vx > 0) {
player.vx = Math.min(0, player.vx - MOVE_ACCEL)
}
}

player.vy += GRAVITY
...

The game logic is configured to run at 30 updates a second and on each update we’re going to move the players based on what their controls are - i.e., are they pushing left/right.jump. We're going to have two sets of collision, one against a tile map for the level and another between players. This lets players use each other as platforms!

The collision code is brute force, look for tiles that we might be colliding with and then check rectangle/rectangle collision for the players. You can see that in isValidPosition.

So how does this synchronize the clients?

The Rune platform runs this logic on the server and each of the clients. When a change is made to the game state is first applied locally - so latency in controls is very low - and then sent to the server and subsequently to all other clients. This is all timed so that the local client isn’t applying the changes too early and gives the server time to schedule the change at the right time.

Everyone playing and the server have a copy of the game logic which they’re keeping up to date based on the changes they receive. This relies on the game logic being fully deterministic but from a developer point of view means you don’t really have to think about how the sync is happening. As long as you keep your updating code in the game logic, the clients will stay in sync.

The client will run a copy of this logic and update() loop so will immediately update is run. The server will also run a copy of this logic and update() loop but slightly behind the client to allow for any action conflict resolution, e.g. two players try to take the same item. When the server has resolved the conflict the client will rollback its changes if needed and apply the new actions from the authoritative server putting the client back in the correct state.

The final bit of the game logic is how the "changes" to the game state can be indicated by players, what Rune calls actions.

// actions are the way clients can modify game state. Rune manages
// how and when these actions are applied to maintain a consistent
// game state between all clients.
actions: {
// Action applied from the client to setup the controls the
// player is currently pressing. We simple record the controls
// and let the update() loop actually apply the changes
controls: (controls, { game, playerId }) => {
const player = game.players.find((p) => p.playerId === playerId)

if (player) {
player.controls = { ...controls }
}
},
},

The actions block defines the set of calls the renderer can make to translate player input into changes to the game state. In this case we simply take whatever the client has said the controls from the player are and store them in the player entity. As mentioned above, because the client is running its own copy of logic these changes are quickly applied.

You can see in this case we’re sending the controls rather than explicit positions, which at first might seem a little strange. This makes sense when you consider one more factor, conflict resolution.

If two players both make actions on their local copy of logic that conflict in some game specific way then the clients have to rollback their game state, apply the actions in the correct order and recalculate game state. Let’s say they both try to take an item at the same time, because their logic is running locally they’ll both think they took it. Once the actions reach either end it becomes clear that one player took the item first and the Rune SDK calculates the state to match the correct situation.

Now, if we sent explicit positions this conflict resolution would result in significant jumps - where a player’s actions were completely disregarded because they were in complete conflict. If we send the controls then the resolution is much smoother, the player still pressed the controls and had them applied, just the resulting game state is a little different. A lot of the time this can be hidden altogether in the renderer.

Now we have the game logic, the players can update controls and they’ll move thanks to our update loop. The final part is to get something on the screen and let our players play! The tech demo uses a very simple renderer without a library or framework. It just draws images (and parts of images) to an HTML canvas and uses DOM events for input. Check out graphics.ts and input.ts if you want to see the details.

First we need to register a callback with Rune so that it can tell us about changes to the game state:

// Start the Rune SDK on the client rendering side. 
// This tells the Rune app that we're ready for players
// to see the game. It's also the hook
// that lets the Rune SDK update us on
// changes to game state
Rune.initClient({
// notification from Rune that there is a new game state
onChange: ({ game, yourPlayerId }) => {
// record the ID of our local player so we can
// center the camera on that player.
myPlayerId = yourPlayerId

// record the current game state for rendering in
// our core loop
gameState = game
},
})

The rendering itself is purely taking the game state that it’s been given and drawing entities to the canvas:

// if the Rune SDK has given us a game state then
// render all the entities in the game
if (gameState) {
// render the game state
for (const player of gameState.players) {
const frames =
player.animation === Animation.JUMP
? playerArt[player.sprite].jump
: player.animation === Animation.WALK
? playerArt[player.sprite].run
: playerArt[player.sprite].idle

drawTile(
player.x - 16,
player.y - 16,
frames,
Math.floor(Date.now() / 50) % frames.tilesAcross,
player.flipped
)
}
...

The only other thing the renderer needs to do is convert player inputs into that action we defined in game logic:

// we're only allowed to update the controls 10 times a second, so
// only send if its been 1/10 of a second since we sent the last one
// and the controls have changed
if (
Date.now() - lastActionTime > 100 &&
(gameInputs.left !== lastSentControls.left ||
gameInputs.right !== lastSentControls.right ||
gameInputs.jump !== lastSentControls.jump)
) {
lastSentControls = { ...gameInputs }
lastActionTime = Date.now()
Rune.actions.controls(lastSentControls)
}

There’s a couple of conditions put on sending actions. We don’t want to send unchanged controls into the game logic, it won’t change anything. The Rune SDK also ensures we send a maximum of 10 actions per second from any client to prevent swamping the network.

That’s it, we have a game logic that will keep the client’s game state in sync and a renderer that will let our players play.

If you have any questions or comments on the tech demo or Rune in general, be sure to join our Discord.

Assets from Pixel Frog.

Subscribe to our newsletter for more game dev blog posts
We'll share your email with Substack
Substack's embed form isn't very pretty, so we made our own. But we need to let you know we'll subscribe you on your behalf. Thanks in advance!