Making a Basic Multiplayer Game

Nikhilesh Sigatapu
Castle Games Blog
Published in
19 min readOct 27, 2019

This is part of a tutorial series for Castle Halloween Party. We also did a live stream covering this tutorial, which you can view here. Check out the full tutorial schedule to see other topics we’ll be covering.

The final multiplayer game with user avatar rendering

In this tutorial we’ll get started making a multiplayer game on Castle. Making multiplayer games is usually challenging because you have to figure out a way to architect the connections between the players’ computers, potentially set up a server in the cloud they can connect to, manage a system for invites and also maintain player identities or profiles. Castle manages all of these things for you so you can dive pretty quickly into the main gameplay code!

In this tutorial we’ll use share.lua, a library for Castle that contains various multiplayer utilities. First, let’s understand a few things about how players’ computers will talk to each other.

Full source code for the finished version of this tutorial is available on GitHub.

Concepts: Client-server architecture

In a single-player game scenario, there is just one computer that maintains the state of the game, obtains input from the user and draws to the computer screen:

A single-player game: just a computer and a player

In a multiplayer scenario, there are many players involved and each is at a computer. They need to coordinate with each other to make the players feel like they are participating in the same experience. We could have the players’ computers directly talk to each other (a ‘peer-to-peer’ or some other system), but Castle’s multiplayer games use a client-server architecture, where all the players’ computers connect to a single computer known as the server, which maintains the shared game state and coordinates the clients:

A multiplayer game with a client-server architecture

The server is different from the players’ computers — it is usually not meant to be used directly by any user and its only purpose is to serve clients. It should have a fast internet connection, be available to serve users at any time and not go down in the middle of a game. Castle automatically runs your game’s server code in a Castle-provided server with these properties and you don’t have to worry about setting one up.

If a player presses their arrow keys or uses their mouse to move in the game, their game’s client code sends a message to the server. The game’s server code then keeps track of this change, and also notifies the other clients:

Player 1 moves, their client sends a message to the server, it keeps track and notifies other clients

The server may also initiate some logic without the players having to do anything. One example of this is spawning enemies in a co-op game, where the enemy logic is not owned by any client. The game’s server code updates its state to track the new enemy, and also notifies all clients:

Game logic on the server spawns an enemy every 30 seconds, it notifies all clients

This covers the main two kinds of communication:

  1. A client sending a message to the server about client-owned state (eg. player movement).
  2. The server notifying clients about shared state changing (eg. spawning an enemy, or synchronizing player movement to other players).

When we are developing our game, it doesn’t make sense to always start up a cloud server instance for every change we make to our server code. Instead, it would be nice to just run the server code on our own computer and connect to it like a client to make sure things are working first. This is called a local server. Castle also supports this way of doing things and we’ll first get started with a local server for development.

With this understanding, let’s start coding up a client-server system for our game!

Step 1: Initial code

Let’s make a new directory for the project. In it, we’ll create four empty files:

  • common.lua: Common code for both the server and the client. This will be required by both server and client code.
  • server.lua: Server code will go here.
  • client.lua: Client code will go here.
  • main_local.lua: The entry-point for a local server game. Launches a local server and a client to connect to it.

First, let’s put some code in common.lua. We’ll require the cs.lua file from share.lua, which is the client-server setup library. As you may have seen before, Castle lets us directly require URIs from the web. So we can do this:

clientServer = require 'https://raw.githubusercontent.com/castle-games/share.lua/6d70831ea98c57219f2aa285b4ad7bf7156f7c03/cs.lua'

We put the library in the clientServer global variable. We’ll then use it in the server.lua and client.lua files.

Now let’s add the following in server.lua to start the server:

require 'common'local server = clientServer.serverserver.enabled = true
server.start('22122')

The 22122 value specifies a port for the server to use. Your computer may be running other servers for various other applications, and this distinguishes it from those. 22122 is usually not used, so we go with that one.

Then, in client.lua, we’ll connect to this server:

require 'common'local client = clientServer.clientclient.enabled = true
client.start('127.0.0.1:22122')

The 127.0.0.1 part provides an IP address to connect to, and in this case we say we want to connect locally (127.0.0.1 is the IP address of ‘localhost’). The 22122 part provides the port of the server on that computer — in this case we use the port value we gave to the server earlier.

