Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 01:30:33 -05:00
commit 2007fb7edc
200 files changed
+27710

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# The server. Built as part of the game's desktop build, or on its own (for the VPS, without SDL):
# cmake -S server -B build-server -DCMAKE_BUILD_TYPE=Release && cmake --build build-server
cmake_minimum_required(VERSION 3.24)
project(smack_a_brother_server LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(EXTERNAL ${CMAKE_CURRENT_SOURCE_DIR}/../external)
if(NOT TARGET datachannel-static)
set(NO_MEDIA ON CACHE BOOL "" FORCE)
set(NO_EXAMPLES ON CACHE BOOL "" FORCE)
set(NO_TESTS ON CACHE BOOL "" FORCE)
add_subdirectory(${EXTERNAL}/libdatachannel ${CMAKE_BINARY_DIR}/libdatachannel EXCLUDE_FROM_ALL)
endif()
if(NOT TARGET simdjson)
add_library(simdjson STATIC ${EXTERNAL}/simdjson/singleheader/simdjson.cpp)
target_include_directories(simdjson SYSTEM PUBLIC ${EXTERNAL}/simdjson/singleheader)
target_compile_definitions(simdjson PUBLIC SIMDJSON_EXCEPTIONS=0)
endif()
add_executable(smack_a_brother_server server.cpp)
target_include_directories(smack_a_brother_server PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src)
target_link_libraries(smack_a_brother_server PRIVATE LibDataChannel::LibDataChannelStatic simdjson)
set_target_properties(smack_a_brother_server PROPERTIES OUTPUT_NAME smack-a-brother-server)
+240
View File
@@ -0,0 +1,240 @@
// 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 the server passes WebRTC signaling between a lobby's members so they can connect
// to each other directly. Nothing of a match passes through here. See src/protocol.h for the messages.
//
// smack-a-brother-server [port] (listens on 127.0.0.1, port 47800 by default)
#include "protocol.h"
#include <rtc/rtc.hpp>
#include <simdjson.h>
#include <algorithm>
#include <csignal>
#include <cstdio>
#include <map>
#include <mutex>
#include <random>
#include <thread>
#include <vector>
constexpr uint16_t PORT_DEFAULT = 47800;
constexpr size_t MESSAGE_MAX = 16384;
constexpr int CODE_TRIES = 100;
// Someone connected: their id, name, socket, and the lobby they're in (empty until they make or join one).
struct Member
{
std::string name;
std::shared_ptr<rtc::WebSocket> socket;
std::string code;
};
// Every member and lobby (codes to member ids, in the order they joined, the first hosting). The WebSocket callbacks
// run on the library's threads, so everything here is touched under the lock.
static std::mutex lock;
static std::map<int, Member> members;
static std::map<std::string, std::vector<int>> lobbies;
static simdjson::dom::parser parser;
static std::mt19937 rng(std::random_device{}());
static int nextId = 1;
static void send(int id, const std::string& message)
{
auto member = members.find(id);
if (member != members.end() && member->second.socket->isOpen())
member->second.socket->send(message);
}
static void error_send(int id, const char* message)
{
send(id, "{\"type\":\"error\",\"message\":" + json_quote(message) + "}");
}
// A name as the lobby shows it: printable characters only, cut to length, or "Player" when nothing's left.
static std::string name_clean(std::string_view raw)
{
std::string name;
for (char c : raw)
if (c >= ' ' && c <= '~' && (int)name.size() < PLAYER_NAME_MAX)
name += c;
return name.empty() ? "Player" : name;
}
static std::string code_make()
{
std::uniform_int_distribution<int> letter(0, (int)sizeof(LOBBY_CODE_LETTERS) - 2);
for (int attempt = 0; attempt < CODE_TRIES; attempt++)
{
std::string code;
for (int i = 0; i < LOBBY_CODE_LENGTH; i++)
code += LOBBY_CODE_LETTERS[letter(rng)];
if (!lobbies.contains(code))
return code;
}
return "";
}
// Puts a member into a lobby and tells everyone: the newcomer gets the whole lobby, the rest get the newcomer.
static void lobby_enter(int id, const std::string& code)
{
std::vector<int>& lobby = lobbies[code];
lobby.push_back(id);
members[id].code = code;
std::string list;
for (int other : lobby)
list += (list.empty() ? "" : ",") + std::string("{\"id\":") + std::to_string(other) +
",\"name\":" + json_quote(members[other].name) + "}";
send(id, "{\"type\":\"welcome\",\"id\":" + std::to_string(id) + ",\"code\":" + json_quote(code) +
",\"host\":" + std::to_string(lobby.front()) + ",\"members\":[" + list + "]}");
for (int other : lobby)
if (other != id)
send(other, "{\"type\":\"member_join\",\"id\":" + std::to_string(id) +
",\"name\":" + json_quote(members[id].name) + "}");
}
// Takes a member out of its lobby, telling the rest (and who hosts now, and why if they were kicked). An empty lobby is
// gone.
static void lobby_exit(int id, const std::string& kick = "", bool isKicked = false)
{
std::string code = members[id].code;
if (code.empty())
return;
members[id].code.clear();
std::vector<int>& lobby = lobbies[code];
std::erase(lobby, id);
if (lobby.empty())
{
lobbies.erase(code);
std::printf("Lobby %s closed (%zu open)\n", code.c_str(), lobbies.size());
return;
}
std::string reason = isKicked ? ",\"kicked\":true,\"reason\":" + json_quote(kick) : "";
for (int other : lobby)
send(other, "{\"type\":\"member_leave\",\"id\":" + std::to_string(id) +
",\"host\":" + std::to_string(lobby.front()) + reason + "}");
}
static void message_handle(int id, const std::string& text)
{
simdjson::dom::element message;
std::string_view type;
if (parser.parse(text).get(message) || message["type"].get(type))
return error_send(id, "Bad message");
// Answered, so proxies see the connection busy both ways.
if (type == "ping")
return send(id, "{\"type\":\"pong\"}");
if (type == "create" || type == "join")
{
int64_t version = 0;
std::string_view name;
if (message["version"].get(version) || version != PROTOCOL_VERSION)
return error_send(id, "Different game version");
if (!members[id].code.empty())
return error_send(id, "Already in a lobby");
members[id].name = name_clean(message["name"].get(name) ? "" : name);
if (type == "create")
{
std::string code = code_make();
if (code.empty())
return error_send(id, "No free lobby codes");
std::printf("Lobby %s opened (%zu open)\n", code.c_str(), lobbies.size() + 1);
return lobby_enter(id, code);
}
std::string_view raw;
if (message["code"].get(raw))
return error_send(id, "No code");
std::string code;
for (char c : raw)
code += (char)std::toupper((unsigned char)c);
auto lobby = lobbies.find(code);
if (lobby == lobbies.end())
return error_send(id, "No lobby with that code");
if ((int)lobby->second.size() >= LOBBY_MEMBERS_MAX)
return error_send(id, "That lobby is full");
return lobby_enter(id, code);
}
if (type == "leave")
return lobby_exit(id);
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
// out (their connection to the server stays; they can join a lobby again).
int64_t target = 0;
std::string_view raw;
std::string& code = members[id].code;
if (message["id"].get(target) || code.empty() || lobbies[code].front() != id || target == id)
return error_send(id, "Only the host can kick");
auto kicked = members.find((int)target);
if (kicked == members.end() || kicked->second.code != code)
return error_send(id, "No such player in the lobby");
std::string reason = text_clean(message["reason"].get(raw) ? "" : raw, KICK_REASON_MAX);
send((int)target, "{\"type\":\"kicked\",\"reason\":" + json_quote(reason) + "}");
std::printf("Lobby %s: %d kicked %d\n", code.c_str(), id, (int)target);
return lobby_exit((int)target, reason, true);
}
if (type == "signal")
{
// Passed on as it came, only between members of the same lobby.
int64_t to = 0;
simdjson::dom::element payload;
if (message["to"].get(to) || message["payload"].get(payload))
return error_send(id, "Bad signal");
auto target = members.find((int)to);
if (target == members.end() || members[id].code.empty() || target->second.code != members[id].code)
return;
send((int)to,
"{\"type\":\"signal\",\"from\":" + std::to_string(id) + ",\"payload\":" + simdjson::minify(payload) + "}");
return;
}
error_send(id, "Unknown message");
}
static volatile std::sig_atomic_t isStopping = 0;
int main(int argc, char** argv)
{
std::setvbuf(stdout, nullptr, _IOLBF, 0);
rtc::InitLogger(rtc::LogLevel::Warning);
rtc::WebSocketServerConfiguration config;
config.port = argc > 1 ? (uint16_t)std::atoi(argv[1]) : PORT_DEFAULT;
config.bindAddress = "127.0.0.1";
config.maxMessageSize = MESSAGE_MAX;
rtc::WebSocketServer server(config);
server.onClient([](std::shared_ptr<rtc::WebSocket> socket) {
std::lock_guard guard(lock);
int id = nextId++;
members[id] = {.socket = socket};
socket->onMessage([id](rtc::message_variant data) {
if (!std::holds_alternative<std::string>(data))
return;
std::lock_guard guard(lock);
message_handle(id, std::get<std::string>(data));
});
socket->onClosed([id]() {
std::lock_guard guard(lock);
if (!members.contains(id))
return;
lobby_exit(id);
members.erase(id);
});
});
std::printf("Server listening on 127.0.0.1:%d\n", server.port());
std::signal(SIGINT, [](int) { isStopping = 1; });
std::signal(SIGTERM, [](int) { isStopping = 1; });
while (!isStopping)
std::this_thread::sleep_for(std::chrono::milliseconds(200));
std::printf("Server stopping\n");
// Closing calls back into onClosed, which takes the lock, so the sockets are gathered first.
std::vector<std::shared_ptr<rtc::WebSocket>> sockets;
{
std::lock_guard guard(lock);
for (auto& [id, member] : members)
sockets.push_back(member.socket);
}
for (auto& socket : sockets)
socket->close();
return 0;
}