All About HTML5 Game Development, from Publishing to Monetization

Game level logic with the XState library -7/11

Game level logic with the XState library -7/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!

You can read here the list of features we are going to develop for our Excalibur game, such as Twine integration for characters interactive dialogs. 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: XState

In this tutorial we integrate XState to our game, to manage the level logic.

In the last tutorial we've added another character to our scene, the mentor, and we made it possible to have a chat with him thanks to an integration with Twine. The mentor purpose is to let the user know what does she have to do, that is to look for the sword. Now that the player knows what she's supposed to do, it's time to make things happen! In this commit of our open source repository, we've added a couple of coins and a sword to our game, following the same strategy we've explained in this tutorial. Here's what we'd like to develop now:

  • taking coins increases the score
  • taking the sword ends the level

According to their documentation,

XState is a library for creating, interpreting, and executing finite state machines and state charts, as well as managing invocations of those machines as actors.

We're going to manage our logic by integrating the XState library in our game. Let's see how!

 

 

XState: suggested reading

In this tutorial we assume you know what a finite state machine is. The machine we're going to write is straightforward and does not require you to have read all of XState documentation, which is quite a world (we didn't either read all of it). There are just two tutorials from official documentation which we'll assume you know well enough:

We're going to modify the given fetch machine example according to our needs. If you like XState and the way we've integrated it into our code, you can keep studying it and write finite state charts for much more complicated game logic!

A finite state machine for our level logic

What we need and what we're going to do

We need two things:

  • Understand how to code the level logic using the XState library
  • Understand how to put together the state machine with our code

How to code the level logic using the XState library

Modify the fetch machine example

The XState visualizer is an incredibly useful tool which allows seeing what the logic looks like and to spot immediately any logic flaw. Here's how their fetch machine example looks like:

This logic seems too much for us. Let's clean it up a bit:

we also rename it:

With these little modifications the state machine looks very close to the final result we want. The story it is telling is this one: if while playing a sword is taken, then all ends with a success.

Now we need to add the coin logic. When a coin is taken the state of the machine remains the same, because taking a coin does not by itself end the level. Then we could write it something like:

Customize XState Example - 3

But that's not the whole story. Taking a coin does not end the level, but it changes the global state of the game, since the player is richer than before. We also need a way to tell the user that after taking the sword the level is finished. What we can use are XState actions, which according to their documentation are fire-and-forget effects:

"Fire-and-forget" effects, which execute a synchronous side-effect with no events sent back to the statechart, or send an event synchronously back to the statechart.

We're going to use actions as a bridge between XState and our Excalibur game. We'll call our code from within XState actions:

Customize XState Example - 4

We'll write in a minute some code to fill the updateCoin and endOfLevel actions. As you can see, the updateCoin action also changes the state machine. In one of the next tutorials, we'll take advantage of this action to also persist data. In this way, if the user opens the game on different days, the total amount of coins is always saved and updated to the last count.

We're done with the level logic on the XState side, let's go on by putting together the state machine actions with our code!

In the next tutorial we're going to set up React for developing the UI of our game, so at the moment we'll forget about the updateCoin action, and write a temporary solution for the endOfLevel one. A simple solution is to show the number of coins that were taken.

To implement this simple solution, we'll create a ModalService under the src/services folder, which calls the DOMService to show a full-size div displaying the total score:


@Service()
export class ModalService {
  private domService = Container.get(DOMService);

  public showEndOfLevelModal(score:number) {
    this.domService.toggleElementVisibility('modal-container', true);
    const coinScoreDiv = this.domService.getElement('coins-score');
    coinScoreDiv.innerText = score.toString();
  }
}

                    

The modal-container div is a new element we've added in the index.html file, which by default is not visible:


<body>
...
<div class="position-absolute position-top full-size" id="iframe-container" style="visibility: hidden;"></div>
<div class="position-absolute position-top full-size modal-container" id="modal-container" style="visibility: hidden;">
    <div id="coins-text">Your score:</div>
    <div id="coins-score"></div>
</div>

</body>
                    

When we want to show the modal, we just need to tell the ModalService to do so.

We have now all the pieces we need, we miss understanding how to put them together. Let's see how!

Put together the state machine with our code

First thing first we create a PlayLevelLogicService under the src/services folder. This service will act as a wrapper to the XState machine: it will create, start and stop it.


@Service()
export class PlayLevelLogicService {
  private stateService;

  private modalService = Container.get(ModalService);

  public startStateMachine(): void {
    const gameMachine = this.buildMachine();
    this.stateService = interpret(gameMachine)
      .onTransition((state) => console.log(state.value))
      .start();
  }

