Setting up matching with lobbies

In this part, we'll learn to set up the matchmaking system.

Let's review the four tasks we have to do for our players to find each other in the online game:

  1. (DONE) To let players set a username.

  2. Let them choose a cluster.

  3. Let them create or list lobbies.

  4. Define matchmaking rules that determine which players can see which lobbies.

Let's work on the remaining three tasks.

There are two ways to do matchmaking: either use the automated matchmaker, or use lobbies.

In this tutorial, we'll use lobbies as they are a much more common way to get people to play together.

See also

If you want to learn about automated matchmaking, please head to the Automated matchmaker page.

Open the script autoloads/game_state.gd once again.

Before we completely move on to point 2, let's load our database manager. Around the top of the game_state.gd file, write:

const DatabaseManager := preload("res://data/database_manager.gd")

This will let us create user profiles and store them in the database.

Getting the list of clusters

Let's start with getting the list of clusters from W4. Clusters are server locations that players can pick from. They are used to select the server that will host the lobby.

We can get the list of clusters by calling W4GD.matchmaker.get_cluster_list().

Let's write a new function, get_cluster_list(), to do that:

## Returns the list of clusters available as an Array of strings.
func get_cluster_list() -> Array[String]:
    last_error = ""
    var clusters: Array[String] = []
    # This call makes a request to get the cluster list from W4 Cloud.
    var result = await W4GD.matchmaker.get_cluster_list().async()
    if result.is_error():
        last_error = str(result.as_error())
        return clusters
    # We use Array.assign() to cast the result to an array of strings.
    clusters.assign(result.as_array())
    return clusters

This returns an array of strings representing the clusters

We find the await ...async() pattern again.

Two notes:

  1. When we wrote the login() function, we got the error like this: result.as_error().message. This is how you get an error message for a database request. Getting clusters is not a result from the database, so in this case we cannot count on message being present. For simplicity, we convert the error into a string to be able to read regardless of its content.

  2. When there's no error, the result is an array of strings. We obtain this with result.as_array(), but the array it provides us is not typed. We use the Array.assign() method to explicitly type the array to get better autocomplete when we use this later.

With that, we can get the list of available clusters.

Creating and interacting with lobbies

After selecting a cluster, we need players to create a lobby or join one.

Precisely, we will need:

  • A way to create lobbies.

  • A way to join lobbies.

  • A way to list all lobbies (so a player can pick one to join).

  • A way to leave lobbies.

  • A way to list all users in a lobby.

There's some interdependent code we need to write for that. To avoid having too many errors as we code, we'll write the code in an order that works around them: we'll start with the code to leave and set up lobbies, and then add the function to create them.

First of all, we'll need some way to keep a reference to the lobby the player is currently in. Let's add some code to the top of the file:

## Keeps track of the lobby the player is currently in.
var _lobby

Lobbies have a maximum player count. We want our game to have up to 12 players, so let's add a constant for that:

## Maximum number of players allowed in one lobby. This is an arbitrary number.
const MAX_PEERS := 12

We will subscribe to that lobby once it's joined or created, to get notified when other players join, or when the game starts.

If we join a lobby, but were already in a lobby, or if we create a lobby and were already in a lobby, we'll need to leave it first. Let's start with this simple function, which we'll use in other lobby-related functions:

## Leaves the current lobby.
##
## If no lobby was setup or joined, this doesn't do anything.
func leave_lobby():
    if _lobby == null:
        return

    await _lobby.leave().async()
    _lobby.unsubscribe()
    _lobby = null

We also need a way to setup a lobby after being joined or created. This function will:

  • Subscribe to the lobby updates (so when the lobby changes remotely, we get warned).

  • Allow the UI to refresh the user list by emitting the player_list_changed signal.

  • Allow the UI to know that you as a player have joined the game and the game will start soon with the signal started_joining_server.

Let's create the two signals at the top:

## Emitted when a player joins or leaves the lobby.
signal player_list_changed()

## Emitted when a game round is starting.
signal started_joining_server()

Then, lower down the script, add the _setup_lobby() function:

## Subscribes to lobby changes, and makes sure game can start upon receiving a server
## ticket.
##
## The lobby must have been initialized first, through join_lobby() or create_lobby()
func _setup_lobby() -> void:
    if not _lobby:
        assert(_lobby, "Lobby was not created before calling _setup_lobby().")
        return

    # Opens a socket on the real-time server to receive lobby updates.
    # We need to call this to receive updates when the lobby changes (players enter or leave the lobby).
    _lobby.subscribe()

    # When a player joins or leaves the lobby, you'll want to update the UI.
    # We emit the `player_list_changed` signal for that purpose.
    var _on_player_list_changed := func _on_player_list_changed(_player_id = null):
        player_list_changed.emit()
    _lobby.player_joined.connect(_on_player_list_changed)
    _lobby.player_left.connect(_on_player_list_changed)

    # When the lobby receives a server ticket, the game round can start, so we can have this player join the game.
    _lobby.received_server_ticket.connect(
        func _on_received_server_ticket(p_server_ticket) -> void:
            _join_game(p_server_ticket.ip, p_server_ticket.port, p_server_ticket.secret)
            started_joining_server.emit()
    )

A Server Ticket is a permission to enter the match that the matchmaker sends. It contains a secret that is necessary to join the game round.

We will not implement the _join_game() function right away. To get rid of the pesky error though, write its definition somewhere:

## Appends a client to a W4 session and opens a connection.
##
## Only called from clients.
func _join_game(ip_or_address: String, port: int, ticket_password: String) -> void:
    pass

Once all the players have joined the game and are ready, the match will start (we will complete this function in a few chapters, in Joining a game round).

Let's now add the function to create a lobby:

## Creates a lobby in the given cluster.
##
## If a lobby was previously joined, this leaves the lobby before creating the new one.
func create_lobby(cluster: String) -> bool:
    last_error = ""
    await leave_lobby()

    # In this project, we provide a class to save the player's data locally, in a file.
    # This way, we can keep their nickname between play sessions.
    var player_name := ProfileData.restore().player_name

    # We use the function we wrote earlier to store the player's username in the database.
    await DatabaseManager.set_own_username(player_name)

    var result = await W4GD.matchmaker.create_lobby(
        # We're using authoritative servers, so we need to use a dedicated server lobby.
        W4GD.matchmaker.LobbyType.DEDICATED_SERVER,
        {
            max_players = MAX_PEERS,
            # This is used to select a specific game server region.
            cluster = cluster,
            props = {
                lobby_owner_username = player_name
            }
        }
    # As always, we use the await ...async() pattern to wait for the result.
    ).async()
    if result.is_error():
        last_error = result.message.as_string()
        return false
    # The result is a Lobby object. We store it in the _lobby variable.
    _lobby = result.get_data()
    # Now we created a lobby, we can initialize it with our _setup_lobby() function.
    _setup_lobby()
    return true

You may notice a particular piece of code: ProfileData.restore().player_name. ProfileData is a custom resource we created for this tutorial to hold local player data.

We use the DatabaseManager.set_own_username() function we created before to ensure the local username is the same as the username on the database. If the user is new (and was never added to the database), this will create a new row for them.

The meat of this function is in W4GD.matchmaker.create_lobby(). We create a lobby, set the maximum amount of players, the cluster, and arbitrary properties: in this case, we store the username of the lobby's owner username.

The props argument could contain any data you want. You could store the game mode, the map, or any other data you want to share with the other players.

Here's an example:

W4GD.matchmaker.create_lobby(
    W4GD.matchmaker.LobbyType.DEDICATED_SERVER,
    {
        max_players = MAX_PEERS,
        cluster = cluster,
        props = {
            lobby_owner_username = player_name,
            custom_prop = "some value",
            # This can be used to select a specific *fleet* based on its labels.
            gameServerSelectors = [{
                matchLabels = {
                    "game-mode": "battle-royale",
                },
            }],
        }
    }
)

Lobbies get attached to "fleets", which we'll describe later in this page. The gameServerSelectors property can be set to attach the lobby to a specific fleet.

See also

In this tutorial, we won't use automated matchmaking rules, since we use Lobbies. But if you are curious about how to set up global constraints for automatic matchmaking, check out the Matchmaking profiles page.

Querying lobbies

Now that we can create lobbies, let's write code to query them, so that we can list lobbies to players.

