uriel avalos

React + Matter.js + State Machine Demo Part 2

In a previous post, I wrote about creating a state machine for a simple game that gives users various commands (like shake the screen. See below for the user requirements). I originally created what turned out to be different states for each possible command. But these states were tightly coupled because the commands are tightly coupled.

So for this post, I'm trying a different design:

  • Three generic game states: thinking, waiting (for user interaction), updating (aka post-processing).
  • Each game state has its own internal state (called context, so it's less confusing).
  • The thinking state decides what command to fire.
  • And the most important part: command processing is now centralized (i.e., colocated)

IMO, the aforementioned coupling/complexity is more plain to see...which can be a good thing for maintainability. For instance, the snippet below shows how tap depends on the rotate command, and how the shake command re-enables tap.

class Thinking implements StateNode {
  nextCommand(
    ctx: StateNodeContext,
    next: Command
  ): TransitionContext | undefined {
    const { engine, ...context } = ctx;

    case 'tap': {
        for (const color of randomSequence(colors)) {
          const canTap = engine.dots.some((dot) => dot.color === color);

          if (canTap && !context.rotated) {
            return {
              next: {
                command: 'tap',
                color: color,
              },
            };
          }
        }
        break;
      }
      case 'shake': {
        const canShake = engine.dots.length > 5;

        if (canShake) {
          return {
            next: {
              command: 'shake',
            },
            nextCommandContext: {
              shaken: true,
              rotated: 'none',
            },
          };
        }
      }
      // ...
    }

    return undefined;
  }

What didn't work out so well was the shift to a more functional style. Originally, I had created classes with shared internal (mutable) state. But in-place mutations were surprising, so I switched to a redux-style message passing:

state handlers return a serializable object contaning the next command to execute as well as the next state context.

For example, in the above handler, the return payload says the next command is shake and that it should update the shared context appropriately:

return {
  next: {
    command: "shake",
  },
  nextCommandContext: {
    shaken: true, // update the context to shaken
    rotated: "none", // reset the rotation
  },
};

However, I'm gonna confess that the result has a higher learning curve, in other words, the DX isn't so great. So there's definitely some room for improvement.

On the plus side, because we're tracking the next command as well as the next internal state, we get command history with full undo functionality for free!

const commandHistory: Array<TransitionContext> = [];

// ...

let current = states.thinking;
let payload: TransitionContext;

while (true) {
  const next = current.transitionState({
    currentContext,
    ...payload,
  });

  if (next.next && next.nextCommandContext) commandHistory.push(next);

  //...
}

Conclusion

You can make the case for either approach:

In my use case, I will be adding more commands over time, so the explicit coupling should hopefully result in less bugs and rework.

Appendix

User Requirements

To recap, these are the user requirements:

  1. A user can be asked to press a dot of the given color. When the user performs this action, a new dot gets added to the game. This command is disabled when a dot of the given color does not exist or the screen is in a rotated state.
  2. A user can be asked to rotate the screen to the left or right. When the user performs this action, all the dots slide to the left or right. Also, it doesn't make sense to allow the same rotation again until the user resets the layout.
  3. A user can be asked to shake the screen. When the user performs this action, the dots are randomly shuffled. That means that all actions are re-enabled.

The Code

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

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

type State = "thinking" | "waiting" | "updating";

const commands = ["tap", "shake", "rotate"] as const;
type Command = typeof commands[number];

interface CommandContext {
  rotated: "left" | "right" | "none";
  shaken: boolean;
}

interface EngineContext {
  engine: {
    dots: Dot[];
  };
}

type RotationType = StateNodeContext["rotated"];

/** Aka the internal state used by a node */
type StateNodeContext = CommandContext & EngineContext;

interface TransitionContext {
  next?:
    | {
        command: Exclude<Command, "tap">;
      }
    | {
        command: Extract<Command, "tap">;
        color: Color;
      };
  nextCommandContext?: CommandContext;
}

interface StateNode {
  transitionState(
    transition: TransitionContext & { currentContext: StateNodeContext }
  ): TransitionContext & { nextState: State };
}

type TransitionStatePayload = Parameters<StateNode["transitionState"]>[0];
type TransitionStateReturn = ReturnType<StateNode["transitionState"]>;

class Thinking implements StateNode {
  nextCommand(
    ctx: StateNodeContext,
    next: Command
  ): TransitionContext | undefined {
    const { engine, ...context } = ctx;

    switch (next) {
      case "tap": {
        for (const color of randomSequence(colors)) {
          const canTap = engine.dots.some((dot) => dot.color === color);

          if (canTap && !context.rotated) {
            return {
              next: {
                command: "tap",
                color: color,
              },
            };
          }
        }
        break;
      }
      case "shake": {
        const canShake = engine.dots.length > 5;

        if (canShake) {
          return {
            next: {
              command: "shake",
            },
            nextCommandContext: {
              shaken: true,
              rotated: "none",
            },
          };
        }
      }
      case "rotate": {
        if (engine.dots.length <= 5) return;

        for (const type of randomSequence<StateNodeContext["rotated"]>([
          "left",
          "right",
        ])) {
          if (type !== context.rotated) {
            return {
              next: {
                command: "rotate",
              },
              nextCommandContext: {
                shaken: false,
                rotated: type,
              },
            };
          }
        }
        break;
      }
      default: {
        // should never get here
        // because compiler will yell
        const x: never = next;
      }
    }

    return undefined;
  }

  transitionState(transition: TransitionStatePayload) {
    const { currentContext } = transition;
    const { nextCommand } = this;

    let payload: ReturnType<typeof nextCommand>;

    for (const next of randomSequence(commands)) {
      payload = this.nextCommand(currentContext, next);

      if (payload) break;
    }

    const nextState: Pick<TransitionStateReturn, "nextState"> = {
      nextState: "waiting",
    };

    return {
      ...nextState,
      ...payload,
    };
  }
}

class Waiting implements StateNode {
  transitionState(transition: TransitionStatePayload) {
    const {
      next,
      currentContext: { engine, shaken, rotated },
    } = transition;
    let verbage: string;

    switch (next.command) {
      case "tap":
        verbage =
          engine.dots.length > 2
            ? `Press any ${next.color} dot`
            : `Press the ${next.color} dot`;
        break;
      case "shake":
        verbage = shaken ? "Shake the screen again" : "Shake the screen";
        break;
      case "rotate":
        verbage = `Tilt the screen to the ${rotated}`;
        break;
      default: {
        // should never get here
        // because compiler will yell
        const x: never = next;
      }
    }

    // show message
    console.log(verbage);
    // wait for user input

    const nextState: State = "updating";
    return { nextState };
  }
}

class Updating implements StateNode {
  transitionState() {
    const state: Pick<TransitionStateReturn, "nextState"> = {
      nextState: "thinking",
    };

    return state;
  }
}

function* randomSequence<T>(array: readonly T[]) {
  const indexes = [];
  const map: { [index: number]: true } = {};

  while (indexes.length < array.length) {
    const nextIndex =
      Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) % array.length;

    if (!map[nextIndex]) {
      indexes.push(nextIndex);
      map[nextIndex] = true;
    }
  }

  for (const index of indexes) {
    yield array[index];
  }
}

function NaiveGameLoop() {
  const currentContext: StateNodeContext = {
    engine: { dots: [] },
    rotated: "none",
    shaken: false,
  };

  const commandHistory: Array<TransitionContext> = [];

  const states: { [name in State]: StateNode } = {
    thinking: new Thinking(),
    waiting: new Waiting(),
    updating: new Updating(),
  };

  let current = states.thinking;
  let payload: TransitionContext;

  while (true) {
    const next = current.transitionState({
      currentContext,
      ...payload,
    });

    if (next.next && next.nextCommandContext) commandHistory.push(next);

    if (next.nextCommandContext) {
      Object.assign(currentContext, next.nextCommandContext);
    }

    current = states[next.nextState];
    payload = next;
  }
}