  public destroyStateMachine(): void {
    this.stateService.stop();
  }

  private buildMachine() {
    return createMachine(
      {
        id: 'play-level-logic',
        initial: 'playing',
        context: {
          coins: 0,
        },
        states: {
          playing: {
            on: {
              COIN_TAKEN: 'afterCoinTaken',
              SWORD_TAKEN: 'success',
            },
          },
          afterCoinTaken: {
            entry: ['updateCoin'],
            always: { target: 'playing' },
          },
          success: {
            type: 'final',
            entry: 'endOfLevel',
          },
        },
      },
      {
        actions: {
          updateCoin: (context) => {
            context.coins += 1;
            console.log('new coin count: ', context.coins);
          },
          endOfLevel: (context) => {
            this.modalService.showEndOfLevelModal(context.coins);
          },
        },
      },
    );
  }
}

interface Context {
  coins: number;
}

                    

That's still not enough. This service contains the logic, but we still miss to add the code which tells the a machine that something has happened. This information is given by the Excalibur events which are raised when actors collide. After a look at the LevelStateModifier class, which represents either a coin or the sword, we understand that this is the right place where to trigger the state machine transition. We can listen for collision events by adding a listener when initializing the actor:


export class LevelStateModifier extends ExcaliburActor {
  public type: LevelStateModifierType;

  private excaliburActionService = Container.get(ExcaliburActionService);

  constructor(type: LevelStateModifierType, actorArgs:ActorArgs, actorConfig: ActorConfig) {
    super(actorArgs, actorConfig);
    this.type = type;
  }

  public onInitialize(_engine: Engine) {
    super.onInitialize(_engine);
    this.graphics.use(ActorAnimationsKeys.IDLE);
    this.on('collisionstart', ({ other }) => {
      if (other.tags.includes(Tags.ACTORS.player)) {
        const eventName: LevelEvents = this.type === 'coin' ? 'COIN_TAKEN' : 'SWORD_TAKEN';
        const callbackFn = () => {
          levelState.next(eventName);
          this.kill();
        };
        if (this.type === 'coin') {
          this.excaliburActionService.easeAndCall(this, callbackFn);
        } else {
          this.excaliburActionService.fadeAndCall(this, callbackFn);
        }
      }
    });
  }
}

                    

The ExcaliburActionService is done as


@Service()
export class ExcaliburActionService {
  public easeAndCall(actor:Actor, callback: () => void) {
    actor.actions.easeTo(vec(actor.pos.x, -100), 800, EasingFunctions.Linear).callMethod(() => {
      callback();
    });
  }

  public fadeAndCall(actor:Actor, callback: () => void) {
    actor.actions.fade(0, 1500).callMethod(() => {
      callback();
    });
  }
}
                    

The collisionstart callback function is calling an external ExcaliburActionService to use the actors actions API for testing purposes (it's easier to verify that the service has been called instead of waiting in an async test for the actions to be completed).

In the LevelStateModifier.onInitialize method you can see there is a levelState variable. That global variable is the bridge between the Excalibur game and the PlayLevelLogicService and is done as


export const levelState: BehaviorSubject<LevelEvents> = new BehaviorSubject(undefined);
export type LevelEvents = 'COIN_TAKEN'| 'SWORD_TAKEN';
                    

The PlayLevelLogicService subscribes to the levelState BehaviorSubject so that every time it changes - that is, every time a coin or a sword collides with the player actor - it forwards the event to the state machine it's wrapping:



@Service()
export class PlayLevelLogicService {

  private stateEventSubscription: Subscription;

  public startStateMachine(): void {
    this.stateEventSubscriptions = levelState
      .pipe(filter((event) => Util.isDefined(event)))
      .subscribe((event) => {
        this.stateService.send({ type: event });
      });
    ...
  }

  public destroyStateMachine(): void {
    ...
    this.stateEventSubscriptions.unsubscribe();
  }

  private buildMachine() {
    ...
  }
}

                    

At this stage, here's what you should see by playing the game:

An Excalibur game with level logic

As you can see, there is no graphical interface. This is the topic of the next tutorial!

Conclusions

In this tutorial, we've integrated XState library to introduce the level logic to our game. We've taken advantage of Excalibur collision events to change the state of the machine and perform some other operations. The logic that we've described is simple and probably didn't need XState to manage it. But the purpose of this project is to write a template to be modified and extended, so integrating XState leaves a place to much more complicated logics!

In the next tutorial we will integrate React to write a nice graphical interface and manage more efficiently anything that is DOM-related. Keep in touch!

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

Related Articles