geometry-tower-defense/design/gdd/event-system.md

20 KiB
Raw Blame History

Event System

Status: Revised — blocking fixes applied (Pillar placeholder defined; Node System dependency updated to Approved; OQ1 resolved) Author: SepComet Last Updated: 2026-04-30 Implements Pillar: Narrative surprise and meaningful stakes — events break combat rhythm, adding story variety and meaningful choices

Overview

The Event System is a deterministic narrative decision service that presents the player with branching choices at Event nodes during a run. It reads event definitions from DREvent data table, selects the active event via EventNodeComponent.SelectActiveEvent() using a seed derived from RunNodeExecutionContext, and executes the player's chosen option through EventOptionExecutor. Each event offers one or more options, each containing: requirements (e.g., gold threshold, component count), cost effects applied before a probability roll, and reward effects applied only if the roll succeeds. All randomness uses seeded System.Random derived from runSeed + sequenceIndex + nodeId + eventId + optionIndex + effectIndex + salt, guaranteeing run reproducibility. Events are data-driven via JSON option payloads in DREvent.OptionsRaw, enabling new events to be authored without code changes.

Player Fantasy

"Every choice carves a different path."

The Event System delivers the fantasy of narrative surprise and meaningful stakes. The player enters an Event node knowing only that something unexpected awaits — they may gain a windfall, suffer a setback, or face a gamble where the odds are unclear. Events break the mechanical rhythm of combat and shop, adding the texture of a story that unfolds differently every run. Each option carries a clear cost and an uncertain reward — the player must read the situation, weigh their resources, and commit.

The player should feel:

  • Curiosity and tension — what will this event be? Events are the primary source of narrative variety between combat nodes
  • Genuine dilemma — options feel meaningfully different, not obviously right or wrong
  • Risk awareness — probabilistic options feel like a gamble, not a guaranteed upgrade
  • Story authorship — the accumulated events of a run become a story the player tells about what happened to them

Reference: Slay the Spire's event philosophy — events are never pure upside, rewards require trade-offs, and the best path through a run is never obvious.

Detailed Design

Core Rules

ER1 — Event Selection (Deterministic) When the player enters an Event node, EventNodeComponent.SelectActiveEvent() picks one event from the DREvent data table using a seed derived from RunNodeExecutionContext: seed = runSeed + sequenceIndex + nodeId. The same run seed, node ID, and sequence index always produce the same event — run reproducibility is guaranteed. If no context is available (null), a random Unity Random.Range selection is used (non-reproducible, only for dev/fallback).

ER2 — Option Availability Evaluation Before displaying options, EventOptionExecutor.EvaluateOption() checks every option against the player's BackpackInventoryData snapshot. Three requirement types exist:

  • GoldAtLeast(count): player gold ≥ count
  • CompCountAtLeast(count, rarity): at least count loose (unassembled) components of specified rarity
  • TowerCountAtLeast(count): at least count assembled towers

Options with unmet requirements are shown as Blocked with a reason string (e.g., "需要至少 100 金币"). Blocked options cannot be selected but remain visible.

ER3 — Option Execution Flow When the player selects an option, EventOptionExecutor.Execute() runs in three steps:

  1. Cost Effects applied immediately — all effects in costEffects[] execute against a working inventory copy (gold deducted, components removed, tower endurance reduced)
  2. Probability RollRollProbability() uses seeded System.Random: seed = runSeed + sequenceIndex + eventId + optionIndex + 0 + salt(17). If random.NextDouble() ≤ probability, roll succeeds
  3. Reward Effects applied on success only — effects in rewardEffects[] execute if the roll succeeded, otherwise nothing is added

ER4 — Effect Types Four effect types exist:

  • AddGold(count): adjusts working gold by count (positive = gain, negative = cost). Throws if gold would go negative
  • RemoveRandomComps(count, rarity): removes count random loose components of specified rarity from inventory, using a seeded shuffle
  • AddRandomComps(count, minRarity, maxRarity): calls InventoryGenerationComponent.BuildEventRewardComponents() to generate count components in the rarity range, seeded by context
  • DamageRandomTowersEndurance(count, amount): reduces endurance of count random assembled towers by amount, using InventoryTowerEnduranceUtility.ReduceTowerEndurance()

ER5 — Inventory Working Copy & Commit All evaluation and execution uses a BackpackInventoryData snapshot (GameEntry.PlayerInventory.GetInventorySnapshot()). On EventOptionExecutionResult.Accepted, GameEntry.PlayerInventory.ReplaceInventorySnapshot(workingInventory) commits changes to the real inventory. The event form closes and NodeCompleteEventArgs fires.

