Skip to content
Jesse Himmelstein edited this page Jun 21, 2024 · 38 revisions

Booyah is a game HTML5 game engine, written in TypeScript.

This wiki explains how to get started working with Booyah version 3 (v3 for short). If you're looking for v1, checkout the previous v1 Getting Started Guide and v1 Cookbook.

First we'll start with the theory, and then jump into the creating our first Booyah game.

What makes Booyah different from other game engines?

Booyah doesn't offer sophisticated rendering or physics simulation. Instead, it let's the developer use other libraries for those features, and focuses on how to connect the different parts of their game code together to improve understandability & reduce potential bugs.

Chips

Chips are the basic building blocks of a Booyah project. Using an electronics metaphor, a chip goes through a lifecycle of activating (starting up), updating itself on regular intervals (ticks), and finally terminating (shutting down). It can repeat this cycle multiple times.

A chip is free to do anything during these steps. For example, a chip that shows text on the screen might add the text to the scene tree in the activation step, move it around across multiple tick steps, and finally remove it during the termination step. The termination can either happen because the chip requests it, or because its parent forces it to.

The basic rule of thumb is that anything a chip does during its "activation" step it should probably undo during its "deactivation" step. For example, if a chip adds a graphical element to a scene tree during its activation, it should remove that element when it deactivates. In other words, a chip should clean up after itself. The same is usually the case for physics engines, large memory allocations, or open network connections. But it might not be the case for playing music, for example.

In Booyah, chips control the input, output, logic, and timing within the game. They are the building blocks of the game code. They are structured in hierarchies/trees, so that some top-level chips will control lower-level ones. In that way, they are composable- more complex chips are build out of less-complex ones. We'll come back to this in a minute.

Lifecycle

All chips go through the same lifecycle:

  • Construction - Like all objects, a chip has a constructor. Any options that describe how the chip should function should be specified in the constructor. However, a chip shouldn't start "doing" anything yet, before it is activated. It At this point, we call the chip inactive. For example, a chip that plays a sound effect might take the filename of the effect to play in its constructor, that it saves for later. But it should not start playing the sound effect at this point.
  • Activation - The activate() function is called. Now's the time for the chip to start getting to work, including subscribing to events. If all the work can be done in just one step, the chip can call teardown() on itself at this point.
  • Ticking - On subsequent frames, tick() is called. Therefore it can be called zero times, one time, or during the whole game. Once again, if the chip finishes its work, it can call teardown() on itself.
  • Paused - Potentially, a chip might be paused, for example because the player hid the browser tab or is showing a settings screen. In this case, Booyah will call pause() on the chip to inform it that won't be calling tick() for a while. When the chip is "un-paused", resume() will be called before any new tick() calls.
  • Termination - terminate() is the opposite of setup. The chip should remove any graphics, unsubscribe from any events (although those events subscribed to using _subscribe() will automatically be unsubscribed), etc. The chip could be torn down at anytime, either by itself, or by a parent chip. There is no way for a chip to "prevent" getting torn down. After teardown, the chip returns to an inactive state - the same as it was after construction - ready to be activated and used again, or garbage collected.

Hierarchy

Chips are structured into a hierarchy, with “parent” chips containing “child” chips. A child chip can only be activated by its parent, and cannot be active if its parent is not active as well. Therefore you if a chip is inactive, you can be sure that all of its descendants are as well. The same goes for pausing.

In Booyah, we use these structures for flow control. For example, a Sequence chip will run one child chip at a time, moving to the next once the previous terminates. A Parallel chip runs multiple chips at once, until all are complete. An Alternative chip runs multiple children until the once of them terminates, at which point it terminates the others. This is similar to how promises/futures are used in many programming languages to handle asynchronous tasks.

Finally, we offer a complete StateMachine implementation, in which each state corresponds to a child chip, and we can associate functions that are run to determine which chips are run after which other ones.

Context

In order to avoid global variables or singletons, Booyah has a "context" mechanism for components or data that are shared among multiple chips, including those that are used by the whole game.

