uriel avalos

React + Matter.js + State Machine Demo

So I've been working on a game. The next step is to add a state machine.

Thoughts and Prayers (a 1st Pass)

I'm gonna be honest and confess that the first pass (below at the end of the post) has alot of boilerplate around the use of discriminated unions for the game state.

interface Tapable {
  name: "tapable";
}

interface Shakeable {
  name: "shakeable";
  random: boolean;
}

type GameStateData = Tapable | Shakeable;

type gameState = GameStateData["name"];

For example, there's this function that ensures we can extract a game state of the right type:

function getGameState<T extends GameStateData>(
  state: GameStateData[],
  name: T["name"]
): T | undefined {
  return state.find((state) => state.name === name) as T;
}

If my old typescript-guru colleague were around, he'd say to remove the discriminated union and move the Shakeable state (the random flag) to the main game state. Simpler DX.

But I would disagree for two reasons: (1) the code is self-documenting. It's crystal clear that the random flag is part of the Shakeable state. (2) If we ever remove this state, the code won't be littered with unused, long forgotten flags (which happens all the time on large enterprise projects). Tradeoffs :-)

The main idea is that GameStateTransitions have a precondition that checks whether or not its transition method can be called. If true, the transition method takes the game state to a new game state.

interface GameState {
  engine: {
    dots: Dot[];
  };
  currentState: GameStateData[];
}

interface GameStateTransition {
  /**
   * Transition allowed only if preconditions are true
   */
  precondition(game: Readonly<GameState>): boolean;
  /**
   * Raw text to show user
   */
  commandVerbage(game: Readonly<GameState>): string;
  transition(game: GameState): void;
}

We start off with two transitions: PressByColor and Shake. The first transition waits for the user to press a dot of the given color and then adds a new dot. (Necessarily, a dot of the given color must exist, aka a precondition, in order to enter this transition.) The second transition waits for the user to shake the screen and then shuffles the dots around the screen. (Its precondition is that there must be at least 5 dots on the screen.)

Here's what PressByColor can look like:

/**
 * If the current game is "tapable"
 * (and there is at least one dot of the given color),
 * then the user can press by color.
 */
class PressByColor implements GameStateTransition {
  color: Color;

  constructor(color: Color) {
    this.color = color;
  }

  precondition(game: Readonly<GameState>): boolean {
    const isInAllowedState = !!getGameState(game.currentState, "tapable");

    return (
      isInAllowedState &&
      game.engine.dots.some((dot) => dot.color === this.color)
    );
  }

  commandVerbage(game: Readonly<GameState>): string {
    let count = 0;
    for (const dot of game.engine.dots) {
      if (dot.color === this.color) count += 1;
      if (count === 2) break;
    }
    return count === 2
      ? `Press any ${this.color} dot`
      : `Press a ${this.color} dot`;
  }

  transition(game: GameState) {
    // wait for user to click a dot
    // add dot to engine
  }
}

Does it Scale?

The real test is whether or not this pattern is robust to adding or modifying game state transitions. So let's add one: a new transition that allows the user to rotate the screen.

