Spotted Arrow

2018-10-19

Let's Build a Simon Game in ReasonReact Pt. 6 Tidying it Up

In this article, you are going to tidy up the application and make it behave in a more optimal way. At least from my perspective. Here is a list of the things that are going to be improved.

I think adding a header makes the app feel more legitimate. The header I usually include for this series has the title of the game and the stack used to create it: Simon Game in ReasonReact.

/* More stuff */
<div className=Styles.container>
  <h1> "Simon Game in ReasonReact"->ReasonReact.string </h1>
/* More stuff */

I learned in the process of making this series that you can also use the fast pipe operator for creating strings in ReasonReact. The one benefit to using this is you don’t have to surround the converted string in parenthesis or curly braces as you would with the pipe operator. I don’t know why this is the case so don’t ask me that 😅.

I don’t really like the browser default for the font-family. Let’s change this to how many modern apps set the font-family which is by using system fonts. Thankfully, bs-css has a global function which allows us to set defaults on tags.

open Css;
  global(
    "body",
    [
      fontFamily(
        "-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif",
      ),
    ],
  );

This font-family is what Medium uses so I think it’s fitting here. Make sure you to escape the internal font families which have quotes around them otherwise it won’t compile.

The user may want to reset the game. Let’s add an action called Reset which takes no arguments and resets the level back to 1 and also clears the user’s input.

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

Then add the action to the reducer.

| Reset => ReasonReact.Update({...state, input: [], level: 1})

Next, add the button to the markup section.

<div>
  <button onClick={(_e) => self.send(PlaySequence)}>
    {"Start" |> ReasonReact.string}
  </button>
  <button onClick={(_e) => self.send(Reset)}>
    "Reset"->ReasonReact.string
  </button>
</div>

Reset is now ready to go!

The original Simon Game comes with a strictness option so that whenever the user clicks on the wrong box they are sent back to level 1. Let’s also do that here. First, create a state field called isStrict which will be a boolean. Afterward, create an action which sets the strictness. It will not take an argument. Instead, it will take the previous state and have it be the opposite. Call this action SetStrictness.

Now update state.

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

Then add the action type.

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

Now handle the action in the reducer.

| SetStrictness =>
    ReasonReact.Update({...state, isStrict: !state.isStrict})

Then add a checkbox, with some other stuff, to the markup with an onChange handler.

<div>
  <span> "Strict"->ReasonReact.string </span>
  <input
    type_="checkbox"
    checked=isStrict
    onChange={_e => self.send(SetStrictness)}
  />
</div>

Now that a strictness level is set you need to modify how you handle user input. When strictness is on and the user selects the wrong box they are sent back to level 1 and the input is cleared.

let {level, input, sequence, isStrict} = state;

/* More stuff here but not included */

