Create your own
Lesson illustration

Typewriter Dialogue with Player Choices

Hello! Welcome back to our journey into creating a JRPG.

In our last lesson, we designed the "blueprint" for our dialogue system: a flexible JSON data format that can represent complex, branching conversations with character portraits and conditional logic. We established the data model for our dialogue.

Today, we'll bring that blueprint to life. This lesson focuses on the learning outcome: Implement a dialogue UI with a typewriter text effect and support for player choices. We will build the "view" and "controller" for our dialogue system—the visual components the player interacts with. Given your background in front-end development, you can think of this as building a stateful UI component that receives data (our dialogue JSON) as a "prop," renders it to the screen, and handles user input to update its state.

1. The Anatomy of a Dialogue UI Component

We'll approach this by conceptualizing a DialogueUI class or object. This component will be responsible for everything related to displaying dialogue on the screen. Its core responsibilities are:

  1. Drawing the Window: Creating the container for text and portraits.
  2. Rendering Text: Displaying the dialogue string with a classic "typewriter" effect.
  3. Handling Progression: Responding to player input to advance text or skip the typewriter effect.
  4. Managing Choices: Displaying a list of options and allowing the player to select one.

Let's start with the visual foundation: the dialogue box itself.

2. Drawing the Dialogue Window

At its simplest, a dialogue window is a rectangle drawn on the canvas. However, for that classic JRPG feel, we often want a stylized window with a border. A common and efficient technique for this is using a 9-slice sprite.

A 9-slice sprite is an image that's divided into a 3x3 grid. When you scale the window, the corners remain unscaled, the top and bottom edges are stretched horizontally, the side edges are stretched vertically, and the center is stretched in both directions. This prevents the borders from looking distorted. This is conceptually similar to the border-image property in CSS.

The following video demonstrates how to set up the parameters for a text box and draw it using this scalable method.

Branching Dialog System with Effects in GMS 2! (Part 1: Setup and Typing Effect)

In this video from 'Branching Dialog System with Effects in GMS 2!', Peyton Burnham explains how to set up the variables for a text box and render it using a 9-slice sprite. The concepts are directly applicable to a canvas-based renderer.

Please watch two segments: 'Setting up the Text Box Object and Variables' (6:48 - 9:57): Focus on the variables being defined: text_box_width, text_box_height, border, and line_sep. This is the basic configuration for our dialogue window. 'Drawing the Text Box Background and Text' (26:46 - 30:00): Pay attention to the logic for drawing the scalable background (draw_sprite_ext). You don't need to worry about the GML-specific function names, but grasp the concept of using the sprite's original dimensions versus the target dimensions to calculate the scale.

In our JavaScript canvas implementation, the logic for a DialogueUI would look something like this:

class DialogueUI {
    constructor() {
        // Dimensions and position on the screen
        this.x = 50;
        this.y = 400;
        this.width = 700;
        this.height = 150;
        this.padding = 20;
    }

    draw(canvasContext) {
        // Draw a simple box for now
        canvasContext.fillStyle = 'blue';
        canvasContext.fillRect(this.x, this.y, this.width, this.height);

        canvasContext.strokeStyle = 'white';
        canvasContext.lineWidth = 4;
        canvasContext.strokeRect(this.x, this.y, this.width, this.height);
        
        // Later, this would be replaced with 9-slice logic
        // for drawing a more detailed window sprite.
    }
}

3. Implementing the Typewriter Effect

The typewriter effect, where text appears character by character, is an iconic feature of JRPGs. The implementation is a straightforward piece of state management. We need a counter to track how many characters of the current line are visible.

In each frame of our game loop, we increment this counter and then draw a substring of the full text.

How do a make a Dialogue System?

For a very clear and concise explanation of the core typewriter logic, a forum post by user FrostyCat on the GameMaker forums is an excellent resource. It cuts through the noise and focuses on the essential counting variables.

Read through the post by FrostyCat. Focus on the variables textIndex and currentTextProgress. See how currentTextProgress is incremented in the Step event (our update function) and then used with string_copy in the Draw GUI event (our draw function) to create the effect. string_copy is analogous to JavaScript's substring or slice method.

Let's adapt this logic into our DialogueUI class. We'll introduce a state for managing the active line of dialogue.

class DialogueUI {
    constructor() {
        // ... previous properties
        this.activeLine = "Halt! Who goes there?";
        this.visibleCharacters = 0;
        this.typewriterSpeed = 50; // Characters per second
    }

    // This method is called from the main game loop
    update(deltaTime) {
        // Increment the number of visible characters over time
        if (this.visibleCharacters < this.activeLine.length) {
            this.visibleCharacters += this.typewriterSpeed * deltaTime;
        }
    }

    draw(canvasContext) {
        // ... draw the box

        // Get the substring to display
        const textToDraw = this.activeLine.substring(0, this.visibleCharacters);

        // Set font properties
        canvasContext.fillStyle = 'white';
        canvasContext.font = '24px sans-serif';
        
        // Draw the text inside the box with padding
        canvasContext.fillText(
            textToDraw, 
            this.x + this.padding, 
            this.y + this.padding + 24 // +24 for font size
        );
    }
}

Notice we're using deltaTime (the time elapsed since the last frame), which we implemented in Module 1. This ensures the text reveals at a consistent speed regardless of the game's frame rate.

