Every byte per packet times every player times every tick adds up fast. Learn to serialize game state compactly — binary formats, bit-packing, and quantization — to fit within a bandwidth budget.
Human-readable formats like JSON are convenient but far too large for real-time state — field names and text numbers waste space that, multiplied across every entity, tick, and player, blows the bandwidth budget. Game state goes over the wire as compact binary: fixed layouts of raw bytes, or a schema-driven format like Protocol Buffers or FlatBuffers. The savings versus JSON are enormous and directly determine how many players a server supports.
Same position update, three ways:
JSON {"x": 123.456, "y": 78.9, "hp": 100} ~35 bytes (+ field names!)
Protobuf schema-driven binary, varint-encoded ~9 bytes
hand-packed binary [x:2][y:2][hp:1] quantized ~5 bytes
Multiply by (entities x ticks x players): JSON destroys your bandwidth budget.
Real-time state uses BINARY:
- fixed struct layouts (struct.pack) for hand-rolled protocols
- Protobuf / FlatBuffers / Cap'n Proto for schema + versioning
Use JSON only for non-real-time config/lobby data, not the simulation stream.You can go below whole bytes. Most game values do not need a full 32-bit field: a health value fits in 7 bits, a boolean in 1, a facing angle in 9. Bit-packing writes fields at the bit level into a shared buffer, squeezing many values into a few bytes. It is the most effective way to shrink a packet and a signature technique of hand-rolled game protocols where every bit counts.
class BitWriter:
def __init__(self):
self.bits = 0; self.count = 0
def write(self, value: int, num_bits: int):
self.bits = (self.bits << num_bits) | (value & ((1 << num_bits) - 1))
self.count += num_bits
# pack many small fields below byte boundaries:
w = BitWriter()
w.write(87, 7) # health 0-127 -> 7 bits (not a whole byte)
w.write(1, 1) # is_alive -> 1 bit
w.write(213, 9) # aim angle 0-359 -> 9 bits
# -> 17 bits total instead of 3 full bytes (24 bits) or three ints (96 bits!)
# only spend the bits a value actually needs. classic hand-rolled netcode.Full-precision floats are overkill for a position the player sees on screen — they cannot perceive sub-millimeter accuracy. Quantization maps a float within a known range to a small integer (say a 16-bit value across the map’s bounds), cutting the data in half or more with imperceptible loss. Combined with bit-packing, quantization is how positions, angles, and velocities are squeezed into a handful of bytes.
def quantize(value, min_v, max_v, bits):
# map a float in [min_v, max_v] to an integer using 'bits' bits
levels = (1 << bits) - 1
t = (value - min_v) / (max_v - min_v)
return round(t * levels)
def dequantize(q, min_v, max_v, bits):
levels = (1 << bits) - 1
return min_v + (q / levels) * (max_v - min_v)
# a position on a 1000-unit map, 16 bits -> ~0.015 unit precision (imperceptible)
q = quantize(742.3, 0, 1000, 16) # a small int instead of a 32-bit float
print(dequantize(q, 0, 1000, 16)) # ~742.3
# players can't see sub-pixel accuracy -> trade invisible precision for bytes.You design netcode against a bandwidth budget: pick a target per-player rate, then work backward to how much state fits per snapshot at your send rate. This drives every decision — send rate, delta compression, interest management, quantization. Treating bandwidth as a fixed budget you spend deliberately, rather than sending everything and hoping, is what separates netcode that scales from netcode that falls over at a few dozen players.
Design to a BUDGET, don't just send everything:
pick a target, e.g. 64 kbps per player down (~8 KB/s)
at a 20 Hz send rate -> ~400 bytes per snapshot per player
-> that 400 bytes must hold the player's whole relevant slice.
Spend the budget with the tools you've learned:
interest management send only nearby entities
delta compression send only what changed
quantization + bit-packing shrink each field
lower send rate fewer snapshots/sec (interpolate to hide it)
reliable only when needed most state is fire-and-forget
Know your per-player budget x player count = server bandwidth cost. This is
what decides how many players a server can hold.