19 KiB
Event System
Status: Designed Author: SepComet Last Updated: 2026-04-29 Implements Pillar: [To be designed]
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 ≥ countCompCountAtLeast(count, rarity): at least count loose (unassembled) components of specified rarityTowerCountAtLeast(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:
- Cost Effects applied immediately — all effects in
costEffects[]execute against a working inventory copy (gold deducted, components removed, tower endurance reduced) - Probability Roll —
RollProbability()uses seededSystem.Random:seed = runSeed + sequenceIndex + eventId + optionIndex + 0 + salt(17). Ifrandom.NextDouble() ≤ probability, roll succeeds - 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 bycount(positive = gain, negative = cost). Throws if gold would go negativeRemoveRandomComps(count, rarity): removescountrandom loose components of specified rarity from inventory, using a seeded shuffleAddRandomComps(count, minRarity, maxRarity): callsInventoryGenerationComponent.BuildEventRewardComponents()to generatecountcomponents in the rarity range, seeded by contextDamageRandomTowersEndurance(count, amount): reduces endurance ofcountrandom assembled towers byamount, usingInventoryTowerEnduranceUtility.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
NodeCompleteEventArgson 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)forAddRandomCompseffect 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) viaCreateInitialModel() - 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 | Designed (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:
probabilityvalue per option (0.0–1.0) — changes success oddscostEffectsandrewardEffectsJSON — changes what the option costs and rewardsrequirementsJSON — changes entry threshold
TK2 — New Requirement Types
Adding a new requirement type (e.g., TowerLevelCountAtLeast) requires:
- New
EventRequirementTypeenum value inEventRequirementType.cs EventRequirementFactory.Create()branchIsRequirementSatisfied()branch inEventOptionExecutorBuildBlockedReason()branch- New JSON type in
Event.txt
TK3 — New Effect Types
Adding a new effect type (e.g., ReduceGold) requires:
- New
EventEffectTypeenum value inEventEffectType.cs EventEffectFactory.Create()branchApplyEffects()switch branch inEventOptionExecutor- 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
NodeCompleteEventArgsfires — 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 == falsewith 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 == 100after cost effects
AC5 — Reward Effects Applied on Success
- Given: player has 200 gold, selects the 70% option, roll succeeds
- When:
Execute()returnsEventOptionExecutionResult.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()returnsEventOptionExecutionResult.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 How many Event nodes appear per run? The Node System GDD specifies 10 total nodes, but does not specify how many are Event nodes. If there are 0 event nodes per run, the Event System is unreachable dead code.
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)?