A context is a simple map of strings to data that parent chips provide to their children. Context can include other chips, configuration variables, or anything else. By default, the context is passed down directly to children, but each chip in the hierarchy has the possibility to extend the context by adding new key-value pairs, or overloading previous ones.

For example, instead of a global variable for an "AudioManager” component, the root context can contain a key for audioManager that maps to the component that handles this task. A parent chip has the possibility to replace the audioManager component for its descendent, but no way to replace that of its own parent.

Interestingly, this same context mechanism also enables us to link into other hierarchies such as scene trees. For example, a parent chip might create a scene tree, passing the root transform to its children. The child will create a new transform, add it to the one provided to it, and overwrite the context for its own children to point to the new transform it created. Rather than tying our framework to a particular rendering technology, the same framework can be used with a variety of rendering libraries, as well as with physics engines, networking, etc.

Subclasses

All chips extend from an interface Chip, allowing for different implementations that can be made. However, to make it easier to create a chip, you probably want to extend an existing base class, either ChipBase for chips that have no children, or Composite for chips that do.

When subclassing ChipBase, override the template methods _onActivate() and _onTick() rather than activate() and tick(). This allows you to focus on the new code rather than the boilerplate work of checking if the chip is in the right state, emitting events, etc. Within the template methods, you can access the _chipContext and _lastTickInfo properties, rather than receiving them as arguments in the method call.

If your chip contains chip chips, extend Composite instead of ChipBase. Don't activate a child chip directly. Instead, call _activateChildChip() to activate it. The composite will automatically add it to the list of children and take care of sending along ticks. You can also give an attribute name to _activateChildChip() and the composite will store the chip under that name.

What's the difference between tick() and _onTick()?

You're free to override tick() if you want. But you need to call the base class as well, using super.tick(...). This creates a lot of boilerplate, and is error-prone.

This is where the _onTick() method comes in. The default implementation of tick() will call _onTick(), so most of the time this is the correct choice.

The exception to this rule is if you don't want to do the default behavior of tick(). For example, if you are extending a Composite and you don't want it to update the child chips for some reason, or want to update them in a different order, you could override tick() to handle this logic.

Communication

In general, parent chips should be able to control child chips by calling their methods directly.

However, child chips should not call methods on their parent chips. This can lead to some nasty logic problems. Instead, it's generally better that child chips emit events that are are picked up by their parents (or cousins, etc.)

Within a chip, use emit(eventName: string, data?: any) to emit an event that other chips can subscribe and react to. To get notified when other event sources send events, you can use _subscribe()

Signals

Chips can be provided dynamic information from other chips using signals. Signals are represented by a name (string) and an optional mapping of string to data.

Chips are given an input signal when they are activated, and return an output signal when they terminate. This can be used for dynamic flow control. For example, a video element could terminate because the player watched it to the end, or because they pressed a skip button. By returning a "skip" signal when the button is pressed to terminate, the game can distinguish between the two cases.

Sequence sends the previous output signal as the input signal of the next chip. This way, subsequence chips can react to the previous chip's output.

Common Chips

Booyah provides some chips for general use cases.

Composites:

  • Sequence - A chip that runs one child chip after another. You can call skip() to move to the next chip in the sequence before waiting.
  • Parallel - A chip that runs several chips in parallel. It waits until all its children are complete before terminating
  • StateMachine - A state machine implementation, where states are represented as chips.
  • Alternative - Executes its children until one of them terminates, then returns that chip's signal.

Transitions:

  • Forever - A chip that runs forever. You can put it at the end of a sequence to keep it from terminating, for example.
  • Transitory - A chip that terminates immediately with a given signal. You can use it as part of a state machine, or to ensure that the last step in a sequence provides a given output signal.
  • Wait - Waits for a given amount of time, before terminating.
  • WaitForEvent - Waits until a given event is delivered
  • Block - Waits until done() is called on it, then terminates with that signal.

Coding:

  • Lambda - A chip that executes a callback, then terminates with the result.
  • Functional - A way to write a chip in a functional style.

Game loop

The class runner.Running manages the game loop for you, calling tick() at regular intervals. The exact framerate depends on your browser & monitor refresh rate.

