FBRY

Wibble: Day 2

When I last left off, I decided that I was going to use a state machine library for handling the state of Wibble. Today I decided which library, State Designer or XState, to use, and implemented the ability to start a game and add letters to the word chain.

Why I Chose XState Over State Designer

When considering the two different state machine libraries that I had found, I had two things on my mind:

  1. I don’t want to pick a library that will make developing Wibble harder than it should be.

  2. I want to pick a technology that is widely used so that I can translate my experience with Wibble to other projects.

And when I considered both libraries, I gravitated to State Designer because it had a simple, straightforward API and easy-to-understand rules. However, there hasn’t been a commit to the project in over two years, and its API was based on the XState API. Additionally, XState is more widely used and adheres to an external standard, SCXML. This isn’t necessarily important for Wibble, but it was enough for me to choose XState over State Designer.

Challenge 1: Where Does the Data Go?

It might sound pretty obvious, but data about the game, such as the contents of the board and the current chain of letters, all went inside the state machine. In XState, you not only define the states of the state machine and what transitions are possible, but you can also define a context. The context can allow the state machine to decide what transition to perform, but it also provides the state machine with data it can manipulate. Using the XState React bindings, we can subscribe to state machine changes, which include context changes, and re-render.

Challenge 2: Implementing the State Machine

The actual states of the state machine are pretty straightforward after having picked apart the features around the chaining feature. We have two predominant states: “chaining” and “non-chaining.” For clarity, I’ll call the “non-chaining” state simply the “idle” state. In XState, the state machine would so far look like this:

createMachine({
	initial: 'idle', // we always start in the 'idle' state.
	states: {
		idle: {},
		chaining: {},
	}
})

Since both the objects defining the “idle” and “chaining” states are empty, if we were to create a diagram of this state machine, it’d look like this:

A state diagram showing an initial state called "Idle" and a disconnected state called "Chaining."

We need to declare some transitions, but first, I should add the context and then hook everything up to the rest of the rendering code to make sure everything is working as intended:

createMachine({
	initial: 'idle', // we always start in the 'idle' state.
	states: {
		idle: {},
		chaining: {},
	},
	context: {
		currentWord: '',
		board: generateRandomBoard(),
	}
})

And after hooking everything together so we now use the XState state machine to give us a board and current word, we get this:

An error screen from Next.js states that a letter on the page has changed, indicating that some rendering code was executed during the rehydration of the React components on the page.

F*ck.

Next.js didn’t like that, and it’s a little confusing because rendering a random board worked fine before, so why not now? I was using a useEffect hook to generate the random board when the app rehydrated, which Next.js allows because it treats useEffect calls as supposed to run on the client and doesn’t execute them during server-side rendering. On the other hand, the state machine initialization isn’t wrapped in a useEffect hook, so it runs during the server-side render and the client-side rehydration, causing the difference that Next.js is complaining about.

A naive solution is to wrap the creation of the state machine in a useEffect somehow. But a more intelligent solution is implementing a feature we needed anyways: a title screen. So abandoning the implementation of the “chaining” and “idle”, we can repurpose our state machine to move from a “title” state and a “play” state:

createMachine({
	initial: 'title',
	states: {
		title: {},
		play: {},
	},
	context: {
		currentWord: '',
		board: generateRandomBoard(),
	}
})

And instead of generating a random board on initialization, we generate a title board. This title board will be the same every time we generate it, keeping Next.js happy. And instead of generating it when we pass the context, we’ll generate the sate machine enters the “title” state:

createMachine(
	{
		initial: 'title',
		states: {
			title: {
				entry: 'setupTitle',
			},
			play: {},
		},
		context: {
			currentWord: '',
			board: [],
		}
	},
	{
		actions: {
			setupTitle: assign({
	      currentWord: (_) => '',
	      board: (_) => generateTitleBoard()
	    }),
		}
	}
)

Here I use the XState notion of “actions.” When you create a state machine with XState, you can supply an options object describing several extensions for your state machine. In this case, I’m using the actions object that will allow me to give my state machine functions it can invoke. I use this to create a setupTitle action that clears the current word and sets the board to the title board. This action is executed whenever the machine enters the “title” state.

So if we check Wibble now, we see our title board:

A five-by-five grid of letters, each row misspelling the game title as "W-I-B-L-E."

But if I diagram the state machine, it still looks like this:

A state chart showing an initial state of "Title" and a disconnected state called "Play."

So when should Wibble transition from the “title” state to the “play” state? When the player tells it to:

A state chart showing an initial state called "Title" with a single transition labeled "Start game" that connects to a state called "Play."

To implement this in XState, all I have to write is this:

createMachine(
	{
		initial: 'title',
		states: {
			title: {
				entry: 'setupTitle',
				on: {
					START_GAME: 'play'
				}
			},
			play: {},
		},
		context: {
			currentWord: '',
			board: [],
		}
	},
	{
		actions: {
			setupTitle: assign({
	      currentWord: (_) => '',
	      board: (_) => generateTitleBoard()
	    }),
		}
	}
)