ER6 — Determinism Guarantees Every random operation — event selection, component shuffle, probability roll, component generation — uses System.Random seeded from the run context. The seed chain is:

  • Event selection: runSeed * 31 + sequenceIndex * 31 + nodeId
  • Probability roll: runSeed + sequenceIndex + eventId + optionIndex + 0 + salt(17)
  • Effect random: runSeed + sequenceIndex + eventId + optionIndex + effectIndex + salt

States and Transitions

The Event System operates across two layers: the node component (managing lifecycle) and the UI form (managing player interaction).

State Owner Description
Idle EventNodeComponent No event active. Component initialized, data table loaded.
EventActive EventNodeComponent Event node is running. Context is set, event is selected, form is open.
FormDisplayed EventFormUseCase Event form is open, options are evaluated and shown. Player is browsing.
Executing EventFormUseCase Player has selected an option. Cost effects applied, probability rolled, rewards applied or skipped.
FormClosed EventFormUseCase Option execution complete. Form is closed.

Transitions:

From To Trigger
Idle EventActive Node System triggers StartEvent(RunNodeExecutionContext)
EventActive FormDisplayed EventFormUseCase.BindEvent() + OpenUI(EventForm) completes
FormDisplayed Executing Player clicks a selectable option; TrySelectOption(optionIndex) is called
Executing FormClosed Execution result returned; EndEvent() called, CloseUI(EventForm)
FormClosed Idle ClearActiveNodeContext() resets component state

There are no player-accessible states — the player only ever sees FormDisplayed (browsing options). The Executing state is transient — cost effects, roll, and reward effects execute synchronously before the form closes.

Interactions with Other Systems

Upstream — Node System

  • Receives: StartEvent(RunNodeExecutionContext) trigger from Node System when player navigates to an Event node
  • Provides: Fires NodeCompleteEventArgs on event end, with inventory snapshot
  • Interface owner: Node System

Upstream — PlayerInventoryComponent

  • Receives: GetInventorySnapshot() to get current gold, components, towers before evaluating options
  • Receives: ReplaceInventorySnapshot(workingInventory) to commit changes after option execution
  • Provides: Working inventory state for requirement checks and effect application
  • Interface owner: Event System (consumer)

Upstream — InventoryGenerationComponent

  • Receives: BuildEventRewardComponents(count, minRarity, maxRarity, runSeed, sequenceIndex, eventId, optionIndex, effectIndex) for AddRandomComps effect type
  • Provides: Component generation service for event rewards
  • Interface owner: Event System (consumer)

Downstream — UI (EventForm)

  • Receives: EventFormRawData (event title, description, option items with availability status) via CreateInitialModel()
  • Receives: TrySelectOption(optionIndex) call when player clicks an option
  • Provides: Opens/closes UIFormType.EventForm
  • Interface owner: Event System (provider)

Formulas

F1 — Probability Roll

success = (random.NextDouble() <= probability)
where random is seeded with: seed = runSeed + sequenceIndex + eventId + optionIndex + 0 + 17

F2 — Event Selection Seed

seed = (((runSeed * 31) + sequenceIndex) * 31) + nodeId

Used by EventNodeComponent.BuildSelectionSeed(). *31 is a standard hash-combining technique.

F3 — Effect Random Seed

seed = runSeed + sequenceIndex + eventId + optionIndex + effectIndex + salt

Salt values vary by effect type (e.g., 101 for component shuffle, 211 for tower endurance damage). This ensures each random operation within an event option is independently reproducible.

F4 — Gold Effect (AddGold)

workingInventory.Gold = workingInventory.Gold + count

No internal cap. The 9999 MaxPlayerGold cap is applied by PlayerInventoryComponent on commit, not by the Event System.

Edge Cases

EC1 — No Event Data Loaded If GameEntry.DataTable.GetDataTable<DREvent>() returns null on OnInit(), the component logs a warning and sets _initialized = true. Any subsequent StartEvent() call logs a warning and returns early without opening the form.

EC2 — No Events in Data Table If _eventItems.Count <= 0 when StartEvent() is called, the same early-return warning path is taken.

EC3 — Requirements Met by Exact Count CompCountAtLeast(count, rarity) and TowerCountAtLeast(count) use >= comparison. A player with exactly count components/towers satisfies the requirement.