With the runner you can also set a root context that will be provided to the whole hierarchy. You might want to use a ParallelEntity at the root, to add children to the context.

Hello Booyah

Let's make our "Hello World" in Booyah.

We'll use Parcel as our code bundler and local web server, and Yarn as our package manager.

Installing dependencies

  1. In a new directory, create a project using yarn init.
  2. Install Booyah with yarn add booyah
  3. Install Parcel with yarn add --dev parcel

Create template files

  1. Create a new sub directory src
  2. Add a files src/index.html file, with something like the following:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Hello World from Booyah</title>
    <link rel="stylesheet" href="styles.scss" />
    <script type="module" src="app.ts"></script>
  </head>
  <body>
    <h1>Hello World from Booyah</h1>
  </body>
</html>
  1. That HTML file makes reference to a SASS stylesheet and a TypeScript file. Create the stylesheet src/styles.scss with something like:
body {
  color: blueviolet;
}
  1. Create the TypeScript file src/app.ts with a simple console log message:
console.log("Hello from TypeScript");
  1. Start Parcel with yarn parcel src/index.html.
  2. Go to http://localhost:1234. You should see Hello World from Booyah written in purple.
  3. Open the developer tools to see the JavaScript console. You should see Hello from TypeScript written there.

Parcel will automatically reload when we save the files, so you can play around with modifying the CSS, HTML or TypeScript already.

Create our first Booyah chip

Let's update our app.ts file to the following:

// Import Booyah dependencies
import * as chip from "booyah/dist/chip";
import * as running from "booyah/dist/running";

function printHelloWorld() {
  console.log("Hello world from Booyah");
}

// A Lambda chip runs an function just once and then terminates itself
// A shorter version of this next line is `new chip.Lambda(() => console.log("Hello"))`;
const helloWorldChip = new chip.Lambda(printHelloWorld);

// Create a runner that runs the chip
const runner = new running.Runner(helloWorldChip);

// Start the chip. It will stop itself
runner.start();

Parcel should auto-restart. If you open up the console you should see the message Hello world from Booyah in the JavaScript console.

The final code for this example can be found at https://github.com/play-curious/booyah/tree/master/examples/hello-world

Running over multiple frames

Now let's take a slightly more complicated example- an animation where we want to change something each frame. To make it super easy, we'll just write update random number on the screen each frame.

First, as a "quality of life" improvement, let's update our package.json so we can just run yarn start instead of remembering the correct Parcel command.