4. Handling Player Input and Progression

A dialogue system isn't just about display; it's interactive. The player must be able to advance the conversation. A common design pattern is:

  • If the player presses the "confirm" button while the text is still typing, the effect is skipped, and the entire line is revealed instantly.
  • If the player presses the button after the line is fully visible, the system advances to the next line of dialogue (or presents choices).

This video provides a practical demonstration of implementing this exact logic.

Branching Dialog System with Effects in GMS 2! (Part 1: Setup and Typing Effect)

Let's return to Peyton Burnham's video, which has an excellent segment on handling this dual-purpose player input.

Watch the section 'Player Input for Page Progression' (20:28 - 24:54). Observe the if/else logic that checks if the text is still 'typing'. If it is, input skips the animation; if not, it advances to the next page or closes the box.

To implement this, our DialogueUI needs to track its state more formally (e.g., TYPING, WAITING_FOR_NEXT), and our update method needs to check for player input.

Here's a sketch of the logic:

// In our input handler, we'd have a flag like...
let confirmButtonPressed = false; 

// Inside DialogueUI.update(deltaTime)

// Check if the current line is fully revealed
const isLineComplete = this.visibleCharacters >= this.activeLine.length;

if (confirmButtonPressed) {
    if (isLineComplete) {
        // Advance to the next line
        this.goToNextLine(); 
    } else {
        // Skip the typewriter effect
        this.visibleCharacters = this.activeLine.length;
    }
}

5. Displaying and Handling Choices

This is where our UI becomes truly interactive. When our dialogue data presents a choices array, the UI must:

  1. Stop displaying text.
  2. Render a list of the available choices.
  3. Display a cursor to indicate the player's current selection.
  4. Allow the player to move the cursor up and down.
  5. On confirmation, report the chosen branch back to the system.

Chatterbox: Branching Dialogue for Game Maker Studio 2

Implementing a choice menu involves a different UI state. The Chatterbox library tutorial provides a clear, logical walkthrough of how to handle this, which we can adapt.

Read the section titled 'Wait, What About Branches?'. Focus on the logic used to: Check if options are available (ChatterboxGetOptionCount). Loop through the options to draw each one on the screen. Handle keyboard input ('1', '2', etc.) to make a selection (ChatterboxSelect). We'll adapt this to a cursor-based selection.

Based on that logic, we can add a CHOICES state to our DialogueUI.

Conceptual Implementation:

  1. State Management: Our DialogueUI will have a mode property, which can be TYPING, IDLE (line finished), or CHOICES.
  2. Data: When showing choices, the UI will need access to the choices array from our dialogue data node and an internal selectedChoiceIndex.
  3. Input Handling: In CHOICES mode, the update method will listen for up, down, and confirm inputs. Up/down will modify selectedChoiceIndex, making sure it wraps around. Confirm will trigger the selection.
  4. Rendering: In the draw method, if the mode is CHOICES:
    • It will iterate over the choices array.
    • For each choice, it will draw the text (e.g., choice.text).
    • If the loop index matches selectedChoiceIndex, it will also draw a cursor (e.g., a > sprite or character) next to the text.
Test your understanding!

Imagine you have implemented the choice rendering logic. Your draw method now includes this pseudocode:

if (this.mode === 'CHOICES') {
    this.activeChoices.forEach((choice, index) => {
        // Draw the text for choice.text at a specific y-position
        drawText(choice.text, x, y_position_for_index(index));

        if (index === this.selectedChoiceIndex) {
            // How do you draw the cursor?
            // ???
        }
    });
}

How would you complete the // ??? section to draw a simple > character as a cursor to the left of the selected choice's text?

Show answer

You would use the drawText function again, but position the > character slightly to the left of the choice text.

if (index === this.selectedChoiceIndex) {
    // Draw the cursor just to the left of the choice text
    const cursorX = x - 20; // 20 pixels to the left
    const cursorY = y_position_for_index(index);
    drawText(">", cursorX, cursorY);
}

This simple addition provides clear visual feedback to the player about their current selection.

Conclusion

Congratulations! We've just mapped out the complete implementation of a dialogue UI. We've moved from the abstract data structure of the last lesson to the concrete visual and interactive elements that the player will experience.

Key Takeaways:

  • A dialogue UI is a stateful component that can be in different modes: TYPING, IDLE (waiting for input), or CHOICES.
  • The typewriter effect is achieved by using a time-based counter to reveal a progressively larger substring of the full text line.
  • Player input is context-sensitive: it can either skip the typewriter animation or advance the dialogue to the next stage.
  • Displaying choices involves rendering a list from the data, tracking a selected index with a visual cursor, and handling input to navigate and confirm a selection.

Preview of the next lesson:
We now have two powerful but separate pieces: a data format for dialogue trees and a UI component capable of rendering them. In the next lesson, "Integrate the dialogue system with the event system to show text and handle choice-based branching," we will finally connect them. We will create a DialogueManager that loads the data, controls our new DialogueUI, and hooks into the master EventInterpreter we built in Module 3. This will allow an event command like ShowDialogue("GuardEncounter") to kick off a fully interactive conversation.

Can't find a good explanation? Sign up and we'll make it for you

Sign up