Play over WebRTC to the server

The server answers each player's WebRTC offer (signaled over the socket) with a
connection of its own: frames go on a lossy channel, everything else on a
reliable one, and the socket carries them until both open or where UDP is
blocked. The client's Net now holds one link to the server instead of one per
player. WebRTC takes UDP 47810-47899 by default, set on the command line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 02:09:21 -05:00
1 parent 655332778c
commit dadfff189d
5 files changed
+228 -195

No files matched your search

+1 -3
View File
@@ -696,9 +696,7 @@ void Menu::online_draw(Game& game, Rect area)
Color color = fighter >= 0 && game.sim.match.state == MATCH_PRACTICE ? game.color_get(fighter, true) : ui.tint;
text.draw(game.renderer, line, {.color = color, .position = {area.x, y + TEXT_ROW_INSET}});
float x = area.x + text.size_get(line).x + PADDING * 2;
if (!isSelf && !member.isConnected)
text.draw(game.renderer, "Connecting...", {.color = ui.shade, .position = {x, y + TEXT_ROW_INSET}});
else if (!isSelf)
if (member.ping >= 0)
ui.ping_draw(game, {x, y + TEXT_ROW_INSET}, member.ping, 1);
y += ROW_HEIGHT;
}
+76 -140
View File
@@ -17,9 +17,7 @@ constexpr Uint64 PING_INTERVAL = 1000;
constexpr Uint64 HEARTBEAT_INTERVAL = 20000;
constexpr float NS_PER_MS = 1e6f;
// A direct connection to one other member: the connection and, once they're there, its two data channels: a reliable,
// ordered one for everything that mustn't go missing, and one for inputs, which are sent again in every packet until
// they're acknowledged, so it neither waits on nor resends a lost one.
// The WebRTC connection to the server and, once they're there, its two data channels (see Net).
struct PeerLink
{
std::shared_ptr<rtc::PeerConnection> connection;
@@ -35,7 +33,7 @@ struct PeerLink
struct Net::Impl
{
std::shared_ptr<rtc::WebSocket> socket;
std::map<int, PeerLink> links;
PeerLink link;
std::string pending;
simdjson::dom::parser parser;
Uint64 nextPing = 0;
@@ -44,7 +42,6 @@ struct Net::Impl
struct Delayed
{
Uint64 due;
int to;
bool isReliable;
std::string bytes;
bool isText = false;
@@ -101,10 +98,9 @@ Net::~Net()
// Closes every connection, waiting for nothing; anything still queued is dropped.
void Net::shutdown()
{
for (auto& [id, link] : impl->links)
if (link.connection)
link.connection->close();
impl->links.clear();
if (impl->link.connection)
impl->link.connection->close();
impl->link = {};
if (impl->socket)
impl->socket->close();
impl->socket.reset();
@@ -124,12 +120,12 @@ void Net::lobby_join(Game& game, const char* lobby)
json_quote(game.settings.name) + ",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}");
}
// Leaves the lobby, closing every direct connection but keeping the one to the server.
// Leaves the lobby, closing the WebRTC connection but keeping the socket to the server.
void Net::lobby_leave()
{
for (auto& [id, link] : impl->links)
link.connection->close();
impl->links.clear();
if (impl->link.connection)
impl->link.connection->close();
impl->link = {};
if (impl->socket && impl->socket->isOpen() && state == NET_LOBBY)
impl->socket->send(std::string("{\"type\":\"leave\"}"));
members.clear();
@@ -174,7 +170,13 @@ void Net::request_send(Game& game, const std::string& request)
});
impl->socket->onMessage([this, shared](rtc::message_variant data) {
if (std::holds_alternative<std::string>(data))
{
shared->post([this, text = std::get<std::string>(data)]() { message_handle(text); });
return;
}
rtc::binary& bytes = std::get<rtc::binary>(data);
std::string packet(reinterpret_cast<const char*>(bytes.data()), bytes.size());
shared->post([this, packet]() { packets.push_back(packet); });
});
impl->socket->onClosed([this, shared]() {
shared->post([this]() {
@@ -230,14 +232,10 @@ void Net::message_handle(const std::string& text)
NetMember member;
member.id = entry["id"].get(number) ? -1 : (int)number;
member.name = entry["name"].get(field) ? "" : std::string(field);
member.isConnected = member.id == selfId;
members.push_back(member);
}
state = NET_LOBBY;
// The newcomer reaches out to everyone already here.
for (NetMember& member : members)
if (member.id != selfId)
peer_open(member.id, true);
link_open();
return;
}
if (type == "member_join")
@@ -266,137 +264,85 @@ void Net::message_handle(const std::string& text)
if (!message["kicked"].get(isKicked) && isKicked)
kicks[id] = text_clean(message["reason"].get(field) ? "" : field, KICK_REASON_MAX);
hostId = message["host"].get(number) ? hostId : (int)number;
peer_close(id);
std::erase_if(members, [&](NetMember& member) { return member.id == id; });
return;
}
if (type == "signal")
{
simdjson::dom::element payload;
if (!message["from"].get(number) && !message["payload"].get(payload))
signal_handle((int)number, simdjson::minify(payload));
if (!message["payload"].get(payload))
signal_handle(simdjson::minify(payload));
}
}
// Starts a direct connection to another member. The one offering makes the data channel, which starts the
// negotiation; the other gets it once the offer is answered. Either way, descriptions and candidates go through the
// server.
void Net::peer_open(int id, bool isOfferer)
// Offers the server a WebRTC connection with both data channels, which starts the negotiation. Descriptions and
// candidates go back and forth over the socket. Pongs on the reliable channel time the round trip.
void Net::link_open()
{
rtc::Configuration config;
config.iceServers.emplace_back(STUN_SERVER);
PeerLink& link = impl->links[id];
PeerLink& link = impl->link;
link.connection = std::make_shared<rtc::PeerConnection>(config);
Impl* shared = impl.get();
auto signal_send = [this](int to, const std::string& payload) {
auto signal_send = [this](const std::string& payload) {
if (impl->socket && impl->socket->isOpen())
impl->socket->send("{\"type\":\"signal\",\"to\":" + std::to_string(to) + ",\"payload\":" + payload + "}");
impl->socket->send("{\"type\":\"signal\",\"payload\":" + payload + "}");
};
link.connection->onLocalDescription([shared, signal_send, id](rtc::Description description) {
link.connection->onLocalDescription([shared, signal_send](rtc::Description description) {
std::string payload = "{\"sdp\":" + json_quote(std::string(description)) +
",\"kind\":" + json_quote(description.typeString()) + "}";
shared->post([signal_send, id, payload]() { signal_send(id, payload); });
shared->post([signal_send, payload]() { signal_send(payload); });
});
link.connection->onLocalCandidate([shared, signal_send, id](rtc::Candidate candidate) {
link.connection->onLocalCandidate([shared, signal_send](rtc::Candidate candidate) {
std::string payload =
"{\"candidate\":" + json_quote(candidate.candidate()) + ",\"mid\":" + json_quote(candidate.mid()) + "}";
shared->post([signal_send, id, payload]() { signal_send(id, payload); });
shared->post([signal_send, payload]() { signal_send(payload); });
});
// A member is connected once both its channels are open. The reliable one also answers pings and times pongs.
// Binary messages on either are queued for the session.
auto channel_watch = [this, shared, id](std::shared_ptr<rtc::DataChannel> channel) {
auto watched = impl->links.find(id);
if (watched == impl->links.end())
{
channel->close();
return;
}
bool isReliable = channel->label() != INPUTS_LABEL;
(isReliable ? watched->second.channel : watched->second.inputs) = channel;
channel->onOpen([this, shared, id]() {
shared->post([this, id]() {
auto link = impl->links.find(id);
NetMember* member = member_get(id);
if (member && link != impl->links.end())
member->isConnected = link->second.is_open();
});
});
channel->onClosed([this, shared, id]() {
shared->post([this, id]() {
if (NetMember* member = member_get(id))
member->isConnected = false;
});
});
channel->onMessage([this, shared, id, isReliable](rtc::message_variant data) {
rtc::DataChannelInit lossy;
lossy.reliability.unordered = true;
lossy.reliability.maxRetransmits = 0;
link.channel = link.connection->createDataChannel(CHANNEL_LABEL);
link.inputs = link.connection->createDataChannel(INPUTS_LABEL, lossy);
for (auto& channel : {link.channel, link.inputs})
channel->onMessage([this, shared](rtc::message_variant data) {
if (std::holds_alternative<rtc::binary>(data))
{
rtc::binary& bytes = std::get<rtc::binary>(data);
std::string packet(reinterpret_cast<const char*>(bytes.data()), bytes.size());
shared->post([this, id, isReliable, packet]() {
packets.push_back({.from = id, .isReliable = isReliable, .bytes = packet});
});
shared->post([this, packet]() { packets.push_back(packet); });
return;
}
shared->post([this, id, text = std::get<std::string>(data)]() {
auto link = impl->links.find(id);
if (link == impl->links.end() || !link->second.channel || !link->second.channel->isOpen())
return;
if (text.starts_with("ping:"))
text_send(id, "pong:" + text.substr(5));
else if (text.starts_with("pong:"))
if (NetMember* member = member_get(id))
member->ping = (SDL_GetTicksNS() - SDL_strtoull(text.c_str() + 5, nullptr, 10)) / NS_PER_MS;
std::string& text = std::get<std::string>(data);
if (!text.starts_with("pong:"))
return;
float ping = (SDL_GetTicksNS() - SDL_strtoull(text.c_str() + 5, nullptr, 10)) / NS_PER_MS;
shared->post([this, ping]() {
if (NetMember* member = member_get(selfId))
member->ping = ping;
});
});
};
if (isOfferer)
{
rtc::DataChannelInit unreliable;
unreliable.reliability.unordered = true;
unreliable.reliability.maxRetransmits = 0;
channel_watch(link.connection->createDataChannel(CHANNEL_LABEL));
channel_watch(link.connection->createDataChannel(INPUTS_LABEL, unreliable));
}
else
link.connection->onDataChannel([shared, channel_watch](std::shared_ptr<rtc::DataChannel> channel) {
shared->post([channel_watch, channel]() { channel_watch(channel); });
});
}
void Net::peer_close(int id)
{
auto link = impl->links.find(id);
if (link == impl->links.end())
return;
link->second.connection->close();
impl->links.erase(link);
}
// A description or candidate from another member. An offer from someone not yet connected starts the connection on
// this side.
void Net::signal_handle(int from, const std::string& text)
// A description or candidate from the server for this screen's connection.
void Net::signal_handle(const std::string& text)
{
simdjson::dom::element payload;
std::string_view sdp;
std::string_view kind;
std::string_view candidate;
std::string_view mid;
if (impl->parser.parse(text).get(payload) || !member_get(from))
PeerLink& link = impl->link;
if (impl->parser.parse(text).get(payload) || !link.connection)
return;
if (!payload["sdp"].get(sdp) && !payload["kind"].get(kind))
{
if (!impl->links.contains(from))
peer_open(from, false);
impl->links[from].connection->setRemoteDescription(rtc::Description(std::string(sdp), std::string(kind)));
return;
}
if (!payload["candidate"].get(candidate) && !payload["mid"].get(mid) && impl->links.contains(from))
impl->links[from].connection->addRemoteCandidate(rtc::Candidate(std::string(candidate), std::string(mid)));
link.connection->setRemoteDescription(rtc::Description(std::string(sdp), std::string(kind)));
else if (!payload["candidate"].get(candidate) && !payload["mid"].get(mid))
link.connection->addRemoteCandidate(rtc::Candidate(std::string(candidate), std::string(mid)));
}
// Sends a binary message to another member, if they're connected.
void Net::send(int to, const std::string& bytes, bool isReliable)
// Sends the server a game message, held back or dropped first by the network simulator if it's on.
void Net::send(const std::string& bytes, bool isReliable)
{
if (impl->lag > 0 || impl->loss > 0)
{
@@ -405,36 +351,31 @@ void Net::send(int to, const std::string& bytes, bool isReliable)
return;
if (impl->lag > 0)
{
impl->delayed.push_back({SDL_GetTicks() + impl->lag, to, isReliable, bytes});
impl->delayed.push_back({SDL_GetTicks() + impl->lag, isReliable, bytes});
return;
}
}
send_now(to, bytes, isReliable);
send_now(bytes, isReliable);
}
void Net::send_now(int to, const std::string& bytes, bool isReliable)
// Over the data channels once they're open, or the socket until then.
void Net::send_now(const std::string& bytes, bool isReliable)
{
auto link = impl->links.find(to);
if (link == impl->links.end() || !link->second.is_open())
return;
auto& channel = isReliable ? link->second.channel : link->second.inputs;
channel->send(reinterpret_cast<const rtc::byte*>(bytes.data()), bytes.size());
auto data = reinterpret_cast<const rtc::byte*>(bytes.data());
if (is_linked())
(isReliable ? impl->link.channel : impl->link.inputs)->send(data, bytes.size());
else if (impl->socket && impl->socket->isOpen() && state == NET_LOBBY)
impl->socket->send(rtc::binary(data, data + bytes.size()));
}
// A text message (pings and pongs) on the reliable channel, held back by the network simulator like everything else.
void Net::text_send(int to, const std::string& text)
// Times the round trip to the server over the reliable channel (the network simulator holds it back too).
void Net::ping_send()
{
std::string text = "ping:" + std::to_string(SDL_GetTicksNS());
if (impl->lag > 0)
impl->delayed.push_back({SDL_GetTicks() + impl->lag, to, true, text, true});
else
text_send_now(to, text);
}
void Net::text_send_now(int to, const std::string& text)
{
auto link = impl->links.find(to);
if (link != impl->links.end() && link->second.channel && link->second.channel->isOpen())
link->second.channel->send(text);
impl->delayed.push_back({SDL_GetTicks() + impl->lag, true, text, true});
else if (is_linked())
impl->link.channel->send(text);
}
// Asks the server to put a member out of the lobby (it only does so for the host).
@@ -445,16 +386,13 @@ void Net::kick_send(int member, const std::string& reason)
"}");
}
// Whether a direct connection to every other member is open.
bool Net::is_connected_all()
// Whether the WebRTC connection to the server is up, with both its channels open.
bool Net::is_linked()
{
for (NetMember& member : members)
if (!member.isConnected)
return false;
return state == NET_LOBBY;
return impl->link.is_open();
}
// Once a frame: handles what the network reported (on desktop), pings every connected member, and on desktop keeps the
// Once a frame: handles what the network reported (on desktop), pings the server over the link, and on desktop keeps the
// server connection alive.
void Net::update()
{
@@ -471,17 +409,15 @@ void Net::update()
{
Impl::Delayed due = impl->delayed.front();
impl->delayed.erase(impl->delayed.begin());
if (due.isText)
text_send_now(due.to, due.bytes);
else
send_now(due.to, due.bytes, due.isReliable);
if (due.isText && is_linked())
impl->link.channel->send(due.bytes);
else if (!due.isText)
send_now(due.bytes, due.isReliable);
}
if (state == NET_LOBBY && now >= impl->nextPing)
{
impl->nextPing = now + PING_INTERVAL;
for (auto& [id, link] : impl->links)
if (link.channel && link.channel->isOpen())
text_send(id, "ping:" + std::to_string(SDL_GetTicksNS()));
ping_send();
}
#ifndef SDL_PLATFORM_EMSCRIPTEN
if (now >= impl->nextHeartbeat)
+14 -24
View File
@@ -15,27 +15,19 @@ enum NetState
NET_LOBBY
};
// A binary message from another member, on the reliable channel (in order, never lost) or the inputs one (either may
// fail).
struct NetPacket
{
int from = -1;
bool isReliable = true;
std::string bytes;
};
// Someone in the lobby (this screen's player too): their server id and name, whether a direct connection to them is
// open, and the round trip over it in milliseconds (-1 until measured). This screen's own member is always connected.
// Someone in the lobby (this screen's player too): their server id and name, and their connection's round trip to the
// server in milliseconds (-1 while it isn't known: this screen only measures its own).
struct NetMember
{
int id = -1;
std::string name;
bool isConnected = false;
float ping = -1;
};
// Online play: a WebSocket to the server (see protocol.h) to make or join a lobby and pass signaling, and a
// WebRTC data channel to each other member, connected directly. A newcomer connects out to everyone already there.
// Online play: a WebSocket to the server (see protocol.h) to make or join a lobby, and through it a WebRTC connection
// to the server to play over, with two data channels: a reliable, ordered one for everything that mustn't go missing,
// and a lossy one for controls and frames, which are sent again until they're confirmed, so a lost one is never waited
// on. Until both are open (or where they never open, with UDP blocked), the game's messages go over the socket.
// The network calls back on its own threads (on desktop), so everything it reports is queued and handled in update,
// once a frame on the main thread. Members are in the order they joined, so the first hosts. Error holds why the last
// request failed or the connection was lost, until the next request.
@@ -51,9 +43,9 @@ public:
void update();
void shutdown();
void heartbeat();
void send(int to, const std::string& bytes, bool isReliable);
void send(const std::string& bytes, bool isReliable);
void kick_send(int member, const std::string& reason);
bool is_connected_all();
bool is_linked();
NetState state = NET_OFFLINE;
std::string code;
@@ -61,8 +53,8 @@ public:
int selfId = -1;
int hostId = -1;
std::vector<NetMember> members;
// Binary messages from other members, oldest first, for the session to take.
std::vector<NetPacket> packets;
// Binary messages from the server, oldest first, for the session to take.
std::vector<std::string> packets;
// Lines for the chat log from the server (like this screen being kicked), oldest first, for the chat to take.
std::vector<std::string> notices;
// Members the server said were kicked, with the reason (empty for none given).
@@ -73,12 +65,10 @@ private:
std::unique_ptr<Impl> impl;
void request_send(Game& game, const std::string& request);
void send_now(int to, const std::string& bytes, bool isReliable);
void text_send(int to, const std::string& text);
void text_send_now(int to, const std::string& text);
void send_now(const std::string& bytes, bool isReliable);
void ping_send();
void message_handle(const std::string& text);
void peer_open(int id, bool isOfferer);
void peer_close(int id);
void signal_handle(int from, const std::string& payload);
void link_open();
void signal_handle(const std::string& payload);
NetMember* member_get(int id);
};
+14 -14
View File
@@ -437,10 +437,10 @@ void Session::members_watch(Game& game)
{
Net& net = game.net;
int self = net.selfId;
if (!isJoined && !isRequested && net.is_connected_all() && net.hostId != self)
if (!isJoined && !isRequested && net.is_linked() && net.hostId != self)
{
Profile profile = profile_get(game);
net.send(net.hostId, PacketWriter().put(PEER_JOIN_REQUEST).put(profile).bytes, true);
net.send(PacketWriter().put(PEER_JOIN_REQUEST).put(profile).bytes, true);
isRequested = true;
}
if (!isJoined || net.hostId != self)
@@ -460,10 +460,10 @@ void Session::members_watch(Game& game)
void Session::packets_take(Game& game)
{
std::vector<NetPacket> packets;
std::vector<std::string> packets;
packets.swap(game.net.packets);
for (NetPacket& packet : packets)
packet_handle(game, packet.from, packet.bytes, packet.isReliable);
for (std::string& packet : packets)
packet_handle(game, -1, packet, true);
}
void Session::packet_handle(Game& game, int from, const std::string& bytes, bool isReliable)
@@ -638,7 +638,7 @@ void Session::join_grant(Game& game, int member, const Profile& profile)
PacketWriter packet;
packet.put(PEER_STATE).put(from);
packet.bytes += state;
game.net.send(member, packet.bytes, true);
game.net.send(packet.bytes, true);
SDL_Log("Session: %d joins at tick %d, from tick %d (%zu bytes)", member, event.tick, from, state.size());
}
@@ -668,7 +668,7 @@ void Session::event_send(Game& game, int to, const SessionEvent& event, int inpu
packet.put(PEER_EVENT).put(event).put(inputsFrom).put((Uint16)inputs.size());
for (Controls& controls : inputs)
packet.put(controls);
game.net.send(to, packet.bytes, true);
game.net.send(packet.bytes, true);
}
// Takes an event in: a join says when the newcomer's controls start counting and that they need this screen's from
@@ -740,7 +740,7 @@ void Session::inputs_send(Game& game)
packet.put(PEER_INPUTS).put(tick).put(logs[member].contiguous).put(first).put(count);
for (int at = first; at < first + count; at++)
packet.put(own.controls[at % INPUT_RING]);
game.net.send(member, packet.bytes, false);
game.net.send(packet.bytes, false);
}
}
@@ -758,7 +758,7 @@ void Session::checksums_exchange(Game& game)
packet.put(PEER_CHECKSUM).put(at).put(checksums[at % SNAPSHOT_RING]);
for (auto& [member, peer] : peers)
if (peer.isSending)
game.net.send(member, packet.bytes, true);
game.net.send(packet.bytes, true);
}
for (auto entry = remoteChecksums.begin(); entry != remoteChecksums.end();)
{
@@ -828,7 +828,7 @@ void Session::broadcast(Game& game, const std::string& bytes)
{
for (NetMember& member : game.net.members)
if (member.id != game.net.selfId)
game.net.send(member.id, bytes, true);
game.net.send(bytes, true);
}
// A chat message from this screen: to everyone (when there's anyone), and into this screen's own log.
@@ -860,7 +860,7 @@ void Session::rename_send(Game& game, int to, bool isQuiet)
if (to < 0)
broadcast(game, packet.bytes);
else
game.net.send(to, packet.bytes, true);
game.net.send(packet.bytes, true);
}
// Notices this screen's name or colour changing (in Customize, or by a chat command). A new name goes to everyone once
@@ -941,7 +941,7 @@ void Session::queue_send(Game& game, const Rules& rules)
return;
if (game.net.hostId == game.net.selfId)
return queue_decide(game, game.net.selfId, true, rules);
game.net.send(game.net.hostId, PacketWriter().put(PEER_QUEUE).put((Uint8)1).put(rules).bytes, true);
game.net.send(PacketWriter().put(PEER_QUEUE).put((Uint8)1).put(rules).bytes, true);
}
void Session::unqueue_send(Game& game)
@@ -950,7 +950,7 @@ void Session::unqueue_send(Game& game)
return;
if (game.net.hostId == game.net.selfId)
return queue_decide(game, game.net.selfId, false, {});
game.net.send(game.net.hostId, PacketWriter().put(PEER_QUEUE).put((Uint8)0).put(Rules{}).bytes, true);
game.net.send(PacketWriter().put(PEER_QUEUE).put((Uint8)0).put(Rules{}).bytes, true);
}
// The host sets a queue change for a moment from now and tells everyone.
@@ -1327,6 +1327,6 @@ float Session::ping_get(Game& game, int member)
int to = member == net.selfId ? net.hostId : member;
for (NetMember& netMember : net.members)
if (netMember.id == to)
return netMember.isConnected ? netMember.ping : -1;
return netMember.ping;
return PING_NONE;
}