Deterministic turn settlement
A submitted turn may contain many resolution steps. The SDK calls those steps waves: wave 0 contains the submitted or derived intents, and consequences produced by one wave are eligible to run in a later wave of the same turn. Settlement ends when the same-turn worklist is empty.
This is a cascade, not a loop that blindly reruns the complete game reducer. Each product rule decides whether a consequence belongs to the current turn or must be deferred to the next one.
submitted intents
|
v
simultaneous movement snapshot
|
v
wave 0 -> wave 1 -> wave 2 -> ... -> quiescence
| |
| +-- collision -> landing -> arrival
+------------- movement -> switch -> environment update
|
+-- explicitly deferred work ----------------> next turnJob contract
Every unit of work has a stable identity and optional ordering policy:
interface SettlementJob {
kind: string;
key: string;
priority?: number;
policy?: 'repeat' | 'coalesce' | 'once';
}The identity is the exact kind + key pair. Lower priority numbers run first. Within one wave, remaining ties are ordered by lexical kind, lexical key, then enqueue sequence. This makes input order relevant only when all explicit fields tie.
Jobs enqueued by a resolver never run in the current wave. They enter the next wave, even when their priority is higher than work already being resolved. This wave barrier is what makes cause and consequence visible in traces.
Complete example
type Job =
| { kind: 'arrival'; key: string; actorId: string; priority: 10 }
| { kind: 'switch'; key: string; switchId: string; priority: 20; policy: 'coalesce' }
| { kind: 'gate'; key: string; gateId: string; priority: 30; policy: 'coalesce' };
const result = runSettlementCascade(world, initialArrivals, (job, context) => {
switch (job.kind) {
case 'arrival':
commitArrival(context.state, job.actorId);
for (const switchId of switchesAtActor(context.state, job.actorId)) {
context.enqueue({
kind: 'switch', key: switchId, switchId,
priority: 20, policy: 'coalesce',
});
}
break;
case 'switch':
updateSwitch(context.state, job.switchId);
for (const gateId of linkedGates(context.state, job.switchId)) {
context.enqueue({
kind: 'gate', key: gateId, gateId,
priority: 30, policy: 'coalesce',
});
}
break;
case 'gate':
updateGate(context.state, job.gateId);
break;
}
}, { maxSteps: 256 });The resolver mutates the supplied product state. The kernel returns that same state together with steps, waves, trace, and deferred jobs.
Kernel boundary
runSettlementCascade owns product-neutral execution behavior:
- deterministic ordering within every wave;
- same-turn consequence scheduling;
- pending-work coalescing and once-per-turn identities;
- explicit next-turn deferral;
- a maximum-step convergence guard; and
- a causal trace linking each resolved job to the step that scheduled it.
The product owns rule policy and world meaning:
- which intent or mutation creates a job;
- how a job reads or mutates product state;
- the job priority and stable identity;
- whether duplicate work repeats, coalesces while pending, or runs once;
- which consequences are same-turn and which are next-turn; and
- snapshot qualification and commit rules for simultaneous effects.
The kernel does not infer dependencies from state mutations. A rule explicitly enqueues its consequences, which keeps replay behavior inspectable and prevents an unrelated board update from accidentally rerunning every mechanic.
Scheduling policies
Jobs choose one of three policies:
repeat: every enqueue is a distinct resolution step;coalesce: duplicate pending work with the same identity is merged, but the identity may be scheduled again after it runs; this supports dirty-resource fixed points; andonce: the identity may run only once during this settlement call.
An identity is the pair kind + key. Products can express narrower scopes by choosing an appropriate key, such as an arrival id, entity id, resource cell, or rule id.
The duplicate policies differ at scheduling time:
| Policy | Duplicate while pending | Same identity after it runs |
|---|---|---|
repeat | accepted | accepted |
coalesce | rejected | accepted again |
once | rejected | rejected for the rest of the call |
context.enqueue(job) returns whether the job was accepted, allowing product traces or counters to distinguish a new consequence from a suppressed duplicate.
Work that must not resolve in the current turn uses defer. Deferred jobs are returned to the caller and are never executed by that settlement call.
Required product invariants
A product integration should characterize these boundaries before replacing an existing loop:
- Which consequences can create more same-turn work?
- Which state is frozen for simultaneous qualification?
- Which mutations are committed only after qualification?
- Which jobs may run more than once, and what proves convergence?
- Which landings or updates deliberately wait for the next turn?
- What deterministic key orders otherwise independent work?
The maximum-step limit is a safety guard, not a game rule. A normal turn should reach quiescence before the limit; exceeding it reports a cycle or a missing convergence condition.
Limit failures and traces
maxSteps must be a positive safe integer. When the next queued job would exceed it, the kernel throws SettlementLimitError containing the configured limit and that nextJob. State mutations from earlier steps are not rolled back, so authoritative callers should resolve against a disposable draft or transaction when a limit failure must leave persistent state untouched.
Each trace entry records the resolved job, zero-based step, zero-based wave, and the parent step that enqueued it. Seed jobs have no parent. A trace therefore forms a causal forest suitable for debugging, replay diagnostics, and agent explanations without exposing animation timing.
Deferred jobs are returned in defer-call order and never enter duplicate-policy tracking for future settlement calls. The product decides how to serialize, revalidate, and seed them on the next turn.
Composition guidance
- Qualify simultaneous actions from one snapshot before seeding jobs.
- Commit atomic movement before enqueuing arrival rules.
- Use
coalescefor dirty resources that may change again after resolution. - Use
oncefor identities such as a one-shot reaction or per-turn trigger. - Use
repeatonly when every occurrence is meaningful and convergence is independently bounded. - Prefer
deferwhen a rule is intentionally next-turn behavior, rather than encoding turn delay as an artificial same-turn wave.
Zonoid example
Zonoid uses settlement as the universal reducer’s same-turn worklist. A committed move can enqueue arrivals, a switch can update a gate, a belt can create another movement pass, and a laser or pickup can enqueue damage or resource work. The platform supplies those jobs and policies; the SDK keeps ordering, deduplication, and the authored safety bound deterministic.