Tutorials > Tutorial n°3: Space Race

In the previous tutorials ("Tutoworld" and "Lone Scoundrel"), we learned the basic features of the Cubzh engine and we discussed a few common dev patterns. Today, we're going to continue our journey and talk about multi-player :)

We are going to make a simple 2-or more-player game called "Space Race"!

In this game, you have a spaceship locked on a road and you have to shoot bullets to destroy the bullets that pop in front of you; "special" red walls require "special" red bullets, and the others just requires normal bullets... but if you ever hit a wall, it's game over!

The ships get faster and faster as time passes, and after a while walls will start to pop more and more often...

The original solo game

Because in this post I want to focus on multi-player concepts, I'm going to start off with an already valid solo-game code. The script is a bit long but it uses things we've already seen in the other tutorials, and we're going to go through it slowly to make sure we're are on the same page! To start us off, here is a demo of the solo version of the game:

If you're already familiar with programming in Lua, you can just have a look at the script below and then skip to the multi-player part :)

So let's have a look at the starting script:

Config = {
    Map = "minadune.empty",
    Items = {
        "minadune.spaceship",
        "minadune.race_start",
    },
}

---------------------------------
local COLOR_BLACK = Color(0, 0, 0)
local COLOR_WHITE = Color(255, 255, 255)
local COLOR_RED = Color(255, 50, 50)
local COLOR_RED_DARK = Color(150, 5, 5)
local COLOR_GRAY = Color(180, 180, 180)
local COLOR_GRAY_DARK = Color(60, 60, 60)
local PLAYER_COLORS = {
    Color(255, 0, 255),
    Color(150, 0, 180),
    Color(10, 235, 10),
    Color(255, 255, 0),
    Color(255, 0, 0),
    Color(80, 80, 255),
    Color(120, 180, 255),
    Color(255, 50, 50)
}
local SHIP_SPEED = 240
local BULLET_RELATIVE_SPEED = 100
---------------------------------
local ASCII_NUMBERS = {
    [1] = ".----------------. \n| .--------------. |\n| |     __       | |\n| |    /  |      | |\n| |    `| |      | |\n| |     | |      | |\n| |    _| |_     | |\n| |   |_____|    | |\n| |              | |\n| '--------------' |\n'----------------' \n",
    [2] = ".----------------. \n| .--------------. |\n| |    _____     | |\n| |   / ___ `.   | |\n| |  |_/___) |   | |\n| |   .'____.'   | |\n| |  / /____     | |\n| |  |_______|   | |\n| |              | |\n| '--------------' |\n'----------------' \n",
    [3] = ".----------------. \n| .--------------. |\n| |    ______    | |\n| |   / ____ `.  | |\n| |   `'  __) |  | |\n| |   _  |__ '.  | |\n| |  | \\____) |  | |\n| |   \\______.'  | |\n| |              | |\n| '--------------' |\n'----------------' \n",
}
---------------------------------
local walls = {}
local bullets = {}
local shipSpeed = SHIP_SPEED
local minSpawnTime = 2
local maxSpawnTime = 4

local countdown = 4
local runCountdown
local acceleratorTimer = nil
local spawnTimer = nil

Client.OnStart = function()
    Map.IsHidden = true

    TimeCycle.On = false
    Time.Current = Time.Noon

    UI.Crosshair = false

    initPlayer(Player)
    spawnPlayer(Player)

    countdownLabel = UI.Label("", Anchor.HCenter, Anchor.VCenter)

    runCountdown = function()
        countdown = countdown - 1
        if countdown > -1 then
            updateCountdown(countdown)
            Timer(1, runCountdown)
        else
            Timer(2, makeWalls)
            acceleratorTimer = Timer(5, true, function()
                shipSpeed = shipSpeed + 10
                minSpawnTime = minSpawnTime - 0.5
                if minSpawnTime < 1 then minSpawnTime = 1 end
                maxSpawnTime = maxSpawnTime - 0.5
                if maxSpawnTime < 2 then maxSpawnTime = 2 end
            end)
        end
    end
    runCountdown()
end

Client.AnalogPad = function(dx,dy)
    if not Player.init then initPlayer(Player) end
    cameraOrbit.LocalRotation.Y = cameraOrbit.LocalRotation.Y + dx * 0.01
end

Client.Tick = function(dt)
    if not Player.alive then return end
    gameUpdate(dt)
end

Client.Action2 = function()
    if not Player.alive then return end
    spawnBullet(Player, false)
end

Client.Action3 = function()
    if not Player.alive then return end
    spawnBullet(Player, true)
end

Client.AnalogPad = function(dx,dy)
    if not Player.init then initPlayer(Player) end
    cameraOrbit.LocalRotation.Y = cameraOrbit.LocalRotation.Y + dx * 0.01
end

