Spotted Arrow

2018-10-16

Let's Build a Simon Game in ReasonReact Pt. 5 User Input

In this article, you will learn to handle user input and either inform the user of valid or invalid input. Let’s begin.

The way the Simon Game works is that after clicking Start the sequence will play up to the current level. If the level is 1, it will play 1 jingle. If you repeat that sequence then you graduate to level 2. At that point, you will hear 2 jingles and if you repeat that you graduate to level 3. So on and so forth.

Let’s step through how the game will function using good ole’ bullet points.

Let’s make this into code!

I like to start with the small stuff first and gradually move up to the things that are more complex. To handle user actions you will need, you guess it, an action. Create an action called Input which takes a color type.

type action =
  | SetSequence(sequence)
  | PlaySequence
  | PlaySound(Types.colors)
  | ResetColor
  | Input(Types.colors); /* This is new */

Next update the reducer to handle that action as you have done before.

| Input(color) => ReasonReact.NoUpdate

There needs to be a way to track user input in order to make sure that after each click of a box, there is a way to see if the box clicked matches up with the sequence at that point in time. Add a field to state called input and have its type be a list of colors.

type state = {
  sequence,
  level: int,
  active: option(Types.colors),
  input: list(Types.colors), /* This is new */
};

As usual, update initial state to reflect these changes.

initialState: () => {sequence: [], level: 5, active: None, input: []},

After the user clicks a box it’s now important to check and make sure the input is correct. You can handle this using another action. Call it CheckInput. This action will fire right after Input(color) is handled.

type action =
  | SetSequence(sequence)
  | PlaySequence
  | PlaySound(Types.colors)
  | ResetColor
  | Input(Types.colors)
  | CheckInput; /* This is new */

Update the reducer now.

| CheckInput => ReasonReact.NoUpdate

Now let’s get down to business.

See what I did there. Sound. Logic. Dad joke of the year! But it actually makes sense here. When the user clicks a box, you add to state that color, then you can compare the first element in the input and then get the size of the input to then pluck off the color in the sequence at the index equal to the size. If that went over your head let’s write some code to clarify things.

First, there is going to be an UpdateWithSideEffects where the input is updated.

ReasonReact.UpdateWithSideEffects(
  { ...state, input: [color, ...state.input] },
  (self) => self.send(CheckInput)
);

I am adding a new color to input by adding to the head and spreading out the previous version of the list to the tail. Now that the input is updated, let’s check to see if the user’s input is accurate. That’s why you call the action CheckInput, which will house the logic for how user actions are handled.

Inside of CheckInput, pluck off the head element to begin. This will give us the latest user input that needs to be tested against the sequence.

let { level, input, sequence } = self.state;
let currentUserColor = Belt.List.headExn(input);

I am using Belt.List.headExn. This is what makes ReasonML an impure language in the functional sense. Instead of returning an option type, this function will either return back the value at the head of the list or will throw an exception if the list is empty. I think this is okay for what we are trying to do because I wouldn’t expect input to ever be empty. You can write the code however you like but this works for me.

Next, get the length of the list.

let inputLength = Belt.List.length(input);

And finally, get the current color for where the user is in the sequence.

let currentSequenceColor = Belt.List.getExn(sequence, inputLength - 1);

Again, I am using Belt.List.getExn, impure but I am okay with it here. The whole enchilada.

let { level, input, sequence } = self.state;
let currentUserColor = Belt.List.headExn(input);
let inputLength = Belt.List.length(input);
let currentSequenceColor = Belt.List.getExn(sequence, inputLength - 1);

I am going to enjoy the next part because I will get to use pattern matching to solve this problem!

Pattern matching is a great way to simplify what can turn into very inefficient or complex steps. There are three scenarios at the current moment to check on: If the user clicked an incorrect box, if the user clicked a correct box, and if a user clicked a correct box and graduates to the next level. I imagine this can get complex in an if/else statement but pattern matching makes this quite easy. Let me show you.

switch (
  (currentUserColor === currentSequenceColor, inputLength === level)
  /* More to come here */
) {
}

That is all you will need. Pretty succinct, huh? Let’s handle the first scenario for when a user hits the wrong box. The user will hear the error sound, then their input will be cleared, and the sequence will play again.

| (false, _) =>
      ReasonReact.UpdateWithSideEffects(
        {...state, input: []},
        (
          self => {
            Sounds.error##play();
            self.send(PlaySequence);
          }
        ),
      )

You might have noticed that the second pattern I am trying to match has an underscore instead of a boolean. This is because I don’t care about the second pattern. I only care whether the user input is false, and if it is, I play the error sound and have the user start over again.

Next, you need to handle when the user’s input is true but has not reached the last sequence in the level. At that point, you only are going to play the sound.

| (true, false) =>
      ReasonReact.SideEffects(
        (self => self.send(PlaySound(currentUserColor))),
      )

Finally, handle when the user input is correct and has reached the last sequence. You will clear the user’s input, increment the level, play the sound, and then play the next sequence.

| (true, true) =>
      ReasonReact.UpdateWithSideEffects(
        {...state, input: [], level: state.level + 1},
        (
          self => {
            self.send(PlaySound(currentUserColor));
            self.send(PlaySequence);
          }
        ),
      )

You may have noticed that PlaySequence does not have an initial delay. For example when the index is 0, 0 multiplied by 1000 is 0. Therefore, PlaySound and PlaySequence will pretty much be simultaneous. Let’s update PlaySequence slightly so that the first element has a slight delay.

(index + 1) * 1000,

This will not do a darn thing without you adding the Input(color) action to the onClick handler in the boxes.

<div
  className={Styles.box(~bgColor=Green, ~active)}
  onClick={_e => self.send(Input(Green))}
/>

Oh yeah, let’s see this thing go!

In this article, if you didn’t just scroll to the bottom, you learned how to handle business logic pretty seamlessly using pattern matching. That was fun. In the next lesson, you will tidy up the application more by adding a reset button, a strictness option, and some more niceties.

This article has Webmentions