Excalibur: An interactive dialogue with Twine

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!
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 us on Twitter and Instagram to be caught up on our latest news!
Table of Contents
- In this tutorial: Twine
- Twine: suggested reading
- Let's made them talk
- Listen for click events on the mentor
- Execute Twine story
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 anindex.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
- In the
- The user clicks or touches the mentor. The Twine dialogue starts
- A service, let's call it an
IFrameService
, starts the Twine dialog in aniframe
HTML element
- A service, let's call it an
- When the Twine dialogue ends, the modal where it is displayed is removed
- The
IFrameService
remove theiframe
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 thevisibility
on anhtml
element, if it exists -
The
createHTMLElement
methods create anhtml
element according to the given configuration, and append it to the specified parent element or to thedocument.body
. In the configuration's possible to specify the element id, the tagName, thecss
classes to apply, thehtml
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:
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:
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!