FBRY

Wibble: Day 3

Today was the third day I worked on Wibble, and the plan was to implement chaining finally. This was actually quite easy due to the thinking I had done back on Day 1.

Planning Out How Chaining Works

Based on my work during Day 2, there are two states: “title” and “play.” But the state machine needs to move into a special chaining state. This has the following requirements:

  1. We enter the chaining state when we press the left mouse button on a letter tile.

  2. While the mouse button is held down, a letter is added to the “chain” that forms the rest of the word whenever the user drags over a new tile.

  3. When a new letter is added, the current word and the score for that word are updated.

  4. When the mouse button is released, we exit the chaining state and add the word score to the player’s total score.

    • More complex behavior will be implemented here in the future, such as validating that what the player spelled is a permissible word!

  5. The last letter is removed when the player drags over a letter that has already been selected and is the second-to-last letter in the chain.

    • This is backtracking and the only way the player can modify their word while chaining.

Implementing Chaining State Transitions with Sub-States

The chaining state implies a non-chaining state or “idle” state in which the state machine transitions out of when the player starts chaining and then into when they are done chaining. A dirty way to implement this with our existing state machine would be to treat the play state as our “idle” state. However, as we have it implemented now, every time we enter the play state, it generates a new random board, which means the board will change every time we exit the chaining state, regardless of whether or not we created a valid word.

To get around this, I used a feature in XState called sub-states. This allows me to nest states under an existing state. In this case, I gave the “play” state two sub-states called “idle” and “chaining.” By default, whenever the state machine enters the “play” state, it immediately transitions to the “idle” sub-state. The machine transitions into the chaining state when the “add letter” event is received in this state.

While in the chaining state, the state machine doesn’t return to the idle state until it is sent a “quit chaining” event. Whenever the state machine receives an “add letter” or “remove letter” event, it self-transitions and modifies the context to either add or remove letters from the current word and update the score appropriately.

Here is a diagram of the state machine so far and the code for representing these sub-states:

A state diagram that shows a state machine with four states: Title, Play, Idle, and Chaining. The Title state is the initial state, which transitions to the Play state when the machine is told to start the game. The Play state immediately transitions to the Idle state, which waits for a letter to be added. When this happens, the Play state transitions to the Chaining state. The Chaining state then waits for a message to quit chaining before transitioning back to the Idle state. While in the Chaining state, any addition or removal of letters causes a self-transition from the Chaining state to itself.
createMachine({
  initial: 'title',
  states: {
    title: {
      entry: 'setupTitle',
      on: {
        START_GAME: 'play'
      }
    },
    play: {
      entry: 'setupGame',
      initial: 'idle',
      states: {
        idle: {
          on: {
            ADD_LETTER: {
              target: 'chaining'
            }
          }
        },
        chaining: {
          on: {
            ADD_LETTER: {},
            REMOVE_LETTER: {},
            QUIT_CHAINING: {
              target: 'idle'
		   }
          }
        }
      }
    }
  }
})

Building Words With The New Sub-States

Now that these new sub-states are in play, I need to handle adding letters. In the old state machine, this was done by telling the play state to execute an addLetter action every time the state machine received an “add letter” event:

play: {
  entry: 'setupGame',
  on: {
    ADD_LETTER: {
      actions: 'addLetter'
    }
  }
}

However, that has been overridden with “add letter” handlers on the two sub-states. Each will have to invoke the addLetter action individually:

play: {
  entry: 'setupGame',
  initial: 'idle',
  states: {
    idle: {
      on: {
        ADD_LETTER: {
          target: 'chaining',
		 actions: 'addLetter'
        }
      }
    },
    chaining: {
      on: {
        ADD_LETTER: {
          actions: 'addLetter'
        },
        REMOVE_LETTER: {},
        QUIT_CHAINING: {
          target: 'idle'
        }
      }
    }
  }
}

Now to get the behavior of pressing the mouse button down and dragging the cursor across letters to build a word, the Tile component has to be modified to alter its behavior depending on whether the game is currently in the “idle” or “chaining” state.

First, to check that the game is in the chaining state, I have to use the useSelector hook from the XState React bindings, which allows the Tile components to only re-render when a specific piece of state changes:

