Cutscenes and Dialogue - UQcsse3200/2026-studio-4 GitHub Wiki
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 (
PlayerActionsfor the control lock; events are sent on the player's event handler) CutsceneEvents(event names and helpers)NarrativeFactory, which builds the narrative entity registered inMainGameScreenNarrativeManagerComponent(events, freeze, lock, once-only, timed events)DialogueDisplay(dialogue UI) driven byDialogueRunner(typewriter and line flow)CutscenePlayer(full-screen video) driven byMp4VideoPlayerNarrativeInputComponent(input capture)
DialogueScriptandCutsceneScript(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
*_FINISHEDevent 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
oncescript 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.eventsare 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]
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)
Tests
Full plan, coverage and manual QA steps: Cutscenes and Dialogue Test Plan.
DialogueRunnerTestNarrativeManagerComponentTestNarrativeInputComponentTestScriptLoadingTestMp4VideoPlayerTestEntityServiceFreezeTestNarrativeCommandsTest