Here are the user requirements:

  1. The user can rotate the screen from any state.
  2. But once the user rotates the screen, the game is not tapable (since the dots will be hard to click when they're bunched together, as will happen when the user rotates the screen).
  3. Also once the user rotates the screen, it doesn't make sense to allow the same rotation again.
  4. Shaking the screen renables the tapable state

The first thing is that we have to add a Rotatable interface:

interface Rotatable {
  name: "rotatable";
  type: "left" | "right" | "none";
}

The implementation would look something like this:

class Rotateable implements GameStateTransition {
  validInputState: gameState[] = ["rotatable"];

  precondition(game: GameState): boolean {
    const isInAllowedState = getGameState(game.currentState, "tapable");

    this.validInputState.every(
      (validState) => !!getGameState(game.currentState, validState)
    );

    return isInAllowedState;
  }

  commandVerbage(game: GameState): string {
    const shakeState = getGameState<Shakeable>(game.currentState, "shakeable")!;

    return shakeState.random ? `Shake your screen again` : `Shake your screen`;
  }

  transition(game: GameState) {
    // wait for user to shake the screen
    // then...
    // disable shakeable state
    const shakeState = getGameState<Shakeable>(game.currentState, "shakeable")!;

    shakeState.random = true;
  }
}

The main drawback to the current implementation (and I'll take it about some more below) is that to satisfy (2) or (4), you must modify the other state transition classes. For instance:

class Shake implements GameStateTransition {
  // ...

  transition(game: GameState) {
    // ...

    // re-enable the tapable state
    addGameState(game.currentState, { name: "tapable" });
  }
}

So on the one hand, the Rotatable state transition is fairly straightforward. On the other hand, IMO, introducing this coupling between the tapable and shakeable is potentially unclear. I mean it's clear now, but it may not be clear months from now or as this app continues to grow.

But it's a pretty solid first attempt. And I'll leave it here for now.

The Code

Full 1st pass code below:

const colors = ["yellow", "red", "blue"] as const;
type Color = typeof colors[number];

interface Dot {
  x: number;
  y: number;
  color: Color;
}

interface Tapable {
  name: "tapable";
}

interface Shakeable {
  name: "shakeable";
  random?: boolean;
}

interface Rotatable {
  name: "rotatable";
  type: "left" | "right" | "none";
}

type GameStateData = Tapable | Shakeable | Rotatable;

type gameState = GameStateData["name"];

interface GameState {
  engine: {
    dots: Dot[];
  };
  currentState: GameStateData[];
}

function getGameState<T extends GameStateData>(
  state: GameStateData[],
  name: T["name"]
): T | undefined {
  return state.find((state) => state.name === name) as T;
}

function removeGameState<T extends GameStateData>(
  state: GameStateData[],
  name: T["name"]
) {
  const index = state.findIndex((state) => state.name === name);

  state.splice(index, 1);
}

function addGameState<T extends GameStateData>(
  state: GameStateData[],
  data: T
) {
  if (!getGameState(state, data.name)) {
    state.push(data);
  }
}

interface GameStateTransition {
  /**
   * Transition allowed only if preconditions are true
   */
  precondition(game: Readonly<GameState>): boolean;
  /**
   * Raw text to show user
   */
  commandVerbage(game: Readonly<GameState>): string;
  transition(game: GameState): void;
}

/**
 * If the current game is "tapable"
 * (and there is at least one dot of the given color),
 * then the user can press by color.
 */
class PressByColor implements GameStateTransition {
  color: Color;

  constructor(color: Color) {
    this.color = color;
  }

  precondition(game: Readonly<GameState>): boolean {
    const isInAllowedState = !!getGameState(game.currentState, "tapable");

    return (
      isInAllowedState &&
      game.engine.dots.some((dot) => dot.color === this.color)
    );
  }

  commandVerbage(game: Readonly<GameState>): string {
    let count = 0;
    for (const dot of game.engine.dots) {
      if (dot.color === this.color) count += 1;
      if (count === 2) break;
    }
    return count === 2
      ? `Press any ${this.color} dot`
      : `Press a ${this.color} dot`;
  }

  transition(game: GameState) {
    // wait for user to click a dot
    // add dot to engine
  }
}

/**
 * If the current game is "shakeable",
 * then the user can shake the screen.
 *
 * Once the user shakes the screen,
 * the random flag is set to true.
 */
class Shake implements GameStateTransition {
  precondition(game: GameState): boolean {
    return !!getGameState(game.currentState, "shakeable");
  }

  commandVerbage(game: GameState): string {
    const shakeState = getGameState<Shakeable>(game.currentState, "shakeable")!;

    return shakeState.random ? `Shake your screen again` : `Shake your screen`;
  }

  transition(game: GameState) {
    // wait for user to shake the screen
    // then...
    const shakeState = getGameState<Shakeable>(game.currentState, "shakeable")!;

    shakeState.random = true;

    addGameState(game.currentState, { name: "tapable" });
  }
}

class Rotateable implements GameStateTransition {
  validInputState: gameState[] = ["rotatable"];
  type: Rotatable["type"];

  constructor(type: Rotatable["type"]) {
    this.type = type;
  }

  precondition(game: GameState): boolean {
    const isInAllowedState =
      getGameState(game.currentState, "tapable") ||
      getGameState(game.currentState, "shakeable");

    if (!isInAllowedState) return false;

    const rotatedState = getGameState<Rotatable>(
      game.currentState,
      "rotatable"
    );

    return rotatedState?.type !== this.type;
  }

  commandVerbage(game: GameState): string {
    return this.type === "left"
      ? `Turn your screen to the left`
      : `Turn your screen to the right`;
  }

  transition(game: GameState) {
    // wait for user to rotate screen
    // then...
    removeGameState(game.currentState, "tapable");

    const state = getGameState<Rotatable>(game.currentState, "rotatable");

    state.type = this.type;
  }
}

class RotateLeft extends Rotateable {
  constructor() {
    super("left");
  }
}

class RotateRight extends Rotateable {
  constructor() {
    super("right");
  }
}

// Now the "game"

function randomEntry<T>(array: readonly T[]): T {
  return array[
    Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) % array.length
  ];
}

function NaiveGameLoop() {
  const colorTransitions = colors.map(
    (color) =>
      class extends PressByColor {
        constructor() {
          super(color);
        }
      }
  );
  const transitions = [
    ...colorTransitions,
    RotateLeft,
    RotateRight,
    Shake,
  ] as const;
  const game: GameState = {
    engine: { dots: [] },
    currentState: [{ name: "tapable" }, { name: "shakeable" }],
  };

  while (true) {
    const Transition = randomEntry(transitions);
    const transition: GameStateTransition = new Transition();

    if (transition.precondition(game)) {
      console.log(transition.commandVerbage(game));

      transition.transition(game);
    }
  }
}