Skip to content

Replace SpacetimeDB with custom TCP server and packet protocol - #58

Open
Enn3Developer wants to merge 11 commits into
masterfrom
claude/custom-tcp-server-fc5qcl
Open

Replace SpacetimeDB with custom TCP server and packet protocol#58
Enn3Developer wants to merge 11 commits into
masterfrom
claude/custom-tcp-server-fc5qcl

Conversation

@Enn3Developer

Copy link
Copy Markdown
Owner

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)

  • Every packet is framed as [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.
  • Serialization goes through the INetworkSerializable pattern (same idiom the project used before Using SpacetimeDB for netcode #41, modernized: UTF-8 strings, ReadExactlyAsync framing instead of DataAvailable polling).
  • 18 packets registered in PacketRegistrar: handshake (with version check), keep-alive, SetName, lobby list/create/join/leave/set-ready requests + typed LobbyActionResult responses, and the LobbyUpdated/LobbyDeleted/GameStarted broadcasts that replace the SpacetimeDB subscriptions.
  • Connection layer shared by both sides: 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 StdbModule in the solution. GameServer + LobbyManager reimplement 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 to OPENPOLYTOPIA_PORT/OPENPOLYTOPIA_BIND_ADDRESS env vars, then port 6969 on every interface.

Godot client

  • SpacetimeNode and ModuleBindings are replaced by NetworkNode, a [GlobalClass] custom node you add to scenes from the editor, with Host, Port and AutoConnect exported to the inspector (overridable via --server-host=/--server-port= user args or OPENPOLYTOPIA_SERVER_* env vars).
  • The underlying connection and session state are shared between instances and survive scene changes: the first node entering the tree connects, later scenes reuse the connection, and only the most recent instance pumps received packets — on the main thread in _PhysicsProcess, so all events are safe for UI code.
  • Restores 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 observable Lobbies collection. Game.tscn now registers the chosen name and switches to the lobby scene.

Testing

  • PacketTest in the GoDotTest suite: round-trips for every packet shape plus wire framing.
  • End-to-end verification against the real server over real sockets (three concurrent clients): version rejection, unregistered-action rejection, full/double/unknown-lobby join errors, ready → scheduler → GameStarted flow, 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 got LobbyFull instead of AlreadyJoined.

Notes for review:

  • The client defaults to enn3.ovh:6969 (NetworkNode.cs exported defaults).
  • DotNext.Threading in Common is now an unused dependency; left in place to keep the diff focused.
  • Scene/.uid edits 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

claude added 4 commits August 12, 2026 13:05
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 Enn3Developer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LobbyData is serialized outside _stateLock during 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 _PhysicsProcess when a version-mismatch handshake response triggers Disconnect() mid-pump (NetworkNode.cs).
  • Stale _disconnectedFlag destroys the next fresh connection after any explicit disconnect — every other reconnect attempt dies (NetworkNode.cs).

Protocol gaps

  • The server replies Ok=false to a failed handshake but keeps serving the incompatible client (GameServer.cs).
  • The keep-alive Captcha is 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 _closed check-then-set can fire OnDisconnected twice (NetworkConnection.cs).
  • Port argument isn't range-validated in Program.cs (70000 crashes 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

Comment thread OpenPolytopia.Common/Network/ServerConnection.cs Outdated
Comment thread OpenPolytopia.Server/GameServer.cs Outdated
Comment thread OpenPolytopia/src/NetworkNode.cs Outdated
Comment thread OpenPolytopia/src/NetworkNode.cs
Comment thread OpenPolytopia.Server/GameServer.cs Outdated
Comment thread OpenPolytopia/src/Lobby.cs
Comment thread OpenPolytopia.Server/Program.cs Outdated
Comment thread OpenPolytopia.Common/Network/NetworkConnection.cs
Comment thread OpenPolytopia.Common/Network/ServerConnection.cs Outdated
Comment thread OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs Outdated
- 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

Copy link
Copy Markdown
Owner Author

All 10 review findings are addressed in 1b171c5:

  • Stalled client freezing the serverServerConnection.SendToAsync now times out after 10s (linked token + CancelAfter) and kicks the client; broadcasts and keep-alive sends run concurrently with Task.WhenAll, so one slow client can't serialize the others or starve the timeout check.
  • Shared LobbyData serialized outside the lock + per-recipient re-serialization — packets containing lobby data (LobbyUpdated, LobbyDeleted, GetLobbiesResponse) are framed once while GameServer still holds _stateLock, and the pre-built byte[] frame is what gets broadcast. New PacketProtocol.FramePacket(IPacket) / NetworkConnection.SendFrameAsync / frame overloads of SendToAsync/BroadcastAsync/BroadcastToAsync.
  • Handshake never enforced — the server tracks handshaked clients, closes the connection after replying Ok = false, and kicks any client that sends a non-handshake packet before completing the handshake.
  • Close() race_closed is now an int flipped with Interlocked.Exchange, so OnDisconnected fires exactly once.
  • NRE in _PhysicsProcess — the connection is captured in a local and the pump bails out if a handler disconnects mid-loop.
  • Stale _disconnectedFlag — cleared at the start of ConnectToServerAsync and consumed in Disconnect(), so reconnecting works.
  • Lobby list selection wipe — the selected lobby id is remembered and re-selected after the rebuild, and the list is ordered by lobby id so entries don't jump around.
  • Port validation — the server entry point now rejects ports outside 1–65535 like the client does.
  • Keep-alive captcha — dropped; it was never verified, so KeepAlivePacket is a plain ping now (liveness is still enforced via the 30s receive timeout).

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 Enn3Developer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. NetworkNode.Send — packets silently dropped while connecting; can also race ahead of the handshake and get the client kicked
  2. GameServer — no per-player/global lobby cap → remote DoS via CreateLobbyPacket loop
  3. PacketProtocol.FramePacketMAX_PACKET_SIZE unenforced on write; an oversized lobby list disconnects every client that requests it
  4. ServerConnection accept loop — a transient SocketException kills the entire server
  5. ClientConnection — no dead-server detection; half-open connections hang the client forever
  6. NetworkNode.ConnectToServerAsync — connection failure never surfaced to the UI, no retry path
  7. Lobby scene — stale _joined state after disconnect (soft-lock)
  8. GameServerTribe unvalidated on create/join
  9. Dead code: IntSerialization, PacketProtocol.WritePacketAsync (the latter bypasses the write lock)
  10. 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

Comment thread OpenPolytopia/src/NetworkNode.cs Outdated
Comment thread OpenPolytopia.Server/GameServer.cs Outdated
Comment thread OpenPolytopia.Common/Network/PacketProtocol.cs
Comment thread OpenPolytopia.Common/Network/ServerConnection.cs Outdated
Comment thread OpenPolytopia.Common/Network/ClientConnection.cs
Comment thread OpenPolytopia/src/NetworkNode.cs
Comment thread OpenPolytopia/src/Lobby.cs Outdated
Comment thread OpenPolytopia.Server/GameServer.cs Outdated
Comment thread OpenPolytopia.Common/Network/NetworkSerialization.cs Outdated
Comment thread OpenPolytopia/src/NetworkNode.cs Outdated
- 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

Copy link
Copy Markdown
Owner Author

All 10 findings from the second review round are addressed in 5ea3478:

  1. Send gatingNetworkNode.Send now requires a completed handshake; a too-early send logs an error instead of being silently lost or racing the handshake onto the wire.
  2. Lobby caps — one lobby per player (AlreadyJoinedLobby on create/join while already in one) plus a global cap of 100 (new TooManyLobbies result).
  3. Max packet size on writeFramePacket now throws ProtocolViolationException for frames over 1 MB, so an oversized packet fails on the sender instead of disconnecting every receiver.
  4. Accept-loop resilience — transient SocketExceptions from AcceptTcpClientAsync are logged and the loop keeps accepting; only cancellation exits.
  5. Dead-server detectionClientConnection runs a timeout loop mirroring the server (closes the connection after 30s of silence, checked every 5s), so half-open connections now fire OnDisconnected.
  6. Failed connect surfaced — the failure path sets the disconnected flag and _PhysicsProcess fires OnDisconnected even with no established connection, letting scenes react and retry.
  7. Lobby scene disconnect — subscribes to OnDisconnected, resets _joined/_joinedLobbyId/ready toggle, and shows "Disconnected from the server".
  8. Tribe validationEnum.IsDefined((TribeType)packet.Tribe) on both create and join, returning InvalidParameters.
  9. Dead codeIntSerialization and PacketProtocol.WritePacketAsync removed.
  10. Batched lobby refreshesLobbies is now a plain list with a single OnLobbiesChanged event fired once per packet, so the UI rebuilds once per update instead of N+1 times.

Generated by Claude Code

@Enn3Developer Enn3Developer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. LobbyManager — all-ready start condition never re-checked when a player leaves/disconnects → lobby can never start.
  2. GameServer — lobby broadcasts sent outside the state lock can be delivered out of order → clients keep stale lobby snapshots.
  3. ServerConnection — unauthenticated clients can idle forever via keep-alive echoes and receive all lobby broadcasts pre-handshake.
  4. NetworkNode.Send — packets silently dropped before handshake completes → dead Play button window.
  5. GameServer.SetName — rename not propagated into lobby player data → stale names in broadcasts/GameStarted.
  6. NetworkNode.Instance — static singleton keeps referencing a freed node after scene exit (latent trap for the future game scene).
  7. NetworkSerialization — unused List<uint>/List<string> wire helpers (dead code).
  8. PacketTest — no coverage for the read side of the wire format (ReadPacketAsync paths).

Generated by Claude Code

Comment thread OpenPolytopia.Server/LobbyManager.cs
Comment thread OpenPolytopia.Server/GameServer.cs Outdated
Comment thread OpenPolytopia.Common/Network/ServerConnection.cs
Comment thread OpenPolytopia/src/NetworkNode.cs Outdated
Comment thread OpenPolytopia.Server/GameServer.cs
Comment thread OpenPolytopia/src/NetworkNode.cs Outdated
Comment thread OpenPolytopia.Common/Network/NetworkSerialization.cs Outdated
Comment thread OpenPolytopia/test/src/PacketTest.cs
claude added 2 commits August 14, 2026 21:07
- 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

Copy link
Copy Markdown
Owner Author

All 8 findings from the third review round are addressed in 41ccac0 (plus 8dea974 for a spellcheck fix):

  1. Stuck lobbyLobbyManager now re-runs the all-ready check via a TryMarkStarting helper after a player leaves (LeaveLobby) or disconnects (RemovePlayerFromAllLobbies), so removing the last not-ready player starts the game.
  2. Out-of-order broadcastsServerConnection delivers packets through one FIFO queue per client (unbounded channel + a single sender task each). GameServer frames and enqueues everything while holding _stateLock, so per-client delivery order always matches the order the state changes were applied; enqueueing is synchronous, so no lock is held across awaits. This also removed the frame-then-broadcast-after-lock dance from every handler.
  3. Unauthenticated connectionsServerConnection now owns the handshake state (CompleteHandshake/IsHandshakeDone): broadcasts and keep-alives only go to clients that completed the handshake, and the keep-alive loop kicks connections that haven't completed it within 10s of accept. The version-mismatch path uses the new Kick, which disconnects after flushing the queued response.
  4. Dropped pre-handshake sendsNetworkNode.Send queues packets while the handshake is in flight and flushes them right after OnConnected; the queue is dropped on a failed or lost connection (which already surfaces OnDisconnected). Clicking Play during the connect window now registers the name as soon as the handshake lands.
  5. Stale renameSetName propagates into LobbyPlayerData via LobbyManager.RenamePlayerInLobbies and broadcasts the resulting LobbyUpdated, restoring the old authoritative-table behavior.
  6. Freed singletonNetworkNode.Instance is now nullable and cleared in _ExitTree when it still points at the exiting node; Lobby and Game grab the instance once in _Ready instead of dereferencing the static on every access.
  7. Dead code — the List<uint>/List<string> wire helpers are gone.
  8. Read-side testsPacketTest now covers ReadPacketAsync (framed round-trip, two packets from one stream, unknown-id skip with payload consumption, tiny/oversized header rejection, malformed-payload → ProtocolViolationException) plus round-trips for the previously untested packets (SetNameResponse, GetLobbies, JoinLobby, LeaveLobby, LeaveLobbyResponse, SetReadyResponse, LobbyUpdated, LobbyDeleted).

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 Enn3Developer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. NetworkNode.cs:344 — after a disconnect there is no reconnect path; sends queue into _pendingPackets forever and the client is stuck until restart.
  2. ServerConnection.cs:284 — unbounded per-client outgoing channel; a deliberately slow-reading client evades the per-write SEND_TIMEOUT and can grow server memory without bound.
  3. LobbyManager.cs:141 — a game can start with a single player (solo ready, or the last not-ready player leaving/disconnecting).
  4. Game.cs:46 — a refused name (>32 chars) produces no user-visible feedback; Play appears dead.
  5. LobbyManager.cs:186 — the lobby.Started guards are dead code (started lobbies are always removed from _lobbies in the same lock scope).
  6. GameServer.cs:136 — redundant Broadcast(PacketProtocol.FramePacket(...)) wrapping at 8 call sites; the Broadcast(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

Comment thread OpenPolytopia/src/NetworkNode.cs
Comment thread OpenPolytopia.Common/Network/ServerConnection.cs Outdated
Comment thread OpenPolytopia.Server/LobbyManager.cs
Comment thread OpenPolytopia/src/Game.cs Outdated
Comment thread OpenPolytopia.Server/LobbyManager.cs Outdated
Comment thread OpenPolytopia.Server/GameServer.cs Outdated
- 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

Copy link
Copy Markdown
Owner Author

All 6 findings from the fourth review round are fixed in e1156d7:

  1. Reconnect pathNetworkNode now retries the connection every 3 seconds after a connection loss (while AutoConnect is on), and Send drops packets when there is no connection at all instead of queueing them forever; the pending queue is only used while a handshake is in flight. A manual Disconnect() (and a version refusal) stops the automatic retry.
  2. Unbounded outgoing queue — the per-client channel is now bounded at 256 frames; a client whose queue fills up (reading too slowly to keep up) gets disconnected instead of growing server memory. The kick-after-drain path is unaffected.
  3. Solo gamesTryMarkStarting now requires at least 2 players, so a lone ready player (or the last not-ready player leaving/disconnecting from a 2-player lobby) no longer starts a 1-player game.
  4. Name refusal feedback — the title screen got a status label that shows the refusal (and connection losses, including a refused handshake, which now raises OnDisconnected), and the name LineEdit has max_length = 32 so an over-long name can't be typed in the first place.
  5. Dead Started guards — removed from JoinLobby/LeaveLobby/SetReady/RemovePlayerFromAllLobbies/TakeStartingLobbies; a comment in TakeStartingLobbies now states the invariant (the flag only marks the snapshot handed to the game — no lobby inside the manager is ever started).
  6. Redundant framing — all 8 call sites use the Broadcast(IPacket) overload now.

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 Enn3Developer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread OpenPolytopia.Server/LobbyManager.cs
Comment thread OpenPolytopia.Common/Network/ServerConnection.cs
Comment thread OpenPolytopia/src/NetworkNode.cs
Comment thread OpenPolytopia/src/NetworkNode.cs
- 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

Copy link
Copy Markdown
Owner Author

All 4 findings from the fifth review round are fixed in 6c4e904:

  1. Starting lobby dropping below the minimumRemovePlayerFromAllLobbies now clears Starting and re-runs TryMarkStarting after removing the player, exactly as suggested, so a 2-player starting lobby whose member disconnects goes back to a joinable 1-player lobby instead of launching a solo game. (LeaveLobby can't hit this: it refuses with LobbyAlreadyStarted while the lobby is starting, so only the disconnect path could shrink one.)
  2. Accept busy loop — the accept path now waits ACCEPT_RETRY_DELAY (1 s) on a SocketException before retrying, using the server's cancellation token so shutdown still exits cleanly. On the file-descriptor note: NetworkConnection.Close() calls TcpClient.Close(), which disposes the underlying socket, so the descriptor itself is released on every disconnect path — the backoff covers the spin.
  3. Re-registration after reconnectNetworkNode remembers the last name the server accepted and re-sends it right after a successful handshake, before flushing the pending queue and refreshing the lobby list, so lobby actions work again after an automatic reconnection.
  4. First reconnect delay — the disconnect path now resets _reconnectAt, exactly as suggested, so the first retry waits the full 3 s too.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants