Tutorial 97: Networking and Multiplayer Basics

CNA — C++ XNA 4.0 reimplementation

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 startTutorial 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:

ValueGuarantee
NoneMay be dropped or reordered. Cheapest; right for per-frame position updates.
ReliableGuaranteed to arrive, order unspecified.
InOrderGuaranteed in order relative to other in-order packets.
ReliableInOrderGuaranteed to arrive, in order. Right for one-shot events like "player fired".
ChatMarks 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.

AreaStatus
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 targetWhat it demonstrates
cna_demo_net_client_server_arenaTwo processes, real SystemLink host and client, per-frame position sync with PacketWriter/PacketReader. The best starting point.
cna_demo_session_browserLAN discovery via Find(), a selectable session list, and joining the highlighted entry.
cna_demo_packet_roundtripEvery PacketWriter/PacketReader type pair, written and read back with the result verified.
cna_demo_session_lifecycle_eventsConsole-only, NetworkSessionType::Local: StartGame()/EndGame() and the resulting state transitions and events.
cna_demo_qos_probeMeasured QualityOfService round-trip times and live NetworkGamer::RoundtripTime.
cna_demo_net_avatar_syncSynchronising richer per-gamer state than a bare position.
cna_demo_simulated_network_conditionsThe 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.