-------------------
-- INITIALIZATION
-------------------
function initPlayer(p)
    if p.init then return end
    p.color = PLAYER_COLORS[math.floor(p.ID % #PLAYER_COLORS) + 1]
    p.Physics = false
    p.IsHidden = true
    World:AddChild(p, true) -- keep world

    p.avatar = spawnPlayerShip(p)
    World:AddChild(p.avatar)
    if p == Player then
        Camera:SetModeFree()
        cameraOrbit = Object()
        Player:AddChild(cameraOrbit)
        cameraOrbit:AddChild(Camera)
        Camera.LocalPosition = { 0, 15, -40 }
        Camera.LocalRotation = { 0.35, 0, 0 }
    end

    p.init = true
end

-------------------
-- SPAWNERS
-------------------
function spawnPlayer(p)
    if not p.init then initPlayer(p) end

    p.Position = getPlayerPosition(p.ID)
    p.avatar.Position = p.Position
    p.alive = true
    p.avatar:ClearTextBubble()

    -- add race start
    if p.start == nil then
        local start = Shape(Items.minadune.race_start)
        start.Position = Number3(p.Position.X, 0, 0)
        start.Scale = Number3(2, 0.5, 2)
        p.start = start
        World:AddChild(start)
    end
    -- add race road
    local road = MutableShape()
    road:AddBlock(COLOR_BLACK, 0, 0, 0)
    road.Scale = Number3(3, 0.5, 300) * Map.LossyScale
    road.Position = Number3(p.Position.X - 7.5, -2.5, -150 * Map.LossyScale.Z)
    World:AddChild(road)
    p.road = road
end

function spawnPlayerShip(p)
    local ship = Shape(Items.minadune.spaceship)
    ship.LocalRotation.Y = math.pi

    -- Add light on ship
    local l = Light()
    l.Type = LightType.Spot
    ship:AddChild(l)
    l.LocalRotation = { math.pi / 2, 0, 0 }
    l.Angle = 0.65
    l.Range = 100
    l.Hardness = 0.75
    l.Color = Map.LocalPalette[p.color].Color

    return ship
end

function spawnWall(wallData)
    local wall = MutableShape()
    wall.isSpecial = wallData.isSpecial
    wall:AddBlock(wallData.isSpecial and COLOR_RED_DARK or COLOR_GRAY, 0, 0, 0)
    wall.Scale = Number3(3, 3, 0.5) * Map.LossyScale
    wall.Position = Number3(wallData.pos[1], wallData.pos[2], wallData.pos[3]) + Number3(-1.5, -0.5, wallData.d) * Map.LossyScale
    wall.CollisionGroups = 2
    wall.Physics = false
    wall.CollisionBox = Box(
        { -1.5, -0.5, wall.Position.Z - 0.25 },
        { 1.5, 0.5, wall.Position.Z + 0.25 })
    World:AddChild(wall)
    if walls[wallData.pID] == nil then walls[wallData.pID] = {} end
    table.insert(walls[wallData.pID], wall)
end

function spawnBullet(p, isSpecial)
    -- prevent bullet spamming
    if #bullets == 3 then return end

    local bullet = MutableShape()
    bullet.isSpecial = isSpecial
    bullet.pID = p.ID
    bullet.Physics = false
    bullet:AddBlock(isSpecial and COLOR_RED or COLOR_WHITE, 0, 0, 0)
    bullet.Scale = 1.5
    World:AddChild(bullet)
    bullet.Position = p.Position - Number3(0.5, 0.5, 0)
    bullet.Forward = p.Forward
    table.insert(bullets, bullet)
end

---------------
-- GAME
---------------
function killPlayer(p)
    p.alive = false
    p.avatar:TextBubble(p.Username .. " ☠")

    clearPlayerLine(p)
end

function clearPlayerLine(p)
    bulletIDsToRemove = {}
    for i, b in pairs(bullets) do
        if b.pID == p.ID then
            b:RemoveFromParent()
            table.insert(bulletIDsToRemove, i)
        end
    end
    for _, i in pairs(bulletIDsToRemove) do
        table.remove(bullets, i)
    end
    if walls[p.ID] ~= nil then
        for _, w in pairs(walls[p.ID]) do
            w:RemoveFromParent()
        end
        walls[p.ID] = nil
    end
end

function prepareWalls()
    local wallsData = {}
    for _, p in pairs(Players) do
        local pos = getPlayerPosition(p.ID)
        for i = 1, math.floor(randomBetween(1, 3)) do
            table.insert(wallsData, {
                pID = p.ID,
                isSpecial = math.random() < 0.3,
                pos = { pos.X, pos.Y, pos.Z },
                d = randomBetween(65, 90) + (i - 1) * 40,
            })
        end
    end
    return wallsData
end

function makeWalls()
    local wallsData = prepareWalls()
    for _, w in ipairs(wallsData) do
        spawnWall(w)
    end

    spawnTimer = Timer(randomBetween(minSpawnTime, maxSpawnTime), makeWalls)
end

-------------------
-- UI
-------------------
function updateCountdown(countdown)
    if countdown == 0 then
        countdownLabel:Remove()
        -- remove "start race" player child objects
        for _, p in pairs(Players) do
            p.start:RemoveFromParent()
            p.start = nil
        end
    elseif countdownLabel ~= nil then
        countdownLabel.Text = ASCII_NUMBERS[countdown]
        countdownLabel:Add()
    end
end

---------------
-- GAME STATE
---------------
function updatePlayerWalls(dt, p)
    if not p.init then return end
    if not p.alive then return end

    if walls[p.ID] ~= nil then
        for _, w in pairs(walls[p.ID]) do
            w.Position = w.Position + p.Backward * shipSpeed * dt
            if collides(p.avatar, w) then
                die()
            end
        end
    end
end

function updateBullets(dt, p)
    for i, b in ipairs(bullets) do
        b.Position = b.Position + b.Forward * BULLET_RELATIVE_SPEED * dt

        -- if hits wall: destroy wall and bullet
        local destroyed = false
        if walls[b.pID] ~= nil then
            for j, w in pairs(walls[b.pID]) do
                if collides(b, w) then
                    if b.isSpecial == w.isSpecial then
                        if Player.playSolo then
                            score = score + 1
                            waitingLabel.Text = "Score: " .. score
                        end
                        w:RemoveFromParent()
                        table.remove(walls[b.pID], j)
                    end
                    b:RemoveFromParent()
                    table.remove(bullets, i)
                    destroyed = true
                end
            end
        end

        if not destroyed then
            -- if too far in front: destroy bullet
            if (b.Position - Player.Position).Length > 300 then
                b:RemoveFromParent()
                table.remove(bullets, i)
            end
        end
    end
end

function gameUpdate(dt)
    for _, p in pairs(Players) do
        updatePlayerWalls(dt, p)
    end
    updateBullets()
end

die = function()
    killPlayer(Player)

    spawnTimer:Cancel()
    acceleratorTimer:Cancel()
    shipSpeed = SHIP_SPEED
    countdown = 4
    Timer(1, function()
        spawnPlayer(Player)
        runCountdown()
    end)
end

-------------------
-- MISC
-------------------
function getPlayerIndex(id)
    local idx = 1
    for _, p in pairs(Players) do
        if p.ID == id then return idx end
        idx = idx + 1
    end
    return idx
end

function getPlayerPosition(id)
    local idx = getPlayerIndex(id)
    local x = 3 * (idx - 1)
    return Number3(x, 1.5, 0) * Map.LossyScale
end

function randomBetween(a, b)
    return math.random() * (b - a) + a
end

function collides(shape1, shape2)
    local halfSize1 = (
        shape1.Width > shape1.Depth
        and shape1.Width * 0.5
        or shape1.Depth * 0.5) * shape1.LocalScale.X
    local halfSize2 = (
        shape2.Width > shape2.Depth
        and shape2.Width * 0.5
        or shape2.Depth * 0.5) * shape2.LocalScale.X
    local shape1Min = Number3(
        shape1.Position.X - halfSize1,
        shape1.Position.Y,
        shape1.Position.Z - halfSize1)
    local shape1Max = Number3(
        shape1.Position.X + halfSize1,
        shape1.Position.Y + shape1.Height * shape1.LocalScale.X,
        shape1.Position.Z + halfSize1)
    local shape2Min = Number3(
        shape2.Position.X - halfSize2,
        shape2.Position.Y,
        shape2.Position.Z - halfSize2)
    local shape2Max = Number3(
        shape2.Position.X + halfSize2,
        shape2.Position.Y + shape2.Height * shape2.LocalScale.X,
        shape2.Position.Z + halfSize2)
    if shape1Max.X > shape2Min.X and
        shape1Min.X < shape2Max.X and
        shape1Max.Y > shape2Min.Y and
        shape1Min.Y < shape2Max.Y and
        shape1Max.Z > shape2Min.Z and
        shape1Min.Z < shape2Max.Z then
        return true
    end
    return false
end

Pfiou, that's a lot of code! But don't worry, it's actually quite straight-forward overall :)

The Client.OnStart()

First, let's focus on the Client.OnStart() function:

Client.OnStart = function()
    Map.IsHidden = true

    TimeCycle.On = false
    Time.Current = Time.Noon

    UI.Crosshair = false

    initPlayer(Player)
    spawnPlayer(Player)

    countdownLabel = UI.Label("", Anchor.HCenter, Anchor.VCenter)

    runCountdown = function()
        countdown = countdown - 1
        if countdown > -1 then
            updateCountdown(countdown)
            Timer(1, runCountdown)
        else
            Timer(2, makeWalls)
            acceleratorTimer = Timer(5, true, function()
                shipSpeed = shipSpeed + 10
                minSpawnTime = minSpawnTime - 0.5
                if minSpawnTime < 1 then minSpawnTime = 1 end
                maxSpawnTime = maxSpawnTime - 0.5
                if maxSpawnTime < 2 then maxSpawnTime = 2 end
            end)
        end
    end
    runCountdown()
end

When we first launch the game, you see that we hide the map (because we're going to spawn our own roads to ride), we fix the daytime to noon and we hide the in-game cursor.

Then, we init and spawn our player. Those two functions allow us to prepare our player's avatar, place it properly in the world, and also add the race start line and the road beneath the spaceship.

Finally, we run the countdown to warn the player the race is about to start - to get a more visible countdown in the middle of the screen, I'm using ASCII art to show large numbers (you can find one converter here, for example).

The code also reads and sets various global variables declared before the function (like the recursive function runCountdown(), the acceleratorTimer or the countdown number).

Initialising/Spawning the player

If you look carefully, you'll see that contrary to the code of the "Lone Scoundrel" tutorial, our initPlayer() and spawnPlayer() functions take in a Player instance: this is how we'll easily apply them to multiple players when we've added them! For now, we call them with our own instance, stored in the built-in Player variable, but later on we'll be able to prepare all the clients the same.

Other than that, the script simply prepares an orbital camera and a specific avatar for our player, just like in this other tutorial. I've also added a spot light under the spaceship to indicate the colour of the player for this game (based on their unique IDs)

Making/Moving walls

Then, we have various functions to periodically add walls on the road for the player to destroy: prepareWalls(), makeWalls() and spawnWall(). In short, altogether they generate 1 to 3 walls in the distance in front of each player (for now, it's just us) and adds them to the walls table so we can update them later on. However, to make the update easier afterwards, we store the walls in a table of table: walls contain one table per connected player, and each sub-table contains the walls on the line of this player. So it has a shape that looks something like this:

{
    [0]: { [wall 0], [wall 1] },
    [1]: { [wall 2], [wall 3], [wall 4] },
    [2]: { [wall 5] },
}

Note: in this example, we have three players with IDs 0, 1 and 2, and they each have walls on their lines: 2 for the player 0, 3 for the player 1 and 1 for the player 2 :)

The way the logic is separated in the three functions may seem a bit arbitrary for now, but it will make it easier to turn it multi-player in a while. The important part is to understand that the "entry-point" of this generation process is the makeWalls() function that call the other two at the right time, and is regularly run by our spawnTimer (the one we set in the Client.OnStart()).

Once our walls have been generated, we use our Client.Tick() function (and more precisely the gameUpdate() and updatePlayerWalls() functions we indirectly call in it) to move those walls in the reverse direction from the one of the player:

function updatePlayerWalls(dt, p)
    if not p.init then return end
    if not p.alive then return end

    if walls[p.ID] ~= nil then
        for _, w in pairs(walls[p.ID]) do
            w.Position = w.Position + p.Backward * shipSpeed * dt
            if collides(p.avatar, w) then
                die()
            end
        end
    end
end

It's the same technique as the one we used in the "Lone Scoundrel" episode: we're simulating the ship moving forward by having everything else move backwards!

Shooting bullets

Each player can shoot bullets (on their own line) to destroy the walls in front of them: if the wall is gray, you have to shoot a "normal" (white) bullet with the Action2 input, and if the wall is red, you have to shoot a "special" (red) bullet with the Action1 input:

Client.Action2 = function()
    if not Player.alive then return end
    spawnBullet(Player, false)
end

Client.Action3 = function()
    if not Player.alive then return end
    spawnBullet(Player, true)
end

Once again, we pass the Player instance to the spawnBullet() function so we can re-use in the multi-player version more easily. The bullet is a simple procedural cube that is spawned at the position of the player that shot it, and is then moved during the gameUpdate() process, with the updateBullets() function:

function updateBullets(dt, p)
    for i, b in ipairs(bullets) do
        b.Position = b.Position + b.Forward * BULLET_RELATIVE_SPEED * dt

        -- if hits wall: destroy wall and bullet
        local destroyed = false
        if walls[b.pID] ~= nil then
            for j, w in pairs(walls[b.pID]) do
                if collides(b, w) and b.isSpecial == w.isSpecial then
                    w:RemoveFromParent()
                    table.remove(walls[b.pID], j)
                    b:RemoveFromParent()
                    table.remove(bullets, i)
                    destroyed = true
                end
            end
        end

        if not destroyed then
            -- if too far in front: destroy bullet
            if (b.Position - Player.Position).Length > 300 then
                b:RemoveFromParent()
                table.remove(bullets, i)
            end
        end
    end
end

This method relies on concepts we already saw in previous episodes, like looping through all the instances in a table (here: bullets) to update their position and move them in the world, or checking for collisions with a collides() function, or even remove the objects if they are too far away. We could of course do some object pooling, as discussed in "Lone Scoundrel", but this would make the code more complex and potentially divert us from the multi-player parts ;)

Ending the game & respawning

Finally, our solo-game script contains some logic to trigger the game over and respawn the player automatically for a new game. Basically, whenever we move the walls, we also check if there isn't one that collides with the player on the line and, if there is, then we "kill" that player. Since, at the moment, we're the only one playing, it's only the game over, and we have a little timer to auto-respawn one second later:

function updatePlayerWalls(dt, p)
    if not p.init then return end
    if not p.alive then return end

    if walls[p.ID] ~= nil then
        for _, w in pairs(walls[p.ID]) do
            w.Position = w.Position + p.Backward * shipSpeed * dt
            if collides(p.avatar, w) then
                die()
            end
        end
    end
end

die = function()
    killPlayer(Player)

    spawnTimer:Cancel()
    acceleratorTimer:Cancel()
    shipSpeed = SHIP_SPEED
    countdown = 4
    Timer(1, function()
        spawnPlayer(Player)
        runCountdown()
    end)
end

"Killing" a player mostly means turning some flag to false to update their state, clearing the walls on their line and showing a TextBubble to tell the others this player is out!

Making a multi-player game

Important note: if you want to test your multi-player game, make sure you take a look at the quick "Testing a multi-player game" guide :)

Now that we've seen how the solo version of this game works, let's discuss how we can turn it into a multi-player one!

Before we code anything, we need to talk a bit about the structure we have to build to do that.

Sharing a world, all united...

A multi-player game is, you guessed it, a game that allows multiple players to play at the same time, either against each other or in cooperation, and usually offers a shared world to everyone. This is crucial, and it can be one of the first problem you encounter when you first dive into multi-player programming: players expect their opponents and friends to see the same thing as them (the point of view will obviously be different, since each player has its own camera, but all cameras should show a consistent world).

To do this, we'll have to maintain a reference to the current state of the game, among four: "Waiting", "Starting", "Running" or "End".

- the "Waiting" state is the initial state for all players: it's when you're still waiting for more players to actually start a game
- the "Starting" state is the next state: it starts when there are enough players to start a game, and the countdown is running for everyone
- then, the "Running" state is the game the state is in most of the time: it starts when the countdown is finished and ends when there is just 1 or 0 players alive (1 player alive makes this one the winner, 0 players alived results in a tie)
- finally, the "End" state is a short transitional state that starts as soon as the last or last-but-one player dies and ends when a new game starts (if there are still enough players)

When you start to design more complex games with states like this, it can be interesting to draw a diagram of the "lifecycle" of your game:

This helps you quickly check in which state your game should be at any point in time :)

