Making a Basic Multiplayer Game
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.
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:
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:
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:
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:
This covers the main two kinds of communication:
- A client sending a message to the server about client-owned state (eg. player movement).
- 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
require
d 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')
endfunction 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')
endfunction 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:
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.
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:
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
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).
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.homesfunction server.load()
share.players = {}
endfunction 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.
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.
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] = {}
endif 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:
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!
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!