JSON Schema for drama modules covering entry conditions, NPC requirements, event sequences, and outcomes. Includes v0.1 vertical slice stub module and authoring guide with review feedback from Mellanie (pattern/motivation reference, terminal outcome semantics, fact ID conventions, trigger type bridge). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 KiB
Tier 1 Drama Module — Authoring Guide
Schema: content/schemas/drama_module.schema.yaml
Module pool: content/modules/tier1/*.yaml
Decisions: D-023 (three-tier model), D-027 (vertical slice), D-029 (30/50/20 population), D-034 (FRIEND pattern)
Vertical slice reference: content/modules/tier1/smuggling_ring_v0_1.yaml
What Is a Tier 1 Drama Module?
Tier 1 is the authored conspiracy layer of D-023. Drama modules are the things that can go wrong — or go very right, or simply happen — beneath the surface of daily life in Sova Transit. They are:
- Hand-authored. Every event sequence, every NPC role, every outcome was written by a person.
- Pool-based. Multiple modules exist. The storyteller draws from the pool at game start and activates a subset based on the district and the storyteller's pacing decisions.
- Optional from the player's perspective. The player can play 60 minutes without engaging the ring. The ring happens anyway. D-027 criterion #4: the observe→notice→follow→discover sequence must emerge from systems, not scripts.
- Dual-lens. Every module must be experienced differently by the smuggler and detective characters. Same world, different keyholes.
What they are not:
- Not quests with markers or objectives.
- Not scripted cutscenes.
- Not balanced challenge encounters.
The storyteller uses the module as a schedule — a series of world events it will fire, and conditions it monitors to determine how the world resolves. The player is a witness and agent in a world that moves with or without them.
File Structure
content/
schemas/
drama_module.schema.yaml ← Schema reference (this file validates against it)
modules/
tier1/
smuggling_ring_v0_1.yaml ← The v0.1 vertical slice module
future_module_v0_1.yaml ← Future modules go here
One .yaml file per drama module. The storyteller's content loader scans content/modules/tier1/ at startup and adds all valid modules to the pool.
Field Reference
Identity Fields
| Field | Required | Description |
|---|---|---|
module_id |
Yes | Stable slug: {name}_v{major}_{minor}. Never reuse. Increment on breaking structural change. |
display_name |
Yes | Human-readable title for dev tooling. Not shown in-game. |
version |
Yes | Authoring version: {major}.{minor}. |
tier |
Yes | Always 1. |
description |
No | One-paragraph design summary. Authoring-only. |
notes |
No | Design rationale, cross-references. Ignored at load time. |
dual_lens |
No | How smuggler vs detective experience this module. Authoring-only. Write this first — it disciplines the design. |
Pool Metadata
Controls how the storyteller samples this module.
| Field | Required | Description |
|---|---|---|
pool.weight |
Yes | Selection probability 1–10. Higher = more likely per playthrough. Default 5. |
pool.compatible_districts |
No | District slugs. Omit for "any". |
pool.incompatible_with |
No | Module IDs that can't run concurrently. |
pool.max_concurrent |
No | Almost always 1. |
Design note on weight: Use weight to tune narrative variety, not difficulty. A weight-1 module is a rare playthrough surprise. A weight-8 module like the smuggling ring is "this is usually what's happening in Sova Transit."
Entry Conditions
Defines when the module becomes eligible for activation. ALL world-state conditions must be true. The activation trigger determines how it fires.
World-State Condition Types
| Type | Required Fields | Use When |
|---|---|---|
npc_present |
role |
The module requires a specific NPC to be in the district. |
location_accessible |
location |
The module requires a location the player can physically reach. |
fact_not_known |
fact_id |
Module shouldn't activate if a precondition has already been discovered. |
no_active_module |
module_id |
Prevents two incompatible modules running at once. |
fact_known |
fact_id, known_by |
Module requires prior knowledge to make sense. |
Player Conditions (Optional)
Player conditions are optional — modules can and should activate without player engagement as a prerequisite. Use player conditions sparingly, only when the module literally cannot function without a minimum relationship state.
Activation Triggers
| Trigger | When to Use |
|---|---|
storyteller_push |
Default. Storyteller activates on its own pacing. Most Tier 1 modules. |
proximity |
Module activates when player wanders near a key location. Useful for "stumble-upon" conspiracies. |
player_action |
Reserved for modules that require player initiation. Use rarely. |
The min_play_ticks field is load-bearing for D-027 criterion #1. At approximately 1 tick/second, 30 minutes of play ≈ 1800 ticks. Set min_play_ticks to at least 1800. The vertical slice uses 2100 to give extra breathing room.
NPC Requirements
Each module specifies its NPC slots. Roles are internal slugs used throughout the rest of the document.
| Field | Required | Description |
|---|---|---|
role |
Yes | Module-internal slug. Kebab-case. Used in event triggers and outcome conditions. |
display_hint |
No | Authoring note: who this role is narratively. |
binding |
Yes | named (specific authored NPC) or generated (any matching NPC). |
named_npc |
Conditional | Required when binding: named. Short-form canonical ID: npc:{slug}. |
axes |
Conditional | Required when binding: generated. Axis constraints the NPC must satisfy. |
must_have_pattern |
No | Optional NPC pattern (D-024 System A). |
must_have_motivation |
No | Optional NPC motivation (D-024 System B). |
is_optional |
No | Default false. If true, module runs without this slot filled (degraded experience). |
Named vs. Generated Bindings
Named bindings reference specific hand-authored NPCs from the district. All v0.1 roles are named. This is the right choice for:
- THE FRIEND NPCs (D-034) — they have authored arcs, not generic behavior
- NPCs with unique relationships in the 5-triangle web
- Roles where voice, history, and moral weight matter
Generated bindings are for future modules set in different districts or using procedurally generated NPCs. They use axis constraints:
axes:
- axis: secret
constraint: has_major_secret
- axis: contentment
constraint: min_contentment_-3 # Discontented, susceptible to opportunity
Constraint conventions: has_{value}, min_{N}, not_{value}. The server's NPC filter system interprets these.
What "Roles" Are Not
NPC roles in a drama module are not the same as NPC patterns (FRIEND, MIRROR, etc.) or motivations (HANDLER, WITNESS, etc.). Module roles are:
- Functional slots within the module's narrative (ring-leader, witness, evidence-holder)
- Module-local: "ring-leader" in the smuggling ring module ≠ "ring-leader" in any other module
- Used to reference the same NPC across events and outcomes without hardcoding the NPC slug
NPC Pattern and Motivation Reference
Patterns (System A, must_have_pattern) encode the NPC's thematic function in the player's experience:
| Pattern | What It Means |
|---|---|
FRIEND |
Emotionally complex anchor; the contradiction arc lives here (D-034) |
MIRROR |
Reflects the player character's own path back at them |
ANCHOR |
Reliable presence; stability the player can always return to |
GHOST |
Presence felt more than seen; past hangs over current events |
CATALYST |
Actions cause cascading effects on other NPCs |
THRESHOLD |
Gatekeeper; controls access to deeper information or relationships |
REMNANT |
Survivor of a prior event; carries knowledge others want buried |
SYSTEM |
Embodies an institution or faction rather than personal stakes |
NOBODY |
Genuinely flat; texture and atmosphere, no arc |
Motivations (System B, must_have_motivation) encode why the NPC acts within the module's conspiracy:
| Motivation | What It Means |
|---|---|
HANDLER |
Organizes or directs others; the operational center |
WITNESS |
Knows something they haven't decided to act on |
TURNCOAT |
Wants out, or has already switched allegiance |
CIVILIAN |
No conspiracy involvement; proximity creates moral weight |
OPERATOR |
Executes tasks; functional cog in the system |
SKEPTIC |
Doubts the conspiracy exists; useful foil for investigation |
Full definitions and canonical usage: decisions/content.md D-024.
Events
Events are world-state changes the storyteller fires. They are not scripted player experiences — they happen in the world, and the player may or may not observe them.
Sequences vs. Pools
| Structure | Use For |
|---|---|
| Sequence | Ordered narrative beats. Step N+1 becomes eligible only after step N fires. Use for character arcs. |
| Pool | Unordered ambient activity. The storyteller fires any eligible event at any time. Use for texture and background. |
The vertical slice uses:
kael_exit_arc(sequence) — Kael's ordered character arcinvestigation_pressure(sequence) — Parallel pressure escalationambient_ring_activity(pool) — Background ring business that runs throughout
Most modules should have 1-2 sequences plus 1 pool.
Event Step Fields
| Field | Required | Description |
|---|---|---|
event_id |
Yes | Unique within module. Used in outcome conditions and ticks_since_event triggers. |
label |
No | Short human-readable label for dev tooling. |
description |
No | What happens narratively. Write this first — events should have a clear observable presence. |
triggers |
Yes | ANY trigger being true fires the event. Multiple triggers = OR logic. |
effects |
No | What changes in the world. |
once |
No | Default true. Set false for repeating events (ambient discrepancies, etc.). |
sets_flag |
No | Module-internal flag set when event fires. Used in outcome conditions. |
Trigger Types
| Type | Fires When | Key Fields |
|---|---|---|
ticks_since_activation |
N ticks after module activated | ticks |
ticks_since_event |
N ticks after a previous event fired | after_event, ticks |
player_proximity |
Player near NPC/location | target_type, target, radius_tiles |
player_action |
Player interacts with target | action, target_role |
fact_known_by_player |
Player has discovered a fact | fact_id |
flag_set |
A module flag has been set | flag |
npc_mood |
NPC enters a mood state | npc_role, mood |
Design principle: events should fire without the player. Every event must have at least one tick-based trigger (ticks_since_activation or ticks_since_event). Proximity and action triggers are secondary paths that fire the event earlier if the player engages. The world moves at its own pace; the player accelerates or delays, not controls.
Effect Types
| Type | Use For |
|---|---|
npc_routine_deviation |
Visible NPC behavior change. Write this descriptively — it's what the player sees. |
fact_becomes_discoverable |
Gates a fact into the knowledge graph at Rumoured confidence. |
tell_intensify |
NPC's tell behavior becomes more frequent/pronounced. |
flag_set |
Internal state tracking. Not visible to player. |
location_state |
Something visible changes in a location. |
npc_knowledge_update |
An NPC learns something new. |
On fact_becomes_discoverable: This makes a fact discoverable, not known. The player still has to find it — through proximity, examination, dialogue, or observation. The discovery_method field is an authoring note for how: be specific enough that a Mellanie can write the dialogue or monologue that surfaces it, and a Gestalt can define the trigger condition in the fact catalog.
Fact ID convention: Use {module-slug}.{fact_name} — e.g., ring.kael_unauthorized_corridor_access. The module slug prefix namespaces the fact to avoid collisions across modules. Before creating a new fact ID, check content/global/knowledge/ to see if an equivalent fact already exists; reuse it rather than creating a duplicate.
Mapping discovery_method to D-035 trigger types: The discovery_method note should describe exactly how the player triggers fact discovery. This maps directly to the D-035 monologue trigger taxonomy (full list in decisions/content.md D-035 and content/global/enums/triggers.yaml):
| If discovery happens via… | D-035 trigger type | What to author |
|---|---|---|
| Player enters the location where something is visible | enter_location |
Monologue line flagging the anomaly on arrival |
| Player watches an NPC doing something unusual | observe_npc |
Monologue line on NPC observation; dialogue option unlocks |
| Player examines an object or terminal | observe_anomaly |
Examine verb interaction; monologue on result |
| Player witnesses two NPCs interacting | witness_interaction |
Monologue line; trust-gated gossip unlock |
| Player finishes a conversation with the relevant NPC | post_conversation |
Monologue beat after talking to the NPC |
| Player discovers a physical object (cargo, message) | discover_evidence |
Examine verb; monologue on discovery |
| Player returns to a location they've been before | return_visit |
Monologue on changed state vs. prior visit |
Write the discovery_method note to specify which of these applies — ideally two methods for redundancy (e.g., enter_location plus observe_anomaly) so players aren't funneled into a single approach.
Outcomes
Outcomes are resolution states. The storyteller checks all outcome conditions each tick after the module activates. The first matching outcome is applied.
Every module must include:
- At least one terminal outcome that represents "the investigation succeeded"
- At least one terminal outcome that represents "the conspiracy ran its course"
- Exactly one expiry outcome (
is_expiry: true) for quiet player non-engagement
Outcome Fields
| Field | Required | Description |
|---|---|---|
outcome_id |
Yes | Unique slug. |
label |
Yes | Short label. |
is_terminal |
Yes | true = module ends. false = transitional state (module can continue evolving). |
is_expiry |
No | true = this is the quiet-exit outcome. One per module. |
conditions |
No | ALL conditions must be true. See below. |
effects |
No | World changes when outcome is reached. |
On is_terminal: false: A non-terminal outcome fires its effects and applies its label, but the module remains active — the storyteller keeps checking for the next matching outcome. Use this for intermediate states where the world has visibly shifted but the situation hasn't resolved: the ring_splinters outcome in the vertical slice is non-terminal because the ring going quiet is a change of state, not a conclusion. A module with only non-terminal outcomes will run forever; always ensure there is a reachable terminal outcome (or expiry) downstream.
Outcome Conditions
| Condition | Description |
|---|---|
facts_known |
Player must know all listed facts. |
facts_not_known |
Player must NOT know any listed facts. |
flags_set |
All listed module flags must be set. |
flags_not_set |
None of listed flags may be set. |
events_fired |
All listed events must have fired. |
ticks_since_activation |
Module has been running for at least N ticks. |
Outcome Effects
| Type | Description |
|---|---|
npc_disposition |
NPC's relationship state with player shifts. |
faction_reaction |
Faction reputation change. |
location_access_change |
Location becomes restricted, locked, or open. |
fact_state |
Fact is permanently known, hidden, or destroyed. |
npc_exit |
NPC leaves the district or becomes inaccessible. |
Design Principles for Tier 1 Modules
1. The World Moves First
Events happen on a tick schedule. The player is a witness who can accelerate, delay, or redirect — not a trigger. If your module can only function if the player takes specific actions, it's a quest, not a drama module.
2. Both Characters Must Have a Story
Every event and outcome must mean something different to the smuggler and the detective. Write the dual_lens authoring field first. If you can't write both lenses, the module is character-agnostic filler — not Tier 1.
3. No Clean Resolutions
D-034 and D-027 both require moral ambiguity. The smuggling ring doesn't have a "good" ending. The detective arresting Kael is not obviously better than letting him go. Every outcome must have a cost. If one outcome is obviously correct, you've failed the design.
4. THE FRIEND Contradiction Is the Pivot
If your module involves a FRIEND-pattern NPC, the observable contradiction (D-034) must be:
- Observable from spatial positioning — not from dialogue, not from menus
- Ambiguous before context — the player sees the behavior before they understand what it means
- Irreversible once witnessed — seeing changes the relationship, even if the player does nothing
The secret meeting in corridor B-7 is the canonical example. After witnessing it, neither character can pretend they don't know what they saw.
5. Expiry Is Not Failure
The module_abandoned expiry outcome should feel like a natural ending, not a penalty. The world closes around this conspiracy without the player. That's the 70% mundane reality (D-029): most conspiracies don't get protagonists. Write the expiry description to feel melancholy but not punitive.
6. Facts, Not Flags, Drive Investigation
Facts (from global/knowledge/) are the player's knowledge graph. Flags are the storyteller's internal state tracking. The key design question: "Is this something the player knows, or is this something the storyteller tracks?" If the player knows it, it's a fact. If the storyteller tracks it, it's a flag.
Facts should be discoverable through multiple methods (observation, dialogue, examination, proximity). Never require a single specific action to surface a critical fact.
Validation and Format Rules (Gestalt)
These rules cover the schema's format constraints and the validation gaps that JSON Schema cannot enforce. All of these are also caught by Tier 2 build-time validation (make validate-content), but catching them during authoring saves a pipeline run.
ID and Slug Formats
| Field | Regex | Example |
|---|---|---|
module_id |
^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$ |
smuggling_ring_v0_1 |
sequence_id, pool_id |
^[a-z][a-z0-9_-]*$ |
kael_exit_arc |
event_id |
^[a-z][a-z0-9_-]*$ |
kael_goes_cold |
outcome_id |
^[a-z][a-z0-9_-]*$ |
ring_exposed |
sets_flag / flag references |
^[a-z][a-z0-9_-]*$ |
kael_behavior_changed |
role (npc slot) |
^[a-z][a-z0-9-]*$ |
ring-member-exiting |
named_npc |
^npc:[a-z][a-z0-9-]*$ |
npc:kael-davan |
version |
^[0-9]+\\.[0-9]+$ |
0.1 |
Note the difference: event_id, outcome_id, sequence_id, and flags use underscores and hyphens ([a-z0-9_-]*). NPC role slugs use hyphens only ([a-z0-9-]*). Mixing them in wrong fields will fail schema validation.
Flag Naming Convention
Flags are module-internal state. Every flag name that appears in sets_flag on an event must also appear in at least one outcome's flags_set or flags_not_set condition — or the flag serves no purpose. Convention:
- Use
snake_casewith underscores:kael_behavior_changed,voss_pressure_applied - Name by what happened, not what it enables:
handler_pressure_appliednotkael_ready_to_flee - Flags set by events accumulate — they are never automatically cleared
- A flag set by a time-triggered event (not player-triggered) cannot be used as an expiry gate (see "Common Mistakes" below)
Axis Constraint Syntax (Generated NPC Bindings)
The constraint field in axes is a freeform string. The storyteller's NPC filter interprets it. Convention (author responsibility — schema does not enforce):
| Prefix | Example | Meaning |
|---|---|---|
has_ |
has_major_secret |
NPC axis value includes this descriptor |
min_contentment_ |
min_contentment_-3 |
Contentment axis value ≤ N (more discontented) |
not_ |
not_combat_trained |
Axis value does NOT include this descriptor |
is_ |
is_ring_member |
Boolean flag set on NPC profile |
What JSON Schema Cannot Validate (Tier 2 Catches These)
| Issue | Where to Look | Impact |
|---|---|---|
fact_id not defined in global/knowledge/ |
Effect fact_becomes_discoverable, outcome facts_known |
Fact silently never becomes discoverable |
sets_flag name not referenced in any outcome condition |
Event sets_flag |
Flag is set but never meaningful |
flags_set/flags_not_set reference flag never set by any event |
Outcome conditions | Condition permanently true or false |
ticks_since_event.after_event references unknown event_id |
Event trigger | Trigger never fires |
named_npc ID doesn't exist in district NPC profiles |
NPC requirements | Load-time failure |
Multiple outcomes have is_expiry: true |
Outcomes list | Undefined storyteller behavior |
faction in outcome effects not in global/factions/ |
Outcome effects | Effect silently ignored |
The Expiry Condition Pitfall
This is the most common authoring mistake for expiry outcomes. The expiry condition must use facts_not_known, not flags_not_set. Reason:
Events with ticks_since_activation triggers fire automatically without player engagement. If an auto-firing event sets a flag, and your expiry checks flags_not_set: [that_flag], the expiry condition becomes permanently false after the event fires — the module can never expire quietly.
Wrong:
# kael_goes_cold fires automatically at tick 300, sets kael_behavior_changed
# This expiry can never fire after tick 300
- outcome_id: module_abandoned
is_expiry: true
conditions:
flags_not_set:
- kael_behavior_changed # This flag is always set by tick 300
ticks_since_activation: 5400
Correct:
# facts_not_known gates on player investigative action, not auto-fired events
- outcome_id: module_abandoned
is_expiry: true
conditions:
facts_not_known:
- "ring.cargo_discrepancy_pattern" # Only known if player examined terminal
- "ring.kael_unauthorized_corridor_access" # Only known if player observed Kael
ticks_since_activation: 5400
Checklist Before Submitting a New Module
module_iduses correct format and doesn't collide with existing modulesdual_lensis written and shows clearly different experiences per charactermin_play_ticks≥ 1800 (30 minutes at 1 tick/second)- Every event sequence step has at least one tick-based trigger
- Every
fact_becomes_discoverableeffect has adiscovery_methodnote - The module includes at least one named FRIEND-pattern NPC (for v0.1 modules)
- Expiry outcome is present (
is_expiry: true) with conditions gated onfacts_not_known, NOTflags_not_set - All outcomes have been reviewed for moral ambiguity — no "obviously correct" resolution
npc_requirementscovers every role referenced in events and outcomes- All fact IDs used in effects/conditions exist in
global/knowledge/ - All
sets_flagnames appear in at least one outcome condition - All
flags_set/flags_not_setnames are set by at least one event'ssets_flag make validate-contentpasses
Cross-References
| Topic | Location |
|---|---|
| Three-tier content model | decisions/content.md D-023 |
| NPC 10-axis model | decisions/content.md D-024 |
| Vertical slice scope | decisions/scope.md D-027 |
| Population ratios | decisions/content.md D-029 |
| THE FRIEND pattern | decisions/content.md D-034 |
| Smuggling ring module | content/modules/tier1/smuggling_ring_v0_1.yaml |
| Drama module schema | content/schemas/drama_module.schema.yaml |
| Fact catalog | content/global/knowledge/ |
| NPC profiles (v0.1) | content/campaigns/main/systems/krenn/ |
| Storyteller stub | server/src/storyteller/mod.rs |
Ticket #158 — Tier 1 drama module schema. Paula (dramatic structure), Gestalt (schema format), Mellanie (authoring review).