All About HTML5 Game Development, from Publishing to Monetization

Interactive dialogues in HTML5 games with Twine - 6/11

Interactive dialogues in HTML5 games with Twine - 6/11

This is an open-source project on game development with a focus on the HTML5 Excalibur game engine. Check out this feature implementation on this commit in our Github repository!

Welcome to the 6th part of my ExcaliburJS HTML5 game engine tutorial serie! You can read here the list of features we are going to develop for our Excalibur game, such as data persistence between scenes and between game sessions. If you're interested in game development, follow me on Twitter and Instagram to be caught up on my latest news!

Table of Contents

In this tutorial: Twine

In this tutorial we integrate Twine to our game, to manage an interactive dialog between the player and the mentor.

In this commit we've added another character to our scene, the mentor, following the same steps we did to add the main player to the scene. The mentor's scope is to teach and guide the player through the game. In our case, the mentor's purpose is to let the user know what she has to do, that is to look for the sword. We want to make our game as involving and interactive as we can, and it would be nice to let the user talk deciding what to say. We can reach this point by enriching the game with an interactive dialog. To implement an interactive dialog and all of its the logic would be quite an amount of work, and we already have a lot to do on our own. Twine is an open-source tool for telling interactive, nonlinear stories. and its stories can be exported in html format. It can, therefore, do the work in our place, since we can easily integrate Twine exported dialogs into our game. Let's find out how!

Twine: suggested reading

To follow this tutorial it's not needed for you to know how to write a Twine story since we will provide ours (you can find it named as mission.html in the public repository of this project). However, if you like the Twine integration, you can enjoy all of the Twine features by reading its documentation and writing your own stories and dialogues to use in your Excalibur game. To write the Twine interactive dialogue you'll see, we've adopted the Harlowe format.

Let's made them talk

As we've said, we are going to use Twine to manage an interactive dialog between the player and the mentor. This is the scene we've imagined: the player approaches the mentor, who changes position to express openness to dialogue. If a certain event triggered by the user occurs (in our case, the user clicks or taps on the mentor), then a modal opens in which the interactive dialogue is executed.

From a technical point of view, we need to do two things:

  • Listen for click events on the mentor
  • Execute Twine story

Listen for click events on the mentor

This piece of work is easy. By reading Excalibur documentation on mouse and touch events, we know that to make an actor listen for events is as easy as


player.on('pointerup', (ev) => {
  player.logger.info('Player selected!', ev)
});
                    

In our case, we want the Mentor character to listen for user inputs: if the user either clicks or touches the mentor, the Twine dialogue has to start. Following the above example, we register pointer events in the initialize method of the Mentor class:


export class Mentor extends ExcaliburActor {

  ...

  public onInitialize(_engine: Engine) {
    super.onInitialize(_engine);
    this.graphics.use(ActorAnimationsKeys.IDLE);
    this.on('pointerdown', () => {
      // start the Twine interactive dialogue
    });
  }

}

                    

Last but not least, we would like for the mentor to change position as soon as the player approaches it, to give the idea that it's possible to start a dialog with it. The best way to do it is to check for the player position in the postUpdate method of the Actor class:


export class Mentor extends ExcaliburActor {

  ...
  private queryManagerService = Container.get(QueryManagerService);
  ...

  public onInitialize(_engine: Engine) {
    ...
  }

  public onPostUpdate(_engine: Engine, _delta: number) {
    const player = this.queryManagerService.getPlayer(_engine.currentScene);
    const playerIsNear = player.pos.distance(this.pos) < 150;
    if (playerIsNear) {
      this.graphics.use(ActorAnimationsKeys.HELLO);
    } else {
      this.graphics.use(ActorAnimationsKeys.IDLE);
    }
  }
}


                    

Execute Twine story

Technical insight

As written in Twine documentation, its stories can be exported in html format. This means we can download an *.html file containing all the html, css and javascript needed to play the interactive story, which for us will be an interactive dialogue. The question is: how to integrate it into our Excalibur game? Let's recap the current situation:

  • The Excalibur game is rendered in a canvas html element, contained in the body of an index.html file
  • The Twine story works on its own index.html file

The two things seem incompatible, right? That's because they are! It's certainly possible to manage to load our javascript into the Twine HTML since it's possible to write custom javascript in it, but this seems to be a rather complicated solution. There's another one which looks easier: to exploit iframe elements capabilities.

Technical solution

According to MDN documentation, iframes are

