Authoritative Multiplayer State Server
Authoritative tick-based game server with lag compensation and bandwidth budgeting for real-time multiplayer.
At a glance
- Fixed authoritative tick loop that stays honest under jitter
- History buffer with rewind for lag compensation
- Per-client bandwidth budgeting and snapshot deltas
- Redis-backed session and state handling
- Configurable tick rate with deterministic replay
From the repository
README
A server-authoritative, fixed-timestep netcode core in Go — 60 Hz simulation, client prediction, lag-compensated hit registration and delta snapshot compression over raw UDP.
Table of Contents
- Overview
- Why It Exists
- Key Features
- Live Demo
- System Architecture
- Component Breakdown
- The Tick Lifecycle
- Wire Protocol
- Netcode Deep Dive
- Tech Stack
- Project Structure
- Quick Start
- Configuration
- Testing & Benchmarks
- Known Limitations
- Roadmap
- Contributing
Overview
Most "multiplayer server" codebases hide the interesting parts: the tick loop that stays honest under jitter, the history buffer that lets a 200 ms player hit what they actually saw, the bandwidth bill that decides whether a match is playable on a hotel Wi-Fi.
This repository is that layer, built end to end and nothing else. There is no rendering, no matchmaking service, no persistence. It is the authoritative simulation core that a real game would sit on top of, with the hard netcode problems solved and tested:
| Problem | Approach | Evidence |
|---|---|---|
| Simulation drift under variable frame times | Accumulator-based fixed timestep, frame time clamped to 5 ticks | tests/fixed_tick_test.go |
| Input feels laggy on the client | Client-side prediction + input replay reconciliation | cmd/mock_client/main.go:197 |
| "I aimed at you and missed" | 128-frame history rewind, capped at 400 ms, raycast against past AABBs | internal/lagcomp/hit_rewind.go:52 |
| Snapshots flood the link | Delta encoding against the client-acked tick + 1 mm quantization | internal/network/delta_compressor.go:80 |
| Modified clients teleport | Kinematic displacement clamp on every simulation step | internal/physics/movement.go:122 |
Scope note: this is a netcode and simulation reference implementation, not a game. The arena is an infinite flat plane at Y = 0 with player hitboxes as the only colliders.
Why It Exists
Networked movement is usually implemented by hand-waving. This project starts from four non-negotiable invariants and proves each one with a test:
- The server owns the world. Clients send a 25-byte input — never a position, velocity or health value. There is no code path that accepts one.
- Time is fixed. Simulation always advances by exactly
1/60 s, independent of how long the last frame actually took. - History is kept, but bounded. A shot may rewind at most 400 ms. Beyond that the request is rejected, not clamped silently.
- Every byte is accounted for. Positions quantize to millimetres, rotations to single bytes, and only entities that actually changed go on the wire.
Key Features
- 🎯 Fixed 60 Hz simulation — accumulator loop with a spiral-of-death clamp; measured drift under 1 µs across 10,000 simulated ticks with 8–24 ms frame jitter.
- 🧮 Client prediction & reconciliation — the client applies each input locally, then on every snapshot drops acknowledged inputs and replays the remainder onto the authoritative position. The harness reports the resulting prediction error.
- 🕰️ Lag compensation (hit rewind) — a 128-frame (~2.1 s) circular history buffer; shots interpolate between the two bracketing frames and raycast the past bounding boxes, then apply damage to current state.
- 📦 Delta snapshot compression — 16 players with 2 movers shrink from 542 → 90 bytes (83.4 % saved), verified by test with a ≥70 % floor.
- 🔒 Server-side anti-cheat — horizontal displacement clamped to
max_speed × dt + 0.05 mevery tick, rejecting speedhacks and teleports; a second guard validates externally claimed positions. - 🔌 Hand-rolled binary protocol — 16-byte big-endian header with magic validation, sequence numbers and a 32-bit sliding ACK bitfield; runt packets are dropped before decode.
- 🚪 Session lifecycle — sessions keyed by UDP source address, 1 Hz reaper evicting peers idle beyond 5 s, heartbeats, explicit connect/disconnect handling.
- 🧪 Bot harness in-box — 8 concurrent clients with injectable latency jitter and a drift telemetry summary, so the whole loop is exercisable with one command.
- 📦 Zero third-party dependencies — Go standard library only. No
go.sum, no vendor tree, nothing to audit but the code in front of you.
Live Demo
Two terminals. That's the whole setup.
# Terminal 1 — the server
go run ./cmd/server -port 7777 -hz 60
[SERVER] Starting Authoritative Multiplayer State Server on 0.0.0.0:7777 at 60 Hz
[SERVER] Running. Press Ctrl+C to terminate.
# Terminal 2 — 8 bot clients, 100 ms of simulated jitter
go run ./cmd/mock_client -server 127.0.0.1:7777 -bots 8 -jitter 100 -duration 4
[MOCK BOT RUNNER] Launching 8 bots targeting 127.0.0.1:7777 (Jitter: 100ms, Duration: 4s)
[MOCK BOT RUNNER] Simulation Complete. Telemetry Summary:
Bot 01 | Inputs Sent: 237 | Snapshots Recv: 242 | Reconciliations: 237 | Max Drift: 2.0056m
Bot 02 | Inputs Sent: 235 | Snapshots Recv: 242 | Reconciliations: 235 | Max Drift: 2.0056m
...
Bot 08 | Inputs Sent: 235 | Snapshots Recv: 242 | Reconciliations: 236 | Max Drift: 2.0056m
Total Inputs Sent: 1889 | Total Snapshots Processed: 1936 | Total Reconciled: 1882
Why is the drift ~2 m? Not a reconciliation bug.
ConnectAckis sent with an empty payload, so the client never learns which entity ID it was assigned and guessesid. Server spawn slots are numbered from 0, so every bot sits exactly one 2 m spawn slot away from the entity it is actually driving. See Known Limitations.
System Architecture
Diagrams are native Mermaid — they render directly on GitHub with no build step, no image assets and no broken links.
Component Breakdown
Every node above, with the exact source that owns it.
Client zone — untrusted
| Component | Source | Responsibility |
|---|---|---|
| Game Client | cmd/mock_client/main.go:55 | Dials UDP, sends Connect, runs a 60 Hz input loop with a random per-input jitter delay before transmit, and decodes inbound snapshots. |
| Prediction | cmd/mock_client/main.go:114 · :197 | Applies every input locally before sending it, then on each snapshot rebuilds the body at the authoritative position and replays all unacknowledged inputs (Sequence > LastProcessedInput). Records max drift. |
Server process — one goroutine of authority
| Component | Source | Responsibility |
|---|---|---|
| Fixed Ticker | internal/loop/ticker.go:86 | Monotonic accumulator loop. dt = 1/60 s is constant; frame time is clamped to MaxAccumulatorTicks = 5 so a stall cannot trigger a spiral of death. Sleeps with a 1 ms safety margin, then runtime.Gosched(). |
| UDP Socket | internal/network/udp_listener.go:122 | Read loop discards runt packets, validates the magic, and keys sessions by addr.String(). A separate 1 Hz reaper evicts peers idle longer than ClientTimeoutPeriod = 5s. |
| RoomManager | internal/match/manager.go:91 | Packet router and matchmaker. Switches on packet type, finds the first room with capacity below MaxRoomPlayers = 16, and records the playerID → room reverse index. |
| Room.Tick | internal/match/room.go:91 | The authoritative step: pop one input per entity, simulate, resolve fire, record history, then broadcast. Holds the room write lock for the duration. |
| Delta Compressor | internal/network/delta_compressor.go:80 | Emits only entities that are new, changed, or moving; appends despawned IDs. Falls back to a full snapshot until the client acknowledges a tick. |
| Binary Codec | internal/network/codec.go:85 · internal/network/packet.go:54 | Hand-rolled big-endian serializers for inputs and snapshots, plus the 16-byte header encode/decode. |
| Movement Controller | internal/physics/movement.go:91 | Kinematic integration: horizontal velocity from yaw + input bits, jump impulse, gravity while airborne, ground-plane resolution at Y = 0, then the displacement clamp. |
| State Buffer | internal/loop/state_buffer.go:59 | sync.RWMutex-guarded 128-entry ring of historical frames. InterpolateHistory lerps position, yaw and pitch between the bracketing frames and enforces the 400 ms ceiling. |
| Hit Rewind | internal/lagcomp/hit_rewind.go:52 | Raycasts the rewound world, picks the closest intersection excluding the shooter, then applies damage to the entity's current state. |
Trust boundaries
┌─ UNTRUSTED ───────────────────────────────────────────────┐
│ Client sends exactly one 25-byte ClientInput per tick. │
│ No field carries position, velocity or health. │
│ Session identity IS the UDP source address — transport │
│ and identity are the same boundary, deliberately. │
└────────────────────────────────────────────────────────────┘
↓ 25 B of intent
┌─ AUTHORITATIVE ───────────────────────────────────────────┐
│ Server derives everything: position, velocity, health, │
│ hit validity and death. Nothing is inferred from the │
│ client except input flags, yaw and pitch. │
└────────────────────────────────────────────────────────────┘
Anti-cheat is a consequence of the architecture, not a filter. Because the server never accepts a client-supplied position, the only thing left to cheat with is input flags — and those are bounded by the kinematic clamp:
max_displacement = max_speed × dt + tolerance = 9.0 × (1/60) + 0.05 = 0.20 m per tick
The Tick Lifecycle
What actually happens between two 16.666 ms boundaries.
Movement Controller, Delta Compressor and Binary Codec are folded into their calling steps so the lifecycle stays legible; each is a separate node in the architecture diagram.
Step by step
- Join — The client sends
PacketTypeConnect. The socket creates a session keyed by source address and hands it toRoomManager, which finds a room with free capacity (or createsroom-N), spawns the player entity with 100 HP and a0.6 × 1.8 × 0.6 mcollider, and replies withConnectAck. - Predict locally — Before transmitting, the client runs the same
StepSimulationagainst its own copy of the body and appends the input to a pending list. The player sees immediate response; the server has not heard anything yet. - Send input — After a random jitter delay (0…
-jitterms), a 25-byte payload goes out:ClientTick(u64) ·InputFlags(u8) ·Yaw(f32) ·Pitch(f32) ·TimestampMs(u64). The sequence number travels in the header, not the payload. - Intake —
RoomManagerupdates the session's acknowledged tick and pushes the input into that player'sInputQueue, which sorts by sequence, rejects duplicates, rejects stale sequences, and evicts the oldest entry past 128. - Integrate —
Room.Tickpops one input per entity and steps the kinematic body. Horizontal velocity is derived from the input bitmask and yaw, jump applies a5.5 m/simpulse, gravity-15 m/s²runs while airborne, and the ground plane atY = 0is resolved against the collider half-height. Entities with no new input still step with zero flags so gravity and damping continue. - Clamp — Measured horizontal displacement is compared against
max_speed × dt + 0.05 m. A violation clamps the position and velocity along the movement vector and flags the tick. - Resolve fire — If
FIREis set, aShotRequestis built and the rewind engine interpolates history at the client-perceived time, raycasts every rewound AABB, picks the closest hit excluding the shooter, verifies the target is still alive now, and applies damage. - Record history — The post-step world is written into the 128-frame ring buffer, so the next tick can rewind into it.
- Broadcast — Per session,
CompressAndSerializeproduces either a delta (changed/spawned entities + removed IDs) or, until the client acks its first tick, a full snapshot. The codec frames it with the 16-byte header. - Reconcile — The client dequantizes its authoritative position, discards every pending input with
Sequence <= LastProcessedInput, replays the rest onto the authoritative body, measures the drift, and adopts the corrected result as its new predicted state.
Wire Protocol
16-byte big-endian header, followed by a type-specific payload. Defined in internal/network/packet.go:39.
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
0 | 2 | Magic | uint16 | Always 0x474D ("GM"). A mismatch is rejected before any payload is touched. |
2 | 1 | PacketType | uint8 | See the packet type table below |
3 | 1 | Flags | uint8 | 0x01 reliable · 0x02 compressed · 0x04 resend (declared, currently unused) |
4 | 4 | Sequence | uint32 | Monotonic per-session counter. On a ClientInput it is also the input sequence. |
8 | 4 | AckSequence | uint32 | Newest sequence received from the peer |
12 | 4 | AckBitfield | uint32 | 32-bit sliding window over the arrivals preceding AckSequence |
| 16 | Total header size |
The first four bytes fit in a single 32-bit word, which is why the decode path can validate the magic with one load.
| Packet type | Value | Direction | Payload |
|---|---|---|---|
Connect | 1 | client → server | empty |
ConnectAck | 2 | server → client | empty |
Heartbeat | 3 | both | empty |
ClientInput | 4 | client → server | 25 B |
WorldSnapshot | 5 | server → client | 14 + 33N B |
DeltaSnapshot | 6 | server → client | 22 + 33U + 2 + 4R B |
Disconnect | 7 | client → server | empty |
Quantized entity — exactly 33 bytes
| Offset | Field | Encoding |
|---|---|---|
0:4 | ID | uint32 |
4:16 | PosX/Y/Z | int32 millimetres (1 mm precision) |
16:28 | VelX/Y/Z | int32 millimetres per second |
28 / 29 | YawByte / PitchByte | uint8 → [0°,360°) / [-90°,90°] |
30:32 | Health | uint16 |
32 | Flags | 0x01 grounded, 0x02 active |
AckBitfield is a 32-bit sliding window over individual arrivals rather than a single "latest seen" cursor: the newest received sequence occupies bit 0, and each older arrival sits one bit further up — so a packet that arrives n slots late sets bit n-1. A forward jump of 32 or more clears the window, and PacketLossRate() derives loss from acked-vs-sent counts.
| Payload | Size at MaxRoomPlayers = 16 |
|---|---|
WorldSnapshot, 16 entities | 542 B |
DeltaSnapshot, worst case (all 16 changed) | 552 B |
Both sit comfortably inside the 1400 B MTU budget, and neither requires IP fragmentation.
proto/game_protocol.protodocuments the message shapes at design time. There is no generated code — the runtime wire format is the hand-rolled big-endian codec ininternal/network/codec.go.
Netcode Deep Dive
Client prediction & reconciliation
// cmd/mock_client/main.go:233 — replay unacknowledged inputs on the server's position
reconciledBody := physics.KinematicBody{Position: authPos, ...}
for _, inp := range unacked {
b.controller.StepSimulation(&reconciledBody, inp.InputFlags, inp.Yaw, inp.Pitch, inp.Sequence, dt)
}
drift := b.predictedBody.Position.Distance(reconciledBody.Position)
b.predictedBody = reconciledBody
This only works because client and server share the exact same simulation function. internal/physics/movement.go is imported by both cmd/mock_client and internal/match — there is no second implementation to drift out of sync.
Lag compensation
history ring: [ t-2.13s ... t-100ms ][ t-83ms ][ t-66ms ] ... [ t ] ← 128 frames
▲ ▲
│ └── now
└── client perceived time
- Validate the ray direction is non-zero; default range to 200 m if unset.
InterpolateHistory(t_perceived, now)— if the request is older than 400 ms it returnsErrRewindLimitExceeded. If it is in the future it clamps tonow.- Find the two bracketing frames and lerp position (
Vec3.Lerp), yaw and pitch byalpha. - Raycast every rewound AABB, skipping the shooter, keeping the closest intersection.
- Apply damage to the current entity. A target that has already despawned or died on this tick is not resurrected.
Proven by test (tests/lag_rewind_test.go:14): a target running at 6 m/s along +X crosses X = 0 at 500 ms. A shot arriving at 650 ms from a 150 ms RTT player aimed at where the target was. The test first asserts the shot misses against current state, then asserts the rewound shot hits for 40 damage at ~20 m, leaving 60 HP. A companion test asserts a client claiming 600 ms of latency is rejected outright.
Anti-cheat
Two independent guards, both unit-tested:
| Guard | Trigger | Action |
|---|---|---|
| Inline clamp | StepSimulation measures horizontal travel per tick | Clamp position + velocity along the move vector, return violation = true |
| External validation | ValidateExternalPosition(prev, claimed, dt) | Return valid = false with the clamped position |
A legitimate player sprinting for one second travels 9.0 m and is never flagged (tests/anti_cheat_test.go:11). A 100 m/s injected velocity is capped to MaxSpeed in a single step.
Tech Stack
| Layer | Choice | Notes |
|---|---|---|
| Language | Go 1.27.0 | go.mod, standard library only |
| Transport | Raw UDP | internal/network/udp_listener.go, :7777, MaxUDPPacketSize = 1400 |
| Serialization | Hand-rolled big-endian binary | internal/network/codec.go — no protobuf at runtime |
| Schema reference | Protocol Buffers (design-time only) | proto/game_protocol.proto, not compiled |
| Concurrency | sync.RWMutex, sync.WaitGroup, atomics | One simulation goroutine; locks around room and session state |
| Timing | Monotonic accumulator loop | internal/loop/ticker.go |
| Geometry | Vec3, Ray, AABB slab intersection | internal/physics/ |
| Storage | In-memory ring buffers | No database, no persistence |
| Containerization | Docker + Docker Compose | Dockerfile (2-stage, CGO_ENABLED=0), docker-compose.yml |
| Build & test | GNU Make + go test -race | Makefile |
| Third-party deps | None | No go.sum, no vendor/ |
Project Structure
.
├── cmd/
│ ├── server/
│ │ └── main.go # Flags · listener · ticker wiring · graceful shutdown
│ └── mock_client/
│ └── main.go # 8-bot harness: prediction, reconciliation, drift telemetry
│
├── internal/
│ ├── physics/ # ── Pure math, no I/O ──
│ │ ├── vector.go # Vec3 algebra
│ │ ├── collision.go # Ray, AABB, slab-method intersection
│ │ ├── movement.go # Kinematic step + anti-cheat clamp
│ │ └── physics_test.go
│ │
│ ├── domain/ # ── Shared types, no I/O ──
│ │ ├── entity.go # Player entity, collider, health
│ │ ├── input.go # ClientInput + flag helpers
│ │ ├── input_queue.go # Ordered, deduped, bounded input buffer
│ │ └── snapshot.go # Quantization: mm positions, byte angles
│ │
│ ├── loop/ # ── Time and history ──
│ │ ├── ticker.go # Fixed-timestep accumulator loop
│ │ └── state_buffer.go # 128-frame history ring + interpolation
│ │
│ ├── network/ # ── Wire and socket ──
│ │ ├── udp_listener.go # Socket, sessions, reaper, SendPacket
│ │ ├── packet.go # Header, encode/decode, AckTracker
│ │ ├── codec.go # Binary serializers
│ │ ├── delta_compressor.go # Diff vs base frame, full-snapshot fallback
│ │ └── network_test.go
│ │
│ ├── lagcomp/
│ │ └── hit_rewind.go # Lag-compensated raycast + damage
│ │
│ └── match/ # ── Orchestration ──
│ ├── room.go # Authoritative match instance (16 players)
│ └── manager.go # Room registry, matchmaker, packet router
│
├── proto/
│ └── game_protocol.proto # Design-time message schema (no generated code)
│
├── tests/ # ── Integration suites ──
│ ├── fixed_tick_test.go # Drift over 10,000 ticks, live ticker
│ ├── anti_cheat_test.go # Speedhack clamp, input ordering, jump/gravity
│ ├── lag_rewind_test.go # 150 ms ping hit, 600 ms claim rejection
│ └── delta_compression_test.go # Bandwidth floor + encode benchmark
│
├── bin/ # Prebuilt server.exe / mock_client.exe (windows/amd64)
├── Dockerfile # 2-stage build → alpine, exposes 7777/udp
├── docker-compose.yml # game-server + redis (see Known Limitations)
├── Makefile # build · test · run-server · run-client
├── go.mod # module authoritative-tick-server, go 1.27.0
└── gaming_authoritative_tick_server_autonomous_blueprint.md
Dependencies only ever point one way:
physics ← domain ← loop ← { network · lagcomp } ← match ← cmd/
internal/physics is the root — pure math, standard library only. domain puts the shared types on top of it; loop adds time and history; network and lagcomp are peers above loop, and neither imports the other. match is the only package that composes them, and cmd/ is the only thing that wires the graph to a socket.
That purity at the bottom is not tidiness for its own sake: it is what lets the server and the bot harness import the same StepSimulation and stay bit-identical, which is the entire precondition for prediction and reconciliation working at all.
Quick Start
Prerequisites
- Go 1.27.0 or newer — the only requirement
- Optional: GNU Make (Git Bash / WSL on Windows, since
cleanusesrm -rf) - Optional: Docker + Docker Compose
Run it
# 1. Clone
git clone https://github.com/Kavryx-Dev/authoritative-tick-based-multiplayer-state-server.git
cd authoritative-tick-based-multiplayer-state-server
# 2. Build (no dependencies to fetch — there are none)
go build ./...
# 3. Test
go test -race ./...
# 4. Start the server
go run ./cmd/server -port 7777 -hz 60
# 5. In a second terminal, run 8 bots against it
go run ./cmd/mock_client -server 127.0.0.1:7777 -bots 8 -jitter 100 -duration 10
With Make
make build # → bin/server, bin/mock_client
make test # go test -v -race ./...
make run-server # go run ./cmd/server
make run-client # go run ./cmd/mock_client
With Docker
# Bring up the authoritative server on 7777/udp
docker compose up --build
# Then, from another shell, run 8 bots against the container
docker compose run --rm --entrypoint /app/mock_client game-server \
-server game-server:7777 -bots 8 -jitter 100 -duration 10
docker compose down
--entrypointis required because the image setsENTRYPOINT ["/app/server"]; without it Docker would run the server with the client's arguments.
Environment
There is no .env file — the server reads no environment variables. Configuration is passed as CLI flags only.
⚠️ UDP firewall note. Every port here is UDP-only. Windows Firewall, WSL2 NAT and some Docker Desktop configurations silently drop
7777/udp. If bots connect and send nothing, suspect the firewall before suspecting the code.
Configuration
Server — cmd/server/main.go:18
| Flag | Default | Description |
|---|---|---|
-port | 7777 | UDP port, bound on 0.0.0.0 |
-hz | 60 | Simulation frequency in Hz |
Bot harness — cmd/mock_client/main.go:257
| Flag | Default | Description |
|---|---|---|
-server | 127.0.0.1:7777 | Server address host:port |
-bots | 8 | Concurrent bot clients |
-jitter | 100 | Simulated one-way delay in ms (uniform 0…jitter) |
-duration | 4 | Run time in seconds; 0 runs until interrupted |
Tunables — compile-time constants
| Constant | Value | Location |
|---|---|---|
DefaultTickRate | 60 | internal/loop/ticker.go |
MaxAccumulatorTicks | 5 | internal/loop/ticker.go |
MaxRoomPlayers | 16 | internal/match/room.go |
StateBufferSize | 128 (~2.13 s) | internal/loop/state_buffer.go |
MaxRewindWindow | 400 ms | internal/loop/state_buffer.go |
MaxUDPPacketSize | 1400 | internal/network/udp_listener.go |
ClientTimeoutPeriod | 5 s | internal/network/udp_listener.go |
MaxInputQueueCapacity | 128 | internal/domain/input_queue.go |
MaxSpeedWalk / MaxSpeedSprint | 6.0 / 9.0 m/s | internal/physics/movement.go |
JumpSpeed / Gravity | 5.5 m/s / -15.0 m/s² | internal/physics/movement.go |
AntiCheatTolerance | 0.05 m | internal/physics/movement.go |
PositionQuantizationScale | 1000.0 (1 mm) | internal/domain/snapshot.go |
Testing & Benchmarks
15 tests across three packages. All passing.
go test -race ./... # everything
go test -v ./tests # integration suites only
go test ./tests -bench BenchmarkFullVsDeltaSnapshot -benchmem -run '^$'
What each suite proves
| Test | Asserts |
|---|---|
TestDriftCompensation10000Ticks | 10,000 ticks through 8–24 ms jitter leave < 1 µs total drift and no accumulator leak |
TestRealtimeFixedTicker | The live ticker completes 60 real ticks within 100 ms of expected wall clock |
TestAntiCheatSpeedhackClamping | A 10 m teleport is rejected and clamped to the legal radius; a 100 m/s velocity is capped to MaxSpeed |
TestLegitimateKinematicMovement | A sprinting player covers ~9.0 m in 1 s and is never flagged as a violation |
TestInputQueueDeterministicOrdering | Out-of-order pushes pop as 1, 2, 3; duplicates and stale sequences are rejected |
TestJumpPhysicsAndGravity | Jump leaves the ground, reaches positive vertical velocity, and lands back at Y = 0.9 |
TestLagRewind150msPingHitConfirmation | 150 ms RTT shot misses against current state and hits rewound: 40 damage, 100 → 60 HP, ~20 m |
TestRewindExceedsMaxWindowRejection | A 600 ms rewind claim returns an error instead of being honoured |
TestDeltaCompressionBandwidthSavings | 16 players / 2 movers must save ≥ 70 %, and the delta must round-trip |
TestPacketHeaderEncodeDecode, TestAckTrackerSequencingAndLoss, TestCodecs, TestUDPListenerLifecycle | Magic validation, ACK bitfield behaviour, codec round-trips, socket lifecycle |
TestVec3Operations, TestCollisionPrimitives | Vector algebra and slab-method AABB/ray intersection |
Measured results
Bandwidth — tests/delta_compression_test.go:13
| Payload | Size |
|---|---|
| Full snapshot, 16 entities | 542 B |
| Delta snapshot, 2 entities changed | 90 B |
| Reduction | 83.39 % |
Encoder throughput — windows/amd64, 13th Gen Intel Core i5-13450HX
BenchmarkFullVsDeltaSnapshot/FullSnapshotEncode-16 1732491 694.4 ns/op 576 B/op 1 allocs/op
BenchmarkFullVsDeltaSnapshot/DeltaSnapshotEncode-16 967569 1440.0 ns/op 224 B/op 3 allocs/op
Delta encoding costs ~2× the CPU of a full encode and saves 61 % of allocated bytes. At 60 Hz with 16 players that is a rounding error next to the bandwidth win — but on a saturated link it is the difference between a playable game and a lag machine.
Known Limitations
Documented honestly, because a netcode repository that hides its edges is not worth reading.
- ConnectAck carries no identity. It is sent with an empty payload (
internal/match/manager.go:95), so the client never learns its entity ID or spawn transform. The harness guessesid, which is one spawn slot off from the server's 0-based numbering — that is the entire ~2 m "prediction error" in the demo output. - Hit results are discarded.
internal/match/room.go:131does_, _ = r.rewindEngine.ResolveShot(...). Damage is applied, but no kill feed, hit marker or hit event is broadcast. - Client-perceived time is hardcoded.
internal/match/room.go:127usesnow.Add(-50 * time.Millisecond)rather than deriving it from the input timestamp or measured RTT.ClientSession.RTTis declared but never written or read. - Deltas use the newest frame as the base, not the client's
ackedTickframe (internal/network/delta_compressor.go:98callsGetLatestFrame()).ackedTickis currently only a zero/non-zero gate. Deltas stay small in practice because any entity with non-zero velocity is always emitted. - The session reaper leaks entities. It deletes from
UDPListener.sessionsbut never notifiesRoomManager, soRoom.entitiesandRoomManager.playerRoomretain the player after a 5 s timeout. - No reliability layer.
FlagReliable,FlagCompressedandFlagResendare defined but never set;SendPacketalways passesflags = 0. There is no retransmit — a lost snapshot simply means no delta that tick. - Snapshot entity order is non-deterministic.
EncodeWorldSnapshotiterates a Go map, so two encodes of identical state can produce different byte layouts. Room.Tickholds the write lock across simulation and network sends (internal/match/room.go:92), so rooms serialize on that lock, and rooms tick sequentially on one goroutine.docker-compose.ymlprovisions Redis that nothing uses, and setsPORT/HZenv vars the server ignores — it reads only-portand-hz. The service is scaffolding from the original blueprint.- No rollback netcode. State is never rewound; only hit registration consults history.
- No level geometry. The arena is an infinite flat plane, so rewind raycasts only ever test player boxes.
Makefiledeclares.PHONY: lintbut defines nolinttarget.
Roadmap
- Return entity ID and spawn transform in
ConnectAck; have the harness adopt the server-assigned ID - Broadcast hit events and a kill feed; stop discarding
HitResult - Derive
ClientPerceivedTimefrom the input timestamp and a real RTT estimate; start populatingClientSession.RTT - Diff against the client's actual
ackedTickframe, with a per-session keyframe interval - Teach the reaper to detach players from rooms and release their entity slots
- Add a reliable channel over the header flags for connect/ack/disconnect, leaving snapshots unreliable
- Shard rooms across worker goroutines so
Room.Tickstops serializing on one lock - Compile
proto/game_protocol.protoand switch the codec, or delete the schema if binary stays - Remove the unused Redis service or implement room-state persistence with it
- Add the missing
linttarget and a CI workflow runninggo vet+go test -race
Contributing
This is a netcode reference, so contributions are judged on correctness and clarity, not features.
- Open an issue first for anything touching the wire format, tick rate or rewind window. These are cross-cutting and version-sensitive.
- Keep the simulation pure.
internal/physicsandinternal/domainmust stay free of sockets, clocks and I/O — that purity is what lets the client and server share one implementation. - A change without a test is not done. Every invariant on the Why It Exists table is currently test-enforced; keep it that way.
- Never widen the trust boundary silently. If a new field lets a client influence authoritative state, it needs a written justification and a clamp.
go test -race ./...
gofmt -l .
go vet ./...
Built with Go's standard library. No frameworks, no runtime dependencies, no magic.
gaming_authoritative_tick_server_autonomous_blueprint.md documents the original specification this implementation was written against.