A lightweight lockstep networking server written in Go. It uses WebSocket for signaling (room management, RPC) and KCP over UDP for real-time frame synchronization, making it suitable for deterministic multiplayer games.
┌────────┐ WebSocket (TCP) ┌──────────┐ KCP (UDP) ┌────────┐
│ Client ├──────────────────►│ Server │◄────────────►│ Client │
└────────┘ signaling/RPC └──────────┘ frame sync └────────┘
- WebSocket layer – handles player connection, room create/join/leave RPCs, and peer-to-peer message relay.
- KCP/UDP layer – each room opens a dedicated UDP port and runs a KCP session per player for reliable, low-latency frame delivery.
- A player calls
create_room→ server allocates a UDP port, creates aRoom, and starts its main loop. - Other players call
join_room(withpre_joinfor a two-phase join) → the room assigns each player a uniqueconv(KCP conversation ID). - The room ticks at the configured frame rate. Each tick it collects player inputs received via KCP, packs them into a frame, and broadcasts the frame to all players.
- When all players leave, the room shuts down and releases the UDP port.
- Go 1.25+
go build -trimpath -ldflags "-s -w" -o lockstep-go-server ./cmd/The server is configured via constants in cmd/main.go:
| Parameter | Default | Description |
|---|---|---|
wsPort |
5999 |
WebSocket listening port |
udpPortMin |
6000 |
Start of the UDP port range for rooms |
udpPortMax |
8000 |
End of the UDP port range for rooms |
udpPortMin and udpPortMax are derived from wsPort in code (wsPort + 1 and udpPortMin + 2000).
Room-level constants are in pkg/core/config.go:
| Constant | Value | Description |
|---|---|---|
MAX_PLAYERS_PER_ROOM |
8 | Maximum players per room |
MAX_INPUTS_PER_PLAYER_PER_FRAME |
10 | Input cap per player per frame |
IKCP_MTU_SIZE |
900 | KCP MTU size |
PingInterval |
20s | WebSocket ping interval |
PendingPlayerTimeout |
10s | Timeout for a pre-joined player to complete join |
All WebSocket messages are MsgPack-encoded arrays of 4 elements:
[srcID, dstID, method, arg]
srcID– sender's player ID (the TCP remote address).dstID– receiver's player ID. If non-empty, the server relays the message to that player (peer-to-peer relay). If empty, the server handles it as an RPC call.method– RPC method name. Prefix with!for fire-and-forget (no reply).arg– method argument (any MsgPack-encodable value).
RPC response format:
["", srcID, "_", [retval, error]]
| Method | Arg | Returns | Description |
|---|---|---|---|
create_room |
{version, max_players, frame_rate} |
Room object |
Create a new room |
join_room |
{port, version, pre_join} |
Room object |
Join an existing room |
leave_room |
– | – | Leave the current room |
room_kcp_ready |
– | – | Mark current player's KCP channel as ready |
| Method | Arg | Description |
|---|---|---|
push_player_id |
player ID string | Sent on connect to inform client of its ID |
push_room_event |
{type, player} |
Notifies room members of room events (pre_join, join, leave, join_timeout, kcp_ready) |
KCP messages are MsgPack-encoded arrays:
[opcode, arg]
| Code | Name | Direction | Arg | Description |
|---|---|---|---|---|
| 0 | Ping |
Client ↔ Server | – | Keepalive ping |
| 1 | Pong |
Server → Client | – | Keepalive pong |
| 2 | ServerInput |
Client → Server | raw bytes or any | Player input for current frame |
| 3 | ClientFrame |
Server → Client | {id, inputs} |
Broadcast frame with all player inputs |
{
"id": 42,
"inputs": {
"<conv>": [input1, input2, ...]
}
}id– monotonically increasing frame sequence number.inputs– a map from playerconv(KCP conversation ID) to an array of inputs submitted during this frame.
Client A Server Client B
│ │ │
├──[ServerInput]───────►│ │
│ │◄──[ServerInput]───────┤
│ │ │
│ (frame tick) │ │
│ │ │
│◄──[ClientFrame]───────┤───[ClientFrame]──────►│
│ {id:1, inputs:{…}} │ {id:1, inputs:{…}} │
All clients receive the same frame data in the same order, ensuring deterministic simulation when combined with a deterministic game engine.