Moud

Servers and games#

Most games are more than one world. A round based game has a lobby where players gather and an arena where the match is played. A role playing game has a town and the dungeons you travel to from it. Moud keeps each world in its own place, and groups the places into a game. This page covers how a game says which places it has, how a script finds out where it is running, and how it sends players from one place to another.

Three words come up everywhere here:

  • A place is one world with its own scripts, the folder with a place.toml that you open in the editor.
  • A game is one or more places that belong together, such as a lobby and the arena it sends players to. Players find the game, not the place.
  • A server is one running copy of one place. Ten matches of the arena at once are ten servers of the same place. A server is never called an instance, because an instance is something in the tree.

A game runs either on one server you start yourself, or on a fleet: moud fleet, the program that starts the servers of your game on your own machine and lists the game on the Moud catalog. See Running a fleet. Only a fleet can move players between servers, because only a fleet has other servers to move them to.

Scripts are written the same way for both. On one server, moving players, reserving a server and listing servers still check what they are given, and then fail with an error that says they need a fleet. A script that uses them works unchanged once a fleet runs it.

A game and its places#

A [game] section in place.toml says which game the place is part of. Here is the lobby of a game called tick-tock, which also has an arena:

toml
id = "lobby"

[game]
id = "tick-tock"
start = "lobby"
places = ["lobby", "arena"]
  • id under [game] names the game. Leave it out and the game is named after the place.
  • places lists the ids of every place of the game, this one included.
  • start is the place players arrive in when they press Play. It must be in places, and is the first of them when left out.

The arena's place.toml has its own id = "arena" and the same [game] section, so either place can be opened and published on its own and still knows the whole game.

A place without [game] is a game of its own, with itself as its only place. An unknown setting, a start that is not in places, or a places that leaves this place out is an error.

The places of one game sit side by side, a folder each, named by their ids:

tick-tock/
  lobby/place.toml
  arena/place.toml

That is the layout a fleet runs, and it is how a player's game finds the place it moves to. When a teleport sends a player to arena, their game looks for the arena folder next to the place it is running. When it is not there, the move stops and the player is told the game is missing that place.

Which server this is#

A script sometimes needs to know where it is running: to print it in a log, to behave differently in the lobby and the arena when both share a module, or to tell a private match from a public one.

game.serverId this server, a text id that stays the same until it stops, through every reload
game.placeId this place, its id in place.toml
game.gameId the game it belongs to, [game] id
game.reservedCode the code of a reserved server, nil on any other

All four are read on the server. Reading one in a LocalScript is an error.

luau
-- server
print("running", game.placeId, "of", game.gameId, "on", game.serverId)

if game.reservedCode ~= nil then
    print("this is a private match")
end

game.serverId is new each time the server starts, so it tells two runs apart in a log. It is not a name to keep in a store, since the server it names is gone after a restart. Under a fleet it is the id the fleet and the catalog list the server by, a short text like c874-45f5-9a1e.

When the server stops#

A server stops when you stop it, and under a fleet also when it has been empty for a while or when the fleet itself stops. Anything you keep only in memory, such as a player's score for the round, is lost with it.

game:bindToClose(handler) runs as the server stops, while the players are still connected, so it is the place to save each of them:

luau
-- server
game:bindToClose(function()
    for _, player in game.players:list() do
        scores:set(player.id, wins[player.id] or 0)
    end
end)

A handler may wait, and the server waits for every handler to return, for up to 30 seconds in all. See When the place stops.

Moving players#

game:teleport sends players from this server to another one. You call it on the server with a list of players, where they go, and optionally some data to take with them:

luau
-- server
game:teleport({ player }, "arena")                                -- a server of the arena
game:teleport(party, servers:reserve("arena"), { mode = "ranked" })
game:teleport({ player }, { server = someServerId })             -- that exact server
  • The first argument is a list of Player, at least one. Everyone in it goes together, to the same server.
  • The second is where they go. A place id from [game] places sends them to a server of that place with room for all of them. A reserved server from servers:reserve sends them to that private server. { server = id } sends them to the server with that game.serverId, such as one picked from servers:list.
  • The third is optional. It is what may be saved in a store: text, numbers, flags, and lists and tables of those, at most 16 KB.

Naming a place that is not in the game is an error that lists the ones that are, and { server = game.serverId } is an error, since the players are already there.

What the player sees#

Under a fleet, game:teleport waits while the fleet finds a server with room for all the players, or starts one, which takes about half a minute. It returns once each player has been sent there.

The player's game shows its loading screen while it moves, and the loading script of the place they go to takes over from there.

Because game:teleport waits, call it inside task.spawn when the script should keep going in the meantime.

