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
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 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. IfsavedStateJson
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?
Oh right, rendering fonts is the worst.
UI Hell
Zoom in, and the kerning falls apart.
I tried to fix it. Nope.
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…
Finishing details
I added the talking bubble style with a stacking effect for the same speaker.
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.