Cispy

Communicating Sequential Processes for JavaScript

Fork me on GitHub

CSP Combination Lock

This example implements the simplest (and most insecure) of combination locks. It demonstrates the use of event channels and governing them all by a timer.

It would probably be a good idea to have a look at the introductory demo before looking at this one. Some of the concepts are pretty close to the same and I won't go as much into them here.

The problem

Quite simply, we have three buttons (marked A, B, and C). There is a single combination of these buttons that will solve the combination (spoiler alert: it's a Genesis song). From the time we push the first button, we have only five seconds to complete the combination.

If we get a button wrong, we can continue until the timer runs out, but we have to start over with our button clicks. If we get the right combination, we want to know right away, rather than having to wait for our five seconds to expire.

The solution

We can do this with channels and a process, which probably makes sense given that this is an example in a CSP library. Here's the working example; after that, we'll go over how it works.

(In case you don't know any Genesis songs, the combination is A B A C A B.)

The channels

Channels are used to communicate the events from the buttons being clicked to any process that's interested in knowing about such a thing. We make this happen by using a function that we used in the intro, which causes an event firing on a particular DOM element to put the event objects on a channel.


      function listen(el, type, ch = chan()) {
        el.addEventListener(type, event => putAsync(ch, event));
        return ch;
      }
    

This function takes a DOM element and a type of event (in our case, since we're looking for click events, that type is 'click'). It then either uses the channel we pass in as the third parameter or, if there is no third parameter, creates a new channel. Every time the specified event occurs on the specified DOM element, the event object will be put onto this channel.

Thing is, we're not really interested in anything about this event object. We're only interested in knowing which button was clicked. There are a few ways to do this. We could check the target property of the event object. We could use the channel property of the object returned by alts to figure out which channel unblocked the process, knowing that each channel is assigned to one button. Or we could actually manipulate the channel to return a value that tells us which channel it is. We'll use the latter because it's a pretty elegant solution.

We don't want to change our code in listen, because it's a generic and highly useful function that we can use over and over again (it was used twice in the three intro examples, for instance). Instead, we'll use the ability of cispy channels to use the transformer functions that come out of a transducer library (there are a few good ones, but you may want to go with xduce). We'll use the map transformer to turn that event object into something we can use better. This is exactly what we did in the third intro example, except this time we don't want coordinates. We want an identifier for the button.


      const A = Symbol();
      const B = Symbol();
      const C = Symbol();

      function constantly(x) {
        return () => x;
      }

      const chA = listen(byId('button-a'), 'click', chan(1, { transducer: map(constantly(A)) }));
      const chB = listen(byId('button-b'), 'click', chan(1, { transducer: map(constantly(B)) }));
      const chC = listen(byId('button-c'), 'click', chan(1, { transducer: map(constantly(C)) }));
    

There are three things to look at here, conveniently separated by blank lines.

The first section creates the identifiers that we want to use for the buttons, given the same name as the buttons themselves. We use symbols here because they are unique. There can never be another symbol created that is the same (from a === perspective) as A, for instance. So we can check to see if something is equal to A, and if it is, we know that it is A.

Symbols are a feature of ES2015 and should be universally supported in modern browsers. Their use doesn't seem to be widespread yet, however. You could just as easily assign {} to each of these three variables and it would work fine.

The second section is the constantly function. This function creates another function that always returns the same value, using the same trick as with location in the third intro example (generating functions based on a parameter rather than having to write different functions for each value of that parameter we need). The upshot is that if we use one of these functions in map, every value taken from the channel will be whatever is passed to constantly, no matter what value was actually put onto the channel.

The last section takes advantage of this. A map transformer that uses a constantly function that always returns the appropriate button's identifier is attached to each event channel that's created by listen. That means that when the A button is clicked, A will appear on chA, and so on.

(A quick note: button-a, etc. are the ID's of the three buttons in the combination example, and byId is just a utility function that gets the DOM element with that ID. So byId('button-a') just returns the DOM element for the first button. See the source code if you're interested.)

The process

Now that we have our channels set up, we can create the process that will be listening to them.


      const MAX_TIME = 5000;
      const COMBINATION = [A, B, A, C, A, B];

      go(async () => {
        let clicks = [];
        let chZ = chan();

        while (true) {
          const alt = await alts([chA, chB, chC, chZ]);
          clicks.push(alt.value);
          ...
        }
      });
    

Aside from the constants set up at the beginning (for the amount of time the user has to get the combination right and for the combination itself), the rest of this should look very familiar. It's the setting of some state variables to initial values and then a while loop with an alts invocation right at the beginning. That's exactly the way the last two intro examples were set up, and it's a recurring pattern when using CSP to manage events.

Perhaps the most interesting thing in this snippet is that a local channel (chZ) is being checked by alts as well. Since the channel is local, no other process can access and therefore put onto it. Why have it at all? We'll discover that a little later.

In the intro examples with event handling, the bulk of the while loop was an if/else statement that performed some action based on what came back from the await alts. Our while loop body here is no different.


      if (alt.channel === chZ) {
        setStatus("You're not fast enough, try again!");
        clicks = [];
        chZ = chan();
      }
    

The first branch executes if the channel that caused await alts to unblock was the mysterious channel Z. We still don't know what might be on that channel, but we get some sort of clue from the fact that the displayed message tells the user that he's run out of time (setStatus is another utility function, one that sets the text in the example's div). All of the state variables are set back to their initial states as well, indicating that the user is able to start again.


      else if (alt.value === COMBINATION[clicks.length - 1]) {
        if (clicks.length === COMBINATION.length) {
          setStatus("Combination unlocked!");
          clicks = [];
          chZ = chan();
        } else {
          setStatus(clicks);
          if (!chZ.timeout) {
            chZ = timeout(MAX_TIME);
          }
        }
      }
    

The next branch executes if the value returned from await alts matches the value at the same index of the combination. In other words, it's called if the user selects the correct next button. What happens then depends on whether this was the last button (the length of our clicks array, which tracks the buttons that have been clicked so far, is the same as the length of the combination).

If it was, it means the user must have entered the correct combination. If so, a message is displayed to say so, and the state variables are set back to their initial values. Just like in the first branch. Again this means that the user may start again.

Far more interesting is what happens if this was not the last button (fewer buttons have been clicked than the length of the combination). Most of the time, we just update the div with all of the buttons that have been clicked so far, and let the while loop continue. But if the timeout property of chZ is false, we reassign chZ to something we have not seen before - a timeout channel.

The timeout function is offered by Cispy, and it creates a new unbuffered channel, just like chan(). The difference is that this channel will automatically close after a delay, specified in milliseconds in the timeout call itself. So now our private, local channel Z turns out to have something to do after all - it can sit around and then close after 5 seconds (5000 milliseconds, the value of MAX_TIME).

How does this help? Well, it turns out that await alts doesn't only unblock if a value is put onto one if its channels. It also unblocks if one of its channels closes. So now we can see that 5 seconds after the first button is pushed, chZ will close, which causes await alts to unblock and set alt.channel to chZ. Which executes the first branch, telling the user that he's failed. The only way this is averted is by getting the right combination, which resets chZ back to a normal, non-timeout channel, meaning that it again sits there and does nothing in the next await alts call.

By the way, both await put and await take share this behavior, unblocking if the channel passed to them closes.

For the sake of completeness, let's look at the last branch of the if/else.


      else {
        setStatus("Wrong combination, try again");
        if (!chZ.timeout) {
          chZ = timeout(MAX_TIME);
        }
        clicks = [];
      }
    

This branch executes if a button was pushed (alt.channel is not chZ) and if that button does not match the next button in the combination. In this case the user is shown a message that the wrong button was clicked, and the list of clicked buttons is cleared (the user has to start over). Channel Z is not reset though; it continues to tick away towards that 5 seconds. Of course, like in the last branch, if this is the first button that's clicked the timeout channel is created to start the countdown.

In the end, if a channel is a timeout channel, it has its timeout property set to true, while a regular channel will have a timeout property value of false. Because of that, we're able to use the channel itself as a state variable in our local event loop. If chZ.timeout === false then the timer must not be running, so when the first button is clicked we will reassign chZ to be a timeout channel. If we do something to finish the combination, either by entering it successfully or taking too long to do so, chZ gets reassigned back to a regular channel. Thus we can always tell whether the proverbial clock is ticking by checking the value of chZ.timeout.