## Returns a list of all available lobbies.
func get_lobbies() -> Array:
    last_error = ""
    var lobbies := []

    # Finds a list of lobbies that have more than one player, and that are open.
    # This function takes one argument: a dictionary representing our query.
    var result = await W4GD.matchmaker.find_lobbies({
        # If this is true, the data returned returns the number of players in each lobby.
        include_player_count = true,
        # If this is true, the result will only return lobbies joined by the current player.
        only_my_lobbies = false,
        # This is a dictionary of constraints that the queried lobbies must match.
        # The state value must be one of W4GD.matchmaker.LobbyState.
        constraints = {
            "state": [W4GD.matchmaker.LobbyState.NEW],
            "player_count": {
                op = ">",
                value = 0,
            },
        },
    }).async()

    if result.is_error():
        last_error = str(result.as_error())
        return lobbies

    for lobby_info in result.get_data():
        var lobby_data = await W4GD.matchmaker.get_lobby(lobby_info["id"], false).async()
        var lobby = lobby_data.get_data()
        lobbies.append(lobby)

    lobbies.sort_custom(
        func(l1, l2) -> bool:
            return l1.created_at > l2.created_at
    )
    return lobbies

In this function, we query all lobbies that have at least one player, are open and have the state NEW. Once a lobby runs, its state changes from NEW to SEALED, and no new players can be added to it. Only the lobby's creator can set a lobby as SEALED, which signals the start of a game round.

Once we obtained those lobbies, we sort them by date using the Array.sort_custom() method and return the list.

Note that you could add more constraints. Assuming that, in our database, we have a user_rank table with a rank column, we could do:

W4GD.matchmaker.find_lobbies({
    # ...
    constraints = {
        # ...
        "player_count": {
            # ...
        },
        "user_rank.rank": {
                "value": [0, 3],
                "op": "between"
        }
    },
})

See also

For a complete list of supported operators, see List of all constraint operators.

Joining lobbies

With the code we've written so far, we can create, list, and leave lobbies.

Let's make a function that allows players to join one now! This one most notably calls W4GD.matchmaker.join_lobby().

The start and end of the function is otherwise similar to the create_lobby() function:

## Joins a lobby.
##
## If a lobby was previously joined, we leave it first.
func join_lobby(lobby_id: String) -> bool:
    last_error = ""
    await leave_lobby()

    # As we've mentioned before, we use a custom class to store the player's username between game sessions.
    # We use it here to set the username in the database.
    await DatabaseManager.set_own_username(ProfileData.restore().player_name)

    var result = await W4GD.matchmaker.join_lobby(lobby_id, false).async()

    if result.is_error():
        last_error = result.message.as_string()
        return false
    _lobby = result.get_data()
    _setup_lobby()
    return true

Finally, let's have function that add and remove players when they join and leave.

Towards the top of the file, add:

## Names for remote players in [peer_id: player_name] format.
var players := {}

We will also need a signal for when a player leaves the game in the middle of play:

## Emitted when a player left or got disconnected.
signal player_left(peer_id: int)

Let's create the functions:

## Called when a player joins the lobby. Adds them to the list of players.
func _on_player_joined(player) -> void:
    players[player.peer_id] = player.info['player_name']


## Called when a player leaves the lobby. Removes them from the list of players.
func _on_player_left(player) -> void:
    # When running a game round, the Level scene will be instantiated and the Level node will appear in the game's node hierarchy.
    # If so, it means the player left the game in the middle of play, so we emit the `player_left` signal.
    if has_node("/root/Level"):
        player_left.emit(player.peer_id)
    # Otherwise, it's not necessary.
    players.erase(player.peer_id)

At the moment they're not used anywhere. We will wire them in _ready() towards the end of the tutorial.

We're almost done with matchmaking! We'll just add two convenience functions:

## Returns the current lobby's name.
##
## The lobby must have been initialized first, through join_lobby() or create_lobby().
func get_lobby_id() -> String:
    if not _lobby:
        assert(_lobby, "lobby was not created before requesting id")
        return ""
    return _lobby.id


## Returns a list of all players in the lobby.
##
## The lobby must have been initialized first, through join_lobby() or create_lobby().
func get_lobby_players() -> Array[String]:
    if not _lobby:
        assert(_lobby, "lobby was not created before requesting players")
        return []
    return _lobby.get_players()

These utility functions will allow UI scenes to easily access that information without directly accessing the pseudo-private _lobby variable, which may be null.

Finally, we can add a little extra utility function to get a player name from their id:

## Returns the player name, as saved in the database
func get_player_name(player_id: String) -> String:
    return await DatabaseManager.get_username(player_id)

Congrats! Take a moment to re-read all of this and take a breather. Next up, we'll actually be hosting a game!