const isChaining = useSelector(actor, (state) => state.matches('play.chaining'))

Here I use the matches function to check what state the state machine is currently in, but unlike the last time, since I’m checking if the machine is in a sub-state, dot syntax has to be used. play.chaining means that matches needs to check if we are first in the “play” state, then in the “chaining” sub-state.

Now that the Tile component knows when it is in the chaining state, it can be modified to have different behavior depending on whether or not the game is in that state:

<div
  {
    ...(
      !isChaining
        ? { onPointerDown: addLetter }
        : { onPointerEnter: addLetter }
    )
  }
>
  {/* ... */}
</div>

This snippet toggles between using the onPointerDown listener and the onPointerEnter listener to invoke an addLetter callback that sends the “add letter” event to the state machine. When the game is not chaining (i.e., it is in the “idle” state), the tile will use the onPointerDown listener, and when the game is chaining, the tile will use the onPointerEnter listener.

Now, when we click down on a tile and then drag the cursor over another tile, we can create a chain of letters:

An animation illustrating simple letter chaining. The player's cursor starts on an "L" and then drags across a "Y" and another "L," spelling "LYL."

Computing the Word and Total Scores

Now that players can chain letters together, the game needs to show them the score for their current letter and the total score overall. To do this, I updated the “add letter” event to pass a letter and the point value of that letter. With this information, the addLetter action in the state machine can be updated to both append the new letter to the current word and also increment the current word score:

addLetter: assign({
  currentWord: (context, event: { type: 'ADD_LETTER', letter: string, points: number }) =>
    context.currentWord + event.letter,
  currentWordScore: (context, event: { type: 'ADD_LETTER', letter: string, points: number }) =>
    context.currentWordScore + event.points,
})

This word score can then be displayed next to the word as the player constructs it:

An animation showing letter chaining, now with a word score. As the player chains together the letters to spell "LYL," a score number updates from 1, to 5, to 6 total points.

When the player stops chaining, the game adds the current word score to a total score. But as of right now, the “quit chaining” event is never emitted, so when the player releases the mouse button, nothing happens.

An event listener for mouse/pointer-up events needs to be added to remedy this. I originally tried this on the tiles themselves:

<div
  {
    ...(
      !isChaining
        ? { onPointerDown: addLetter }
        : {
            onPointerEnter: addLetter,
            onPointerUp: stopChaining,
          }
    )
  }
>
  {/* ... */}
</div>

But this meant the player had to have their cursor already on a tile to exit the chain, which wasn’t intuitive nor convenient. Instead, I added the event listener to the board itself. This could be hoisted all the way up to the window if the player’s cursor is outside the board when they release the mouse button, but I decided attaching it to the board was sufficient for now.

Once the player stops chaining, we want the state machine to perform an action that does two things:

  1. Add the word score to the game’s total score.

  2. Clear the current word and word score.

This action I’ll call cleanup:

cleanup: assign({
  currentWord: "",
  currentWordScore: 0,
  totalScore: (context) => context.totalScore + context.currentWordScore,
})

And if we add a display for the current score, we have what looks like a functional game:

An animation showing an updating total score along side the chained letters and word score.

But there’s one problem: once a player has selected tiles, they cannot deselect a tile to correct a mistake.

Refactoring to Implement Backtracking

In my laziness and desire to implement things as simple as possible, my code only works if you want to add letters. If you want to remove letters, things get pretty complicated. As noted at the beginning of this post, backtracking works by checking if the player has dragged their cursor over a selected letter, checking that the letter is the second-to-last letter in the chain, and then dropping the last letter of the chain.

Currently, if I wanted to check if the letter the player hovered over was the second to last in the chain, I’d take a slice of the current word and check that the second to last character equals the character displayed on the tile. But the problem with this is that characters are not unique among tiles — every board shown has repeated tiles, and (English) words tend to have repeated characters inside them. With this proposed solution, the player could run into a situation where they tried to spell “RUNNER,” made a wrong selection when trying to pick “E,” had to backtrack to the last “N,” only to accidentally select the first “N.” While the player would still see the intended result — “E” was deselected — they also will be able to make selections branching from the middle of the chain, which are illegal.

