41 min read
On this page

Decouple Idle Settlement from Call Frequency

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 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.

Determinism Also Requires Stable Traversal and Stable ID Allocation

Using keyed RNG does not automatically guarantee identical complete states. If multiple tasks or definitions are traversed via unordered hash maps, their random attributes might be identical individually, but global instance IDs and return report order might still differ.

Therefore, settlement must also specify:

  • Background positions sorted by stable slot or job ID;
  • Item definitions for each task sorted by stable definition ID;
  • Minting within the same definition by ordinal ascending;
  • Instance IDs allocated only within this stable order;
  • Reports follow the actual mint order, or are explicitly sorted by stable keys;
  • Use canonical, ordered data structures for saves and hashes.

If parallelizing settlement, first let each partition generate candidate results with complete stable keys, then merge by key and allocate global IDs. Letting multiple threads compete for a single incrementing ID will lose reproducible order, even if quantities and random attributes match.

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:

  1. Authoritative state completes settlement using the unified integrator;
  2. Real instances immediately enter unique inventory;
  3. Construct reports based on the actual delta of this session;
  4. 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:

  1. Fully Frozen: The task saves all values affecting results and the generation version until stopped;
  2. Content Hash Locking: Restoration requires hash consistency; if inconsistent, perform explicit migration or stop the task;
  3. 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_ordinal for 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;
  • Instance sequence in the current 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.

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_ordinal independently for each item definition;
  • Random keys include domain, purpose, activity, source, and stable ordinal;
  • Unrelated random streams have independent cursors;
  • Task, definition, ordinal, and instance ID allocation order is stable;
  • 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; stable traversal ensures global IDs and reports are also reproducible. 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