Repository navigation
Interactable NPCs
Friendly NPCs are characters the player can walk up to and interact with by pressing E. An interaction starts the NPC's dialogue, its cutscene, or both, locks player movement while required, and gives control back when the sequence finishes.
The system is data-driven: every NPC uses the same code, and what makes one NPC different from another (name, sprite, range, dialogue, cutscene, conditions) lives in configs/friendlyNpcs.json. NPCs are placed in rooms through configs/rooms.json. Adding an NPC needs no Java changes.
Friendly NPCs are kept separate from enemies at every level: their own config file, room field, manager component and physics layer. They can't be damaged, targeted by spells, or block a room from being cleared.
The system doesn't display dialogue or play cutscenes itself. It asks the narrative system (NarrativeManagerComponent) to play them by id, using events, and waits to be told they have finished.
Contents
- Player guide
- Acceptance criteria
- Adding an NPC
- Architecture
- UML class diagram
- Interaction state machine
- Sequence diagrams
- Event contract
- Design decisions
- Testing plan
- Known limitations and future work
- Walk close to a friendly NPC. A glowing marker bobs above its head and the prompt in the top right shows, for example, Press E — Talk to Wandering Spirit.
- Press E to interact. Dialogue opens and/or a cutscene plays. While they run, the player can't move, dash or attack (unless the NPC is set up to allow movement).
- When the sequence ends, control comes back straight away.
- Some NPCs only respond under conditions (for example after the room's enemies are defeated, or after talking to another NPC). Pressing E on them shows why they won't respond.
- Some NPCs can only be talked to once. Afterwards they show a short message instead.
In the current build, the Dungeon Selection room has three NPCs:
| NPC | Behaviour | Demonstrates |
|---|---|---|
| Travelling Merchant | Dialogue, then cutscene. Repeatable. | Dialogue + cutscene |
| Wandering Spirit | Dialogue, then cutscene. Once only. | Once-only interaction |
| Sealed Guardian | Cutscene only. Needs the room cleared and the Spirit completed first. | Cutscene-only, conditions, prerequisites |
-
Completion isn't saved. Once-only and prerequisite state lives in memory and resets on a new game.
NpcInteractorComponent.markCompleted/getCompletedNpcIdsare the hooks for a save system. -
Conditions are fixed. Only "room cleared" and "other NPC completed" are supported. More (e.g. holding an item, boss defeated) would follow the same pattern as the room-cleared
BooleanSupplier. - One interaction at a time. Interactions don't queue; a second NPC can't be started until the first finishes.
- Indicator art. The marker is drawn procedurally. It should be replaced with a sprite once art exists.
- Static NPCs. NPCs don't move or turn to face the player.
Friendly NPCs are characters the player can walk up to and interact with by pressing E. An interaction starts the NPC's dialogue, its cutscene, or both, locks player movement while required, and gives control back when the sequence finishes.
The system is data-driven: every NPC uses the same code, and what makes one NPC different from another (name, sprite, range, dialogue, cutscene, conditions) lives in configs/friendlyNpcs.json. NPCs are placed in rooms through configs/rooms.json. Adding an NPC needs no Java changes.
Friendly NPCs are kept separate from enemies at every level: their own config file, room field, manager component and physics layer. They can't be damaged, targeted by spells, or block a room from being cleared.
The system doesn't display dialogue or play cutscenes itself. It asks the narrative system (NarrativeManagerComponent) to play them by id, using events, and waits to be told they have finished.
Contents
- [Player guide](#player-guide)
- [Acceptance criteria](#acceptance-criteria)
- [Adding an NPC](#adding-an-npc)
- [Architecture](#architecture)
- [UML class diagram](#uml-class-diagram)
- [Interaction state machine](#interaction-state-machine)
- [Sequence diagrams](#sequence-diagrams)
- [Event contract](#event-contract)
- [Design decisions](#design-decisions)
- [Testing plan](#testing-plan)
- Tutorial NPC
- [Known limitations and future work](#known-limitations-and-future-work)
- Walk close to a friendly NPC. A glowing marker bobs above its head and the prompt in the top right shows, for example, Press E — Talk to Wandering Spirit.
- Press E to interact. Dialogue opens and/or a cutscene plays. While they run, the player can't move, dash or attack (unless the NPC is set up to allow movement).
- When the sequence ends, control comes back straight away.
- Some NPCs only respond under conditions (for example after the room's enemies are defeated, or after talking to another NPC). Pressing E on them shows why they won't respond.
- Some NPCs can only be talked to once. Afterwards they show a short message instead.
In the current build, the Dungeon Selection room has three NPCs:
| NPC | Behaviour | Demonstrates |
|---|---|---|
| Travelling Merchant | Dialogue, then cutscene. Repeatable. | Dialogue + cutscene |
| Wandering Spirit | Dialogue, then cutscene. Once only. | Once-only interaction |
| Sealed Guardian | Cutscene only. Needs the room cleared and the Spirit completed first. | Cutscene-only, conditions, prerequisites |
| Acceptance criterion | How it is met | Verified by |
|---|---|---|
| Player can detect when they are within interaction range of an NPC |
NpcInteractableComponent.isInRange compares centre distances against the NPC's interactionRange; FriendlyNpcManagerComponent.findNearestInRange picks the closest |
detectsRangeFromCentres, spawnsOnRoomCreatedAndFindsNearestInRange
|
| A visual indicator is displayed when an NPC can be interacted with |
NpcInteractionIndicatorComponent draws a marker above the nearest available NPC; the HUD prompt shows the action |
showsIndicatorOnlyOnNearestAvailableNpc, indicatorEventsFireOnlyOnChange, manual M1 |
| Player can interact using the designated input |
E → RoomManager.interact() → NpcInteractableComponent.interact()
|
npcInRangeConsumesTheKeyPress, manual M2 |
| Interacting initiates the NPC's dialogue |
NpcInteractionSequence sends startDialogue(dialogueId, npc)
|
interactingStartsDialogue, runsDialogueThenCutsceneAndRestoresControls
|
| NPCs can trigger a cutscene after or during dialogue |
cutsceneTiming: AFTER_DIALOGUE, BEFORE_DIALOGUE, DURING_DIALOGUE (via dialogueCutsceneCue) |
cutsceneAfterDialogue…, cutsceneBeforeDialogue, duringDialogueCue…
|
| Player movement is disabled when required |
PlayerActions.setControlsLocked, only while a step whose lockMovementDuring… flag is set is running |
onlyLocksForStepsThatAskForIt, neverLocksWhenNothingAsksForIt
|
| Player controls are restored when the sequence finishes | The lock is released on finish, cancel, death and dispose |
runsDialogueThenCutsceneAndRestoresControls, dyingCancelsAndUnlocksWithoutCompleting, disposeReleasesLock
|
| NPCs can have different dialogue and cutscenes | Per-NPC dialogueId / cutsceneId in JSON |
shippedDefinitionsCoverDialogueOnlyCutsceneOnlyAndBoth |
| Configured without modifying core code | All per-NPC behaviour comes from InteractableNpcConfig; placement from npcSpawns
|
shippedDefinitionsLoadAndValidate, everyRoomSpawnNamesADefinedNpc
|
| NPCs can trigger an interaction only once |
once: true, tracked in NpcInteractorComponent
|
onceOnlyNpcCannotBeRepeated, repeatableNpcCanBeTalkedToAgain
|
| Handles dialogue-only and cutscene-only NPCs | The sequence only queues the steps that are configured |
dialogueOnlyNpcLocksUntilDialogueFinishes, cutsceneOnlyNpcPlaysCutsceneWithoutDialogue
|
| NPCs registered separately from enemies |
FriendlyNpcManagerComponent, npcSpawns, PhysicsLayer.FRIENDLY_NPC
|
roomClearedConditionReadsTheEnemyManager |
{
"id": "oldSage",
"name": "Old Sage",
"atlas": "images/sage.atlas",
"animation": "idle",
"dialogueId": "sage_intro",
"cutsceneId": "sage_opens_gate",
"cutsceneTiming": "AFTER_DIALOGUE",
"once": true,
"completedMessage": "The sage is meditating."
}"npcSpawns": [{ "npcId": "oldSage", "x": 8, "y": 14 }]x and y are tile coordinates. Spawns outside the map are rejected by EntityManagerComponent.
Create configs/dialogues/sage_intro.json and configs/cutscenes/sage_opens_gate.json for the narrative system. If a file is missing, the narrative system still reports "finished", so the player is never stuck.
| Field | Default | Purpose |
|---|---|---|
id |
required | Stable id. Used by rooms and to remember completion. |
name |
required | Shown in the prompt. |
promptVerb |
"Talk to" |
Prompt reads Press E — {promptVerb} {name}. |
atlas + animation
|
– | Animated sprite (looping). Takes priority over texture. |
texture |
– | Static sprite, used when there is no atlas. |
width |
1.0 |
Sprite width in world units. Height follows the art's aspect ratio. |
interactionRange |
1.5 |
World units, centre to centre. |
dialogueId |
null |
Dialogue to start. |
cutsceneId |
null |
Cutscene to start. |
cutsceneTiming |
AFTER_DIALOGUE |
AFTER_DIALOGUE, BEFORE_DIALOGUE or DURING_DIALOGUE. |
lockMovementDuringDialogue |
true |
Lock movement, dash and attacks while the dialogue runs. |
lockMovementDuringCutscene |
true |
Lock movement, dash and attacks while the cutscene runs. |
once |
false |
Interaction can only be completed once. |
requiresRoomCleared |
false |
Only available once the room's enemies are defeated. |
requiresCompleted |
[] |
NPC ids that must have been completed first. |
unavailableMessage |
null |
Shown while conditions are unmet. |
completedMessage |
null |
Shown after a once-only interaction is used. |
Definitions are validated the first time a room loads (InteractableNpcConfigs.validate()). An NPC must have a dialogue or a cutscene, a unique id, a name, and a sprite, and its prerequisites must name real NPCs other than itself. A missing atlas animation fails when the NPC is created, so mistakes are found immediately rather than producing an invisible NPC.
The system is split into an NPC side (one per NPC, owned by the room) and a player side (one per player, survives room changes), joined by a plain-Java state machine. Outside systems are reached only through events on the player.
flowchart LR
subgraph Config["Configuration (JSON)"]
FN["friendlyNpcs.json"]
RJ["rooms.json<br/>npcSpawns"]
end
subgraph Room["Room entity"]
RF["RoomFactory"]
FNM["FriendlyNpcManagerComponent"]
EM["EnemyManagerComponent"]
end
subgraph Npc["NPC entity (one per NPC)"]
NIC["NpcInteractableComponent"]
IND["NpcInteractionIndicatorComponent"]
end
subgraph Player["Player entity"]
KB["KeyboardPlayerInputComponent"]
NPI["NpcInteractorComponent"]
SEQ["NpcInteractionSequence"]
PA["PlayerActions"]
end
RM["RoomManager"]
NM["NarrativeManagerComponent<br/>(narrative system)"]
HUD["InteractionPromptDisplay"]
FN --> RF
RJ --> RF
RF --> FNM
FNM -- "FriendlyNpcFactory" --> Npc
FNM -. "is room cleared?" .-> EM
KB -- "interact (E)" --> RM
RM --> FNM
RM --> HUD
NIC --> NPI
NPI --> SEQ
NPI -- "lock / unlock" --> PA
NIC -- "indicator events" --> IND
NPI <-- "start / finished events" --> NM
| Class | Responsibility |
|---|---|
InteractableNpcConfig / InteractableNpcConfigs
|
JSON definition of each NPC, and validation of the whole file. |
NpcSpawnConfig |
Places an NPC at a tile in a room (RoomConfig.npcSpawns). |
FriendlyNpcFactory |
Builds an NPC entity: sprite, static collider on FRIENDLY_NPC, interactable and indicator components. No combat stats, AI or hitbox. |
FriendlyNpcManagerComponent |
On the room. Spawns its NPCs, finds the nearest in range, shows the indicator, loads and unloads sprites. |
NpcInteractableComponent |
On each NPC. Range, availability (once, room cleared, prerequisites), prompt text, indicator state. |
NpcInteractionIndicatorComponent |
On each NPC. Draws the bobbing marker while the NPC is the one the player can use. |
NpcInteractorComponent |
On the player. Runs one interaction at a time, remembers completed NPCs, forwards "finished" events, locks controls. |
NpcInteractionSequence |
Plain Java. Orders dialogue and cutscene steps and decides when movement is locked. |
NpcInteractionEvents |
Event names shared with the narrative system. |
RoomManager |
Routes E to an NPC before exits; puts the NPC prompt first in the HUD. |
KeyboardPlayerInputComponent |
Ignores E while an interaction runs; doesn't also pick up items when E starts one. |
classDiagram
direction TB
class Component {
<<libGDX engine>>
#entity: Entity
+create()
+update()
+dispose()
}
class InteractableNpcConfig {
+id: String
+name: String
+promptVerb: String
+atlas: String
+animation: String
+texture: String
+width: float
+interactionRange: float
+dialogueId: String
+cutsceneId: String
+cutsceneTiming: CutsceneTiming
+lockMovementDuringDialogue: boolean
+lockMovementDuringCutscene: boolean
+once: boolean
+requiresRoomCleared: boolean
+requiresCompleted: String[]
+unavailableMessage: String
+completedMessage: String
+hasDialogue() boolean
+hasCutscene() boolean
}
class CutsceneTiming {
<<enumeration>>
AFTER_DIALOGUE
BEFORE_DIALOGUE
DURING_DIALOGUE
}
class InteractableNpcConfigs {
+CONFIG_PATH: String$
+npcs: InteractableNpcConfig[]
+get(npcId: String) InteractableNpcConfig
+validate()
}
class NpcSpawnConfig {
+npcId: String
+x: int
+y: int
}
class EntityManagerComponent {
<<abstract>>
#spawnEntity(e: Entity)
#spawnEntityAt(e: Entity, tile: GridPoint2, centerX: boolean, centerY: boolean)
}
class FriendlyNpcManagerComponent {
-spawns: NpcSpawnConfig[]
-npcs: List~NpcInteractableComponent~
-player: Entity
+spawnNpcs(player: Entity)
+updateIndicators(player: Entity)
+findNearestInRange(player: Entity) NpcInteractableComponent
+getPrompt(player: Entity) String
+getNpcs() List~NpcInteractableComponent~
-isRoomCleared() boolean
}
class EnemyManagerComponent {
+isCleared() boolean
}
class FriendlyNpcFactory {
<<utility>>
+createFriendlyNpc(config: InteractableNpcConfig)$ Entity
}
class NpcInteractableComponent {
-config: InteractableNpcConfig
-roomCleared: BooleanSupplier
-indicatorVisible: boolean
+setRoomClearedSupplier(s: BooleanSupplier)
+distanceTo(player: Entity) float
+isInRange(player: Entity) boolean
+getAvailability(player: Entity) Availability
+getPrompt(player: Entity) String
+interact(player: Entity) boolean
+setIndicatorVisible(visible: boolean)
}
class Availability {
<<enumeration>>
AVAILABLE
BUSY
ALREADY_COMPLETED
CONDITIONS_UNMET
}
class NpcInteractionIndicatorComponent {
-visible: boolean
-elapsed: float
+isVisible() boolean
#draw(batch: SpriteBatch)
}
class RenderComponent {
<<abstract>>
}
class NpcInteractorComponent {
-completedNpcIds: Set~String~
-activeSequence: NpcInteractionSequence
-activeNpcId: String
+beginInteraction(npc: Entity, config: InteractableNpcConfig) boolean
+cancelInteraction()
+isInteracting() boolean
+hasCompleted(npcId: String) boolean
+markCompleted(npcId: String)
}
class NpcInteractionSequence {
-pending: Deque~Step~
-dialogueActive: boolean
-cutsceneActive: boolean
-movementLocked: boolean
+start()
+onDialogueFinished(id: String) boolean
+onCutsceneFinished(id: String) boolean
+onCutsceneCue(id: String) boolean
+cancel()
+isFinished() boolean
}
class Output {
<<interface>>
+startDialogue(dialogueId: String)
+startCutscene(cutsceneId: String)
+setMovementLocked(locked: boolean)
}
class PlayerActions {
+setControlsLocked(owner: Object, locked: boolean)
}
class RoomManager {
+interact()
-interactWithNpc() boolean
-getNpcPrompt() String
}
Component <|-- EntityManagerComponent
EntityManagerComponent <|-- FriendlyNpcManagerComponent
Component <|-- NpcInteractableComponent
Component <|-- NpcInteractorComponent
Component <|-- RenderComponent
RenderComponent <|-- NpcInteractionIndicatorComponent
InteractableNpcConfigs "1" o-- "*" InteractableNpcConfig
InteractableNpcConfig --> CutsceneTiming
NpcInteractableComponent --> Availability
FriendlyNpcManagerComponent "1" --> "*" NpcSpawnConfig
FriendlyNpcManagerComponent "1" o-- "*" NpcInteractableComponent : spawned NPCs
FriendlyNpcManagerComponent ..> FriendlyNpcFactory : creates NPCs
FriendlyNpcManagerComponent ..> EnemyManagerComponent : room cleared?
FriendlyNpcManagerComponent ..> InteractableNpcConfigs : resolves ids
NpcInteractableComponent --> InteractableNpcConfig
NpcInteractableComponent ..> NpcInteractorComponent : asks / starts
NpcInteractableComponent ..> NpcInteractionIndicatorComponent : indicator events
NpcInteractorComponent "1" *-- "0..1" NpcInteractionSequence : active
NpcInteractorComponent ..|> Output : inner class
NpcInteractionSequence --> Output
NpcInteractorComponent ..> PlayerActions : lock / unlock
RoomManager ..> FriendlyNpcManagerComponent
RoomManager ..> NpcInteractableComponent
Output is an interface nested in NpcInteractionSequence, implemented by a private inner class of NpcInteractorComponent. This lets the sequence run without any libGDX classes, so it can be unit tested on its own.
From the player's point of view, each NPC is in one of these states. NpcInteractableComponent.getAvailability works out the state, checking in this order: busy, already completed, conditions.
stateDiagram-v2
[*] --> OutOfRange
OutOfRange --> InRange: player within interactionRange
InRange --> OutOfRange: player walks away
state InRange {
[*] --> Check
Check --> Available: no interaction running,<br/>not completed (or repeatable),<br/>conditions met
Check --> ConditionsUnmet: room not cleared or<br/>prerequisite missing
Check --> AlreadyCompleted: once = true and done
Available: indicator shown<br/>"Press E — Talk to …"
ConditionsUnmet: unavailableMessage
AlreadyCompleted: completedMessage
}
Available --> Interacting: E pressed
Interacting --> Available: sequence finished (repeatable)
Interacting --> AlreadyCompleted: sequence finished (once)
Interacting --> Available: cancelled (player died)
ConditionsUnmet --> Available: room cleared / prerequisite done
Inside an interaction, NpcInteractionSequence steps through the configured content:
stateDiagram-v2
[*] --> Queued: start()
Queued --> Dialogue: next step is dialogue
Queued --> Cutscene: next step is cutscene
Queued --> Finished: nothing left
Dialogue --> Queued: dialogueFinished
Dialogue --> DialogueAndCutscene: dialogueCutsceneCue<br/>(DURING_DIALOGUE only)
DialogueAndCutscene --> Cutscene: dialogueFinished
DialogueAndCutscene --> Dialogue: cutsceneFinished
Cutscene --> Queued: cutsceneFinished
Dialogue --> Cancelled: cancel()
Cutscene --> Cancelled: cancel()
DialogueAndCutscene --> Cancelled: cancel()
Finished --> [*]: mark completed,<br/>unlock controls
Cancelled --> [*]: unlock controls,<br/>not completed
Movement is locked whenever a running step has its lockMovementDuring… flag set. Between two locked steps the lock is held, so there is no unlock/relock flicker.
sequenceDiagram
autonumber
participant ES as EntityService
participant FNM as FriendlyNpcManagerComponent
participant NIC as NpcInteractableComponent (each NPC)
participant IND as NpcInteractionIndicatorComponent
participant RM as RoomManager
participant HUD as InteractionPromptDisplay
ES->>FNM: update()
FNM->>FNM: findNearestInRange(player)
loop each NPC in the room
FNM->>NIC: distanceTo(player)
end
loop each NPC in the room
FNM->>NIC: getAvailability(player)
FNM->>NIC: setIndicatorVisible(isNearest && AVAILABLE)
alt visibility changed
NIC-->>IND: npcIndicatorShown / npcIndicatorHidden
end
end
RM->>RM: refreshInteractionPrompt()
alt an interaction is running
RM->>HUD: clearPrompt()
else
RM->>FNM: getPrompt(player)
FNM->>NIC: getPrompt(player)
NIC-->>RM: "Press E — Talk to Wandering Spirit"
RM->>HUD: setPrompt(resolve(npc, item, exit))
end
sequenceDiagram
autonumber
actor P as Player
participant KB as KeyboardPlayerInputComponent
participant RM as RoomManager
participant FNM as FriendlyNpcManagerComponent
participant NIC as NpcInteractableComponent
participant NPI as NpcInteractorComponent
participant SEQ as NpcInteractionSequence
participant PA as PlayerActions
participant NM as NarrativeManagerComponent
P->>KB: press E
KB->>KB: isNpcInteractionRunning()? no
KB->>RM: trigger "interact"
RM->>FNM: findNearestInRange(player)
FNM-->>RM: NPC
RM->>NIC: interact(player)
NIC->>NIC: getAvailability() == AVAILABLE
NIC->>NIC: setIndicatorVisible(false)
NIC->>NPI: beginInteraction(npc, config)
NPI->>SEQ: new NpcInteractionSequence(config, output, onFinished)
NPI-->>NPI: trigger npcInteractionStarted(id, npc)
NPI->>SEQ: start()
SEQ->>NPI: output.setMovementLocked(true)
NPI->>PA: setControlsLocked(interactor, true)
SEQ->>NPI: output.startDialogue(dialogueId)
NPI-)NM: startDialogue(dialogueId, npc)
NIC-->>RM: true
RM-->>KB: handled
KB->>KB: isNpcInteractionRunning()? yes, skip "itemPickup"
Note over NM: Dialogue plays. The narrative system also<br/>freezes the world and holds its own control lock.<br/>E is ignored by the player input while it runs.
NM-)NPI: dialogueFinished(dialogueId)
NPI->>SEQ: onDialogueFinished(dialogueId)
SEQ->>NPI: output.startCutscene(cutsceneId)
NPI-)NM: startCutscene(cutsceneId, npc)
Note over SEQ,PA: Lock is held between the two steps
NM-)NPI: cutsceneFinished(cutsceneId)
NPI->>SEQ: onCutsceneFinished(cutsceneId)
SEQ->>NPI: output.setMovementLocked(false)
NPI->>PA: setControlsLocked(interactor, false)
SEQ->>NPI: onFinished
NPI->>NPI: completedNpcIds.add(id)
NPI-->>NPI: trigger npcInteractionFinished(id, npc)
Open arrowheads (-)) are events on the player's EventHandler, not method calls: NpcInteractorComponent and NarrativeManagerComponent don't reference each other. NpcInteractionSequence never touches the engine; every side effect goes through its Output, which NpcInteractorComponent implements.
The ids here are examples; none of the shipped NPCs use this timing yet.
sequenceDiagram
autonumber
participant NPI as NpcInteractorComponent
participant SEQ as NpcInteractionSequence
participant NM as NarrativeManagerComponent
NPI->>SEQ: start()
SEQ->>NPI: output.startDialogue("guide_hint")
NPI-)NM: startDialogue("guide_hint", npc)
Note over NM: A dialogue line marked as a cue appears
NM-)NPI: dialogueCutsceneCue("guide_hint")
NPI->>SEQ: onCutsceneCue("guide_hint")
SEQ->>NPI: output.startCutscene("guide_opens_gate")
NPI-)NM: startCutscene("guide_opens_gate", npc)
Note over SEQ: Dialogue and cutscene both active
NM-)NPI: dialogueFinished("guide_hint")
NPI->>SEQ: onDialogueFinished(...)
Note over SEQ: Still waiting on the cutscene
NM-)NPI: cutsceneFinished("guide_opens_gate")
NPI->>SEQ: onCutsceneFinished(...)
SEQ->>NPI: onFinished, unlock controls
opt no cue line in the dialogue
Note over SEQ,NM: The cutscene stays queued and plays<br/>after dialogueFinished instead, so it is never skipped
end
sequenceDiagram
autonumber
actor P as Player
participant RM as RoomManager
participant NIC as NpcInteractableComponent
participant NPI as NpcInteractorComponent
participant GA as GameAreaDisplay
P->>RM: interact (E)
RM->>NIC: interact(player)
NIC->>NPI: isInteracting()
NIC->>NPI: hasCompleted(id)
alt once and already completed
NIC-->>RM: false
RM->>NIC: getPrompt(player)
NIC-->>RM: completedMessage
else room not cleared or prerequisite missing
NIC-->>RM: false
RM->>NIC: getPrompt(player)
NIC-->>RM: unavailableMessage
end
alt message configured
RM->>GA: showStatus(message)
Note over RM: Key press consumed, exits not checked
else no message
Note over RM: Falls through to exit handling
end
sequenceDiagram
autonumber
participant P as Player entity
participant NPI as NpcInteractorComponent
participant SEQ as NpcInteractionSequence
participant PA as PlayerActions
P-)NPI: entityDied
NPI->>NPI: cancelInteraction()
NPI->>SEQ: cancel()
SEQ->>NPI: output.setMovementLocked(false)
NPI->>PA: setControlsLocked(interactor, false)
NPI-->>P: npcInteractionCancelled(id, npc)
Note over NPI: NPC is NOT marked completed,<br/>so a once-only NPC can be retried
All events go through the player entity's event handler. Constants live in NpcInteractionEvents (the narrative system re-exports them in CutsceneEvents).
Sent by the NPC system
| Event | Arguments | Meaning |
|---|---|---|
startDialogue |
String dialogueId, Entity npc |
Open this dialogue |
startCutscene |
String cutsceneId, Entity npc |
Play this cutscene |
npcInteractionStarted |
String npcId, Entity npc |
An interaction began |
npcInteractionFinished |
String npcId, Entity npc |
Every step completed |
npcInteractionCancelled |
String npcId, Entity npc |
Abandoned (e.g. player died) |
Received by the NPC system
| Event | Arguments | Meaning |
|---|---|---|
dialogueFinished |
String dialogueId |
The dialogue closed (including skipped or missing) |
cutsceneFinished |
String cutsceneId |
The cutscene ended (including skipped or missing) |
dialogueCutsceneCue |
String dialogueId |
Start a DURING_DIALOGUE cutscene now |
On the NPC entity
| Event | Arguments | Meaning |
|---|---|---|
npcIndicatorShown / npcIndicatorHidden
|
none | The NPC became, or stopped being, interactable |
Every listener of an event must take the same number of arguments. The engine's
EventHandlercasts every listener of an event to the arity of thetriggercall, so sendingstartDialoguewith one argument, or listening with one, throws aClassCastException. Keep(String id, Entity npc)for the start events, even whennpcisn't needed.
| Decision | Reason |
|---|---|
Separate physics layer (FRIENDLY_NPC) |
Weapons, spells (EnemyUtils.isEnemy) and room-clear tracking treat everything on PhysicsLayer.NPC as an enemy. A new layer keeps friendly NPCs out of all of them without touching combat code. |
Separate manager and npcSpawns |
EnemyManagerComponent decides when a room is cleared. Keeping NPCs out of it means they never block progress, and they still appear in cleared rooms. |
| Events instead of direct calls to dialogue/cutscene code | The narrative system was built in parallel by another team. Events let both sides be developed and tested independently, with only names and argument types to agree on. |
Plain-Java NpcInteractionSequence |
Ordering and lock rules are the most error-prone part. With no libGDX dependency they can be tested quickly and fully, including edge cases like content that finishes synchronously. |
| Completion state on the player | NPC and room entities are destroyed on room change; the player isn't. Once-only and prerequisite state survives moving between rooms. |
| Owner-based control lock |
PlayerActions.setControlsLocked(owner, …) lets several systems (NPC interactions, the narrative system, boss phases) lock independently. One releasing its lock doesn't unlock the others. |
Room-cleared check via BooleanSupplier |
The NPC asks "is my room cleared?" without depending on EnemyManagerComponent. The manager supplies the answer, and tests can supply a simple flag. |
| Fail loudly on bad config | Unknown NPC ids, missing animations or an unreadable friendlyNpcs.json throw on load with a clear message, instead of producing NPCs that silently don't appear. |
| NPC before items before exits on E | Standing between an NPC and a door must never change rooms mid-conversation, and starting a conversation shouldn't also pick up an item underfoot. |
The Old Sage is the first friendly NPC a new player meets. He stands in the Dungeon Selection room, a few tiles from where the player spawns, and teaches the basics of playing in one short dialogue. He is built entirely with the steps in Adding an NPC: config, dialogue and art only, with no Java changes.
| File | What it adds |
|---|---|
configs/friendlyNpcs.json |
The tutorialGuide definition |
configs/rooms.json |
Spawn at tile (12, 22) in the selection room, next to the start entry point (16, 21) |
configs/dialogues/tutorial_guide.json |
The tutorial dialogue between the Sage and the player |
images/tutorial_guide.atlas |
The old wizard at 96, 128 on rogues.png, the same sheet the Merchant and Hecate use |
What he teaches
| Topic | Keys / where |
|---|---|
| Moving and dashing | W A S D, Space |
| Attacking | J (quick), K (heavy) |
| Switching weapons | 1 sword, 2 knife, 3 bow |
| Talking, picking up loot, using doorways | E |
| Inventory and potions | I, Tab to cycle, Q to use |
| Buying upgrades and potions | Travelling Merchant |
| Abilities | Hecate, then L to use |
| Reaching the dungeons | The bookshelves in the hall |
Design choices
-
Repeatable, no conditions.
once,requiresRoomClearedandrequiresCompletedstay at their defaults, so a player who forgets a control can walk back and ask again. - Dialogue only. The player reads at their own pace: Space or J advances, Esc leaves early.
- Movement locked while talking, so the player can't walk off mid-explanation.
- Placed by the spawn point, so he's the first NPC the player sees, and kept clear of the Merchant (16, 16) and Hecate (11, 18).
Testing
| Check | Verified by |
|---|---|
Defined as a dialogue-only NPC using tutorial_guide, with movement locked |
guideIsDefinedAsADialogueOnlyNpc |
| Repeatable, with no once-only, room-cleared or prerequisite gating | guideCanBeRevisitedAnyTime |
| Spawns in the starting room | guideStandsInTheStartingRoom |
| Dialogue loads, opens with the Sage, and every line has a known speaker and text | tutorialDialogueLoadsAndOpensWithTheSage |
| Atlas and region exist; no two NPCs share a tile | Existing FriendlyNpcAssetsTest, InteractableNpcConfigsTest
|
| Prompt shows Press E — Talk to Old Sage, dialogue plays, controls return, can talk again | Manual |
The first four tests are in TutorialGuideConfigTest.
Limitation: the keys in the dialogue are plain text. If a keybind changes in KeyboardPlayerInputComponent, tutorial_guide.json needs updating to match.
-
Completion isn't saved. Once-only and prerequisite state lives in memory and resets on a new game.
NpcInteractorComponent.markCompleted/getCompletedNpcIdsare the hooks for a save system. -
Conditions are fixed. Only "room cleared" and "other NPC completed" are supported. More (e.g. holding an item, boss defeated) would follow the same pattern as the room-cleared
BooleanSupplier. - One interaction at a time. Interactions don't queue; a second NPC can't be started until the first finishes.
- Indicator art. The marker is drawn procedurally. It should be replaced with a sprite once art exists.
- Static NPCs. NPCs don't move or turn to face the player.