Prestige Threshold & Ceremony UI¶
What you'll learn
- Threshold gating as a UI pattern: a button (or any control) whose
visible/disabledstate is recomputed every time the gating resource changes. The control's state is a function of game state, not an event. - The signal-driven visibility loop:
GameState.resource_changed→_on_resource_changed→_update_visibility()→visible = can_pledge_crusade(). The same primitive M2.3 used for label text, applied to a differentControlproperty. AcceptDialogfor the post-prestige ceremony moment: an OK-only modal (no Cancel) that punctuates a one-way action with flavor text. Distinct from M6.1'sConfirmationDialog(yes/no, before the action).- The two-modal arc —
ConfirmationDialogbefore (gates the destructive action),AcceptDialogafter (acknowledges the result and shows what was gained). Same Godot pattern, different modal classes, different positions in the action timeline. - Why ceremony is structural, not cosmetic: an irreversible mutation that resolves silently feels like a misclick. A ceremony moment buys the player a beat to register what just happened, which is what makes prestige feel like a milestone instead of a button press.
How it applies
- Locked-content discoverability. A button that is visible-but-disabled signals "this exists, you'll get it later." A button that is hidden-until-ready signals "surprise reveal at the right moment." Idle games typically use the former for the first prestige (so the player sees the goal early) and the latter for second-tier metas (so the player isn't overwhelmed). The choice is a player-onboarding decision, not a layout decision.
- The misclick problem with one-way actions. Steam refunds catch buyer's remorse on purchases; saves catch it on most gameplay state; but a prestige is the save in idle games — there's nothing to undo to. Every irreversible action needs (a) a confirmation gate before, (b) a clear post-event display after. M6.1's
ConfirmationDialogis (a); M6.3'sAcceptDialogis (b). Skipping (b) is how players post complaints like "I clicked something and my save just reset." - Ceremony pacing in long-session genres. Idle players run sessions of 20+ minutes, sometimes hours. A 0.5-second flash and a counter reset is forgettable; a 3-second modal with a flavor sentence and the gained Honor displayed is memorable. Memorable matters because the next prestige decision (do I push for 2 Honor or pledge now at 1?) is influenced by how vivid the prior pledge felt. Game-feel research calls this the "anchoring moment."
- Threshold display vs threshold gate. Showing the player the threshold — "Pledge unlocks at 1,000,000 Light" — is a separate UI decision from gating the button. A gated-but-hidden button with no threshold display is a discoverability failure. A gated button with a visible "X / 1,000,000" progress label is the genre standard. M6.3 builds the gate; the progress label is left as walkthrough exercise.
- Settings-driven ceremony. Some players will replay the same ceremony 200 times. A "skip ceremony" toggle (off by default, on after experience) is a real shipped feature in mature idle games. The architectural enabler: ceremony is one node, one method call (
PledgeCeremony.popup_centered()); skipping is one settings check around that call. M6.3 builds the ceremony; the skip toggle is a one-line addition once a Settings autoload exists.
Concepts¶
Threshold gating as a function of state¶
A gated button's visibility is not set when the threshold is crossed. It is recomputed whenever the gating resource changes. The difference matters because crossing isn't a single event — the player can lose Light (if M6.1's _lifetime_light were reversible, which it isn't) or gain it past threshold many times, and the button must reflect current state, not historical events.
threshold gating
This mirrors M4.4's unlock thresholds (where each upgrade row recomputed disabled on resource changes) but applies to visibility instead of disabled. M6.3 picks visible because the prestige action is foreign to a first-time player — better to surface it only when relevant. M4.4 picked disabled because upgrades stay in the list; the player benefits from seeing what's coming.
Example
A player at 999,000 Light. The button is hidden (can_pledge_crusade() returns false). They earn 2,000 more Light over the next two ticks. Each add_to_resource("light", ...) call emits resource_changed("light", new_total). The button's _on_resource_changed handler runs, calls _update_visibility, recomputes visible = true once the total crosses 1,000,000. The button appears in the same frame the threshold is met, no polling required.
Wiring the visibility recompute to resource_changed¶
The signal source is GameState.resource_changed(name, new_value) — same signal M2.3 used for the Light label. Subscribing the button's _update_visibility to this signal is the recompute trigger.
func _ready() -> void:
GameState.resource_changed.connect(_on_resource_changed)
_update_visibility()
func _on_resource_changed(resource_name: String, _new_value: float) -> void:
if resource_name == "light":
_update_visibility()
Three observations.
First, the initial _update_visibility() call in _ready covers the load-from-save case — when a save is loaded with _lifetime_light already past threshold, no resource_changed fires, but the button still needs to be visible. Recomputing once on _ready makes the function idempotent.
Second, the filter if resource_name == "light" matters: every resource change pings every connected handler. M6.3's button only reacts to Light because the threshold is a Light threshold. Honor changes don't affect the gate.
Third, _lifetime_light (M6.1) is not a _resources key, so it does not emit resource_changed. But because _lifetime_light strictly tracks positive Light gains, every gain that contributes to lifetime also increments _resources["light"] and emits the signal. The Light signal is a sufficient proxy for the lifetime gate — though can_pledge_crusade reads _lifetime_light directly, the trigger to recheck is the live Light change.
Example
A player gains 100 Light in a tick. add_to_resource("light", 100) runs: _resources["light"] += 100, _lifetime_light += 100 (M6.1 contract — lifetime tracks positive Light gains), resource_changed.emit("light", new_total). The button's _on_resource_changed filter passes (name is "light"), _update_visibility runs, reads can_pledge_crusade() (which checks _lifetime_light >= PRESTIGE_THRESHOLD), and updates visible. The chain is one signal hop wide and four function calls deep — well within signal-handler budget.
AcceptDialog vs ConfirmationDialog¶
Godot ships two modal dialog classes that differ in exactly one button:
ConfirmationDialog— OK + Cancel. Emitsconfirmedon OK,canceledon Cancel. Use for "are you sure?"AcceptDialog— OK only. Emitsconfirmedon OK. Use for "here's what happened, click to dismiss."
AcceptDialog
ConfirmationDialog actually subclasses AcceptDialog and adds the Cancel button. The pattern: the broader class is the "show user a thing" primitive; the narrower class adds the decision gate. M6.3 reaches for AcceptDialog because the player has no decision to make at the ceremony moment — the pledge has already happened.
Example
The button click chain in M6.1 went _on_pressed → ConfirmationDialog.popup_centered → ConfirmationDialog.confirmed → pledge_crusade(). The button click chain in M6.3 extends it: ... → pledge_crusade() returns honor_gain → PledgeCeremony.dialog_text = "X Honor earned" → PledgeCeremony.popup_centered. Two modals, sandwiching the destructive call. The ConfirmationDialog gates the call; the AcceptDialog displays its result.
Listening for crusade_pledged to drive the ceremony¶
The crusade_pledged(honor_gain) signal — declared on GameState in M6.1 — is the trigger for the ceremony modal. Subscribing the modal's "show" action to this signal decouples the ceremony from the button: any future code path that calls pledge_crusade() (a debug menu, an autopledge feature, a unit test in headless mode) gets the ceremony for free.
@onready var _ceremony: AcceptDialog = %PledgeCeremony
func _ready() -> void:
GameState.crusade_pledged.connect(_on_crusade_pledged)
func _on_crusade_pledged(honor_gain: int) -> void:
_ceremony.dialog_text = "Crusade pledged.\n%d Honor earned." % honor_gain
_ceremony.popup_centered()
ceremony moment
The signal contract carries the gained Honor as its argument; the ceremony reads it and formats the message. No reach back into GameState to query "how much Honor did I just gain?" — the signal payload already has it. This is M2.2's signal-payload discipline: the emitter knows the most about the change; pass that knowledge in the signal arguments rather than forcing receivers to recompute.
The two-modal arc as a structural pattern¶
Together M6.1 + M6.3 establish the pattern for every irreversible action in the game's future:
| Stage | Class | Purpose | Signal |
|---|---|---|---|
| Before | ConfirmationDialog |
Gate intent. Last chance to back out. | Emits confirmed → triggers the action |
| Action | (none — direct call) | Mutate state | Emits the result signal (e.g., crusade_pledged) |
| After | AcceptDialog |
Acknowledge. Display result. Pace the moment. | Emits confirmed → dismisses modal |
two-modal arc
Applied later: a hard-reset button in M7 will follow the same arc with different text. A respec mechanic (if added) will follow the same arc. The pattern is architectural — the dialog classes and signal connections — not the specific copy.
Example
Imagine adding "Reset save file" in M7. The structure is identical: a ConfirmationDialog titled "Erase all progress?" gates the destructive call; the destructive call (SaveSystem.wipe()) emits a save_wiped signal; an AcceptDialog connected to save_wiped displays "Save erased. Restart the game to begin a new run." The flavor text changes; the architecture is the M6.3 pattern, repeated.
Walkthrough¶
You'll extend prestige_button.gd (introduced in M6.1) with threshold-gated visibility, then add a PledgeCeremony AcceptDialog to the scene tree and wire it to GameState.crusade_pledged.
Step 1. Open scenes/prestige_button.tscn. The current root is the Pledge Crusade Button with a child ConfirmationDialog (named PrestigeDialog from M6.1).
Step 2. Add a new child to the Button: Scene → Add Child Node → AcceptDialog. Rename it PledgeCeremony. In the Scene dock, right-click PledgeCeremony → Access as Unique Name so the script can reach it as %PledgeCeremony (the % prefix resolves to the unique-named node, the way M5.2 wired %BuyButton). In the Inspector, set its title to "Crusade Pledged". Leave dialog_text blank — the script will populate it per pledge.
Step 3. Save the scene (Ctrl+S).
Step 4. Open prestige_button.gd. Add an @onready reference to the new ceremony dialog alongside the existing _dialog declaration from M6.1 (which references the PrestigeDialog ConfirmationDialog):
The %PledgeCeremony resolves through the Unique Name mark you set in Step 2. Without the declaration, the script cannot refer to the dialog at all — Step 7's handler would fail to parse with "Identifier 'PledgeCeremony' not declared in current scope."
Then locate _ready() from M6.1 — it already connects GameState.resource_changed (M6.1's _refresh() trigger from the prestige-button visibility code). Add only the new connection for the ceremony, plus the explicit visibility recompute:
Do not re-add the resource_changed.connect(...) line — it is already there from M6.1. A second connect call to the same (signal, callable) pair adds an additional registration; the handler would run twice per emit. The M2.3 quiz Q2 covered exactly this footgun: Godot does not deduplicate connections.
The trailing _update_visibility() covers the load-from-save case where no signal fires but the button must reflect current state.
Step 5. Add _update_visibility:
One line, but conceptually load-bearing — every other gating decision in the file routes through it.
Step 6. Add _on_resource_changed. Filter on the relevant resource:
func _on_resource_changed(resource_name: String, _new_value: float) -> void:
if resource_name == "light":
_update_visibility()
The leading underscore on _new_value tells GDScript the parameter is intentionally unused (the visibility recompute reads _lifetime_light via can_pledge_crusade, not the new value). The first parameter is resource_name, not name — naming a method parameter name on a Node-extending script would shadow Node.name and emit SHADOWED_VARIABLE_BASE_CLASS warnings (the same M3.3 discipline carries forward).
Step 7. Add _on_crusade_pledged. This populates the ceremony dialog text and shows it through the _ceremony reference declared in Step 4:
func _on_crusade_pledged(honor_gain: int) -> void:
_ceremony.dialog_text = "Crusade pledged.\n%d Honor earned." % honor_gain
_ceremony.popup_centered()
popup_centered shows the dialog at the center of the parent window with the dialog's natural size.
Step 8. Save (Ctrl+S). Run the game (F5).
Step 9. New run — the button should be hidden. Earn Light by clicking + tick income until you cross 1,000,000 Light (lifetime). The button should pop into existence the moment the threshold is crossed.
Step 10. Click the Pledge Crusade button. The PrestigeDialog (ConfirmationDialog, M6.1) appears. Click OK. The pledge resolves: Light wipes, buildings clear, PledgeCeremony appears showing "Crusade pledged. 1 Honor earned." Click OK on the ceremony to dismiss. Confirm the button is now hidden again (_lifetime_light reset to 0 in M6.1's _reset_run_state, so can_pledge_crusade returns false).
Step 11. Run a quick state check. Open the Remote tree (Scene panel → Remote tab while game is running). Inspect GameState: _resources["honor"] should be 1, _lifetime_light should be 0 (or whatever you've earned post-pledge), tick_multiplier should be at its post-pledge value (1.0 if no Sermons bought yet — Sermons clear with the run). The visible state matches what the ceremony reported.
Step 12 (optional, M6.3 walkthrough exercise). Add a Label next to the button — ProgressLabel — that shows current lifetime Light vs threshold, e.g. "850,000 / 1,000,000 Light". Wire it to the same resource_changed filter you already have. Hide it when the button is visible (the threshold is met; the label is now redundant).
Self-check quiz¶
Q1 — A player loads a save where _lifetime_light is already 5,000,000 (well past threshold). What ensures the Pledge Crusade button is visible immediately on load?
A. The resource_changed signal fires automatically on save load.
B. can_pledge_crusade() is polled every frame in _process.
C. The trailing _update_visibility() call in _ready() recomputes visibility once when the button enters the tree.
D. AcceptDialog.popup_centered() is called on load.
Reveal answer
Correct: C. The _update_visibility() call at the end of _ready is the load-from-save handler. No resource_changed fires for state that was deserialized before the button existed; the explicit recompute on _ready covers the gap.
- A is wrong: save loading restores fields directly; whether the load path emits
resource_changeddepends on save-system implementation (M7 will define this), and even if it does, the button must already be in the tree to receive it. The_readycall is the safer guarantee. - B is wrong and is exactly the polling antipattern threshold gating avoids — recompute on signal, not every frame.
- D is wrong:
AcceptDialogis the ceremony modal, unrelated to button visibility.
Q2 — Why is the ceremony wired to GameState.crusade_pledged instead of being called inline at the end of pledge_crusade()?¶
A. Calling UI from GameState would crash because GameState is an autoload and has no scene tree.
B. Decoupling: any caller of pledge_crusade() (debug menu, headless test, future autopledge) gets the ceremony for free without GameState knowing about UI.
C. crusade_pledged carries the Honor gain in its payload; calling inline would lose that data.
D. The signal is required by Godot 4.6 — direct UI calls from autoloads are a runtime error.
Reveal answer
Correct: B. Decoupling. GameState does not import the ceremony modal, does not know it exists, does not need to be edited if the ceremony moves or is skipped. The signal is the contract; subscribers wire themselves.
- A is wrong: an autoload can absolutely walk the tree and call UI nodes. It is a design mistake to do so (because it couples model to view), not a crash.
- C is wrong: an inline call could pass
honor_gainas an argument to a UI method directly. The signal isn't strictly required to preserve the payload. - D is wrong: Godot has no such restriction.
Q3 — A teammate adds a "skip ceremony" toggle in settings. Where is the cleanest place to check it?¶
A. Inside pledge_crusade() in GameState — return early before emitting crusade_pledged if skip is on.
B. Inside _on_crusade_pledged() in prestige_button.gd — wrap the popup_centered() call in an if not Settings.skip_ceremony: check.
C. Inside _update_visibility() — hide the ceremony pre-emptively when skip is on.
D. Disconnect crusade_pledged.connect(_on_crusade_pledged) whenever the setting changes.
Reveal answer
Correct: B. The handler is the right scope: skipping affects display, not state. The pledge still happens, the signal still emits, the Honor still accumulates — only the modal is suppressed. One-line check, localized to the UI subscriber.
- A is wrong: suppressing the signal hides the result from every subscriber, not just the modal. Save systems, achievement listeners, sound effects might also subscribe — they should still fire.
- C is wrong:
_update_visibilitydecides button visibility, not ceremony visibility. Conflating the two is a category error. - D is wrong: connection management on every settings change is fragile and order-dependent. A simple gate inside the handler is cleaner.
Integration question¶
Compare M6.3's threshold gating to M4.4's unlock thresholds. Both recompute a Control property from a resource value via resource_changed. Why does M6.3 toggle visible while M4.4 toggles disabled? Could the patterns be swapped — could M4.4 use visible and M6.3 use disabled? What changes for the player in each case?
Reveal answer
The patterns are architecturally identical: subscribe to resource_changed, recompute a property as a function of state. They differ in which property because the player-facing intent differs.
M4.4's upgrade list is a catalog. The player benefits from seeing the upcoming upgrade — its name, cost, effect — even before they can afford it. Toggling disabled keeps the row visible (so the player can plan) but unclickable (so they can't accidentally buy what they can't afford). The cost/state still updates live; the player watches the affordability creep up.
M6.3's prestige button is a milestone gate. A first-time player at 100 Light has no use for a "pledge unlocks at 1,000,000 Light" reminder — it would be visual noise far ahead of relevance. The hidden button reduces UI clutter early and produces a "the path opens" moment when threshold is met.
Could you swap them? Yes, and games do.
- M4.4 with
visible: a "discover upgrades as you progress" feel. Reveal-driven. Reduces early UI complexity. The cost is the player can't see what's coming; goals feel less concrete. - M6.3 with
disabled: a "see the goal from session 1" feel. Goal-anchored. The button is visible immediately with text like "Pledge Crusade — requires 1,000,000 lifetime Light" but unclickable. The cost is potential clutter; the gain is the player has a long-horizon objective from minute one.
The choice is a player-onboarding decision per gate, not a uniform policy. M4.4 picked disabled because upgrades are progression scaffolding; M6.3 picked visible because the prestige action is foreign and disorienting if surfaced too early.
Glossary¶
Glossary
- threshold gating
- A UI pattern where a
Control's visibility (or disabled state) is recomputed from current game state every time the gating resource changes. The control's state is a pure function of game state, not the result of a one-shot "unlock" event. Crossings, regressions, and reloads all produce the correct UI without special-case logic. M6.3 applies it to the Pledge Crusade button keyed offcan_pledge_crusade(). AcceptDialog- Godot's built-in modal dialog with one OK button (no Cancel). Subclass of
Window. Emitsconfirmedwhen the user clicks OK. Used for post-event acknowledgment flows where the user has nothing to decide — they just need a beat to register the result. Distinct fromConfirmationDialog, which has both OK and Cancel and gates a yes/no decision. - ceremony moment
- A UI beat — typically a modal or screen — that punctuates an irreversible game-state change with flavor text and a display of what was gained or lost. Architecturally: a
Controlnode whose "show" is connected to the result signal of the destructive action (in M6.3,crusade_pledged). Distinct from the confirmation gate (ConfirmationDialogbefore the action) — the ceremony is the after-event acknowledgment. - two-modal arc
- A UI pattern wrapping any irreversible action with two dialogs:
ConfirmationDialogbefore (yes/no decision gate) andAcceptDialogafter (post-event acknowledgment). The action itself sits between them, decoupled from both — the modals subscribe to the action's input signal (button press) and output signal (result emission). Future irreversible actions (hard reset, character respec, save deletion) reuse the pattern by name.