Server & clients

This notion of "sharing a world" also directly leads to another fundamental multi-player programming concept: the notion of client and server.

As a Cubzh player, when you edit or launch a game from the menu and get teleported in the world as your avatar, you exist in a world that is stored somewhere on a server. You yourself are a client, i.e. a "user" of this world: you can act upon it and modify it to some extent, but the data is not actually on your computer.

Now, suppose other players connect into the same world - they will be registered as other clients of your server.

By default, they'll get the same world as you: they'll have the same map, and they'll be spawned in the same location as you. But they won't be able to see you, or you them. And if you start to spawn some objects (like walls or bullets) in your world, you'll quickly realise that you're just updating your version of the world.

That's the difference between running code on the "server side" and the "client side": what happens on the server is shared between all clients connected to it, but what happens on a client is not known by the other clients:

What we'd like for our multi-player "Space Race" is to have a unique world common to all players:

To get this unique consistent shared instance of the world, we basically have two types of data transfer:

- we can compute and/or store things on the server, and then send them to all the clients. This is useful when you have a game-related data to share that should be perfectly synced between all clients and can be precisely timed by the server. In that case, the data is computed on the shared server and is then "interpreted" by each client in the same way to reproduce equivalent states everywhere. For example, the server could tell each client to "spawn a wall" at a specific position: each client will receive this message and interpret it to instantiate a wall in its version of the world. In the end, all clients will have a new wall in the same position, and will therefore share a consistent world state.

- we can also compute things on one client, and then send it to the other clients. Typically, if the result of a user action (like Action1 or Action2) changes the world (for example, by spawning a new bullet somewhere), then the server can't know about it in advance; instead, it makes more sense to have the client performing the action directly tell the rest of the clients what happened so they can update their own world and get a consistent state.

In that case, the client performing the action takes care of updating the world on its own first, and then sends the info to the other clients so they can change their versions too.

- we could even have some info that is computed by a player but is useful to the server; for example, if a player dies, the server should know it to re-update its list of active players and properly assess whether the game should end or not.

The server or the client that wants to communicate some info can obviously want to do so to multiple recipients at once (typically: if the player dies, the server should be informed but the other players too, because they'll need to update the dead player's avatar and/or state in their own version of the world).

But now the question is of course: how can we actually transfer this data between our server and clients? For now, we've seen various Cubzh APIs to communicate between our client and the server, but we don't know how to make the server talk to the client(s), or the clients talk to each other! The solution is to use events! :)

Events: the crux of data sharing

Basically, events are like messages. They are created by the "sender", can be filled with extra properties to carry some data, and transferred to one or more "receivers". You can send and receive events whichever way you'd like: from/to the server and from/to the client(s).

Long story short: the events are the Lua objects we need to create to make the green, blue and purple arrows in our last three diagrams:

Creating events is very easy thanks to Cubzh's Event API:

e = Event()
-- set a property to define the "action"
-- to trigger with this event
e.action = "displayText"
-- set extra properties
e.text = "Hello world"
e.toUpper = false

-- send to all clients = players
e:SendTo(Players)
-- send to the server
e:SendTo(Server)

