ENet multiplayer: lobby, RPC and server-owned state
A complete server/two-client lobby: sender identity, RPC permissions, replay and disconnect.
Run the lobby
Add LobbySession.cs and LobbyLab.cs to a 1.0.0 C# project. Create a separate Control-root level, attach LobbyLab, build and press F6. Use a 640×420 or larger window. Initially the server and both clients should show 3 players and 0 ready; the server counts as a player.
The first two buttons toggle their respective client's readiness. After pressing both, all three participants show 3 / 2. The third button disconnects/reconnects the second client. A disconnected client is labelled explicitly and the server removes its ready state.
LobbyLab.cs
using Reckfell;
public partial class LobbyLab : Control
{
public LobbySession HostSession { get; private set; } = null!;
public LobbySession First { get; private set; } = null!;
public LobbySession Second { get; private set; } = null!;
public Label Status { get; private set; } = null!;
private bool _firstReady, _secondReady;
private bool _ru;
public override void _Ready()
{
_ru = TranslationServer.GetLocale().StartsWith("ru");
HostSession = new LobbySession { Name = "Host" }; AddChild(HostSession);
First = new LobbySession { Name = "First" }; AddChild(First);
Second = new LobbySession { Name = "Second" }; AddChild(Second);
var box = new VBoxContainer { Position = new Vector2(24, 24), Size = new Vector2(570, 300) }; AddChild(box);
box.AddChild(new Label { Text = _ru ? "ENet: сервер и два клиента" : "ENet: server and two clients" });
Status = new Label(); box.AddChild(Status);
var first = new Button { Text = _ru ? "Первый игрок: готовность" : "First player: toggle ready" }; box.AddChild(first);
first.Pressed += () => { if (First.RequestReady(!_firstReady) == Error.Ok) _firstReady = !_firstReady; };
var second = new Button { Text = _ru ? "Второй игрок: готовность" : "Second player: toggle ready" }; box.AddChild(second);
second.Pressed += () => { if (Second.RequestReady(!_secondReady) == Error.Ok) _secondReady = !_secondReady; };
var reconnect = new Button { Text = _ru ? "Второй игрок: отключить / подключить" : "Second player: disconnect / reconnect" }; box.AddChild(reconnect);
reconnect.Pressed += () => { if (Second.Active) Second.Stop(); else { _secondReady = false; Second.Join("127.0.0.1", HostSession.LocalPort); } };
Error host = HostSession.Host();
if (host != Error.Ok) { Status.Text = $"Host: {host}"; SetProcess(false); return; }
First.Join("127.0.0.1", HostSession.LocalPort); Second.Join("127.0.0.1", HostSession.LocalPort);
}
public override void _Process(double delta)
{
Status.Text = _ru
? $"Сервер: игроков {HostSession.Players}, готовы {HostSession.ReadyPlayers}\nКлиент 1: {ClientStatus(First)}\nКлиент 2: {ClientStatus(Second)}"
: $"Server: players {HostSession.Players}, ready {HostSession.ReadyPlayers}\nClient 1: {ClientStatus(First)}\nClient 2: {ClientStatus(Second)}";
}
private string ClientStatus(LobbySession session) => session.Connected
? $"{session.Players} / {session.ReadyPlayers}" : (_ru ? "отключён" : "disconnected");
}LobbySession.cs
using System;
using System.Collections.Generic;
using Reckfell;
public partial class LobbySession : Entity
{
public bool Active => _api is not null;
public bool Connected { get; private set; }
public bool IsServer => _api?.IsServer() == true;
public int PeerId => _api?.GetUniqueId() ?? 0;
public int LocalPort => IsServer ? _peer!.Host.GetLocalPort() : 0;
public int Players { get; private set; }
public int ReadyPlayers { get; private set; }
public int SnapshotSequence { get; private set; }
public int AcceptedRequests { get; private set; }
public int RejectedRequests { get; private set; }
public int LastSenderId { get; private set; }
public string LastFailure { get; private set; } = "";
public event Action? Joined;
private SceneMultiplayer? _api;
private ENetMultiplayerPeer? _peer;
private EntityPath? _rootPath;
private readonly Dictionary<int, bool> _ready = new();
private readonly Dictionary<int, int> _lastInput = new();
private int _nextInput;
private bool _pendingStop;
private bool _publishPending;
public Error Host(int port = 0)
{
if (Active) return Error.Busy;
if (!IsInsideTree()) return Error.Unconfigured;
if (port < 0 || port > 65535) return Error.InvalidParameter;
var peer = new ENetMultiplayerPeer(); peer.SetBindIP("127.0.0.1");
Error error = peer.CreateServer(port, maxClients: 4, maxChannels: 2);
if (error != Error.Ok) { peer.Dispose(); return error; }
Attach(peer); Connected = true; _ready[1] = false; Publish(); return Error.Ok;
}
public Error Join(string address, int port)
{
if (Active) return Error.Busy;
if (!IsInsideTree()) return Error.Unconfigured;
if (port <= 0 || port > 65535 || string.IsNullOrWhiteSpace(address)) return Error.InvalidParameter;
var peer = new ENetMultiplayerPeer(); Error error = peer.CreateClient(address, port, channelCount: 2);
if (error != Error.Ok) { peer.Dispose(); return error; }
Attach(peer); return Error.Ok;
}
private void Attach(ENetMultiplayerPeer peer)
{
_peer = peer; var api = new SceneMultiplayer { AllowObjectDecoding = false, ServerRelay = false }; _api = api;
_rootPath = GetPath(); GetTree().SetMultiplayer(api, _rootPath); api.MultiplayerPeer = peer;
_pendingStop = _publishPending = false; Connected = false; LastFailure = ""; _nextInput = 0;
Players = ReadyPlayers = SnapshotSequence = AcceptedRequests = RejectedRequests = LastSenderId = 0;
_ready.Clear(); _lastInput.Clear();
api.ConnectedToServer += () => { if (_api == api) { Connected = true; Joined?.Invoke(); } };
api.ConnectionFailed += () => { if (_api == api) { LastFailure = "connection-failed"; _pendingStop = true; } };
api.ServerDisconnected += () => { if (_api == api) { LastFailure = "server-disconnected"; _pendingStop = true; } };
api.PeerConnected += id => { if (_api == api && api.IsServer()) { _ready[(int)id] = false; _publishPending = true; } };
api.PeerDisconnected += id => { if (_api == api && api.IsServer()) { _ready.Remove((int)id); _lastInput.Remove((int)id); _publishPending = true; } };
}
public Error RequestReady(bool ready)
{
if (!Connected || _api is null) return Error.Unconfigured;
if (IsServer) { _ready[1] = ready; _publishPending = true; return Error.Ok; }
return RpcId(1, nameof(SubmitReady), ++_nextInput, ready);
}
[Rpc(MultiplayerApi.RpcMode.AnyPeer, TransferMode = MultiplayerPeer.TransferModeEnum.Reliable, TransferChannel = 1)]
private void SubmitReady(int sequence, bool ready)
{
if (_api is null || !_api.IsServer()) { RejectedRequests++; return; }
int sender = _api.GetRemoteSenderId(); LastSenderId = sender;
int previous = _lastInput.GetValueOrDefault(sender);
if (sender <= 1 || !_ready.ContainsKey(sender) || sequence <= previous || sequence > previous + 64)
{ RejectedRequests++; return; }
_lastInput[sender] = sequence; _ready[sender] = ready; AcceptedRequests++; _publishPending = true;
}
private void Publish()
{
Players = _ready.Count; ReadyPlayers = 0;
foreach (bool ready in _ready.Values) if (ready) ReadyPlayers++;
SnapshotSequence++;
if (_api?.GetPeers().Length > 0) Rpc(nameof(ApplySnapshot), SnapshotSequence, ReadyPlayers, Players);
}
[Rpc(MultiplayerApi.RpcMode.Authority, TransferMode = MultiplayerPeer.TransferModeEnum.Reliable, TransferChannel = 1)]
private void ApplySnapshot(int sequence, int ready, int players)
{
if (_api is null || _api.IsServer() || _api.GetRemoteSenderId() != 1 ||
sequence <= SnapshotSequence || ready < 0 || players < 1 || players > 5 || ready > players)
{ RejectedRequests++; return; }
SnapshotSequence = sequence; ReadyPlayers = ready; Players = players;
}
public override void _Process(double delta)
{
if (_pendingStop) { Stop(); return; }
if (_publishPending && IsServer) { _publishPending = false; Publish(); }
}
public void Stop()
{
Connected = false; _pendingStop = _publishPending = false;
if (_api is not null)
{
_api.MultiplayerPeer = null!;
if (IsInsideTree() && _rootPath is not null) GetTree().SetMultiplayer(null!, _rootPath);
_api.Dispose(); _api = null;
}
_peer?.Close(); _peer?.Dispose(); _peer = null; _rootPath?.Dispose(); _rootPath = null;
_ready.Clear(); _lastInput.Clear();
}
public override void _ExitTree() => Stop();
}Three independent SceneMultiplayer instances exchange real UDP traffic in one window. EntityTree.SetMultiplayer gives each LobbySession its own root, peer ID, connection and RPC context. A shared collection does not replace client networking. A separate test runs unchanged LobbySession in a host and two client engine processes.
ServerRelay is explicitly false: this protocol exchanges requests to the server and snapshots from it. A client's Players comes from gameplay snapshots, not its GetPeers count. This lobby does not use client-to-client routing.
Who changes state
| Step | Participant | Action |
|---|---|---|
| RequestReady | Client | Sends an input sequence and desired readiness to server ID 1 |
| SubmitReady | Server | Gets actual sender ID from MultiplayerApi and validates membership/sequence |
| Publish | Server | Recounts players/readiness and increments SnapshotSequence |
| ApplySnapshot | Client | Accepts a valid snapshot only from authority, server 1 |
The request contains no trusted player ID supplied by user payload. Read GetRemoteSenderId inside the RPC handler. Capture that ID before deferred/asynchronous work: outside the current RPC context it does not identify the former sender.
AnyPeer describes permitted remote callers, not automatic safety or server-only execution. SubmitReady additionally checks the server role. ApplySnapshot uses Authority; with authority = 1, a client cannot substitute its own server snapshot. The engine rejects that attempt in the test.
Ordering and disconnect
Input sequence and SnapshotSequence are distinct counters, not engine versions. The server rejects nonincreasing inputs and excessive jumps; clients accept only newer snapshots. The allowed increment of 64 is this lobby's rule, not an ENet requirement.
Membership/readiness changes flag a pending publication. _Process publishes a single final state after network-event processing instead of immediately sending RPC from every PeerDisconnected. This matters when several clients leave together: cleanup should not send to a connection that is already closing.
Disconnect removes both readiness and the participant's last input sequence. Reconnect receives current state and starts a new input sequence. Peer IDs are temporary; do not use them as persistent account/save keys.
Stop releases only the peer and API override owned by this LobbySession. API-table mutation is deferred from network events to _Process. _ExitTree calls Stop when the Entity is removed. Manage MultiplayerApi/tree state on the main thread.
RPC, channels and structure
Both sides need matching RPC methods, signatures, attributes and paths relative to the network root. All participants use identical LobbySession.cs. Outer Host/First/Second branch names differ, but each API treats its own LobbySession as root.
Game RPC uses Reliable on channel 1. Server and clients create two user channels, so channel 1 is valid. Reliable helps delivery/ordering within a live connection, not durable results after disconnection. For frequent motion updates, separately choose mode/channel, frequency and loss policy; every character position need not join a reliable large-message stream.
EntityTree normally polls MultiplayerApi automatically. If MultiplayerPoll is disabled, organize Poll yourself rather than running two conflicting schemes. An ordinary C# method invocation does not replace network Rpc/RpcId.
Authority is neither account authentication nor the editor-save Entity.Owner property. Coordinate authority changes across participants; SetMultiplayerAuthority does not automatically distribute all gameplay rules. Entity/prefab spawning and property replication require separate MultiplayerSpawner/MultiplayerSynchronizer setup; this example does not include them.
Scope
The server binds 127.0.0.1 and accepts at most four remote clients, for five participants including the host. CreateClient begins connecting rather than confirming entry. ConnectedToServer, ConnectionFailed and ServerDisconnected describe later outcomes. External servers require an address, UDP routing and separate network checks.
AllowObjectDecoding remains false. The lobby does not implement accounts, encrypted ENet traffic, request-flood protection, matchmaking, match recovery, prediction, rollback or physics synchronization. It verifies a small server protocol: actual sender, sequence, authority and disconnect cleanup. Tests cover local independent APIs and three separate processes, not WAN/NAT, other devices or an exported build.
A separately observed defect in the checked build: ordinary ServerRelay = true logged native Unable to send packet on channel 0, max channels: 0 during simultaneous exits of two client processes. This is retained as a reproduced defect, not an engine fix. Passing the ServerRelay = false lobby does not establish correct operation of that relay mode; verify it separately before enabling it.