Skip to content

Cutscenes and Dialogue

Benjamin Chau edited this page Oct 8, 2026 · 4 revisions

Cutscenes and Dialogue

Task: #190
PR: #231
Builds on: Interactable NPCs (#191, PR #216)

Reusable dialogue boxes and video cutscenes for story sequences. Content lives in JSON files and is started by sending an event on the player, so adding a new conversation or cutscene needs no new code.

While a dialogue or cutscene plays the world is frozen (no enemy, projectile or trap updates, no physics, no run timer, sprite animations stop) and the player's controls are locked. Both are always restored when it ends, including when it is skipped or a video cannot be played.

Participants

  • Player (PlayerActions for the control lock; events are sent on the player's event handler)
  • CutsceneEvents (event names and helpers)
  • NarrativeFactory, which builds the narrative entity registered in MainGameScreen
    • NarrativeManagerComponent (events, freeze, lock, once-only, timed events)
    • DialogueDisplay (dialogue UI) driven by DialogueRunner (typewriter and line flow)
    • CutscenePlayer (full-screen video) driven by Mp4VideoPlayer
    • NarrativeInputComponent (input capture)
  • DialogueScript and CutsceneScript (data loaded from JSON)
  • EntityService (setFrozen)

The narrative entity is separate from the player because it has to keep updating while the world is frozen, and the player must not.

Trying it out

Open the Debug Terminal with F1:

Command Plays
dialogue demo assets/configs/dialogues/demo.json
cutscene demovideo assets/configs/cutscenes/demovideo.json, which plays assets/videos/demo.mp4

Give the file name with or without .json. The terminal stays open if the file is not found.

Controls while something is playing: SPACE / ENTER / left click advance a dialogue line (the first press finishes the typewriter, the second goes to the next line) or skip a skippable cutscene. ESC leaves the dialogue entirely (the remaining lines are not shown; talk to the NPC again to restart it), or skips the cutscene if no dialogue is open. F1 still opens the terminal, and while the terminal is open it gets all the input, so typing a command (including spaces) never advances a dialogue.

The Interactable NPCs in the hub (Travelling Merchant, Wandering Spirit) also play these: press E next to one and its dialogue and cutscene run through this system.

Starting one from game code

Everything is an event on the player. The helpers in CutsceneEvents wrap it:

CutsceneEvents.playDialogue(player, "old_sage");        // configs/dialogues/old_sage.json
CutsceneEvents.playCutscene(player, "boss_defeated");   // configs/cutscenes/boss_defeated.json

// or build one in code, no file needed
CutsceneEvents.playDialogue(player,
    new DialogueScript("tip")
        .speaker("sage", "Old Sage", "images/ghost.atlas", "float")
        .line("sage", "Watch out for the traps!"));
CutsceneEvents.playCutscene(player,
    CutsceneScript.ofVideo("gate_opens", "videos/gate_opens.mp4").at(2f, "openGate", null));

// react when one ends
player.getEvents().addListener(CutsceneEvents.CUTSCENE_FINISHED, (String id) -> openGate());

Events

Request (send) Arguments
START_DIALOGUE (String dialogueId, Entity npcOrNull)
START_CUTSCENE (String cutsceneId, Entity npcOrNull)
START_DIALOGUE_SCRIPT (DialogueScript)
START_CUTSCENE_SCRIPT (CutsceneScript)
Notification (listen) Arguments
DIALOGUE_STARTED, DIALOGUE_FINISHED (String dialogueId)
DIALOGUE_SKIPPED (String dialogueId), sent when the player presses ESC; followed by DIALOGUE_FINISHED
DIALOGUE_LINE_SHOWN (String dialogueId, Integer lineIndex)
DIALOGUE_CUTSCENE_CUE (String dialogueId), sent when a line marked "cue": true appears
CUTSCENE_STARTED, CUTSCENE_FINISHED (String cutsceneId)
CUTSCENE_SKIPPED (String cutsceneId), followed by CUTSCENE_FINISHED

START_DIALOGUE, START_CUTSCENE, DIALOGUE_FINISHED, CUTSCENE_FINISHED and DIALOGUE_CUTSCENE_CUE are the same events the Interactable NPC system uses (NpcInteractionEvents), so an NPC's dialogueId and cutsceneId in friendlyNpcs.json are just script file names.

EventHandler casts every listener of an event name to one argument count, so keep to the signatures above.

Rules

  • If a script file is missing, nothing is shown, a warning is logged and the matching *_FINISHED event is still sent so nothing waits forever.
  • A second dialogue (or cutscene) requested while one of that kind is open is ignored. A dialogue and a cutscene can overlap, e.g. a cutscene started from a dialogue cue.
  • A once script plays a single time per session.

Dialogue file

configs/dialogues/<id>.json:

{
  "once": false,
  "speakers": [
    { "id": "sage", "name": "Old Sage", "atlas": "images/ghost.atlas", "animation": "float" },
    { "id": "me", "name": "You", "texture": "images/box_boy.png" }
  ],
  "lines": [
    { "speaker": "sage", "text": "Welcome, traveller." },
    { "speaker": "me", "text": "Who are you?", "cue": true }
  ]
}

The text appears on the left and is typed out. The speaker's framed portrait and name are on the right. A portrait is either an animation (atlas plus animation, with optional frameSeconds) or a still texture. Speaker art is loaded when the dialogue opens.

Cutscene file

configs/cutscenes/<id>.json. A cutscene is an mp4 played full screen:

{
  "video": "videos/boss_intro.mp4",
  "skippable": true,
  "once": false,
  "events": [ { "at": 2.5, "event": "openGate", "arg": "optional string" } ]
}
  • skippable (default true) lets the player skip.
  • events are fired on the player's event handler at those times (seconds from the start of the video).
  • Decoding is pure Java (JCodec), so it behaves the same on every OS. It works best at 720p or lower, H.264 baseline or main profile.
  • There is no audio.
  • If a video cannot be decoded the cutscene ends immediately (the error is logged) and the game is released.
  • The three NPC cutscenes (merchant_opens_shop, spirit_reveals_dungeons, guardian_awakens) currently play the demo video as a placeholder.

Freezing the world

EntityService.setFrozen(owner, boolean) freezes every entity that has not called Entity.setUpdatesWhilePaused(true) (the HUD and the narrative entity do). Each owner releases its own freeze, so overlapping dialogue and cutscene cannot unfreeze each other. This is separate from the inventory's toggleUpdate() pause.

MainGameScreen also skips physics, room logic and the run timer, and sets the game time scale to 0, while frozen.

Flowchart

flowchart TD
    A[Code, NPC or terminal sends START_DIALOGUE / START_CUTSCENE] --> B[NarrativeManagerComponent]
    B --> C{Script found and playable?}
    C -->|no| D[Send FINISHED straight away]
    C -->|yes| E[Freeze world and lock controls]
    E --> F[DialogueDisplay / CutscenePlayer shows it]
    F --> G{Finished or skipped?}
    G -->|no| F
    G -->|yes| H[Unfreeze and unlock]
    H --> I[Send FINISHED]
Loading

Sequence diagram

sequenceDiagram
    participant Src as Code / NPC / Terminal
    participant P as Player events
    participant M as NarrativeManagerComponent
    participant E as EntityService
    participant V as DialogueDisplay
    participant In as NarrativeInputComponent

    Src->>P: START_DIALOGUE(id, npc)
    P->>M: playDialogue(id)
    M->>E: setFrozen(owner, true)
    M->>P: DIALOGUE_STARTED(id)
    M->>V: show(script)
    loop each line
        V->>P: DIALOGUE_LINE_SHOWN(id, index)
        In->>V: SPACE advances
    end
    V->>M: onFinished
    M->>E: setFrozen(owner, false)
    M->>P: DIALOGUE_FINISHED(id)
Loading

Tests

Full plan, coverage and manual QA steps: Cutscenes and Dialogue Test Plan.

  • DialogueRunnerTest
  • NarrativeManagerComponentTest
  • NarrativeInputComponentTest
  • ScriptLoadingTest
  • Mp4VideoPlayerTest
  • EntityServiceFreezeTest
  • NarrativeCommandsTest

Table of Contents

Home

Getting Started

Game Engine

Entities and Components
Input Handling

Features

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

Test Plans

Team 2

Troubleshooting

Clone this wiki locally