Jovian Encounter System
Data-driven encounter authoring for Unity. One composable Encounter type carrying a polymorphic IEncounterKind payload, dialog options with designer-authored events, cross-table quest chaining, and gated quest progression that serialises cleanly.
Package Structure
Packages/com.jovian.encounter-system/
├── Runtime/
│ ├── IEncounter.cs ← interface + Encounter + definition/visuals/properties
│ ├── IEncounterKind.cs ← marker interface + Combat/Quest/Social/Puzzle/...
│ ├── EncounterTable.cs ← ScriptableObject: list of encounters + roll helpers
│ ├── EncountersCollection.cs ← ScriptableObject: group of tables
│ ├── EncounterDialogOptionSet.cs ← ScriptableObject: shared option list
│ ├── EncounterLink.cs ← cross-table reference (table + internalId)
│ ├── EncounterReference.cs ← MonoBehaviour scaffold wiring common UI fields
│ ├── EncounterRegistry.cs ← id → encounter lookup cache
│ ├── SubclassSelectorAttribute.cs← attribute powering the concrete-type dropdown
│ ├── IEncounterEvent.cs ← event interface + ChainTo/StartCombat/Log/GiveReward starters
│ ├── Reward.cs ← ScriptableObject: reward asset (id + displayName + icon + kind)
│ ├── IRewardKind.cs ← marker interface + Currency/Item/Experience/Unlockable/Other
│ ├── EncounterContext.cs ← per-resolution data bag
│ ├── EncounterResolver.cs ← Register<T>/Resolve dispatch by concrete event type
│ ├── QuestProgress.cs ← gated progression + QuestStarted/Advanced/Completed events
│ └── QuestLog.cs ← chronological serialisable record of quest events
└── Editor/
├── SubclassSelectorDrawer.cs ← dropdown + inline children for [SerializeReference] fields
├── EncounterLinkDrawer.cs ← two-row table + encounter picker
├── EncounterValidator.cs ← project-wide scan + "Validate All" menu + browser badges
└── EncounterBrowserWindow.cs ← Jovian → Encounters → Encounter Browser
Quick Start
- Add the package to your project (local package in
Packages/). - Create a table:
Assets → Create → Jovian → Encounter System → Encounter Table. - Create a collection:
Assets → Create → Jovian → Encounter System → Encounters Collection, drag tables into its array. - In the table inspector, add encounters to the list. For each: pick the kind in the dropdown, fill in shared fields (id/name/description), and populate the kind-specific payload.
- For a quest chain, set
QuestKind.nextEncounteron each step — pick the target table, then pick the encounter inside it. - Browse everything:
Jovian → Encounters → Encounter Browserfor a searchable, filterable list view.
Key Concepts
Encounter is one class; IEncounterKind is where types live
A single concrete Encounter carries shared fields (EncounterDefinition, EncounterProperties, EncounterVisuals, EncounterDialogOptionSet). Its Kind property is a [SerializeReference, SubclassSelector] polymorphic slot. Adding a new kind is one small class:
[Serializable]
public class MerchantKind : IEncounterKind {
public string shopInventoryId;
public float priceMultiplier = 1f;
}
The editor dropdown picks it up automatically via reflection. No base class to extend, no subclass of Encounter to write.
Dialog option events are designer-authored, code-dispatched
Each EncounterDialogOption holds an ordered List<IEncounterEvent> (also [SerializeReference, SubclassSelector]). Events are data only — what happens when the option is chosen is determined by handlers registered on EncounterResolver:
var resolver = new EncounterResolver();
resolver.Register<StartCombatEvent>((evt, ctx) => combatSystem.Start(evt.combatEncounterId));
resolver.Register<ChainToEncounterEvent>((evt, ctx) => encounterFlow.GoTo(evt.nextEncounterId));
resolver.Register<LogEvent>((evt, _) => Debug.Log(evt.message));
// When the player picks an option:
resolver.Resolve(chosenOption.events, new EncounterContext(currentEncounter));
Rewards are reusable assets, referenced by events
Reward is a ScriptableObject — one file per reusable reward (10_Gold.asset, Rusty_Sword.asset, Unlock_Riverfort.asset). Each asset has shared metadata (id, display name, icon) plus a polymorphic IRewardKind payload:
[Serializable]
public class PotionOfHealingRewardKind : IRewardKind {
public int healAmount = 50;
}
To grant rewards, drop GiveRewardEvents onto a dialog option and assign a Reward asset to each. Multiple rewards = multiple events — the resolver iterates them in order.
Game-side wiring applies the reward:
resolver.Register<GiveRewardEvent>((evt, ctx) => rewardApplier.Apply(evt.reward, ctx));
rewardApplier lives in game code — the package ships only the data types.
Cross-table references via EncounterLink
EncounterLink stores a table asset + stable internalId GUID. Rename-safe (the GUID doesn't change) and diffable. The custom drawer renders two dropdowns: first pick a table, then pick an encounter inside it.
Gated quest progression
QuestProgress walks an EncountersCollection at construction and builds a map of "predecessor quest encounter" for every target of a QuestKind.nextEncounter. An encounter is gated iff its predecessor hasn't been resolved:
var questProgress = new QuestProgress(encountersCollection);
// When rolling from a table, exclude gated entries:
var next = table.GetRandomEncounter(e => !questProgress.IsGated(e));
// After showing any encounter to the party:
questProgress.OnEncounterTriggered(shown);
Progression fires QuestStarted / QuestAdvanced(from, to) / QuestCompleted events. Single-step quests (root with no nextEncounter) fire both QuestStarted and QuestCompleted.
Quest log (serialisable record)
QuestLog subscribes to the three QuestProgress events and appends a QuestLogEntry for each. CreateSnapshot() returns the save payload; Restore(saved) rehydrates. Pair it with QuestProgress.LoadResolvedIds(questLog.ResolvedEncounterIds()) on load to restore the gating set.
Runtime API Cheatsheet
// Encounter rolling with gating
IEncounter next = table.GetRandomEncounter(e => !questProgress.IsGated(e));
// Resolving a picked dialog option
resolver.Resolve(chosenOption.events, new EncounterContext(currentEncounter));
// After any encounter fires
questProgress.OnEncounterTriggered(shown);
// Save/load quest state
save.questLogEntries = questLog.CreateSnapshot();
questLog.Restore(save.questLogEntries);
questProgress.LoadResolvedIds(questLog.ResolvedEncounterIds());
// Fast id → encounter lookup
encounterRegistry.GetEncounters().TryGetValue(id, out var encounter);
Menu Items
| Menu Path | Description |
|---|---|
| Assets → Create → Jovian → Encounter System → Encounter Table | New table asset |
| Assets → Create → Jovian → Encounter System → Encounters Collection | New collection asset |
| Assets → Create → Jovian → Encounter System → Dialog Option Set | New dialog option set |
| Assets → Create → Jovian → Encounter System → Encounter Registry | New registry asset |
| Jovian → Encounters → Encounter Browser | Searchable designer browser |
| Jovian → Encounters → Validate All | Scan all tables/rewards for issues, print click-through report |
Composition Pattern
Per the project's composition-over-inheritance stance, extending behaviour means:
- New encounter type? Add a new
IEncounterKindimplementation. - New dialog side effect? Add a new
IEncounterEvent+resolver.Register<T>. - Never subclass
Encounteror stackIEncounterinheritance chains.
Inheritance is only used where Unity effectively requires it — ScriptableObject assets (EncounterTable, EncountersCollection, EncounterDialogOptionSet, EncounterRegistry) and the EncounterReference MonoBehaviour scaffold.
Known Fragilities
[SerializeReference]stores the concrete type's full name + assembly in the YAML. Renaming or moving anIEncounterKindorIEncounterEventclass drops existing instances silently. Use[MovedFrom]when refactoring:[MovedFrom("Jovian.EncounterSystem", "Jovian.EncounterSystem", "StartCombatEvent")] public class BeginCombatEvent : IEncounterEvent { ... }EncounterDefinition.internalIdis a GUID created at class construction. If you duplicate an encounter by copying YAML, dedupe the id before saving — otherwiseEncounterLinklookups become ambiguous.