You can send events to 4 types of "people": the server (Server), all the players (Players), all the players but the sender (OtherPlayers) or a client in particular (i.e. an instance of Player).

Then, you'll need to tell your recipients to listen to events and have a callback process; this is done in the Client.DidReceiveEvent() or Server.DidReceiveEvent() functions:

Client.DidReceiveEvent = function(e)
    local s = e.Sender
    print("Received event from: " .. s)
    if e.action == "displayText" then
        if e.toUpper then
            print(e.text.upper())
        else
            print(e.text)
        end
    end
end

Server.DidReceiveEvent = function(e)
    local s = e.Sender
    print("Received event from: " .. s)
    if e.action == "displayText" then
        print("(I am the server - I don't react to do this event)")
    end
end

All the clients that receive an event (no matter who sent it) will react to it in their Client.DidReceiveEvent() function - here, if the event has an action of "displayText", they'll (optionally format and) print the text in the event. The server also checks events, but it won't actually do anything receiving an event with this action.

Turning "Space Race" into a multi-player game!

Alright - enough talking! Time to use all these concepts and theories to make our game multi-player :)

Important note: in all this code, we'll use the following convention: all the variables and functions that are on the server side are prefixed with the word "server", while the ones on the client side don't have any specific prefix.

First of all, let's prepare our 4 game states, and some functions to update the current value:

---------------------------------
local GAME_STATES = {
    Waiting = 0,
    Starting = 1,
    Running = 2,
    End = 3
}
local gameState = GAME_STATES.Waiting
local serverGameState = GAME_STATES.Waiting

function setGameState(newState)
    -- perform special actions on state transitions
    -- (client side)

    -- update the state on the client side
    gameState = newState
end

function serverSetGameState(newState)
    -- perform special actions on state transitions
    -- (server side)

    -- update the state on the server side
    serverGameState = newState
end

Even if they will always be the same, it's better to define a variable for the current game state both on the client side and the server side because:

- the server variable is the real source of truth: it keeps everyone in sync and insures our game is robust
- the client variable cannot change on its own: it can only be set via an event from the server - but it gives us a quicker access to the value and makes our game more optimised (because the client doesn't have to ask the server for the value - it's like a local "caching" of the server value)

Now let's go to through each of game states one by one, without forgetting to implement the logic of the state transitions (for example, how to update the UI when switching from "Waiting" to "Starting" states).

Implementing the "Waiting" state

We said before that the "Waiting" state is the one the clients and the server start in: that's why I set the default value of the gameState and serverGameState values to GAME_STATES.Waiting.

What we need to do is keep checking if there are enough players for a new game, and if there are switch to the "Starting" state. In other words, if our current nPlayers count is at least equal to our MIN_PLAYERS arbitrary value (the minimum number of players required to start a game), we'll want to switch to the "Starting" state on the server side, and communicate this update to the clients with a specific "setGameState" event. And we'll need to run this check regularly on the server side, so we should use the Server.Tick():

---------------------------------
local MIN_PLAYERS = 2
local nPlayers = 0

Server.Tick = function(dt)
    if serverGameState == GAME_STATES.Waiting then
        if nPlayers >= MIN_PLAYERS then
            serverSetGameState(GAME_STATES.Starting)
        end
    end
end

function serverSetGameState(newState)
    local e = Event()
    e.action = "setGameState"
    e.state = newState
    e:SendTo(Players)

    serverGameState = newState
end

To actually keep track of the current number of players, we have to increment or decrement our nPlayers counter whenever a new player joins the game, or leaves it; this can be done easily via the cleverly named Server.OnPlayerJoin() and Server.OnPlayerLeave() functions ;)

Server.OnPlayerJoin = function(p)
    nPlayers = nPlayers + 1
end

Server.OnPlayerLeave = function(p)
    nPlayers = nPlayers - 1
    p.alive = false
end

Of course, we also want to do a few things on the client side. Namely, we want to init the avatar of the player that just joined and instantiate it in this client's version of the world. That's where our initPlayer() function and its Player instance parameter will come in handy! We can implement our logic in the client-equivalent of the functions we just saw, that are named Client.OnPlayerJoin() and Client.OnPlayerLeave():

Client.OnPlayerJoin = function(p)
    print(p.Username .. " just joined")
    initPlayer(p)
end

Client.OnPlayerLeave = function(p)
    killPlayer(p)
    p.avatar:RemoveFromParent()
end

And when we receive the "setGameState" event, we should update our local gameState variable, and spawn the players we initialised previously:

Client.DidReceiveEvent = function(e)
    if e.action == "setGameState" then
        setGameState(e.state)
    end
end

function setGameState(newState)
    if newState == GAME_STATES.Starting then
        Camera.LocalPosition.Y = 20
    
        for _, p in pairs(Players) do
            spawnPlayer(p)
        end
    end

    gameState = newState
end

If you run the game and ask friends to join in, you'll see that the other player(s) now spawn next to you:

The only problem is that, for now, most of the game state is still computed on the client side. For example, the countdown is actually different from one player to another, depending on when they joined! So let's keep going and take care of our "Starting" phase :)

Fixing the "Starting" state

The nice thing is that, overall, we've already coded the countdown logic we need. All we have to do is move to the server side, and send the current countdown value to all the clients via a new "countdown" event:

local serverSpawnTimer = nil
local serverAcceleratorTimer = nil

--------------
-- CLIENT
--------------
Client.DidReceiveEvent = function(e)
    if e.action == "setGameState" then
        setGameState(e.state)
    elseif e.action == "countdown" then
        updateCountdown(e.countdown)
    end
end

--------------
-- SERVER
--------------
function serverSetGameState(newState)
    local e = Event()
    e.action = "setGameState"
    e.state = newState
    e:SendTo(Players)

    if newState == GAME_STATES.Starting and serverGameState == GAME_STATES.Waiting then
        for _, p in pairs(Players) do
            p.alive = true
        end

        -- reset ship speed to default
        shipSpeed = SHIP_SPEED

        local countdown = 3 -- in seconds
        local e = Event()
        e.action = "countdown"
        local serverUpdateCountdown
        serverUpdateCountdown = function()
            e.countdown = countdown
            e:SendTo(Players)
            countdown = countdown - 1
            if countdown > -1 then
                Timer(1, serverUpdateCountdown)
            else
                Timer(2, serverSendWalls)
                serverAcceleratorTimer = Timer(5, true, function()
                    shipSpeed = shipSpeed + 10
                    minSpawnTime = minSpawnTime - 0.5
                    if minSpawnTime < 1 then minSpawnTime = 1 end
                    maxSpawnTime = maxSpawnTime - 0.5
                    if maxSpawnTime < 2 then maxSpawnTime = 2 end
                end)
                serverSetGameState(GAME_STATES.Running)
            end
        end
        serverUpdateCountdown()
    end

    serverGameState = newState
end

Note: I've also renamed some of our variables like the timers with the "server" prefix since they are now stored on the server side :)

Of course, don't forget to remove all the obsolete code from the Client.OnStart() function:

Client.OnStart = function()
    Map.IsHidden = true

    TimeCycle.On = false
    Time.Current = Time.Noon

    UI.Crosshair = false

    countdownLabel = UI.Label("", Anchor.HCenter, Anchor.VCenter)
    countdownLabel:Remove()
end

Now, our countdown is handled by the server and perfectly synced between all the currently connected players!

Coding up the "Running" state

The next step is to update the logic for our main state: the "Running" phase. Luckily, our code is structured with these changes in mind so it shouldn't be too hard. We just have to be careful what the server knows, what the client knows and what the other clients know...

First of all: let's fix the walls. For now, if you ask your friend on the other end of the second avatar, they'll probably tell you that something's wrong: they apparently don't have the same playground as you!

That's because, as you might remember, those walls are spawned and positioned randomly... and, up to this point, we kept all this code on the client side so, basically, each player just had its own random walls generated each time. Again, we need to move the code to the server; but remember how we cut the logic in multiple functions before, with our prepareWalls(), makeWalls() and spawnWall()? Now, this preparation will shine :)

What we want to do is take the prepareWalls() and makeWalls() functions and move with the rest of the server code; then, we'll rename them serverPrepareWalls() and serverSendWalls(), and we'll modify the second one a bit to send the result of the computation as an event to all the clients. However, as of today, Cubzh Events can't transfer Lua tables directly: they only support strings, numbers or booleans. So the trick is to encode our data using the built-in JSON encoder to turn it into a formatted string:

