BitPack is a high-performance, native AOT-compatible, zero-allocation C# bit-level serialization class library. It is designed specifically for multiplayer games and high-throughput network architectures where bandwidth footprint and execution overhead must be kept to an absolute minimum.
By generating serialization logic at compile time using Roslyn Incremental Source Generators, BitPack reads and writes variables directly to a bitstream, allowing properties to occupy fractional byte lengths on the wire (e.g., storing a rotation angle in exactly 9 bits instead of a 32-bit integer).
- Installation
- Quick Start
- Supported Types
- Attributes
- Member Selection
- Range-Based Optimization Examples
- Serialization Wire Format
- Backward Compatibility
- Annotations and Runtime Safety
- Architectural Guidance
- Diagnostics
- Benchmark Results
- Contributing
Install the BitPack class library package via the NuGet Package Manager or .NET CLI:
dotnet add package BitPackInstall-Package BitPack<PackageReference Include="BitPack" Version="1.6.0" />using BitPack;
using System.ComponentModel.DataAnnotations;
[BitPacket]
public partial class PlayerInput
{
[BitFieldKey(0)]
[Range(0, 360)] public int AimAngle { get; set; }
[BitFieldKey(1)]
public bool IsMoving { get; set; }
[BitFieldKey(2)]
[MaxLength(16)] public string Name { get; set; } = "";
}
var packet = new PlayerInput { AimAngle = 90, IsMoving = true, Name = "Alex" };
var buffer = new byte[PlayerInput.MaxBytes];
packet.Serialize(new BitWriter(buffer));
var restored = PlayerInput.Read(new BitReader(buffer));The static Read factory and the Serialize/Read methods are generated for every [BitPacket] type at compile time, alongside MaxBits, MaxBytes, LayoutHash, and LayoutManifest constants.
BitPack supports a dedicated subset of types designed for predictable sizing and serialization:
| Type Category | Supported Types | Wire Serialization Mechanics |
|---|---|---|
| Primitives | bool |
1 bit (true = 1, false = 0) |
char |
16 bits (standard UTF-16 character value) | |
sbyte, byte |
8 bits (default, range-compressible) | |
short, ushort |
16 bits (default, range-compressible) | |
int, uint |
32 bits (default, range-compressible) | |
long, ulong |
64 bits (default, range-compressible) | |
| Floating-Point | float, double |
32/64 bits (default IEEE 754) or quantized fixed-point integer bits |
| High-Precision | decimal |
128 bits (lossless 4-segment 32-bit integer backing) |
| Native Integers | nint, nuint |
64 bits (default, range-compressible) |
| Text | string |
UTF-8 byte stream prepended by a bit-length header |
| Enums | Any C# enum |
Compact bits based on range or the enum's maximum declared value |
| Date & Time | DateTime |
64 bits (UTC tick representation) |
| Game Math | System.Numerics.Vector2, Vector3, Quaternion, angle float/double |
Attribute-driven quantized game-networking codecs |
| Value Objects | User-defined struct |
Supported via custom static Read(BitReader) and Serialize(BitWriter) hook detection |
| Nested Objects | Types marked [BitPacket] |
Recursively serialized inline |
| Bounded Arrays | One-dimensional T[] with [MaxLength] or [FixedCount] |
Compact length header for variable arrays, no header for fixed arrays |
| Interfaces | Interfaces implementing IBitSerializable |
Deserialized into a pre-allocated concrete object held by the host |
| Compact Unions | Interfaces marked [BitDiscriminator] + [BitVariant] |
Discriminator picks the concrete variant purely from the wire |
Use the following attributes to configure bit constraints, quantization, versioning, and member selection.
| Attribute | Targets | Description |
|---|---|---|
[BitPacket(Version = 1)] |
Classes, structs, records | Marks a type as a serialization target. The generator creates full snapshot APIs (Serialize, Deserialize, Read, TrySerialize) and baseline delta APIs (SerializeDelta, ReadDelta, TrySerializeDelta) when the packet layout supports them. If Version > 1, a 4-bit protocol version header is prepended to the bitstream. |
[Precision(int decimals)] |
Float and double properties/fields | Combines with [Range] to quantize floating-point values into fixed-point representations. [Precision(2)] multiplies by 100 and reserves only the bits needed for that integer range. |
[SinceVersion(int version)] |
Properties/fields in a [BitPacket] |
Restricts serialization of the member to streams matching or exceeding the specified version index. |
[BitFieldKey(int key)] |
Properties/fields (and inherited members) | Assigns a stable wire-layout key. When any serializable member in a packet uses [BitFieldKey], every serializable member must use it. Members are written in ascending key order. Keys must be zero or greater and unique across the full inherited packet layout. |
[FixedCount(int count)] |
One-dimensional arrays | Serializes exactly count array elements with no length header. The array must be non-null and must have exactly count elements at serialization time. |
[Angle(int bits = 16)] |
float/double members (including bounded array elements) |
Quantizes a degree angle in [0, 360) into bits bits. Out-of-range values throw during Serialize(). |
[VectorRange(double minimum, double maximum, int decimals)] |
Vector2/Vector3 members (including bounded array elements) |
Quantizes every vector component using the same range and decimal precision. |
[QuaternionSmallestThree(int bitsPerComponent = 16)] |
Quaternion members |
Normalizes and serializes a quaternion using the smallest-three representation: 2 bits for the omitted largest component index plus three quantized components. |
[BitDiscriminator(int discriminatorBits = 0, int defaultVariant = 0)] |
Union slot properties/fields/parameters | Marks the slot as a compact union. Each [BitVariant] declares a concrete [BitPacket] type and the discriminator index used to select it. |
[BitVariant(Type variantType, int index)] |
Same target as [BitDiscriminator] |
Declares one concrete variant for the slot. The index is the discriminator value written before the variant payload. Multiple [BitVariant] attributes per slot are allowed. |
| Attribute | Targets | Description |
|---|---|---|
[Range(double minimum, double maximum)] |
Integer, float, double properties/fields | Defines the numerical boundaries on the wire. BitPack calculates the absolute minimum bit-width required to store the range size (maximum - minimum). |
[MaxLength(int length)] / [StringLength(int length)] |
String properties/fields | Dictates the maximum expected character length. BitPack uses this value to size the length header on the wire. |
[MaxLength(int length)] |
One-dimensional arrays | Dictates the maximum element count for variable-length arrays. BitPack writes a compact array length header followed by only the present elements. |
| Attribute | Targets | Description |
|---|---|---|
[JsonIgnore] / [IgnoreDataMember] |
Public properties/fields | Excludes the decorated public member from serialization. |
[DataMember] |
Non-public properties/fields | Promotes the decorated non-public member to a serialization target. |
This example shows how to control which properties and fields are serialized:
using System;
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using BitPack;
[BitPacket]
public partial class PlayerState
{
// Public read-write property. Serialized by default.
// Constrained to 0-1000 range (uses 10 bits).
[Range(0, 1000)]
public int PlayerId { get; set; }
// Public property excluded using JsonIgnore.
[JsonIgnore]
public string TemporarySessionToken { get; set; }
// Public property excluded using IgnoreDataMember.
[IgnoreDataMember]
public int UIReferenceCount { get; set; }
// Private field promoted to a serialization target using DataMember.
[DataMember]
[Range(0, 100)]
private int _health = 100;
// Internal property promoted using DataMember.
[DataMember]
[MaxLength(16)]
internal string ClanTag { get; set; }
public int GetHealth() => _health;
public void SetHealth(int value) => _health = value;
}[BitPacket]
public partial struct NavigationSnapshot
{
// Range: -100.0 .. 100.0. Range size = 200.0.
// Precision: 2 decimal places. Scale = 10^2 = 100.
// Integer range: 0 to (200.0 * 100) = 20000.
// Bits required: ceil(log2(20001)) = 15 bits on the wire.
[Range(-100.0, 100.0)]
[Precision(2)]
public float Latitude { get; set; }
[Range(-180.0, 180.0)]
[Precision(2)]
public float Longitude { get; set; }
}Custom read-only value-object structs are supported when they expose a Serialize(BitWriter) method and a static Read(BitReader) factory:
public readonly struct NetworkId
{
public uint Value { get; }
public NetworkId(uint value) => Value = value;
// Serialization hook called automatically by the generator.
public void Serialize(BitWriter writer) => writer.WriteULong(Value, 24); // 24 bits
// Deserialization hook called automatically by the generator.
public static NetworkId Read(BitReader reader) => new NetworkId((uint)reader.ReadULong(24));
}
[BitPacket]
public partial record NetworkHeader
{
public NetworkId SourceId { get; set; }
}Arrays must be one-dimensional and bounded with either [MaxLength] or [FixedCount] so BitPack can calculate MaxBits and MaxBytes at compile time.
[BitPacket]
public partial record SnapshotBatch
{
// Variable count: writes a compact length header, then 0..16 IDs.
[MaxLength(16)]
[Range(0, 1023)]
public int[] EntityIds { get; set; } = Array.Empty<int>();
// Fixed count: writes exactly 4 bools and no length header.
[FixedCount(4)]
public bool[] Buttons { get; set; } = Array.Empty<bool>();
}Variable arrays treat null as empty during serialization. Fixed arrays must be non-null and exactly the declared length. Array element types use the same attributes as scalar fields, so [Range], [Precision], enum range overrides, nested [BitPacket] types, and custom value objects apply to each element.
Current limitations: jagged arrays, multidimensional arrays, string[], interface arrays, and List<T> are not supported. Use nested packet types or custom value objects when you need richer repeated structures.
Game math codecs target common networking payloads where full 32-bit floats are usually wasteful:
using System.Numerics;
[BitPacket]
public partial record PlayerTransform
{
// 16 bits instead of a 32-bit float. Valid range is [0, 360).
[Angle(16)]
public float Yaw { get; set; }
// Each Vector3 component uses fixed-point quantization.
// Range -1024..1024 with 2 decimals needs 18 bits per component.
[VectorRange(-1024, 1024, 2)]
public Vector3 Position { get; set; }
// 2-bit largest-component index + 3 quantized components.
[QuaternionSmallestThree(16)]
public Quaternion Rotation { get; set; }
}[Angle]throws for values outside[0, 360)instead of wrapping silently.[VectorRange]throws when any component is outside the declared range.[QuaternionSmallestThree]normalizes non-zero quaternions during serialization and rejects zero-length quaternions.
Game math codecs also work with bounded arrays when the codec attribute is placed on the array member:
[BitPacket]
public partial record AimHistory
{
[FixedCount(4)]
[Angle(8)]
public float[] RecentAimDegrees { get; set; } = Array.Empty<float>();
}Delta serialization writes only fields that differ from a baseline packet. Each field contributes one changed bit; changed fields then write their normal BitPack payload, while unchanged fields write no payload.
var baseline = new PlayerInput { Tick = 100, AimAngle = 90, IsMoving = false };
var current = new PlayerInput { Tick = 101, AimAngle = 90, IsMoving = true };
Span<byte> delta = stackalloc byte[PlayerInput.MaxDeltaBytes];
current.TrySerializeDelta(baseline, delta, out var bytesWritten);
var reader = new BitSpanReader(delta[..bytesWritten]);
var restored = PlayerInput.ReadDelta(baseline, ref reader);Delta payloads are not standalone. The reader must apply the payload to the same baseline state that the writer used, and full Serialize/Read snapshots should be sent whenever a peer needs a new baseline.
Generated delta APIs include:
SerializeDelta(baseline, BitWriter writer)SerializeDeltaUnchecked(baseline, BitWriter writer)ReadDelta(baseline, BitReader reader)TrySerializeDelta(baseline, byte[] buffer, out int bytesWritten)- Span equivalents for span-compatible packets.
MaxDeltaBits and MaxDeltaBytes report worst-case delta size (full snapshot size plus one changed bit per field). Actual deltas are often much smaller. Packets with interface-typed fields do not emit delta APIs yet because changed interface fields need an explicit concrete factory or clone contract.
A compact union pairs a property with a tiny on-wire discriminator that lets the decoder pick the concrete variant type purely from the bit stream. Unlike a plain IBitSerializable interface field, unions do not require the receiver to know the concrete type in advance.
public interface IGameEvent : IBitSerializable { }
[BitPacket]
public partial class MoveEvent : IGameEvent
{
[Range(0, 100)] public int Distance { get; set; }
}
[BitPacket]
public partial class AttackEvent : IGameEvent
{
[Range(0, 360)] public int Angle { get; set; }
public bool Heavy { get; set; }
}
[BitPacket]
public partial class PlayerInputPacket
{
[BitFieldKey(0)] public int Tick { get; set; }
[BitFieldKey(1)]
[BitDiscriminator]
[BitVariant(typeof(MoveEvent), 0)]
[BitVariant(typeof(AttackEvent), 2)]
public IGameEvent Event { get; set; } = new MoveEvent();
}The wire shape for the union slot is:
[discriminatorBits bits: variant index] [variant payload per its [BitPacket] layout]
With two variants declared, the generator defaults discriminatorBits to 1; with up to four variants it becomes 2. Use [BitDiscriminator(discriminatorBits: N)] to override.
Generated delta APIs treat a union slot as a single field. One changed bit covers both the discriminator and the variant payload — if either differs from the baseline, the new discriminator and full payload are written. Span and delta APIs both work for union slots automatically.
Out-of-range discriminators fall back to the configured defaultVariant (default 0) so older streams remain decodable. Diagnostic BP0006 reports empty variant lists, non-[BitPacket] variant types, duplicate indices, and discriminator widths that cannot address the declared variants.
BitPack writes the entire bitstream as a dense, unbroken sequence of bits — there are no delimiters, field names, separators, or type metadata between properties. The wire is self-describing only to code that was compiled with the exact same [BitPacket] layout.
Properties (and [DataMember]-promoted fields) are serialized in declaration order as they appear in the source file unless [BitFieldKey] is used:
[BitPacket]
public partial record PlayerInput
{
[Range(0, 360)] public int AimAngle { get; set; } // Offset 0 bits
public bool IsMoving { get; set; } // Offset 9 bits
[MaxLength(16)] public string Name { get; set; } // Offset 10 bits
}The wire layout for a single packet is:
[bits 0–8: AimAngle (9 bits)] [bit 9: IsMoving (1 bit)] [bits 10+: Name (6-bit length header + UTF-8 bytes)]
If the packet has [BitPacket(Version = N)] with N > 1, a 4-bit version header is prepended at offset 0 before all properties.
Delta payloads use the same field order, but each field is preceded by a 1-bit changed flag. If the flag is 0, the reader copies that field from the supplied baseline. If the flag is 1, the reader consumes the normal field payload from the stream.
Union payloads interleave a small discriminator before each variant payload, sharing the host packet's bit stream.
The deserializer reads properties in the same sequential order, consuming exactly the same number of bits per property. Any mismatch in order, type, or bit-width between writer and reader produces silent data corruption — not an error message.
To make refactors safer, use [BitFieldKey] on every serializable member in a packet. The generator then writes fields in ascending key order instead of source declaration order:
[BitPacket]
public partial record PlayerInput
{
[BitFieldKey(1)]
public bool IsMoving { get; set; }
[BitFieldKey(0)]
[Range(0, 360)]
public int AimAngle { get; set; }
}This still writes AimAngle first, then IsMoving, even though the source declarations are reversed. If one member has [BitFieldKey], all serializable members in that packet must have one. Duplicate or negative keys are generator errors.
| Property type | Wire bits (constrained) | Wire bits (unconstrained) |
|---|---|---|
bool |
1 bit (always) | — |
[Range(min, max)] int |
⌈log₂(max − min + 1)⌉ |
32 bits |
[Range(min, max)][Precision(d)] float |
⌈log₂((max − min) × 10ᵈ + 1)⌉ |
32 bits (IEEE 754) |
[MaxLength(n)] string |
⌈log₂(n × 4)⌉ length header + UTF-8 bytes |
⌈log₂(256 × 4)⌉ = 10-bit header |
decimal |
128 bits (always) | — |
enum |
⌈log₂(max value + 1)⌉ or [Range] override |
— |
[BitPacket] nested type |
Sum of its own property bits | — |
[MaxLength(n)] T[] |
⌈log₂(n + 1)⌉ length header + present element bits |
Unsupported without bounds |
[FixedCount(n)] T[] |
n × element bits |
Unsupported without bounds |
[Angle(b)] float |
b bits |
32 bits |
[VectorRange(min, max, d)] Vector2 |
2 × component bits |
Unsupported without codec |
[VectorRange(min, max, d)] Vector3 |
3 × component bits |
Unsupported without codec |
[QuaternionSmallestThree(b)] Quaternion |
2 + 3b bits |
Unsupported without codec |
[BitDiscriminator] T |
discriminatorBits + max(variant MaxBits) |
Unsupported without [BitVariant] |
For a variable bounded array like [MaxLength(4)][Range(0, 31)] int[], BitPack writes a 3-bit array length header followed by 5 bits per present element. For a fixed array like [FixedCount(4)] bool[], BitPack writes exactly 4 bits with no length header.
For [VectorRange], component bits are calculated as ⌈log₂((max − min) × 10ᵈ + 1)⌉.
Use [SinceVersion] to add new fields to live protocols without breaking backward compatibility:
[BitPacket(Version = 2)] // Prepends a 4-bit header indicating V2
public partial record PlayerInput
{
[Range(0, 360)]
public int AimAngle { get; set; } // Version 1 (default)
[SinceVersion(2)]
public bool ExtraBoost { get; set; } // Only serialized if stream version >= 2
}Stream gating rules:
- V2 Client to V2 Server: Version 2 header is sent. Both
AimAngleandExtraBoostare read and written. - V1 Client to V2 Server: Version 1 header is sent. The server reads
AimAngle, seespacketVersion (1) < SinceVersion (2), skips readingExtraBoost, and defaults it tofalse. The bitstream remains aligned.
BitPack's dense bitstream layout means that any change to the property sequence, count, types, or constraint attributes can silently break compatibility. Since there are no field identifiers or type tags on the wire, mismatches produce garbled data rather than clean errors.
Properties are serialized in declaration order unless [BitFieldKey] is used. Reordering unkeyed properties in source code changes the wire layout:
// Version A — compatible stream
[BitPacket]
public partial record PlayerInput
{
public bool IsMoving { get; set; } // bit 0
public int AimAngle { get; set; } // bits 1–9
}
// Version B — INCOMPATIBLE (reordered)
[BitPacket]
public partial record PlayerInput
{
public int AimAngle { get; set; } // NOW bit 0 — misaligned!
public bool IsMoving { get; set; } // NOW bit 9 — reads garbage
}A V2 client sending in the new order to a V1 server will interleave AimAngle bits where IsMoving was expected, corrupting every subsequent field. For unkeyed packets, always append new properties to the end of the class. For keyed packets, keep existing keys stable and assign new fields unused keys.
| Action | Result |
|---|---|
Add at end + [SinceVersion] |
New clients write the field; old clients skip it on read. Safe. |
Add at end without [SinceVersion] |
Old clients won't know the field exists — deserialization reads incorrect bits from the new field's slot. Unsafe. |
| Add in the middle | Same as reordering — breaks alignment for all subsequent fields. Unsafe. |
Add with a new [BitFieldKey] but no [SinceVersion] |
Source order is stable, but old clients still do not know the field exists. Unsafe. |
Removing a property creates a "hole" in the bitstream. Old clients still encode bits for the removed field; the new server or client will read those bits as the next property, shifting all subsequent reads:
// Version A
[BitPacket(Version = 2)]
public partial record PlayerInput
{
[SinceVersion(2)]
public bool SprintEnabled { get; set; } // bits 0–0
public int AmmoCount { get; set; } // bits 1–7
}
// Stream: [SprintEnabled:1b][AmmoCount:7b]
// Version B — REMOVED SprintEnabled
[BitPacket(Version = 2)]
public partial record PlayerInput
{
public int AmmoCount { get; set; } // NOW bits 0–6 — misaligned!
}
// But old client writes: [SprintEnabled:1b][AmmoCount:7b]
// New server reads AmmoCount from bits 0–6, losing SprintEnabled bit + 1 AmmoCount bitInstead of removing a field, gate it behind [SinceVersion] on a bumped packet version and leave its declaration in place. This ensures old clients produce a stream that the new server can still parse correctly.
Renaming a property has no effect on the wire format. BitPack serializes by declaration order, not by member name.
| Change | Effect |
|---|---|
[Range(0, 100)] → [Range(0, 1000)] |
Bit-width changes (7 → 10 bits). Wire layout shifts. |
[MaxLength(16)] → [MaxLength(32)] |
Length header bit-width changes. All subsequent fields shift. |
[MaxLength(4)] int[] → [MaxLength(8)] int[] |
Array length header and max payload size can change. |
[FixedCount(4)] bool[] → [FixedCount(8)] bool[] |
Fixed payload width changes. |
[Angle(16)] → [Angle(12)] |
Angle bit-width changes. |
[VectorRange(-100, 100, 2)] → [VectorRange(-1000, 1000, 2)] |
Component bit-width changes. |
[QuaternionSmallestThree(16)] → [QuaternionSmallestThree(12)] |
Quaternion payload width changes. |
int → long |
32 → 64 bits (or constrained bit-width changes). Shift. |
float → [Range][Precision] float |
32 bits → range bits. Shift. |
| Change | Safe? | Mitigation |
|---|---|---|
| Append new property at end | If [SinceVersion] and version bumped |
Gate behind version |
| Append new property at end (no version) | No | Bump packet version first |
| Reorder unkeyed properties | No | Never reorder, or migrate to [BitFieldKey] with golden-file tests |
| Reorder keyed properties in source | Yes | Keep existing [BitFieldKey] values unchanged |
| Delete a property | No | Gate behind [SinceVersion] instead |
| Rename a property | Yes | No action needed |
Change a [BitFieldKey] value |
No | Treat as a wire-layout change |
Change [Range] on existing property |
No | Fork to a new packet type, or bump version and add a new constrained property at end while deprecating the old one |
Change [MaxLength] on existing string property |
No | Same as range change |
Change [MaxLength] or [FixedCount] on an array |
No | Add a new version-gated array field and keep the old one |
| Change game math codec settings | No | Add a new version-gated field and keep the old one |
Change property type (int → long) |
No | Add new property at end; keep old as unused |
BitPack cannot detect wire-format mismatches at runtime (there is no schema negotiation). The recommended approach is:
- Protocol negotiation: Exchange a version handshake at connection time (independent of BitPack). The server rejects clients with incompatible packet versions.
- Packet type IDs: Prefix each packet with a small integer identifying its schema version, checked before deserialization.
- Integration tests: Serialize known payloads to golden byte arrays in CI. Any change to the wire format breaks the golden test, alerting the developer.
// Golden-file test pattern
[Fact]
public void PlayerInput_WireFormat_IsStable()
{
var packet = new PlayerInput { AimAngle = 90, IsMoving = true };
var writer = new BitWriter(new byte[16]);
packet.Serialize(writer);
var golden = Convert.ToHexString(writer.ToArray());
Assert.Equal("5A02", golden); // Fails if wire format ever changes
}BitPack's bit-width optimization is driven entirely by compile-time attributes. Without them, every property falls back to its full C# type width (32 bits for int, 64 bits for long, etc.):
| Attribute | Effect on wire size |
|---|---|
[Range(0, 1000)] |
Stores the value offset in exactly ⌈log₂(1001)⌉ = 10 bits instead of 32 |
[MaxLength(16)] (string) |
Allocates only ⌈log₂(16×4)⌉ = 6 bits for the string length header |
[Precision(2)] (with [Range]) |
Quantizes a float/double into a fixed-point integer needing only range bits |
[Angle(16)] |
Stores a degree angle in 16 bits instead of 32 |
[VectorRange(-100, 100, 2)] |
Stores each Vector2/Vector3 component as fixed-point range bits |
[QuaternionSmallestThree(16)] |
Stores a quaternion in 50 bits instead of 128 |
[BitDiscriminator] + [BitVariant] |
Adds discriminatorBits + max(variant MaxBits) instead of an interface reference |
Enums are detected automatically. BitPack calculates the minimum bit-width from the largest declared enum field value. To override this (e.g., to reserve headroom for future values), apply [Range] directly on the enum property:
[BitPacket]
public partial record GamePacket
{
// Auto-detected: max field is 2 → 2 bits on the wire
public GameState State { get; set; }
// Override: reserve up to 15 → 4 bits on the wire
[Range(0, 15)]
public GameState FutureProofState { get; set; }
}
public enum GameState { Idle = 0, Running = 1, Paused = 2 }BitPack trusts the annotations at face value. Assigning values that exceed declared constraints throws an ArgumentOutOfRangeException at Serialize() time for all constrained types — strings, integers, floats, and enums.
Strings — exceeds [MaxLength] or [StringLength]:
[BitPacket]
public partial record PlayerPacket
{
[MaxLength(16)]
public string Name { get; set; }
}
// Throws ArgumentOutOfRangeException at Serialize():
new PlayerPacket { Name = "This string is way longer than sixteen characters" };Arrays — exceeds [MaxLength] or violates [FixedCount]:
[BitPacket]
public partial record InputPacket
{
[MaxLength(8)]
[Range(0, 1023)]
public int[] EntityIds { get; set; } = Array.Empty<int>();
[FixedCount(4)]
public bool[] Buttons { get; set; } = Array.Empty<bool>();
}
// Throws: EntityIds has 9 elements but max is 8.
new InputPacket { EntityIds = new int[9], Buttons = new bool[4] }.Serialize(writer);
// Throws: Buttons must contain exactly 4 elements.
new InputPacket { EntityIds = Array.Empty<int>(), Buttons = new bool[2] }.Serialize(writer);Integers — outside [Range]:
[BitPacket]
public partial record PlayerPacket
{
[Range(0, 100)]
public int Health { get; set; }
}
// Throws ArgumentOutOfRangeException at Serialize():
var packet = new PlayerPacket { Health = 999 };
packet.Serialize(writer);Enums — auto-detected or [Range]-declared:
[BitPacket]
public partial record GamePacket
{
// Auto-detected max: Paused=2 → throws for values > 2
public GameState State { get; set; }
// Explicit [Range]: throws for values outside [0, 15]
[Range(0, 15)]
public GameState FutureProofState { get; set; }
}
// Throws ArgumentOutOfRangeException at Serialize():
new GamePacket { State = (GameState)99 };Floats/doubles with [Precision] throw identically for values outside the [Range].
Game math codecs — [Angle] throws outside [0, 360), [VectorRange] throws when any component is outside the declared range, and [QuaternionSmallestThree] throws for zero-length quaternions:
[BitPacket]
public partial record TransformPacket
{
[Angle(16)]
public float Yaw { get; set; }
[VectorRange(-100, 100, 2)]
public Vector3 Position { get; set; }
[QuaternionSmallestThree(16)]
public Quaternion Rotation { get; set; }
}
// Throws: angle must be less than 360.
new TransformPacket { Yaw = 360f, Rotation = Quaternion.Identity }.Serialize(writer);
// Throws: X exceeds the VectorRange max.
new TransformPacket { Position = new Vector3(101f, 0f, 0f), Rotation = Quaternion.Identity }.Serialize(writer);BitPack cannot validate runtime values at compile time. Guard your setters or validate inputs before constructing packets to stay within declared bounds.
BitPack supports C# object initialization patterns:
- Parameterless Constructors: If a class or record exposes a parameterless constructor, the static
Readfactory instantiates the type and sets properties using object initializers. - Init-Only and Required Properties: Properties marked
initorrequiredare initialized during object construction in the staticRead(BitReader)factory. - Parameterized and Primary Constructors: If a type defines a parameterized constructor (such as C# records with primary constructors), the generator matches the constructor parameter names to the serialization targets (case-insensitive). It parses the values from the bitstream first, instantiates the type using the matched constructor, and sets any remaining targets via object initializers.
Concrete [BitPacket] classes include serializable members inherited from base classes. This lets you define common packet metadata once:
public abstract class BaseGamePacket
{
[BitFieldKey(0)]
[Range(0, 1_000_000)]
public int Tick { get; set; }
[BitFieldKey(1)]
public bool IsReliable { get; set; }
}
[BitPacket]
public partial class PlayerInputPacket : BaseGamePacket
{
[BitFieldKey(2)]
[Range(0, 360)]
public int AimAngle { get; set; }
[BitFieldKey(3)]
public bool IsMoving { get; set; }
}Base members are discovered before derived members, and keyed packets are serialized by ascending [BitFieldKey] across the full inherited layout. Do not mark abstract base classes with [BitPacket]; only concrete packet types should be packet targets.
All string properties are serialized using UTF-8 encoding. This supports international characters and Unicode symbols. Temporary byte buffers are rented from ArrayPool<byte>.Shared when string payloads need encoding or decoding.
BitWriter and BitReader support both array and memory-backed buffers:
var buffer = new byte[PlayerInput.MaxBytes];
var writer = new BitWriter(buffer.AsMemory());
packet.Serialize(writer);
var reader = new BitReader(new ReadOnlyMemory<byte>(buffer, 0, writer.BytesWritten));
var restored = PlayerInput.Read(reader);For stackalloc, socket buffers, and allocation-sensitive hot paths, use the span-native ref struct APIs:
Span<byte> buffer = stackalloc byte[PlayerInput.MaxBytes];
var writer = new BitSpanWriter(buffer);
packet.Serialize(ref writer);
var reader = new BitSpanReader(buffer[..writer.BytesWritten]);
var restored = PlayerInput.Read(ref reader);Generated span overloads are emitted only when every field can be serialized with built-in span-compatible codecs. Packets containing interface fields (including [BitDiscriminator] slots whose variants are not all span-compatible), nested packet fields with custom hooks, or unsupported custom value objects keep the classic BitWriter/BitReader APIs.
BitPack is engineered solely for structural packet size optimization (fitting fields into precise bit configurations).
- Compression: Generic compression algorithms (like Brotli, Deflate, or Gzip) operate on byte patterns. Trying to compress individual bits during serialization is counterproductive.
- Encryption: Symmetric encryption (like AES-GCM or ChaCha20) must be applied to the completed byte array.
- Pipeline Recommendation:
If encryption or compression are required, they should be applied to the written byte range, e.g.
[C# Objects] ──> BitPack (Serialize) ──> [Raw Bytes] ──> Encrypt/Compress ──> [UDP/TCP Socket]buffer.AsSpan(0, writer.BytesWritten).
BitPack's performance is achieved through several deliberate design choices:
- Tiered bit-width writes: The
WriteLong/ReadLongcore methods branch on the total bit span (bitOffset + bitCount), dispatching to byte (≤8 bits),ushort(≤16),uint(≤32), orulong(≤64) read-modify-write paths. This avoids full 64-bit memory traffic for narrow values like booleans or range-constrained integers. - Low-allocation paths: Decimal serialization uses stack storage on modern TFMs, and string encoding/decoding rents temporary buffers from
ArrayPool<byte>.Shared. - Span-based APIs throughout:
BitSpanWriter,BitSpanReader,WriteBytes, andReadBytesoperate onSpan<byte>/ReadOnlySpan<byte>, enabling stackalloc buffers and direct socket/pipeline integration. - Compile-time code generation: The Roslyn incremental source generator emits specialized serialization logic per type, avoiding any runtime reflection or dynamic code generation.
BitPack enforces strict compile-time checks to prevent unoptimized layouts or invalid configurations:
| Diagnostic | Severity | Description |
|---|---|---|
BP0001 |
Error | A property type is not packable (lacks [BitPacket], does not implement custom serialization hooks, or is dynamic/object), or a property's SinceVersion exceeds its parent's version. Also raised when a property target lacks a getter accessor, or lacks a setter/init accessor while not matching any constructor parameters (preventing successful serialization/deserialization). |
BP0002 |
Warning | A primitive integer or string does not specify optimization attributes (such as [Range] or [MaxLength]). Full type storage (unquantized) will be used as a fallback. |
BP0003 |
Error | [BitFieldKey] usage is invalid: mixed keyed and unkeyed serializable members, duplicate keys, or negative keys. |
BP0004 |
Error | Array bounds are invalid: missing [MaxLength]/[FixedCount], both bounds specified, negative/zero fixed counts, jagged arrays, multidimensional arrays, string[], or interface arrays. |
BP0005 |
Error | Game math codec attributes are invalid or applied to unsupported types. |
BP0006 |
Error | A [BitDiscriminator] slot is misconfigured: empty [BitVariant] list, a variant that is not marked [BitPacket], duplicate discriminator indices, or a declared discriminatorBits that cannot address the highest declared index. |
The following benchmarks compare serialization and deserialization of two packet structures under High Process Priority using .NET 10.0.9:
- Simple Packet (Primitive-only): Contains 12 properties (integer, float, double, byte, sbyte, short, ushort, ticks, and boolean states) with no string fields.
- Complex Packet: Contains 12 properties including strings, DateTimes, enums, and nested structs.
Run the full benchmark suite in Release mode from the repository root:
dotnet run -c Release -f net10.0 --project BitPack.Benchmarks/BitPack.Benchmarks.csprojBenchmarkDotNet will prompt for which benchmarks to run. Use * to run all benchmarks, or enter a filter such as *Simple*, *Complex*, *Serialize*, or *Deserialize* to run a subset.
Results are written to BenchmarkDotNet.Artifacts/results/ as Markdown, CSV, and HTML reports. For the most stable numbers, close other heavy applications and avoid using the machine while the benchmarks run.
| Serializer | Wire Size (Bytes) | Bandwidth Saved vs MessagePack | Bandwidth Saved vs JSON |
|---|---|---|---|
| BitPack | 17 bytes | 55.3% | 89.7% |
| MemoryPack | 48 bytes | -26.3% | 70.9% |
| MessagePack | 38 bytes | Reference | 77.0% |
| protobuf-net | 50 bytes | -31.6% | 69.7% |
| System.Text.Json | 165 bytes | -334.2% | Reference |
BenchmarkDotNet v0.15.8, .NET 10.0.9 (X64 RyuJIT x86-64-v3). Deserialization benchmarks consume the full deserialized object with BenchmarkDotNet's Consumer.
| Method | Scenario | Mean Speed | Median Speed | Managed Allocated Memory |
|---|---|---|---|---|
| Serialize_Simple_BitPack_ReusableBuffer | Reused caller buffer | 27.639 ns | 26.864 ns | 0 B |
| Serialize_Simple_BitPack_FreshBuffer | Fresh byte array | 31.995 ns | 31.773 ns | 88 B |
| Serialize_Simple_BitPack_PooledBuffer | ArrayPool byte array | 36.041 ns | 35.799 ns | 40 B |
| Serialize_Simple_MemoryPack_ReusableWriter | Reused writer | 3.182 ns | 3.167 ns | 0 B |
| Serialize_Simple_MemoryPack_FreshWriter | Fresh writer | 15.431 ns | 15.445 ns | 312 B |
| Serialize_Simple_MessagePack_ReusableWriter | Reused writer | 36.159 ns | 35.943 ns | 0 B |
| Serialize_Simple_MessagePack_FreshWriter | Fresh writer | 50.333 ns | 49.903 ns | 312 B |
| Serialize_Simple_ProtoBuf_ReusableBuffer | Reused buffer | 151.106 ns | 151.534 ns | 64 B |
| Serialize_Simple_ProtoBuf_FreshBuffer | Fresh buffer | 162.467 ns | 161.377 ns | 344 B |
| Serialize_Simple_JsonContext_ReusableBuffer | Reused buffer | 250.478 ns | 246.602 ns | 512 B |
| Serialize_Simple_JsonContext_FreshBuffer | Fresh buffer | 260.442 ns | 256.496 ns | 792 B |
| Method | Mean Speed | Median Speed | Managed Allocated Memory |
|---|---|---|---|
| Deserialize_Simple_BitPack | 21.481 ns | 21.431 ns | 0 B |
| Deserialize_Simple_MemoryPack | 1.089 ns | 1.088 ns | 0 B |
| Deserialize_Simple_MessagePack | 53.104 ns | 52.062 ns | 0 B |
| Deserialize_Simple_ProtoBuf | 149.046 ns | 149.089 ns | 88 B |
| Deserialize_Simple_JsonContext | 402.547 ns | 401.944 ns | 64 B |
| Serializer | Wire Size (Bytes) | Bandwidth Saved vs MessagePack | Bandwidth Saved vs JSON |
|---|---|---|---|
| BitPack | 68 bytes | 40.9% | 83.2% |
| MemoryPack | 139 bytes | -20.9% | 65.7% |
| MessagePack | 115 bytes | Reference | 71.6% |
| protobuf-net | 138 bytes | -20.0% | 65.9% |
| System.Text.Json | 405 bytes | -252.2% | Reference |
BenchmarkDotNet v0.15.8, .NET 10.0.9 (X64 RyuJIT x86-64-v3).
| Method | Scenario | Mean Speed | Median Speed | Managed Allocated Memory |
|---|---|---|---|---|
| Serialize_Complex_BitPack_ReusableBuffer | Reused caller buffer | 106.775 ns | 106.514 ns | 0 B |
| Serialize_Complex_BitPack_FreshBuffer | Fresh byte array | 135.982 ns | 135.772 ns | 296 B |
| Serialize_Complex_BitPack_PooledBuffer | ArrayPool byte array | 114.985 ns | 114.826 ns | 40 B |
| Serialize_Complex_MemoryPack_ReusableWriter | Reused writer | 27.278 ns | 27.204 ns | 0 B |
| Serialize_Complex_MemoryPack_FreshWriter | Fresh writer | 45.359 ns | 43.821 ns | 312 B |
| Serialize_Complex_MessagePack_ReusableWriter | Reused writer | 103.857 ns | 103.753 ns | 0 B |
| Serialize_Complex_MessagePack_FreshWriter | Fresh writer | 129.704 ns | 128.765 ns | 312 B |
| Serialize_Complex_ProtoBuf_ReusableBuffer | Reused buffer | 343.284 ns | 343.380 ns | 64 B |
| Serialize_Complex_ProtoBuf_FreshBuffer | Fresh buffer | 362.311 ns | 361.694 ns | 344 B |
| Serialize_Complex_JsonContext_ReusableBuffer | Reused buffer | 952.025 ns | 947.801 ns | 5016 B |
| Serialize_Complex_JsonContext_FreshBuffer | Fresh buffer | 1,016.394 ns | 1,010.441 ns | 6280 B |
| Method | Mean Speed | Median Speed | Managed Allocated Memory |
|---|---|---|---|
| Deserialize_Complex_BitPack | 104.865 ns | 103.978 ns | 104 B |
| Deserialize_Complex_MemoryPack | 42.235 ns | 41.502 ns | 104 B |
| Deserialize_Complex_MessagePack | 196.158 ns | 192.539 ns | 104 B |
| Deserialize_Complex_ProtoBuf | 390.517 ns | 389.103 ns | 192 B |
| Deserialize_Complex_JsonContext | 1,200.014 ns | 1,184.467 ns | 792 B |
Note on allocations: The 104 bytes allocated during Deserialize_Complex_BitPack represent the two deserialized string objects themselves ("Alex" and "Multiplayer Engine Pilot"). BitPack's deserializer internals perform 0 garbage/helper allocations.
Contributions are welcome. Please open an issue first to discuss what you'd like to change.
- .NET SDK 10.0 (or later)
dotnet build BitPack.slnxdotnet test BitPack.Tests/BitPack.Tests.csprojSee Running Benchmarks.
| Directory | Description |
|---|---|
BitPack/ |
Core library — BitWriter, BitReader, attributes, and the IBitSerializable interface |
BitPack.Generator/ |
Roslyn incremental source generator — emits Serialize, Deserialize, Read, and the span/delta/union API surface at compile time |
BitPack.Tests/ |
xUnit test suite covering all serialization scenarios |
BitPack.Benchmarks/ |
BenchmarkDotNet harness comparing BitPack against other serializers |
- All public API methods are annotated
[MethodImpl(MethodImplOptions.AggressiveInlining)]. - Internal hot-path methods use
Unsafeintrinsics for unaligned memory access. - The library targets
netstandard2.1for broad compatibility while the generator targetsnetstandard2.0.