Home › Multiplayer & game networking › Bandwidth
algorithm low

Bitstream serialization

Problem A program that sends many small values over a bandwidth-limited link (game state, sensor readings, codec headers) wastes most of every byte or word on values that need only a few bits. Each packet must shrink to the exact bits needed.

A bit writer and reader over a byte buffer that stores each field in exactly the bits its range needs: booleans in 1 bit, bounded integers in ceil(log2(span+1)) bits.

O(1) per value; bounded int takes ceil(log2(max-min+1)) bits; about 2.5 ns per value measured
bit-packingserializationbandwidthbounds-checkingwire-format
Overview

A byte-aligned struct spends 8, 16 or 32 bits on a flag, a 0-100 health value or a 10-bit id. A bit writer packs each field into exactly the bits its declared range needs; a 64-bit scratch register lets values straddle byte and word boundaries. The price: no field tags and no slack, so both sides must share one schema, bit order and every range constant. Reads trust nothing: each read checks the remaining bits and the range.

Key facts

Use when

  • Networked game state and voice or sensor packets where bytes cost bandwidth (State quantization, Snapshot delta compression).
  • Radio telemetry with tiny payloads: The Things Network advises binary encoding for LoRaWAN, not JSON or text.
  • Compression and codec headers: DEFLATE is specified as bit-granular fields; H.264 headers use Exp-Golomb codes (from memory).

Avoid when

Deep dive

How it works

In: values, each with a width or range fixed by the schema · Out: a byte buffer plus an exact bit count · State: writer: u64 scratch, bits held, buffer; reader: slice, byte position, bits consumed · Cost: O(1) per value, one word store per 32 bits

  1. Widths come from the schema, not the value. bool 1 bit; enum bits_for(variants-1); bounded int stores v-min in bits_for(max-min) bits, with bits_for(span) = 32 - span.leading_zeros() (0 bits when min == max); a quantized float is an integer over its range; an optional field is a presence bit then the payload; an array is a bounded count then elements.
  2. Write: scratch |= value << held; held += n. At held >= 32, store the low 32 bits as a little-endian word and shift scratch right by 32. With held < 32 and n <= 32 a u64 cannot overflow.
  3. Bit order is LSB-first: the first bit written is bit 0 of byte 0. A reader with the other order decodes wrong values without any error. DEFLATE packs fields LSB-first but Huffman codes MSB-first (RFC 1951 §3.1.1): conventions can differ per element type.
  4. Align before a byte blob: zero bits up to the byte boundary, then whole bytes. The reader rejects nonzero padding.
  5. Finish: flush the partial scratch as ceil(held/8) bytes. Length is in bytes, so up to 7 trailing bits are padding; the schema says where data ends.
  6. Read: before every read check consumed + n <= 8 * len, else error. Refill scratch 4 bytes at a time, mask n bits.
  7. Range-check: a span that is not a power of two (0..=10 takes 4 bits, which hold 0..=15) can decode above max: error.
  8. Any error drops the whole packet.

Little-endian word flushes make the stream host-independent; with big-endian words a packet cut at its last used byte would lose that byte (Fiedler).

Code

#[derive(Debug, PartialEq)]
pub enum Error { Overrun, OutOfRange, BadPadding }

/// Bits needed for values 0..=span: ceil(log2(span + 1)); 0 bits when span == 0.
pub fn bits_for(span: u32) -> u32 { 32 - span.leading_zeros() }

#[derive(Default)]
pub struct BitWriter { buf: Vec<u8>, scratch: u64, held: u32 } // LSB-first: first bit written = bit 0

impl BitWriter {
    pub fn write_bits(&mut self, value: u32, n: u32) {
        assert!(n <= 32 && (n == 32 || value >> n == 0), "value does not fit in n bits");
        self.scratch |= (value as u64) << self.held; // held < 32, n <= 32: no overflow
        self.held += n;
        if self.held >= 32 { // flush one little-endian word, keep the overflow
            self.buf.extend_from_slice(&(self.scratch as u32).to_le_bytes());
            self.scratch >>= 32;
            self.held -= 32;
        }
    }
    pub fn write_bool(&mut self, b: bool) { self.write_bits(b as u32, 1) }
    pub fn write_bounded(&mut self, v: u32, min: u32, max: u32) {
        assert!(min <= v && v <= max);
        self.write_bits(v - min, bits_for(max - min));
    }
    pub fn bits(&self) -> usize { self.buf.len() * 8 + self.held as usize }
    pub fn align(&mut self) { let pad = (8 - self.bits() % 8) % 8; self.write_bits(0, pad as u32) }
    pub fn write_bytes(&mut self, b: &[u8]) { self.align(); for &x in b { self.write_bits(x as u32, 8) } }
    pub fn finish(mut self) -> Vec<u8> {
        for i in 0..(self.held + 7) / 8 { self.buf.push((self.scratch >> (8 * i)) as u8) }
        self.buf
    }
}

