Run lobbies' games on the server

The server now runs every lobby's sandbox and queued games as rooms, stepped at
the tick rate on one thread. Players send controls ahead of time, late ones repeat
their last, and everyone in a room gets back the controls it played, its events
and checksums. Games start from sandbox queues, players return when they leave,
and play-again moves them to a fresh room. Protocol version 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 02:04:36 -05:00
1 parent 9613e85706
commit 655332778c
5 files changed
+585 -97

No files matched your search

+36 -1
View File
@@ -27,7 +27,7 @@
#include <string_view>
#include <type_traits>
constexpr int PROTOCOL_VERSION = 2;
constexpr int PROTOCOL_VERSION = 3;
constexpr int LOBBY_MEMBERS_MAX = 4;
constexpr int LOBBY_CODE_LENGTH = 4;
// Letters only, without I and O, so codes are easy to read out and type.
@@ -38,6 +38,41 @@ constexpr int KICK_REASON_MAX = 60;
constexpr const char SERVER_URL_DEFAULT[] = "wss://shweetz.net/smack-a-brother/ws";
constexpr int SERVER_URL_LENGTH = 128;
// Binary messages between a game and the server once it's in a lobby, each starting with its type. A room is one
// online game (a lobby's sandbox, or a game queued in it), numbered by the server.
//
// Game to server:
// PACKET_PROFILE Profile the fighter this player brings into the lobby
// PACKET_INPUTS room, ack, first, Uint16 count, Controls* this player's controls from a tick on, and the last
// tick it has confirmed
// PACKET_QUEUE Uint8 isOpen, Rules queue a game in the sandbox, or stop queueing it
// PACKET_CHAT Uint16 length, text
// PACKET_TYPING Uint8 isTyping
// PACKET_RENAME Uint16 length, text
//
// Server to game:
// PACKET_STATE room, tick, state the game this player is in now, as a tick starts
// PACKET_EVENT room, serial, RoomEvent the room's events, numbered from 0, in order
// PACKET_FRAMES room, lead, first, Uint16 count, then for each tick: Uint16 events, Uint8 fighters, Controls*
// every fighter's controls as the server played them,
// once the events before them are in, and how early
// (in ticks) this player's last controls came
// PACKET_CHECKSUM room, tick, Uint32 the room's state as that tick starts
// PACKET_CHAT from, Uint16 length, text and the same for typing and renames
enum PacketType : unsigned char
{
PACKET_PROFILE = 1,
PACKET_INPUTS,
PACKET_QUEUE,
PACKET_CHAT,
PACKET_TYPING,
PACKET_RENAME,
PACKET_STATE,
PACKET_EVENT,
PACKET_FRAMES,
PACKET_CHECKSUM
};
// Packets are plain values one after another, as they sit in memory (every platform agrees on these layouts).
struct PacketWriter
{
+31 -31
View File
@@ -22,17 +22,17 @@ constexpr Uint64 RENAME_SLACK = 2000;
constexpr int QUEUE_LEAD = 15;
constexpr Uint32 ARENA_SEED_MIX = 2654435761u;
enum PacketType : Uint8
enum PeerPacket : Uint8
{
PACKET_JOIN_REQUEST = 1,
PACKET_EVENT,
PACKET_STATE,
PACKET_CHECKSUM,
PACKET_INPUTS,
PACKET_CHAT,
PACKET_TYPING,
PACKET_RENAME,
PACKET_QUEUE
PEER_JOIN_REQUEST = 1,
PEER_EVENT,
PEER_STATE,
PEER_CHECKSUM,
PEER_INPUTS,
PEER_CHAT,
PEER_TYPING,
PEER_RENAME,
PEER_QUEUE
};
// Field by field, since the padding between them is whatever was in memory.
@@ -440,7 +440,7 @@ void Session::members_watch(Game& game)
if (!isJoined && !isRequested && net.is_connected_all() && net.hostId != self)
{
Profile profile = profile_get(game);
net.send(net.hostId, PacketWriter().put(PACKET_JOIN_REQUEST).put(profile).bytes, true);
net.send(net.hostId, PacketWriter().put(PEER_JOIN_REQUEST).put(profile).bytes, true);
isRequested = true;
}
if (!isJoined || net.hostId != self)
@@ -469,11 +469,11 @@ void Session::packets_take(Game& game)
void Session::packet_handle(Game& game, int from, const std::string& bytes, bool isReliable)
{
PacketReader reader = {bytes};
PacketType type;
PeerPacket type;
if (!reader.get(type))
return;
if (type == PACKET_INPUTS && !isReliable)
if (type == PEER_INPUTS && !isReliable)
{
int senderTick = 0;
int ack = 0;
@@ -496,7 +496,7 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool
}
return;
}
if (type == PACKET_QUEUE)
if (type == PEER_QUEUE)
{
Uint8 isOpen = 0;
Rules rules;
@@ -504,14 +504,14 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool
queue_decide(game, from, isOpen != 0, rules);
return;
}
if (type == PACKET_JOIN_REQUEST)
if (type == PEER_JOIN_REQUEST)
{
Profile profile;
if (reader.get(profile) && isJoined && game.net.hostId == game.net.selfId)
join_grant(game, from, profile);
return;
}
if (type == PACKET_EVENT)
if (type == PEER_EVENT)
{
SessionEvent event;
int first = 0;
@@ -530,7 +530,7 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool
event_add(game, event);
return;
}
if (type == PACKET_STATE && !isJoined)
if (type == PEER_STATE && !isJoined)
{
int at = 0;
if (!reader.get(at))
@@ -559,15 +559,15 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool
local_find(game);
return;
}
if (type == PACKET_CHAT || type == PACKET_RENAME)
if (type == PEER_CHAT || type == PEER_RENAME)
{
// Text from another player is cleaned before it's used (see text_clean), whatever their game sent.
Uint8 isQuiet = 0;
Uint16 length = 0;
if ((type == PACKET_RENAME && !reader.get(isQuiet)) || !reader.get(length) || reader.at + length > bytes.size())
if ((type == PEER_RENAME && !reader.get(isQuiet)) || !reader.get(length) || reader.at + length > bytes.size())
return;
std::string_view raw(bytes.data() + reader.at, length);
if (type == PACKET_CHAT)
if (type == PEER_CHAT)
{
std::string text = text_clean(raw, CHAT_MESSAGE_MAX);
if (!text.empty())
@@ -592,14 +592,14 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool
game.chat.system(before + " changed their name to " + name_get(game, from) + ".");
return;
}
if (type == PACKET_TYPING)
if (type == PEER_TYPING)
{
Uint8 isTyping = 0;
if (reader.get(isTyping))
game.chat.typing[from] = isTyping != 0;
return;
}
if (type == PACKET_CHECKSUM)
if (type == PEER_CHECKSUM)
{
int at = 0;
Uint32 value = 0;
@@ -636,7 +636,7 @@ void Session::join_grant(Game& game, int member, const Profile& profile)
if (pending.tick >= from && !(pending.member == member && pending.type == EVENT_JOIN))
event_send(game, member, pending, 0, -1);
PacketWriter packet;
packet.put(PACKET_STATE).put(from);
packet.put(PEER_STATE).put(from);
packet.bytes += state;
game.net.send(member, packet.bytes, true);
SDL_Log("Session: %d joins at tick %d, from tick %d (%zu bytes)", member, event.tick, from, state.size());
@@ -665,7 +665,7 @@ void Session::event_send(Game& game, int to, const SessionEvent& event, int inpu
for (int at = inputsFrom; at <= inputsTo && log.has(at); at++)
inputs.push_back(log.controls[at % INPUT_RING]);
PacketWriter packet;
packet.put(PACKET_EVENT).put(event).put(inputsFrom).put((Uint16)inputs.size());
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);
@@ -737,7 +737,7 @@ void Session::inputs_send(Game& game)
int last = std::min(ownNext - 1, first + INPUT_PACKET_MAX - 1);
Uint16 count = last >= first ? last - first + 1 : 0;
PacketWriter packet;
packet.put(PACKET_INPUTS).put(tick).put(logs[member].contiguous).put(first).put(count);
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);
@@ -755,7 +755,7 @@ void Session::checksums_exchange(Game& game)
continue;
checksumSent = at;
PacketWriter packet;
packet.put(PACKET_CHECKSUM).put(at).put(checksums[at % SNAPSHOT_RING]);
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);
@@ -837,7 +837,7 @@ void Session::chat_send(Game& game, const std::string& text)
if (!isActive)
return game.chat.say(game, game.net.selfId, text);
PacketWriter packet;
packet.put(PACKET_CHAT).put((Uint16)text.size());
packet.put(PEER_CHAT).put((Uint16)text.size());
packet.bytes += text;
broadcast(game, packet.bytes);
game.chat.say(game, game.net.selfId, text);
@@ -847,7 +847,7 @@ void Session::chat_send(Game& game, const std::string& text)
void Session::typing_send(Game& game, bool isTyping)
{
if (isActive)
broadcast(game, PacketWriter().put(PACKET_TYPING).put((Uint8)isTyping).bytes);
broadcast(game, PacketWriter().put(PEER_TYPING).put((Uint8)isTyping).bytes);
}
// Sends this screen's name to one member (or everyone, for -1). A quiet one isn't announced in the chat.
@@ -855,7 +855,7 @@ void Session::rename_send(Game& game, int to, bool isQuiet)
{
std::string& name = names[game.net.selfId];
PacketWriter packet;
packet.put(PACKET_RENAME).put((Uint8)isQuiet).put((Uint16)name.size());
packet.put(PEER_RENAME).put((Uint8)isQuiet).put((Uint16)name.size());
packet.bytes += name;
if (to < 0)
broadcast(game, packet.bytes);
@@ -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(PACKET_QUEUE).put((Uint8)1).put(rules).bytes, true);
game.net.send(game.net.hostId, 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(PACKET_QUEUE).put((Uint8)0).put(Rules{}).bytes, true);
game.net.send(game.net.hostId, 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.
+18
View File
@@ -73,6 +73,24 @@ void Sim::game_start(Match& lobby, const int* members, int count, const Rules& r
match.start(*this);
}
void Sim::event_apply(const RoomEvent& event)
{
if (event.type == ROOM_JOIN)
match.member_join(*this, event.member, event.profile);
if (event.type == ROOM_LEAVE)
match.member_leave(*this, event.member);
int index = match.fighter_find(*this, event.member);
if (index < 0)
return;
if (event.type == ROOM_QUEUE)
match.queue_open(*this, index, event.rules);
if (event.type == ROOM_UNQUEUE)
{
match.queues[index].isActive = false;
match.isQueueReady[index] = false;
}
}
// Moves a level by a step within the levels there are, cueing whether it went up or down.
void level_step(Sim& sim, int& level, int step)
{
+21
View File
@@ -30,6 +30,26 @@ public:
inline Cues CUES_NONE;
enum RoomEventType
{
ROOM_JOIN,
ROOM_LEAVE,
ROOM_QUEUE,
ROOM_UNQUEUE
};
// A change to who's in an online game, or to what they've queued in the sandbox, set for a tick so the server and every
// player make it at the same point: a member's fighter appearing (from their profile) or going, or a queued game
// opening (by its rules) or closing.
struct RoomEvent
{
int tick = 0;
int type = ROOM_JOIN;
int member = -1;
Profile profile;
Rules rules;
};
// Everything a tick reads and writes, so a game can be rewound to it.
struct Snapshot
{
@@ -57,6 +77,7 @@ struct Sim
void init(Uint32 seed);
void tick();
void game_start(Match& lobby, const int* members, int count, const Rules& rules, Uint32 seed);
void event_apply(const RoomEvent& event);
Snapshot snapshot_save();
void snapshot_load(Snapshot& snapshot);
Uint32 checksum_get();