function serverSpawnWalls()
    local wallsData = {}
    for _, p in pairs(Players) do
        local pos = getPlayerPosition(p.ID)
        for i = 1, math.floor(randomBetween(1, 3)) do
            table.insert(wallsData, {
                pID = p.ID,
                isSpecial = math.random() < 0.3,
                pos = { pos.X, pos.Y, pos.Z },
                d = randomBetween(65, 90) + (i - 1) * 40,
            })
        end
    end
    return wallsData
end

function serverSendWalls()
    local e = Event()
    e.action = "walls"
    e.walls = JSON:Encode(serverSpawnWalls())
    e:SendTo(Players)

    serverSpawnTimer = Timer(randomBetween(minSpawnTime, maxSpawnTime), serverSendWalls)
end

Then, on the client side, we'll listen to this event and, if need be, call our spawnWall() function from before with the decoded data from the event:

Client.DidReceiveEvent = function(e)
    if e.action == "setGameState" then
        setGameState(e.state)
    elseif e.action == "countdown" then
        updateCountdown(e.countdown)
    elseif e.action == "walls" then
        local wallsData = JSON:Decode(e.walls)
        for _, w in ipairs(wallsData) do
            spawnWall(w)
        end
    end
end

Try to publish and re-run the game: you'll see that all the players now have the same walls in front of them - thanks to a "server-to-client" event, we've made our world consistent :)

Except that we have the exact same issue with the bullets: at the moment, they are spawned solely on the client side and the others have no idea there is currently a bullet moving in front of their opponent to hit the wall. This time, we'll want to trigger a "client-to-client" event so that whenever a client spawns a bullet, the other know about it and "copy" this bullet in their own version of the world.

To do this, let's update our spawnBullet() function so that, at the end, it calls a sendEventBulletSpawned() method that, in turns, tells all the OtherPlayers about the new bullet. And then, we'll add this new type of event to our Client.DidReceiveEvent() if-else checks:

Client.DidReceiveEvent = function(e)
    -- get event sender
    local p = e.Sender
    
    if e.action == "setGameState" then
        ...
    elseif e.action == "countdown" then
        ...
    elseif e.action == "walls" then
        ...
    elseif e.action == "bulletSpawned" then
        spawnBullet(p, e.isSpecial)
    end
end

function spawnBullet(p, isSpecial)
    ...
    if p == Player then
        sendEventBulletSpawned(isSpecial)
    return
end

sendEventBulletSpawned = function(isSpecial)
    local e = Event()
    e.action = "bulletSpawned"
    e.isSpecial = isSpecial
    e:SendTo(OtherPlayers)
end

Be careful: you must insure that only the current player sends the event, and that it sends it only to the other players - otherwise, you risk having an infinite loop of events that keep calling themselves and spawn bullets endlessly!

A last improvement is to check if the game state is GAME_STATES.Running to cancel the firing of bullets, or the movement of the walls:

Client.Tick = function(dt)
    if gameState == GAME_STATES.Running then
        if not Player.alive then return end
        gameUpdate(dt)
    end
end

Client.Action2 = function()
    if not Player.alive then return end
    if gameState == GAME_STATES.Running then
        spawnBullet(Player, false)
    end
end

Client.Action3 = function()
    if not Player.alive then return end
    if gameState == GAME_STATES.Running then
        spawnBullet(Player, true)
    end
end

With all those fixes, the bullets are now properly shared between the two screens along with the walls and the rest of the world:

Setting up the end game

Last but not least, we have to fix the transition to the "End" phase, and handle the auto-reload for a new game. For now, our code will crash if we hit a wall because the die() function we coded for the solo-game references some invalid timers:

die = function()
    killPlayer(Player)

    spawnTimer:Cancel()
    acceleratorTimer:Cancel()
    shipSpeed = SHIP_SPEED
    countdown = 4
    Timer(1, function()
        spawnPlayer(Player)
        runCountdown()
    end)
end

What we want to do is, instead of having this die() function, replace it with a sendEventDied() function that broadcasts a "died" event to all players and the server:

function updatePlayerWalls(dt, p)
    if not p.init then return end
    if not p.alive then return end

    if walls[p.ID] ~= nil then
        for _, w in pairs(walls[p.ID]) do
            w.Position = w.Position + p.Backward * shipSpeed * dt
            if collides(p.avatar, w) then
                sendEventDied()
            end
        end
    end
end

sendEventDied = function()
    local e = Event()
    e.action = "died"
    killPlayer(Player)
    e:SendTo(OtherPlayers)
    e:SendTo(Server)
end

We'll have to catch and respond to this event on the client side, by "killing" the player (this will clean the avatar and the line of the dead player):

Client.DidReceiveEvent = function(e)
    -- get event sender
    local p = e.Sender
    
    if e.action == "setGameState" then
        ...
    elseif e.action == "countdown" then
        ...
    elseif e.action == "walls" then
        ...
    elseif e.action == "bulletSpawned" then
        ...
    elseif e.action == "died" then
        killPlayer(p)
    end
end

Then, we'll also have to get the event on the server side to properly handle the transition to the "End" phase. Basically, we just have to check how many players are still alive - I'll also make sure to handle a solo-game case (i.e. with MIN_PLAYERS = 1):

- if we are in multi and there are 0 players still alive, we end the game with a tie
- if we are in multi and there is 1 player still alive, we end the game with a win for the alive player
- if we are in solo, we simply end the game with a "Game over!" message

Client.DidReceiveEvent = function(e)
    -- get event sender
    local p = e.Sender
    
    if e.action == "setGameState" then
        ...
    elseif e.action == "clearLine" then
        clearPlayerLine(Players[e.player])
    end
end

Server.DidReceiveEvent = function(e)
    if e.action == "died" and serverGameState == GAME_STATES.Running then
        if not e.Sender.alive then return end
        e.Sender.alive = false
        local playersAlive = {}
        for _, p in pairs(Players) do
            if p.alive then
                table.insert(playersAlive, p)
            end
        end
        local nPlayersAlive = #playersAlive
        if MIN_PLAYERS > 1 then
            if nPlayersAlive == 0 then
                serverEndGame("Tie")
            end
            if nPlayersAlive == 1 then
                local winner = playersAlive[1]
                serverEndGame(winner.Username .. " won!")

                local e = Event()
                e.action = "clearLine"
                e.player = winner.ID
                e:SendTo(Players)
            end
        else
            serverEndGame("Game over!")
        end
    end
end

function serverEndGame(text)
    local e = Event()
    e.action = "endGame"
    e.text = text
    e:SendTo(Players)

    serverSpawnTimer:Cancel()
    serverAcceleratorTimer:Cancel()

    serverSetGameState(GAME_STATES.End)
end

By the way, we can also make sure that if everyone leaves and there isn't enough players to continue playing, we switch back to "Waiting":

Server.Tick = function(dt)
    if serverGameState == GAME_STATES.Waiting then
        if nPlayers >= MIN_PLAYERS then
            serverSetGameState(GAME_STATES.Starting)
        end
    end
    if serverGameState == GAME_STATES.Starting or serverGameState == GAME_STATES.Running then
        if nPlayers < MIN_PLAYERS then
            print("All the other players left. Waiting for a new player...")
            serverSetGameState(GAME_STATES.Waiting)
        end
    end
end

And create a Timer as soon as the game ends to restart a new one 3 seconds later:

function serverSetGameState(newState)
    local e = Event()
    e.action = "setGameState"
    e.state = newState
    e:SendTo(Players)

    if newState == GAME_STATES.Starting and serverGameState == GAME_STATES.Waiting then
        ...
    elseif newState == GAME_STATES.End and serverGameState == GAME_STATES.Running then
        Timer(3, function()
            serverSetGameState(GAME_STATES.Waiting)
        end)
    end

    serverGameState = newState
end

Adding a few UI labels

I'll wrap up this tutorial by adding a "Waiting" label (that will also be used to show the number of remaining players), and an "End" label for the win/game over screen. We can create them in the Client.OnStart():

