Teleports between rooms
teleportPlayers moves a group of players from the room they play in to another room of the same game, all of them together or none. Use it when one room is the wrong place for the next part of play, such as a lobby that sends a match’s worth of players to a fresh match room, or a portal in a hub that leads to a player’s world. Roblox’s TeleportService:TeleportAsync does the same.
To move a body inside a room, such as a character to a checkpoint, call teleport instead, as Player says. To run several contests in one room instead, such as courts in a park, give each Match a queue.
When to move players
Section titled “When to move players”A room holds the number of players its game’s maxPlayers sets. One room is enough when every player shares one place. Move players when the next part of play needs a room of its own:
- A match that starts from a lobby. A battle royale’s lobby holds 40 players. Once 30 wait, the lobby sends those 30 to a fresh arena room, private to them, and fills again. Every match runs in its own room, so the game is never capped at one room’s players.
- Back to the lobby as a match ends. The match sends its players to the lobby they came from, or to the fullest lobby that runs.
- A portal to a world. A hub sends a player to a world by its id, such as a friend’s island.
- Latecomers to a match that runs. The answer to a teleport names the room the players went to, so a lobby can send a later player to that room by its id.
A game names its kinds of room in defineRoom({ kinds }). Play always opens the first kind, so the lobby goes first, and a room of any other kind opens only when a teleport or a world asks for one. A kind may hold its own number of players, which a room of the kind holds in place of the game’s maxPlayers, such as a 16-player lobby beside 8-player arenas: defineRoom({ maxPlayers: 8, kinds: { lobby: { maxPlayers: 16 }, arena: {} } }).
Send players to another room
Section titled “Send players to another room”teleportPlayers(step, players, target, options) takes the room’s step, the players to move, where they go and an optional hint. It returns the request’s id, a number, and the platform answers on a later tick with PlayersTeleportedEvent. Call it from a system that runs in the room. A join hook’s step cannot call it, so a move as a player joins waits for the next step’s system. A game played alone has no other room to go to, so a call there throws.
The target is one of three:
| Target | Where the players go |
|---|---|
{ kind, mode } |
A room of the kind, which mode picks, as the next table says: a fresh room private to them when left out. |
{ worldId } |
The room that runs the world, which the platform starts where none runs. Each player enters by the player’s standing in the world. |
{ roomId } |
A room of the game that runs now and holds no world, such as the arena an earlier teleport filled. It is never the room the players are in. |
kind takes the kind’s object from the game’s kinds, such as roomKinds.arena, never its name as a string, so a typo is an error in your editor. An object that is not one of the kinds defineRoom lists throws, naming the fix. A kind whose rooms hold a world is refused as no-place: send players to such a room by its world’s id.
mode is one of TeleportRoomMode’s values:
mode |
Which room of the kind |
|---|---|
NewPrivate, default |
A fresh room private to the group, as a match a party enters together: Roblox’s reserved server. |
NewPublic |
A fresh public room, which Play fills and Browse lists as any other once the group is in. |
JoinOrCreatePublic |
The fullest public room of the kind that runs with a seat for every player, as Play fills one; a fresh public room only where none has one. |
JoinOrCreatePublic sends a match’s players back to a lobby that runs: { kind: roomKinds.lobby, mode: TeleportRoomMode.JoinOrCreatePublic }. Roblox’s TeleportAsync does the same with no reserved server. It never picks the room the players are in, a private room, or a room on an older version of the game.
A private room from NewPrivate takes nobody by itself: Play never fills it and Browse never lists it, so only a teleport by its roomId, or the link of a private room the group came from, sends anyone else there. It ends a minute after its last player leaves, as every room does. A player who drops comes back within the room’s hold, as Drops, lag and other builds says, and after the hold the room takes the player no more.
When the lobby itself is a player’s private room, the private room’s link moves with the group to the new room. A teleport by roomId into a player’s private room refuses anyone its owner banned, as not-allowed, and anyone its owner blocked who comes from outside that private room.
Read which room this is
Section titled “Read which room this is”A system reads the room it runs in with three reads:
readRoomId(step.world): the room’s own id, which a{ roomId }teleport takes, such as a lobby’s id it hands its match in the drop’s hint, so the match can send its players back to that lobby. Roblox’sgame.JobIdreads the same. Null on a page and in a game played alone.isRoomKind(step.world, roomKinds.lobby): whether the room runs that kind, so one scene’s systems behave as a lobby or as an arena.readPrivateRoom(step.world): the player’s private room it runs, or null, as A private room says.
Each reads true values from the room’s first step on, in every join hook and system: a room the platform hosts runs no step before the platform tells it its kind, its id and its private room. The game’s start runs before then, so read none of them there.
All or none
Section titled “All or none”A teleport moves every player it names or none of them. A room of a kind holds the kind’s own maxPlayers, else the game’s, so a group larger than that never fits, and the platform refuses it as full before it starts a room. A bot holds no seat for the platform to move, so a teleport that names one answers not-in-room: leave bots out of the group, as the sample below does. A dropped player whom the room still holds has no page to move, so the teleport answers not-in-room too, and the sample leaves such a player out. The platform checks each player against the place before it moves anyone, and a place with too few seats for the whole group refuses them all. A teleport that moved nobody leaves every player in the room, playing on as before, so your game decides what to do next, such as wait and try again.
The answer’s reason says why a teleport moved nobody, one of TeleportRefusal’s values:
reason |
Why nobody moved |
|---|---|
full |
The place has fewer free seats than the group has players. |
not-allowed |
A player may not enter the world: banned from it, or not a member of a private one. |
no-place |
No world, room or kind has that id or name, the kind’s rooms hold a world, or the room has ended. |
closing |
The world is closing for its owner’s wipe, delete or restore. |
not-in-room |
A player left the room, or dropped, before the teleport went. |
unavailable |
The platform did not answer, or no platform runs the place: a world under spawnite play with no --worlds, or any place under spawnite dev. |
The answer’s refused lists each player the place refused, by player id, with that player’s own reason, such as the one banned player in a party of four.
Read the answer
Section titled “Read the answer”PlayersTeleportedEvent answers each teleport on a later tick than the call, in the room alone; no page receives it. Its record carries the following fields:
request: the idteleportPlayersreturned, which ties the answer to the call.playerIds: the players the call named, by their player ids, asreadPlayerIdreads them.roomId: the room they move to, or null where nobody moved.reason: null where they move, or why nobody moved.refused: each player the place refused, and why.
Where the players move, the room lets each one go. It commits the player’s save, runs onPlayerLeave, and closes the page’s connection to this room with the sentence “You went on to another place”. The player’s seat moves to the new room, which loads the player’s save as any join does. The players arrive one at a time, each as the player’s page connects, in no set order, so count arrivals in the new room’s onPlayerJoin rather than read the order of playerIds. Until then the players are still in this room, so mark them, as the sample below does, rather than send them twice.
Where nobody moves, tell the players what happened if they would notice. The sample below keeps the reason on each player’s LobbyTrait, which every page receives, and the player’s page reads it with useTrait(usePlayer(), LobbyTrait)?.refusal.
Read where a player came from
Section titled “Read where a player came from”A hint tells the new room why a player came, such as the portal the player stepped through. List its name in the room’s joinHints, then pass it in the options: { hint: { name: "drop" } }. A hint may carry an id too, up to 980 characters, that names a thing, such as which gate. It names a thing and never holds state, which belongs in a save.
The new room reads it with readJoinHint(player) in onPlayerReady, which runs once the player’s save restored, so a saved position does not move the character back afterwards. teleportPlayers throws for a hint whose name joinHints does not list, or whose id is too long, before it sends anything. Codes and links says how a share link carries the same hints, and how to register the room so every hint’s name is typed.
A lobby that fills a fresh match room
Section titled “A lobby that fills a fresh match room”The following plugin gathers players in a lobby and sends a match’s worth of them to a fresh arena room, private to them. It marks each player it sends with the teleport’s id, so the next group leaves them out, and puts its own id in the drop’s hint, so the arena can send them back. Where the platform refuses a group, the lobby keeps the reason for the player’s page and puts the group back in line after a wait. The arena reads the hint each player arrives with:
import { addTrait, ConnectionState, ConnectionTrait, createQuery, definePlugin, defineRoom, defineSystem, defineTrait, isPlayer, isRoomKind, Phase, PlayersTeleportedEvent, PlayerTrait, readEach, readEvents, readJoinHint, readPlayerId, readRoomId, readTrait, Role, setTrait, teleportPlayers, updateEach, type AuthoritativeStep, type PlayerEntity,} from "@spawnite/engine/core";
// A battle royale's lobby and its arena, built as a creator's plugin is,// from the engine's public entry alone. Play seats players in a lobby// room. Once a match's worth of them wait there, the lobby sends them// together to a fresh arena room, private to them, and fills again. The// arena reads the hint they arrive with.
/** The game's kinds of room. Play opens the first, the lobby, and only a * teleport opens an arena. A teleport names a kind by its object. */export const roomKinds = { lobby: {}, arena: {} };
/** The game's room: every kind holds up to 40 players, so a lobby's match * always fits in a fresh arena. */export const room = defineRoom({ maxPlayers: 40, kinds: roomKinds, joinHints: ["drop"],});
/** What `matchmaking` takes. */export interface MatchmakingOptions { /** How many players one match takes. * @defaultValue 30 */ matchSize?: number; /** How long a refused group waits before the lobby sends it again. * @defaultValue 5 */ retrySeconds?: number;}
/** On each player in a lobby, which every page reads: the teleport the * player is in, 0 while the player waits; the tick from which a refused * player may be sent again; and why the last teleport moved nobody, a * `TeleportRefusal`, which the player's page shows. */export const LobbyTrait = defineTrait("lobby", { request: 0, retryAtTick: 0, refusal: "",});
/** On a player who arrived in an arena by the lobby's drop: the arena's * own systems drop the player's character from its ship, and send the * player back to the lobby the drop's hint names as the match ends. */export const DropShipTrait = defineTrait("dropShip", { lobby: "" });
const lobbyPlayers = createQuery(LobbyTrait);const connectedLobbyPlayers = createQuery(LobbyTrait, ConnectionTrait);// The group a teleport takes, kept here so a step makes no array.const group: PlayerEntity[] = [];
/** Sends the first `matchSize` waiting players to a fresh arena, once that * many wait. A dropped player, whom the room holds for a while, waits * out of the group: the platform moves no seat whose page is gone. */function teleportWaitingPlayers(step: AuthoritativeStep, matchSize: number) { group.length = 0; readEach( step.world, connectedLobbyPlayers, ([lobby, connection], player) => { if (group.length === matchSize || !isPlayer(player)) return; if (connection.state !== ConnectionState.Connected) return; if (lobby.request === 0 && lobby.retryAtTick <= step.tick) group.push(player); }, ); if (group.length < matchSize) return; // The drop's hint names this lobby, so the arena can send them back. const lobby = readRoomId(step.world); const request = teleportPlayers( step, group, { kind: roomKinds.arena }, { hint: { name: "drop", ...(lobby !== null && { id: lobby }) } }, ); for (const player of group) setTrait(step, player, LobbyTrait, { request, retryAtTick: 0, refusal: "", });}
/** Puts a refused group back in the lobby's line, after a wait. */function readAnswers(step: AuthoritativeStep, retrySeconds: number) { for (const { record } of readEvents(step, PlayersTeleportedEvent)) { const { reason, refused } = record; // Moved: the room lets each player go by its next checkpoint. if (reason === null) continue; // All or none: nobody in the group moved, so all of it waits. const retryAtTick = step.tick + Math.round(retrySeconds / step.deltaSeconds); updateEach(step, lobbyPlayers, ([lobby], player) => { if (lobby.request !== record.request || !isPlayer(player)) return; const playerId = readPlayerId(player); // The player's own reason where the place named the player. const own = refused.find((each) => each.playerId === playerId); lobby.request = 0; lobby.retryAtTick = retryAtTick; lobby.refusal = own?.reason ?? reason; }); }}
export const matchmaking = definePlugin( ({ matchSize = 30, retrySeconds = 5 }: MatchmakingOptions = {}) => ({ name: "matchmaking", // Recorded, so two calls with other options make two plugins. options: { matchSize, retrySeconds }, // The room alone moves players, so both systems run there alone. systems: [ defineSystem({ name: "readAnswers", phase: Phase.Rules, run: (step) => readAnswers(step, retrySeconds), }), defineSystem({ name: "teleportWaitingPlayers", phase: Phase.Rules, run: (step) => teleportWaitingPlayers(step, matchSize), }), ], // A bot holds no seat for the platform to move, so it stays out // of the line. onPlayerJoin: ({ step, player }) => { const isBot = readTrait(player, PlayerTrait)?.role === Role.Bot; if (isRoomKind(step.world, roomKinds.lobby) && !isBot) addTrait(step, player, LobbyTrait); }, // The hint is read once the player's save restored, as every // placement is, so a saved position does not move the character. onPlayerReady: ({ step, player }) => { const hint = readJoinHint(player); const isArena = isRoomKind(step.world, roomKinds.arena); if (isArena && hint?.name === "drop") addTrait(step, player, DropShipTrait, { lobby: hint.id ?? "" }); }, }),);Give the game the room and the plugin with defineGame({ name, start, room, plugins: [matchmaking()] }) from @spawnite/engine/core, as Set the room’s players and rates shows. Every room of the game runs the same scene and plugins, so a system reads which kind it runs in with isRoomKind(world, roomKinds.lobby). A lobby with fewer than matchSize players sends nobody, so a lobby that should start with fewer after a wait counts its own ticks and sends the group it has.
Send the players back to the lobby
Section titled “Send the players back to the lobby”The lobby above puts its own id in the drop’s hint, and the arena keeps it on each player’s DropShipTrait. As the match ends, the arena sends its players back to that lobby by its id. Where the lobby has ended or filled, the answer refuses them as no-place or full, and the arena sends them on to the fullest public lobby that runs, with JoinOrCreatePublic:
import { ConnectionState, ConnectionTrait, createQuery, isPlayer, readEach, requireRoomId, teleportPlayers, TeleportRoomMode, type AuthoritativeStep, type PlayerEntity,} from "@spawnite/engine/core";import { DropShipTrait, roomKinds } from "./matchmaking.ts";
// The trip back from the Teleports page's sample: as a match ends, the// arena sends its players back to the lobby they came from, by the id the// lobby put in the drop's hint, or to the fullest lobby that runs.
/** What `sendBackToLobby` takes. */export interface SendBackOptions { /** Whether any lobby that runs takes them, as after the one they came * from refused them as `full` or `no-place`. * @defaultValue false */ anyLobby?: boolean;}
const droppedPlayers = createQuery(DropShipTrait, ConnectionTrait);// The group the teleport takes, kept here so a call makes no array.const group: PlayerEntity[] = [];
/** Sends every connected player the arena dropped back to a lobby in one * teleport, and answers its id: to the lobby they came from, else to the * fullest public lobby with a seat for them all, a fresh one where none * has. Call it from a system in the arena as its match ends; * `PlayersTeleportedEvent` answers it as any teleport. */export function sendBackToLobby( step: AuthoritativeStep, { anyLobby = false }: SendBackOptions = {},): number { group.length = 0; let lobby = ""; readEach(step.world, droppedPlayers, ([drop, connection], player) => { if (!isPlayer(player)) return; if (connection.state !== ConnectionState.Connected) return; group.push(player); lobby ||= drop.lobby; }); return teleportPlayers( step, group, anyLobby || lobby === "" ? { kind: roomKinds.lobby, mode: TeleportRoomMode.JoinOrCreatePublic, } : { roomId: requireRoomId(lobby) }, );}The arena calls sendBackToLobby(step) from its own system as the match ends, and reads the answer in PlayersTeleportedEvent as the lobby does. A refusal with no-place or full calls it again with { anyLobby: true }. requireRoomId turns the id the hint carried as a string back into a room id, and throws for text that is no id. A player who arrived with no lobby in the hint, as one a game played alone sent, goes to any lobby.
Try it while you build
Section titled “Try it while you build”spawnite play start runs your game’s rooms on your machine as the platform runs them. Its room runs the first kind and keeps the list of rooms. A teleport to a kind or a room id starts or joins another room on your machine, and each page follows its player there, as a page does once the platform moves it. Every mode works, and the players’ saves are shared between the rooms, so a save written in the lobby is the one the arena loads.
Two flags open a page where a teleport would land:
--room-kind arenaruns the first room as that kind, so a page joins an arena with no lobby before it.--hint drop:lobby-1joins each page with that hint, its name then its id after a colon, as a teleport’s options set it, soreadJoinHintreads it inonPlayerReady.
Worlds in a playtest
Section titled “Worlds in a playtest”--worlds <file> seeds the playtest’s worlds, such as a player’s own island, a friend’s and a public one:
{ "worlds": [ { "id": "public", "name": "Public Island" }, { "id": "ada", "name": "Ada's Island", "owner": "Ada" }, { "id": "ben", "name": "Ben's Island", "owner": "Page 2", "members": ["Ada"] } ]}- The session’s room holds the first world, and each other world runs in a room of its own, started on its first join.
- An owner and members are named by the name each page joins under: the game’s own name for a player, or Page 2 on for
--pages. Each seat enters with its standing there. - A world with no owner is the creator’s, listed as a world Play fills is, so anyone enters it. A player’s world lets in its owner and members alone, and refuses anyone else as
not-allowed. - Each world keeps its own record, and each player’s save goes from world to world.
- A page’s
useWorldDirectory()lists the worlds the player owns, is a member of and entered, with who plays in each. Itsjoin({ worldId })and a room’steleportPlayersto{ worldId }move the page there, and itscreatemakes a world the player owns. - A page’s
useWorldBrowse()lists the listed worlds, with who plays in each. A creator’s world is listed and a player’s is not, unless it setsisListed. - A world’s
createdAt, in epoch milliseconds, is the time Browse dates it by. Without one, the playtest dates its worlds a second apart in the file’s order, the first the oldest. "friends": [["Ada", "Page 2"]]besideworldsmakes two players friends, so each reads the other infriendsOnlineandjoin({ friend })follows the other in.
A teleport to a world with no --worlds answers unavailable, and the room logs the flag to add. spawnite dev runs one room, so each teleport there answers unavailable on a later tick, and the room logs that teleportPlayers needs the platform.