Kavryx Dev
← All products
Real-time & InteractivePROTOTYPE

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

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:

ProblemApproachEvidence
Simulation drift under variable frame timesAccumulator-based fixed timestep, frame time clamped to 5 tickstests/fixed_tick_test.go
Input feels laggy on the clientClient-side prediction + input replay reconciliationcmd/mock_client/main.go:197
"I aimed at you and missed"128-frame history rewind, capped at 400 ms, raycast against past AABBsinternal/lagcomp/hit_rewind.go:52
Snapshots flood the linkDelta encoding against the client-acked tick + 1 mm quantizationinternal/network/delta_compressor.go:80
Modified clients teleportKinematic displacement clamp on every simulation stepinternal/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:

  1. 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.
  2. Time is fixed. Simulation always advances by exactly 1/60 s, independent of how long the last frame actually took.
  3. History is kept, but bounded. A shot may rewind at most 400 ms. Beyond that the request is rejected, not clamped silently.
  4. 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 m every 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. ConnectAck is sent with an empty payload, so the client never learns which entity ID it was assigned and guesses id. 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.

Rendering diagram…

Component Breakdown

Every node above, with the exact source that owns it.

Client zone — untrusted

ComponentSourceResponsibility
Game Clientcmd/mock_client/main.go:55Dials UDP, sends Connect, runs a 60 Hz input loop with a random per-input jitter delay before transmit, and decodes inbound snapshots.
Predictioncmd/mock_client/main.go:114 · :197Applies 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

ComponentSourceResponsibility
Fixed Tickerinternal/loop/ticker.go:86Monotonic 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 Socketinternal/network/udp_listener.go:122Read 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.
RoomManagerinternal/match/manager.go:91Packet router and matchmaker. Switches on packet type, finds the first room with capacity below MaxRoomPlayers = 16, and records the playerID → room reverse index.
Room.Tickinternal/match/room.go:91The authoritative step: pop one input per entity, simulate, resolve fire, record history, then broadcast. Holds the room write lock for the duration.
Delta Compressorinternal/network/delta_compressor.go:80Emits only entities that are new, changed, or moving; appends despawned IDs. Falls back to a full snapshot until the client acknowledges a tick.
Binary Codecinternal/network/codec.go:85 · internal/network/packet.go:54Hand-rolled big-endian serializers for inputs and snapshots, plus the 16-byte header encode/decode.
Movement Controllerinternal/physics/movement.go:91Kinematic integration: horizontal velocity from yaw + input bits, jump impulse, gravity while airborne, ground-plane resolution at Y = 0, then the displacement clamp.
State Bufferinternal/loop/state_buffer.go:59sync.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 Rewindinternal/lagcomp/hit_rewind.go:52Raycasts 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.

Rendering diagram…

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

  1. Join — The client sends PacketTypeConnect. The socket creates a session keyed by source address and hands it to RoomManager, which finds a room with free capacity (or creates room-N), spawns the player entity with 100 HP and a 0.6 × 1.8 × 0.6 m collider, and replies with ConnectAck.
  2. Predict locally — Before transmitting, the client runs the same StepSimulation against 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.
  3. Send input — After a random jitter delay (0…-jitter ms), 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.
  4. Intake — RoomManager updates the session's acknowledged tick and pushes the input into that player's InputQueue, which sorts by sequence, rejects duplicates, rejects stale sequences, and evicts the oldest entry past 128.
  5. Integrate — Room.Tick pops one input per entity and steps the kinematic body. Horizontal velocity is derived from the input bitmask and yaw, jump applies a 5.5 m/s impulse, gravity -15 m/s² runs while airborne, and the ground plane at Y = 0 is resolved against the collider half-height. Entities with no new input still step with zero flags so gravity and damping continue.
  6. 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.
  7. Resolve fire — If FIRE is set, a ShotRequest is 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.
  8. Record history — The post-step world is written into the 128-frame ring buffer, so the next tick can rewind into it.
  9. Broadcast — Per session, CompressAndSerialize produces 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.
  10. 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.