Put the following snippet into your package.json (don't copy the ...):

...
  "source": "src/index.html",
  "scripts": {
    "start": "parcel",
    "build": "parcel build"
  },
...

Now update your HTML file to include a spot where we will show the number:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Random Numbers Booyah Example</title>
    <link rel="stylesheet" href="styles.scss" />
    <script type="module" src="app.ts"></script>
  </head>
  <body>
    <h1>Random Numbers Booyah Example</h1>

    <h2 id="random-number"></h2>
  </body>
</html>

Finally, let's adapt our TypeScript file app.ts:

// Import Booyah dependencies
import * as chip from "booyah/dist/chip";
import * as running from "booyah/dist/running";

// Generates random numbers and shows them on the web page
class RandomNumberGenerator extends chip.ChipBase {
  // The generator will create numbers between 0 and `_max`
  constructor(private readonly _max: number = 100) {
    super();
  }

  // Called on each tick
  protected _onTick(): void {
    // Pick the number
    const number = Math.floor(Math.random() * this._max);

    // Update the HTML document to show the number
    const element = document.getElementById("random-number") as HTMLDivElement;
    element.innerText = number.toString();
  }
}

// Create a runner that runs the chip
const runner = new running.Runner(new RandomNumberGenerator());

// Start the chip.
runner.start();

When you run this, with yarn start, you should see random numbers running quickly across your screen, to fast to read.

Why? Because we are updating each frame (tick). So let's slow it down.

Sequences

One way to do this is to modify our RandomNumberGenerator to check the time between each frame, and only modify it once the time has reached a certain level.

But let's take advantage of Booyah's flow control mechanisms to make this easier on us, as well as more understandable.

Here's the new code for src/app.ts:

// Import Booyah dependencies
import * as chip from "booyah/dist/chip";
import * as running from "booyah/dist/running";

// Generates random numbers and shows them on the web page
class RandomNumberGenerator extends chip.ChipBase {
  // The generator will create numbers between 0 and `_max`
  constructor(private readonly _max: number = 100) {
    super();
  }

  // Called once on each activation
  protected _onActivate(): void {
    // Pick the number
    const number = Math.floor(Math.random() * this._max);

    // Update the HTML document to show the number
    const element = document.getElementById("random-number") as HTMLDivElement;
    element.innerText = number.toString();

    // Terminate yourself
    this.terminate();
  }
}

// Our random number generator
const rng = new RandomNumberGenerator();

// A chip that waits for 1 second
const wait = new chip.Wait(1000);

// A chip that runs an infinite loop of random number generator followed by the wait
const sequence = new chip.Sequence([rng, wait], { loop: true });

// Create a runner that runs the chip
const runner = new running.Runner(sequence);

// Start the chip. It will stop itself
runner.start();

You'll notice a few changes to RandomNumberGenerator:

  • The work of changing the random number is now done in _onActivate() instead of in _onTick(). This means that it will just be done once, instead of on each frame.
  • _onActivate() calls this.terminate(), stopping the chip.

We also added a Wait chip that waits for a given amount of time. In this case, it's 1000 milliseconds, or 1 second.

Finally, we put these two chips into a Sequence. A Sequence runs one chip at a time, running one after the previous one finishes. And we configured it here to run in a loop.

So the sequence first activates the RandomNumberGenerator, which shows a new number and then terminates itself. The sequence will then move onto the Wait. Once the time is up, the Wait terminates itself, and the sequence moves back to the RandomNumberGenerator.

Parallel

Let's try another basic flow control chip, called Parallel. It runs a number of chips at the same time. When all them are done, it terminates itself.

In our example, we'll add a second random number that will be generated. Let's add it to our src/index.html page:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Random Numbers Booyah Example</title>
    <link rel="stylesheet" href="styles.scss" />
    <script type="module" src="app.ts"></script>
  </head>
  <body>
    <h1>Random Numbers Booyah Example</h1>

    <h2 id="random-number-1"></h2>
    <h2 id="random-number-2"></h2>
  </body>
</html>

Next, we'll update /src/app.ts as so:

// Import Booyah dependencies
import * as chip from "booyah/dist/chip";
import * as running from "booyah/dist/running";

// Generates random numbers and shows them on the web page
class RandomNumberGenerator extends chip.ChipBase {
  // The generator will create numbers between 0 and `_max`
  constructor(
    private readonly _elementId: string,
    private readonly _max: number = 100
  ) {
    super();
  }

  // Called once on each activation
  protected _onActivate(): void {
    // Pick the number
    const number = Math.floor(Math.random() * this._max);

    // Update the HTML document to show the number
    const element = document.getElementById(this._elementId) as HTMLDivElement;
    element.innerText = number.toString();

    // Terminate yourself
    this.terminate();
  }
}

// Our random number generator
const rng1 = new RandomNumberGenerator("random-number-1");
// Our random number generator
const rng2 = new RandomNumberGenerator("random-number-2");

// A chip that waits for 1 second
const wait = new chip.Wait(1000);

// Put the two random number generators in parallel
const parallel = new chip.Parallel([rng1, rng2]);

// A chip that runs an infinite loop of random number generators followed by the wait
const sequence = new chip.Sequence([parallel, wait], { loop: true });

// Create a runner that runs the chip
const runner = new running.Runner(sequence);

// Start the chip. It will stop itself
runner.start();

We changed RandomNumberGenerator to take the element ID as the first argument, so we can instantiate multiple objects, each taking care of a different element.

We put these two into a Parallel chip, so they will run at the same time, and put that into the sequence.

When you reload, you should see two random numbers, changing at the same time.

Hopefully these examples illustrate why using chips for flow control is quite powerful, allowing us to compose different behaviors from a smaller amount of code.

The final code for this example can be seen at https://github.com/play-curious/booyah/tree/master/examples/random-numbers