Client.OnStart = function()
    Map.IsHidden = true

    TimeCycle.On = false
    Time.Current = Time.Noon

    UI.Crosshair = false

    -- UI variables
    waitingLabel = UI.Label("Waiting for more players...", Anchor.HCenter, Anchor.Top)
    countdownLabel = UI.Label("", Anchor.HCenter, Anchor.VCenter)
    endLabel = UI.Label("", Anchor.HCenter, Anchor.VCenter)

    countdownLabel:Remove()
    endLabel:Remove()
end

Then:
- if we're in the "Waiting" state, we'll set the "Waiting" label's message and we'll hide the "End" label
- if we're in the "Starting" state, we'll change the "Waiting" label to show "Ready?"
- if we're in the "End" state, we'll update and show the "End" label when the client receives an "endGame" event

Client.DidReceiveEvent = function(e)
    -- get event sender
    local p = e.Sender
    
    if e.action == "setGameState" then
        ...
    elseif e.action == "endGame" then
        endLabel.Text = e.text
        endLabel:Add()
    end
end

function setGameState(newState)
    if newState == GAME_STATES.Waiting then
        waitingLabel.Text = "Waiting for more players..."
        endLabel:Remove()
    elseif newState == GAME_STATES.Starting then
        if waitingLabel ~= nil then
            waitingLabel.Text = "Ready?"
        end

        Camera.LocalPosition.Y = 20
    
        for _, p in pairs(Players) do
            spawnPlayer(p)
        end
    end

    gameState = newState
end
        

- and all throughout the game, we'll use the "Waiting" label to show how many players are still alive:

function setGameState(newState)
    if newState == GAME_STATES.Waiting then
        ...
    elseif newState == GAME_STATES.Starting then
        ...
    elseif newState == GAME_STATES.Running then
        updateRemainingPlayers()
    end

    gameState = newState
end

function updateRemainingPlayers()
    local nPlayers = 0
    for _, p in pairs(Players) do
        if p.alive then
            nPlayers = nPlayers + 1
        end
    end
    if waitingLabel ~= nil then
        waitingLabel.Text = "Remaining players: " .. nPlayers
    end
end

sendEventDied = function()
    local e = Event()
    e.action = "died"
    killPlayer(Player)
    updateRemainingPlayers()
    e:SendTo(OtherPlayers)
    e:SendTo(Server)
end

Bonus: Adding some road markers to help fake the movement

We've now successfully "converted" our solo-game to a multi-player version: we can have several clients join and spawn their ships next to each while always maintaining a consistent shared state of the world (thanks to the server that allows robust synchronisation).

But if you play the game at this point, you might feel like it's a bit off - once you and your friends have destroyed all the walls on the lines, then everything gets totally still until new ones pop. That's not ideal, cause it makes our feeling of motion constantly come and go!

To fix this, we can spawn some plots on the side of the road to keep this motion going; and this time, I'll re-use the object pooling technique we saw in the previous tutorial :)

---------------------------------
local PLOTS_SPACING = 30
local PLOTS_N_HALF = 5
local roadPlots = {}

function spawnPlayer(p)
    ...

    -- if first or last player: add road plots
    local idx = getPlayerIndex(p.ID)
    local plotsSpawnSides = {}
    if idx == 1 then table.insert(plotsSpawnSides, -1) end
    if idx == #Players then table.insert(plotsSpawnSides, 1) end
    
    for _, side in pairs(plotsSpawnSides) do
        for i = -PLOTS_N_HALF, PLOTS_N_HALF do
            local plot = MutableShape()
            plot:AddBlock(COLOR_BLACK, 0, 0, 0)
            plot.Scale = Number3(0.5, 2, 0.5) * Map.LossyScale
            local x = p.Position.X + side * 2 * Map.LossyScale.X
            if side == -1 then x = x - 0.5 * Map.LossyScale.X end
            plot.Position = Number3(x, -1, i * PLOTS_SPACING * Map.LossyScale.Z)
            World:AddChild(plot)
            table.insert(roadPlots, plot)
        end
    end
end

function setGameState(newState)
    if newState == GAME_STATES.Waiting then
        waitingLabel.Text = "Waiting for more players..."
        endLabel:Remove()

        for _, p in pairs(roadPlots) do
            p:RemoveFromParent()
        end
        roadPlots = {}
    elseif newState == GAME_STATES.Starting then
        ...
    elseif newState == GAME_STATES.Running then
        ...
    end

    gameState = newState
end

function updateRoadPlots(dt)
    for _, p in pairs(roadPlots) do
        p.Position = p.Position + Player.Backward * shipSpeed * dt
        if p.Position.Z < -PLOTS_N_HALF * PLOTS_SPACING * Map.LossyScale.Z then
            p.Position.Z = PLOTS_N_HALF * PLOTS_SPACING * Map.LossyScale.Z
        end
    end
end

function gameUpdate(dt)
    for _, p in pairs(Players) do
        updatePlayerWalls(dt, p)
    end
    updateBullets(dt)
    updateRoadPlots(dt)
end

Conclusion

In this third tutorial, we made our first multi-player game: "Space Race"! This time, we re-used the Cubzh's features we saw before, and discovered how to turn a solo-game into a multi-player one :)

You can find the game on Cubzh (by going to the worlds list and searching for "Space Race") or re-create your own and then tweak it to your liking: feel free to share all your creations with us, we'd love to hear from you!

Resources & links

API objects used in this tutorial:

- Button
- Camera
- Client
- Color
- Config
- Fog
- Items
- Label
- Light
- MutableShape
- Number3
- Object (indirectly)
- Palette (indirectly)
- Player
- Pointer
- UI
- World

Final code

As a reference, here is the final code of "Space Race" - remember that you can also check it out directly on Cubzh by playing the game and clicking the "Read the code" button!

Config = {
    Map = "minadune.empty",
    Items = {
        "minadune.spaceship",
        "minadune.race_start",
    },
}

---------------------------------
local COLOR_BLACK = Color(0, 0, 0)
local COLOR_WHITE = Color(255, 255, 255)
local COLOR_RED = Color(255, 50, 50)
local COLOR_RED_DARK = Color(150, 5, 5)
local COLOR_GRAY = Color(180, 180, 180)
local COLOR_GRAY_DARK = Color(60, 60, 60)
local PLAYER_COLORS = {
    Color(255, 0, 255),
    Color(150, 0, 180),
    Color(10, 235, 10),
    Color(255, 255, 0),
    Color(255, 0, 0),
    Color(80, 80, 255),
    Color(120, 180, 255),
    Color(255, 50, 50)
}
local SHIP_SPEED = 210
local BULLET_RELATIVE_SPEED = 100
---------------------------------
local GAME_STATES = {
    Waiting = 0,
    Starting = 1,
    Running = 2,
    End = 3
}
local gameState = GAME_STATES.Waiting
local serverGameState = GAME_STATES.Waiting
---------------------------------
local MIN_PLAYERS = 1
local nPlayers = 0
---------------------------------
local PLOTS_SPACING = 30
local PLOTS_N_HALF = 5
local roadPlots = {}
---------------------------------
local ASCII_NUMBERS = {
    [1] = ".----------------. \n| .--------------. |\n| |     __       | |\n| |    /  |      | |\n| |    `| |      | |\n| |     | |      | |\n| |    _| |_     | |\n| |   |_____|    | |\n| |              | |\n| '--------------' |\n'----------------' \n",
    [2] = ".----------------. \n| .--------------. |\n| |    _____     | |\n| |   / ___ `.   | |\n| |  |_/___) |   | |\n| |   .'____.'   | |\n| |  / /____     | |\n| |  |_______|   | |\n| |              | |\n| '--------------' |\n'----------------' \n",
    [3] = ".----------------. \n| .--------------. |\n| |    ______    | |\n| |   / ____ `.  | |\n| |   `'  __) |  | |\n| |   _  |__ '.  | |\n| |  | \\____) |  | |\n| |   \\______.'  | |\n| |              | |\n| '--------------' |\n'----------------' \n",
}
---------------------------------
local walls = {}
local bullets = {}
local shipSpeed = SHIP_SPEED
local minSpawnTime = 2
local maxSpawnTime = 4

local serverSpawnTimer = nil
local serverAcceleratorTimer = nil