To avoid this exploit, I need to store the actual chain of tiles and not just the current word and score. This involves giving each tile a unique identifier so that I can tell them apart. Luckily, each tile already has a unique identifier: its position on the grid. The chain will then be an array of column-row pairs, where each pair represents the location of a tile that has been selected and added to the chain. This means that the addLetter action has to be modified not to accept a letter and score but rather a column-row pair:

addLetter: assign({
  currentChain: (context, { location }: { type: 'ADD_LETTER', location: [number, number] }) =>
    context.currentChain.concat([location])
}),

And to keep the word and word score displays up to date, I’ll add a new action called updateWord:

updateWord: assign({
  currentWord: (context) => context.currentChain.reduce(
    (word, [col, row]) => word + context.board[row][col].letter,
    ''
  ),
  currentScore: (context) => context.currentChain.reduce(
    (score, [col, row]) => score + context.board[row][col].score,
    0
  )
}),

Now the state machine needs an update to the “idle” and “chaining” states:

states: {
    idle: {
      on: {
        ADD_LETTER: {
          target: 'chaining',
          actions: [
            'addLetter',
            'updateWord'
          ]
        }
      }
    },
    chaining: {
      on: {
        ADD_LETTER: {
          actions: [
            'addLetter',
            'updateWord'
          ]
        },
        REMOVE_LETTER: {},
        QUIT_CHAINING: {
          target: 'idle'
        }
      }
    }
  }

XState invokes actions in the order they appear in the actions array, so updateWord will always be invoked afteraddLetter. To make the code a bit more DRY, I extracted this repeated array into a variable called addLetterActions:

const addLetterActions = [ 'addLetter', 'updateWord' ]

// ...

states: {
    idle: {
      on: {
        ADD_LETTER: {
          target: 'chaining',
          actions: addLetterActions
        }
      }
    },
    chaining: {
      on: {
        ADD_LETTER: {
          actions: addLetterActions
        },
        REMOVE_LETTER: {},
        QUIT_CHAINING: {
          target: 'idle'
        }
      }
    }
  }

Now that the code has been refactored to store a chain of tiles instead of just the current word and score, removing letters can be implemented. The action removeLetter is simple since we will only ever be removing the last letter in a chain:

removeLetter: assign({
  currentChain: (context) =>
    context.currentChain.slice(0, -1)
}),

Wiring it up to the “remove letter” handler in the state machine is identical to the “add letter” handler:

chaining: {
  on: {
    ADD_LETTER: {
      actions: addLetterActions
    },
    REMOVE_LETTER: {
      actions: [
        'removeLetter',
        'updateWord',
      ]
    },
    QUIT_CHAINING: {
      target: 'idle'
    }
  }
}

Now the tile component has to be configured to remove a letter only when the tile is the second to last in the chain. This is accomplished by adding two additional values to the useSelector selector:

useSelector(actor, (state) => ({
  isChaining: state.matches('play.chaining'),
  isTailOfChain: state.context.currentChain.slice(-2)[0].toString() === location.toString(),
  isSelected: state.context.currentChain.find((l) => l.toString() === location.toString())
}))

Here, isTailOfChain is a flag that informs the tile that it is the second to last in the chain and isSelected informs the tile that it is in the chain. Using these flags, the tile component can be given a callback called removeLetter:

const removeLetter = useCallback(() => {
  if (isTailOfChain) {
    actor.send('REMOVE_LETTER')
  }
}, [actor, location, isTailOfChain])

The usage of the React hook useCallback ensures that the callback is only updated when either the state machine instance (actor), the location of the tile or the tile’s position in the chain changes. Then the tile’s event listeners can be configured like this:

<div
  className={(isSelected != null) ? style.tileSelected : style.tile}
  {
    ...(
      !isChaining
        ? { onPointerDown: addLetter }
        : (
            { onPointerEnter: (isSelected ? removeLetter : addLetter) }
          )
    )
  }
>
  {letter}
  <div className={style.tileScore}>
    {score}
  </div>
</div>

Now the player can backtrack!

An animation showing the player first spelling "RAD," then backtracking to spell "RAL," then "RAAL."

This version of Wibble is hosted at: https://wibble-day-3.vercel.app. The code can also be found on my GitHub.