switch (
        currentUserColor === currentSequenceColor,
        inputLength === level,
        isStrict,
      ) {
      | (false, _, false) =>
        ReasonReact.UpdateWithSideEffects(
          {...state, input: []},
          (
            self => {
              Sounds.error##play();
              self.send(PlaySequence);
            }
          ),
        )
      | (false, _, true) =>
        ReasonReact.UpdateWithSideEffects(
          {...state, input: [], level: 1},
          (
            self => {
              Sounds.error##play();
              self.send(PlaySequence);
            }
          ),
        )
      | (true, false, _) =>
        ReasonReact.SideEffects(
          (self => self.send(PlaySound(currentUserColor))),
        )
      | (true, true, _) =>
        ReasonReact.UpdateWithSideEffects(
          {...state, input: [], level: state.level + 1},
          (
            self => {
              self.send(PlaySound(currentUserColor));
              self.send(PlaySequence);
            }
          ),
        )

Notice you still have to handle the case for the other patterns even though it doesn’t matter to them. That’s why I just pass an underscore. With that I think we are done here.

It kind of messes up the gameplay when a user can click buttons whenever they want. For example, when the game is playing back a sequence and it’s not finished a user could click a box and that is not good. It’s not that it’s bad, but it’s not how the user should play the game.

First, add a state field called isPlaying. Then add an action called SetPlaying which takes no arguments. Then in the reducer, specifically in the PlaySequence action, update isPlaying to true. Then, only after the entire sequence is done, set isPlaying to false.

Updating state:

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

Updating actions:

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

Updating the reducer with an additional action:

| SetPlaying =>
    ReasonReact.Update({...state, isPlaying: !state.isPlaying})

Updating PlaySequence to update state when the sequence starts and ends:

ReasonReact.UpdateWithSideEffects(
        {...state, isPlaying: true}, /* This is new */
        (
          self => {
            Belt.List.forEachWithIndex(
              l,
              (index, color) => {
                let _id =
                  Js.Global.setTimeout(
                    () => self.send(PlaySound(color)),
                    (index + 1) * 1000,
                  );
                ();
              },
            );

            /* This here below is new */
            let _id =
              Js.Global.setTimeout(
                () => self.send(SetPlaying),
                (state.level * 1000) + 300,
              );
            ();
          }
        ),
      );

Now update the buttons to be disabled whenever the sequence is playing.

<div>
  <button onClick={_e => self.send(PlaySequence)} disabled=isPlaying>
    {"Start" |> ReasonReact.string}
  </button>
  <button onClick={_e => self.send(Reset)} disabled=isPlaying>
    "Reset"->ReasonReact.string
  </button>
</div>

The isPlaying value is destructured above: let {level, active, isStrict, isPlaying} = self.state;

Also, update boxes to be buttons as well.

<button
  type_="button"
  className={Styles.box(~bgColor=Green, ~active)}
  onClick={_e => self.send(Input(Green))}
  disabled=isPlaying
/>
<button
  type_="button"
  className={Styles.box(~bgColor=Red, ~active)}
  onClick={_e => self.send(Input(Red))}
  disabled=isPlaying
/>
<button
  type_="button"
  className={Styles.box(~bgColor=Blue, ~active)}
  onClick={_e => self.send(Input(Blue))}
  disabled=isPlaying
/>
<button
  type_="button"
  className={Styles.box(~bgColor=Yellow, ~active)}
  onClick={_e => self.send(Input(Yellow))}
  disabled=isPlaying
/>

I like to declare a button as a button type just to be more semantic. This does add some funky looking borders to your boxes now though. Just remove all borders from the boxes to clean this up.

let baseStyle = [
  minHeight(px(250)),
  minWidth(`px(250)),
  border(`px(0), `none, `transparent),
];

That should be all. If you now play around with the game while a sequence is going on you shouldn’t be able to do a darn thing!

To notify the user they have won you will use a simple alert. You could do this by using plain ole’ JS inside of Reason but I think it’s better to use a type-safe library. Here you will use the current web api library that’s ported over the DOM to Reason.

After adding bs-webapi to the project open the module up in the App.re file.

/* Top of App.re file  */
open Webapi.Dom;

The way this works is when the user has reached the end of the game and has answered all the questions correctly, the alert comes on, and a new game is created. A new game means making an entirely new sequence, clearing out any user input, and setting the level back to 1. This will require updating the logic inside of the CheckInput action. You will add an additional pattern to check for when the user has reached the end. Before doing this though, pull out the function in onMount that creates the random sequence into a function outside the component so it can be passed around.

let makeSequence = (~len=5, ()) =>
  Belt.List.makeBy(
    len,
    _i => {
      open Types;
      let num = Js.Math.floor(Js.Math.random() *. 4.0 +. 1.0);
      switch (num) {
      | 1 => Green
      | 2 => Red
      | 3 => Blue
      | 4 => Yellow
      | _ => Green
      };
    },
  );

You might notice something peculiar. As you have seen in a previous post, the ~ signifies a labeled argument. But what you haven’t seen is the additional () that comes after the labeled argument. The reason there are extra parenthesis is because I gave a default value of 5 to the len argument. That means the labeled argument is optional. And because it’s optional, you have to indicate to the compiler when you want to execute the function. This means, when you pass in an argument, makeSequence(~len=20), the function has all it’s arguments and can execute. And when you don’t pass in an argument, makeSequence(()) which converts to makeSequence(), it accepts the default value passed in and executes. This is quite different from the JS side of things where function arguments are given defaults and can be called like any other function. A little strange but such is the nature of labeled arguments. Now you can replace the function in onMount.

let list = makeSequence();
self.send(SetSequence(list));
();

Next, let’s update the logic inside of the CheckInput action. Like I said before, you need to check and see if the user is at the end. You can find this out by checking to see if the user input length is the same length as the sequence.

let isEnd = inputLength === Belt.List.length(sequence);

Next, update the switch statement to check for this condition as well.

switch (
        currentUserColor === currentSequenceColor,
        inputLength === level,
        isStrict,
        isEnd,
      )

Now you need to walk through each pattern and add this match.

{
      | (false, _, false, _) =>
        ReasonReact.UpdateWithSideEffects(
          {...state, input: []},
          (
            self => {
              Sounds.error##play();
              self.send(PlaySequence);
            }
          ),
        )
      | (false, _, true, _) =>
        ReasonReact.UpdateWithSideEffects(
          {...state, input: [], level: 1},
          (
            self => {
              Sounds.error##play();
              self.send(PlaySequence);
            }
          ),
        )
      | (true, false, _, false) =>
        ReasonReact.SideEffects(
          (self => self.send(PlaySound(currentUserColor))),
        )
      | (true, true, _, false) =>
        ReasonReact.UpdateWithSideEffects(
          {...state, input: [], level: state.level + 1},
          (
            self => {
              self.send(PlaySound(currentUserColor));
              self.send(PlaySequence);
            }
          ),
        )
      | (true, _, _, true) =>
        let list = makeSequence();
        ReasonReact.UpdateWithSideEffects(
          {...state, input: [], level: 1, sequence: list}
          (
            self => {
              self.send(PlaySound(currentUserColor));
              let _id =
                Js.Global.setTimeout(
                  () => Window.alert("You won!", window),
                  400,
                );
              ();
            }
          ),
        )
      };

For the first two patterns, you don’t really care if you reached the end. This is because the answer is wrong, which has its own path to follow. When you get to the last three, that’s where the pattern is important. You handle the situation for when the user gets a correct answer, goes to another level, and for when the user has reached the last level.

Try it out! You should win the game pretty easily 😄.

There are a few styles I want to update to make the app a little more presentable. Currently, the strict checkbox and buttons are not aligned. Let’s fix that. Use textAlign to center the content inside the controls styles.

let controls = style([marginTop(`px(10)), textAlign(`center)]);

Next, give a little spacing between the buttons, Start and Reset, and the level. To do this wrap, the buttons in a buttons className.

<div className=Styles.buttons>
  <button onClick={_e => self.send(PlaySequence)} disabled=isPlaying>
    {"Start" |> ReasonReact.string}
  </button>
  <button onClick={_e => self.send(Reset)} disabled=isPlaying>
    "Reset"->ReasonReact.string
  </button>
</div>

These styles don’t exist yet so create them. When you do, add a marginTop of 10 pixels.

let buttons = style([marginTop(`px(10))]);

Hmm. I think it’s starting to look good now. I am satisfied. If you’re not then keep working on it!

That’s all folks 🐷!

In this lesson, you learned how to clean up the app and make it bit more presentable. What a series it’s been. Six freaking parts. It was fun though. Thanks for tagging along and I hope you are now not only more competent using Reason/ReasonReact but also excited to use it too. That’s it, get outta here!

This article has Webmentions