pub struct BitReader<'a> { buf: &'a [u8], pos: usize, scratch: u64, held: u32, read: usize }

impl<'a> BitReader<'a> {
    pub fn new(buf: &'a [u8]) -> Self { BitReader { buf, pos: 0, scratch: 0, held: 0, read: 0 } }
    pub fn read_bits(&mut self, n: u32) -> Result<u32, Error> {
        assert!(n <= 32);
        if self.read + n as usize > self.buf.len() * 8 { return Err(Error::Overrun) } // check first
        if self.held < n { // refill: 4 bytes at once when possible
            if self.held <= 32 && self.pos + 4 <= self.buf.len() {
                let w = u32::from_le_bytes(self.buf[self.pos..self.pos + 4].try_into().unwrap());
                self.scratch |= (w as u64) << self.held;
                self.pos += 4; self.held += 32;
            } else {
                while self.held < n { self.scratch |= (self.buf[self.pos] as u64) << self.held; self.pos += 1; self.held += 8 }
            }
        }
        let v = (self.scratch & ((1u64 << n) - 1)) as u32;
        self.scratch >>= n; self.held -= n; self.read += n as usize;
        Ok(v)
    }
    pub fn read_bool(&mut self) -> Result<bool, Error> { Ok(self.read_bits(1)? == 1) }
    pub fn read_bounded(&mut self, min: u32, max: u32) -> Result<u32, Error> {
        let v = self.read_bits(bits_for(max - min))? as u64 + min as u64;
        if v > max as u64 { Err(Error::OutOfRange) } else { Ok(v as u32) } // 0..=span is not a power of two
    }
    pub fn align(&mut self) -> Result<(), Error> {
        let pad = (8 - self.read % 8) % 8;
        if self.read_bits(pad as u32)? != 0 { Err(Error::BadPadding) } else { Ok(()) }
    }
    pub fn read_bytes(&mut self, n: usize) -> Result<Vec<u8>, Error> {
        self.align()?; // n is bounded by the caller; the overrun check stops the loop early
        (0..n).map(|_| self.read_bits(8).map(|b| b as u8)).collect()
    }
}

fn main() {
    // Mixed packet: bounded int, bool, optional field (presence bit), bounded-count array, aligned blob.
    let mut w = BitWriter::default();
    w.write_bounded(1000, 0, 1023);          // 10 bits
    w.write_bool(true);                      // 1
    w.write_bool(true); w.write_bounded(7, 5, 12); // presence bit + 3 bits
    w.write_bounded(3, 0, 8);                // array count 0..=8: 4 bits
    for v in [1, 200, 99] { w.write_bits(v, 8) }
    assert_eq!(w.bits(), 10 + 1 + 1 + 3 + 4 + 24);
    w.write_bytes(b"hi");                    // pads 43 -> 48 bits, then 16
    assert_eq!(w.bits(), 64);
    let bytes = w.finish();
    assert_eq!(bytes.len(), 8);
    let mut r = BitReader::new(&bytes);
    assert_eq!(r.read_bounded(0, 1023), Ok(1000));
    assert_eq!(r.read_bool(), Ok(true));
    assert_eq!((r.read_bool(), r.read_bounded(5, 12)), (Ok(true), Ok(7)));
    let n = r.read_bounded(0, 8).unwrap();
    let arr: Vec<u32> = (0..n).map(|_| r.read_bits(8).unwrap()).collect();
    assert_eq!(arr, [1, 200, 99]);
    assert_eq!(r.read_bytes(2).unwrap(), b"hi");
    assert_eq!(r.read_bits(1), Err(Error::Overrun));           // past the end
    // Value that fits the bit width but not the range: 4 bits hold 0..=15, range is 0..=10.
    let mut w = BitWriter::default(); w.write_bits(13, 4);
    assert_eq!(BitReader::new(&w.finish()).read_bounded(0, 10), Err(Error::OutOfRange));
    // Random round trip (xorshift64).
    let mut x = 0x9E3779B97F4A7C15u64;
    let mut next = || { x ^= x << 13; x ^= x >> 7; x ^= x << 17; x };
    let items: Vec<(u32, u32)> = (0..100_000).map(|_| { let n = (next() % 33) as u32; ((next() as u32) & (((1u64 << n) - 1) as u32), n) }).collect();
    let mut w = BitWriter::default();
    for &(v, n) in &items { w.write_bits(v, n) }
    let total: u32 = items.iter().map(|i| i.1).sum();
    assert_eq!(w.bits(), total as usize);
    let bytes = w.finish();
    let mut r = BitReader::new(&bytes);
    for &(v, n) in &items { assert_eq!(r.read_bits(n), Ok(v)) }
    println!("ok");
}

Complexity and constants

