Skip to main content

Example 2: Dialogue Controller

We’ll create a dialogue controller like the one shown below. Let’s build it together, step by step, as we gradually add DSL syntax.

Create a Scene

First, create a scene with a Control node named Dialogue. Then, add child nodes so that the scene tree looks like the example below.

Make sure that Content and Button are accessible by their unique node names.

Node tree
Dialogue <Control Node>
├ ColorRect
├ Content <RichTextLabel Node>
└ Button

Resize the ColorRect to serve as the dialog frame, and place the Content inside it. Finally, position the Button below the frame.

Create a scene

If the text looks too small, try adjusting the Font Size from the Inspector.

Designing the DSL

Let's explore a DSL for displaying dialogue content within the Content node of the scene we created. Ideally, it should offer the readability and ease of use of plain text.

Striving for Readability

Since we want the lines to be easy to read aloud, we aim for a format like: (Character) say "(Line)"

This structure feels natural and expressive.

How should we define this?

Defining via Method Calls

Let's begin with a simple approach: using method calls with keyword-style arguments.

Alice says: "I love Ruby."

This is beautiful—just one line, with clarity and elegance.

However, if we continue writing this way, the alignment of says can become uneven depending on the length of character names, which slightly affects readability. Also, when the same character speaks multiple times in succession, the repetition can feel verbose.

Alice says: "I love Ruby."
Benjamin says: "I love Ruby too."
Benjamin says: "I also love Godot."

Defining with Blocks

Next, let's consider using blocks.

Since each character name is followed by a line break, the position of says remains aligned. This also helps avoid redundancy when the same character speaks multiple times.

Alice do
says "I love Ruby."
end

Benjamin do
says "I love Ruby too."
says "I also love Godot."
end

However, the extra line required for end can feel a bit distracting.

What we're aiming for is a DSL where dialogue flows naturally from top to bottom―spoken lines by characters, readable and writable like a gentle stream. Ideally, we'd like to omit any unnecessary lines.

Using const_missing

Is it possible to remove the end lines from the code above and still make it work? After some thought, it seems feasible with the following structure:

Alice;
says "I love Ruby."

Benjamin;
says "I love Ruby too."
says "I also love Godot."

By treating character names as constants rather than methods, and leveraging metaprogramming via const_missing, we can eliminate the extra end lines.

Creating a Helper to Run the DSL

Let's write the code needed to run the DSL above. Save the following code as a helper.rb file in the same directory as the scene.

I ended up using a mix of metaprogramming techniques―but all in service of bringing the ideal DSL to life.

helper.rb
# = Redirects method calls to Xxx.current
#
# `delegate Foo, :bar` means calling `bar` will forward to `Foo.current.bar`
#
def delegate(const, *keys)
keys.each do |key|
define_method(key) do |*args|
const.current.send(key, *args)
end
end
end


class Dialogue
class << self
attr_accessor :current
end

attr_accessor :fiber

def initialize(&block)
self.fiber = Fiber.new do
yield
Godot.emit_signal :finished, true
end
Dialogue.current = self
end

def continue(val = nil)
fiber.resume(val)
end

def listen!
Fiber.yield
end
end
delegate Dialogue, :continue #


class Speaker
class << self
attr_accessor :all, :current
end
self.all = []

attr_accessor :name

def initialize(name)
self.name = name
Speaker.all << self
end

def says(str)
emit :says, { content: str }
Dialogue.current.listen!
end

private
def emit(key, args = {})
Godot.emit_signal key, { name: name, **args }
end
end
delegate Speaker, :says #


# = const_missing
#
# `Alice;` sets `Speaker.current` to the Speaker instance named "Alice"
#
def Object.const_missing(name)
speaker = Speaker.all.find{|s| s.name == name.to_s }
if speaker
Speaker.current = speaker
else
super
end
end


def speakers(names)
names.each{|name| Speaker.new(name) }
end

Once you've saved the file, try running the following in the REPL:

tip

You'll need to specify the path to the file you just created in require 'path/to/helper'.

If typing it out feels tedious, you can simply drag and drop the Ruby file from the FileSystem into the REPL input field.

This will automatically insert the correct require 'path/to/helper' line, and this feature works not only in the REPL, but also in the Editor.

require 'path/to/helper'

Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

Narrator;
says "One sunny afternoon,"
says "Alice met the White Rabbit"

