This custom RiveScript-inspired engine is designed for building deeply interactive, dynamic, and state-aware dialogue systems within Godot 4.x.
It supports:
- Pattern-based triggers and responses
- Wildcards and substitutions
- Conditionals based on user memory, world state, or global systems
- Function-like macros and auto-loaded game state access
- Topic management and persona switching
RiveScript, created by AiChaos, is a lightweight scripting language designed for writing dialogue-based or narrative AI and chatbots. It uses human-readable syntax to define triggers (user inputs) and replies (bot responses), making it easy for writers and designers to craft conversational logic without deep programming knowledge. It intends a simple and intuitive approach to personalized and reactive dialogue. You can find out more here.
In this project, it has been extended to integrate tightly with Godot, allowing scripts to interact with game state, call functions, and manage narrative flow through a custom engine. GD-Rive isn't a 1:1 analogue implementation of RiveScript, nor does it implement all features in the RiveScript working draft. It provides a condensed sub-set suitable for game authoring and coupling with the Godot Engine.
GD-Rive doesn't intend to replace or compete with other dialogue managers, but to provide an alternative for specific use cases where a fluid narrative dialogue is preferred. Leveraging RiveScript essentially transforms any character (enemy, npc, companion, etc) in your game into a story and game world immersive chat-bot. Instead of dialogue trees and branches, it provides a non-linear, open, context aware, conversation driven dialogue engine. This system is intended predominantly for Open Narrative Visual Novels, Text Based Adventures, TTRPG simulators, User Content Extension, Procedural Quest and Content Generation, and other such game formats and concepts. For more traditional dialogue systems, the excellent Dialogue Manager by Nathan Hoad is strongly recommended.
graph TD
A[User Input] --> B[Input Normalization]
B --> C[Topic & Trigger Matching]
C --> D{Conditions?}
D -- Yes --> E{Evaluate Condition}
E -->|True| F[Conditional Response]
E -->|False| G{More Conditions?}
G -- No --> H{Fallback}
H -- Yes --> I[Fallback Response]
H -- No --> J[Random Reply from '-']
D -- No --> J
F --> K[Process Reply Tags]
I --> K
J --> K
K --> L[Reply Output]
subgraph Runtime Components
M[rive_engine.gd]
N[rive_conditions.gd]
O[rive_macros.gd]
end
K --> M
E --> N
K --> N
K --> O
GD-Rive can be installed as a plugin into your addons folder. After initialisation, you will need to reload your project.
The plugin provides a rudimentary scripting interface to load persona files and brain files, featuring syntax highlighting and error indication. The Rive Editor also allows for testing, trigger, and topic discovery.
Implementing custom file types in Godot is a non-trivial task and something beyond my capability at this time. For now, rive files are saved and stored with the .txt extension for editor and file system visibility. Rive files are separated into 2 conceptual categories:
brainfilespersonafiles
To make development easier, GD-Rive relies on convention to differentiate between these file types.
Brain files represent the shared or general knowledge you want to exist between characters. This may be common substitions or generic topics such as lore or world building.
Example brain files are provided with the plugin when initialized.
Persona files represent the individual speakers and characters in your game world. They provide flavour, character, and personality, and specific character knowledge.
Example persona files are provided with the plugin when initialized.
- On first run, the following file paths will be created
res://data/riveres://data/personasres://rive_engine
- Main interpreter and runtime handler for
rivefiles. Accesible as RiveEngine - Supports parsing, condition matching, macro execution, and global access.
- Autoload singleton holding functions callable via
<call>. - Dynamically registered via
register_all_macros().
- Stores world and player state: boolean flags and flexible key-value data.
- Used by
<condition>,<flag>,<data>, and<get-global>features.
res://
├── rive_engine/
│ ├── rive_macros.gd
│ └── rive_conditions.gd
├── data/
│ ├── rive/
│ │ ├── common.txt
│ │ ├── lore.txt
│ │ └── quests.txt
│ └── personas/
│ ├── eleanora.txt
│ ├── gronk.txt
│ └── sasha.txt
└── scenes/
└── game_logic.tscn
data/rive/: shared dialogue knowledge ("brain")data/personas/: individual character scripts
RiveEngine.[METHOD]- On startup, macros are auto-registered and
brainfiles are loaded. - While the engine will function without a
persona, it is recommended to callswitch_to_persona(persona)before interaction.
RiveEngine.switch_to_persona("gronk")switch_to_persona(name, with_brain)— Load persona-specificrivefrom name;with_brainoptionally loads brain context (defauttrue)reply(player_name, message)— Get reply string from inputload_brain(files)— Manually load brainrivefilesload_persona(file, with_brain)— Load one personarivefile;with_brainoptionally loads brain context (defauttrue)load_persona_with_own_brain(files, persona)— Load a persona with a specific set of brain files.reset()— Clear internal state except user memoryget_all_brain_files()— Returns an array of all brain files from the conventional folderset_default_brain(files)— Overwrites the selection of brain files commonly found viaget_all_brain_files()get_all_topics()— Returns topic namesget_triggers_for(topic)— Lists triggers under a topicget_topic_tree()— Exports the full topic tree including all triggersregister_all_macros()— Re-scanRiveMacrosfor callable methodsset_topic(player_name, topic_name)— Force topic switch for a given character from outside of the conversation.export_state()— Exports the current state of the engine for saving in a custom resource or save system.restore_state(state)— Import a previously exported engine state.
brain_files: Array[String]— An array of the default brain file paths.current_persona: String— The persona of the current speaker
RiveEngine emits the following signals for tracking or listening to:
topic_changed(topic_name: String)persona_changed(persona_name: String)
This section summarizes all core and extended tags and keywords available for scripting inside rive files.
An authoring guide and cheatsheet is hosted in the plugin's Rive Editor for quick reference.
> topic/< topic— Define and close a topic block! sub— Define input substitutions! array— Define reusable word groups!flag— set the value for a trigger-centric flag declaration+— Define a trigger (user input pattern)-— Define one or more replies^— Continuation of previous reply*— Use a wildcard in a trigger<set>/<get>— Store and retrieve user variables! var— Define bot-side variables (used via<bot>)
<condition>— Check flag or data fromRiveConditions<get-global>— Compare global property/method in a condition<flag>— Set a boolean flag<data>— Set a numeric or string value<set-global>— Assign a value to a singleton's property<global>— Call a global method (no return)<call>— Call a macro fromRiveMacros<call-global>— Call a method from any singleton and return its value
This section breaks down the core constructs of a rive file. These elements form the foundational structure for defining character dialogue, memory, and pattern matching.
> topic shop
+ buy *
- You bought <star>.
< topic
Topics group related triggers and replies into isolated scopes. This allows you to structure complex conversation systems without global keyword conflicts.
You can switch topics within the conversation flow with <set topic=topic_name> or use contextual rules to guide the conversation.
! sub i'm = i am
! sub don't = do not
Substitutions help normalize input before pattern matching. They are applied globally to all user input to simplify trigger patterns. This improves flexibility by accounting for common contractions and phrasing differences.
! array color = red green blue
Use in triggers like + I like @color
+ hello
Defines a user input pattern to match. Triggers initiate dialogue flow. Patterns can include literal phrases or wildcard structures. Triggers are matched against normalized input after substitutions are applied.
You can configure per-trigger options using inline flags directly beneath a + trigger. These begin with !flag and set options for advanced matching or behavior.
Example:
+ [*] @yes [*]
!flag allow_mid_wildcard=true
- Affirmative.
The above enables mid-pattern wildcard matching for this trigger.
Other trigger flags tbd
- Hello!
- Greetings, traveler.
Replies are tied to the preceding + trigger. Multiple replies can be defined for variety. If conditions (*) exist, they are evaluated first. Replies can contain embedded tags that manipulate state or inject dynamic content.
The ^ tag allows you to extend a reply (-) across multiple lines in your rive file. This is useful for maintaining readability in scripts with long responses.
+ tell me a story
- Once upon a time, in a land far away,
^ there lived a dragon who hoarded secrets.
^ One day, a traveler dared to speak its name.
This will be treated as a single concatenated response:
Once upon a time, in a land far away, there lived a dragon who hoarded secrets. One day, a traveler dared to speak its name.
Notes:
^must come after a-line; it cannot start a reply on its own.- Continuation lines are automatically concatenated with a space; no need to add leading spaces.
- This is purely a formatting feature and provides no functional capability.
- Continuation lines are only relevant to
repliesand not supported fortriggers.
+ my name is *
- Hello <star>.
Wildcards allow you to capture arbitrary user input. The captured content can be accessed in replies using <star>, <star1>, etc. This enables dynamic responses and memory assignment.
<set name=John>
<get name>
These tags enable persistent user memory. <set> stores a key-value pair specific to the user, and <get> retrieves it during future interactions. This is foundational for building personalized, stateful conversations.
! var spell = {fireball|teleport|ice bolt}
Defines bot-side variables used in responses. These can be referenced with <bot [VARIABLE NAME]> to inject randomly chosen or static content, e.g., for the above example
+ cast a spell
- I cast <bot spell>
Useful for adding flavor and unpredictability.
Conditionals allow dialogue to branch dynamically based on user memory, game state, or global values. Conditions are declared using * lines beneath a trigger (+). They are evaluated top-down, and the first matching condition returns the associated response.
<get var> == value: checks a user-specific variable<condition flag>: checks a boolean flag inRiveConditions<condition not flag>: checks the negated value of a flag<condition data.key op value>: compares a data entry inRiveConditions<get-global Singleton.method(...) == value>: evaluates a global function or property
+ check status
* <get mood> == happy => You seem pleased.
* <condition has_key> => You open the gate.
* <condition not has_key> => You need the key.
* <condition data.hp >= 30> => You're healthy.
* <get-global Inventory.has_item("sword") == true> => You're ready.
When a trigger has conditional responses, you can define a fallback using **. This line is used only if none of the * conditions match.
+ knock on door
* <condition door_open> => The door is already open.
** No one answers.
This ensures graceful degradation if no condition applies.
State tags can be embedded in reply lines (-) to change memory, flags, or invoke system logic:
<flag key=bool>sets a boolean inRiveConditions<data key=value>sets string or numeric data inRiveConditions<set-global Singleton.prop = value>assigns to a singleton variable<global Singleton.method(...)>invokes a global function (no return value)
+ take item
- You now have the key. <flag has_key=true>
+ train
- Your strength increases. <data strength=10>
+ enter forest
- Map updated. <set-global game_state.map = "forest">
+ complete quest
- Stage progressed. <global quest_tracker.advance(2)>
Macros allow dynamic responses using callable logic defined in GDScript. They can return a value into the reply or trigger logic in the game via registered functions.
Calls a GDScript method defined in RiveMacros.gd, which is registered at runtime.
+ what time is it
- It's <call>get_current_time</call>.
The get_current_time() function must exist in RiveMacros and return a string.
Invokes a method on any autoloaded singleton and inserts the return value into the response.
+ what time is it
- It's <call-global>Clock.get_time</call-global>.
You can also pass arguments:
- Status: <call-global>Player.get_status("mood")</call-global>
- Only expose methods meant to be accessed from dialogue.
- Avoid mutating state via return-based macros (use
<global>if needed). - Use return values strictly for response content.
Global access allows dialogue scripts to interact directly with autoloaded singletons in the Godot engine. This powerful feature makes it possible to inspect game systems and trigger changes from within dialogue, but it also requires thoughtful design.
- Global access can read or modify runtime state.
- Scripts should only access globals that are intended to be exposed.
- Prefer encapsulated, readonly methods where possible to avoid accidental state mutation.
- Scripts should only access globals that are intended to be exposed.
RiveEngineusesEngine.get_singleton()internally to resolve singleton access; if the global / autoload does not live in this space, it will not be accessible.
! sub i'm = i am
! array direction = north south east west
! var mood = {happy|sad|tired}
! var spell = {fireball|frostbolt|illusion|levitate|enchant|polymorph|banish|ward|scry}
> topic general
+ hello
- Greetings, traveler!
- Hello there.
+ i am feeling @mood
- I see. You are <star>.
+ what is your name
- My name is Eleanora of the Verdant Sigil.
+ my name is *
- <set name=<star>>Nice to meet you, <get name>.
+ where should i go
* <condition data.strength >= 10> => You are strong. Go <bot direction>.
** Just follow your heart.
+ give me the key
- Very well. <flag has_key=true>
+ open the door
* <condition has_key> => You unlock the door.
** It won't budge.
+ what is my strength
* <condition data.strength >= 10> => You currently have high strength.
* <condition data.strength < 10> => You are weak.
+ train
- Your strength increases. <data strength=12>
+ what time is it
- It is <call-global>Clock.get_time</call-global>.
+ check inventory
* <get-global Inventory.has_item("sword") == true> => You are armed.
* <get-global Inventory.has_item("sword") == false> => You are unarmed.
+ update location
- Marked on map. <set-global Map.last_position = "dungeon">
+ tell me how you know this man
- It all started a long time ago... <global SceneManager.play("intro")>
+ cast a spell
- I cast <bot spell>!
< topic
