Scoped the vertical slice: 16 EntityKnowledge keys, v0.1 mechanical NPC mapping, YAML content format, 7-verb interaction model, server- authoritative pause, 38 tickets created across copy/server/client/ci. 20 decisions (D-042 through D-061). 8 agents, 2 rounds + closing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
18 KiB
Round 2 Response: Stig (UI Developer)
v0.1 Content Scoping Workshop
Task 1: Single-Action Prompt Designed for Multi-Verb Extension
The lead confirmed: v0.1 ships single-action. Multi-verb is v0.2. I need to design the prompt system so the upgrade path is clean — no throwaway architecture.
How it works in v0.1
The server sends an InteractionOptions payload per entity when the player enters interaction range. v0.1 format:
InteractionOptions {
entity_id: "kael_davan",
display_name: "Kael Davan",
primary: { verb: "Talk", keybind: "E" },
secondary: null
}
The client renders:
[E] Talk
Kael Davan
[====]
One prompt. One key. The player approaches, reads, presses. Done.
For objects:
InteractionOptions {
entity_id: "manifest_terminal_03",
display_name: "Manifest Terminal",
primary: { verb: "Examine", keybind: "E" },
secondary: null
}
Renders as:
[E] Examine
Manifest Terminal
[====]
Same pattern. The verb changes. The keybind stays the same. The player learns one input: "E means interact."
How it extends to v0.2 (multi-verb)
The server payload grows:
InteractionOptions {
entity_id: "kael_davan",
display_name: "Kael Davan",
primary: { verb: "Talk", keybind: "E" },
secondary: { verb: "Observe", keybind: "F" },
actions: [
{ verb: "Talk", keybind: "E" },
{ verb: "Observe", keybind: "F" }
]
}
The client renders both:
[E] Talk [F] Observe
Kael Davan
[====]
What changes in the client:
- The prompt renderer reads
actions[]instead of justprimary - Layout goes from single-label to horizontal label row
- Keybind display pluralizes
What does NOT change:
- The prompt's position (world-space, entity-tracked)
- The fade behavior (proximity-triggered)
- The input routing (keybind → server action request)
- The overall visual language (bracket-key + verb)
The v0.1 client code:
# InteractionPrompt.gd
func update_prompt(options: InteractionOptions) -> void:
if options == null:
hide()
return
# v0.1: show primary action only
# v0.2: iterate options.actions[] for multi-label
_label.text = "[%s] %s" % [options.primary.keybind, options.primary.verb]
_name_label.text = options.display_name
_target_entity = options.entity_id
show()
The options.actions[] field exists in the protocol from day one. The v0.1 client ignores it. The v0.2 client reads it. No protocol change needed.
Architectural notes for Dudley/Tyre
The InteractionOptions should be part of the ObserverSnapshot update — not a separate message. When the player is near an entity, the snapshot includes the interaction payload. When they move away, it's null. The client never requests interaction options — they arrive passively as part of perception.
This means the server's InteractionResolver system evaluates available actions every tick for entities in the player's interaction radius. In v0.1 that's a simple lookup (NPC → Talk, Object → Examine). In v0.2 it evaluates relationship state, knowledge, and context to determine the full action set.
For Gestalt's Examine-vs-Talk question: The server models both verbs internally from v0.1 onward. It just surfaces only the primary one to the client. If the server determines that "Observe" is more relevant than "Talk" at a given moment (e.g., player has already talked to this NPC this shift, or NPC is currently in an anomalous state), the server can swap the primary. Single-action doesn't mean single-verb-forever — it means the server picks the best one.
Task 2: Pause System State Machine
Three states. Clean transitions. No ambiguity.
States
RUNNING ← normal gameplay, full sim speed
OVERLAY_ACTIVE ← UI overlay open (knowledge panel, dialogue), 50% sim speed
PAUSED ← spacebar pause, 0% sim speed
Transitions
[Tab] [Space]
RUNNING ──────────────> OVERLAY_ACTIVE ──────────────> PAUSED
^ | |
| [Tab] | [Space] |
+────────────────────────+ | |
| | |
+─────────────────────────────────────+ |
| [Space] |
+─────────────────────────────────────────────────────+
Rules
| Input | From RUNNING | From OVERLAY_ACTIVE | From PAUSED |
|---|---|---|---|
| [Space] | → PAUSED | → PAUSED | → previous state (RUNNING or OVERLAY_ACTIVE) |
| [Tab] (knowledge panel) | → OVERLAY_ACTIVE | → RUNNING (closes panel) | → PAUSED + OVERLAY_ACTIVE (panel opens, stays paused) |
| [E] (interaction/dialogue) | → OVERLAY_ACTIVE (if dialogue starts) | stays OVERLAY_ACTIVE | no effect |
| [Esc] | no effect | → RUNNING (closes overlay) | → previous state |
Key behaviors:
-
Spacebar is king. It always toggles to/from PAUSED. Period. No other input overrides spacebar. If the player is in dialogue and hits space, the game pauses with the dialogue still visible.
-
Overlay = 50% speed, not 0%. When the knowledge panel is open, the world still breathes. NPCs still move (slowly). Time still passes (slowly). This prevents the knowledge panel from becoming a freeze-frame intelligence tool. The player can check their notes, but the world doesn't wait.
-
Spacebar from OVERLAY_ACTIVE goes to PAUSED, not RUNNING. If I'm reading my knowledge panel and I want the world to stop completely, I hit space. I don't have to close the panel first. Conversely, hitting space from PAUSED with an overlay open returns to OVERLAY_ACTIVE (50% speed with panel still visible), not RUNNING.
-
Dialogue is an overlay. When in conversation, sim runs at 50%. NPCs outside the conversation keep moving. This means: while you're talking to Kael, Sera might leave the bar. You could miss it. That's the game. If you want to freeze everything, pause.
-
Multiple overlays don't stack speed reductions. If dialogue is active (50%) and the player opens the knowledge panel (also 50%), sim stays at 50%. Not 25%.
Implementation (client-side)
# PauseManager.gd
enum SimState { RUNNING, OVERLAY_ACTIVE, PAUSED }
var _state: SimState = SimState.RUNNING
var _pre_pause_state: SimState = SimState.RUNNING
var _active_overlays: int = 0 # count of open overlays
func _input(event: InputEvent) -> void:
if event.is_action_pressed("pause"): # spacebar
_toggle_pause()
elif event.is_action_pressed("knowledge_panel"): # tab
_toggle_knowledge_panel()
func _toggle_pause() -> void:
if _state == SimState.PAUSED:
_set_state(_pre_pause_state)
else:
_pre_pause_state = _state
_set_state(SimState.PAUSED)
func _toggle_knowledge_panel() -> void:
if _knowledge_panel_open:
_close_knowledge_panel()
else:
_open_knowledge_panel()
func _open_knowledge_panel() -> void:
_knowledge_panel_open = true
_active_overlays += 1
if _state == SimState.RUNNING:
_set_state(SimState.OVERLAY_ACTIVE)
# if PAUSED, stay PAUSED — panel opens visually but sim doesn't resume
func _close_knowledge_panel() -> void:
_knowledge_panel_open = false
_active_overlays -= 1
if _active_overlays == 0 and _state == SimState.OVERLAY_ACTIVE:
_set_state(SimState.RUNNING)
func _set_state(new_state: SimState) -> void:
_state = new_state
match new_state:
SimState.RUNNING:
Engine.time_scale = 1.0
SimState.OVERLAY_ACTIVE:
Engine.time_scale = 0.5
SimState.PAUSED:
Engine.time_scale = 0.0
Question for Dudley
Does the server need to know about pause state? If the client sets Engine.time_scale = 0.0, does the server keep ticking and the client just stops rendering? Or does the client need to signal the server to pause its tick loop?
For single-player (v0.1), I think the client controls this entirely. The server runs as a subprocess — if the client freezes its consumption of server ticks, the server's output buffer fills and it naturally stalls. But this depends on the IPC design. Dudley should confirm.
For the 50% overlay speed: does the client request half-speed ticks from the server, or does it receive full-speed ticks and render every other one? I lean toward the client setting the tick request rate. Cleaner.
Task 3: Monologue Display Constraints (Confirmed)
Mellanie needs hard numbers. Here they are.
Display constraints
| Parameter | Value | Rationale |
|---|---|---|
| Max characters | 160 | Two lines at ~80 chars/line at reference font size. Fits 40% viewport width. |
| Max lines | 2 | One breath. Monologue competes with gameplay — the player is moving, looking, deciding. Two lines is a glance. Three is a paragraph. |
| Display duration (base) | 4.0 seconds | Long enough to read 160 characters at comfortable pace (~40 chars/sec reading speed for on-screen text). |
| Display duration (short, <80 chars) | 3.0 seconds | Short lines don't need 4 seconds. Scale linearly. |
| Display duration (urgent chime) | 5.0 seconds | Anomaly observations deserve an extra beat. The player needs time to register that something changed. |
| Fade in | 0.3 seconds | Fast enough to not feel laggy. Slow enough to not pop. |
| Fade out | 0.5 seconds | Slightly slower than fade-in. The thought lingers. |
| Interruption fade | 0.15 seconds | When a new monologue replaces the current one, the old one exits fast. No collision. |
| Cooldown between lines | 2.0 seconds minimum | Two monologues back-to-back feel like a data dump. The gap is where the player absorbs. |
| Max queue depth | 1 | If two triggers fire simultaneously, the higher-priority one displays and the other is dropped. No stacking, no scroll-back. The character's mind moves on. |
Priority ordering (when two triggers fire at once)
1. observe_anomaly ← the game is telling you something important
2. witness_interaction ← the game is telling you something happened
3. discover_evidence ← you found something
4. post_conversation ← reaction to what just happened
5. hear_sound ← environmental awareness
6. observe_npc ← identification / emotional reaction
7. enter_location ← atmospheric
8. return_visit ← atmospheric
9. time_idle ← lowest — reflective, only when nothing else is happening
observe_anomaly always wins. If THE FRIEND's contradiction fires at the same time as an enter_location line, the contradiction shows. The atmospheric line is gone. The player will never know it existed. That's fine — the important thing happened.
What Mellanie should write to
Target: 80-120 characters for most lines. 160 is the hard maximum — use it rarely. The best monologue lines are 40-80 characters. One punchy thought.
Examples from the authoring guide, with character counts:
"Morning shift. Recycled air and cargo lubricant. Home sweet home."— 66 chars. Perfect. One line, one breath."Kael's here. Good — I was starting to worry."— 47 chars. Even better. Fast, emotional."Dock worker Davan — lattice activity spiked. Three pings in two minutes. Expecting a message? Or checking for surveillance?"— 124 chars. This pushes into line 2 but earns it — the detective's analytical voice needs the full thought."He looked left. He always looks left when he's making something up. Two years I've known that tell. Kael is lying to me."— 121 chars. This is a climax moment. Two lines justified.
Rule of thumb: If it fits on one line (~80 chars), it should be one line. Two lines are for moments that matter.
Font specification (for reference)
Monologue text at reference resolution (1920x1080):
- Font: Clean sans-serif (Godot default or a custom face — Araminta's call)
- Size: 18-20px equivalent
- Line height: 1.4x
- Max width: 40% of viewport = ~768px at 1920 wide
- At 18px, ~80 characters fit in 768px with standard proportional font metrics
This gives comfortable readability at typical viewing distance. Not tiny, not shouting. The text whispers.
Monologue during dialogue
When a dialogue panel is active, monologue can still fire (e.g., observe_anomaly while in conversation). The monologue display shifts up to sit above the dialogue panel:
Normal state:
+------------------------------------------------------------------+
| |
| GAME WORLD |
| |
| +--monologue----------------------------+ |
| | Kael's here. Good. | |
| +----------------------------------------+ |
| |
+------------------------------------------------------------------+
During dialogue:
+------------------------------------------------------------------+
| |
| GAME WORLD |
| |
| +--monologue----------------------------+ |
| | That's not anyone from our rotation. | |
| +----------------------------------------+ |
+------------------------------------------------------------------+
| KAEL DAVAN |
| "Just a friend. Don't worry about it." |
| [Continue] [End] |
+------------------------------------------------------------------+
The monologue sits in the world layer. The dialogue sits in the HUD layer. They don't overlap. The player reads dialogue first (larger, centered, paneled), catches monologue peripherally (smaller, left, ghostly). Two voices — one external, one internal — simultaneously.
This is critical for Wow Moment #3. The contradiction fires monologue WHILE the player might be in conversation. The internal "What the hell?" happens at the same moment as the external smooth deflection. The UI must support both without making the player choose which to read.
Responses to Round 1 Open Questions (Answered by Lead Decisions)
D-04 (Examine vs Talk) — Resolved
Lead decision: conceptually separate, v0.1 ships single-action. Covered in Task 1 above. The server models both verbs; the client surfaces one. Extensible to multi-verb in v0.2 without protocol changes.
D-05 (Knowledge Panel Pause) — Resolved
Lead decision: 50% sim speed for overlays, spacebar always pauses. Covered in Task 2 above.
Client text delivery — Resolved (A-12, proposed D-048)
Server sends full text, client renders. Confirmed by lead and tracking doc. No local content loading on the client.
Updated UI Bill of Materials (incorporating Round 2 decisions)
| System | Scenes | Scripts | Shaders | Priority | Notes |
|---|---|---|---|---|---|
| Monologue renderer | 1 | 1 | 0 | P0 | Fade, timing, priority queue, character tint |
| Interaction prompt | 1 | 1 | 0 | P0 | World-space tracking, single-action (extensible to multi) |
| Dialogue panel | 1 | 1 | 0 | P0 | Semi-transparent, text advance, exit handling |
| Entity color system | 0 | 1 | 1 | P0 | D-033 palette, 0.5s fade transitions |
| Pause manager | 0 | 1 | 0 | P0 | State machine: RUNNING / OVERLAY / PAUSED |
| HUD bar | 1 | 1 | 0 | P1 | Location + time phase. Placeholder text in v0.1 |
| Minimap | 1 | 1 | 1 | P1 | Insert-style, player dot + area bounds |
| Knowledge panel | 1 | 1 | 0 | P1 | Right-side overlay, People/Places/Observations |
| News ticker | 1 | 1 | 0 | P1 | World-space scrolling text on bar display entity |
| Fog rendering | 0 | 0 | 1 | P0 | Shared with server team (shadowcast → visual) |
| TOTAL | 7 | 9 | 3 |
Reduced from 9 scenes to 7 (pause manager and entity colors don't need dedicated scenes). Added 1 shader (fog rendering confirmed as client-side).
Dependencies I'm Waiting On
| From | What I Need | Blocks |
|---|---|---|
| Dudley | IPC message format — specifically ObserverSnapshot structure for interaction options, monologue payloads, dialogue payloads. |
Interaction prompt, dialogue panel, monologue renderer |
| Dudley | Pause architecture — does client control sim speed, or does it signal the server? | Pause manager |
| Tyre | ObserverSnapshot v3 protocol spec (Round 2 task for Tyre). |
Everything that reads server state |
| Araminta | Font selection, diegetic styling direction for knowledge panel. | Knowledge panel visual treatment |
| Mellanie | First monologue content (even 5-10 lines) for integration testing. | Monologue renderer testing |
I can build all UI scaffolding with mock data. But integration testing needs real payloads from the server and real text from Mellanie. The sooner those arrive, the sooner we know if the display constraints actually feel right at runtime.
Spacebar always pauses. Everything else fades in when needed and disappears when it's done.