  • O(1) per value; ceil(bits/8) bytes, at most 7 padding bits.
  • Measured by me, Apple M4 Pro, one thread, --release: 64 entities per packet, 66 bits each (ids 10, two fields of 18, one of 12, bounded 0..=100 in 7, one bool), 528 bytes. Write 405 M values/s (with a fresh Vec per packet), read 455 M values/s (cache-hot packet).
  • As aligned fields (u16, three f32, u8, u8) the entity is 128 bits; 66 is 52 % of that (arithmetic).
  • Cost drivers: value count rather than bit count, the flush branch, Vec growth.

Efficiency

  • Cost: about 2.5 ns per value written and 2.2 ns read (measured above, M4 Pro), ceil(bits/8) bytes per packet. The bound is instruction throughput and the flush branch: 5 Gbit/s is far below memory bandwidth.
  • Levers:
    • range-bounded ints — 0..=1000 takes 10 bits instead of 16 (Fiedler) — fields with a known maximum
    • bool as 1 bit — 8x less than a byte (Fiedler) — flag-heavy state
    • quantize floats first — position 96 → 56 bits in the State quantization example — bounded physical values
    • presence bit plus delta against an acknowledged baseline — unchanged field costs 1 bit (Snapshot delta compression) — slowly changing state
  • Naive → standard → state of the art:
    • text or JSON with field names · the description is larger than the data (Fiedler)
    • byte-aligned structs · 128 bits for the example entity
    • bounded, quantized bit-packing · 66 bits, about 2.5 ns per value
    • the same plus delta and an entropy coder for skewed fields · fewest bits, more CPU and state

Implementation notes

  • Derive each width from its range constant (bits_for(MAX)); never type both.
  • One serialize function generic over the stream keeps read and write in sync (Fiedler, Serialization Strategies).
  • Bound every array count and string length in the schema; allocate only after the bound passes.
  • Start each packet with a protocol id or schema hash; add a checksum or authenticated encryption, since in-range bit flips go unnoticed.
  • Log bits() per field and packet kind: the biggest total is the next target.

Pitfalls

  • Widening a constant (MaxElements 32 → 200) without its bit count truncates silently (Fiedler's example).
  • 1u32 << 32 panics or is undefined: mask in u64.
  • Length is rounded up to bytes, so "read until the end" sees phantom zero values.
  • Mismatched bit order, enum order or range gives plausible garbage, not an error: test with golden byte vectors.
  • Clamp NaN and infinity before quantizing; offset signed ints by min.

Variants and alternatives

In production

  • Fiedler's serialize.h and yojimbo: 1-bit bool, ranged ints, alignment, one function for read and write.
  • DEFLATE (RFC 1951) and video codecs: bit-granular headers.
  • LoRaWAN: maximum payload is region and data-rate specific (The Things Network).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
bitstream-ioRusttuffy/bitstream-iocargo add bitstream-io————Unaligned reads and writes in big- and little-endian order
serializeC++mas-bandwidth/serializebrew install serialize————Single header: 1-bit bool, ranged ints, alignment
yojimboC++mas-bandwidth/yojimbogit clone https://github.com/mas-bandwidth/yojimbo————Game network library with bitpacker; build from source
dekuRustsharksforarms/dekucargo add deku————Derive macro for bit-level structs
bitvecRustbitvecto-rs/bitveccargo add bitvec————Bit-addressed collections; no packet reader
bitbufferRusticewind/bitbuffercargo add bitbuffer————Reads bit sequences from a byte slice

Sources

  • G. Fiedler, Reading and Writing Packets, Gaffer On Games, 2016. article
  • G. Fiedler, Serialization Strategies, Gaffer On Games, 2016. article · code
  • P. Deutsch, DEFLATE Compressed Data Format Specification 1.3, RFC 1951, 1996, §3.1.1. rfc
  • Nasdaq, TotalView-ITCH 5.0, data types. spec
  • The Things Network, LoRaWAN message types. docs
  • ITU-T, Recommendation H.264. page

Used by

Systems (5):

Alternatives and related