route-engine is a state-driven visual novel engine that follows a unidirectional data flow architecture.
The engine follows a strict State → View → Action cycle:
┌─────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ State │───▶│ View │───▶│ Action │ │
│ │ (systemState)│ │(route-graphics) │ (events) │ │
│ └──────────────┘ └──────────────┘ └─────────────┘ │
│ ▲ │ │
│ │ │ │
│ └───────────────────────────────────────┘ │
│ systemStore actions │
│ update the state │
└─────────────────────────────────────────────────────────────┘
-
State (systemState): The single source of truth. Contains all runtime data including pointers, variables, and configuration.
-
View (route-graphics): Renders the current state. The view is a pure function of state - given the same state, it always produces the same output.
-
Action/Events: User interactions (clicks, key presses) or system events (timers) trigger actions. Actions are processed by systemStore action functions.
-
systemStore Actions: These functions receive the current state, apply mutations (via Immer), and produce the next state. The cycle then repeats.
This pattern ensures:
- Predictability: State changes only through defined actions
- Debuggability: You can inspect any state and understand what the view should show
- Testability: Each part can be tested in isolation
flowchart TD
Start([Start]) --> projectData[projectData]
projectData --> systemState[systemState]
projectData --> constructPresentationState[[constructPresentationState]]
systemState --> constructPresentationState
constructPresentationState --> presentationState[presentationState]
presentationState --> constructRenderState[[constructRenderState]]
projectData --> constructRenderState
systemState --> constructRenderState
constructRenderState --> renderState[renderState]
renderState --> renderer[renderer]
user[user] --> renderer
renderer --> user
renderer --> action[[action]]
action --> sideEffect[sideEffect]
action --> systemState
sideEffect --> renderer
Static, read-only data that defines the visual novel content:
- resources: Images, audio, animations, transforms, layouts, characters, fonts, colors, and
textStyles- Localization is not implemented in the current runtime. The planned patch-based model is documented in
docs/L10n.md - Voice audio is stored under
resources.voices[sceneId][voiceId]and line actions reference the scene-localvoiceId - Layout text elements should reference shared styles with
textStyleId resources.colors[*].hexshould be opaque hex only; text fill and stroke transparency should be authored onresources.textStyleswithcolorAlpha/strokeAlpha, not insideresources.colors- Layout sprite elements should reference images with
imageIdand optionalhoverImageId/clickImageId - Layout rect elements should reference shared colors with
colorIdand optionalhover.colorId/click.colorId/rightClick.colorId - Authored inline
textStyleobjects, authored spritesrc/hover.src/click.srcfields, and authored rectfill/hover.fill/click.fill/rightClick.fillfields in layout elements are invalid and fail fast at render-state construction
- Localization is not implemented in the current runtime. The planned patch-based model is documented in
- story: Scenes, sections, and lines that define the narrative flow
- Scene containers remain part of authored story structure
- Section IDs are globally unique across scenes and are the primary runtime routing key
Project data is loaded once and never mutated during runtime.
Mutable runtime state managed by the system store. Key components:
-
global: Application-wide settings
pendingEffects: Queue of side effects to executeautoMode/skipMode: Playback mode flagsdialogueUIHidden: UI visibility toggleaccountViewedRegistry: Account-level seen registry used by skip-unseen checksnextLineConfig: Controls line advancement behaviorsaveSlots: Save game dataisLineCompleted: Whether current line animation finished
-
contexts: Stack of isolated game contexts (supports title screen, gameplay, replays)
currentPointerMode: Always'read'pointers: Position tracker for the active read locationconfiguration: Context-specific settingsviews: Overlay stackbgm: Current background musicvariables: Game variablesrollback: Active branch timeline for rollback navigation
The engine has separate concepts that should not be collapsed:
historyDialogue: A render-time dialogue backlog projection for the current section. It is used by layouts and does not restore state.context.rollback.timeline: The active path for rollback navigation in the current context. It crosses sections and is saved with slots, but abandoned future checkpoints are removed when the player rolls back and branches.global.accountViewedRegistry: The account-level seen snapshot. It is persisted outside slots and is not replaced byloadSlot.
runtime.skipUnseenText is a device-level preference. The seen data it checks is account-level: skip-unseen uses global.accountViewedRegistry, not save slots or rollback.timeline.
Derived state computed from project data and system state. Represents what should be displayed without rendering specifics.
const presentationState = constructPresentationState(presentations);Presentation state includes:
background: Current background or CGdialogue: Speaker, layered speaker sprite, text content, mode (ADV/NVL)character: Character sprites and positionsvisual: Additional visual elementsbgm/sfx/voice: Audio configurationanimation: Active animationslayout: UI layoutschoice: Choice menu data
Final output format ready for the renderer:
const renderState = constructRenderState({
presentationState,
resources,
});Render state structure:
elements: Tree of renderable elements (containers, sprites, text)animations: Renderer animation descriptors to applyaudio: Sound effects and music to play
Contexts provide isolated environments for different game states:
- Title Screen Context: The main menu before starting a game
- Gameplay Context: Active game session (new game or loaded save)
- Replay Context: History replay mode with read-only global variables
All contexts share global state but maintain their own:
- Pointer positions
- Rollback timelines
- Variables
- View stacks
Pointers are the core navigation mechanism in route-engine. A pointer tracks the current position in the story by referencing a sectionId and lineId.
Section IDs are globally unique, so section lookup is scene-agnostic at runtime even though scenes still exist in authored project data.
pointer: {
sectionId: 'chapter_1_intro',
lineId: 'line_42'
}Some runtime paths may also carry sceneId as additional metadata, but the
authoritative lookup key is the globally unique sectionId.
The pointer always points to a specific line within a specific section. The engine uses this to:
- Retrieve the current line's content and actions
- Determine which lines to include in presentation state (all lines from start of section up to current line)
- Navigate forward/backward through the story
When nextLine is executed:
- Get the current pointer's
sectionIdandlineId - Find the section using
selectSection({ sectionId }) - Find the current line's index in
section.lines - Move to
lines[currentIndex + 1] - Update the pointer with the new
lineId
// Simplified nextLine logic
const section = selectSection({ sectionId });
const currentIndex = section.lines.findIndex((line) => line.id === lineId);
const nextLine = section.lines[currentIndex + 1];
pointer.lineId = nextLine.id;Each context maintains a single active read pointer:
pointers: {
read: { sectionId: '...', lineId: '...' }
}- The read pointer advances through lines sequentially during gameplay.
- Back navigation is handled by the rollback timeline, not a separate history pointer.
- Controlled by
nextLineConfig.manual enabled: Whether manual advancement is allowedrequireLineCompleted: Whether line must finish animating first
- Controlled by
nextLineConfig.auto enabled: Whether auto-advance is activetrigger: When to advance ('fromStart'or'fromComplete')delay: Milliseconds to wait before advancing
Global playback modes use a different timing model:
- Global
autoModestarts its delay after the current line is completed. - In practice, completion is driven by Route Graphics
renderComplete, so text reveal and other tracked render work finish first. - Global
skipModedoes not wait for completion; it advances aggressively on its own short timer. nextLineConfig.autois the only built-in auto-like behavior that can intentionally start from line start viatrigger: "fromStart".
Traditional visual novel style with one text box showing the current line. Each new line replaces the previous content.
Novel-style display where lines accumulate on screen. Text is appended rather than replaced.
Functions that mutate system state. Examples:
nextLine: Advance to next linerollbackByOffset: Go back through rollback checkpointssectionTransition: Jump to a different sectionjumpToLine: Jump to specific lineconditional: Execute the first matching action branchtoggleAutoMode/toggleSkipMode: Control playbacktoggleDialogueUI: Show/hide dialogue box
Side effects queued during action execution:
render: Re-render the current statehandleLineActions: Process actions attached to a linestartAutoNextTimer/clearAutoNextTimer: Auto mode timersstartSkipNextTimer/clearSkipNextTimer: Skip mode timersnextLineConfigTimer/clearNextLineConfigTimer: Authored next-line timers
The built-in createEffectsHandler(...) coalesces only the latest occurrence of replaceable built-in effects such as render, timer effects, line-action dispatch, and persistence effects. Custom effect names are preserved and must be handled explicitly.
The engine uses a custom store implementation (createStore) with:
- Selectors: Pure functions starting with
select*that read state - Actions: Functions that mutate state via Immer
const store = createStore(initialState, {
selectCount: (state) => state.count,
increment: (state) => {
state.count++;
},
});Two patterns for processing multiple actions:
Sequential Executor: Applies all actions to each payload in sequence
const executor = createSequentialActionsExecutor(createInitialState, actions);
const result = executor(payloads);Selective Executor: Applies only specified actions with their payloads
const executor = createSelectiveActionsExecutor(
deps,
actions,
createInitialState,
);
const result = executor({ actionName: payload });Tracks content the player has seen:
- sections: Array of
{ sectionId, lastLineId }entries - resources: Array of
{ resourceId }entries
For lines, this is intentionally a section-level frontier model:
lastLineIdmeans the furthest seen line reached within that section.- Any line at or before that frontier is treated as seen.
- This assumes section flow is effectively linear, which matches the engine's current use of seen-lines for skip behavior and progress tracking.
The frontier is updated when the current line is completed and also when advancing away from the current line. That keeps the final completed line in a section marked as seen even if there is no later line to move to.
Used for:
- Skip mode (skip only viewed content)
- Unlocking gallery items
- Tracking completion progress
Save slots store:
slotId: Unique identifiersavedAt: Unix timestampimage: Screenshot (base64)state: Serialized game state