Tutorial 97: Network Sessions on the Local Network
What you’ll learn
- Enabling CNA's XNA-compatible networking API in your build.
- Signing in a local offline profile, then hosting, finding and joining a
SystemLinksession — with no server and no Internet. - Sending and receiving packets, handling session events, and owning the session object correctly.
- What changes for online
PlayerMatchandRankedsessions, and the limits worth designing around.
Before you start — Tutorial 04: The Game Class Lifecycle (session state maps onto lifecycle callbacks) and Tutorial 18: Game States (lobby and in-game are game states). The Gamer Services page describes the profiles, the Guide and the optional server this tutorial builds on.
CNA ships an XNA-compatible networking API
You do not need to bolt a third-party socket library onto your CNA game. CNA implements
Microsoft::Xna::Framework::Net — the same namespace XNA 4.0 exposed — as a real
implementation, not a stub. It is covered by unit tests including a genuine two-process loopback test
that spawns a host and a client as separate OS processes and makes them exchange packets.
What travels where depends on the session type. Local and LocalWithLeaderboards
sessions live in one process. SystemLink sessions are peer-to-peer ENet over
UDP on the local network: CNA vendors ENet and wraps it behind the XNA API surface, LAN session
discovery is a real broadcast protocol on UDP port 61190, and the QualityOfService
attached to a discovered session carries a measured round-trip time and measured upstream and downstream
bandwidth. SystemLink needs no server and works offline. Online PlayerMatch and
Ranked sessions go through the optional CNA Gamer Services server instead (see
Online sessions below). This tutorial is about the local network.
If you have read older advice telling you to hand-roll BSD sockets or to
FetchContent ENet from GitHub for a CNA game, disregard it. ENet is already vendored and
already wrapped; pulling a second copy in gives you two ENet builds in one process and none of the
XNA API compatibility.
Enabling networking in your build
The CMake option CNA_ENABLE_NET defaults to ON, so networking is built
unless you explicitly turn it off. Link your game against the networking libraries:
target_link_libraries(MyGame PRIVATE
CNA
CNA_GamerServices # SignedInGamer, Guide, GamerServicesComponent
CNA_Net # NetworkSession, PacketReader/Writer, ...
SHARP_RUNTIME)
CNA_GamerServices is not optional: every session is created for one or more
SignedInGamer instances, and those come from the GamerServices layer. Voice chat is built when
libopus 1.3 or newer is found; CNA_ENABLE_VOICE (AUTO, ON or
OFF) controls that, and voice is never built in a cross-compiled build (Emscripten, Android, iOS, or Windows built from Linux).
CNA's own networking demos are gated on CNA_ENABLE_NET AND NOT EMSCRIPTEN. The Net
library itself builds under Emscripten, but a browser page cannot open a UDP socket, so SystemLink
discovery finds nothing there, and the online service, its relay and voice are not available in a browser either.
Signing in a local gamer
With no server configured — the default — CNA’s gamers are local offline profiles,
as on an Xbox 360 without Xbox LIVE, and nobody is signed in until the player picks or creates a
profile in the Guide’s sign-in page (Guide::ShowSignIn) or the auto-sign-in setting signs one in.
Add GamerServicesComponent exactly as in XNA, and keep a GraphicsDeviceManager so the Guide
can draw:
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/GamerServices/GamerServicesComponent.hpp"
#include "Microsoft/Xna/Framework/GamerServices/Gamer.hpp"
#include "Microsoft/Xna/Framework/GamerServices/Guide.hpp"
#include "Microsoft/Xna/Framework/GamerServices/SignedInGamerCollection.hpp"
#include <memory>
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::GamerServices;
class LanGame : public Game {
GraphicsDeviceManager graphics_{this}; // the Guide draws through it
public:
LanGame() {
getComponentsProperty().Add(std::make_shared<GamerServicesComponent>(*this));
}
protected:
void Update(GameTime& gameTime) override {
Game::Update(gameTime); // runs GamerServicesDispatcher::Update
SignedInGamer* player = (*Gamer::getSignedInGamersProperty())[PlayerIndex::One];
if (player == nullptr && !Guide::getIsVisibleProperty())
Guide::ShowSignIn(1, false); // pick or create a local profile
// ... once player is not null, host or join a session (below)
}
};
A local profile has IsSignedInToLive false and no online-session privilege, but it plays
Local and SystemLink sessions. Profiles are stored per user on the machine
(profiles.json under the user’s data directory, or under CNA_GAMER_SERVICES_PROFILES_DIR).
For unattended runs and CI, set CNA_GAMER_SERVICES_AUTO_SIGN_IN to up to four comma-separated profile
names: they are created when missing and signed in when gamer services start, and appear at the first
Update.
Hosting a session
NetworkSession::Create() returns a raw NetworkSession* that
you own. Dispose() tears down the ENet host and frees every gamer object the session
created, but it does not free the session object itself: call Dispose(), then delete
the pointer.
#include "Microsoft/Xna/Framework/Net/NetworkSession.hpp"
#include "Microsoft/Xna/Framework/Net/LocalNetworkGamer.hpp"
using namespace Microsoft::Xna::Framework::Net;
// sessionType, maxLocalGamers, maxGamers -- the signed-in profile becomes the local gamer
NetworkSession* session =
NetworkSession::Create(NetworkSessionType::SystemLink, 1, 8);
LocalNetworkGamer* me = session->getLocalGamersProperty()[0];
// ... later, exactly once:
session->Dispose();
delete session;
There are richer Create() overloads taking privateGamerSlots and a
NetworkSessionProperties to advertise, plus an overload taking an explicit
std::vector<SignedInGamer*>. Each has a matching
BeginCreate()/EndCreate() asynchronous pair. The XNA limits are available as
NetworkSession::MaxSupportedGamers (31) and NetworkSession::MaxPreviousGamers (100), and the
gamer limit you request is preserved. Local, LocalWithLeaderboards and SystemLink
sessions need no server; asking for PlayerMatch or Ranked without one throws (see below).
Finding and joining a session
NetworkSession::Find() performs the real LAN broadcast and returns an
AvailableNetworkSessionCollection. Discovery is a UDP request/reply exchange, so a
single call can legitimately return nothing — poll it. On macOS the query also goes to every network
interface’s own broadcast address and the replies arrive on a socket of the search’s own, because Darwin refuses the
global broadcast address; CNA records two-process discovery on one Mac, not yet between two Macs.
Discovery is gated to SystemLink, and does nothing on the web.
Find() with NetworkSessionType::Local throws ArgumentException (a local session is
never discovered), and with PlayerMatch or Ranked it searches the server’s session directory,
or throws GamerServices::GamerServicesNotAvailableException when no server is configured. Under Emscripten
SystemLink discovery returns nothing at all, because a browser page cannot open a UDP socket. Do not build a
session browser that treats "no results" as "still searching" on the web path.
AvailableNetworkSessionCollection is a read-only collection. Its
non-const operator[] is the mutating accessor and throws
NotSupportedException; only the const overload returns a usable reference. Bind a
const& to the collection before indexing it.
#include "Microsoft/Xna/Framework/Net/AvailableNetworkSessionCollection.hpp"
#include "Microsoft/Xna/Framework/Net/NetworkSessionProperties.hpp"
#include <chrono>
#include <thread>
AvailableNetworkSessionCollection available = NetworkSession::Find(
NetworkSessionType::SystemLink, 1, NetworkSessionProperties{});
for (int attempt = 0; attempt < 100 && available.getCountProperty() == 0; ++attempt) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
available = NetworkSession::Find(
NetworkSessionType::SystemLink, 1, NetworkSessionProperties{});
}
if (available.getCountProperty() == 0) {
// No host is running on this LAN.
return;
}
const AvailableNetworkSessionCollection& found = available; // const overload
NetworkSession* session = NetworkSession::Join(&found[0]);
Every AvailableNetworkSession exposes HostGamertag,
CurrentGamerCount, OpenPublicGamerSlots,
OpenPrivateGamerSlots, its advertised SessionProperties, and a
QualityOfService. Use them to build a server browser:
const AvailableNetworkSession& s = found[i];
const QualityOfService& qos = s.getQualityOfServiceProperty();
double pingMs = qos.getAverageRoundtripTimeProperty()
.getTotalMillisecondsProperty();
// "Host (2/8) - 3 ms"
std::printf("%s (%d/%d) - %.0f ms\n",
s.getHostGamertagProperty().c_str(),
s.getCurrentGamerCountProperty(),
s.getCurrentGamerCountProperty() + s.getOpenPublicGamerSlotsProperty(),
pingMs);
NetworkSession::Join() deliberately has no overload taking an explicit gamer list
— matching real XNA, it always draws from Gamer::SignedInGamers. Only
Create() accepts an explicit list.
Sending and receiving packets
PacketWriter derives from System::IO::BinaryWriter and adds overloads for
the XNA math types: Color, Matrix, Quaternion,
Vector2, Vector3, Vector4, plus float and
double. All of BinaryWriter's other Write() overloads remain
available. PacketReader is the symmetric BinaryReader. Every one of these
round-trips is covered by cna_demo_packet_roundtrip.
You send through a LocalNetworkGamer, not through the session, and you pump the session
once per frame with Update():
#include "Microsoft/Xna/Framework/Net/PacketReader.hpp"
#include "Microsoft/Xna/Framework/Net/PacketWriter.hpp"
void MyGame::Update(GameTime& gameTime) {
Game::Update(gameTime); // gamer services first
session_->Update(); // pumps ENet, raises queued events
// Broadcast our position to everyone else in the session.
PacketWriter writer;
writer.Write(localPosition_); // Vector2 overload
local_->SendData(writer, SendDataOptions::InOrder);
// Drain everything that arrived since the last frame.
PacketReader reader;
NetworkGamer* sender = nullptr;
while (local_->getIsDataAvailableProperty()) {
local_->ReceiveData(reader, sender);
if (sender != nullptr && !sender->getIsLocalProperty()) {
remotePositions_[sender] = reader.ReadVector2();
}
}
}
The sender out-parameter can point at your own local gamer — a broadcast is
delivered to every gamer in the session including the originator — so the
getIsLocalProperty() check above is not optional. ReceiveData(PacketReader&, ...) returns
the packet’s size and sizes the reader to it.
SendDataOptions has five members:
| Value | Guarantee |
|---|---|
None | May be dropped or reordered. Cheapest; right for per-frame position updates. |
Reliable | Guaranteed to arrive. XNA leaves the order unspecified; CNA's SystemLink transport sends it on ENet's reliable channel, so it also arrives in order. |
InOrder | In order relative to other in-order packets, but not guaranteed to arrive: CNA sends it sequenced but unreliable, so a late packet is dropped rather than delivered out of order. |
ReliableInOrder | Guaranteed to arrive, in order. Right for one-shot events like "player fired". |
Chat | Marks the packet as chat data. CNA sends it reliably and in order; it cannot be combined with the other members. |
XNA marks this enum [Flags] and its values (0–4) do compose: ReliableInOrder (3) is
Reliable | InOrder, and Chat (4) is a separate bit that XNA lets you combine with the others.
CNA ports it as a plain enum class with no bitwise operators, so those combinations cannot be written:
pass exactly one of the five members (Chat on its own is sent reliably and in order).
SendData() also has overloads taking a raw
std::vector<SharpRuntime::bytecs>, an offset/count pair, and a target
NetworkGamer* recipient for point-to-point delivery instead of broadcast.
Session events
NetworkSession exposes GamerJoined, GamerLeft,
GameStarted, GameEnded, HostChanged, and
SessionEnded. Events are raised from Update(), never spontaneously; the one exception is that subscribing to GamerJoined replays the gamers already in the session (see the note below).
#include "Microsoft/Xna/Framework/Net/GamerJoinedEventArgs.hpp"
#include "Microsoft/Xna/Framework/Net/GamerLeftEventArgs.hpp"
#include "Microsoft/Xna/Framework/Net/NetworkSessionEndedEventArgs.hpp"
session->GamerJoined += [this](System::Object*, const GamerJoinedEventArgs& e) {
NetworkGamer* g = e.getGamerProperty();
if (!g->getIsLocalProperty()) {
remotePositions_[g] = Vector2(300.0f, 300.0f);
}
};
session->GamerLeft += [this](System::Object*, const GamerLeftEventArgs& e) {
remotePositions_.erase(e.getGamerProperty());
};
session->SessionEnded += [](System::Object*, const NetworkSessionEndedEventArgs&) {
// Host quit, you were removed, or the connection dropped.
};
session->Update(); // pump events each frame; the += on GamerJoined above already replayed the gamers present
Calling Update() once immediately after subscribing is optional. Subscribing to GamerJoined replays the gamers already present. The local gamers
established by Create()/Join() are already in the session when the factory
returns — you could not possibly have subscribed yet, since the session pointer does not exist until the
factory returns. As in real XNA, the NetworkSession constructor installs a replay hook, so
+= on GamerJoined invokes the new handler immediately for every gamer already in the
session, and no extra Update() is needed for those initial join events (a later
Update() does not deliver them twice). Gamers who join afterwards still arrive through
Update().
Lobby, game start, and game end
A session is in one of three NetworkSessionState values: Lobby,
Playing, or Ended. Gamers signal readiness with
NetworkGamer::IsReady; the host polls IsEveryoneReady and calls
StartGame().
if (session->getIsHostProperty() && session->getIsEveryoneReadyProperty()) {
session->StartGame();
session->Update(); // raises GameStarted, moves to Playing
}
// ... when the round is over:
session->EndGame();
session->Update(); // raises GameEnded
session->ResetReady(); // clears every gamer's IsReady flag
Note that EndGame() returns the session to Lobby, not to
Ended. Ended is reached when the local gamer actually leaves the session.
Online sessions: PlayerMatch and Ranked
Online sessions use the same API, but they go through the optional CNA Gamer Services server
(cna-gamer-services-server), which the game finds through configuration outside its code
(environment variables, a cna-title.json manifest or a settings file). Without a configured server,
Create/Find for PlayerMatch or Ranked throw
GamerServicesNotAvailableException. With one:
- Only signed-in server accounts can take part; local offline profiles and guests are refused.
- Sessions are created and found through the server’s session directory, and every datagram, game data and voice alike, travels through the server’s authenticated WebSocket relay over TLS. There is no peer-to-peer path and no NAT traversal, so expect TCP’s head-of-line latency when packets are lost.
- The asynchronous
Begin*calls complete duringGamerServicesDispatcher::Update, which theGamerServicesComponentruns fromGame::Update. - Invitations work (players invite from the Guide; the invited game hears
InviteAcceptedand callsJoinInvited), host migration is decided by the server, andRankeddoes not allow joining a game in progress. - The server is not Xbox LIVE and no public instance is provided: you run your own. Its public-Internet deployment has not been independently qualified.
The Gamer Services page covers configuration, the server and the security model.
What works, and what does not
The networking layer is real, but it is not complete. These limits are all verified against the source, and it is better to design around them now than to discover them mid-project.
| Area | Status |
|---|---|
NetworkSessionType::SystemLink |
Real LAN transport, no server needed. Direct ENet over UDP between the machines, with broadcast discovery. It needs at least one signed-in gamer (a local profile is enough) and works fully offline. The traffic is plain, unauthenticated ENet on your local network: treat every peer as untrusted input. |
NetworkSessionType::Local, LocalWithLeaderboards |
Work as single-process sessions: gamers, events and state transitions all work, with no sockets involved. Packets are not delivered: data sent on a Local or LocalWithLeaderboards session never arrives, not even for the sender (getIsDataAvailableProperty() stays false). |
PlayerMatch, Ranked, JoinInvited |
Through the CNA Gamer Services server only (see Online sessions); refused with GamerServicesNotAvailableException when none is configured. Native builds only. |
| Voice chat | Routed automatically in SystemLink and online sessions when CNA was built with libopus: Opus at 16 kHz, one microphone per machine (player one’s gamer), HasVoice, IsTalking and IsMutedByLocalUser, and EnableSendVoice to stop sending to a gamer. CNA_VOICE=0 turns it off at run time, and Local sessions carry no voice. It was tested in software only; a physical microphone-to-speaker loop has not been verified. |
NetworkMachine::RemoveFromSession() |
Implemented: the host removes another machine and its gamers from the session. |
QualityOfService |
For SystemLink search results the round-trip time and the upstream and downstream bandwidth are measured. Online search results report IsAvailable false. |
AllowHostMigration |
Implemented, best effort, off by default. With the default false the session ends when its host disconnects (XNA/FNA’s reference behaviour). Set to true and every surviving SystemLink peer independently computes the same new host — the lowest remaining wire id — and either promotes itself or reconnects to the promoted peer, and HostChanged is raised. It is a full reconnect, not a seamless hand-over: the remote-gamer roster is rebuilt (fresh NetworkGamer* objects, real GamerLeft and GamerJoined events), and the session still ends if no reachable host remains. Online sessions migrate under the server’s control instead. |
SimulatedLatency, SimulatedPacketLoss |
Genuinely implemented — and this is a place CNA goes beyond its reference. SimulatedLatency drives a real per-session deferred-delivery queue; SimulatedPacketLoss drives a real probabilistic drop with a seeded RNG (0.0 and 1.0 short-circuit deterministically). Both are scoped to AppData: session-management traffic and a host's relay hop for two other peers are deliberately unaffected. FNA's equivalents are inert auto-properties; CNA's are not. |
| Leaderboards / TrueSkill | The WriteArbitratedLeaderboard, WriteUnarbitratedLeaderboard and WriteTrueSkill events belong to server-backed gameplay: by a reading of the source they are raised only when a session of server accounts commits its leaderboard writes at the host’s EndGame, so a SystemLink session of local profiles never raises them. There is no TrueSkill rating. See Tutorial 123. |
| Browser, Android and Apple | In a browser, SystemLink discovery is empty and the online service, relay and voice are unavailable. An Android build without libcurl has no online service either. A macOS build linked against Apple’s system libcurl (no WebSocket support) refuses the online relay with RELAY_SECURE_WEBSOCKET_UNAVAILABLE; link a WebSocket-capable libcurl such as Homebrew’s. iOS builds have no networking (CNA_ENABLE_NET=OFF). |
In short: build LAN multiplayer on SystemLink, prototype single-process session logic (lobby, readiness, state transitions) on
Local, test your netcode against the real SimulatedLatency/SimulatedPacketLoss
dials, treat host migration as best-effort (it needs AllowHostMigration and a rebuilt roster), and plan online play around a server you operate.
What has been qualified. CNA records its final audit run of this work on Linux (2 October 2026): the GamerServices suite 649 passed and the Net suite 524 passed, with no failures; the two-process loopback test runs on POSIX hosts only. On one Apple M4, CNA’s Apple campaign records the two-process test (8 of 8) and the ENet suites passing after a macOS discovery fix. Windows, a real wide-area network with NAT, and voice on physical audio devices have not been qualified. These are CNA’s records, not re-run for this page.
Changed since alpha.1. The join handshake now completes before Join() returns; the requested gamer limit is preserved; changes to a session’s mutable NetworkSessionProperties replicate to peers; sends that reuse one PacketWriter are trimmed to the bytes written; and PacketReader::ReadColor() now reads the four bytes that PacketWriter::Write(Color) writes (alpha.1 read sixteen). That last one is a wire-format fix: a peer built from alpha.1 and a peer built from this snapshot cannot exchange Color packets, so run both ends of a test from the same revision.
Lockstep vs client-server
Lockstep: all clients simulate the same game state using the same inputs, synchronized frame by frame. Simple to implement but sensitive to latency — one slow client stalls everyone. Best for turn-based or slow-paced games. Client-server: one machine is authoritative; clients send inputs and receive state updates. More complex but handles latency and cheating better. Best for action games.
Serialization of game state
Do not reinterpret-cast packed structs across the wire. PacketWriter already gives you
a portable, length-prefixed encoding for every type you need, and PacketReader reads
them back in the same order. Write a small, explicit encode/decode pair per message type:
#include "Microsoft/Xna/Framework/Net/PacketReader.hpp"
#include "Microsoft/Xna/Framework/Net/PacketWriter.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Net;
enum class MessageId : SharpRuntime::bytecs { PlayerState = 1, Fired = 2 };
struct PlayerState {
Vector2 position;
Vector2 velocity;
SharpRuntime::intcs sequenceNumber;
};
void WritePlayerState(PacketWriter& w, const PlayerState& s) {
w.Write(static_cast<SharpRuntime::bytecs>(MessageId::PlayerState));
w.Write(s.position); // Vector2 overload
w.Write(s.velocity);
w.Write(s.sequenceNumber); // BinaryWriter's int overload
}
PlayerState ReadPlayerState(PacketReader& r) {
PlayerState s;
s.position = r.ReadVector2();
s.velocity = r.ReadVector2();
s.sequenceNumber = r.ReadInt32();
return s;
}
Reading the leading MessageId byte first lets one packet stream carry several message
types. Because a broadcast is delivered to the sender too, dispatch only after checking
sender->getIsLocalProperty().
Latency hiding
Techniques to hide network latency: Client-side prediction — apply your own input immediately, reconcile with server state when it arrives. Interpolation — render remote players at a slightly delayed time using the last two received positions. Dead reckoning — extrapolate remote player positions using last known velocity.
// Simple interpolation between two received positions
Vector2 InterpolateRemote(const Vector2& prev, const Vector2& next,
float alpha) {
return Vector2::Lerp(prev, next, alpha);
}
Working code to read next
Everything on this page is drawn from CNA's own networking demos. They live in CNA's own tree and are built with it
(when CNA_ENABLE_NET is on and the target is not Emscripten), so they are the authoritative reference when this page and the code disagree (one exception: some of them call Dispose() on exit without deleting the session object; your code should do both, as shown above):
| Demo target | What it demonstrates |
|---|---|
cna_demo_net_client_server_arena | Two processes, real SystemLink host and client, per-frame position sync with PacketWriter/PacketReader. The best starting point. |
cna_demo_session_browser | LAN discovery via Find(), a selectable session list, and joining the highlighted entry. |
cna_demo_packet_roundtrip | Every PacketWriter/PacketReader type pair, written and read back with the result verified. |
cna_demo_session_lifecycle_events | Console-only, NetworkSessionType::Local: StartGame()/EndGame() and the resulting state transitions and events. |
cna_demo_qos_probe | Measured QualityOfService round-trip times and live NetworkGamer::RoundtripTime. |
cna_demo_net_avatar_sync | Synchronising richer per-gamer state than a bare position. |
cna_demo_simulated_network_conditions | The SimulatedLatency/SimulatedPacketLoss dials, raised and lowered live. Both genuinely affect AppData delivery; the measured QualityOfService RTT is a separate discovery-time measurement and does not move with them. |
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Network sessions and the SystemLink protocol — What each NetworkSessionType does in CNA, online sessions through the CNA service relay, the SystemLink wire and discovery protocols, the trust model, delivery guarantees, host migration and the evidence behind them.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-170: A joined SystemLink session answers LAN discovery for itself and handles ClientHello as if it were the host — Every SystemLink session, including a joined one, registers with discovery and ReplyToQuery ignores IsHost, so Find lists a session once per member and joining through a client's entry attaches to that client instead of
- CNA-BUG-248: PacketReader::ReadColor and PacketWriter::Write(Color) doc comments still say ReadColor reads four floats and is not the inverse of Write(Color) — Since c4561fd2b ReadColor reads the four bytes Write(Color) writes and a test asserts the round trip, but both public header comments and a test-file comment still describe a four-float asymmetry preserved from 'upstream
- CNA-BUG-250: LocalNetworkGamer::SendData in a Local or LocalWithLeaderboards session silently drops every packet, so neither the sender nor another local gamer ever receives it — NetworkSession::Update raises PacketSend events only when real networking is enabled (SystemLink, or a service-backed PlayerMatch/Ranked session), so SendData in a Local or LocalWithLeaderboards session reports success a
- CNA-PLAT-012: Under Emscripten, SystemLink discovery is empty, hosting binds the fixed port 61191 and Join returns before the handshake — The web build stubs out discovery (Find returns nothing), hosts on a fixed port only a Node.js relay can use, rebuilds a joining session as an outbound-only client and returns from Join before the server welcome; Node.js