Tutorial 97: Networking and Multiplayer Basics
What you’ll learn
- Enabling CNA's XNA-compatible networking API in your build.
- Signing in, hosting, finding and joining a session.
- Sending and receiving packets, and handling session events.
- Lockstep versus client-server, and what the API does not cover.
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). Networking is opt-in at build configure time.
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.
The transport is real: peer-to-peer ENet over UDP. CNA vendors ENet and wraps it behind
the XNA API surface, so NetworkSession is reliable/unreliable UDP with packet sequencing,
not a stub. LAN session discovery is a real broadcast protocol on UDP port 61190, and the
QualityOfService attached to a discovered session carries a genuinely measured
round-trip time, sampled from the wall-clock delay between the discovery query and the host's reply.
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, GamerServicesDispatcher
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.
CNA's own networking demos are gated on CNA_ENABLE_NET AND NOT EMSCRIPTEN. The Net
library itself builds under Emscripten, but the demos that open real UDP sockets are excluded there,
because a browser page cannot open one.
Signing in a local gamer
Before any session call, populate Gamer::SignedInGamers by initializing the
GamerServices dispatcher. GamerServicesDispatcher::Initialize() takes an
System::IServiceProvider& but never dereferences it, so a null implementation is
sufficient:
#include "Microsoft/Xna/Framework/GamerServices/Gamer.hpp"
#include "Microsoft/Xna/Framework/GamerServices/GamerServicesDispatcher.hpp"
#include "Microsoft/Xna/Framework/GamerServices/SignedInGamerCollection.hpp"
#include "System/IServiceProvider.hpp"
using namespace Microsoft::Xna::Framework;
using Microsoft::Xna::Framework::GamerServices::SignedInGamer;
class NullServiceProvider : public System::IServiceProvider {
public:
[[nodiscard]] void* GetService(const std::type_info&) const override {
return nullptr;
}
};
NullServiceProvider services;
GamerServices::GamerServicesDispatcher::Initialize(services);
SignedInGamer* localGamer =
(*GamerServices::Gamer::getSignedInGamersProperty())[0];
Hosting a session
NetworkSession::Create() returns a raw NetworkSession* that
you own. Call Dispose() when you are done with it — that is what tears
down the ENet host and frees every gamer object the session created.
#include "Microsoft/Xna/Framework/Net/NetworkSession.hpp"
#include "Microsoft/Xna/Framework/Net/LocalNetworkGamer.hpp"
using namespace Microsoft::Xna::Framework::Net;
// sessionType, maxLocalGamers, maxGamers
NetworkSession* session =
NetworkSession::Create(NetworkSessionType::SystemLink, 1, 8);
LocalNetworkGamer* me = session->getLocalGamersProperty()[0];
// ... later, exactly once:
session->Dispose();
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.
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.
Discovery is gated to SystemLink, and does nothing on the web.
Find() returns an empty collection for every other session type — that is the gate
working, not a network failure — and it does nothing at all under Emscripten, where a browser page
cannot open a UDP socket. Do not build a session browser that treats "no results" as "still searching"
on those paths.
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) {
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.
SendDataOptions has five members:
| Value | Guarantee |
|---|---|
None | May be dropped or reordered. Cheapest; right for per-frame position updates. |
Reliable | Guaranteed to arrive, order unspecified. |
InOrder | Guaranteed in order relative to other in-order packets. |
ReliableInOrder | Guaranteed to arrive, in order. Right for one-shot events like "player fired". |
Chat | Marks the packet as chat data. |
XNA marked this enum [Flags], but its members use plain sequential values, not
power-of-two bits — ReliableInOrder is a discrete member, not
Reliable | InOrder. CNA ports it as a plain enum with no bitwise operators, matching
the actual runtime behaviour.
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.
#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(); // required: delivers the initial local-gamer join events
Call Update() once immediately after subscribing. The local gamers
established by Create()/Join() are queued at construction time — you
could not possibly have subscribed yet, since the session pointer does not exist until the factory
returns. Real XNA replayed GamerJoined on subscription; C++ has no equivalent hook, so
the one extra Update() is the intended, permanent pattern, not a workaround.
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.
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 transport. This is the only session type that opens an ENet host and puts bytes on the wire. |
NetworkSessionType::Local |
Fully functional as a single-process session: gamers, events, state transitions, and the local packet queue all work. No sockets involved. |
PlayerMatch, Ranked |
Create and behave as sessions, but no networking is started. Correctly so — matchmaking and ranked play required Xbox Live, which has no open replacement. |
| Voice chat | No-op by design. EnableSendVoice() and SendPartyInvites() have empty bodies; HasVoice/IsTalking never become true. |
NetworkMachine::RemoveFromSession() |
Throws NotImplementedException, matching FNA's own stub. Remove gamers through the session instead. |
QualityOfService bandwidth |
BytesPerSecondUpstream and BytesPerSecondDownstream are hardcoded to 0. Discovery is connectionless UDP with no established peer to measure throughput from. The round-trip time fields are genuinely measured. |
AllowHostMigration |
Stored and readable, but migration is not implemented. If the host leaves, the session ends. |
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 delegates are wired and can be raised, but nothing in CNA ever raises them. |
In short: build LAN multiplayer on SystemLink, prototype single-process logic on
Local, test your netcode against the real SimulatedLatency/SimulatedPacketLoss
dials, and do not plan around voice, host migration or matchmaking.
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 compile and run against the current source, so they are the authoritative reference when this page and the code disagree:
| 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. |