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 GameStateTransition
s 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:
- The user can rotate the screen from any state.
- 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).
- Also once the user rotates the screen, it doesn't make sense to allow the same rotation again.
- 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);
}
}
}