Client.OnStart = function()
    Map.IsHidden = true

    TimeCycle.On = false
    Time.Current = Time.Noon

    UI.Crosshair = false

    -- UI variables
    waitingLabel = UI.Label("Waiting for more players...", Anchor.HCenter, Anchor.Top)
    countdownLabel = UI.Label("", Anchor.HCenter, Anchor.VCenter)
    endLabel = UI.Label("", Anchor.HCenter, Anchor.VCenter)

    countdownLabel:Remove()
    endLabel:Remove()
end

Client.OnPlayerJoin = function(p)
    print(p.Username .. " just joined")
    initPlayer(p)
end

Client.OnPlayerLeave = function(p)
    killPlayer(p)
    p.avatar:RemoveFromParent()
end

Client.AnalogPad = function(dx,dy)
    if not Player.init then initPlayer(Player) end
    cameraOrbit.LocalRotation.Y = cameraOrbit.LocalRotation.Y + dx * 0.01
end

Client.Tick = function(dt)
    if gameState == GAME_STATES.Running then
        if not Player.alive then return end
        gameUpdate(dt)
    end
end

Client.DidReceiveEvent = function(e)
    -- get event sender
    local p = e.Sender
    
    if e.action == "setGameState" then
        setGameState(e.state)
    elseif e.action == "countdown" then
        updateCountdown(e.countdown)
    elseif e.action == "walls" then
        local wallsData = JSON:Decode(e.walls)
        for _, w in ipairs(wallsData) do
            spawnWall(w)
        end
    elseif e.action == "bulletSpawned" then
        spawnBullet(p, e.isSpecial)
    elseif e.action == "died" then
        killPlayer(p)
    elseif e.action == "clearLine" then
        clearPlayerLine(Players[e.player])
    elseif e.action == "endGame" then
        endLabel.Text = e.text
        endLabel:Add()
    end
end

Client.Action2 = function()
    if not Player.alive then return end
    if gameState == GAME_STATES.Running then
        spawnBullet(Player, false)
    end
end

Client.Action3 = function()
    if not Player.alive then return end
    if gameState == GAME_STATES.Running then
        spawnBullet(Player, true)
    end
end

Client.AnalogPad = function(dx,dy)
    if not Player.init then initPlayer(Player) end
    cameraOrbit.LocalRotation.Y = cameraOrbit.LocalRotation.Y + dx * 0.01
end