When it fails#

A move can fail: there is no place by that name, the server you named has closed or is full, you passed a reserved server's id instead of its code, or the fleet is already running as many servers as its settings allow. game:teleport then raises an error, which a script catches with pcall and handles, for instance by telling the player:

luau
-- server
local function sendToArena(player)
    task.spawn(function()
        local ok, why = pcall(function()
            game:teleport({ player }, "arena", { mode = "ranked" })
        end)
        if not ok then
            print("could not move", player.name, why)
        end
    end)
end

On one server, once its arguments check out, game:teleport fails with an error that says it needs a fleet.

Arriving#

On the server the players arrive at, game.players.arrived fires with each player and the data the sending server passed:

luau
-- server, in the arena
game.players.arrived:connect(function(player, data)
    seat(player, data and data.mode)
end)

It fires as the player joins, and only for a player who came through a teleport, so a place can tell players who were sent there from players who came in another way. A player who came from the Play button gets game.players.joined alone. A teleported player gets joined as well, and arrived right after it, so code that sets up every player stays in joined. On one server arrived never fires.

The data goes from server to server through the catalog, never through the player's game, so it is what the sending server wrote and a player cannot change it. It is still not where coins live: a player's coins are in their store, and the data says which match they came for.

Reserved servers#

A party that queues together wants a match of its own, where nobody else can wander in. That is a reserved server: a server of a place that only a teleport with its code can enter.

luau
-- server, in the lobby
local match = servers:reserve("arena")     -- { code = "...", place = "arena" }
game:teleport(party, match, { mode = "ranked" })

servers:reserve(place) gives you a code for a new server of that place. Nothing starts yet: the server starts when the first teleport with the code arrives, and on it game.reservedCode is that code. Keep the code to send more players to the same match later, such as a player who disconnected and wants back in.

Like any server, a reserved one closes once it has been empty for a while. The next teleport with the same code starts a fresh one.

Matchmaking#

A lobby with one server can pair its own players and reserve an arena for them, as above. Once the game has ten lobbies, two players looking for a duel are probably on different ones, and neither lobby sees the other. Matchmaking moves the waiting to the catalog: every lobby puts its players into the same named queue, the catalog picks who plays together, reserves a server of the arena for them and sends them there, whichever lobby each one was on.

A queue is opened by name with the rules its matches follow:

luau
-- server, in the lobby
local duel = matchmaking:queue("duel", {
    place = "arena",
    teams = 2, teamSize = 1,
    skill = { range = 50, widen = 100, every = 10 },
    timeout = 120,
})

duel.found:connect(function(match)
    for _, entry in match.players do
        print(entry.name, "plays on team", entry.team)
    end
end)

duel.timedOut:connect(function(players)
    for _, player in players do
        print(player.name, "waited two minutes and found nobody")
    end
end)

game.players.joined:connect(function(player)
    local rating = ratings:get(player.id) or 1000
    duel:add({ player }, { skill = rating, region = "eu" })
end)
  • place is where matches are played. Each match gets a reserved server of it of its own.
  • teams and teamSize say how big a match is: here two teams of one, a duel. A match is always exactly teams * teamSize players.
  • skill keeps players of a similar rating together. Two players can meet when their ratings are range apart or less, and the window grows by widen every every seconds a player waits, so nobody waits forever for a perfect opponent. Here a player rated 1000 meets 950 to 1050 at once, 850 to 1150 after ten seconds, and so on.
  • timeout is how many seconds a player waits before they leave the queue on their own, 300 when left out.

duel:add(players, attributes) puts players in the queue. The players of one call are a group: they always land in the same match, on the same team, so a party queues by passing all of its members. add waits while the catalog takes them, so it goes inside task.spawn in code that must not wait. The attributes are optional:

skill a number, compared as above
region text such as "eu". Players who give one only meet players of the same region
mode text such as "ranked". Players only meet players who gave the same mode, none included
party text. Two add calls with the same party, even from two lobbies, wait as one group
data what may be saved in a store, at most 1 KB, handed to the arena with the player

A game that wants matches of any size between two numbers gives minPlayers and maxPlayers instead of teams. A match then forms as soon as maxPlayers are waiting, or with minPlayers or more once the first of them has waited fillTime seconds, 15 when left out:

luau
local brawl = matchmaking:queue("brawl", { place = "arena", minPlayers = 4, maxPlayers = 12, fillTime = 30 })

Every lobby should open a queue with the same rules. The catalog keeps one set of rules for each queue, the one the latest add brought.

Waiting#

duel:status(player) tells the lobby where a player stands, for a "searching" panel. It answers nil when the player is not in the queue:

