Skip to main content
The live game

Not translated yet — showing English.

Rooms

A room is a game session; the platform doesn't care what hosts it. One abstraction covers a dedicated server per match, one big shared map split into logical layers, a master-client-hosted room, and a backend-hosted mini-game. Room internals are ours; you drive a room from outside.

When to use it

  • Your game has sessions (matches, lobbies, dungeons, races), and something must own their lifecycle, membership and reconnects.
  • You host on dedicated servers, a player's master-client or the backend itself, and need players routed there.
  • One shared map must run many logical sessions: layers, scoped by Visibility.
  • Players join mid-session and must see the current state: the room's state on arrival, then live traffic.
  • A dropped connection must not cost the seat: the template's grace window (45s in battle) resumes the same membership.
  • Skip it when a feature is purely request/response over records. Plain Data already covers it.

Who does what

ActorOn this page
room-ownerregisters rooms across the process; on one room instance — the per-instance admin interface: patches live config, kicks, locks, broadcasts, disposes
entry-validatoraccepts or rejects join requests with a code and a reason
room-visitorbrowses, joins with data, reconnects within grace window, leaves
spectatorjoins without contesting; receives broadcasts and live traffic
match-organizerreserves seats that count toward capacity; a reserve expires on the template's term (90s in battle)

At a glance

The battle template: capacity, tick, host kind, a named map, and the two seat windows
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Wherever it is authored, the pushed template is versioned and editable in the panel, so live-ops retunes a room type without an engine redeploy. Hosting rooms built from it is the same surface reached by a different role, and both an Unreal dedicated server and a master-client hold all of it — register, host several per process, patch live config, kick, publish, dispose.

The entry-validator hook: banned players rejected at the door, with a code and a reason
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

Runs off the engine

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.

Runs off the engine

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Client:

Browse CTF rooms by filter, join with loadout data, react to arrivals
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — runs as-is in Unity against the generated types
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

The model

A room is a group with rules, and it is not an entity. Membership comes from groups; its system state is platform-level, while the game's state lives in entities scoped to it. And no consumer code runs inside a room — in either authority mode.

What a room type declares.

DeclaresWhat it is
capacityin seats, and a seat is the unit of capacity separated from membership: it may be reserved before a join and held through inactivity. A reservation is time-limited, with a declared deadline after which the seat is freed without a join
visibilityenumerable · by name or code · hidden
creation modeone of three, and the on first join mode is obliged to declare an initialisation hook
two independent timeoutsthe idle timeout — how long a participant may be silent; and the empty-room TTL — how long a room with nobody in it survives
rejoin windowwithin it a return restores the same membership and the same seat, rather than making a new participant
authority modeour simulation or external authority, and there is no default
trust in a reported outcomefor external authority: accept it · check it with a hook · do not accept it. Again no default
behaviour when the host dropswait out the grace window · close the room · admit a replacement
map instance and world strataoptionally, which instance it occupies and which strata inside it
room-scoped entitieswhich of the studio's entities have the room's scope — expressed by a predicate, not by a new mechanism

The two machines.

OfStates
a roomcreated → open → closed → torn down, where torn down is terminal and closed means no new joins rather than gone
a membershipactive ⇄ inactive → departed, with departed terminal for that membership

What is true of every room.

AlwaysWhat it is
losing a connection and leavingare different events, and the window's outcome is observable: "returned" and "the window expired" are distinguishable, so a client is never left guessing which happened
a spectatoris not a degenerate participant: present, occupying no seat, and not in the roster addressed as "the players"
no in-room rolesa room owner is an actor holding a right (Access), not a rank in the member list
presencehas a history, the roster does not: who joined, dropped, returned and departed is kept; the roster's changes are not a second history
the interfaceis a specific room's: you address this room, not only its type
three axes, not twothe API spanning every room (browse, register, list); the per-room API any member calls (join, leave); and a per-instance admin interface — kick, lock, patch config, close, dispose on this room — gated to whoever holds the admin or host role for that one instance rather than to membership
one room type — one set of declarationsexternal authorityour simulationdedicated server · host keymaster client · room-ownerbackend · backend-service a match on a fleeta player's machinea mini-game
Who runs the tick: one room type, with its hosts grouped by authority mode