Alice;
says "Hi! I'm Alice. I'm curious!"

WhiteRabbit;
says "Hi, I'm late. I'm busy."
end
Output
=> "#<Dialogue:0x177babf47d0 @fiber=#<Fiber:0x177babf46e0 (created)>>"

The Fiber was successfully created without any errors.

Next, run the following:

continue
Output
[ says ] signal emitted: { &"name": "Narrator", &"content": "One sunny afternoon," }
=> <null>

A signal with the key says was emitted. The content was correctly assigned to the corresponding name. It looks like it's working smoothly.

Now, go ahead and run continue four more times.

Output
[ says ] signal emitted: { &"name": "Narrator", &"content": "Alice met the White Rabbit" }
=> <null>

[ says ] signal emitted: { &"name": "Alice", &"content": "Hi! I\'m Alice. I\'m curious!" }
=> <null>

[ says ] signal emitted: { &"name": "WhiteRabbit", &"content": "Hi, I\'m late. I\'m busy." }
=> <null>

[ finished ] signal emitted: true
=> true

Each name was correctly paired with the expected content.

Finally, a signal with the key finished was emitted, indicating the process has completed successfully.

So what happens if we call continue again after it's already finished? The answer: it results in an error.

Output
Error: resuming dead fiber (FiberError)

A Fiber maintains its own state to indicate whether it has finished.

Dialogue.current.fiber

Dialogue.current.fiber.alive?
Output
=> "#<Fiber:0x177babf46e0 (terminated)>"

=> false

When working with a Fiber, you can either handle its state using the methods above, or―like in this example―emit a signal at the end of the Fiber.new do ... end block to avoid errors.

Attach a GDScript

Now that the DSL syntax and signal structure are in place, let's write the logic for the Dialogue scene.

Attach the following GDScript to the scene:

dialogue.gd
extends Control

@export var controller : ReDScribe


func _ready() -> void:
controller.channel.connect(_handle)
%Button.pressed.connect(continue_dialogue)
clear()


func speak(speaker: String, content: String) -> void:
%Content.text = "(%s)\n%s" % [speaker, content]


func continue_dialogue() -> void:
controller.perform('continue')


func clear(all: bool = false) -> void:
%Content.text = ''
if all: %Button.hide()


func _handle(key: StringName, payload: Variant) -> void:
match key:
&'says': speak(payload['name'], payload['content'])
&'finished': clear(true)
_: print_debug('[%s] %s', [key, payload])

Create a Scenario (Ver. 1)

Everything's ready. Let's write the conversation script and save it as scenario.rb.

You might not even realize this is written in a programming language—but that's the magic of Ruby's flexibility.

scenario.rb
require 'path/to/helper'
Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

Narrator;
says "Alice is resting in the field."
says "Suddenly, she hears a panicked voice from afar."

WhiteRabbit;
says "I'm late, I'm late, I'm late!"

Alice;
says "Hi! Where are you going?"

WhiteRabbit;
says "I'm late, I'm late, for a very important date!"

Alice;
says "Wait!"
end

Click the Controller property in the Inspector and select New ReDScribe.

New ReDScribe

Set the scenario.rb file you created as the Boot File, then run the scene.

Did the conversation dialogue appear as shown in the video below?

If you found this step difficult, the demo project is available on the following GitHub repository. Feel free to check it out: https://github.com/tkmfujise/redscribe-docs-demo/tree/main/src/08.dialogue_basic

Controlling Character Expressions

You've built the foundation of the conversation dialogue. Next, let's add face graphics to bring the dialogue to life.

Create the Speaker Scene

Create a new scene named Speaker to manage face graphics.

