Serialization & Compression
Serialization & Compression
Orix provides a custom binary serialization system designed for deterministic networked games and simulations. This document explains the problems with standard serialization approaches and how Orix solves them.
The Problems with Standard Serialization
1. JSON/XML are Inefficient for High-Frequency Data
Text-based formats like JSON and XML add significant overhead:
{ "position": {"x": 123.456, "y": 789.012, "z": 345.678}, "velocity": {"x": 1.23, "y": 4.56, "z": 7.89}, "health": 100}This single entity requires ~130 bytes in JSON. The same data in Orix binary format requires ~25 bytes - a 5x reduction.
For networked games transmitting state at 60 Hz, this difference is critical:
- 100 entities in JSON: 13 KB/frame → 780 KB/s @ 60 Hz
- 100 entities in Orix binary: 2.5 KB/frame → 150 KB/s @ 60 Hz
2. Schema Drift Causes Deserialization Failures
When data structures evolve, text formats fail silently or catastrophically:
// Version 1: Worksvar json = "{\"health\": 100}";
// Version 2: Add required fieldpublic class Player { public int Health { get; set; } public int MaxHealth { get; set; } // NEW - required!}
// Deserialize old data → MaxHealth = 0 (silent bug)Orix schemas enforce versioning and migration paths. Incompatible changes are detected at serialization time, not at runtime.
3. No Deterministic Guarantees
Standard serializers don’t guarantee bit-identical output:
- Dictionary/HashSet iteration order varies
- Floating-point precision differs across platforms
- Date/time serialization depends on locale and timezone
Orix enforces determinism:
- Ordered collections only (
UnsafeMap,UnsafeSet) - Fixed-point arithmetic (
DFixed64) - Tick-based time (
Tick)
4. Bandwidth Waste in Networked Games
Most game state doesn’t change every frame. Standard serializers send the entire state:
Frame 1: [100 entities × 26 bytes = 2600 bytes]Frame 2: [100 entities × 26 bytes = 2600 bytes] ← 95% unchanged!Frame 3: [100 entities × 26 bytes = 2600 bytes]Orix delta compression only sends changes:
Frame 1: [100 entities × 26 bytes = 2600 bytes] ← Initial stateFrame 2: [5 changed entities × 12 bytes = 60 bytes] ← 97% reduction!Frame 3: [3 changed entities × 12 bytes = 36 bytes]How Orix Solves It
Technical Approach
Orix provides four layers of serialization capability:
- Bit-Level Primitives:
BitWriterandBitReaderfor fine-grained control - Schema-Driven Encoding: Axion generates type-safe serialization from schemas
- Delta Compression: Only transmit changed data
- Type-Safe Persistence:
AxionBinaryStore<T>for file storage
What’s Provided
BitWriter: Bit-Level Binary Writing
Zero-allocation writer for binary data with bit-level precision.
Key Features:
- Write arbitrary bit counts (1-64 bits)
- Variable-length integers (VarInt)
- Native support for Orix primitives (
DFixed64,DVector3,Tick,Entity) - Deterministic output (same input = same bytes)
API:
Span<byte> buffer = stackalloc byte[256];var writer = new BitWriter(buffer);
// Write arbitrary bitswriter.WriteBits(value, 12); // 12 bits only
// Write primitiveswriter.WriteByte(255);writer.WriteInt32(42);writer.WriteUInt64(12345678901234);
// Write Orix typeswriter.WriteFixed64(DFixed64.FromDouble(123.456));writer.WriteVector3(new DVector3(1, 2, 3));writer.WriteTick(Tick.FromRaw(1000));writer.WriteEntity(entity);
// Write variable-length integerswriter.WriteVarUInt(42); // 1 byte for small valueswriter.WriteVarUInt(10000); // 2 bytes for medium values
// Write stringswriter.WriteString("Hello"); // Length-prefixed UTF-8writer.WriteFixedString("ABC", 16); // Fixed-width (padded)
// Get written dataReadOnlySpan<byte> data = writer.WrittenSpan;int byteCount = writer.BytePosition;Example: Compact Entity Encoding
// Standard approach: 26 bytes per entityBitConverter.GetBytes(entity.Id); // 4 bytesBitConverter.GetBytes(entity.X); // 4 bytes (float)BitConverter.GetBytes(entity.Y); // 4 bytes (float)// ... total: 26 bytes
// Orix approach: ~18 bytes per entitywriter.WriteVarUInt(entity.Id); // 1-4 bytes (often 1)writer.WriteFixed64(entity.X); // 8 bytes (deterministic)writer.WriteFixed64(entity.Y); // 8 bytes (deterministic)writer.WriteBits(entity.State, 3); // 3 bits (8 possible states)writer.WriteBits(entity.Team, 2); // 2 bits (4 teams)BitReader: Bit-Level Binary Reading
Corresponding reader with same precision and type safety.
API:
ReadOnlySpan<byte> data = receivedPacket;var reader = new BitReader(data);
// Read arbitrary bitsuint value = reader.ReadBits(12);
// Read primitivesbyte b = reader.ReadByte();int i = reader.ReadInt32();ulong l = reader.ReadUInt64();
// Read Orix typesDFixed64 x = reader.ReadFixed64();DVector3 pos = reader.ReadVector3();Tick tick = reader.ReadTick();Entity entity = reader.ReadEntity();
// Read variable-length integersuint small = reader.ReadVarUInt();
// Read stringsstring? text = reader.ReadString();string name = reader.ReadFixedString(16);
// Check statusbool hasMore = !reader.IsAtEnd;int remaining = reader.BitsRemaining;AxionBinaryStore: Type-Safe Persistence
High-level API for storing Axion-generated types to disk.
Single Entity Storage:
// Define schema (in .axion file)/*entity Player { @key id: uuid; name: string; level: int32; position: vec3;}*/
// Use generated typevar store = new AxionBinaryStore<Player>("player.axs");
// Savevar player = new Player { Id = playerId, Name = "Alice", Level = 42, Position = new DVector3(100, 0, 200)};store.Save(player);
// LoadPlayer loaded = store.Load(); // Returns default if not found
// Check/Deletebool exists = store.Exists();store.Delete();Collection Storage:
var listStore = new AxionBinaryListStore<Player>("players.axs");
// Save listvar players = new List<Player> { player1, player2, player3 };listStore.Save(players);
// Load allList<Player> loaded = listStore.Load();
// AppendlistStore.Append(newPlayer); // Note: Reloads and rewrites file
// Count without loadingint count = listStore.Count();Size Comparison (100 entities):
| Format | Size | vs Orix |
|---|---|---|
| JSON (Newtonsoft.Json) | 13,247 bytes | 5.3x larger |
| Binary (AxionBinaryStore) | 2,504 bytes | 1.0x (baseline) |
Delta Compression
Specialized compressors for efficient network synchronization.
DeltaCompressor: General-Purpose Delta Encoding
Vector Delta:
// Encode position changeDVector3 previous = new(100, 0, 200);DVector3 current = new(100.5, 0, 200.2); // Small movement
var writer = new BitWriter(buffer);DeltaCompressor.WriteDeltaVector3(ref writer, previous, current);// Result: ~6 bytes instead of 24 bytes (4x compression)
// Decodevar reader = new BitReader(buffer);DVector3 reconstructed = DeltaCompressor.ReadDeltaVector3(ref reader, previous);Adaptive Fixed-Point Delta:
Uses different bit widths based on delta magnitude:
DeltaCompressor.WriteDeltaFixed(ref writer, delta);
// Encoding:// Zero: 2 bits total// Tiny (-128 to 127): 2 + 8 = 10 bits// Small (-32768 to 32767): 2 + 16 = 18 bits// Full: 2 + 64 = 66 bitsSparse Delta (for large arrays):
// Only encode changed elementsReadOnlySpan<DFixed64> previous = previousHealthValues; // 1000 elementsReadOnlySpan<DFixed64> current = currentHealthValues; // 5 changed
DeltaCompressor.WriteSparseDeltas(ref writer, previous, current);// Result: ~15 bytes instead of 8000 bytesMotionCompressor: Predictive Motion Encoding
Exploits physics prediction for moving entities:
// Encode motionMotionCompressor.WriteMotion( ref writer, prevPos, prevVel, currPos, currVel, dt);
// If motion is predictable (constant velocity), this is very small// Prediction: currPos ≈ prevPos + prevVel * dt// Only the error is transmitted
// Decodevar (pos, vel) = MotionCompressor.ReadMotion( ref reader, prevPos, prevVel, dt);Example Result:
Perfect prediction (constant velocity): 6 bits totalSmall acceleration change: 12-18 bitsLarge direction change: 40-50 bits
vs. Raw encoding: 384 bits (2 × DVector3)XorCompressor: Binary Difference Encoding
For near-identical binary buffers (e.g., snapshot deltas):
byte[] baseline = SerializeGameState(previousFrame);byte[] current = SerializeGameState(currentFrame);
byte[] compressed = new byte[current.Length];int compressedSize = XorCompressor.Compress(baseline, current, compressed);
// Decompressionbyte[] decompressed = new byte[baseline.Length];XorCompressor.Decompress(baseline, compressed, decompressed);How it works:
- XOR current with baseline
- Run-length encode zero runs (unchanged bytes)
- Store non-zero runs (changed bytes)
Example:
Baseline: [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0]Current: [0x12, 0x34, 0x56, 0x99, 0x9A, 0xBC, 0xDE, 0xF0] ^^^ only 1 byte changed
Compressed: [0x00, 0x03, 0x81, 0xE1, 0x00, 0x04] ^^^^^^^^^ 3 zeros ^^^ 1 diff ^^^ 4 zeros = 6 bytes vs 8 bytes rawBitfieldCompressor: Boolean/Enum Packing
Efficient encoding for boolean flags and small enums:
// Encode up to 32 boolean flagsuint previousFlags = 0b11010010;uint currentFlags = 0b11011010; // 1 bit changed
var writer = new BitWriter(buffer);BitfieldCompressor.WriteChangedBools(ref writer, previousFlags, currentFlags);// Result: ~8 bits instead of 32 bits
// Decodevar reader = new BitReader(buffer);uint flags = BitfieldCompressor.ReadChangedBools(ref reader, previousFlags);Encoding strategy:
- No changes: 1 bit
- 1-4 changes: 1 + 1 + (3 + 5×N + N) bits (sparse mode)
- 5+ changes: 1 + 1 + 32 bits (dense mode)
Binary Format Benefits
1. Compact Size
Typical compression ratios:
| Data Type | JSON | Orix Binary | Ratio |
|---|---|---|---|
| Game entity (100 entities) | 13,247 bytes | 2,504 bytes | 5.3x |
| Position vector | 45 bytes | 24 bytes | 1.9x |
| Delta position | 45 bytes | 6 bytes | 7.5x |
| Boolean flags (32) | 160 bytes | 4 bytes | 40x |
2. Fast Serialization
Performance characteristics:
- BitWriter/BitReader: ~1-2 GB/s throughput
- Zero heap allocation: Operates on stack buffers (
Span<byte>) - Direct memory mapping possible: Binary layout matches runtime representation
Benchmark (100 entities, 1000 iterations):
| Method | Avg Size | Serializations/sec |
|---|---|---|
| JSON | 13,247 bytes | ~15,000 |
| MessagePack | 2,891 bytes | ~45,000 |
| Raw Binary | 2,600 bytes | ~120,000 |
| Orix (Full) | 2,504 bytes | ~110,000 |
| Orix (Delta) | 412 bytes | ~180,000 |
3. Schema Validation
Axion-generated types enforce schema compliance:
// Schema defines valid statesentity Player { health: int32 @range(0, 100); state: PlayerState;}
enum PlayerState { Idle = 0; Running = 1; Jumping = 2;}
// Invalid data is rejected at read timevar reader = new BitReader(corruptedData);var player = new Player();player.Unpack(ref reader); // Throws if state > 2 or health > 1004. Deterministic Output
Critical for networked games:
// Same input produces bit-identical outputvar entity = CreateTestEntity(seed: 42);
var buffer1 = Serialize(entity); // Windows, x64var buffer2 = Serialize(entity); // Linux, ARMvar buffer3 = Serialize(entity); // MacOS, x64
Assert.True(buffer1.SequenceEqual(buffer2));Assert.True(buffer2.SequenceEqual(buffer3));This enables:
- Deterministic lockstep: All clients compute same hash
- Replay verification: Recorded inputs produce identical output
- Deduplication: Same state = same bytes = single storage
Example Usage
Low-Level Bit Manipulation
// Pack entity state efficientlySpan<byte> buffer = stackalloc byte[64];var writer = new BitWriter(buffer);
// Health: 0-100 (7 bits)writer.WriteBits((uint)player.Health, 7);
// State: 0-7 (3 bits)writer.WriteBits((uint)player.State, 3);
// Team: 0-3 (2 bits)writer.WriteBits((uint)player.Team, 2);
// Flags (6 booleans = 6 bits)writer.WriteBit(player.IsRunning);writer.WriteBit(player.IsJumping);writer.WriteBit(player.IsCrouching);writer.WriteBit(player.IsShooting);writer.WriteBit(player.IsReloading);writer.WriteBit(player.IsAiming);
// Total: 18 bits = 3 bytes// vs. naive approach: 6 bytes minimumType-Safe Persistence
// Schema-first approachvar store = new AxionBinaryStore<GameSave>("save.axs");
var save = new GameSave { Tick = currentTick, Seed = worldSeed, PlayerPosition = player.Position, Inventory = player.Inventory, CompletedQuests = questSystem.GetCompleted()};
store.Save(save);
// Later...GameSave loaded = store.Load();world.RestoreFrom(loaded);Network Synchronization
// Server: Send delta to clientsvar previous = lastAckedState[clientId];var current = GetCurrentWorldState();
Span<byte> packet = stackalloc byte[1400]; // MTU sizevar writer = new BitWriter(packet);
// Encode tick numberwriter.WriteTick(currentTick);
// Delta-encode each entityforeach (var entity in current.Entities) { var prev = previous.FindEntity(entity.Id);
writer.WriteEntity(entity.Id);
// Only send changed components if (entity.Position != prev.Position) { writer.WriteBit(true); // Position changed DeltaCompressor.WriteDeltaVector3( ref writer, prev.Position, entity.Position); } else { writer.WriteBit(false); // Position unchanged }
if (entity.Health != prev.Health) { writer.WriteBit(true); // Health changed writer.WriteBits((uint)entity.Health, 7); } else { writer.WriteBit(false); // Health unchanged }}
// Send packetnetwork.Send(clientId, writer.WrittenSpan);
// Typical result: 200-600 bytes for 100 entities// vs. full state: 2500+ bytesAdvantages
For Networked Games
- Minimal bandwidth: Delta encoding reduces typical packets by 80-95%
- Low latency: Fast serialization (~1-2 GB/s) allows 60+ Hz updates
- Deterministic: Enables lockstep networking and replay verification
- MTU-friendly: Efficient encoding fits more entities per packet
For Persistence
- Compact files: 5-10x smaller than JSON (faster loading, less storage)
- Type safety: Schema enforcement prevents corruption
- Fast I/O: Binary format minimizes disk operations
- Version tracking: Schema hashes detect format mismatches
For Debugging
- Reproducible: Same input = same bytes (easier to trace bugs)
- Traceable: Can log/diff binary packets efficiently
- Verifiable: Schema validation catches corruption early
Disadvantages
1. Not Human-Readable
Binary data cannot be inspected without tools:
JSON: {"x": 123.4, "y": 567.8} ← Readable in text editorBinary: 0x3D 0x1F 0x00 0x00 ... ← Requires decoderMitigations:
- Provide inspection tools (
orix inspect <file>) - Maintain JSON export capability for debugging
- Use schema documentation for understanding format
2. Requires Tooling to Inspect
Standard tools (hex editors, grep) are insufficient:
Solution: Orix tooling
# Inspect binary fileorix inspect player.axs
# Convert to JSON for debuggingorix export player.axs --format json
# Validate schema complianceorix validate player.axs3. Schema Required for Decoding
Cannot deserialize without matching schema:
// This fails if schema changedvar data = File.ReadAllBytes("old-save.axs");var reader = new BitReader(data);var save = new GameSave();save.Unpack(ref reader); // Error: Schema hash mismatchSolutions:
- Embed schema version in files
- Maintain migration paths
- Keep old schemas for legacy data
4. Learning Curve
Developers must understand:
- Bit-level operations
- Schema design
- Delta encoding strategies
- Fixed-point arithmetic
Mitigations:
- Comprehensive documentation (this document)
- Example projects (
tools/SerializationDemo) - Generated code handles complexity
Performance Comparison
Size Comparison (100 game entities)
Raw Binary: 2,600 bytes (baseline: simple BitConverter encoding)JSON: 13,247 bytes (+5.1x larger)MessagePack: 2,891 bytes (+1.1x larger)Orix (Full): 2,504 bytes (-3.7% smaller)Orix (Delta): 412 bytes (-84% smaller) ← 6.3x compression!Speed Comparison
Serializations per second (100 entities, 1000 iterations):JSON: 15,000/secMessagePack: 45,000/sec (3.0x faster than JSON)Raw Binary: 120,000/sec (8.0x faster than JSON)Orix (Full): 110,000/sec (7.3x faster than JSON)Orix (Delta): 180,000/sec (12x faster than JSON)Bandwidth at 60 Hz (100 entities)
JSON: 795 KB/s (13,247 bytes × 60)MessagePack: 174 KB/s (2,891 bytes × 60)Raw Binary: 156 KB/s (2,600 bytes × 60)Orix (Full): 150 KB/s (2,504 bytes × 60)Orix (Delta): 24 KB/s (412 bytes × 60) ← 97% reduction vs JSON!Real-world impact:
For a multiplayer game with 10 players each receiving 100 entities at 60 Hz:
- JSON: 7.95 MB/s per player → 79.5 Mbps total (exceeds most connections)
- Orix Delta: 0.24 MB/s per player → 2.4 Mbps total (works on mobile)
Comparison to Alternatives
vs JSON
JSON Advantages:
- Human-readable
- Universally supported
- Self-describing (field names included)
Orix Advantages:
- 5-10x smaller
- 10-50x faster serialization
- Deterministic (JSON serializers vary)
- Schema-enforced (no silent errors)
Verdict: Use JSON for configuration, use Orix for runtime data.
vs MessagePack
MessagePack Advantages:
- Language-agnostic
- Self-describing
- Widely adopted
Orix Advantages:
- Schema-enforced (MessagePack is schemaless)
- Better compression (delta encoding)
- Deterministic guarantees
- Native Orix type support (
DFixed64,Tick,Entity)
Verdict: Use MessagePack for external APIs, use Orix for internal state.
vs Protobuf
Protobuf Advantages:
- Industry standard
- Excellent tooling
- Language-agnostic
Orix Advantages:
- Tighter platform integration (Axion schemas)
- Bit-level control (Protobuf is byte-aligned)
- Native deterministic types
- Delta compression built-in
Verdict: Similar capability, Orix better for Orix-native projects.
vs FlatBuffers
FlatBuffers Advantages:
- Zero-copy deserialization
- Access without unpacking
- Language-agnostic
Orix Advantages:
- Schema evolution (FlatBuffers requires careful design)
- Delta compression
- Simpler API
Verdict: Use FlatBuffers for large static data, Orix for dynamic state.
Real-World Example: Networked Game
Problem
Synchronize 100 players in a multiplayer game at 60 Hz:
State per player:- Position: DVector3 (24 bytes)- Velocity: DVector3 (24 bytes)- Rotation: DQuaternion (32 bytes)- Health: 0-100 (1 byte)- Ammo: 0-255 (1 byte)- State flags: 8 booleans (1 byte)
Total: 83 bytes per player raw100 players = 8,300 bytesAt 60 Hz = 498 KB/s per connected clientSolution: Orix Delta Compression
// Server: 60 Hz update loopvoid BroadcastGameState() { foreach (var client in clients) { var lastAcked = client.LastAcknowledgedState; var current = GetWorldState();
Span<byte> packet = stackalloc byte[1400]; // MTU var writer = new BitWriter(packet);
int encoded = 0; foreach (var player in current.Players) { if (encoded >= 50) break; // Prioritize nearby
var prev = lastAcked.FindPlayer(player.Id);
// Delta-encode motion MotionCompressor.WriteMotion( ref writer, prev.Position, prev.Velocity, player.Position, player.Velocity, tickDelta);
// Sparse-encode health/ammo (rarely changes) bool healthChanged = player.Health != prev.Health; writer.WriteBit(healthChanged); if (healthChanged) writer.WriteBits((uint)player.Health, 7);
bool ammoChanged = player.Ammo != prev.Ammo; writer.WriteBit(ammoChanged); if (ammoChanged) writer.WriteByte(player.Ammo);
// Bitfield-encode flags BitfieldCompressor.WriteChangedBools( ref writer, prev.Flags, player.Flags);
encoded++; }
client.Send(writer.WrittenSpan); }}Result:
Typical packet (100 players, ~5 moving significantly):- Predictable motion (95 players): ~4 bits each = 380 bits- Unpredictable motion (5 players): ~40 bits each = 200 bits- Health changes (2 players): ~8 bits each = 16 bits- Flag changes (3 players): ~10 bits each = 30 bits
Total: ~626 bits = 79 bytesvs. Raw: 8,300 bytes
Compression: 99% reduction!Bandwidth: 4.7 KB/s @ 60 Hz (vs 498 KB/s raw)Conclusion
Orix serialization provides:
- Efficiency: 5-10x smaller than JSON, 80-95% compression with deltas
- Speed: 10-50x faster than JSON, ~1-2 GB/s throughput
- Determinism: Bit-identical output enables lockstep networking
- Type Safety: Schema enforcement prevents corruption
- Flexibility: Bit-level control for optimal encoding
Trade-offs:
- Not human-readable (use tools for inspection)
- Requires schema definitions
- Learning curve for developers
Best suited for:
- Networked game state synchronization
- High-frequency data recording (replay systems)
- Embedded systems with limited bandwidth
- Deterministic simulations requiring reproducibility
Not recommended for:
- Configuration files (use JSON/TOML)
- External APIs (use JSON/MessagePack/Protobuf)
- Human-editable data
Related Documents
- Atom Foundation - Deterministic primitives
- Axion Schema - Schema language for type definitions
- Nexus Networking - Delta compression for networking
- Executive Overview - Platform overview and Five Laws