-------------------
-- INITIALIZATION
-------------------
function initPlayer(p)
    if p.init then return end
    p.color = PLAYER_COLORS[math.floor(p.ID % #PLAYER_COLORS) + 1]
    p.Physics = false
    p.IsHidden = true
    World:AddChild(p, true) -- keep world

    p.avatar = spawnPlayerShip(p)
    World:AddChild(p.avatar)
    if p == Player then
        Camera:SetModeFree()
        cameraOrbit = Object()
        Player:AddChild(cameraOrbit)
        cameraOrbit:AddChild(Camera)
        Camera.LocalPosition = { 0, 15, -40 }
        Camera.LocalRotation = { 0.35, 0, 0 }
    end

    p.init = true
end

-------------------
-- SPAWNERS
-------------------
function spawnPlayer(p)
    if not p.init then initPlayer(p) end

    p.Position = getPlayerPosition(p.ID)
    p.avatar.Position = p.Position
    p.alive = true
    p.avatar:ClearTextBubble()

    -- add race start
    if p.start == nil then
        local start = Shape(Items.minadune.race_start)
        start.Position = Number3(p.Position.X, 0, 0)
        start.Scale = Number3(2, 0.5, 2)
        p.start = start
        World:AddChild(start)
    end
    -- add race road
    local road = MutableShape()
    road:AddBlock(COLOR_BLACK, 0, 0, 0)
    road.Scale = Number3(3, 0.5, 300) * Map.LossyScale
    road.Position = Number3(p.Position.X - 7.5, -2.5, -150 * Map.LossyScale.Z)
    World:AddChild(road)
    p.road = road

    -- if first or last player: add road plots
    local idx = getPlayerIndex(p.ID)
    local plotsSpawnSides = {}
    if idx == 1 then table.insert(plotsSpawnSides, -1) end
    if idx == #Players then table.insert(plotsSpawnSides, 1) end
    
    for _, side in pairs(plotsSpawnSides) do
        for i = -PLOTS_N_HALF, PLOTS_N_HALF do
            local plot = MutableShape()
            plot:AddBlock(COLOR_BLACK, 0, 0, 0)
            plot.Scale = Number3(0.5, 2, 0.5) * Map.LossyScale
            local x = p.Position.X + side * 2 * Map.LossyScale.X
            if side == -1 then x = x - 0.5 * Map.LossyScale.X end
            plot.Position = Number3(x, -1, i * PLOTS_SPACING * Map.LossyScale.Z)
            World:AddChild(plot)
            table.insert(roadPlots, plot)
        end
    end
end

function spawnPlayerShip(p)
    local ship = Shape(Items.minadune.spaceship)
    ship.LocalRotation.Y = math.pi

    -- Add light on ship
    local l = Light()
    l.Type = LightType.Spot
    ship:AddChild(l)
    l.LocalRotation = { math.pi / 2, 0, 0 }
    l.Angle = 0.65
    l.Range = 100
    l.Hardness = 0.75
    l.Color = Map.LocalPalette[p.color].Color

    return ship
end

function spawnWall(wallData)
    local wall = MutableShape()
    wall.isSpecial = wallData.isSpecial
    wall:AddBlock(wallData.isSpecial and COLOR_RED_DARK or COLOR_GRAY, 0, 0, 0)
    wall.Scale = Number3(3, 3, 0.5) * Map.LossyScale
    wall.Position = Number3(wallData.pos[1], wallData.pos[2], wallData.pos[3]) + Number3(-1.5, -0.5, wallData.d) * Map.LossyScale
    wall.Physics = false
    World:AddChild(wall)
    if walls[wallData.pID] == nil then walls[wallData.pID] = {} end
    table.insert(walls[wallData.pID], wall)
end

function spawnBullet(p, isSpecial)
    if #bullets == 3 then return end

    local bullet = MutableShape()
    bullet.isSpecial = isSpecial
    bullet.pID = p.ID
    bullet.Physics = false
    bullet:AddBlock(isSpecial and COLOR_RED or COLOR_WHITE, 0, 0, 0)
    bullet.Scale = 1.5
    World:AddChild(bullet)
    bullet.Position = p.Position - Number3(0.5, 0.5, 0)
    bullet.Forward = p.Forward
    table.insert(bullets, bullet)
    if p == Player then
        sendEventBulletSpawned(isSpecial)
    end
end

---------------
-- GAME
---------------
function killPlayer(p)
    p.alive = false
    p.avatar:TextBubble(p.Username .. " ☠")

    clearPlayerLine(p)
end

function clearPlayerLine(p)
    bulletIDsToRemove = {}
    for i, b in pairs(bullets) do
        if b.pID == p.ID then
            b:RemoveFromParent()
            table.insert(bulletIDsToRemove, i)
        end
    end
    for _, i in pairs(bulletIDsToRemove) do
        table.remove(bullets, i)
    end
    if walls[p.ID] ~= nil then
        for _, w in pairs(walls[p.ID]) do
            w:RemoveFromParent()
        end
        walls[p.ID] = nil
    end
end

-------------------
-- UI
-------------------
function updateCountdown(countdown)
    if countdown == 0 then
        countdownLabel:Remove()
        -- remove "start race" player child objects
        for _, p in pairs(Players) do
            if p.start ~= nil then
                p.start:RemoveFromParent()
                p.start = nil
            end
        end
    elseif countdownLabel ~= nil then
        countdownLabel.Text = ASCII_NUMBERS[countdown]
        countdownLabel:Add()
    end
end

---------------
-- GAME STATE
---------------
function setGameState(newState)
    if newState == GAME_STATES.Waiting then
        waitingLabel.Text = "Waiting for more players..."
        endLabel:Remove()

        for _, p in pairs(roadPlots) do
            p:RemoveFromParent()
        end
        roadPlots = {}
    elseif newState == GAME_STATES.Starting then
        if waitingLabel ~= nil then
            waitingLabel.Text = "Ready?"
        end

        Camera.LocalPosition.Y = 20
    
        for _, p in pairs(Players) do
            spawnPlayer(p)
        end
    elseif newState == GAME_STATES.Running then
        updateRemainingPlayers()
    end

    gameState = newState
end

function updateRemainingPlayers()
    local nPlayers = 0
    for _, p in pairs(Players) do
        if p.alive then
            nPlayers = nPlayers + 1
        end
    end
    if waitingLabel ~= nil then
        waitingLabel.Text = "Remaining players: " .. nPlayers
    end
end

function updatePlayerWalls(dt, p)
    if not p.init then return end
    if not p.alive then return end

    if walls[p.ID] ~= nil then
        for _, w in pairs(walls[p.ID]) do
            w.Position = w.Position + p.Backward * shipSpeed * dt
            if collides(p.avatar, w) then
                sendEventDied()
            end
        end
    end
end

function updateBullets(dt)
    for i, b in ipairs(bullets) do
        b.Position = b.Position + b.Forward * BULLET_RELATIVE_SPEED * dt

        -- if hits wall: destroy wall and bullet
        local destroyed = false
        if walls[b.pID] ~= nil then
            for j, w in pairs(walls[b.pID]) do
                if collides(b, w) then
                    if b.isSpecial == w.isSpecial then
                        if Player.playSolo then
                            score = score + 1
                            waitingLabel.Text = "Score: " .. score
                        end
                        w:RemoveFromParent()
                        table.remove(walls[b.pID], j)
                    end
                    b:RemoveFromParent()
                    table.remove(bullets, i)
                    destroyed = true
                end
            end
        end

        if not destroyed then
            -- if too far in front: destroy bullet
            if (b.Position - Player.Position).Length > 300 then
                b:RemoveFromParent()
                table.remove(bullets, i)
            end
        end
    end
end

function updateRoadPlots(dt)
    for _, p in pairs(roadPlots) do
        p.Position = p.Position + Player.Backward * shipSpeed * dt
        if p.Position.Z < -PLOTS_N_HALF * PLOTS_SPACING * Map.LossyScale.Z then
            p.Position.Z = PLOTS_N_HALF * PLOTS_SPACING * Map.LossyScale.Z
        end
    end
end

function gameUpdate(dt)
    for _, p in pairs(Players) do
        updatePlayerWalls(dt, p)
    end
    updateBullets(dt)
    updateRoadPlots(dt)
end

---------------
-- EVENTS
---------------
sendEventBulletSpawned = function(isSpecial)
    local e = Event()
    e.action = "bulletSpawned"
    e.isSpecial = isSpecial
    e:SendTo(OtherPlayers)
end

sendEventDied = function()
    local e = Event()
    e.action = "died"
    killPlayer(Player)
    updateRemainingPlayers()
    e:SendTo(OtherPlayers)
    e:SendTo(Server)
end

-------------------
-- SERVER
-------------------
Server.Tick = function(dt)
    if serverGameState == GAME_STATES.Waiting then
        if nPlayers >= MIN_PLAYERS then
            serverSetGameState(GAME_STATES.Starting)
        end
    end
    if serverGameState == GAME_STATES.Starting or serverGameState == GAME_STATES.Running then
        if nPlayers < MIN_PLAYERS then
            print("All the other players left. Waiting for a new player...")
            serverSetGameState(GAME_STATES.Waiting)
        end
    end
end

Server.DidReceiveEvent = function(e)
    if e.action == "died" and serverGameState == GAME_STATES.Running then
        if not e.Sender.alive then return end
        e.Sender.alive = false
        local playersAlive = {}
        for _, p in pairs(Players) do
            if p.alive then
                table.insert(playersAlive, p)
            end
        end
        local nPlayersAlive = #playersAlive
        if MIN_PLAYERS > 1 then
            if nPlayersAlive == 0 then
                serverEndGame("Tie")
            end
            if nPlayersAlive == 1 then
                local winner = playersAlive[1]
                serverEndGame(winner.Username .. " won!")

                local e = Event()
                e.action = "clearLine"
                e.player = winner.ID
                e:SendTo(Players)
            end
        else
            serverEndGame("Game over!")
        end
    end
end

Server.OnPlayerJoin = function(p)
    nPlayers = nPlayers + 1
end

Server.OnPlayerLeave = function(p)
    nPlayers = nPlayers - 1
    p.alive = false
end

function serverSetGameState(newState)
    local e = Event()
    e.action = "setGameState"
    e.state = newState
    e:SendTo(Players)

    if newState == GAME_STATES.Starting and serverGameState == GAME_STATES.Waiting then
        for _, p in pairs(Players) do
            p.alive = true
        end

        -- reset ship speed to default
        shipSpeed = SHIP_SPEED

        local countdown = 3 -- in seconds
        local e = Event()
        e.action = "countdown"
        local serverUpdateCountdown
        serverUpdateCountdown = function()
            e.countdown = countdown
            e:SendTo(Players)
            countdown = countdown - 1
            if countdown > -1 then
                Timer(1, serverUpdateCountdown)
            else
                Timer(2, serverSendWalls)
                serverAcceleratorTimer = Timer(5, true, function()
                    shipSpeed = shipSpeed + 10
                    minSpawnTime = minSpawnTime - 0.5
                    if minSpawnTime < 1 then minSpawnTime = 1 end
                    maxSpawnTime = maxSpawnTime - 0.5
                    if maxSpawnTime < 2 then maxSpawnTime = 2 end
                end)
                serverSetGameState(GAME_STATES.Running)
            end
        end
        serverUpdateCountdown()
    elseif newState == GAME_STATES.End and serverGameState == GAME_STATES.Running then
        Timer(3, function()
            serverSetGameState(GAME_STATES.Waiting)
        end)
    end

    serverGameState = newState
end

function serverSpawnWalls()
    local wallsData = {}
    for _, p in pairs(Players) do
        local pos = getPlayerPosition(p.ID)
        for i = 1, math.floor(randomBetween(1, 3)) do
            table.insert(wallsData, {
                pID = p.ID,
                isSpecial = math.random() < 0.3,
                pos = { pos.X, pos.Y, pos.Z },
                d = randomBetween(65, 90) + (i - 1) * 40,
            })
        end
    end
    return wallsData
end

function serverSendWalls()
    local e = Event()
    e.action = "walls"
    e.walls = JSON:Encode(serverSpawnWalls())
    e:SendTo(Players)

    serverSpawnTimer = Timer(randomBetween(minSpawnTime, maxSpawnTime), serverSendWalls)
end

function serverEndGame(text)
    local e = Event()
    e.action = "endGame"
    e.text = text
    e:SendTo(Players)

    serverSpawnTimer:Cancel()
    serverAcceleratorTimer:Cancel()

    serverSetGameState(GAME_STATES.End)
end

-------------------
-- MISC
-------------------
function getPlayerIndex(id)
    local idx = 1
    for _, p in pairs(Players) do
        if p.ID == id then return idx end
        idx = idx + 1
    end
    return idx
end

function getPlayerPosition(id)
    local idx = getPlayerIndex(id)
    local x = 3 * (idx - 1)
    return Number3(x, 1.5, 0) * Map.LossyScale
end

function randomBetween(a, b)
    return math.random() * (b - a) + a
end

function collides(shape1, shape2)
    local halfSize1 = (
        shape1.Width > shape1.Depth
        and shape1.Width * 0.5
        or shape1.Depth * 0.5) * shape1.LocalScale.X
    local halfSize2 = (
        shape2.Width > shape2.Depth
        and shape2.Width * 0.5
        or shape2.Depth * 0.5) * shape2.LocalScale.X
    local shape1Min = Number3(
        shape1.Position.X - halfSize1,
        shape1.Position.Y,
        shape1.Position.Z - halfSize1)
    local shape1Max = Number3(
        shape1.Position.X + halfSize1,
        shape1.Position.Y + shape1.Height * shape1.LocalScale.X,
        shape1.Position.Z + halfSize1)
    local shape2Min = Number3(
        shape2.Position.X - halfSize2,
        shape2.Position.Y,
        shape2.Position.Z - halfSize2)
    local shape2Max = Number3(
        shape2.Position.X + halfSize2,
        shape2.Position.Y + shape2.Height * shape2.LocalScale.X,
        shape2.Position.Z + halfSize2)
    if shape1Max.X > shape2Min.X and
        shape1Min.X < shape2Max.X and
        shape1Max.Y > shape2Min.Y and
        shape1Min.Y < shape2Max.Y and
        shape1Max.Z > shape2Min.Z and
        shape1Min.Z < shape2Max.Z then
        return true
    end
    return false
end