The iframe HTML element represents a nested browsing context, embedding another HTML page into the current one.

This seems exactly what we were looking for: using an iframe, we will be able to display the Twine story, loaded in a index.html file (in the our code it's called mission.html), independently of the main index.html file where our game is being executed.

Here's the events flow and what the technical solution should be able to do:

  • The player approaches the mentor
  • The mentor changes graphics to express openness to dialogue
    • In the postUpdate() method we're checking for the player position
  • The user clicks or touches the mentor. The Twine dialogue starts
    • A service, let's call it an IFrameService, starts the Twine dialog in an iframe HTML element
  • When the Twine dialogue ends, the modal where it is displayed is removed
    • The IFrameService remove the iframe HTML element containing the Twine exported dialogue

The user clicks or touches the mentor. The Twine dialogue starts

Let's start with this piece of code. We're already listening for the pointer events on the mentor. What we're missing is to call the service responsible for displaying/removing the iframe. Let's create it under the src/services folder:


@Service()
export class IFrameService {

  public toggleIFrame(show: boolean):void {
    if (show) {
        //show iframe
    } else {
        //remove iframe
    }
  }

}
                    

As you can see, the toggleIFrame method is empty for now. We will write it in a minute. First, let's change the Mentor class to call the service on pointer-events:


export class Mentor extends ExcaliburActor {

  ...
  private iFrameService = Container.get(IFrameService);

  public onInitialize(_engine: Engine) {
    super.onInitialize(_engine);
    this.graphics.use(ActorAnimationsKeys.IDLE);
    this.on('pointerdown', () => {
      this.iFrameService.toggleIFrame(true);
    });
  }
  ...
}
            

Now that the pieces are on their right places, let's think about the IFrameService. It is supposed to create and remove the iframe element as requested. At the same time, the service responsible for managing the html elements is the DOMService. This service does not have at the moment methods to create or remove elements, or hide them: let's write them!


@Service()
export class DOMService {

  ...

  public toggleElementVisibility(elementId: DomElementIds, visible:boolean) {
    const exist = this.elementExist(elementId);
    if (exist) {
      const element = this.getElement(elementId);
      element.style.visibility = visible ? 'visible' : 'hidden';
    }
  }

  public createHTMLElement({id, tagName, classes, parentContainer, attributes,}: HTMLElementConfig) {
    const htmlElement = document.createElement(tagName);
    htmlElement.id = id;
    if (Util.isDefined(parentContainer)) {
      parentContainer.appendChild(htmlElement);
    } else {
      document.body.appendChild(htmlElement);
    }
    classes?.forEach((c) => htmlElement.classList.add(c));
    attributes?.forEach(({ name: attributeName, value: attributeValue }) => htmlElement.setAttribute(attributeName, attributeValue));
    this.map.set(id, htmlElement);
    return htmlElement;
  }

  public removeElement(elementId: DomElementIds): void {
    const element = this.getElement(elementId);
    element?.remove();
  }

}

...
export type HTMLElementConfig = {tagName:string, id:DomElementIds, classes?:string[], parentContainer?:HTMLElement, attributes?:HTMLElementAttribute[]}
export type HTMLElementAttribute = {name:string, value:string};

                    
  • The toggleElementVisibility method changes the visibility on an html element, if it exists
  • The createHTMLElement methods create an html element according to the given configuration, and append it to the specified parent element or to the document.body. In the configuration's possible to specify the element id, the tagName, the css classes to apply, the html attributes to set, and the parent container
  • The removeElement method removes an element if it exists

The DOMService is now exposing the methods we need to call in the IFrameService to create and remove the iframe element, and to hide or show the elements. Let's get back to it then, and write the empty methods:


@Service()
export class IFrameService {

  private domService: DOMService = Container.get(DOMService);
  private iFrameContainerElement: HTMLElement;

  public init(): void {
    this.iFrameContainerElement = this.domService.getElement('iframe-container');
  }

  public toggleIFrame(show: boolean):void {
    this.domService.toggleElementVisibility('iframe-container', show);
    this.domService.toggleElementVisibility('joystick', !show);
    if (show) {
      const config: HTMLElementConfig = {
        id: 'iframe',
        tagName: 'iframe',
        classes: ['position-absolute', 'position-top', 'full-size'],
        parentContainer: this.iFrameContainerElement,
        attributes: [{ name: 'src', value: 'twinery-dialog/mission.html' }],
      };
      this.domService.createHTMLElement(config);
    } else {
        //remove iframe
    }
  }

}

                    

The iframe-container element is the empty div that will host the iframe: we need to add it to the index.html file:


<body>
...

    <canvas id="game" style="display: block; margin: auto auto"></canvas>

    <div class="position-absolute position-top full-size" id="iframe-container" style="visibility: hidden;"></div>

...
</body>
                    

The IFrameService takes advantage of this element to append to it the iframe. The iframe itself will load, thanks to the src attribute, the mission.html file that we've built with Twine.

Remember to hide the joystick when the iframe is shown!

The story we've made with Twine is simple and short. It contains one interactive question where the player can choose the answer. If you'd like to use Twine in your game, you can start by importing and modifying the mission.html file on your Twine account. Here is what it looks like:

A Twine interactive dialogue

When the Twine dialogue ends, the modal where it is displayed is removed

The final step is to remove the iframe when the dialogue ends. But when?

The implementation is easy, thanks to the methods we've added in the DOMService:


public toggleIFrame(show: boolean):void {
    this.domService.toggleElementVisibility('iframe-container', show);
    this.domService.toggleElementVisibility('joystick', !show);
    if (show) {
      const config: HTMLElementConfig = {
        id: 'iframe',
        tagName: 'iframe',
        classes: ['position-absolute', 'position-top', 'full-size'],
        parentContainer: this.iFrameContainerElement,
        attributes: [{ name: 'src', value: 'twinery-dialog/mission.html' }],
      };
      this.domService.createHTMLElement(config);
    } else {
      this.domService.removeElement('iframe');
    }
}
                    

The problem is not how to do it, the problem is when. Why? Because the iframe should be removed when the dialogue ends. But at the moment the dialogue is hosted on an embedded HTML element that is not communicating with the main index.html where our game is run. That is, at the moment we don't have a way to know when the toggleIFrame(false) the method should be called. And we cannot call the IFrameService from within the mission.html javascript, since its scope does not include it (it's a different window!). How to solve this problem?