luau
-- server
task.spawn(function()
    while true do
        local status = duel:status(player)
        if status == nil then break end
        label.text = `Searching... {status.waited}s, {status.size} in the queue`
        task.wait(2)
    end
end)

position is the player's place in the line, 1 for the group that has waited longest, waited the seconds since they were added and size how many players the queue holds. duel:remove(player) takes the player out, with the rest of their group, which is what a Cancel button calls. A player who leaves the server leaves the queue too.

When a match is found#

The catalog looks at every queue once a second, starting with the group that has waited longest. When it has a match it starts a reserved server of the place, signs a ticket for each player, and tells each lobby that has players in it. On each of those lobbies found fires with the match, and right after, the engine moves the players there. There is no game:teleport to write.

The match is a table:

id an id for this match
queue, place the queue it came from and the place it is played in
code the reserved server's code, the same as game.reservedCode on it
mode, region when the players gave one
players a list of { id, name, team, skill, region, data }, one for each player
teams with teams, a list of player ids for each team

When the catalog cannot set the match up, because the fleet runs as many servers as it may or the server did not start, failed fires on each lobby with the players who were in it and the reason. They are out of the queue, and the lobby decides whether to add them again:

luau
duel.failed:connect(function(players, reason)
    print("no arena:", reason)
    task.wait(5)
    duel:add(players)
end)

timedOut fires the same way for players who waited past timeout.

In the arena#

On the server the match was started for, matchmaking:current() answers the match, and game.players.arrived fires for each player with the match as its data:

luau
-- server, in the arena
local match = matchmaking:current()
local teamOf = {}
for _, entry in match.players do
    teamOf[entry.id] = entry.team
end

game.players.arrived:connect(function(player, data)
    local spawn = if teamOf[player.id] == 1 then redSpawn else blueSpawn
    player:spawn(spawn.position)
end)

current is there before any player arrives, so the arena can build the map for the mode while the players are still loading. It is nil on a server no match started.

The queues are kept in the catalog's memory. If the catalog restarts, every queue is empty and nobody is told; a lobby that shows a searching panel finds out from status answering nil.

The server list#

A server browser shows the running servers of a place and lets the player pick one. servers:list(place, options) gives you that list, fullest first:

luau
-- server
for _, server in servers:list("arena", { tag = "ranked", limit = 20 }) do
    print(server.id, server.players, "/", server.maxPlayers, table.concat(server.tags, ", "))
end
  • Each entry has the server's id, its place, how many players are on it, its maxPlayers and its tags. Pass the id to game:teleport as { server = id } to send someone there.
  • limit is how many to list, 1 to 100.
  • tag keeps only the servers with that tag.
  • Reserved servers are never listed.

A server gives itself tags with servers:setTags. They are what servers:list filters on, and the server list on the game's page shows them too:

luau
-- server
servers:setTags({ "ranked", "eu" })

The tags replace the ones the server had: up to 8, each 1 to 32 letters. Set them again whenever they change, such as when a round switches mode.

servers:reserve and servers:list check the place first, and on one server then fail with an error that says they need a fleet. servers:setTags works on one server too, where nothing reads the tags.

Messages between servers#

messaging is the topic board described in Messages between scripts on one side. Under a fleet it reaches further: a message published on one server is heard on every server of the game. That is how one server tells all the others something happened, such as a world boss appearing.

On the server, a handler is also told which server published:

luau
-- server
messaging:subscribe("boss", function(message, from)
    print(message.name, "appeared on", from)
end)

messaging:publish("boss", { name = "Bahamut" })

from is the publishing server's game.serverId. Under a fleet, the message reaches every server of the game, on every fleet it runs on:

  • The server that published hears it straight away, inside publish, the same as on one server.
  • The other servers hear it a moment later, on their next frame, through the catalog.
  • A message is delivered at most once and in no promised order. Use it for news like "a boss appeared", not for anything that has to arrive, such as a purchase.
  • What goes to the other servers is a copy, so it has to be something a store could save: text, numbers, flags and tables of those, at most 1 KB once written out. Anything else is an error from publish, under a fleet only.
  • publish waits for the fleet to take the message, and raises an error a script can catch when the server is over its quota.

Who gets in#

A server under a fleet only lets in players the catalog sent. Each player brings a ticket: a short signed note that names the player, the server and how long it is good for. The catalog signs one when a player presses Play, and one for each player a teleport moves. The player's game carries it to the server, the server checks it, and each ticket lets its player in once.

A player who connects without a ticket, with one for another player or server, or with one that ran out or was already used, is turned away with a short reason. So players cannot skip the queue for a full server or walk into a reserved match by typing your address into Minecraft.

