Replace SpacetimeDB with custom TCP server and packet protocol - #58
Replace SpacetimeDB with custom TCP server and packet protocol#58Enn3Developer wants to merge 11 commits into
Conversation
Remove the SpacetimeDB module and client SDK in favor of an in-house netcode stack built on raw TCP: - OpenPolytopia.Common/Network: wire protocol shared by client and server. Packets are framed as [u32 length][u32 packet id][payload] (big-endian) and serialized through INetworkSerializable, with a PacketRegistrar mapping ids to packet types. Includes handshake, keep alive, player registration and all lobby packets, plus NetworkConnection/ServerConnection/ClientConnection managing framing, send/receive loops and keep-alive timeouts. - OpenPolytopia.Server: new console server replacing StdbModule. GameServer + LobbyManager reimplement the old reducer semantics (SetName, CreateLobby, JoinLobby, LeaveLobby, ready handling) with per-player ready state, lobby broadcasts, disconnect cleanup and the 5-second scheduler that starts games when every player is ready. - Godot client: SpacetimeNode and the generated ModuleBindings are replaced by NetworkNode (autoload), which drains received packets on the main thread each physics frame and exposes typed events plus the observable Lobbies collection. Restores Lobby.cs for Lobby.tscn with a working lobby browser and wires Game.tscn to it. - Tests: packet round-trip and framing tests in the GoDotTest suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
Server: `OpenPolytopia.Server [port] [bind-address]`, falling back to the OPENPOLYTOPIA_PORT/OPENPOLYTOPIA_BIND_ADDRESS environment variables, then to port 6969 on every interface. ServerConnection now accepts an optional bind address. Client: the host and port NetworkNode connects to are resolved from, in order of precedence: `--server-host=`/`--server-port=` command line user args, OPENPOLYTOPIA_SERVER_HOST/OPENPOLYTOPIA_SERVER_PORT environment variables, the open_polytopia/network/server_host and server_port project settings (editable in the Godot editor), and finally the built-in defaults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
NetworkNode is now a [GlobalClass] node that can be added to any scene from the editor, with Host, Port and AutoConnect exported to the inspector (command line and environment overrides still take precedence). The underlying connection and the session state (player id, handshake state, lobbies) are shared between all instances and survive scene changes: the first node entering the tree connects and the following ones reuse the connection, with only the most recent instance pumping received packets. Game.tscn and Lobby.tscn now embed a NetworkNode; the autoload and the open_polytopia project settings section are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
One-line summaries, side notes moved to remarks tags Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
Enn3Developer
left a comment
There was a problem hiding this comment.
Code review of the new TCP netcode stack (reviewed at dbe778f). 10 findings as inline comments, most severe first:
Server robustness (most serious)
- No send timeout + sequential broadcasts: one client that stops reading its socket freezes all lobby broadcasts and the keep-alive/timeout loop (
ServerConnection.cs). - Live
LobbyDatais serialized outside_stateLockduring broadcasts; a concurrent join/leave throws mid-serialization and kicks the innocent recipient (GameServer.cs). Framing the packet once under the lock fixes this and the per-recipient re-serialization.
Client crashes / broken reconnect
- NRE in
_PhysicsProcesswhen a version-mismatch handshake response triggersDisconnect()mid-pump (NetworkNode.cs). - Stale
_disconnectedFlagdestroys the next fresh connection after any explicit disconnect — every other reconnect attempt dies (NetworkNode.cs).
Protocol gaps
- The server replies
Ok=falseto a failed handshake but keeps serving the incompatible client (GameServer.cs). - The keep-alive
Captchais generated but never validated on echo (KeepAlivePacket.cs).
Smaller items
- Lobby browser rebuilds the list on every collection change, wiping the user's selection (
Lobby.cs). Close()'s_closedcheck-then-set can fireOnDisconnectedtwice (NetworkConnection.cs).- Port argument isn't range-validated in
Program.cs(70000crashes with a raw stack trace).
Process note: single-pass review (line-by-line scan of the new Network/Server/client code, removed-behavior audit against the old StdbModule reducers, and cross-file packet-flow tracing); each finding was re-verified against the code before posting.
Generated by Claude Code
- timeout server sends and run broadcasts concurrently so a stalled client can't freeze the server or disable the keep alive kicks - frame packets containing shared lobby data while holding the state lock, broadcasting the pre-built frame to every client; this also removes the per-recipient re-serialization - enforce the handshake server-side: kick clients with an incompatible version and clients that send anything before a successful handshake - make NetworkConnection.Close thread-safe with an atomic flag so OnDisconnected can't fire twice - drop the never-verified keep alive captcha, it's a plain ping now - fix a NullReferenceException in NetworkNode._PhysicsProcess when a packet handler disconnects mid-pump - clear the stale disconnected flag on reconnect so a new connection isn't destroyed by the previous one's disconnection - restore the lobby list selection after a rebuild and keep the list ordered by lobby id - validate the port range in the server entry point Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
|
All 10 review findings are addressed in 1b171c5:
Build passes and the packet round-trip test was updated for the captcha removal. Generated by Claude Code |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
Enn3Developer
left a comment
There was a problem hiding this comment.
Code review of the custom TCP server/protocol stack (single-pass review at high effort; the usual multi-agent fan-out and separate verification pass were unavailable in this context, so all findings were self-verified against the diff in one sitting).
10 findings posted inline, ranked most-severe first:
NetworkNode.Send— packets silently dropped while connecting; can also race ahead of the handshake and get the client kickedGameServer— no per-player/global lobby cap → remote DoS viaCreateLobbyPacketloopPacketProtocol.FramePacket—MAX_PACKET_SIZEunenforced on write; an oversized lobby list disconnects every client that requests itServerConnectionaccept loop — a transientSocketExceptionkills the entire serverClientConnection— no dead-server detection; half-open connections hang the client foreverNetworkNode.ConnectToServerAsync— connection failure never surfaced to the UI, no retry pathLobbyscene — stale_joinedstate after disconnect (soft-lock)GameServer—Tribeunvalidated on create/join- Dead code:
IntSerialization,PacketProtocol.WritePacketAsync(the latter bypasses the write lock) - Client lobby UI rebuilds O(N²) per lobby-list refresh
Also audited the removed SpacetimeDB module for dropped invariants — the new server preserves the old reducer semantics (registration gate, join/leave/ready rules, all-ready → starting, including the pre-existing "solo lobby can start" behavior).
Generated by Claude Code
- gate client sends on the completed handshake - cap lobbies to one per player and 100 per server - enforce the max packet size on the sender too - survive transient accept failures on the server - disconnect the client when the server goes silent - surface failed connection attempts to the scenes - reset the lobby scene state on disconnection - validate the tribe on lobby create and join - remove the unused IntSerialization and WritePacketAsync - coalesce lobby list changes into one event per packet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
|
All 10 findings from the second review round are addressed in 5ea3478:
Generated by Claude Code |
Enn3Developer
left a comment
There was a problem hiding this comment.
Code review at high effort (single-pass, inline — the multi-agent fan-out/verify pipeline was unavailable in this session, so all angles were worked through in one context). 8 findings, ranked most severe first; each is posted as an inline comment on the relevant line.
LobbyManager— all-ready start condition never re-checked when a player leaves/disconnects → lobby can never start.GameServer— lobby broadcasts sent outside the state lock can be delivered out of order → clients keep stale lobby snapshots.ServerConnection— unauthenticated clients can idle forever via keep-alive echoes and receive all lobby broadcasts pre-handshake.NetworkNode.Send— packets silently dropped before handshake completes → dead Play button window.GameServer.SetName— rename not propagated into lobby player data → stale names in broadcasts/GameStarted.NetworkNode.Instance— static singleton keeps referencing a freed node after scene exit (latent trap for the future game scene).NetworkSerialization— unusedList<uint>/List<string>wire helpers (dead code).PacketTest— no coverage for the read side of the wire format (ReadPacketAsyncpaths).
Generated by Claude Code
- Mark a lobby as starting also when the last not-ready player leaves or disconnects, not only on a ready change - Deliver server packets through one ordered queue per client, framed and enqueued while holding the state lock, so lobby updates can't get reordered; broadcasts now reach only handshaked clients - Kick clients that don't complete a handshake within 10 seconds - Queue client packets sent before the handshake completes and flush them right after it, instead of dropping them - Propagate a rename into the lobbies the player joined - Clear NetworkNode.Instance when the active node exits the tree and grab the instance once per scene instead of on every access - Remove the unused List<uint> and List<string> wire helpers - Cover the read side of the wire format and the remaining packets with tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
|
All 8 findings from the third review round are addressed in 41ccac0 (plus 8dea974 for a spellcheck fix):
Verified with a fresh end-to-end run against the real server over sockets (20/20 checks): create/join/rename/ready flows, rename visible in broadcasts, game starting after the last not-ready player leaves and after they disconnect, pre-handshake client receiving zero broadcasts and getting kicked on the deadline, and the wrong-version client receiving the refusal before the kick. Generated by Claude Code |
Enn3Developer
left a comment
There was a problem hiding this comment.
Code review of the custom TCP netcode stack (single-pass review at high effort; the Agent tool was unavailable in this context, so this was one careful pass through all angles rather than the usual multi-agent fan-out + verify pipeline).
6 findings, most severe first:
- NetworkNode.cs:344 — after a disconnect there is no reconnect path; sends queue into
_pendingPacketsforever and the client is stuck until restart. - ServerConnection.cs:284 — unbounded per-client outgoing channel; a deliberately slow-reading client evades the per-write
SEND_TIMEOUTand can grow server memory without bound. - LobbyManager.cs:141 — a game can start with a single player (solo ready, or the last not-ready player leaving/disconnecting).
- Game.cs:46 — a refused name (>32 chars) produces no user-visible feedback; Play appears dead.
- LobbyManager.cs:186 — the
lobby.Startedguards are dead code (started lobbies are always removed from_lobbiesin the same lock scope). - GameServer.cs:136 — redundant
Broadcast(PacketProtocol.FramePacket(...))wrapping at 8 call sites; theBroadcast(IPacket)/SendTo(id, IPacket)overloads already do this.
The wire format, framing/validation, keep-alive/timeout logic, handshake gating, lock discipline around lobby state, and packet-order guarantees all checked out — the protocol layer is solid.
Generated by Claude Code
- Reconnect automatically after a connection loss and drop the packets sent while there is no connection instead of queueing them forever - Bound the per-client outgoing queue and disconnect a client that doesn't drain it, so slow readers can't grow the server memory - Require at least 2 players to start a game, solo games aren't allowed - Surface a refused name and a connection loss in the title screen and limit the name input to the length the server accepts - Remove the dead lobby.Started guards, started lobbies leave the manager - Use the Broadcast(IPacket) overload instead of framing at the call sites Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
|
All 6 findings from the fourth review round are fixed in e1156d7:
Verified with a fresh end-to-end run over real sockets (27/27 checks), including: solo-ready and leave-to-solo lobbies not starting, a 3-player lobby starting with 2 when the last not-ready player leaves or disconnects, rename propagation, pre-handshake exclusion + deadline kick, and the wrong-version refusal-then-kick. Build is clean. Generated by Claude Code |
Enn3Developer
left a comment
There was a problem hiding this comment.
Reviewed the whole diff (protocol/serialization, ServerConnection/ClientConnection, GameServer/LobbyManager, and the Godot NetworkNode/Lobby/Game side) for correctness bugs. The wire format, framing, bounds checks and the state-lock discipline in GameServer all look solid — in particular the framing round-trip, the MAX_PACKET_SIZE symmetry between send and receive, the malformed-payload catch, and the fact that Enqueue/Close/ClientDisconnectedAsync are fire-and-forget so they can't deadlock against _stateLock.
Four findings below: one lobby state-machine bug that lets a game start with a single player, one server-side busy-loop risk in the accept path, and two reconnection issues on the client.
Generated by Claude Code
- Clear the starting flag of a lobby that drops below the minimum players when someone disconnects, so a 1-player game can't start - Back off before retrying a failed accept, so a persistent failure like running out of file descriptors can't busy loop the server - Register the player again after an automatic reconnection, the server forgets the name when the old connection goes away - Apply the reconnect delay to the first retry too Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
|
All 4 findings from the fifth review round are fixed in 6c4e904:
Verified with the end-to-end harness (33/33 checks), including the new scenario: a 2-player all-ready lobby whose second player disconnects does not start within 7 s, accepts a new player afterwards, and starts normally once back at two ready players. Build is clean. Generated by Claude Code |
Removes SpacetimeDB entirely (StdbModule, client SDK, generated ModuleBindings) in favor of an in-house netcode stack built on raw TCP with a packet protocol we define.
Wire protocol (
OpenPolytopia.Common/Network)[u32 content length][u32 packet id][payload], big-endian, with a 1 MB size cap; unknown packet ids are skipped instead of dropping the connection, so the protocol stays forward-compatible.INetworkSerializablepattern (same idiom the project used before Using SpacetimeDB for netcode #41, modernized: UTF-8 strings,ReadExactlyAsyncframing instead ofDataAvailablepolling).PacketRegistrar: handshake (with version check), keep-alive,SetName, lobby list/create/join/leave/set-ready requests + typedLobbyActionResultresponses, and theLobbyUpdated/LobbyDeleted/GameStartedbroadcasts that replace the SpacetimeDB subscriptions.NetworkConnection(framing + send/receive loops),ServerConnection(accept loop, keep-alive pings every 10s, 30s timeout kick),ClientConnection(background reader with a thread-safe incoming queue).Server (
OpenPolytopia.Server)New console project replacing
StdbModulein the solution.GameServer+LobbyManagerreimplement the old reducer semantics: registration required before lobby actions, join/leave/ready rules, all-ready → starting, and the 5-second scheduler that starts games (world generation still TODO, as before). Improvements over the module: per-player ready state instead of a counter, empty lobbies get deleted, and disconnects clean the player out of non-started lobbies with broadcasts.Run with
dotnet run --project OpenPolytopia.Server -- [port] [bind-address]; falls back toOPENPOLYTOPIA_PORT/OPENPOLYTOPIA_BIND_ADDRESSenv vars, then port 6969 on every interface.Godot client
SpacetimeNodeandModuleBindingsare replaced byNetworkNode, a[GlobalClass]custom node you add to scenes from the editor, withHost,PortandAutoConnectexported to the inspector (overridable via--server-host=/--server-port=user args orOPENPOLYTOPIA_SERVER_*env vars)._PhysicsProcess, so all events are safe for UI code.src/Lobby.cs(Lobby.tscn had pointed at a deleted script since the SpacetimeDB migration) with a working lobby browser: tribe picker, create/join/leave/ready, live list updates via the observableLobbiescollection.Game.tscnnow registers the chosen name and switches to the lobby scene.Testing
PacketTestin the GoDotTest suite: round-trips for every packet shape plus wire framing.GameStartedflow, disconnect cleanup — 19/19 checks pass. This caught (and the PR fixes) an ordering quirk inherited from the old reducer where a member re-joining a full lobby gotLobbyFullinstead ofAlreadyJoined.Notes for review:
enn3.ovh:6969(NetworkNode.csexported defaults).DotNext.Threadingin Common is now an unused dependency; left in place to keep the diff focused..uidedits were authored by hand (no Godot editor available here); the editor may rewrite formatting on first save, which is harmless.🤖 Generated with Claude Code
https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX
Generated by Claude Code