EC4 — RemoveRandomComps — Insufficient Candidates If CollectLooseComponents() returns fewer components than removeCount, ApplyRemoveRandomComponentsEffect() throws InvalidOperationException. This indicates a data-authoring error — the requirement check should prevent this path at runtime.

EC5 — AddRandomComps — No InventoryGenerationComponent If GameEntry.InventoryGeneration == null when AddRandomComps is applied, ApplyAddRandomComponentsEffect() throws InvalidOperationException. Event authors must not use AddRandomComps in an environment where InventoryGenerationComponent is absent.

EC6 — DamageRandomTowersEndurance — No Towers or Count ≤ 0 ApplyDamageRandomTowerEnduranceEffect() silently returns if towerCount <= 0, enduranceLoss <= 0, or there are no assembled towers. No exception is thrown — the effect is simply a no-op.

EC7 — Probability = 0 (Guaranteed Failure) RollProbability() returns false immediately for probability <= 0. Cost effects are still applied. The player pays the cost but always receives no reward.

EC8 — Probability = 1 (Guaranteed Success) RollProbability() returns true immediately for probability >= 1. Reward effects are always applied.

EC9 — Gold Would Go Negative from AddGold Cost ApplyAddGoldEffect() throws InvalidOperationException if workingInventory.Gold + count < 0. This is prevented by the GoldAtLeast requirement on options that spend gold.

EC10 — Component Instance Not Found on Removal RemoveComponentByInstanceId() throws InvalidOperationException if the component instance is not found in the list. This should not occur given the CollectLooseComponents → Shuffle → Remove flow.

EC11 — Duplicate Option Indices If TrySelectOption() receives an out-of-range optionIndex, it returns false and logs a warning. The form remains open.

Dependencies

Inherited from other systems:

Entity Value Source
MaxPlayerGold 9999 design/gdd/shop.md — applied on PlayerInventoryComponent.ReplaceInventorySnapshot() commit

Upstream Dependencies:

System Status Interface Contract
Node System Approved (design/gdd/node-system.md) Fires StartEvent(context), receives NodeCompleteEventArgs
PlayerInventoryComponent Code only GetInventorySnapshot() / ReplaceInventorySnapshot()
InventoryGenerationComponent Code only BuildEventRewardComponents()
DataTable (DREvent) Code only Event definitions with JSON OptionsRaw

Downstream Dependents:

System Status Interface Contract
UI (EventForm) Code only EventFormRawData, TrySelectOption()

Tuning Knobs

TK1 — Event Authoring (Data Table) All event tuning lives in Assets/GameMain/DataTables/Event.txt. Adding a new event requires a new DREvent row with a unique ID, title, description, and JSON option payloads. No code changes needed.

Tunable per event:

  • probability value per option (0.01.0) — changes success odds
  • costEffects and rewardEffects JSON — changes what the option costs and rewards
  • requirements JSON — changes entry threshold

TK2 — New Requirement Types Adding a new requirement type (e.g., TowerLevelCountAtLeast) requires:

  1. New EventRequirementType enum value in EventRequirementType.cs
  2. EventRequirementFactory.Create() branch
  3. IsRequirementSatisfied() branch in EventOptionExecutor
  4. BuildBlockedReason() branch
  5. New JSON type in Event.txt

TK3 — New Effect Types Adding a new effect type (e.g., ReduceGold) requires:

  1. New EventEffectType enum value in EventEffectType.cs
  2. EventEffectFactory.Create() branch
  3. ApplyEffects() switch branch in EventOptionExecutor
  4. New JSON type in Event.txt

TK4 — Event Selection Variety The number of events in DREvent data table directly controls event variety. More events = more entropy in event selection per node.

Visual/Audio Requirements

V1 — Event Node Entry When StartEvent() is called, the Node System handles scene/camera transition and fires NodeEnterEventArgs. Event System itself has no standalone VFX.

V2 — Event Form Appearance When EventForm opens:

  • Event title and description displayed prominently
  • Options shown as cards with: option text, requirement status (Selectable / Blocked with reason)
  • No VFX beyond standard UI hover/select feedback

V3 — Option Selection (Success Path) When EventOptionExecutionResult.Accepted(isProbabilitySuccess=true):

  • Standard UI close animation
  • NodeCompleteEventArgs fires — Node System handles success feedback
  • Inventory changes are silent (gold/component changes appear in the HUD on next frame)

V4 — Option Selection (Failure Path) When EventOptionExecutionResult.Accepted(isProbabilitySuccess=false):

  • Cost was deducted at execution time (gold already gone from working inventory)
  • UI shows brief "失败" indicator before form closes (~0.5s)