Luckily for us, this is not a new problem. Somebody else went through it before us and came out with a solution, which is the Window.postMessage API. As the MDN documentation says,

The window.postMessage() method safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it.

This is exactly what we are looking for. We are going to send a message from the iframe window to the main window where the game is being executed. When the message arrives, we will close the iframe by calling the IFrameService.toggleIFrame method.

To use this solution we first need to listen for the events from the iframe embedded window:


@Service()
export class IFrameService {

  /**
     * The IFrame is listening for the CLOSE_IFRAME event. If you change this string here, it must be changed on the Twinery project too!
     */
  private closeIframeTwinerySentEvent: string = 'CLOSE_IFRAME';


  public init(): void {
    this.iFrameContainerElement = this.domService.getElement('iframe-container');
    this.initWindowListeners();
  }

  ...

  private initWindowListeners(): void {
    window.onmessage = (event) => {
      if (event.data === this.closeIframeTwinerySentEvent) {
        this.toggleIFrame(false);
      }
    };
  }
}

                    

As you can see, the init method listen for events and if they are of the right type (that is, sent from the iframe containing the Twine dialogue, with a specific data string), then the toggleIFrame method is called. We miss the very last piece. How do we send the message from the iframe? That is, when should we call the Window.postMessage API?

The easiest solution is to call the method when we are sure that the user has read the dialogue and is ready to play. We've already set up what we need: at the end of the dialogue, there is a big button saying: Play Sword Adventure! It's enough then to listen for click events on that button to send the message with the postMessage API. We have to set this up on the Twine side. In the Twine project, we've done by adding some javascript on the last slice of the dialogue:

This is the result that we've obtained in the end:

Twine story

Our Twine story is easy, but remember that in Twine stories can be as beautiful as you want, as you can see from their gallery!

Conclusions

In this tutorial we've integrated Twine, an open-source tool for telling non-linear stories, to add an interactive dialogue to our game. Now the mentor, responding to the user input, can tell the player what is his purpose: to reach the Sword and to take it! It is time then to start thinking about the logic of our level and to understand how to code it. This tutorial had a lot of stuff in it, the next one will be better: we're going to manage another integration, this time with the XState library!

In the next tutorial we will add the level logic to our game: the level is completed when the player takes the sword. We'll integrate the XState library for doing that!

Did you like this article and wish there were more? Donate on Paypal so that I can write more!

Related Articles