The two authority modes.

ModeWho runs the tick
our simulationour room implementation and its modules
external authoritya process running our SDK, in the room under an authoritative role: the studio's game server, or a player's client as the master-client

What the mode decides, and what it does not.

What it is
the lineis drawn by role, not by whose process it is. A dedicated server is the same client without the rendering; what separates it from a player's machine is trust, not construction — which is also why peer-to-peer needs no third mode, being a room in external-authority mode whose authority is a client host
what is identicalentry rules, presence, reconnect and every declaration, across all three. What differs is only which process holds the authority and how much of it that process is granted
the room does not moveno consumer code runs inside a room in either mode, and session and persistent state stay with us in both. A master-client is a member holding an authoritative role: the tick is computed there, the room does not live there

Hosting a room.

Registering rooms from the pushed template: an idempotency key each, several per process
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — as a master-client build — a client that registers the room holds the same host surface at runtime
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
AlwaysWhat it is
the handleis the same object the client tab uses: there is no host bootstrap and no server-only handle. It answers these calls because the actor's role includes them at runtime — a dedicated server or a master-client running under a host key (Access)
registrationtakes an idempotency key, because a timeout on it is otherwise unrecoverable: repeat the call with the same key and you get the same room back, not a second one nobody knows the address of

Entry, presence and reconnect.

seat reserved by Matchmakingseat freed, with an eventyour entry hookrefused — a code and a reasonactive member — room state, then live trafficinactive — the seat is helddeparted — a leave, with an event 90 s, no joinjoin with datarejectedacceptedconnection lostback in 45 s45 s passed
A seat's life: reserved, checked at the door, held through a drop (90 s and 45 s are the battle template's values)
What it is
entryis a validated request: the joiner supplies data at join and the entry hook accepts or rejects with a code and a reason. That data is what the member initialises itself from — in battle the loadout carried in at join is what the member's Tank spawns with
seats and reservationsMatchmaking claims a seat for the template's reserve term — 90 seconds in battle — and the client then joins directly. Reservations count toward capacity, and expiry frees the seat with an event rather than silently
a drop is not a leavea disconnected member keeps their seat for the grace window (45s in battle) and reconnects into the same membership; expiry makes it a leave, and the event carries which of the two it was. Coming back one second late is told the room is alive and the membership is not — a different answer from "no such room"
late join is state, not a journala joiner gets the room's current state and then live traffic

Events aren't replayed after a reconnect

Events sent while a player was away are not replayed, for a late joiner or for a returning member. Anything that has to survive the gap must be state: the mine a player laid is a room-scoped entity, not a MinePlaced message somebody has to catch.

Errors

  • A room that does not exist or is hidden by a predicate, and a torn-down room, answer not found — a refusal never reveals a room you may not see.
  • Capacity exhausted is a conflict, and reserved seats count as occupied; worth repeating once a seat frees. Closed to joins is likewise a conflict, worth repeating if it opens.
  • A join rejected by a rule is a conflict; rejected by a hook carries the hook's own code and reason, so "a rule of the game said no" never arrives looking like a transport failure.
  • The rejoin window expired is a conflict: join as a new participant, with a new seat.
  • A lapsed reservation is a conflict: take a new one.
  • A map instance that is unavailable or does not exist is a validation refusal.
  • A move the target room rejected is a conflict, and what to do depends on its reason.
  • Creation beyond the room limit answers as a rate limit or a conflict depending on which limit it was.

Limits

The numbers arrive with the platform limits chapter; what happens at each ceiling is fixed now.

  • A room's capacity: a join is refused as a conflict, with reserved seats counted as occupied.
  • Rooms per project: creation is refused as a conflict.
  • Rooms per actor: creation is refused, and rooms already created are never torn down to make space.
  • The rate of room creation: a rate limit with a deadline.
  • A participant's idle timeout: a forced departure with an event and a declared reason.
  • The empty-room TTL: teardown with an event; switchable off on the persistent-zone type.
  • A seat reservation's deadline: release with an event.
  • Rooms on one map instance: creation on an occupied instance is refused unless the type declared shared occupancy.
  • The size of a room event's payload: publication is refused before sending, never truncated.