V5 — Blocked Options Options with unmet requirements show blocked reason text (e.g., "需要至少 100 金币") in red/disabled style. No special audio.

V6 — Probabilistic Feel Probability values in Event.txt are designer-facing only. The UI must not reveal exact odds — events should feel like genuine gambles. The 70% and 30% options in 赌马 must not visually differ in probability signaling.

UI Requirements

U1 — Event Form Layout

  • Single form: event title (large), description (medium), option list (vertical, scrollable if > 4 options)
  • Each option card shows: option text, availability state (normal / grayed-out with reason)
  • No explicit probability display on cards

U2 — Option Card Anatomy

  • Option text (primary label) — should imply the cost/action
  • Availability state: "可选择" (normal) or blocked reason text (disabled)
  • No price/cost field — the option text is the cost communication

U3 — Gold Display

  • Player's current gold visible in standard HUD during event
  • Gold changes reflected in HUD after event closes

U4 — Accessibility

  • All option text readable by screen readers
  • Blocked reason must use both color AND text label (not color alone)

Acceptance Criteria

AC1 — Event Selection Determinism

  • Given: a run with runSeed=12345, sequenceIndex=3, nodeId=7
  • When: the player enters the Event node twice with the same context
  • Then: the same event is selected both times

AC2 — Option Availability — Gold Requirement Not Met

  • Given: player has 50 gold, an option has requirement GoldAtLeast(100)
  • When: EventOptionExecutor.EvaluateOption() is called
  • Then: EventOptionAvailability.IsSelectable == false with reason "需要至少 100 金币"

AC3 — Option Availability — Gold Requirement Met

  • Given: player has 150 gold, an option has requirement GoldAtLeast(100)
  • When: EvaluateOption() is called
  • Then: EventOptionAvailability.IsSelectable == true

AC4 — Cost Effects Deducted on Selection

  • Given: player has 200 gold, selects the "下注 100 金币" option of 赌马
  • When: Execute() is called
  • Then: workingInventory.Gold == 100 after cost effects

AC5 — Reward Effects Applied on Success

  • Given: player has 200 gold, selects the 70% option, roll succeeds
  • When: Execute() returns EventOptionExecutionResult.Accepted(true)
  • Then: workingInventory.Gold == 250 (cost deducted + reward applied)

AC6 — Reward Effects Skipped on Failure

  • Given: player has 200 gold, selects the 70% option, roll fails
  • When: Execute() returns EventOptionExecutionResult.Accepted(false)
  • Then: workingInventory.Gold == 100 (cost deducted, no reward)

AC7 — Probability Roll Reproducibility

  • Given: same context (runSeed, sequenceIndex, eventId, optionIndex), run twice
  • When: RollProbability() is called both times
  • Then: both calls return the same result

AC8 — RemoveRandomComps Requirement

  • Given: player has 2 loose white components, option has CompCountAtLeast(2, White)
  • When: EvaluateOption() is called
  • Then: IsSelectable == true

AC9 — Tower Damage Effect

  • Given: player has assembled towers, selects the "代价与回报" option
  • When: Execute() completes
  • Then: at least one tower's endurance is reduced by 20

AC10 — Event Form Closes After Selection

  • Given: player selects any selectable option
  • When: TrySelectOption() returns true
  • Then: GameEntry.UIRouter.CloseUI(UIFormType.EventForm) is called

Open Questions

OQ1 — Event Frequency in Run Status: RESOLVED — Per the approved Node System GDD, the Plain theme places exactly one Event node at position 6. Events may only occupy positions 48 (future themes may vary). The Event System is not dead code — exactly 1 Event node appears per run in the Plain theme.

OQ2 — Player-Driven Event Avoidance Can the player choose to skip or avoid Event nodes? Currently there is no reroll or skip mechanic. In Slay the Spire, events are often optional (path away). Are Event nodes mandatory stops or optional detours?

OQ3 — Purely Narrative Events Current events all have mechanical trade-offs. Should purely narrative events exist (e.g., "a traveler tells you a story — nothing happens") for flavor, or should every event always have mechanical stakes?

OQ4 — Player Influence Over Event Selection Currently event selection uses runSeed + sequenceIndex + nodeId — deterministic but player has no agency. An alternative: add a choice layer ("you see two merchants — choose one"). Is this desirable?

OQ5 — Event Component Rarity Budget AddRandomComps uses InventoryGenerationComponent with the same rarity budget as shop/inventory generation. Should event rewards have a separate rarity budget (e.g., events always drop one tier lower than shop)?