Genre: Narrative Horror
Engine: Unity 2021 LTS+
Narrative Engine: Ink (Inkle)
Dead Air — A project by Michele Grimaldi | E-C-H-O SYSTEMS
- DEAD_AIR_STORY_ARCHITECTURE
- DEAD_AIR_CONCEPT
- DEAD AIR - Scene 1
- DEAD AIR - Observer Pattern Functions and Role
- DEAD AIR - Main Menu State Pattern
- DEAD AIR's Feedback
- DEAD AIR - Light GDD
- What the Game Does
- How the Code Works
- File Structure
- Event Channels
- How to Write a Story
- How to Add a New Tag
- System Visual Schema
- Systems and Their Roles
- Troubleshooting
- Quick Reference
- Upcoming Improvements
- Contact
- Audio Optimization
You are a 911 operator in the 1990s. You answer emergency calls that grow increasingly disturbing.
Gameplay:
- Choose a call from the menu
- Listen and read the dialogue
- Choose how to respond
- The story continues based on your choices
- Note: Unity automatically optimizes audio based on folder location (see Section 13).
The game uses a tag-driven system: you write the story in .ink files, add special tags, and the game reacts automatically.
INK FILE (story + tags)
↓
PARSER (reads the tags)
↓
CHANNELS (inter-system communication)
↓
MANAGER (audio, UI, voice)
↓
IN-GAME EFFECT
Practical example:
911, what's your emergency? # speaker:ward # voice:ward_01
Text appears on screen + character voice audio plays| Tag | What It Does | Example |
|---|---|---|
#speaker:{name} |
Changes text color | #speaker:iris |
#voice:{file} |
Plays character voice | #voice:iris_01 |
#sfx:{file} |
Sound effect | #sfx:phone_ring |
#amb:{file} |
Ambient music (loop) | #amb:dispatch_night |
#amb:stop |
Stops ambient music | #amb:stop |
#ui:{command} |
Special UI command | #ui:dead_air_screen |
Note (from March 2026): The #speaker and #ui tags internally use type-safe enums (SpeakerType, UICommandType) to prevent errors and improve maintainability. See Section 6.5 for details.
Assets/
├── Scripts/
│ ├── Narrative/
│ │ ├── StoryManager.cs → Loads Ink and coordinates everything
│ │ └── DialogueParser.cs → Reads tags from the Ink file
│ │
│ ├── UI/
│ │ ├── DialogueUI.cs → Displays text and choices
│ │ └── ChoiceButton.cs → Button for player choices
│ │
│ ├── Audio/
│ │ ├── AudioManager.cs → SFX and Ambience
│ │ └── VoiceManager.cs → Character voices
│ │
│ └── Events/
│ ├── Channels/ → Communication types
│ │ ├── StringEventChannel.cs
│ │ ├── VoidEventChannel.cs
│ │ └── ... (others)
│ │
│ └── ScriptableObjects/ → Communication channels (14 .asset files)
│ ├── DialogueLineChannel.asset
│ ├── SFXRequestedChannel.asset
│ └── ... (others)
│
├── Ink/
│ └── dead_air_demo_en.ink → Main story
│
├── Audio
An Audio Library is a ScriptableObject that holds a collection of audio files with associated IDs. It allows you to:
- Share audio clips across different scenes
- Organize audio by category (SFX, Ambience, Voice)
- Swap audio without modifying code
Example: Voice_Demo_Iris.asset contains all 10 of Iris's voice clips (iris_01 → iris_10).
INK FILE: #voice:iris_01
↓
StoryManager reads tag
↓
Raises event on VoiceRequestedChannel("iris_01")
↓
VoiceManager receives event
↓
VoiceManager searches "iris_01" in Voice_Demo_Iris.asset
↓
Plays iris_01.wav
| Library Type | Purpose | Example |
|---|---|---|
| SFX Library | Short sound effects | SFX_Demo.asset → phone_ring, glass_break |
| Ambience Library | Ambient loops | Ambience_Demo.asset → dispatch_night |
| Voice Library | Character voices | Voice_Demo_Iris.asset → iris_01...iris_10 |
STEP 1 — Create Library:
Assets/Audio/Libraries/ → Right Click
→ Create → DEAD AIR → Audio → Audio Clip Library
→ Rename: "Voice_MyStory"
STEP 2 — Populate Library:
Voice_MyStory.asset Inspector:
├─ Library Name: "My Story Voice"
├─ Description: "Character voices for story X"
└─ Clips (Array):
├─ [0] id: "character_01", clip: character_01.wav
├─ [1] id: "character_02", clip: character_02.wav
└─ ...
STEP 3 — Assign to Manager:
Scene → VoiceManager Inspector
→ Voice Libraries (Array)
→ Drag "Voice_MyStory.asset"
STEP 4 — Use in Ink:
Hello there! # voice:character_01Zero C# code changes required.
| Approach | New Story Setup | Cross-Scene Reuse | Maintainability |
|---|---|---|---|
| Inspector Array (old) | 15 min (reassign everything) | No (duplication) | Difficult |
| SO Libraries (current) | 2 min (drag & drop) | Yes (shared) | Easy |
It is a "communication bridge" between different systems. Instead of having systems talk directly to each other, we use these bridges.
Advantages:
- Systems are unaware of each other (you can modify one without breaking the others)
- Each system can be tested in isolation
- No memory leaks
- Easy to debug from the Unity Inspector
StoryManager reads the tag #sfx:phone_ring
↓
StoryManager raises the event on the "SFXRequestedChannel"
↓
AudioManager is listening on that channel
↓
AudioManager receives "phone_ring" and plays the sound
Dialogue:
DialogueLineChannel→ Text to displaySpeakerLineChannel→ Who is speaking + textChoicesPresentedChannel→ List of available choices
Audio:
SFXRequestedChannel→ Sound effect to playAmbienceStartChannel→ Ambience to startAmbienceStopChannel→ Stop ambienceVoiceRequestedChannel→ Voice to playVoiceStopChannel→ Stop voice
Player Input:
ContinueRequestedChannel→ Player clicks to continueChoiceSelectedChannel→ Player selects an option
Other:
UICommandChannel→ Special UI commandsStoryEndChannel→ Story endedVoiceStartedChannel→ Voice started (with duration)VoiceFinishedChannel→ Voice finished
Location: Assets/Scripts/Events/ScriptableObjects/
// IRIS CALL - The Bear
-> intro
=== intro ===
# amb:dispatch_night
# sfx:phone_ring
2 AM. Line 3 lights up.
+ [ANSWER]
-> answer
=== answer ===
# sfx:phone_pickup
911, what's the address of your emergency? # speaker:ward
Hi... I need help with the Bear. # speaker:iris # voice:iris_01
+ [What's your name?]
-> ask_name
+ [Where are you calling from?]
-> ask_location
=== ask_name ===
My name is Iris. # speaker:iris # voice:iris_02
-> END
=== ask_location ===
I'm... I'm at home. # speaker:iris # voice:iris_03
-> END| Type | Format | Unity Optimization | Example |
|---|---|---|---|
| Voice | {speaker}_{number}.wav |
ADPCM, Mono, Optimize SR | iris_01.wav |
| SFX | {description}.wav |
ADPCM, Mono, Optimize SR | phone_ring.wav |
| Ambience | {location}.ogg |
Vorbis 70%, Streaming, Stereo | dispatch_night.ogg |
| Music | {mood}.ogg |
Vorbis 80%, Streaming, Stereo | tension_loop.ogg |
Note: Unity automatically optimizes audio based on folder location (see Section 13 - Audio Optimization).
Unity → Project → Assets/Scripts/Events/ScriptableObjects
→ Right Click → Create → DEAD AIR → Events → String Event Channel
→ Rename: "MusicRequestedChannel"
Add at the top (after line 25):
private const string TAG_MUSIC = "music:";Add to the ParsedLine struct (after line 60):
public string Music;
public bool HasMusic;Add to the ParseTags() method (after line 100):
else if (trimmedTag.StartsWith(TAG_MUSIC))
{
result.Music = ExtractValue(trimmedTag, TAG_MUSIC);
result.HasMusic = !string.IsNullOrEmpty(result.Music);
}Add field (after line 50):
[SerializeField] private StringEventChannel musicRequestedChannel;Add to the ProcessLine() method (after line 180):
if (parsed.HasMusic && musicRequestedChannel != null)
{
musicRequestedChannel.RaiseEvent(parsed.Music);
}using UnityEngine;
using DeadAir.Events;
public class MusicManager : MonoBehaviour
{
[SerializeField] private AudioSource musicSource;
[SerializeField] private StringEventChannel musicRequestedChannel;
private void OnEnable()
{
if (musicRequestedChannel != null)
musicRequestedChannel.Subscribe(PlayMusic);
}
private void OnDisable()
{
if (musicRequestedChannel != null)
musicRequestedChannel.Unsubscribe(PlayMusic);
}
private void PlayMusic(string musicId)
{
// Load and play music
Debug.Log($"Playing music: {musicId}");
}
}-
Hierarchy → Create Empty → "MusicManager"
-
Add Component → MusicManager
-
Inspector:
- Assign AudioSource
- Drag "MusicRequestedChannel" into the field
-
StoryManager Inspector:
- Drag "MusicRequestedChannel" into the field
=== tense_moment ===
# music:tension_loop
Ward feels something is wrong.Done! Estimated time: 30 minutes.
UI commands (#ui:{command}) are special instructions in the Ink file that trigger UI behaviors such as:
- Showing special screens (
#ui:dead_air_screen) - Returning to the menu (
#ui:return_to_menu) - Showing overlays, transitions, or other custom UI effects
From March 2026, UI commands use type-safe enums instead of fragile strings. This prevents typos and makes the code more maintainable.
INK FILE: #ui:dead_air_screen
↓
DialogueParser.cs: "dead_air_screen" (string) → UICommandType.DeadAirScreen (enum)
↓
StoryManager.cs: UICommandType.DeadAirScreen → "dead_air_screen" (string for channel)
↓
UICommandChannel: Raises "dead_air_screen"
↓
DialogueUI.cs: "dead_air_screen" (string) → UICommandType.DeadAirScreen (enum)
↓
DialogueUI.cs: Switch on enum → ShowDeadAirScreen()
Why this flow?
- Event Channels still use
stringfor backward compatibility - Parser and UI convert to enum for type safety and exhaustiveness check
- A typo in the Ink file generates a runtime warning (eg. "Unknown UI command: dead_air")
File: Assets/Scripts/Narrative/DialogueParser.cs
FIND the UICommandType enum (around line 28):
public enum UICommandType
{
None = 0, // Default
DeadAirScreen = 1, // #ui:dead_air_screen
ReturnToMenu = 2 // #ui:return_to_menu
}ADD the new command (example: pause screen):
public enum UICommandType
{
None = 0, // Default
DeadAirScreen = 1, // #ui:dead_air_screen
ReturnToMenu = 2, // #ui:return_to_menu
PauseScreen = 3 // #ui:pause_screen ← NEW COMMAND
}IMPORTANT RULES:
None = 0must always be the first value (safe default)- Number progressively: 1, 2, 3, 4...
- Add a comment with the corresponding Ink tag
File: Assets/Scripts/Narrative/DialogueParser.cs
FIND the ParseTags method (around line 180), UI TAG block:
else if (trimmedTag.StartsWith(TAG_UI))
{
string? uiValue = ExtractValue(trimmedTag, TAG_UI);
result = new ParsedLine
{
// ... other fields ...
UICommand = uiValue?.ToLowerInvariant() switch
{
"dead_air_screen" => UICommandType.DeadAirScreen,
"return_to_menu" => UICommandType.ReturnToMenu,
_ => UICommandType.None
}
};
}ADD the case for the new command:
UICommand = uiValue?.ToLowerInvariant() switch
{
"dead_air_screen" => UICommandType.DeadAirScreen,
"return_to_menu" => UICommandType.ReturnToMenu,
"pause_screen" => UICommandType.PauseScreen, // ← ADD THIS
_ => UICommandType.None
}File: Assets/Scripts/Narrative/StoryManager.cs
FIND the ProcessLine method (around line 233), UI EVENTS block:
if (parsed.HasUICommand)
{
string? commandString = parsed.UICommand switch
{
UICommandType.DeadAirScreen => "dead_air_screen",
UICommandType.ReturnToMenu => "return_to_menu",
_ => null
};
if (commandString != null)
uiCommandChannel.RaiseEvent(commandString);
}ADD the case for the new command:
string? commandString = parsed.UICommand switch
{
UICommandType.DeadAirScreen => "dead_air_screen",
UICommandType.ReturnToMenu => "return_to_menu",
UICommandType.PauseScreen => "pause_screen", // ← ADD THIS
_ => null
};File: Assets/Scripts/UI/DialogueUI.cs
FIND the HandleUICommand method (around line 193):
private void HandleUICommand(string command)
{
UICommandType commandType = command?.ToLowerInvariant() switch
{
"dead_air_screen" => UICommandType.DeadAirScreen,
"return_to_menu" => UICommandType.ReturnToMenu,
_ => UICommandType.None
};
switch (commandType)
{
case UICommandType.DeadAirScreen:
ShowDeadAirScreen();
break;
case UICommandType.ReturnToMenu:
QuitApplication();
break;
case UICommandType.None:
Debug.LogWarning($"[DialogueUI] Unknown UI command: {command}");
break;
}
}ADD the parsing and the case:
// STEP 4.1 — Add parsing
UICommandType commandType = command?.ToLowerInvariant() switch
{
"dead_air_screen" => UICommandType.DeadAirScreen,
"return_to_menu" => UICommandType.ReturnToMenu,
"pause_screen" => UICommandType.PauseScreen, // ← ADD THIS
_ => UICommandType.None
};
// STEP 4.2 — Add case
switch (commandType)
{
case UICommandType.DeadAirScreen:
ShowDeadAirScreen();
break;
case UICommandType.ReturnToMenu:
QuitApplication();
break;
case UICommandType.PauseScreen: // ← ADD THIS
ShowPauseScreen();
break;
case UICommandType.None:
Debug.LogWarning($"[DialogueUI] Unknown UI command: {command}");
break;
}STEP 4.3 — Create the handler method:
private void ShowPauseScreen()
{
if (_pauseScreen != null)
{
StopTypewriter();
HideContinueIndicator();
_pauseScreen.SetActive(true);
Debug.Log("[DialogueUI] Pause screen active");
}
}=== critical_moment ===
Ward, you need to make a decision. Now.
* [Pause and think]
# ui:pause_screen
→ ENDDone! The command is now type-safe end-to-end.
| Aspect | Before (Strings) | After (Enums) |
|---|---|---|
| Typo Protection | "dead_air_screeen" = silent fail |
Compile error if wrong enum |
| Refactoring | Manual Find/Replace | Automatic IDE rename |
| Exhaustiveness | Switch can miss cases | Compiler warns on missing case |
| Autocomplete | None | IDE suggests enum values |
| Debugging | "Unknown command: X" | Precise stacktrace + enum value |
- STEP 1: Add value to
UICommandTypeenum (DialogueParser.cs) - STEP 2: Add string → enum parsing (DialogueParser.cs,
ParseTags) - STEP 3: Add enum → string conversion (StoryManager.cs,
ProcessLine) - STEP 4: Add parsing + case (DialogueUI.cs,
HandleUICommand) - STEP 5: Implement handler method (DialogueUI.cs, eg.
ShowPauseScreen) - TEST: Use
#ui:{command}in Ink file and verify it works
Estimated time: 10 minutes per command.
Why 4 modification points?
- DialogueParser: Converts Ink string → enum (single source of truth)
- StoryManager: Converts enum → string for Event Channel (legacy compatibility)
- DialogueUI: Converts string → enum for type safety + implements logic
Future: Migrating Event Channels to use UICommandType directly would eliminate STEP 3 and 4.1.
Similar Pattern: Use the same strategy for SpeakerType enum when adding new characters.
PLAYER CLICKS "CONTINUE"
↓
DialogueUI raises on ContinueRequestedChannel
↓
StoryManager receives event
↓
StoryManager advances Ink story
↓
StoryManager reads tags (#speaker:iris #voice:iris_01)
↓
StoryManager raises on SpeakerLineChannel and VoiceRequestedChannel
↓
DialogueUI receives from SpeakerLineChannel → Displays text
VoiceManager receives from VoiceRequestedChannel → Plays audio
No system talks directly to another — everything passes through the Channels
| System | What It Does | Listens (IN) | Raises (OUT) |
|---|---|---|---|
| StoryManager | Coordinates everything, reads Ink | ContinueRequested, ChoiceSelected | DialogueLine, SpeakerLine, SFX, Ambience, Voice, UI, StoryEnd |
| DialogueUI | Displays text and choices | DialogueLine, SpeakerLine, ChoicesPresented, UI, StoryEnd | ContinueRequested, ChoiceSelected, VoiceStop |
| AudioManager | SFX and Ambience (via Libraries) | SFXRequested, AmbienceStart, AmbienceStop | None |
| VoiceManager | Character voices (via Libraries) | VoiceRequested, VoiceStop | VoiceStarted, VoiceFinished |
Checklist:
- Tag written correctly in the .ink file? (
#voice:iris_01NOT# voice: iris_01) - Channel asset created?
- Channel assigned in StoryManager?
- Channel assigned in the listening Manager?
- Audio file present in the Media folder?
Checklist:
- AudioManager has an AudioSource assigned?
- AudioManager has the correct Library assigned? (Inspector → SFX Libraries / Ambience Libraries)
- Does the Library contain the clip with the correct ID? (Open Library asset → verify ID)
- Does the file name match the ID in the Library? (
#sfx:phone_ring→ ID "phone_ring" in Library) - AudioSource Volume > 0?
- Console shows
[AudioClipLibrary] X → N clips loaded?
If Console shows [AudioManager] Total loaded: 0 SFX:
- Verify the Library is assigned in AudioManager's Inspector
- Verify the Library contains clips (is not empty)
Checklist:
- DialogueUI has TextMeshPro assigned in the
_dialogueTextfield? - DialogueUI has the
dialogueLineChannelassigned? - Canvas is active in the scene?
| File | What It Contains |
|---|---|
DialogueParser.cs |
Parsing of all tags |
StoryManager.cs |
General coordination, story advancement |
DialogueUI.cs |
Text and choice display |
AudioManager.cs |
SFX and Ambience |
VoiceManager.cs |
Character voices |
- Serialized fields:
[SerializeField] private TypeName _fieldName; - Public methods:
PascalCase(eg.PlayMusic) - Private methods:
PascalCase(eg.HandleMusicRequested) - Event handlers:
Handleprefix (eg.HandleDialogueLine) - Constants:
ALL_CAPS(eg.TAG_MUSIC)
private void OnEnable()
{
// Subscribe to channels here
channel.Subscribe(Handler);
}
private void OnDisable()
{
// ALWAYS Unsubscribe to avoid memory leaks
channel.Unsubscribe(Handler);
}Completed:
- Audio Libraries system (ScriptableObject-based)
- Singleton AudioManager with hot-reload across scenes
- Audio format optimization (ADPCM, Vorbis, Streaming)
- Type-Safe Enums for Speaker and UI Commands (April 2026)
- DialogueParser refactoring: readonly struct, nullable strings, derived properties
To Do:
- Typed Event Channels (native SpeakerType, UICommandType)
- Auto-populate Libraries from folders (Editor script)
- Save progress system
- Full main menu
- Multiple stories system (selection menu)
- Library Validation Tool (duplicate ID check)
Possible New Tags:
#music:{id}→ Background music#camera_shake:{intensity}→ Camera shake#fade:{type}→ Screen transitions
Developer: Michele Grimaldi
Studio: E-C-H-O SYSTEMS
Project: DEAD AIR
DEAD AIR uses type-specific optimizations following Unity best practices:
| Type | Load Type | Compression | Sample Rate | Mono/Stereo | Memory |
|---|---|---|---|---|---|
| Voice | Decompress On Load | ADPCM | Optimize (~22 kHz) | Mono | ~120 KB per 5s |
| SFX | Decompress On Load | ADPCM | Optimize (~22 kHz) | Mono | ~50 KB per 2s |
| Ambience | Streaming | Vorbis 70% | 44.1 kHz | Stereo | ~200 KB buffer |
| Music | Streaming | Vorbis 80% | 44.1 kHz | Stereo | ~200 KB buffer |
ADPCM for Voice/SFX:
- 3.5x compression vs PCM
- Minimal CPU overhead (+5% vs PCM)
- 95% quality (dialogue tolerates artifacts)
- Zero latency (decompressed in RAM)
Vorbis Streaming for Ambience/Music:
- ~10x compression vs PCM
- Fixed memory (~200 KB buffer, independent of clip duration)
- Streaming from disk (no memory spikes)
- 90-95% quality (acceptable for ambient loops)
Optimize Sample Rate:
- Unity analyzes audio frequencies
- Automatically reduces sample rate when possible (eg. 44.1 kHz → 22 kHz)
- 50% memory saving with no perceptible quality loss
Memory (Idle): ~2 MB (target: <5 MB) OK
Memory (Playing): ~5 MB (target: <10 MB) OK
CPU Audio: <1 ms/frame (target: <2 ms) OK
Disk Size: ~15 MB (target: <50 MB) OK
Load Time: <20 ms (target: <50 ms) OK
Unity automatically applies import settings based on the file's folder:
- Files in
Audio/Voice/→ ADPCM, Mono, Optimize - Files in
Audio/SFX/→ ADPCM, Mono, Optimize - Files in
Audio/Ambience/→ Vorbis 70%, Streaming, Stereo
No manual setup required (managed by the AudioImportProcessor script).
Document Version: 2.2 (April 2026)
Architecture: Event Channels + Audio Libraries (ScriptableObject) + Type-Safe Enums
Last Modified: April 6, 2026
Build Version: 0.8 (C# Types Refactoring)