In main_local.lua, let’s run both of these to fire them up:

require 'server'
require 'client'

Now, we’re ready to try this out! In Castle, launch main_local.lua by going to Create -> Open another project… and then navigating to and selecting your main_local.lua file. The game should start!

On Windows, you may get a pop-up about enabling network permissions. You will need to enable communication on private and public networks to continue.

The game will render a black screen and also not print anything to the logs. Our code doesn’t really give us any feedback, so let’s get to that!

(Full code till this step is available here)

Step 2: Testing the local connection

First, let’s just print some simple messages to make sure the local connection works. In server.lua, add the following at the bottom:

function server.connect(clientId)
print('server: client ' .. clientId .. ' connected')
end
function server.disconnect(clientId)
print('server: client ' .. clientId .. ' disconnected')
end

The server.connect event is called on the server when a client connects to it. The clientId is a number that is unique to that client and identifies that client. It’ll be used by other share.lua functions to specify the client, and you can also use it in your own code as a key to separate player information.

The server.disconnect event is called when a client disconnects and is passed in the clientId of the client that disconnected.

In client.lua, add the following at the bottom:

function client.connect()
print('client: connected to server')
end
function client.disconnect()
print('client: disconnected from server')
end

These events are called on the client when it connects to the server and when it is disconnected, respectively. There is only one server, so it doesn’t need any identifier and nothing is passed to these events.

Now run the game and you should see the following printed in the logs:

Successful connection!

Great! This means the server and client were successfully initialized and connected to each other.

Now, you can open another instance of Castle and launch the game in the same way, to make sure that the second instance connects as a client to the first instance. You can log in as the same user in the second instance.

Two clients connecting to the local server

You’ll also see a “couldn’t start server — is port in use?” message. This is because main_local.lua always starts both a server and a client. In the second instance of the game, starting a server is unsuccessful because the port 22122 is already being used by the server from the first instance. But starting a client is successful because it the client connects to the first server we already started. So this message is expected, and can be ignored. It is not an error. In a local session, all instances other than the first one will show this message.

But most importantly, the first instance of the game should show a “server: client 2 connected” message. This means the first instance is now aware of the second instance. We have a connection across two instances of the game!

Now let’s send some messages around in response to user input.

(Full code till this step is available here, along with a diff from the previous step)

Step 3: Sending messages

Let’s do a very simple experiment: when the player presses a key in an instance of the game, we’ll notify all other instances and have them print a message about who pressed what key.

First, let’s have the client listen for a key press. Add this at the bottom of client.lua:

function client.keypressed(key)
if client.connected then
client.send('pressedKey', key)
end
end

We use client.keypressed, which is like love.keypressed. In general, we should use the client. and server. versions of LÖVE callbacks when programming our game. This is because the client and server do some additional processing before and after some of the events (especially update), and it also allows separating code in server.update from client.update and so on. The client object supports all LÖVE callbacks, while the server only supports load, update and quit (and also lowmemory and threaderror if you need those). This is because the server should not do any input and output, especially on a Castle remote server.

When a key is pressed, we want to let the server know. The client.send function sends a message to the server. All of the arguments passed to it are sent in the message. Since we may want to send many kinds of messages, we’ll first include an argument specifying the message type ( 'pressedKey' in this case). Then we include the key itself. We also first check that the client has connected to the server before sending the message, else it will throw an error.

Now let’s handle this message on the server and let other clients know. In server.lua, add:

function server.receive(clientId, message, ...)
if message == 'pressedKey' then
local key = ...
server.send('all', 'otherClientPressedKey', clientId, key)
end
end

The server.receive event is called when the server receives a message from a client. clientId is the id of the client that sent the message. The rest of the arguments are the arguments the client included in client.send. In this case, we read the message type, then keep the other arguments in ... to pull them out based on the message type.

If the message is a 'pressedKey' message as we just wrote, we pull out the key. Now we want to notify the clients, so we use server.send. The first argument to server.send is the id of the client to send the message to. It can also be 'all' to send to all clients, which we use here. The rest of the arguments are sent in the message. We include a message type 'otherClientPressedKey', include the client id of the client that pressed the key, then include the key itself.

Finally, we can handle the 'otherClientPressedKey' message on the client:

