Cookbook
Short, copy-pasteable recipes matching the real, shipped API — every sample below compiles against main.
Each Rust recipe leaves out error handling you’d keep in real code (Result unwraps stand in for real handling).
1. Minimal integration
The entire integration cost for Level-1 (tick-only) divergence detection: one call per tick, one serialization of “whatever state determinism depends on.”
#![allow(unused)]
fn main() {
let mut session = Session::builder()
.tick_rate_hz(60)
.peer_count(2)
.build()?;
// each simulation tick:
let state_bytes = bincode::serialize(&world.deterministic_state())?;
session.hash_tick(tick, &state_bytes)?;
// exchange session.take_pending_hashes() with peers over your existing
// netcode channel (a handful of bytes per tick — piggyback on an existing
// packet, don't open a new one just for this).
}
If a peer’s hash for a tick doesn’t match, session.check_divergence() returns Some(DivergenceTick(tick)) the moment enough peers have reported that tick — you don’t need to wait for the whole match to end to know something’s wrong.
2. CI gate
Single-process determinism testing: run the same simulation twice (or replay a fixed input log twice), assert identical hashes — this is GGRS::SyncTestSession’s trick, generalized and made engine-agnostic.
#![allow(unused)]
fn main() {
let mut a = Session::builder().tick_rate_hz(60).peer_count(1).build()?;
let mut b = Session::builder().tick_rate_hz(60).peer_count(1).build()?;
for tick in 0..NUM_TICKS {
run_one_tick(&mut world_a, tick);
run_one_tick(&mut world_b, tick); // fresh instance, same recorded inputs
a.hash_tick(tick, &serialize(&world_a))?;
b.hash_tick(tick, &serialize(&world_b))?;
}
assert_eq!(a.finish(), b.finish(), "non-determinism detected — see bisection report below");
}
Run foldback ci-check <file> against an already-recorded .foldback file for the CI exit-code contract (0 clean, non-zero on divergence) — see CI Integration.
3. GGRS/Bevy integration
GGRS has no hookable “checksum callback” to wrap — a game computes its own checksum and hands it to GGRS via GameStateCell::save(frame, state, checksum), and for a real networked session GGRS emits GgrsEvent::DesyncDetected { local_checksum, remote_checksum, .. } when two peers’ checksums for a frame disagree. foldback_rs::ggrs (feature ggrs) is the real integration point — two small helpers, not a session-wrapping extension trait:
#![allow(unused)]
fn main() {
use foldback_rs::ggrs::{checksum, record_desync};
// At the point your game already computes a checksum for GameStateCell::save:
let cs = checksum(&state_bytes); // Foldback's hash, widened to GGRS's u128 checksum type
cell.save(frame, Some(state), Some(cs));
// When GGRS's own event loop reports a desync it already detected over the network:
if let GgrsEvent::DesyncDetected { frame, local_checksum, remote_checksum, .. } = event {
record_desync(&mut foldback_session, frame as u64, local_peer_id, remote_peer_id,
local_checksum, remote_checksum)?;
}
}
SyncTestSession (single-process — see examples/ggrs-demo) doesn’t need this bridge: it catches a checksum mismatch internally, no cross-peer network exchange to bridge.
4. Per-entity hashing
Upgrades a divergence report from “tick 4821” to “tick 4821, entity Player#3” — worth the extra integration cost once Level 1 has actually found a bug and you need to narrow it down.
#![allow(unused)]
fn main() {
for (entity_id, component_bytes) in world.iter_entities_serialized() {
session.hash_entity(tick, entity_id, &component_bytes)?;
}
}
Can be added incrementally alongside hash_tick — Level 1 and Level 2 aren’t mutually exclusive; the bisection engine uses whichever levels are present in a given session and reports “enable per-entity hashing for a finer result” when only Level 1 data exists. See examples/ggrs-demo for a real recording using this.
5. Per-field hashing
#![allow(unused)]
fn main() {
use foldback_core::FoldbackHash;
#[derive(FoldbackHash)]
struct PlayerState {
#[foldback(hash)]
position: [f32; 3],
#[foldback(hash)]
velocity: [f32; 3],
debug_name: String, // unmarked — not hashed, named in PlayerState::UNTRACKED_FIELDS
}
session.hash_fields(tick, entity_id, &player_state)?; // macro-generated per-field hashing + value capture
}
Opt-in by default — see RFC-0003 for why. #[derive(FoldbackHash)] hashes nothing until a field is explicitly marked; an unmarked field’s name lands in the generated UNTRACKED_FIELDS constant rather than silently disappearing either way. Two ways to mark a field, both landing in TRACKED_FIELDS:
#[foldback(hash)]— hashed here, by the generatedwrite_hashed_fieldsabove, viafoldback_core::hashable::FieldBytes(built for the common fixed-size numeric primitives and fixed-size arrays of them; implement it yourself for a custom vector/quaternion type).#[foldback(reflect)]— tracked, but left for a reflective walker (e.g. Bevy’sfoldback_rs::bevy::hash_reflected, see Auto/Reflective Hashing) to hash instead. NoFieldBytesbound, so a compound field (a nested struct, aVec, aHashMap) can opt in without needing aFieldBytesimpl it doesn’t otherwise need.
#[foldback(skip)] marks a field as deliberately not hashed (distinct from leaving it unmarked, which still shows up in UNTRACKED_FIELDS for visibility).
6. Reading a .foldback file programmatically
For building your own tooling on top (a custom dashboard, a Slack bot that posts a divergence summary) without going through the CLI or UI.
#![allow(unused)]
fn main() {
use foldback_core::format::{FrameReader, Frame};
use std::fs::File;
let file = File::open("match_4821.foldback")?;
for frame in FrameReader::new(file) {
match frame? {
Frame::TickHash { tick, peer_id, hash } => { /* ... */ }
Frame::Snapshot { tick, peer_id, compressed } => { /* ... */ }
_ => {}
}
}
}
Feed collected TickHashRecords into foldback_core::bisect::find_first_divergence directly — the same function foldback analyze itself calls internally — to find the first divergence tick from your own tooling.
7. Live mode
A separate LiveServer type used alongside Session, not a SessionBuilder option — a WebSocket connection has its own hello/reconnect lifecycle (protocol spec §2), not a plain byte sink the way a recording file is.
#![allow(unused)]
fn main() {
use foldback_core::live::LiveServer;
use foldback_core::format::Frame;
// Once, at startup — binds immediately (a port-in-use error surfaces
// synchronously), a background thread owns accept/handshake/reconnect.
let live = LiveServer::bind("127.0.0.1:9871", 60, 2, build_id)?;
let mut session = Session::builder().tick_rate_hz(60).peer_count(2).build()?;
// each tick:
session.hash_tick(tick, &state_bytes)?;
for pending in session.take_pending_hashes() {
live.send_frame(Frame::TickHash { tick: pending.tick, peer_id: 0, hash: pending.hash });
}
}
send_frame never blocks the caller (an unbounded channel send) and silently drops frames if no UI is connected yet. In the UI: drag-and-drop a .foldback file for offline analysis, or click “Connect live…” and give it ws://127.0.0.1:9871 — same timeline/peer-comparison/drill-down views either way. See examples/live-demo for a real end-to-end proof (a toy sim streaming a real injected divergence, caught live in the actual UI).
8. Unity integration
Level 1 (per-tick), Level 2/3 (per-entity/per-field, HashEntity/HashField and their peer-recording counterparts), and reflective hashing are all exposed across the FFI boundary and verified under real IL2CPP AOT compilation in CI. See Unity for the full picture.
using Foldback;
var session = new FoldbackSession(new FoldbackConfig { TickRateHz = 60, PeerCount = 2 });
// each FixedUpdate:
byte[] state = SerializeDeterministicState();
session.HashTick(tick, state);
[FoldbackHash] field attribute mirrors the Rust derive macro (recipe 5) via Unity’s own reflection, at higher per-tick cost than the explicit-serialization path — fine for editor/dev builds, measure before shipping it in a release build’s hot path.
9. Godot integration
extends Node
var session := FoldbackSession.new()
func _ready():
if not session.configure({"tick_rate_hz": 60, "peer_count": 2}):
push_error(session.get_last_error())
func _physics_process(_delta):
var state := serialize_deterministic_state()
session.hash_tick(Engine.get_physics_frames(), state)
GDExtension binding (bindings/godot) exposes the same core through a GDScript-native FoldbackSession class rather than raw FFI calls — built directly on gdext (godot-rust) rather than through foldback-sys’s C ABI, since gdext generates the GDExtension registration itself.
A GDExtension class’s .new() calls its zero-argument _init, so there’s no supported way to route constructor arguments through it directly — hence the two-step .new() then .configure(dict) -> bool shape, returning false and setting get_last_error() on failure rather than throwing. u64 hashes cross into GDScript as i64 via exact bit-reinterpretation (GDScript’s only integer type) — a hash may print as negative, which is expected and harmless for equality-based divergence checks.
10. Unreal integration
#include "FoldbackSession.h"
FFoldbackSession Session;
FFoldbackSession::FConfig Config;
Config.TickRateHz = 60;
Config.PeerCount = 2;
Session.Configure(Config);
// each tick:
TArray<uint8> State = SerializeDeterministicState();
Session.HashTick(Tick, State);
for (const auto& Pending : Session.TakePendingHashes(/*Max=*/64))
{
SendToPeers(Pending.Key /* tick */, Pending.Value /* hash */);
}
FFoldbackSession is plain C++ (not a UObject) — usable directly, as above, or through UFoldbackSubsystem (a UGameInstanceSubsystem) for Blueprint access. Both wrap the same foldback-sys C ABI Unity uses, rather than going through gdext the way Godot does — see Unreal Engine for the full binding surface and its Mass Entity integration.
11. Reflective (auto) hashing
Get Level 3 (per-field) bisection without a hand-written hash_field/HashField call per field — the binding walks the engine’s own reflection system instead. Opt-in per field, same as recipe 5, just marked and discovered a different way per engine:
#![allow(unused)]
fn main() {
// Bevy — foldback_rs::bevy::hash_reflected
use foldback_rs::bevy::hash_reflected;
#[derive(Reflect, FoldbackHash)]
struct Unit {
#[foldback(reflect)] // compound field — the walker recurses into it
pos: Position,
#[foldback(hash)] // primitive field — hashed directly, same as recipe 5
hp: i32,
debug_label: String, // unmarked — invisible to the walker
}
let preview = hash_reflected(&mut session, tick, entity_id, "unit", &unit)?;
}
// Unity — FoldbackReflection.HashReflected
class Unit
{
[FoldbackHash] public Position Pos;
[FoldbackHash] public int Hp;
public string DebugLabel; // unmarked
}
var preview = FoldbackReflection.HashReflected(session, tick, entityId, "unit", unit);
# Godot — FoldbackSession.hash_reflected. No custom attribute system in
# GDScript, so the marker is a `foldback_` name prefix instead.
class Unit extends RefCounted:
var foldback_pos: Position
var foldback_hp: int = 0
var debug_label: String = ""
var preview: Array = session.hash_reflected(tick, entity_id, "unit", unit)
// Unreal — FFoldbackReflectiveHasher::HashTaggedFields, over the
// existing UPROPERTY meta system rather than a new attribute
USTRUCT()
struct FUnit
{
UPROPERTY(meta=(FoldbackHash)) FVector Pos;
UPROPERTY(meta=(FoldbackHash)) int32 Hp;
UPROPERTY() FString DebugLabel; // unmarked
};
FUnit UnitInstance;
FFoldbackReflectiveHasher::HashTaggedFields(Session, Tick, EntityId, FUnit::StaticStruct(), &UnitInstance);
All four return the same (field path, hash) data the manual API would have recorded, just discovered reflectively. This trades integration cost for runtime cost — reflective hashing runs roughly 3.5-19x an equivalent explicit hash_fields call depending on engine (Godot’s get_property_list() is the notable outlier) — so it’s a dev/editor-build tool layered on top of the explicit API, not a release-build default. See Auto/Reflective Hashing for the full per-engine picture: determinism hazards (unordered containers, cycles, enum representation), visibility tooling, and each engine’s in-editor dock.
12. Scanning a source tree for tagged fields
Because reflection makes it easy to lose track of what is actually being hashed (no derive-macro call site to grep for), foldback lint statically scans Bevy/Rust source for #[derive(FoldbackHash)] structs and prints each one’s tracked fields alongside its untagged siblings — the “did you mean to include this one too” check.
foldback lint --engine bevy src/
Unit (src/game/unit.rs)
tracked : hp, pos
untracked : debug_label
Unity/Godot/Unreal don’t have their own static scanners — each has other visibility tooling instead (ListTracked/list_tracked reflective queries, plus a real in-editor dock for all three engines). See Auto/Reflective Hashing for what each engine actually ships.
13. Catching schema drift between builds
If a reflected type’s tagged-field set changes between the build that recorded a .foldback session and the build now analyzing it (someone added a field mid-development), comparing the two blindly can mistake that for a real divergence. Every reflective walker (recipe 11) records each type’s tagged-field fingerprint automatically — no extra call needed on your part — so foldback schema-diff can catch the mismatch after the fact:
foldback schema-diff old-build.foldback new-build.foldback
SCHEMA DRIFT: my_game::Unit
before : hp,pos
after : hp,pos,shield
Exit code 1 on drift, 0 if the two builds’ recorded schemas agree (or if a file has no schema metadata at all — e.g. it only used explicit hashing). See CLI Reference for the full contract.