On one server there are no tickets, and anyone who can reach the server joins.

Friends and parties#

Players on Moud have friends and parties, and they live on the catalog, not in your game. A player joins a friend from the home, accepts an invite from the corner of the screen, or follows their party's leader onto your server. Your scripts see two things of that: the party a player came with, and a way to ask a player to bring their friends.

Inviting friends in#

game.players:promptInvite(player) opens the invite window on that player's screen. It lists their friends, online ones first, each with an Invite button. A friend they invite gets a notification with Join, in the Moud home or in whatever game they are playing, and Join puts them on this server while it has room. This is Roblox's game invite prompt, and like it the player picks who to invite: a script can open the window, it can never send an invite on its own.

A good moment to open it is when a game needs more people, such as a round that waits for players:

luau
-- server
local MIN_PLAYERS = 4

game.players.joined:connect(function(player)
    if #game.players:list() < MIN_PLAYERS then
        game.players:promptInvite(player)
    end
end)

Each player who joins a round that is still short of four gets the window, so the first ones in are the ones asked to bring friends.

  • The window opens once per call. A player who closes it is not asked again until a script calls it again, so tie it to a button or a moment rather than calling it every frame.
  • From a client script, game.players:promptInvite() opens it for the local player, which is what an "Invite friends" button in your own menu calls.
  • Only a game joined from Moud has friends to invite. In Studio and in a game exported as its own jar the call shows a short notice instead.

Players can always invite from Moud's own menu too: Esc in a game joined from Moud opens it, with Invite friends next to Resume and Leave game. A game whose client script takes Esc for its own pause screen keeps it, so give that screen an invite button.

Parties#

A party is a few friends who play together. When the leader joins a game, every member with Moud open joins the same server with them, each with a ticket of their own. A server without room for all of them takes the ones that fit, and the others are told so.

player.partyId is the party a player came with, nil when they came alone. Two players with the same partyId arrived together, which is what a team picker needs to keep friends on one side:

luau
-- server
local red = game.world:add("Team", { name = "red", teamColor = color(1, 0.25, 0.25) })
local blue = game.world:add("Team", { name = "blue", teamColor = color(0.25, 0.45, 1) })

local function smallestTeam()
    return #red:getPlayers() <= #blue:getPlayers() and red or blue
end

local function teamFor(player)
    if player.partyId then
        for _, other in game.players:list() do
            if other ~= player and other.partyId == player.partyId and other.team then
                return other.team
            end
        end
    end
    return smallestTeam()
end

game.players.joined:connect(function(player)
    player.team = teamFor(player)
end)

A player who came with a party looks for a party member already on a team and joins them. Everyone else, and the first of a party to arrive, goes to the smaller team.

  • The party is read when the player's ticket is checked, so it is the party they had as they joined. Someone who leaves the party mid-game keeps the old partyId until they join again.
  • partyId is an opaque text. Compare it, do not show it.
  • On one server, without a fleet, there are no tickets and partyId is always nil.

On one server and under a fleet#

Call On one server Under a fleet
game.serverId, game.placeId, game.gameId answered answered, the id is the fleet's
game.reservedCode nil the code on a reserved server, nil on the others
game:bindToClose runs as the server stops runs as the server stops, also when the fleet stops it for being empty
game.players.arrived never fires fires for each player a teleport brought
messaging heard by this server's own subscribers heard by every server of the game
game:teleport checks its arguments, then fails: it needs a fleet moves the players
servers:reserve, servers:list check the place, then fail: they need a fleet answered by the fleet
servers:setTags kept, nothing reads them listed by servers:list and the website
matchmaking:queue checks its rules opens the queue
queue:add, remove, status, matchmaking:current check their arguments, then fail: they need a fleet answered by the catalog
store kept by this server, see Saving shared by every server of the game, kept by the catalog
player.partyId nil the party the player joined with
game.players:promptInvite sends the window, which shows a notice outside Moud opens the invite window

Quotas#

Under a fleet, stores and messages go through the catalog, and each server has a budget a minute that grows with the players on it. A server with more players gets more calls, so a game that does a sensible amount per player never notices.

A minute, for each server
store calls 60, plus 10 for each player on the server
messages published 150, plus 60 for each player on the server
matchmaking calls 60, plus 10 for each player on the server

Every call to a store counts: a get, a set, a key list, a sorted read and each renewal of a session. An update counts twice, and twice again each time another server wrote first and it tries over.

A call over the budget raises an error a script can catch with pcall. The budget refills over the minute, so a server that went over gets its calls back a few at a time. On one server there are no quotas.