Notice the on field added to the “title” state. This defines our transitions and their behaviors. In this case, I tell the “title” state that when it receives an event labeled START_GAME, it should transition to the “play” state. In XState, an event is how you interact with a state machine from the larger application. When you use a state machine, for instance, with the useMachine hook, you are given three things: the current state, a send function, and a reference to the currently running machine.

The send function is how I’ll tell the state machine to change from the “title” to the “play” state:

const [state, send] = useMachine(stateMachine)

return (
  <main>
		{/* ... */}
    <button onClick={() => send('START_GAME')}>
      Play
    </button>
  </main>
)

But if we click this button, we don’t see anything happen:

An animation showing a five-by-five grid of letters and a button underneath them. The user clicks on the button, only for nothing to change. The user repeatedly clicks on the button in frustration, with no changes happening.

I haven’t told XState that anything happens when we enter the “play” state. I can fix this by telling the “play” state to set the board to a randomly generated board on entry:

createMachine(
	{
		initial: 'title',
		states: {
			title: {
				entry: 'setupTitle',
				on: {
					START_GAME: 'play'
				}
			},
			play: {
				entry: 'setupGame',
			},
		},
		context: {
			currentWord: '',
			board: [],
		}
	},
	{
		actions: {
			setupTitle: assign({
	      currentWord: (_) => '',
	      board: (_) => generateTitleBoard()
	    }),
			setupGame: assign({
	      board: (_) => generateRandomBoard()
	    }),
		}
	}
)

Additionally, I’ll tell the game to remove the “Play” button when we aren’t in the “title” state:

state.matches('title')
? (
  <button onClick={() => actor.send('START_GAME')}>
    Play
  </button>
  )
: null

And if I try to click play again, we see that the play button disappears and the board changes:

An animation showing a five-by-five grid of letters and a button underneath them. When the user clicks the button, the button disappears, and the grid of letters changes to now contain a random assortment of letters.

Challenge 3: Allowing All Components to Talk to the State Machine

Currently, the state machine is only wired to the game's top-level component. But each tile is a component that needs to talk to the state machine since the tile component is what handles the mouse input and tells the game what character to append to the current word. Initially, I just invoked the useMachine hook and used the same send function as used to make the play button work.

However, there was a problem. The state machine wasn’t receiving the events to add letters to the current word. I spent a lot of time scratching my head before I realized what should have been readily apparent: useMachine creates a whole new state machine every time it is invoked. That meant that each tile was talking to its own, unique instance of the state machine!

It turns out that XState’s docs answered my trouble: use React Context to share a single state machine across many components. This also required two other hooks from the XState React bindings: useInterpret and useActor. This created code that looked like this:

const Wibble = () => {
	const actor = useInterpret(gameStateMachine)
	
	return (
		<GameStateMachineContext.Provider value={actor}>
		  <Game />
	  </GameStateMachineContext.Provider>
	)
}
const Game = () => {
	const actor = useContext(GameStateMachineContext)
	const [state] = useActor(actor)

	return (
		// Code that draws the board and button.
		// Instead of using `send` here, we can use `actor.send`.
	)
}
const Tile = ({ letter, score }) => {
	const actor = useContext(GameStateMachineContext)
	// We don't need the state in the Tiles, so we skip useActor.

	return (
		<div onClick={() => actor.send({ type: 'ADD_LETTER', letter })}>
			{letter}
			{/* ... */}
		</div>
	)
}

useInterpret is like useMachine, but instead of giving us access to the machine state and the send function, it only gives us a reference to the actor. This means that the top-level component will re-render only when the machine is created (or recreated). Then, in our game component, we retrieve this actor instance and call useActor, which gives us access to the state and the send function, though we don’t need that if we have the actor. Calling actor.send does the same thing as send from useMachine or useActor.

And what does our “add letter” event do? Well, I updated our state machine definition:

createMachine(
	{
		initial: 'title',
    states: {
      title: {
        entry: 'setupTitle',
        on: {
          START_GAME: 'play'
        }
      },
      play: {
        entry: 'setupGame',
        on: {
          ADD_LETTER: {
            actions: 'addLetter',
          }
        }
      }
    },
    context: {
      currentWord: '',
      board: []
    },
  },
  {
    actions: {
      setupTitle: assign({
        currentWord: (_) => '',
        board: (_) => generateTitleBoard()
      }),
      setupGame: assign({
        board: (_) => generateRandomBoard()
      }),
      addLetter: assign({
        currentWord: (context, event: { type: 'ADD_LETTER', letter: string }) =>
          context.currentWord + event.letter
      })
    }
  }
)

Here I gave the “play” state the instruction that whenever an “add letter” event is sent, it will invoke the addLetter action. This appends the letter sent as part of the event’s payload to the current word. Now when we run Wibble, we can start the game, get a random board, and click on random letters to create a word:

An animation showing a five-by-five grid of letters and a button underneath it. The user clicks the button, making it disappear, and the letters in the grid are replaced with a new assortment of letters. The user hesitates momentarily before clicking the letters S, I, and T. As the user clicks these letters, they appear above the grid, eventually spelling the word 'SIT.'

You can play with this version of Wibble here: https://wibble-day-2.vercel.app/. The code for this version can also be found on my GitHub.