function client.receive(message, ...)
if message == 'otherClientPressedKey' then
local otherClientId, key = ...
print('client: other client ' .. otherClientId .. " pressed key '" .. key .. "'")
end
end

client.receive is called when the client receives a message from the server. Its arguments are the second argument onward passed to server.send. We check for the 'otherClientPressedKey' message and accordingly pull out the other client’s id and the key itself. Then we finally print the message saying which client pressed which key, as as we’ve been wanting to do!

Now you may reload both instances of the game. Try pressing keys in either instance, and you should see the other one print a message about the keys pressed:

Clients telling each other about key presses

If your key presses aren’t being registered, make sure to click in the center of the black screen the game is rendering, to focus the game itself rather than focusing Castle’s UI.

You may notice that clients are printing messages about their own key presses. This is because the server.send('all', ...) call is sending the 'otherClientPressedKey' message to all clients, so even the client that the key was originally pressed on gets it. We can just make sure the otherClientId is different from the client’s own id when handling the message to get around this:

function client.receive(message, ...)
if message == 'otherClientPressedKey' then
local otherClientId, key = ...
if otherClientId ~= client.id then
print('client: other client ' .. otherClientId .. " pressed key '" .. key .. "'")
end
end
end
Only handling messages about key presses from other clients

This could actually be all we need to create a multiplayer game. You could imagine using client.send to send player updates to the server, and the server using server.send to synchronize state to all clients. In practice, you wouldn’t want to send each update of state as individual messages (eg. in server to client messages, sending one message per enemy for position updates) — it’s more efficient to send a single message with the entire state update across the game at the end of each frame. Also, it’s wasteful to include state that didn’t change since the previous frame.

share.lua includes utilities to make efficient state sharing easier than having to manage the messages yourself. Messages are still useful for one-off events or if you want to write your own efficient state sharing logic that is particular to your game’s design.

(Full code till this step is available here, along with a diff from the previous step)

Concepts: Shared state with ‘share’ and ‘homes’

In share.lua, the server and clients each include a store of data that is automatically synchronized across their connections:

  • The share: The server owns a store called the share, in which any data that you store is automatically visible to each client. The synchronization is in the server -> client direction — so any changes the server makes are sent to clients, but any changes the clients make in their local copy aren’t sent back (and may be overwritten the next time an update arrives).
  • The homes: Each client owns a store called a home. There is one home per client. Any data that a client stores in its home is automatically visible to the server. The server sees each client’s home separately, and they are separate from the share. The synchronization is in the client -> server direction — so any changes the client makes in its home are sent to the server, but any changes the server makes in its local copy of a client’s home aren’t sent back (and may be overwritten the next time an update arrives).
share.lua’s model of ‘share’ and ‘homes’

Generally, the shared state of the entire game is put in the share by the server. Each client puts state that it ‘owns’ into its home (such as the player’s current position, the player’s avatar file, some user settings, etc.). But this data needs to be actually transferred from homes to the share by the game’s server logic for it to be seen by other clients. The server is the authority on shared state.

On each server update, the server incorporates all of the data from the homes (such as copying player positions from homes into the share) and performs game state update logic to update the data in the share. The clients then see these updates and render them.

This may make more sense with a practical example, so let’s try it out!

Step 4: Walking around

When the server starts up, we’ll initialize an empty map of player data in the share. Then, when a client connects, we’ll initialize player data for it with a random position to start:

local share = server.share
local homes = server.homes
function server.load()
share.players = {}
end
function server.connect(clientId)
print('server: client ' .. clientId .. ' connected')
share.players[clientId] = {
x = math.random(40, 800 - 40),
y = math.random(40, 450 - 40),
}
end

Note that we’re updating the existing server.connect function here.

server.share is the server’s version of the share and server.homes is a table of homes, one per client. The keys of this table are the client ids, so that the home of a client with id clientId is at server.homes[clientId].

In the server.load event, we just initialize an empty players map in the share.

And on server.connect, we initialize a player object for that client in the players map, with a random position. The screen is 800x450 by default in Castle, so we use those dimensions with 40 units of padding.

On the client, let’s draw the players. First, add this after the client.start line to get the share and home:

local share = client.share
local home = client.home

Then, we’ll add client.draw:

function client.draw()
if client.connected then
for clientId, player in pairs(share.players) do
love.graphics.rectangle('fill', player.x - 20, player.y - 20, 40, 40)
end
else
love.graphics.print('connecting...', 20, 20)
end
end

Here we show a ‘connecting…’ message if the client isn’t connected yet. If it’s connected, the share can now be used. We simply draw a rectangle for each of the player objects in the share, centered around the position.

Again, reload each instance of the game to see the changes (or fire up two instances of the game if you don’t already have them running). When the first instance is started, you should see a rectangle show up for the first player. The first instance will update to add another rectangle when you connect the second one. This means player 2 just joined! The second instance should show two rectangles as soon as it connects.

Two player rectangles starting out at random positions

Now let’s have the players walk around. We’ll use the homes to have clients send their position updates to the server. First, let’s initialize the position to the position given by the server when a client connects:

function client.connect()
print('client: connected to server')
local player = share.players[client.id]
home.x, home.y = player.x, player.y
end

In client.connect, share is always already initialized, and also server.connect has already been run with the clientId of this client and all of the updates to share from the server’s connection event are available. So, it is safe to access share.players[client.id] here.

Next, we move the player in the client.update event, similar to the logic of a single-player game:

local PLAYER_SPEED = 200function client.update(dt)
if client.connected then
if love.keyboard.isDown('left') then
home.x = home.x - PLAYER_SPEED * dt
end
if love.keyboard.isDown('right') then
home.x = home.x + PLAYER_SPEED * dt
end
if love.keyboard.isDown('up') then
home.y = home.y - PLAYER_SPEED * dt
end
if love.keyboard.isDown('down') then
home.y = home.y + PLAYER_SPEED * dt
end
end
end

Note that we are reading from and writing to the home version of the position here, and not the share version. The home version is the one owned by the client, so that’s what we must use here. Furthermore, the share version also lags behind the value of the home version on the client, due to the network latency of messages going to the server and back. We will soon update client.draw to use the home version of the position so that the player sees immediate and smooth motion of their own player.

But first, we need to read the positions out of home and copy them to share in server.update, so that the clients can see each others’ updates positions:

function server.update(dt)
for clientId, player in pairs(share.players) do
local home = homes[clientId]
if home.x and home.y then
player.x, player.y = home.x, home.y
end
end
end

We iterate through the player data and merge in the clients’ updates to x and y. The only complication is that the very first update to home made by the client may not have arrived yet — once the initial player data is put in share on server.connect, there may be a few rounds of server.update being called while the connection message is sent back to the client, it processes it and updates the home, and the home update message comes back to the server. So we make sure that home.x or home.y aren’t nil.

At this point, if you reload the two instances of the game and try using your arrow keys in one of them, you should see motion in the other one!

As promised, let’s use the home position when drawing a client’s own player. Update the for loop in client.draw as follows:

for clientId, player in pairs(share.players) do
local x, y = player.x, player.y
if clientId == client.id then
x, y = home.x, home.y
end
love.graphics.rectangle('fill', x - 20, y - 20, 40, 40)
end

We compare clientId to the client’s own id to decide if this is the client’s own player. If it is, we use the position from home.

Now on reloading both instances and trying to move around, you’ll notice that moving your own player should feel smoother.

Multiplayer player motion!

We have one last thing to do in this step. If you exit the second instance of the game by clicking ‘End game’, you will notice the following errors in the first instance:

Let’s end the first instance too since it is now broken. The issue is that when a client with id clientId disconnects, homes[clientId] is now nil, but there is still leftover player data for it in share.players and so we attempt to read from its now non-existent home. The solution is to remove the player data in server.disconnect — which also makes it so when a player disconnects, you see them disappear on other clients. This means we should update server.disconnect as follows:

function server.disconnect(clientId)
print('server: client ' .. clientId .. ' disconnected')
share.players[clientId] = nil
end

Now, if you load two instances of the game and end the second one, the first one should properly show their player rectangle disappearing!

Next, we’ll make the game render the user’s avatars rather than just a white rectangle.

(Full code till this step is available here, along with a diff from the previous step)

Step 5: Rendering avatars

To render a user’s avatar, we first need to get the URL of their avatar from Castle. For this we can use the castle.user.getMe function. We’ll put the return value of this function (a table containing user data, including .photoUrl) into home. Let’s add the following to client.connect after the existing code in it:

home.me = castle.user.getMe()

Now let’s update server.update to also pass on the me values into share:

function server.update(dt)
for clientId, player in pairs(share.players) do
local home = homes[clientId]
if home.x and home.y then
player.x, player.y = home.x, home.y
end
if home.me and not player.me then
player.me = home.me
end
end
end

All clients should now be able to see each others me values through the share. We need to now update the client.draw function to render avatars. This can be a bit tricky because we need to load one image per client, and the image loading is asynchronous because we are loading an image over the network. First let’s keep a table to store loaded player images, right above client.draw:

local playerImages = {}

Then, in client.draw, we’ll replace the love.graphics.rectangle line with the following:

if not playerImages[clientId] then
playerImages[clientId] = {}
end
if playerImages[clientId].image then
-- Image is loaded, draw it
local image = playerImages[clientId].image
love.graphics.draw(image, x - 20, y - 20, 0,
40 / image:getWidth(), 40 / image:getHeight())
else
-- Image isn't loaded, draw a rectangle for now
love.graphics.rectangle('fill', x - 20, y - 20, 40, 40)
-- If a photo is available and we haven't fetched it yet,
-- start fetching it and then save the image
if player.me and player.me.photoUrl
and not playerImages[clientId].fetched then
playerImages[clientId].fetched = true
network.async(function()
local image = love.graphics.newImage(player.me.photoUrl)
playerImages[clientId].image = image
end)
end
end

First, we initialize the playerImages entry for this client to an empty table if it isn’t set yet. Then, if the image is loaded, we draw it scaled to fit the 40x40 rectangle we’ve been using. If the image isn’t loaded, we draw the rectangle for now, as before. We also fire off an asynchronous load for the image if we haven’t already — we keep track of whether we’ve fired it off in the .fetched field of the playerImages entry, then save the final image in .image once it’s loaded.

After this, you should be able to see some player avatars:

Multiplayer with avatars!

This is it for the gameplay for our multiplayer game for now. So far we’ve been using a local server for development. Let’s get our game set up to use a Castle server so that we can have other users actually join us!

(Full code till this step is available here, along with a diff from the previous step)

Step 6: Using a Castle remote server

When using a Castle server, we want to let Castle automatically configure the port of the server and pass the correct IP address and port to the client. share.lua provides .useCastleConfig methods on the server and client objects for this. We do want to keep our local server case around, so let’s modify the main_local.lua to enable a global flag that we’ll check in server.lua and client.lua. Add this to the top of main_local.lua:

USE_LOCAL_SERVER = true

Then let’s replace the server.enabled and server.start lines in server.lua with:

if USE_LOCAL_SERVER then
server.enabled = true
server.start('22122')
else
server.useCastleConfig()
end

Similarly, replace the client.enabled and client.start lines in client.lua:

if USE_LOCAL_SERVER then
client.enabled = true
client.start('127.0.0.1:22122')
else
client.useCastleConfig()
end

Now, we’ll need to add a .castle file to our project and publish our game. Add a project.castle file with the following contents:

main: client.lua
multiplayer:
enabled: true
serverMain: server.lua
title: Basic Multiplayer Tutorial
owner: <your_username>
description: Learning to make a multiplayer game on Castle

Make sure to replace <your_username> with your own username. For main: we use the client entrypoint of the game. The multiplayer: key has child keys to enable multiplayer and to specify the server entrypoint.

Then, add the game to your profile.

Now, when you play the published version of your game, you will see an invite link pop up below. You can try using the invite link in a second instance of Castle to join the game — except this time, it’s running against a Castle remote server instead of a local one. And you can send this invite link to any other user to have them join your game!

Running on a remote server with invite links on the bottom

That’ll be all for this tutorial, but we hope to do more soon on more advanced multiplayer topics! One way to attempt improving the current version of the game is to add velocity state to the player data in the share so that clients can locally predictively update the position of other players.

(Full code till this step is available here, along with a diff from the previous step)

Attributions

The icons in the illustrations above are made by Freepik from www.flaticon.com. Thanks!

This is part of a tutorial series for Castle Halloween Party. View the corresponding live stream on Castle’s Twitch channel. If you have any questions, please ask us on Twitter or Discord!

--

--