Skip to content

Interactable NPCs

Yogesh Sevda edited this page Oct 8, 2026 · 3 revisions

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

  1. Player guide
  2. Acceptance criteria
  3. Adding an NPC
  4. Architecture
  5. UML class diagram
  6. Interaction state machine
  7. Sequence diagrams
  8. Event contract
  9. Design decisions
  10. Testing plan
  11. Known limitations and future work

Player guide

  • 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

Known limitations and future work

  • Completion isn't saved. Once-only and prerequisite state lives in memory and resets on a new game. NpcInteractorComponent.markCompleted / getCompletedNpcIds are 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.
# 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

  1. [Player guide](#player-guide)
  2. [Acceptance criteria](#acceptance-criteria)
  3. [Adding an NPC](#adding-an-npc)
  4. [Architecture](#architecture)
  5. [UML class diagram](#uml-class-diagram)
  6. [Interaction state machine](#interaction-state-machine)
  7. [Sequence diagrams](#sequence-diagrams)
  8. [Event contract](#event-contract)
  9. [Design decisions](#design-decisions)
  10. [Testing plan](#testing-plan)
  11. Tutorial NPC
  12. [Known limitations and future work](#known-limitations-and-future-work)

Player guide

  • 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 criteria

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

Adding an NPC

1. Define it in assets/configs/friendlyNpcs.json

{
  "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."
}

2. Place it in a room in assets/configs/rooms.json

"npcSpawns": [{ "npcId": "oldSage", "x": 8, "y": 14 }]

x and y are tile coordinates. Spawns outside the map are rejected by EntityManagerComponent.

3. Add its content

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.

Configuration fields

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.


Architecture

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
Loading
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.

UML class diagram

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
Loading

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.


Interaction state machine

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
Loading

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
Loading

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.


Sequence diagrams

1. Detecting range and showing the indicator (every frame)

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
Loading

2. Interacting: dialogue, then cutscene (AFTER_DIALOGUE)

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)
Loading

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.

3. Cutscene during dialogue (DURING_DIALOGUE)

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
Loading

4. Unavailable or once-only NPC

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
Loading

5. Interaction cancelled (player dies)

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
Loading

Event contract

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 EventHandler casts every listener of an event to the arity of the trigger call, so sending startDialogue with one argument, or listening with one, throws a ClassCastException. Keep (String id, Entity npc) for the start events, even when npc isn't needed.


Design decisions

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.

Tutorial NPC

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, requiresRoomCleared and requiresCompleted stay 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.


Known limitations and future work

  • Completion isn't saved. Once-only and prerequisite state lives in memory and resets on a new game. NpcInteractorComponent.markCompleted / getCompletedNpcIds are 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.

Table of Contents

Home

Test Plan

Team 2

Features

Components
Player
Items and Inventory
Enemies
Interactable NPCs
Cutscenes and Dialogue
Music
Bosses
Rooms
Weapons
Abilities & Spells
Game Screens and Areas
Dungeons

Game Engine

Getting Started

Entities and Components

Service Locator

Loading Resources

Logging

Unit Testing

Debug Terminal

Input Handling

UI

Animations

Audio

AI

Physics

Terrain

Concurrency & Threading

Settings

Troubleshooting

MacOS Setup Guide

Clone this wiki locally