Start with a Sprite2D node, and assign a 2x2 tile image that includes both the default and flustered expressions. (If you'd like to use the same image as the sample, you can download it here.)

Set HFrames and VFrames to "2".

We'll use Frame Coords to control which character and expression are displayed.

Speaker scene

speaker.gd
extends Sprite2D
class_name Speaker

enum Name { Alice, WhiteRabbit }
enum Face { DEFAULT, FLUSTERED }

@export var speaker_name : Name : set = set_speaker
@export var face : Face : set = set_face


func set_speaker(val: int) -> void:
frame_coords.y = val
speaker_name = val as Name


func set_face(val: int) -> void:
frame_coords.x = val
face = val as Face

Add to the Dialogue Scene

Add the Speaker scene to the Dialogue scene and position it within the dialogue frame.

Make sure the Speaker node can be accessed by its unique name.

Add the Speaker scene

Before modifying the GDScript, let's consider how to update the DSL.

Update the Scenario (Ver. 2)

We'll extend the DSL with syntax to control character expression.

To do this, we'll define two methods: one for temporarily changing a character's expression, and one for changing it persistently.

  • Someone; with :some_face sets the expression until the next speaker speaks.
  • Someone; got :some_face sets the expression persistently until another expression is assigned.
scenario.rb
require 'path/to/helper'
Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

Narrator;
says "Alice is resting in the field."
says "Suddenly, she hears a panicked voice from afar."

WhiteRabbit; got :flustered
says "I'm late, I'm late, I'm late!"

Alice;
says "Hi! Where are you going?"

WhiteRabbit;
says "I'm late, I'm late, for a very important date!"

Alice; with :flustered
says "Wait!"
end

Enable support for with and got calls, and update the signal to include the face attribute.

helper.rb
class Speaker
class << self
attr_accessor :all, :current

def current=(speaker)
current.temporary_face = nil if current
@current = speaker
end
end
self.all = []

attr_accessor :name, :temporary_face, :permanent_face

def initialize(name)
self.name = name
self.permanent_face = ''
Speaker.all << self
end

def says(str)
emit :says, { content: str }
end

def with(face)
self.permanent_face = ''
self.temporary_face = face.to_s
end

def got(face)
self.permanent_face = face.to_s
end

def face
temporary_face || permanent_face
end

private
def emit(key, args = {})
Godot.emit_signal key, { name: name, face: face, **args }
end
end
delegate Speaker, :says, :with, :got #

Update the GDScript

Now that the DSL syntax and the shape of the emitted signals are defined, let's update the logic in the Dialogue scene.

We'll modify the speak method to change the face graphic.

dialogue.gd
func speak(speaker: String, content: String, face: String) -> void:
%Content.text = "(%s)\n%s" % [speaker, content]
set_speaker(speaker)
set_face(face)


func set_speaker(speaker: String) -> void:
%Speaker.show()
match speaker:
'Alice': %Speaker.speaker_name = Speaker.Name.Alice
'WhiteRabbit': %Speaker.speaker_name = Speaker.Name.WhiteRabbit
_: %Speaker.hide()


func set_face(face: String) -> void:
match face:
'flustered': %Speaker.face = Speaker.Face.FLUSTERED
_: %Speaker.face = Speaker.Face.DEFAULT


func clear(all: bool = false) -> void:
%Speaker.hide()
%Content.text = ''
if all: %Buttons.hide()


func _handle(key: StringName, payload: Variant) -> void:
match key:
&'says':
speak(payload['name'], payload['content'], payload['face'])
&'finished': clear(true)
_: print_debug('[%s] %s', [key, payload])

Try running it. If the face graphic switches correctly, you're good to go!

Switching the face graphic

Displaying Choices

Let's add the ability to show choices during the dialogue.

Update the Scenario (Ver. 3)

We'll extend the DSL to support displaying choices.

The idea is to define a method for showing choices and another for retrieving the selected value:

  • asks "(line)" will display the dialogue line along with a set of choices.
  • ___? will retrieve the value selected by the user.
scenario.rb
require 'path/to/helper'
Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

Narrator;
says "Alice is resting in the field."
says "Suddenly, she hears a panicked voice from afar."

WhiteRabbit; got :flustered
says "I'm late, I'm late, I'm late!"

Alice;
says "Hi! Where are you going?"

WhiteRabbit;
says "I'm late, I'm late, for a very important date!"

Alice; with :flustered
says "Wait!"

Narrator;
says "Alice chased after the rabbit."
says "But the rabbit disappeared into a burrow."

Alice;
says "He went in here."
asks "Should I go in too?"
unless ___?
says "It looks so narrow and grimy... I really shouldn't."
until ___?
says "But I just can't stop wondering."
asks "Maybe I should go in after all?"
end
end
says "Alright, here goes!"

Narrator;
says "As Alice entered the burrow, the ground gave away and she fell down."
end

How do you feel about it? Everyone experiences if differently, of course, but I personally enjoy the snappy contrast between says and asks, and the way ___? feels like filling in a blank—it's quite satisfying.

We'll enable support for calling asks and ___?, and update the signal to include choices.

helper.rb
class Dialogue
class << self
attr_accessor :current
end

attr_accessor :fiber, :last_value

def initialize(&block)
self.fiber = Fiber.new do
yield
Godot.emit_signal :finished, true
end
Dialogue.current = self
end

def continue(val = nil)
fiber.resume(val)
end

def listen!
self.last_value = Fiber.yield
end

def ___?
last_value
end
end
delegate Dialogue, :continue, :___? #


class Speaker
class << self
attr_accessor :all, :current

def current=(speaker)
current.temporary_face = nil if current
@current = speaker
end
end
self.all = []

attr_accessor :name, :temporary_face, :permanent_face

def initialize(name)
self.name = name
self.permanent_face = ''
Speaker.all << self
end

def says(str)
communicate :says, str
end

def asks(str, choices = { 'Yes' => true, 'No' => false })
communicate :asks, str, choices
end

def with(face)
self.permanent_face = ''
self.temporary_face = face.to_s
end

def got(face)
self.permanent_face = face.to_s
end

def face
temporary_face || permanent_face
end

private
def emit(key, args = {})
Godot.emit_signal key, { name: name, face: face, **args }
end

def communicate(key, str, choices = {})
emit key, { content: str, choices: choices }
Dialogue.current.listen!
end
end
delegate Speaker, :says, :asks, :with, :got #

Update the Dialogue Scene

Rename the original Button node to ButtonTemplate and set it to hidden.

Create a new HBoxContainer node named Buttons, and add a single Button as its child for layout adjustment.

Node tree
Dialogue <Control Node>
├ ColorRect
├ Speaker <Speaker Scene>
├ Content <RichTextLabel Node>
├ Buttons <HBoxContainer Node>
│ └ Button
└ ButtonTemplate

Create the ButtonTemplate

Update the GDScript

Update the logic so that when says or asks signals are emitted, choices are automatically generated.

To do this, use the set_choices method to duplicate the ButtonTemplate and create buttons for each choice.

Also, by using continue_dialogue.bind(value), you can pre-bind the value that will be passed to the Fiber when a button is pressed.

dialogue.gd
func set_choices(choices: Dictionary = { 'Continue': null }) -> void:
for child in %Buttons.get_children(): child.queue_free()
for key in choices: add_choice(key, choices[key])


func add_choice(txt: String, value: Variant) -> void:
var btn = %ButtonTemplate.duplicate()
%Buttons.add_child(btn)
btn.text = txt
btn.pressed.connect(continue_dialogue.bind(value))
btn.show()


func continue_dialogue(value) -> void:
controller.perform('continue %s' % _value_for_rb(value))
if controller.exception:
printerr("controller: %s" % controller.exception)


func clear(all: bool = false) -> void:
%Speaker.hide()
%Content.text = ''
set_choices()
if all: %Buttons.hide()


func _value_for_rb(value: Variant) -> Variant:
match typeof(value):
TYPE_STRING_NAME: return ':%s' % value
TYPE_STRING: return '"%s"' % value
TYPE_NIL: return 'nil'
_: return value


func _handle(key: StringName, payload: Variant) -> void:
match key:
&'says':
speak(payload['name'], payload['content'], payload['face'])
set_choices()
&'asks':
speak(payload['name'], payload['content'], payload['face'])
set_choices(payload['choices'])
&'finished': clear(true)
_: print_debug('[%s] %s', [key, payload])

Once you run it, you should see the choices appear as expected.

Add choices

Changing Background Image or BGM

With what we've implemented so far, the dialogue system now supports the following features:

  • Displaying text
  • Changing face graphics
  • Showing choices

In addition to these, what if you want to add more dramatic efforts—such as changing the background image or BGM?

Update the Scenario (Ver. 4)

Here, I'll introduce a new method scene :scene_name to represent scene transitions, such as changing the background image. This allows you to specify the timing of the transition directly within the scenario.

scenario.rb
require 'path/to/helper'
Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

Narrator;
says "Alice is resting in the field."
says "Suddenly, she hears a panicked voice from afar."

scene :riverbank

WhiteRabbit; got :flustered
says "I'm late, I'm late, I'm late!"

Alice;
says "Hi! Where are you going?"

WhiteRabbit;
says "I'm late, I'm late, for a very important date!"

Alice; with :flustered
says "Wait!"

Narrator;
says "Alice chased after the rabbit."
says "But the rabbit disappeared into a burrow."

scene :burrow

Alice;
says "He went in here."
asks "Should I go in too?"
unless ___?
says "It looks so narrow and grimy... I really shouldn't."
until ___?
says "But I just can't stop wondering."
asks "Maybe I should go in after all?"
end
end
says "Alright, here goes!"

Narrator;
says "As Alice entered the burrow, the ground gave away and she fell down."
end

Add the scene method so that it emits a background signal.

helper.rb
class Dialogue
class << self
attr_accessor :current
end

attr_accessor :fiber, :last_value

def initialize(&block)
self.fiber = Fiber.new do
yield
Godot.emit_signal :finished, true
end
Dialogue.current = self
end

def continue(val = nil)
fiber.resume(val)
end

def listen!
self.last_value = Fiber.yield
end

def ___?
last_value
end

def scene(sym)
Godot.emit_signal :background, sym
end
end
delegate Dialogue, :continue, :___?, :scene #

Update the GDScript

The implementation is omitted, but you should be able to change the background image by modifying the _handle method.

dialogue.gd
func _handle(key: StringName, payload: Variant) -> void:
match key:
&'says':
speak(payload['name'], payload['content'], payload['face'])
set_choices()
&'asks':
speak(payload['name'], payload['content'], payload['face'])
set_choices(payload['choices'])
&'background': print_debug("TODO [background] %s" % payload)
&'finished': clear(true)
_: print_debug('[%s] %s', [key, payload])

Refining the DSL

As the content displayed in the dialogue grows longer, some of you may start to feel that the DSL-specific keywords like says, asks and scene are beginning to feel like noice.

At first, I liked the idea of writing readable sentences like (Charater) say "(Line)", something you could almost speak aloud. But what truly matters is making it easy to write the character and their line.

So, how can we reduce the noise?

Update the Scenario (Ver. 5)

By using operator overloading, we can eliminate the need to explicitly write says, asks, or scene.

Let's add the following to helper.rb:

helper.rb
module SymbolExt
def ~@
scene self
end
end
Symbol.prepend SymbolExt

module StringExt
def -@
says self
end

def !@
asks self
end
end
String.prepend StringExt

Each operator now acts as an alias:

  • says "content" becomes - "content"
  • asks "content" becomes ! "content"
  • scene :scene_name becomes ~ :scene_name

Here's how the revised scenario looks:

scenario.rb
require 'path/to/helper'
Dialogue.new do
speakers %w(Narrator WhiteRabbit Alice)

Narrator;
- "Alice is resting in the field."
- "Suddenly, she hears a panicked voice from afar."

~ :riverbank

WhiteRabbit; got :flustered
- "I'm late, I'm late, I'm late!"
Alice;
- "Hi! Where are you going?"
WhiteRabbit;
- "I'm late, I'm late, for a very important date!"
Alice; with :flustered
- "Wait!"
Narrator;
- "Alice chased after the rabbit."
- "But the rabbit disappeared into a burrow."

~ :burrow

Alice;
- "He went in here."
! "Should I go in too?"
unless ___?
- "It looks so narrow and grimy... I really shouldn't."
until ___?
- "But I just can't stop wondering."
! "Maybe I should go in after all?"
end
end
- "Alright, here goes!"

Narrator;
- "As Alice entered the burrow, the ground gave way and she fell down."
end

What do you think? By using operators, we've made the syntax more compact while keeping it readable. It's hard to believe this is still Ruby—it almost feels a plain-text format designed specially for dialogue systems.

Of cource, someone seeing this for the first time might not immediately grasp what each operator means or how it bahaves.

But compared to third-party dialogue systems, this approach offers a major advantage: you got to define the grammar yourself. There's no need to memorize awkward syntax or learn special embedded formats for control flow or variables. If something doesn't feel right, you can reshape it to fit your hand.

No need to write a parser—you can define your own grammar freely. Ruby will likely respond flexibly to the kind of game you want to create.

You can check out the demo we created here: https://github.com/tkmfujise/redscribe-docs-demo/tree/main/src/08.dialogue_refined

If you're having trouble getting it to work, feel free to use the above as a reference.