Match Rooms
Overview
The package Altruist.Gaming.Rooms hosts fixed-step, authoritative match rooms. Its center is RoomHost<TSim, TInput, TPlayer>, a fixed-rate world stepper that owns everything a session-based match server repeats from game to game:
- connections: one connection per principal (account); a newer connection replaces the old one;
- rooms and seats: each seat is driven by a player or a bot, and keeps its simulated body when the driver changes;
- sequenced inputs between the network and the fixed step;
- snapshots on a global step phase;
- reconnect grace and rejoin, bot refills of open seats, team forfeits, the room's end and its disposal.
Your game supplies the simulation, the bots, the results and the packets. The host never looks inside a packet. Matchmaking and lobbies are optional modules on top of the host (see Matchmaking and Lobbies).
The three type parameters are your simulation (TSim : class, IRoomSimulation<TInput>), your input (TInput : struct) and your per-player data (TPlayer, for example a profile record).
How It Works
Threading
All of the host's state belongs to the engine thread. The connection calls (Connect, Disconnect, SubmitInput, Return, RejoinRequest, ...) are not thread-safe. Run the engine with world-step: inline so that next-tick delegates and the world step share one thread, and post every socket event with WaitForNextTick:
altruist:
game:
engine:
framerateHz: 60
world-step: inline
// In a gate handler (socket thread): hand the call to the engine thread.
_engine.WaitForNextTick(() => _host.SubmitInput(clientId, packet.Seq, new Move(packet.X, packet.Y)));
The frame
The world coordinator calls the host once per engine frame:
BeforeSteps: host time (Now) advances by the frame's clamped duration.FixedStep× N: every room takes one step. After each step the host finishes rooms whose simulation ended and disposes rooms that ended more thanReturnAfterEndSecondsago. EverySnapshotEveryNStepssteps, snapshots go out.AfterSteps: in this order: actions handed toPost, the drain, the reconnect timers, the modules'BeforeBotRefills(matchmaking runs here), bot refills, the modules'AfterFrame.
A room's step collects one input per seat, calls IRoomGame.BeforeStep, then IRoomSimulation.Step. A seat whose owner is connected consumes its next input. A bot seat calls its controller's Think(). Any other seat (open, or its owner is disconnected) gets NeutralInput. By default rooms keep stepping after their simulation ended, until they are disposed, so an end screen can show players still moving: Step must then also work after Ended becomes true. Set RoomHostOptions.StepAfterEnd = false to stop stepping ended rooms (snapshots still go out).
The Game Contract
IRoomSimulation<TInput>
The authoritative simulation of one room. The host calls it one step at a time and never from two threads at once.
| Member | Meaning |
|---|---|
float Dt | Seconds per step. |
bool Ended | The match is over. The host finishes the room. |
AddSeat(seat, team) / RemoveSeat(seat) | A seat joins or leaves. Seat and team ids are ints the game chooses. |
SetBotControlled(seat, bot) | A bot drives the seat from now on, or no longer does. |
Step(inputs) | Exactly one step. inputs has an entry for every seat. |
ISeatController<TInput>
A server-side driver of a seat (a bot). TInput Think() is called once per step.
IRoomGame<TSim, TInput, TPlayer>
Everything else the host needs from the game. Members marked default have a default implementation.
| Group | Member | Called when |
|---|---|---|
| Basics | NeutralInput | Input of an idle seat, and a player's starting "last input". |
NameOf(player), BotNameOf(room, seat) | Display names for notices. | |
CreateSimulation(mode, playlist) | A new room needs a simulation. | |
LoadOf(mode, playlist) default 1 | The load one room of this mode puts on the server (see Capacity and Drain). | |
OnRoomCreated(room) default | A room was created, before its seats are added. | |
OnStarting(room) default | Every seat is added and the room is about to run (reset kickoff, clock). | |
| Seats and steps | OnSeatAdded(room, seat) default | A seat was added. |
CreateBot(room, seat) | A bot is needed for a seat. | |
OnHumanSeated(room, seat, p, newDriver) default | A player took a seat. newDriver is true when someone else, or nobody, had it before. | |
OnBotSeated(room, seat) default | A bot took a seat. | |
OnReleased(room, seat, p) default | The owner gives the seat up (move per-seat stats to the player). | |
BeforeStep(room) default | Right before each simulation step, after the inputs were collected. | |
| Lifecycle | OnFinished(room) default | The room ended (simulation over, or a forfeit draw). Compute and persist results. |
OnAbandoned(room, p) default | A player abandoned (see Room Rules). | |
OnTeamForfeit(room, winnerTeam) default | Only one team has seats left. End the simulation. Called once per room. | |
OnForfeitDraw(room) default | No team has seats left, or no player is connected, joining or allowed to rejoin. The room is finished and disposed right after. | |
OnSessionReplaced(old) default | A newer connection of the same principal arrived. Tell the old one before it closes. | |
OnForcedClose(room) default | A drain timed out with the room still running. Record it as cancelled. | |
| Wire format | Welcome(room, p) | A player was seated (start, join in progress, rejoin). |
Roster(room) | The seats changed. Sent to every active player. | |
Notice(room, kind, seat, name) | RoomNoticeKind: Joining, Left, BotIn, BotOut, Reconnected. | |
CaptureSnapshot(room) | Shared room state for one snapshot round (a fresh copy). | |
PersonalizeSnapshot(shared, p) | One receiver's snapshot. | |
ReturnedToMenu(room) | A player was released from a room that has no lobby to return to. | |
NothingToRejoin() | A rejoin request with nothing to rejoin. | |
ServerDraining() default null | The server is going away (see Capacity and Drain). | |
Redirect(redirect) default null | Sends a client to another server of the fleet. Null keeps every player where it connected. |
A method that returns null for a packet sends nothing.
IRoomTransport
Where the host's packets go: Send(clientId, packet) and Disconnect(clientId), which closes the connection after what was queued before. In production this is the router's outbound queue (_router.Client.Enqueue and _router.Client.CloseAfterFlush, see Router). In tests it is a recorder.
Seats, Participants and Bots
- A
RoomSessionis one connection:ClientId,PrincipalId,Player, theRoomit plays in and thePendingRoomit is about to join.Get<T>()andSet<T>()hold per-connection state of modules or the game. - A
Participantis a player taking part in a room, connected or disconnected within the grace:Team,ClientId(null while disconnected),Seat,LastSeat,DisconnectedAt,LeftEarly,Abandoned, itsInputBufferasInput, andGameDatafor your own per-player state. - A
Seathas anId, aTeam, anOwner(participant) or aBot, or neither (IsOpen). An open seat recordsOpenSinceandBotAt, the time a bot takes it unless a queued player claims it (Pending).GameDataholds your per-seat state.
Room exposes Id, Mode, Playlist, LobbyCode, Rules, Load, Sim, Seats, Participants, Teams, Ended, Finished, ActiveHumans and ConnectedHumans. The seat helpers AddSeat, Join, GiveToHuman, GiveToBot, Release and RemoveSeat keep the simulation in sync (AddSeat, RemoveSeat, SetBotControlled). A bot that takes a seat keeps the seat's body: position and velocity carry over.
Inputs
SubmitInput(clientId, seq, input) offers a sequenced input to the seat of the connection. Each participant has an InputBuffer<TInput>:
- an input with a sequence at or below the last applied one, or at or below the newest queued one, is dropped as stale or duplicate;
- at most
MaxQueuedInputsinputs wait; beyond that the oldest are dropped; - each step consumes one input; a step with nothing queued repeats the last input (what a predicting client assumes);
LastAppliedSeqis the snapshot ack,Depththe number of inputs waiting.
The buffer is reset whenever a player takes a seat, disconnects or rejoins, so a client starts a new sequence from 1 after a rejoin.
The client side of this contract (canonical presses, sequence numbers, redundant packets, acks and pacing toward the buffer's target depth) is @altruist/input; prediction and reconciliation against the snapshots is @altruist/prediction.
Snapshots
Snapshots go out every SnapshotEveryNSteps host steps, and the phase is global: every room sends in the same step. Per room and round the host calls CaptureSnapshot once, only if the room has an active player, and then PersonalizeSnapshot(shared, p) for each receiver. The personal part usually carries the ack and the input depth:
public IPacketBase PersonalizeSnapshot(object shared, Participant<FieldSim, Move, Pilot> p) =>
new FieldSnapshot
{
State = (float[])shared,
Ack = p.Input.LastAppliedSeq,
InputDepth = p.Input.Depth,
};
A player who is seated (start, join in progress, rejoin) gets a snapshot at once, outside the phase.
To declare the snapshot's rows once for the server encoder and the client decoders (instead of hand-indexed arrays on both sides), see Snapshot Schemas.
Room Rules
RoomRules sets the lifecycle of one room. Whoever creates the room sets them: a playlist, a lobby, or your own code.
| Property | Default | Meaning |
|---|---|---|
OnDisconnect | Hold | What happens when a seated player's connection drops. |
OnGraceExpired | RemoveSeat | What happens to the seat when the reconnect grace runs out. |
OnVoluntaryLeave | RemoveSeat | What happens when a player leaves on purpose (Return). |
WhenEmpty | Dispose | When a room with nobody in it ends. |
ReclaimTeamSeat | false | A returning player whose seat went to a bot may take a bot or open seat of their team. |
JoinInProgress | false | Queued players may join the running room (matchmaking backfill). |
BotRefill | true | Seats the rules open (BotAfterDelay, OpenForBot, ForfeitRejoin) get a bot once their BotAt time is reached. false: they stay open, idling on NeutralInput, for queued players to take (JoinInProgress). |
DisconnectAction | Meaning |
|---|---|
Hold | The seat waits for its player, idling on NeutralInput, until the grace runs out. Then OnGraceExpired applies. |
BotAfterDelay | After BotRefillDelaySeconds the seat opens and a bot takes it at once (unless BotRefill is off). The player may still come back within the grace when ReclaimTeamSeat is set. When the grace runs out, OnGraceExpired applies to the seat the player left (unless someone else drives it now). |
GraceExpiredAction | Meaning |
|---|---|
ForfeitRejoin | The seat stays and a bot drives it (a held seat opens for a bot now; with BotAfterDelay the bot already has it); only the right to rejoin ends. |
RemoveSeat | The seat leaves the room and the simulation (with BotAfterDelay: together with its bot). |
Abandon | The seat leaves and the game is told (OnAbandoned: penalties, records). |
VoluntaryLeaveAction | Meaning |
|---|---|
OpenForBot | The seat opens. A bot takes it after BotRefillDelaySeconds (unless BotRefill is off) unless a queued player claims it first. |
RemoveSeat | The seat leaves the room. |
Abandon | The seat leaves and the game is told. |
Leaving on purpose always ends the right to rejoin.
EmptyRoomAction | Meaning |
|---|---|
Dispose | The room is disposed once no player is connected, no seat has a pending claim and no disconnected player may still rejoin. A disconnected player within the grace keeps the room up, so a network blip that drops every connection at once does not end the match. |
TeamForfeit | When one team still has seats, OnTeamForfeit(room, winnerTeam) is called once (end the simulation). When no team has seats, or no player is connected, joining or still allowed to rejoin (only bots are left), OnForfeitDraw is called and the room is finished and disposed. |
Reconnects and Rejoin
Connect(clientId, principalId, player) registers an authenticated connection and returns its session (null when the client id is already connected). A previous connection of the same principal is replaced: OnSessionReplaced(old) is called, the old session is dropped and its connection closed.
When the principal is bound to a live room, Connect seats it again right away, when:
- the room has not ended and the participant did not abandon;
- the participant still owns its seat, or (with
ReclaimTeamSeat, within the grace) a seat of its team has no owner and no pending claim; - no module refuses (
OnRejoining).
The rejoining player gets a welcome, the roster and a snapshot; the others get a Reconnected notice. The client then sends its rejoin request, RejoinRequest(clientId), which answers NothingToRejoin() only when the connection has no room, no pending join and is not busy in a module (a queue or a lobby).
ActiveRoomOf(principalId) tells, without changing anything, which room a fresh connection would rejoin, the participant and the grace left (null while connected). Use it for a "rejoin your match" screen or a REST endpoint. Lobby rooms are rejoined through their lobby and are not reported. Bind, Unbind and BoundRoom manage the principal-to-room binding when you start rooms yourself.
Disconnect(clientId) drops the session: modules let go of it (queues, lobbies), and its room keeps the seat according to the rules. Return(clientId) leaves the room on purpose: back to the menu, or to the lobby through the lobby module. In a room that already ended it only releases the player.
Example
A small game: every seat is a point in a field, a match lasts 90 seconds. The host is registered as a world stepper; the portal posts socket events to the engine thread.
using Altruist;
using Altruist.Engine;
using Altruist.Gaming;
using Altruist.Gaming.Rooms;
using MessagePack;
public readonly record struct Move(sbyte X, sbyte Y);
public sealed record Pilot(string AccountId, string Name);
public record struct Dot(int Team, float X, float Y);
public sealed class FieldSim : IRoomSimulation<Move>
{
public readonly SortedDictionary<int, Dot> Dots = new();
public int Steps;
public float Dt => 1f / 30f;
public bool Ended => Steps >= 90 * 30;
public void AddSeat(int seat, int team) => Dots[seat] = new Dot(team, team == 0 ? -5 : 5, 0);
public void RemoveSeat(int seat) => Dots.Remove(seat);
public void SetBotControlled(int seat, bool bot) { }
public void Step(IReadOnlyDictionary<int, Move> inputs)
{
if (Ended) return; // frozen end screen (or set RoomHostOptions.StepAfterEnd = false)
foreach (var (seat, m) in inputs)
{
var d = Dots[seat];
Dots[seat] = d with { X = Math.Clamp(d.X + m.X * 4 * Dt, -10, 10), Y = Math.Clamp(d.Y + m.Y * 4 * Dt, -10, 10) };
}
Steps++;
}
}
[MessagePackObject]
public class FieldSnapshot : IPacketBase
{
[Key(0)] public uint MessageCode { get; set; } = 20;
[Key(1)] public float[] State { get; set; } = []; // seat, x, y per seat
[Key(2)] public int Ack { get; set; }
[Key(3)] public int InputDepth { get; set; }
}
/// <summary>Walks to the center. Reads only its own room, so it is safe on any step worker.</summary>
public sealed class CenterBot(FieldSim sim, int seat) : ISeatController<Move>
{
public Move Think() => new((sbyte)-Math.Sign(sim.Dots[seat].X), (sbyte)-Math.Sign(sim.Dots[seat].Y));
}
[Service]
public sealed class FieldGame(IAltruistRouter router) : IRoomGame<FieldSim, Move, Pilot>, IRoomTransport
{
public Move NeutralInput => default;
public string NameOf(Pilot p) => p.Name;
public string BotNameOf(Room<FieldSim, Move, Pilot> room, Seat<FieldSim, Move, Pilot> seat) => $"Bot {seat.Id}";
public FieldSim CreateSimulation(string mode, string? playlist) => new();
public ISeatController<Move> CreateBot(Room<FieldSim, Move, Pilot> room, Seat<FieldSim, Move, Pilot> seat) => new CenterBot(room.Sim, seat.Id);
public void OnFinished(Room<FieldSim, Move, Pilot> room) { /* persist results off the engine thread */ }
// Packets: [MessagePackObject] classes implementing IPacketBase, defined like FieldSnapshot.
public IPacketBase? Welcome(Room<FieldSim, Move, Pilot> room, Participant<FieldSim, Move, Pilot> p) => new FieldWelcome { Seat = p.Seat!.Id, Team = p.Team };
public IPacketBase? Roster(Room<FieldSim, Move, Pilot> room) => new FieldRoster { Seats = room.Seats.Select(s => s.Id).ToArray() };
public IPacketBase? Notice(Room<FieldSim, Move, Pilot> room, RoomNoticeKind kind, int seat, string name) => new FieldNotice { Kind = (int)kind, Seat = seat, Name = name };
public object CaptureSnapshot(Room<FieldSim, Move, Pilot> room) => room.Sim.Dots.SelectMany(d => new[] { d.Key, d.Value.X, d.Value.Y }).ToArray();
public IPacketBase PersonalizeSnapshot(object shared, Participant<FieldSim, Move, Pilot> p) =>
new FieldSnapshot { State = (float[])shared, Ack = p.Input.LastAppliedSeq, InputDepth = p.Input.Depth };
public IPacketBase? ReturnedToMenu(Room<FieldSim, Move, Pilot> room) => new FieldMenu();
public IPacketBase? NothingToRejoin() => new FieldMenu();
// IRoomTransport: the router's outbound queues never block the engine thread.
public void Send(string clientId, IPacketBase packet) => router.Client.Enqueue(clientId, packet);
public void Disconnect(string clientId) => router.Client.CloseAfterFlush(clientId);
}
[Service]
[Service(typeof(IWorldStepper))]
public sealed class FieldHost : RoomHost<FieldSim, Move, Pilot>
{
public FieldHost(FieldGame game)
: base(game, game, new RoomHostOptions { ReconnectGraceSeconds = 45 }, fixedHz: 30) { }
/// <summary>A practice room: the player against one bot.</summary>
public void StartPractice(RoomSession<FieldSim, Move, Pilot> s)
{
if (s.Room is not null || !CanOpenRoom("practice", null)) return;
var room = CreateRoom(Game.CreateSimulation("practice", null), "practice", null, null,
new RoomRules { OnGraceExpired = GraceExpiredAction.RemoveSeat });
var p = room.Join(s.PrincipalId, s.Player, s.ClientId);
room.GiveToHuman(room.AddSeat(1, 0), p);
room.GiveToBot(room.AddSeat(2, 1));
if (!TryStart(room)) return; // drained or filled meanwhile
s.Room = room;
Bind(s.PrincipalId, room);
SendWelcome(s, p, room);
SendRoster(room);
SendSnapshotTo(room, p);
}
}
The portal turns socket events into host calls on the engine thread:
[Portal("/field")]
public class FieldPortal(FieldHost host, IAltruistEngine engine) : Portal, OnConnectedAsync, OnDisconnectedAsync
{
public Task OnConnectedAsync(string clientId, ConnectionManager connections, AltruistConnection connection)
{
if (connection.AuthDetails?.PrincipalId is { Length: > 0 } account)
engine.WaitForNextTick(() => host.Connect(clientId, account, new Pilot(account, account)));
return Task.CompletedTask;
}
public Task OnDisconnectedAsync(string clientId, Exception? exception)
{
engine.WaitForNextTick(() => host.Disconnect(clientId));
return Task.CompletedTask;
}
[Gate("input")]
public Task OnInput(InputPacket packet, string clientId)
{
engine.WaitForNextTick(() => host.SubmitInput(clientId, packet.Seq, new Move(packet.X, packet.Y)));
return Task.CompletedTask;
}
// "practice", "rejoin" and "return" gates post host.StartPractice(session),
// host.RejoinRequest(clientId) and host.Return(clientId) the same way.
}
Rooms are usually started by the matchmaking and lobby modules, which follow the same steps: CreateRoom, add and fill seats, TryStart, bind the players, send welcome, roster and snapshot. TryStart(room) starts the room only while the server takes it (not draining, its load fits) and returns false otherwise: drop the room then. Start(room) starts it whatever the capacity, for tools and tests. Checking CanOpenRoom first, as above, saves building a room that will not start.
Modules
A module implements IRoomHostModule<TSim, TInput, TPlayer> and is added with host.Use(module). Hooks run in the order the modules were added; host.Module<T>() finds one. Every hook has a default.
| Hook | Called when |
|---|---|
Attach(host) | The module was added. |
OnSessionDropping(s) | A connection closes or is replaced, before its seat is let go. |
OnPlayerUpdated(s) | UpdatePlayer changed the connection's player. |
TryReturn(s, room, p) | The connection asked to leave its room. Return true when the module handled it. |
OnRejoining(s, room, p) | A returning connection is being seated again. Return false to refuse. |
OnRejoined(s, room) | The returning connection has its welcome and snapshot. |
OnRoomDisposing(room) / OnRoomDisposed(room) | Before and after the room's participants are released. |
BeforeBotRefills() | Every frame, after the reconnect timers, before bots refill open seats. |
AfterFrame() | Every frame, last. |
IsBusy(s) | The connection waits in this module (a queue, a lobby). |
OnTakenOver(s) | Another module took the connection for its work (host.TakeOver(s, module): it queued, it joined a lobby). Let go of it. |
OnDraining() | The server began draining. Called once, on the engine thread. |
OnHandoff(s, handoff) | A connection arrived from another server with work handed over. Return true when the module took it. |
Multi-core Steps
UseScheduler(IStepScheduler) steps the rooms' simulations on several threads. Inject the default scheduler from DI; its worker count comes from configuration:
altruist:
game:
engine:
step-workers: auto # 1 (default), a number, or auto (one per core)
public FieldHost(FieldGame game, IStepScheduler scheduler)
: base(game, game, new RoomHostOptions(), fixedHz: 30) => UseScheduler(scheduler);
With more than one worker and more than one room, each room's step (input collection with the bots' Think, BeforeStep, IRoomSimulation.Step) runs on a worker. Each room is stepped by one thread per step. Room ends, disposals, snapshots and every packet stay on the engine thread, in room order. The results and the packets are bit-identical to one worker.
Note:
With more than one worker, IRoomSimulation.Step, the bots' Think and IRoomGame.BeforeStep may only touch their own room. Anything shared between rooms must be thread-safe. Independent Box2D worlds may step on different threads (see Physics).
When a room's step throws, every other room still steps, the step is counted and the snapshots still go out; the failures are thrown together afterwards (AggregateException) and the world coordinator logs them. This holds for one worker and for many.
Capacity and Drain
UseServer(IServerNode) binds the host to this server's node, described in Capacity, Drain & Health. The host then:
- counts its rooms against the server's capacity under kind
rooms: one unit per started room, and a load ofIRoomGame.LoadOf(mode, playlist)per room (default 1, soaltruist:server:capacity:max-loadis a room count); - admits new rooms only while
CanOpenRoom(mode, playlist): the server is not draining, is ready, and the room's load still fits. Matchmaking and lobbies check it before they open a room; - takes part in the drain.
When the server is full, nobody is dropped: matchmaking keeps its players searching and still backfills running rooms, and a lobby's start is refused with Unavailable. When a room ends, new rooms open again.
When the server drains:
- At the next frame, every module gets
OnDraining(queues empty, pending joins are called off, lobbies move in a fleet) and every connection without a room getsServerDraining()once. - Running rooms play on to their end. Their players may still rejoin; a new connection with nothing to rejoin gets
ServerDraining(). Players released from a room during the drain getReturnedToMenuand thenServerDraining(). - The host is drained when no room runs any more.
- If the drain times out, every room still running is closed:
OnForcedClose(room)for rooms that had not finished, then disposal.
IsDraining, IsDrained, Sample() (rooms and load, thread-safe) and Server report the state.
Fleet
UseFleet(IFleet) makes the host a member of a fleet of servers sharing a backplane: players' rooms are claimed under the unit kind player, a connection without a room on this server looks up its room and any handed-over work elsewhere (off the engine thread; a rejoin request waits for the answer), and players are sent on with IRoomGame.Redirect(RoomRedirect). A draining server sends newcomers to another server. Redirect(session, nodeId, reason, handoff) writes the handoff before the client is told, then closes the connection; OtherServerFor(cost) picks the least loaded server that takes the cost. With a single server nothing changes. See Fleet & Scaling.
public FieldHost(FieldGame game, IStepScheduler scheduler, IServerNode server, IFleet fleet)
: base(game, game, new RoomHostOptions(), fixedHz: 30)
{
UseScheduler(scheduler).UseServer(server).UseFleet(fleet);
}
Post and RunAsync
The engine thread must never wait on I/O. Two helpers move work off it and back:
Post(action)runsactionon the engine thread at the start of the next frame'sAfterSteps. It may be called from any thread.RunAsync(work, then, failed)runsworkon the thread pool and continues with its result on the engine thread in the next frame. A failure goes tofailedon the engine thread, or is dropped.
host.RunAsync(
() => profiles.LoadAsync(accountId),
profile => host.UpdatePlayer(clientId, profile),
error => logger.LogWarning(error, "profile reload failed"));
PendingAsync counts work that has not posted back yet plus posted actions not run yet.
Configuration Reference
RoomHostOptions is passed to the host's constructor (the package does not read it from config.yml):
| Property | Default | Meaning |
|---|---|---|
SnapshotEveryNSteps | 2 | Snapshots go out every N host steps (global phase). |
BotRefillDelaySeconds | 2 | Seconds after a seat opens (or its player drops, with BotAfterDelay) before a bot takes it. |
ReconnectGraceSeconds | 30 | Seconds a disconnected player may come back into the same room. |
ReturnAfterEndSeconds | 6 | Seconds an ended room stays before its players are released. |
MaxQueuedInputs | 16 | Inputs buffered beyond this are dropped, oldest first. |
StepAfterEnd | true | Ended rooms keep stepping until they are released (an end screen with moving players). false: they stop stepping; snapshots go on. |
The constructor's fixedHz is the host's step rate. Host time (Now) advances with the engine's clamped frame time; utcNow (optional) is the wall clock used for Room.StartedAtUtc.
Engine keys that matter for rooms (see Understanding the Engine):
altruist:
game:
engine:
framerateHz: 60 # sampling rate, at least the host's fixedHz
world-step: inline # socket posts and room steps on one thread
step-workers: 1 # 1, a number, or auto
fixed-step:
max-frame-delta: 0.25
max-steps-per-frame: 8
overrun: drop