OffsetSizeFieldTypeDescription
02Magicuint16Always 0x474D ("GM"). A mismatch is rejected before any payload is touched.
21PacketTypeuint8See the packet type table below
31Flagsuint80x01 reliable · 0x02 compressed · 0x04 resend (declared, currently unused)
44Sequenceuint32Monotonic per-session counter. On a ClientInput it is also the input sequence.
84AckSequenceuint32Newest sequence received from the peer
124AckBitfielduint3232-bit sliding window over the arrivals preceding AckSequence
16Total 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 typeValueDirectionPayload
Connect1client → serverempty
ConnectAck2server → clientempty
Heartbeat3bothempty
ClientInput4client → server25 B
WorldSnapshot5server → client14 + 33N B
DeltaSnapshot6server → client22 + 33U + 2 + 4R B
Disconnect7client → serverempty

Quantized entity — exactly 33 bytes

OffsetFieldEncoding
0:4IDuint32
4:16PosX/Y/Zint32 millimetres (1 mm precision)
16:28VelX/Y/Zint32 millimetres per second
28 / 29YawByte / PitchByteuint8 → [0°,360°) / [-90°,90°]
30:32Healthuint16
32Flags0x01 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.

PayloadSize at MaxRoomPlayers = 16
WorldSnapshot, 16 entities542 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.proto documents the message shapes at design time. There is no generated code — the runtime wire format is the hand-rolled big-endian codec in internal/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
  1. Validate the ray direction is non-zero; default range to 200 m if unset.
  2. InterpolateHistory(t_perceived, now) — if the request is older than 400 ms it returns ErrRewindLimitExceeded. If it is in the future it clamps to now.
  3. Find the two bracketing frames and lerp position (Vec3.Lerp), yaw and pitch by alpha.
  4. Raycast every rewound AABB, skipping the shooter, keeping the closest intersection.
  5. 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:

