8 min read
Dialogue System

After using Ink for a Wizard Dating Sim, I realized it was the perfect fit for Skymagi’s dialogue system. Its flexibility for crafting narratives is unparalleled, the only downside is i18n difficulties.

To think I was planning to write my own…

So, Skymagi’s dialogue is like a Dungeon Master. A narrator sets the scene and provides context. Then the player makes choices with gameplay consequences. These choices may start combat, a quest, open a shop, or other scripts.

FTL kept this premesis simple, but Skymagi has RPG roots. I want character dialogue. Reactions, conversations, banter. Think Baldur’s Gate or Arco.

That means my dialogue system needs to handle longform narration and bursts of character interaction.

First, lets write some ink

I knew I’d need basic events: an intro, empty skies, hostile bandit mages, shops.

I’ve already written Skymagi’s intro, but I want to establish the playthrough tone from the beginning. Get the player thinking about their cabal’s motivations. What drives them?

// ...
Welcome to the end times.
* [We can't stop. We have to keep moving.] // pragmatic survivor
* [I will stop the storm. Somehow.] // idealist hero
* [The Mages Guild brought this on us. I'll see it burn.] // vengeful
* [When law breaks down, I will become the law.] // lawful, authoritarian
* [No one left to hold me back.] // chaotic, opportunist
* [This isn't the end. It's a new beginning.] // optimist, rebuilder
-> END

I love ink. Having a DSL is underrated. The content becomes the focus over the form.

The inner-DM in me fell into writing events for hours. But I just want enough content to experiment for now.

Here’s some empty events. Ink handles randomization for me.

INCLUDE ../globals.ink
{~ 
  - A strange stillness takes the skies as the hum of teleportation fades. Your cabal murmurs about ghosts in the clouds.
  - The land below was once a bustling outpost for the Mages Guild. Now, only a gaping crater marks where it was uprooted.
  - Your scrying reveals the wreckage of two fallen castles. Neither survived and it seems scavengers got here first.
  - A flock of herons glide past your battlements, unbothered. They're heading toward the eye, just like the rest of us. 
  - The skies stretch quiet and blue. There's nothing here but wind and the creaking wood of the castle.
}
-> CONTINUE

Ink stories must finish with END. Since players should be prompted before closing dialogue, I wrote a wrapper CONTINUE.

// in globals.ink
=== CONTINUE ===
+ [Continue...]
-> END

I could’ve handled this in system code, but this keeps narrative flow control in Ink. globals.ink also establishes universal variables.

Even more complex interactions were a breeze. I established a common set of functions and tags while writing, driving requirements for implementation. An illustrative example from playing too much Oblivion Remastered:

bandit: A raven arrives: "Your money or your life."
* [Pay 150g] #cost 150g
  ->CONTINUE
* [Attack]
  bandit: "Then pay with your blood."
  ~setHostile(bandit)
  ->CONTINUE
* {has_charm_person} [[Use enchantment sigil]] #cast enchant
  bandit: "Oh, I'm so sorry, I didn't realize how gracious you were. You may pass."
  ->CONTINUE
* {has_necro} [[They've lost a few mages. Raise them pre-emptively.]]
  #spawn skeleton 2 rand
  bandit: "Sickening dark magic. You'll pay for this."
  ->CONTINUE

UI Design

Overall, my primary goals:

  • Distinguish narration and dialogue text
  • Use serif font to make it feel more fantasy without reducing readability
  • Font size for longform narration and conversations
  • Allow for quick choices via clicking or pressing a number key
  • Easy to skim since it’s a roguelike

Text

Swapping dark/light for text/narration distinguishes text and narration. I originally tried dark gray, but blue felt easier on the eyes.

I reused character portraits from the HUD and turned text into a classic dialogue bubble.

Choices

Choices can happen in conversation and narration, so two variants. My writing will have to use consistent indicators for actions versus talking.

Ink Integration

Using blade-ink-java, I built an InkStorySystem that handles narrative commands like startStory, continueStory, and tryPickOption.

Each story is represented by an ActiveStory component. While ink’s Story object contains runtime and logic, its output history must be stored separately for rendering and persistence.

class ActiveStory implements Component {
    // an id for my own tracking of the asset
    public String storyId;
    // the actual story ink runtime (not serialized of course)
    public transient Story story;
    // Used for saving/reloading mid-story. Snapshot of current story state.
    public String savedStateJson;
    // outputs are stored/formatted optimized for my dialogue rendering
    public Array<Entry> history;
}
// story history entry
class Entry {
    EntryType type; // Enum: Narrate, Talk
    String content; // output w/o label
    String label; // text label if talk
    String portraitId; // texture id for portrait if there is one
    boolean portraitRight; // right or left display
}

The InkStorySystem is lean since Ink handles the narrative logic.

startStory:

  • Spawns an ActiveStory entity. If savedStateJson exists, restores story progress from it.
  • Issues StasisPauseCommand to pause gameplay with a special flag.

continueStory:

  • Advances the story. Parse content/labels/tags into Entry objects. Snapshots the current story’s state.
  • If there’s no more content, awaits a choice or ends.

tryPickOption:

  • Attempts to choose an option for the ActiveStory. If valid, continues the story.

Additionally, a StoryCommand component is processed to invoke the above functions for decoupled communication between other systems.

Now, the UIRenderSystem handles downstream rendering of history. And… viola?

thats ugly

Oh right, rendering fonts is the worst.

UI Hell

Zoom in, and the kerning falls apart. pain

I tried to fix it. Nope.

agony

Fonts in LibGDX are rasterized bitmaps locked to fixed sizes. My PixelUiViewport scales the UI to the nearest integer multiple of 960x540, but I want text rendered at full, native resolution.

I tried font.setScale to correct it, but that squashed the bitmap before rendering, which ruined the kerning, spacing, legibility.

Likewise, the GlyphLayout relied on an accurate maxWidth for wordwrapping. So font.setScale doubled the text max width incidentally.

Moreover, multiple dialogue entries are stacked on each other. I need to keep track of each box’s height to render the next, but that height is dynamic based on text wrap.

A microcosm of the nightmare I hacked together.

// create word-wrapped glyph layout based on the pixel UI width, but rescale to full text size
float dialogueTextWidth = DIALOGUE_WIDTH * textScale;
float dialoguePadText = DIALOGUE_PAD * textScale;
float maxTextWidth = dialogueTextWidth - dialoguePadText * 2;
// glyphlayouts must be pooled to avoid reallocating memory on every render call
GlyphLayout textLayout = Layouts.obtain();
textLayout.setText(textureCache.font, curText, TEXT_COLOR, maxTextWidth, Align.left, true);

// find total height of the text to draw the frame around
float textHeight = (textLayout.height / textScale);
float panelHeight = textHeight + DIALOGUE_PAD * 2;
DrawDropShadow(batch, dialogNarrate, panelX, panelY, DIALOGUE_WIDTH, panelHeight, 2, 1);
dialogNarrate.draw(batch, panelX, panelY, DIALOGUE_WIDTH, panelHeight);

// font is rendered at different resolution from rest of the game
// so swap and rescale whole viewport
batch.end();
Matrix4 old = batch.getTransformMatrix().cpy();
batch.getTransformMatrix().scl(1 / textScale);
batch.begin();
textureCache.font.draw(batch, textLayout, textX, textY);
batch.end();
batch.setTransformMatrix(old);
batch.begin();

Deferred Text Rendering

If you’re mixing pixel art UI with full-resolution text: use deferred text rendering.

Rather than drawing the text and frames together, have a queue that stores all text that can be drawn in a single scaled draw call at the end.

After hours of refactoring, I introduced a TextRenderQueue:

class TextRenderQueue {
    float textScale = 1;
    Array<TextCmd> queue;

    // start new queue
    void begin(float textScale);
    // draw text, but defer until flush
    void deferText(x, y, BitmapFont font, String text, float wrapWidth);
    // rescale viewport, draw all text at native size
    void flushRender(SpriteBatch batch);
}

Which simplified that nightmare above into…

// draw text, but defer its render until end of frame.
TextRenderQueue.TextCmd textLayout = textRenderQueue.deferText(
    x, y, textureCache.font, entry.content, textColor, dialogueWidth, Align.left
);
// text height conversion to calculate total frame height
float panelHeight = textLayout.getUiHeight() + DIALOGUE_PAD * 2;
float panelWidth = dialogueWidth + DIALOGUE_PAD * 2;
DrawDropShadow(batch, x, y, panelWidth, panelHeight, 1);
dialogNarrate.draw(batch, x, y, panelWidth, panelHeight);

Using TTF in LibGDX

With text rendering correctly, I needed the proper font!

I’d already used custom fonts before, but those were pixel fonts. This time, I needed a scalable font rendered at the correct size for the player’s monitor.

LibGDX doesn’t support TTFs, but the FreeTypeFontGenerator library can generate bitmap fonts from TTF.

I wrote a heuristic mixing Gdx.graphics.getDensity() with viewport size, generate the font while loading, and…

dialogue v1

Finishing details

I added the talking bubble style with a stacking effect for the same speaker.

Stacked talking

And just like that, I could write complex and advanced stories with multiple characters with ink.

Dialogue demo

An example event where the party can recruit a new apprentice to join the cabal.