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

+123 -14
View File
@@ -1,10 +1,17 @@
// The server for Smack a Brother. Players connect over a WebSocket (behind nginx, which handles TLS), make a lobby or
// join one by its code, and play in it here: the server runs every lobby's sandbox and the games queued in it, each
// join one by its code, then open a WebRTC connection to the server through it (signaling over the socket), and play
// over that: the server runs every lobby's sandbox and the games queued in it, each
// a room, stepping them all at the game's tick rate on one thread. Players send their controls ahead of time; each
// tick takes whatever has come (repeating a player's last controls when theirs are late) and sends every player in
// the room the controls it played, which their game replays to confirm its own. See src/protocol.h for the messages.
//
// smack-a-brother-server [port] (listens on 127.0.0.1, port 47800 by default)
// The WebRTC connection has two data channels: a reliable one for everything that mustn't go missing, and a lossy one
// for frames, which are sent again until confirmed. Until both are open (or if they never open, where UDP is blocked),
// the binary messages go over the WebSocket instead.
//
// smack-a-brother-server [port] [first UDP port] [last UDP port]
// (listens on 127.0.0.1, port 47800 by default, and takes WebRTC on UDP 47810 to 47899, which the firewall must let
// through)
#include "protocol.h"
#include "sim.h"
@@ -23,6 +30,10 @@
#include <vector>
constexpr uint16_t PORT_DEFAULT = 47800;
constexpr uint16_t UDP_FIRST_DEFAULT = 47810;
constexpr uint16_t UDP_LAST_DEFAULT = 47899;
constexpr const char STUN_SERVER[] = "stun:stun.l.google.com:19302";
constexpr const char INPUTS_LABEL[] = "inputs";
constexpr size_t MESSAGE_MAX = 65536;
constexpr int CODE_TRIES = 100;
constexpr int FRAME_RING = 256;
@@ -57,7 +68,7 @@ struct Room
int tick = 0;
};
// Someone connected: their name and socket, their lobby (empty until they make or join one), the room they play in
// Someone connected: their name, socket and WebRTC connection (with its reliable channel and lossy one), their lobby (empty until they make or join one), the room they play in
// (0 for none), the last tick of it they've confirmed, how early their controls last came, and the fighter they
// brought, kept while they're away in a game so they come back to the sandbox as they left.
struct Member
@@ -66,6 +77,9 @@ struct Member
std::string name;
std::string code;
std::shared_ptr<rtc::WebSocket> socket;
std::shared_ptr<rtc::PeerConnection> connection;
std::shared_ptr<rtc::DataChannel> channel;
std::shared_ptr<rtc::DataChannel> inputs;
int ack = -1;
int lead = 0;
int room = 0;
@@ -78,11 +92,13 @@ struct Lobby
std::vector<int> rooms;
};
// What the network's threads hand the game loop: a member's connection opening or closing, or a message from them.
// What the network's threads hand the game loop: a member's connection opening or closing, one of their data channels
// arriving, or a message from them.
struct Arrival
{
std::string bytes;
std::shared_ptr<rtc::WebSocket> socket;
std::shared_ptr<rtc::DataChannel> channel;
int id = 0;
bool isBinary = false;
bool isClosed = false;
@@ -97,27 +113,99 @@ static std::map<std::string, Lobby> lobbies;
static std::map<int, Room> rooms;
static simdjson::dom::parser parser;
static std::mt19937 rng(std::random_device{}());
static rtc::Configuration peerConfig;
static int nextRoom = 1;
// A connection can drop between checking it and sending, which throws. It's let go: its closing comes in later.
static void send(int id, const std::string& message, bool isBinary = false)
// Sends a member a lobby message (text) or a game one (binary): binary goes over their data channels once both are
// open (the lossy one for frames), and the socket until then. A connection can drop between checking it and sending,
// which throws. It's let go: its closing comes in later.
static void send(int id, const std::string& message, bool isBinary = false, bool isLossy = false)
{
auto member = members.find(id);
if (member == members.end() || !member->second.socket->isOpen())
auto found = members.find(id);
if (found == members.end())
return;
Member& member = found->second;
auto data = reinterpret_cast<const std::byte*>(message.data());
bool isLinked = member.channel && member.channel->isOpen() && member.inputs && member.inputs->isOpen();
try
{
if (isBinary)
member->second.socket->send(rtc::binary(data, data + message.size()));
else
member->second.socket->send(message);
if (isBinary && isLinked)
(isLossy ? member.inputs : member.channel)->send(data, message.size());
else if (isBinary && member.socket->isOpen())
member.socket->send(rtc::binary(data, data + message.size()));
else if (member.socket->isOpen())
member.socket->send(message);
}
catch (const std::exception&)
{
}
}
// Answers a member's WebRTC offer with a connection of the server's own, passing its description and candidates back
// over the socket. Their data channels come to the game loop as they arrive, and their messages like the socket's.
static void peer_open(int id)
{
Member& member = members[id];
if (member.connection)
member.connection->close();
member.connection = std::make_shared<rtc::PeerConnection>(peerConfig);
std::weak_ptr<rtc::WebSocket> socket = member.socket;
auto signal_send = [socket](const std::string& payload) {
auto open = socket.lock();
if (open && open->isOpen())
open->send("{\"type\":\"signal\",\"payload\":" + payload + "}");
};
member.connection->onLocalDescription([signal_send](rtc::Description description) {
signal_send("{\"sdp\":" + json_quote(std::string(description)) +
",\"kind\":" + json_quote(description.typeString()) + "}");
});
member.connection->onLocalCandidate([signal_send](rtc::Candidate candidate) {
signal_send("{\"candidate\":" + json_quote(candidate.candidate()) + ",\"mid\":" + json_quote(candidate.mid()) +
"}");
});
member.connection->onDataChannel([id](std::shared_ptr<rtc::DataChannel> channel) {
std::weak_ptr<rtc::DataChannel> weak = channel;
channel->onMessage([id, weak](rtc::message_variant data) {
// Pings are answered at once, so they time the connection alone.
if (std::holds_alternative<std::string>(data))
{
std::string& text = std::get<std::string>(data);
auto open = weak.lock();
if (open && text.starts_with("ping:"))
open->send("pong:" + text.substr(5));
return;
}
rtc::binary& binary = std::get<rtc::binary>(data);
std::lock_guard guard(lock);
arrivals.push_back({.bytes = std::string(reinterpret_cast<const char*>(binary.data()), binary.size()),
.id = id,
.isBinary = true});
});
std::lock_guard guard(lock);
arrivals.push_back({.channel = channel, .id = id});
});
}
// A description or candidate for a member's connection, from their game.
static void signal_handle(int id, simdjson::dom::element payload)
{
std::string_view sdp;
std::string_view kind;
std::string_view candidate;
std::string_view mid;
Member& member = members[id];
if (!payload["sdp"].get(sdp) && !payload["kind"].get(kind))
{
if (kind == "offer")
peer_open(id);
if (member.connection)
member.connection->setRemoteDescription(rtc::Description(std::string(sdp), std::string(kind)));
return;
}
if (!payload["candidate"].get(candidate) && !payload["mid"].get(mid) && member.connection)
member.connection->addRemoteCandidate(rtc::Candidate(std::string(candidate), std::string(mid)));
}
static void error_send(int id, const char* message)
{
send(id, "{\"type\":\"error\",\"message\":" + json_quote(message) + "}");
@@ -300,6 +388,13 @@ static void text_handle(int id, const std::string& text)
}
if (type == "leave")
return lobby_exit(id);
if (type == "signal")
{
simdjson::dom::element payload;
if (message["payload"].get(payload) || members[id].code.empty())
return error_send(id, "Bad signal");
return signal_handle(id, payload);
}
if (type == "kick")
{
// Only a lobby's host can kick, and only someone else in their lobby. The kicked member is told why, then is
@@ -539,7 +634,7 @@ static void frames_send(int serial)
for (int i = 0; i < frame.count; i++)
packet.put(frame.controls[i]);
}
send(id, packet.bytes, true);
send(id, packet.bytes, true, true);
}
if (room.tick % CHECKSUM_INTERVAL == 0)
for (auto& [id, member] : members)
@@ -585,9 +680,14 @@ static void arrivals_take()
{
if (arrival.socket)
members[arrival.id] = {.socket = arrival.socket};
else if (arrival.channel && members.contains(arrival.id))
(arrival.channel->label() == INPUTS_LABEL ? members[arrival.id].inputs : members[arrival.id].channel) =
arrival.channel;
else if (arrival.isClosed)
{
lobby_exit(arrival.id);
if (members[arrival.id].connection)
members[arrival.id].connection->close();
members.erase(arrival.id);
}
else if (members.contains(arrival.id))
@@ -606,6 +706,10 @@ int main(int argc, char** argv)
config.bindAddress = "127.0.0.1";
config.maxMessageSize = MESSAGE_MAX;
rtc::WebSocketServer server(config);
peerConfig.iceServers.emplace_back(STUN_SERVER);
peerConfig.portRangeBegin = argc > 2 ? (uint16_t)std::atoi(argv[2]) : UDP_FIRST_DEFAULT;
peerConfig.portRangeEnd = argc > 3 ? (uint16_t)std::atoi(argv[3]) : UDP_LAST_DEFAULT;
peerConfig.maxMessageSize = MESSAGE_MAX;
int nextId = 1;
server.onClient([&nextId](std::shared_ptr<rtc::WebSocket> socket) {
@@ -629,7 +733,8 @@ int main(int argc, char** argv)
arrivals.push_back({.id = id, .isClosed = true});
});
});
std::printf("Server listening on 127.0.0.1:%d\n", server.port());
std::printf("Server listening on 127.0.0.1:%d, WebRTC on UDP %d to %d\n", server.port(), peerConfig.portRangeBegin,
peerConfig.portRangeEnd);
std::signal(SIGINT, [](int) { isStopping = 1; });
std::signal(SIGTERM, [](int) { isStopping = 1; });
@@ -649,6 +754,10 @@ int main(int argc, char** argv)
}
std::printf("Server stopping\n");
for (auto& [id, member] : members)
{
if (member.connection)
member.connection->close();
member.socket->close();
}
return 0;
}