Decouple Idle Settlement from Call Frequency
On this page
Leaving for an hour should yield the exact same state, remainder, and instance sequence, regardless of whether settlement occurs once or every minute. It should not merely yield "approximately the same" amount.
Idle systems appear to be simply "time multiplied by rate." However, once save states, random drops, and online ticks are introduced, the problem quickly becomes one of determinism. Browsers may call once per second, background tabs may wake up only every few dozen seconds, and players might close the page and settle hours of progress in one go. If results depend on the number of calls, device performance, page visibility, and save timing will silently alter rewards.
These errors often start with discrepancies of only one or two units, making them hard to detect from the UI. But when the output consists of real instances with random attributes, a difference in quantity shifts all subsequent instance IDs, affixes, report order, and random cursors. Even if total rewards coincidentally match later, the two save files can no longer replay the same history.
This article discusses a "segment-invariant" approach to background evolution. The goal is not merely to match statistical expectations, but to satisfy a stronger invariant:
settle(state, a + b)
== settle(settle(state, a), b)
The equation applies to the same already credited time interval and an integrator with identical frozen inputs. Wall-clock observation, per-session offline caps, and UI notification counts are outside it: min(a+b, cap) generally differs from min(a, cap)+min(b, cap). A cap that must survive repeated restarts needs persistent accounting of credit already used within the same offline window.
The equality compares the complete authoritative state, not just a total resource count.
Three Most Common Sources of Breakage
Independent Rounding Down on Each Call
The most intuitive implementation is:
earned = floor(elapsed × rate)
If the rate is one item per hour and the online system settles every twenty minutes, each call yields zero; settling once after an hour offline yields one item. Call granularity becomes a hidden reward parameter.
Saving a floating-point remainder seems like a fix, but floats can still produce boundary differences across platforms, serialization round-trips, and long-term accumulation, and are unsuitable for authoritative hashing.
All Drops Share a Single Global Random Stream
If background drops simply call rng.next() sequentially, adding a new reward type, adjusting traversal order, or even performing an unrelated check elsewhere will shift all subsequent instances. The same save file and duration will no longer generate the same items.
Reading Current Configuration at Settlement
Players might change equipment, unlock new drops, or have content tables update rates after starting an idle task. If old tasks read the current state on the next tick, a period of elapsed time will be retroactively rewritten by the new configuration. Frequent online settlements and single offline settlements will read different historical inputs.
The solution must simultaneously fix time integration, random identity, and task inputs. Fixing only one is insufficient.
Define a Frozen Background Task First
When a background task starts, it should save all inputs that affect its result, rather than just a boolean "is idle":
BackgroundJob {
job_id,
target_id,
frozen_build,
elapsed_ms,
reward_rows: {
definition_id -> {
rate_per_hour_fixed,
remainder,
next_ordinal
}
}
}
Depending on the gameplay, frozen inputs typically include:
- Unique task or deployment ID;
- Target, difficulty, and fully verified combat inputs;
- Production rates valid at that time;
- Drop definitions unlocked at that time and permitted by that target;
- Fixed-point remainders and next ordinal for each definition;
- Content version or content hash, if necessary.
Subsequent equipment changes, item transfers, and new unlocks should not penetrate this snapshot. The latest configuration is only read when the player stops and restarts the task.
This is not to reject dynamic systems, but to define clear time boundaries. If the design truly allows mid-stream rate changes, the task should first settle to the switch point using the old configuration, then create a new time segment using the new configuration. Offline recovery should not guess when changes occurred.
Eliminate Segmented Errors Using Cumulative Function Differences
Let:
t = milliseconds accumulated since task start
r = fixed-point progress generated per hour
H = milliseconds in one hour
First, define the cumulative theoretical output from the task start to any moment:
C(t) = floor(t × r / H)
When settling from time a to b, do not calculate the interval length directly; instead, take the difference between two cumulative values:
delta(a, b) = C(b) - C(a)
This naturally satisfies telescoping summation:
delta(0, a) + delta(a, b)
= [C(a) - C(0)] + [C(b) - C(a)]
= C(b) - C(0)
= delta(0, b)
Rounding down still exists, but it only occurs on the cumulative function from a common start point, preventing repeated loss of remainders in each call segment.
For example, an item requires 1000 progress points, and the rate is also 1000 points per hour. Settling every twenty minutes:
C(20m) = 333
C(40m) = 666
C(60m) = 1000
Segmental increments = 333 + 333 + 334 = 1000
The third segment automatically compensates for the difference left by rounding in the first two segments, resulting in exact consistency with a single hourly settlement.
When implementing, use sufficiently wide integer arithmetic for multiplication, e.g., expand to 128-bit first, then divide by the hour constant; outer time, rate, and cumulative limits must also have clear boundaries. Saturating arithmetic can serve as a crash-prevention fallback, but should not replace input limits and boundary testing, as standard algebraic invariants may no longer hold once saturation is truly reached.
Save Remainders Independently for Each Item Definition
Different items can have different rates, so each definition needs its own progress row:
earned = cumulative_delta(previous_elapsed, next_elapsed, rate)
accumulated = remainder + earned
count = accumulated / threshold
remainder = accumulated % threshold
Then, create real instances one by one for 0..count.
If minting under valid frozen inputs is defined as non-failable, this precondition must be guaranteed by content and save validation; if minting can still fail, then progress, ordinal, instance ID, and random cursor for this settlement must be rolled back together as a transaction. You cannot skip failed instances while having already consumed progress.
Independent progress rows have several important properties:
- Adding or removing another definition does not change the remainder of this definition;
- One definition crossing a threshold does not block other definitions from continuing to accumulate;
- Reports can precisely explain each type of item, rather than showing only a mixed progress bar;
- No need to reverse-engineer history from total resources after save round-trips.
Remainders must be saved to the archive. Saving only the integer count of produced items will lose real progress that hasn't yet reached one full item upon each restart.
Address Random Numbers by Semantic Coordinates
Fixed-point integration solves "how many items are produced," but not "which items are produced." To ensure the complete instance sequence is segment-invariant, random decisions cannot rely on how far a global stream has progressed, but must be located by stable semantic coordinates.
A practical key structure is:
RandomKey {
domain, // Large domains like combat, drops, etc.
purpose, // Specific uses like equipment affixes, background drops, etc.
activity, // Current task or deployment ID
source // Stable combination of target, definition, and ordinal
}
For the n-th item of a definition, encode the following into the key:
(target, difficulty, definition, job_id, ordinal = n)
Then mix the global seed, complete key, and local cursor for that key to generate the random value. Each key has an independent cursor, thus:
- Drawing twenty extra times in combat does not shift the drop stream;
- Drawing one extra time for another definition in the same drop domain does not shift this definition;
- Adding unrelated random decisions does not rewrite old instances;
- After the random ledger is saved and restored, each stream continues from its original position.
ordinal is key to this design. It represents "the n-th instance generated by this task for this definition" and must be monotonically saved. The number of settlement segments does not change the 0, 1, 2... sequence; the same ordinal always corresponds to the same set of random results and source proofs.
Do not directly use array indices, current timestamps, or newly allocated instance IDs as random sources. Array order changes with content edits, wall clocks are unstable, and instance IDs are usually allocated after minting. Random identity should come from domain coordinates determined before minting.
Stable Sorting Is Not Enough: Identity Must Survive Settlement Boundaries
Suppose definitions A and B each produce one item per hour. Every call visits A before B and allocates increasing global integer IDs. Settling two hours once gives A0→1, A1→2, B0→3, B1→4; settling one hour twice gives A0→1, B0→2, A1→3, B1→4. Both traversals are sorted, with identical counts and keyed RNG attributes, but A1 has a different ID.
Sorting covers only the current call. A two-hour event set differs from each one-hour set, so local ordering cannot fix allocation across calls. Merging parallel partitions before assigning increasing IDs likewise resolves only scheduling differences within that merge.
A direct solution is to define instance identity as (job_id, definition_id, ordinal). Job IDs are globally unique and never reused; definition IDs remain stable in the frozen job; ordinals are persistent and monotonic. All three exist before minting and do not depend on tick counts, thread order, or a global allocator. Specify tuple encoding, such as a JSON array with fixed field order, rather than concatenating unescaped strings. A hash-based representation also needs defined encoding, versioning, and collision handling.
Index inventory by identity and sort by an explicit stable key for canonical serialization or display. This is a canonical representation, not necessarily creation-time order. A chronological report needs a logical production time derived from frozen rates, with (time, job, definition, ordinal) breaking ties. The current loop order is not historical time.
If the application requires globally increasing integer IDs, all relevant production and external events must share a logical timeline and total order, with constraints on event insertion and catch-up boundaries. This is stronger than sorting the current batch. Without that protocol, increasing IDs cannot be claimed to be partition-invariant.
Parallel workers may generate candidates carrying semantic identities, then deduplicate and merge by identity. Different content for the same identity is an error, not an arbitrary overwrite. Frozen inputs, remainders, ordinals, instances, and the observation boundary must still commit together.
Online and Offline Must Call the Same Integrator
Do not maintain separate "online tick algorithms" and "offline formulas." Both paths should only be responsible for obtaining time intervals, finally entering the same settle_elapsed:
online tick:
settle_elapsed(job, elapsed_since_last_tick)
offline restore:
observed = max(0, now - saved_at)
credited = min(observed, offline_cap)
settle_elapsed(job, credited)
max(0, ...) or unsigned saturating subtraction can handle system clock rollback; the single offline duration cap controls economic inflation and overflow risks. This cap is a product rule, not an anti-cheat proof: players with full control over local clocks and saves can still tamper with the local game.
Offline recovery only advances tasks explicitly allowed to evolve in the background. Interactive combat, unconfirmed choices, story dialogues, and other states requiring player input cannot "simulate completion" due to offline time. For these states, the correct behavior is usually to keep them frozen, waiting for the player to return.
The saved_at in the save file is only an observation boundary and should not be used to determine drop types, instance attributes, or random seeds. Restricting wall clocks to the layer of "calculating includable elapsed" ensures clock anomalies do not pollute content identity.
Return Reports Are Projections of Settlement Results
Offline reports should not drive reward distribution. The correct order is:
- Authoritative state completes settlement using the unified integrator;
- Real instances immediately enter unique inventory;
- Construct reports based on the actual delta of this session;
- Do not interrupt the player with empty reports if no valid changes occurred.
Reports can be instantaneous projections because rewards, remainders, and ordinals have already entered the authoritative state. However, "whether this time period has been settled" must be persisted along with these results; otherwise, if the page crashes after displaying the report but before the next save, the same time period might still be calculated repeatedly.
Persistence boundaries can follow the principles in Atomic Commands and Save Protocols: candidate states simultaneously contain the new observation boundary, items, remainders, ordinals, and random ledgers. Only after successful persistence are return results published. Segment invariance solves mathematics and random identity; atomic saves solve crash replay. The two cannot replace each other.
Content Updates Must Have Clear Strategies
Long-running background tasks may span game version updates. If saves only record definition_id, but the new version modifies rates, drop tables, or instance generation rules, old tasks face ambiguity upon restoration.
There are three common strategies:
- Fully Frozen: The task saves all values affecting results and the generation version until stopped;
- Content Hash Locking: Restoration requires hash consistency; if inconsistent, perform explicit migration or stop the task;
- Segmented Migration: First settle to the save boundary using old rules, then create a new task with new rules from that boundary.
The most unsafe is silently reading the new content table and calculating all offline duration using new rules. This makes the update moment determined by "when the player opens the game," giving online and offline players different histories.
Tests Must Compare Complete States, Not Just Totals
The core property test is arbitrary splitting:
whole = settle(initial, total)
split = clone(initial)
for duration in random_partition(total):
split = settle(split, duration)
assert whole == split
Comparison objects must include at least:
- Total amount of all standard resources;
- Fixed-point remainders for each definition;
next_ordinalfor each definition;- Instance IDs, definitions, qualities, affixes, and sources;
- Inventory order or canonical representation;
- All keys and cursors in the random ledger;
- Background task accumulated time;
- Reports aggregated by semantic identity over the same observation window; the final short tick report alone does not equal the whole-interval report.
Boundary cases should also cover:
- Just below, equal to, and exceeding an item threshold;
- Crossing multiple thresholds in one go;
- Zero duration and very short durations;
- Online small ticks, offline full segments, and mixed scenarios;
- Saving mid-stream, restoring, and continuing settlement;
- Stable traversal order for multiple tasks and definitions;
- Adding unrelated draws in the same random domain, leaving existing instances unchanged;
- Newly unlocked definitions not penetrating already frozen tasks;
- Clock rollback, offline caps, and integer boundaries;
- Rolling back remainders, ordinals, and random cursors together when mint fails;
- Following planned migration when content hashes change, rather than silently recalculating.
Property testing is well-suited for randomly generating total durations and split points; additionally, keep a few golden sets, locking specific instance sequences and canonical saves. The former excels at finding mathematical boundaries, while the latter detects unintentional changes to random keys, traversal order, or serialization formats.
Runnable Multi-Definition Counterexample and Check
This Python 3 standard-library example covers two jobs, different rates, complete authoritative state, random partitions of 107 total durations, and save/restore after every segment. An independent cumulative formula checks counts and remainders. It models fixed rates only; inventory caps, failing transactions, and external events require their own boundary protocols and tests. The first two SHA-256 bytes provide a reproducible illustrative attribute, not a complete equipment generator.
"""Executable teaching model: identity must survive partitioning, not just row sorting."""
= 3_600_000
return
return
=
+=
, =
=
=
=
=
assert not in
=
+=
return
"""Even perfectly sorted rows give different global IDs across calls."""
, = 0,
= + 1
+=
return
assert !=
assert == 2
assert == 3
=
=
+=
=
=
=
=
=
=
= # save/restore at every boundary
assert ==
assert ==
# Independent closed-form oracle, including fractional progress.
, =
assert ==
Common but Insufficient Patches
"Fix ticks to one second."
Browsers throttle background timers, and processes may pause. Call intervals are not reliable clocks, nor can they constrain offline recovery.
"Store more floating-point decimals."
This only reduces error, not providing complete equality across platforms and saves. Authoritative economies should use bounded integer fixed-point arithmetic.
"Save a global RNG state to reproduce."
It can only reproduce identical call orders. After feature additions or settlement splits change call counts, subsequent results will still drift globally.
"As long as the final item count is the same, it's fine."
Real instances include IDs, affixes, sources, and report order. Same count does not mean same state.
"Re-simulate offline based on current power level."
This allows the equipment configuration at return to retroactively affect the past, making results dependent on when the game is opened. Background tasks must either freeze inputs or explicitly segment by change points.
Minimal Implementation Checklist
- Clearly define the invariant for full/segmented complete state equality;
- Freeze all inputs affecting output at task start;
- Use integer fixed-point for authoritative rates and remainders;
- Use cumulative function differences for interval rewards, not independent rounding per call;
-
Save remainders and
next_ordinalindependently for each item definition; - Random keys include domain, purpose, activity, source, and stable ordinal;
- Unrelated random streams have independent cursors;
- Instance identity derives from job, definition, and ordinal; inventory and reports have a canonical order;
- Online ticks and offline recovery call the same integrator;
- Wall clocks only determine elapsed time, handling rollback and single caps;
- Only advance states allowed to evolve in the background;
- Atomically save new observation boundaries, rewards, remainders, and random ledgers together;
- Random split property tests compare complete states;
- Content updates have freeze, hash rejection, or segmented migration strategies.
Conclusion: Time is an Input, Not a Call Count
A reliable idle system should define results as a function of frozen inputs, stable seeds, and accumulated time, not as a function of "how many times the settlement function was called."
Cumulative differences automatically cancel rounding errors under any segmentation; fixed-point remainders save real progress not yet completed; semantic random keys and ordinals fix the identity of each instance; semantic instance identity and canonical ordering make inventory and aggregated reports reproducible across calls. Finally, atomic saves protect observation boundaries, making online, offline, background throttling, and refresh merely different time inputs, not four secretly different economic rules.
Keywords: game development, offline settlement, idle system, fixed-point, partition invariance, deterministic RNG, keyed random, ordinal, save consistency, property testing