Offline Earnings on Load¶
A milestone — Recovery at the Chapel¶
M7.3 is the chapter where Blood Knight Grove becomes the kind of game a player picks up in the morning, plays for ten minutes, closes for the workday, and returns to in the evening to find their order has continued without them. The genre calls this "offline earnings" and treats it as the defining feature — what separates an idle game from an "active clicker that idles when ignored." The textbook frames it as "Recovery at the Chapel": while the player is away, the acolytes of the order continue their devotions, tithe Light to the chapel, and present the total when the player returns to take account. The modal displayed on relaunch is one short sentence — "Acolytes tithed N Light over X hours" — and it is the textbook's most committed fiction beat. By the end of this chapter, that sentence will appear in your game.
What you'll learn
- Offline elapsed time as a derived value:
now - saved_at. The save'ssaved_atfield (M7.1) is the load-time anchor; the difference is how long the player was away. - The cap, and why it exists:
min(elapsed_seconds, OFFLINE_CAP)clamps offline time to a known maximum (8 hours in M7.3) so a player who returns after a vacation does not get a 30-day income windfall that breaks the progression curve. - Applying offline earnings as a single bulk addition, not as simulated ticks:
_resources["light"] += elapsed * _income_rates["light"]. The simulation shortcut works because_income_ratesis constant for the offline interval (no purchases happen offline). - The presentation layer: an
AcceptDialog(the "Recovery at the Chapel" modal — M6.3 introducedAcceptDialog, M7.3 reuses it) showing how much was earned offline, anchored to the cap so the player understands they'd have earned more had they returned sooner. - The order of operations in
_apply_state: load primary state → recompute income rates → calculate offline gains → apply offline gains → emitresource_changed. Reordering breaks the math (offline gains must use the loaded buildings' rates, not the pre-load defaults).
How it applies
- Offline progression is the genre. Idle games are defined by the player earning while away. The offline calc isn't a feature, it's the premise. A bad offline calc — too generous, too stingy, too obscure — is a critical-path failure that drops engagement metrics by double digits in beta. M7.3 ships the cap-and-show pattern that mature idle games (Cookie Clicker, AdVenture Capitalist, Realm Grinder) all variants of.
- The cap is a balance dial, not a technical limit. 8 hours is one design choice; 4 hours and 24 hours are also defensible. Too short: the player can't return overnight. Too long: a player can vacation-bank and trivialize the prestige loop. The right cap depends on the prestige cadence: if a typical prestige run is 2–4 hours, 8h offline is 2× a session and feels generous-but-bounded. M7.3 picks 8h; the value is one constant change.
- Display matters more than the math. The "while you were away you earned X Light" modal is the touchpoint where players form their opinion about offline progression. Showing the formula breakdown (e.g., "8h capped from 14h, at 100/sec = 2,880,000 Light") sets accurate expectations. Hiding the cap silently is a player-trust failure: the player will eventually notice they don't earn more by staying away longer, and will conclude the game is broken.
- Anti-cheat surface. A naive
now - saved_atis trivially exploited by setting the system clock forward. Idle games that care about leaderboards or in-app purchases compare against a server time. Single-player offline games (Blood Knight Grove, in our case) accept the exploit because (a) cheating only hurts the cheater, (b) server-time would require a backend the project doesn't have. The cap mitigates the worst of the exploit by capping per-session gain. - Devices that sleep, suspend, hibernate. A laptop closing its lid mid-game suspends the process. Reopening 6 hours later: from the OS's view the process never quit (no
NOTIFICATION_WM_CLOSE_REQUEST); from the game's view, no time has elapsed because_processwas paused. M7.3's calc only fires on load (cold start with a save). The suspended-laptop case earns nothing — a known gap that matters for users who play with the lid closed. Detecting suspend reliably is OS-specific (M7+ exercise). - Energy budget on the player's machine. Running the simulation on an interactive loop while the player is away wakes the CPU, drains the battery, and produces heat. Marking the time at idle and applying a one-shot delta-multiplied gain on return — the time-delta pattern this chapter teaches — accomplishes the same observable outcome with a minimal number of CPU cycles. The pattern is documented in idle-game design literature (Spiel et al., CHI PLAY 2019) as the genre-canonical answer to the sustainability question. Players don't see the pattern; they feel its absence (a phone that gets hot in their pocket while idling).
Concepts¶
Offline elapsed time as a derived value¶
The save's saved_at field (M7.1, line in _serialize_state) is a unix timestamp captured at save-write time. On load, the current system time minus the saved time is the offline interval:
var now: int = int(Time.get_unix_time_from_system())
var elapsed: int = max(0, now - state.get("saved_at", now))
offline elapsed time
The max(0, ...) clamp matters: the player's clock may have moved backwards between save and load (manual change, DST in November, NTP correction). Without the clamp, the calc would deduct Light from a player whose clock skipped backwards — clearly wrong. Clamping to zero produces the worst-case "earned nothing" instead of "lost balance."
The fallback default state.get("saved_at", now) covers v1-pre-saved_at saves (none exist for Blood Knight Grove since v1 has the field; the pattern is for forward-compatibility): missing field → no offline interval → no offline gain.
Example
A player saves at unix time 1734567890 (Dec 18, 22:24:50 UTC). They reopen the game 3 hours later at 1734578690. now - saved_at = 10800 seconds = 3h. The clamp passes. Below-cap, so the cap is a no-op. Bulk apply 10800 × current Light rate. Modal displays "While you were at the chapel, your acolytes earned 540,000 Light over 3 hours."
The offline cap¶
The cap is a constant on GameState (or in a config autoload):
The application:
offline cap
Three adjacent design decisions:
First, the cap is seconds, not hours. The unit lives in the constant as 8 * 3600 for readability while staying in the integer-second domain everywhere else. Mixing units across the calc is a recipe for a "displayed 8 hours, gave 8 minutes" bug.
Second, the cap applies after the max(0, ...) clamp but before the _income_rates multiplication. Capping after multiplying would technically work but means the modal can't accurately display "you would have earned more had you returned sooner" — the cap-vs-actual comparison needs the pre-multiplied seconds.
Third, the cap is not configurable in v1. Adding a "skip cap" debug flag is one boolean; adding a player-facing "extend cap to 24h" purchase is a M7+ design feature. The cap is the dial; the dial itself is the simplest possible knob in M7.3.
Example
A player saves at noon and returns 14 hours later (2am). elapsed = 50400, min(50400, 28800) = 28800. The cap engages. The modal displays: "While you were at the chapel for 14 hours, your acolytes earned 8h of Light (cap reached)." The player understands they returned after the cap; future returns within 8 hours earn proportionally.
Applying as a bulk addition (not simulation)¶
The simulation shortcut: instead of running _on_tick 28,800 times to simulate 8 hours of ticks, M7.3 multiplies the per-second rate by the elapsed seconds and adds the result in one step.
for resource_name in _income_rates:
var gain: float = _income_rates[resource_name] * float(capped)
if gain > 0.0:
add_to_resource(resource_name, gain)
offline bulk apply
The shortcut works because:
1. No purchases happen offline. _income_rates is the same at second 0 as at second 28800 — no building or upgrade transitions changed the rate.
2. Linear accumulation. A constant rate over time produces rate × time Light, identical to summing rate × dt over n slices of dt.
3. add_to_resource side effects are linearly compatible. _lifetime_light accumulates positive Light; the M6.1 contract says "every positive add increments lifetime by the same amount." Bulk-adding 2,880,000 Light increments lifetime by 2,880,000 — same as 28,800 ticks of 100 Light each.
What would break the shortcut: - A resource that decays over time (rate is negative or non-linear). - An upgrade that triggers mid-interval (e.g., a "double income at lifetime 1M" effect — bulk apply jumps past the trigger). - A non-determinism source (RNG drops per tick — bulk apply gives the expected value, simulation gives an actual roll).
For Blood Knight Grove's mechanics in M1–M7, none of these apply, so the shortcut is sound. M8 onward, watch for any addition that breaks one of the assumptions.
Example
A player has 10 Initiates producing 1.0 Light/sec each, plus a tick_multiplier of 1.5 and honor_multiplier of 1.1. _recalculate_income writes _income_rates["light"] = 10 × 1.0 × 1.5 × 1.1 = 16.5. Offline 8 hours = 28,800 sec. Bulk gain: 16.5 × 28800 = 475,200. Single add_to_resource("light", 475200.0) call. _lifetime_light jumps by 475,200. The next prestige's Honor calc reflects the offline-earned lifetime. One signal emit, one bulk add, identical end state.
Order of operations in _apply_state¶
M7.2 ended with this sketch of _apply_state:
M7.3 inserts offline calc between recompute and emit:
load primary fields → reconstruct buildings → _recalculate_income() → calc + apply offline → emit resource_changed
_apply_state order
Three sequencing constraints.
First, the offline calc must come after _recalculate_income. The income rates depend on loaded buildings + tick multiplier + honor multiplier; computing offline gain before the recompute uses stale (default) rates and produces zero gain.
Second, the offline calc must come before the resource_changed emit loop. The signals notify UI of post-offline values; emitting before the offline addition would refresh UI to the saved-not-yet-bumped Light, then the offline addition would emit more resource_changed signals from inside add_to_resource, doubling work and possibly producing a visible "jump" if any UI element animates the value.
Third, the modal display must be deferred until after the load sequence finishes. The "you earned X offline" modal needs to know gain, but it cannot display before UI scenes have entered the tree. M7.3 stores the gain in a field and a separate UI subscriber (which runs in its own _ready after the autoload) checks the field and displays.
Example
A player saves with 1000 Light, 10 Initiates, 8 hours offline. Wrong order (offline before recompute): rates are still empty default (built only after this), gain is 0, modal shows "0 Light earned." Right order (recompute first): rates are 16.5/sec, gain is 475200, modal shows "475,200 Light earned." The order is the difference between a working feature and a silently-broken one.
The "Recovery at the Chapel" presentation¶
Per the M7 theme commitment (plan §G), offline framing is "acolytes continuing devotion at the chapel while you sleep." The modal lives in the main scene, displays once on load, and dismisses with OK.
@onready var _offline_dialog: AcceptDialog = %OfflineDialog
func _on_offline_gain_ready(gain: float, hours: float, capped: bool) -> void:
var text: String = "Acolytes tithed %d Light over %.1fh." % [gain, hours]
if capped:
text += "\n(8h cap reached.)"
_offline_dialog.dialog_text = text
_offline_dialog.popup_centered()
offline modal
The flow:
1. GameState._apply_state calls offline calc, emits offline_gain_ready(gain, hours, capped).
2. OfflineDialog (an AcceptDialog in the main scene) subscribes in its _ready.
3. On signal, populates dialog text and pops centered.
4. Modal dismisses on OK.
The modal does not block input on the rest of the UI (it's a Window, not a foreground-only veil). The player can dismiss and start playing immediately or pause to read.
Walkthrough¶
You'll add an offline-calc method to GameState, wire it into _apply_state, declare a new signal, and add an OfflineDialog AcceptDialog to the main scene.
Step 1. Open scripts/game_state.gd. Add the constant near the other save constants:
Step 2. Add the signal declaration near the top of the class (with other signals):
Step 3. Add _apply_offline_gain near _apply_state:
func _apply_offline_gain(saved_at: int) -> void:
var now: int = int(Time.get_unix_time_from_system())
var elapsed: int = max(0, now - saved_at)
var capped: int = min(elapsed, OFFLINE_CAP_SECONDS)
if capped <= 0:
return
var light_rate: float = _income_rates.get("light", 0.0)
var gain: float = light_rate * float(capped)
if gain > 0.0:
add_to_resource("light", gain)
var hit_cap: bool = elapsed > OFFLINE_CAP_SECONDS
offline_gain_ready.emit(gain, float(capped) / 3600.0, hit_cap)
The light_rate lookup uses Dictionary.get(key, default) so that a save with no buildings (rate dictionary empty) produces gain = 0 instead of crashing on missing key.
Step 4. Modify _apply_state (from M7.2) to emit resource_changed for every saved resource before applying offline gain, then run _apply_offline_gain. The shape:
func _apply_state(state: Dictionary) -> void:
# ... existing field loads ...
# ... building reconstruction ...
_recalculate_income()
# Emit resource_changed for every saved resource so UI subscribers
# (Labels, buttons, visibility gates) see the loaded values.
for resource_name in _resources:
resource_changed.emit(resource_name, _resources[resource_name])
_apply_offline_gain(state.get("saved_at", 0))
Order matters. The trailing emit loop runs before _apply_offline_gain. UI subscribers first see the saved values; then _apply_offline_gain calls add_to_resource("light", gain), which emits its own resource_changed("light", new_value) for the post-offline total. Without the reorder, _apply_offline_gain would run first and add_to_resource would emit once, then the trailing loop would emit resource_changed("light", ...) again for the same resource — a duplicate emit per offline-touched resource per load. Subscribers that animate value changes (M8.2's tween) would interpret the doubled fire as two separate increments and tween twice.
The default 0 for saved_at covers missing-field saves: now - 0 is huge, but the cap clamps it to 8h. A v1-without-saved_at save (none exist, but defensive) would result in maxing out the offline cap on first load — a one-time anomaly preferable to a crash.
Step 5. Open scenes/main.tscn (or whichever scene roots the game UI). Add: Scene → Add Child Node → AcceptDialog. Rename it OfflineDialog. In the Scene dock, right-click OfflineDialog → Access as Unique Name so the script can reach it as %OfflineDialog (the same % resolution M5.2 used for %BuyButton and M6.3 used for %PledgeCeremony). Set title to "Recovery at the Chapel". Save.
Step 6. Open the script attached to the main scene root. Add the @onready reference and the _ready connection (or extend the existing _ready):
@onready var _offline_dialog: AcceptDialog = %OfflineDialog
func _ready() -> void:
GameState.offline_gain_ready.connect(_on_offline_gain_ready)
func _on_offline_gain_ready(gain: float, hours: float, capped: bool) -> void:
if gain <= 0.0:
return
var text: String = "Acolytes tithed %d Light over %.1f hour(s)." % [gain, hours]
if capped:
text += "\n(8-hour cap reached.)"
_offline_dialog.dialog_text = text
_offline_dialog.popup_centered()
Without the @onready declaration, the handler would fail to parse with "Identifier 'OfflineDialog' not declared in current scope." The if gain <= 0.0: return early-out skips the modal entirely on a fresh game (no save, no offline, no need to bother the player with a "you earned 0 Light" popup).
Step 7. Save (Ctrl+S). Run the game (F5).
Step 8. First-launch path. The game boots fresh — no save exists, _apply_state doesn't run, the modal stays closed. Earn some Light, buy a few Initiates so _income_rates["light"] is non-zero. Quit (X button — M7.1 quit-save fires).
Step 9. Wait at least one minute (the modal threshold is 0 < gain, so any non-trivial offline interval triggers it; one minute × few Light/sec = something visible). Re-launch.
Step 10. The game boots, _apply_state runs, offline calc fires. The "Recovery at the Chapel" modal pops up showing the gain. Dismiss with OK. The Light counter should reflect the post-offline value.
Step 11. Test the cap. Quit. Edit savegame.json in Notepad: change saved_at to a time 12 hours ago. Save the file. Re-launch. The modal shows "8-hour cap reached" with 8 hours of gain. Light counter increases by exactly 8 × 3600 × current rate.
Step 12. Test the clock-rewind path. Quit. Edit savegame.json: change saved_at to a time in the future (e.g., now + 3600). Save. Re-launch. The max(0, ...) clamp produces zero offline; the if gain <= 0.0: return early-out suppresses the modal. No popup, no Light gain. The save's saved_at is overwritten on next save with the current time, so the broken state self-corrects.
Self-check quiz¶
Quiz
Q1. Why does _apply_offline_gain run after _recalculate_income in _apply_state?
- A)
_recalculate_incomepopulates_income_rates. The offline calc multiplies the elapsed seconds by_income_rates["light"]. Reversed order would multiply by an empty/default rate dictionary and produce zero gain. - B)
_recalculate_incomevalidates the loaded fields; offline calc requires validated fields. - C)
_recalculate_incomeemitsresource_changed, which the offline calc listens for. - D) Godot enforces this order via
_ready.
Reveal
Correct: A. The offline gain is rate × seconds. The rate comes from _income_rates, populated by _recalculate_income. Calling offline first reads an empty/default rate dictionary, multiplies it by however many seconds, gets zero, and the player's offline progress vanishes.
- B is wrong:
_recalculate_incomedoesn't validate fields, just aggregates rates. - C is wrong: it doesn't emit
resource_changed; the explicit emit loop later does. - D is wrong: Godot doesn't enforce internal ordering inside
_apply_state.
Q2. A player sets their system clock 24 hours forward, saves, sets it back, and reopens the game. What does M7.3 do?
- A) Awards 24 hours of offline gain (capped to 8h) — the cheat works to the cap.
- B) Awards zero offline gain —
now - saved_atis negative, themax(0, ...)clamp produces zero. - C) Crashes — negative offline interval is a parse error.
- D) Shows a warning to the user about clock manipulation.
Reveal
Correct: B. The save was written when the clock said future. On real-time reload, now < saved_at, so now - saved_at is negative. The max(0, ...) clamp returns 0. No gain. The early-return on gain <= 0.0 suppresses the modal entirely. The player has effectively wasted the cheat — they get zero offline instead of zero plus the modal's confused "earned 0" message.
- A: that direction works (clock forward then save with future time, reload at real time → negative). The reverse — clock back then save then forward then reload — would award the maxed cap. Both directions are accepted as known single-player exploits.
- C is wrong: max(0, negative) is well-defined.
- D is wrong: M7.3 silently zeroes; warning the user about a behavior they may not have intentionally caused (DST? clock sync?) would be over-aggressive.
Q3. Why is offline gain applied via add_to_resource("light", gain) instead of directly setting _resources["light"] += gain?
- A)
add_to_resourceis shorter to type. - B)
add_to_resourceruns the M6.1 lifetime tracking (_lifetime_light += amountfor positive amounts) and emitsresource_changed. Direct field mutation skips both, breaking prestige progression and UI updates. - C) Direct field assignment is forbidden by GDScript's type system.
- D)
add_to_resourceclamps the value to a maximum.
Reveal
Correct: B. add_to_resource is the canonical mutation path for resources. It performs three things: (1) updates _resources, (2) increments _lifetime_light if applicable, (3) emits resource_changed. Bypassing it means offline gain doesn't count toward prestige and doesn't trigger UI refreshes.
- A is true but trivial.
- C is wrong: GDScript permits direct dictionary mutation.
- D is wrong: no clamp in the M6.1 / M7.3 versions.
Integration question¶
The offline calc applies bulk gains using _income_rates, which is the current (post-load) rate. But the saved state might have had a different rate — for example, the player might have purchased a building 30 minutes before saving, and the offline interval should arguably split into "30 min at old rate, the rest at new rate." M7.3 ignores this nuance and uses only the post-load rate. Is that right? When does it matter? What would the simulation alternative cost?
Reveal
The choice is correct because of how saves work: the save captures the rate-determining state (buildings, upgrades, Honor) at save time. The post-load rate, recomputed from those values, is the rate at save time. There is no intermediate rate change between save and load — the player wasn't playing during that interval, by definition.
The "split rate" concern would apply if: - A purchase happened between save and load (impossible — the player was offline). - Time-varying effects existed (e.g., a 1-hour-buff item that expires while offline). M7.3's M1–M6 mechanics have no time-varying effects, so the concern is theoretical. M8+ might add one (e.g., a 30-minute "blessing" buff), at which point M7.3 would need a real simulation pass.
The simulation alternative — running 28,800 ticks instead of one bulk add — costs: - ~30 ms of CPU at game start (28,800 dictionary writes, signal emits). Visible as a load-time stutter. - One subtle bug surface: if any tick handler has cross-tick state (e.g., "drop a critical bonus every 100th tick"), the bulk version misses it; the simulation version preserves it.
For Blood Knight Grove's mechanics, bulk add is exactly correct and zero-cost. If a future feature breaks the "rate constant over interval" assumption, the integration question gets reopened — the offline calc switches from one bulk multiply to a loop, and the modal text adjusts.
Glossary¶
Glossary
- offline elapsed time
- The integer number of seconds between the save's
saved_atfield and the system clock at load time. Computed asmax(0, now - saved_at)so a system-clock backwards adjustment (Daylight Saving, manual change, NTP correction) produces zero rather than negative offline time. M7.3 caps the result toOFFLINE_CAP(8 hours) before applying. - offline cap
- The maximum offline interval (in seconds) M7.3 will pay out for. M7.3 ships at
8 × 3600 = 28800. Implemented asmin(elapsed, OFFLINE_CAP_SECONDS)before applying gains. Player-facing implications: returning after the cap window earns the same as returning at the cap, capping the value of long absences without disabling them. The cap is a balance design choice, not a technical limit. - offline bulk apply
- The shortcut M7.3 uses instead of simulating offline ticks: rate × seconds added once per resource, rather than rate × tick_interval added n times. Works because no purchases happen offline (rates are constant for the interval). Side effects of
add_to_resource(like_lifetime_lighttracking) fire once with the bulk amount, which preserves the same observable end state as the simulated version. Breaks if any per-tick effect is non-linear (e.g., RNG drops, cap-clamping mid-interval, or resources that decay). - offline modal
- The
AcceptDialogdisplayed once on load showing offline-earned gains. Distinct from M6.3'sPledgeCeremony— same Godot class (AcceptDialog), different role (post-load vs post-prestige). Subscribes to a one-shot signal fromGameState(offline_gain_ready) emitted at the end of_apply_state. Includes cap-reached indicator so the player understands the cap rather than wondering why the number seems low. _apply_stateorder- The required ordering inside
_apply_state: (1) populate primary fields from save, (2) reconstruct_buildingsvia_building_lookup, (3) call_recalculate_incometo populate_income_rates, (4) calculate offline elapsed and apply viaadd_to_resource, (5) emitresource_changedfor each resource. Reordering breaks the math: offline must use the loaded buildings' rates, not pre-load defaults; emit must come last so handlers see the post-offline values. Document the order in code with a comment block.