Randomness, determinism and saving
A simulation that comes out the same every time it runs with the same inputs is easy to debug, test and compare: replay a bug, check a balance change against the old version, let an agent run experiments. Crabby simulations are reproducible by default.
Random numbers
Get random numbers in systems from ctx.random():
#![allow(unused)]
fn main() {
fn wander(ctx: &mut Ctx) {
let random = ctx.random();
ctx.world.query::<(Entity, &mut Heading)>().par_for_each(|(e, h)| {
h.0 += random.of(e).range(-0.2, 0.2);
});
}
}
struct Heading { float radians; };
static void wander(crabby::ctx& ctx) {
const crabby::random random = ctx.random();
ctx.world().each_par([&](crabby::entity e, Heading& h) {
h.radians += random.of(e).range(-0.2f, 0.2f);
});
}
random.of(entity)is that entity’s own stream for this tick and system. It doesn’t depend on which thread runs the entity or in what order, so it’s safe inpar_for_each.random.stream(n)is for numbers that don’t belong to an entity (where to spawn food, which event fires).- Both give an
Rngwithf32(),range(lo, hi),below(n),chance(p),angle(),unit_vec2()andu64().
For setup code and tests, Rng::new(seed) makes a generator directly.
C++ has the same generator in the header (crabby::rng, crabby::random),
bit for bit: a C++ system gets exactly the numbers its Rust twin would.
Seeds
Every game has a seed, DEFAULT_SEED unless you set one:
#![allow(unused)]
fn main() {
Game::new("Colony", 1280, 720).seed(42)
}
Same seed, same simulation. For a different world each run, seed from the clock.
What keeps runs the same
The engine guarantees, for a fixed seed:
- systems run in the order they were added, every tick;
- queries visit entities in the same order;
par_for_eachgives the same results as a plain loop, as long as each entity only writes its own components (the borrow rules make you do that);- the spatial grid, spatial sorting and commands are deterministic;
sim_system_everyandctx.turnsdepend only on the tick.
The engine’s own tests run a simulation with all of these at once (threads, grid queries, sorting, reduced rates, spawning, despawning) and check that two runs give byte-identical saves.
Things that break determinism, and what to do instead:
| Don’t | Do |
|---|---|
rand::thread_rng(), time-based randomness in systems | ctx.random() |
frame systems (system) changing simulation state | do it in a sim_system |
iterating a HashMap to decide things | BTreeMap, or sort first |
| reading wall-clock time in sim systems | ctx.sim_tick(), ctx.sim_time() |
Floating point is deterministic on one machine and build. Across CPU
architectures, transcendental functions (sin, exp) can differ in the
last bit.
Saving and loading the world
With reflection, the whole world saves to JSON:
#![allow(unused)]
fn main() {
let save = ctx.world.save(); // serde_json::Value
std::fs::write("save.json", save.to_string())?;
let save: Value = serde_json::from_str(&std::fs::read_to_string("save.json")?)?;
ctx.world.load(&save)?;
}
const std::string save = world.save(); // JSON text; write it to a file
// ...
world.load(save); // every entity back, with its id
- Ids are kept. Loading restores every entity with the id it had, so
components that refer to other entities (
struct Target(Entity)) still point at the right ones.Entityitself saves as its 64-bit id. - All or nothing.
loadchecks every component name and value first; if anything is wrong, the world is left as it was and you get an error saying what. - Only reflected components are saved. The save’s
"not_saved"lists the other components by name and count, so nothing goes missing silently. Reflected resources are saved too (under"resources"); other resources and the sim clock aren’t. C++ components are saved once their fields are described (see Reflection).
Agents can do the same through ponko-mcp (save_world, load_world):
checkpoint, try something, roll back.