GuardTriggerAction
Inline clampStepSimulation measures horizontal travel per tickClamp position + velocity along the move vector, return violation = true
External validationValidateExternalPosition(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

LayerChoiceNotes
LanguageGo 1.27.0go.mod, standard library only
TransportRaw UDPinternal/network/udp_listener.go, :7777, MaxUDPPacketSize = 1400
SerializationHand-rolled big-endian binaryinternal/network/codec.go — no protobuf at runtime
Schema referenceProtocol Buffers (design-time only)proto/game_protocol.proto, not compiled
Concurrencysync.RWMutex, sync.WaitGroup, atomicsOne simulation goroutine; locks around room and session state
TimingMonotonic accumulator loopinternal/loop/ticker.go
GeometryVec3, Ray, AABB slab intersectioninternal/physics/
StorageIn-memory ring buffersNo database, no persistence
ContainerizationDocker + Docker ComposeDockerfile (2-stage, CGO_ENABLED=0), docker-compose.yml
Build & testGNU Make + go test -raceMakefile
Third-party depsNoneNo 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 clean uses rm -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

--entrypoint is required because the image sets ENTRYPOINT ["/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

FlagDefaultDescription
-port7777UDP port, bound on 0.0.0.0
-hz60Simulation frequency in Hz

Bot harness — cmd/mock_client/main.go:257

FlagDefaultDescription
-server127.0.0.1:7777Server address host:port
-bots8Concurrent bot clients
-jitter100Simulated one-way delay in ms (uniform 0…jitter)
-duration4Run time in seconds; 0 runs until interrupted

Tunables — compile-time constants

ConstantValueLocation
DefaultTickRate60internal/loop/ticker.go
MaxAccumulatorTicks5internal/loop/ticker.go
MaxRoomPlayers16internal/match/room.go
StateBufferSize128 (~2.13 s)internal/loop/state_buffer.go
MaxRewindWindow400 msinternal/loop/state_buffer.go
MaxUDPPacketSize1400internal/network/udp_listener.go
ClientTimeoutPeriod5 sinternal/network/udp_listener.go
MaxInputQueueCapacity128internal/domain/input_queue.go
MaxSpeedWalk / MaxSpeedSprint6.0 / 9.0 m/sinternal/physics/movement.go
JumpSpeed / Gravity5.5 m/s / -15.0 m/s²internal/physics/movement.go
AntiCheatTolerance0.05 minternal/physics/movement.go
PositionQuantizationScale1000.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

TestAsserts
TestDriftCompensation10000Ticks10,000 ticks through 8–24 ms jitter leave < 1 µs total drift and no accumulator leak
TestRealtimeFixedTickerThe live ticker completes 60 real ticks within 100 ms of expected wall clock
TestAntiCheatSpeedhackClampingA 10 m teleport is rejected and clamped to the legal radius; a 100 m/s velocity is capped to MaxSpeed
TestLegitimateKinematicMovementA sprinting player covers ~9.0 m in 1 s and is never flagged as a violation
TestInputQueueDeterministicOrderingOut-of-order pushes pop as 1, 2, 3; duplicates and stale sequences are rejected
TestJumpPhysicsAndGravityJump leaves the ground, reaches positive vertical velocity, and lands back at Y = 0.9
TestLagRewind150msPingHitConfirmation150 ms RTT shot misses against current state and hits rewound: 40 damage, 100 → 60 HP, ~20 m
TestRewindExceedsMaxWindowRejectionA 600 ms rewind claim returns an error instead of being honoured
TestDeltaCompressionBandwidthSavings16 players / 2 movers must save ≥ 70 %, and the delta must round-trip
TestPacketHeaderEncodeDecode, TestAckTrackerSequencingAndLoss, TestCodecs, TestUDPListenerLifecycleMagic validation, ACK bitfield behaviour, codec round-trips, socket lifecycle
TestVec3Operations, TestCollisionPrimitivesVector algebra and slab-method AABB/ray intersection

Measured results

Bandwidth — tests/delta_compression_test.go:13

PayloadSize
Full snapshot, 16 entities542 B
Delta snapshot, 2 entities changed90 B
Reduction83.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.

  1. 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 guesses id, 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.
  2. Hit results are discarded. internal/match/room.go:131 does _, _ = r.rewindEngine.ResolveShot(...). Damage is applied, but no kill feed, hit marker or hit event is broadcast.
  3. Client-perceived time is hardcoded. internal/match/room.go:127 uses now.Add(-50 * time.Millisecond) rather than deriving it from the input timestamp or measured RTT. ClientSession.RTT is declared but never written or read.
  4. Deltas use the newest frame as the base, not the client's ackedTick frame (internal/network/delta_compressor.go:98 calls GetLatestFrame()). ackedTick is currently only a zero/non-zero gate. Deltas stay small in practice because any entity with non-zero velocity is always emitted.
  5. The session reaper leaks entities. It deletes from UDPListener.sessions but never notifies RoomManager, so Room.entities and RoomManager.playerRoom retain the player after a 5 s timeout.
  6. No reliability layer. FlagReliable, FlagCompressed and FlagResend are defined but never set; SendPacket always passes flags = 0. There is no retransmit — a lost snapshot simply means no delta that tick.
  7. Snapshot entity order is non-deterministic. EncodeWorldSnapshot iterates a Go map, so two encodes of identical state can produce different byte layouts.
  8. Room.Tick holds 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.
  9. docker-compose.yml provisions Redis that nothing uses, and sets PORT/HZ env vars the server ignores — it reads only -port and -hz. The service is scaffolding from the original blueprint.
  10. No rollback netcode. State is never rewound; only hit registration consults history.
  11. No level geometry. The arena is an infinite flat plane, so rewind raycasts only ever test player boxes.
  12. Makefile declares .PHONY: lint but defines no lint target.

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 ClientPerceivedTime from the input timestamp and a real RTT estimate; start populating ClientSession.RTT
  • Diff against the client's actual ackedTick frame, 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.Tick stops serializing on one lock
  • Compile proto/game_protocol.proto and 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 lint target and a CI workflow running go vet + go test -race

Contributing

This is a netcode reference, so contributions are judged on correctness and clarity, not features.

  1. Open an issue first for anything touching the wire format, tick rate or rewind window. These are cross-cutting and version-sensitive.
  2. Keep the simulation pure. internal/physics and internal/domain must stay free of sockets, clocks and I/O — that purity is what lets the client and server share one implementation.
  3. A change without a test is not done. Every invariant on the Why It Exists table is currently test-enforced; keep it that way.
  4. 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.