An ASCII puzzle for an escape room challenge

Jail Break

© Lead Image © besjunior; 123rf.com

© Lead Image © besjunior; 123rf.com

Author(s):

A digital puzzle presents a challenge for young people in an escape room.

A teacher recently asked me to help create a couple of puzzles for an escape room she was designing for her classes. Escape rooms have a number of interpretations, themes, and implementations but ultimately comprise a series of puzzles designed around a theme. Solving one puzzle provides a clue to something else. Sometimes a puzzle is just an off-the-shelf combination lock, and as you play other parts of the game, you'll discover the combination (or three numbers that you can try as the combination).

One puzzle I designed starts with a number on a seven-segment display (see the "Seven-Segment Display" box), a bundle of leads clipped onto two rows of electrical connections, and an unfinished set of notes. A previous adventurer has started to decode the puzzle and left notes for whoever might follow. The top set of connections is numbered 128, 64, 32, 16 , 8, 4, 2, and 1. The bottom set of connections isn't labeled, but each post has an associated LED, only a few of which are lit.

Seven-Segment Displays

Seven-segment displays are one of the most prevalent types of displays found in electronics projects. They are easy to control and very readable, even from large distances or in small physical sizes. The downside is that the value 7 is not displayed by seven discreet LEDs but by three LEDs arranged in the shape of a 7.

A number of driver chips can take care of the translation to an LED display. The MAX7219 used in this project can drive up to eight seven-segment displays (or 64 individual LEDs). If you are only displaying numbers, you can pass them directly to the 7219 in binary form, and it will display the appropriate segments to show the corresponding decimal number.

Because I want to show letters, as well, a special "no decode" mode tells the driver to just display the segments requested rather than decoding the value into a number. In this case, I pass an 8-bit binary number, where each bit represents one of the seven segments. The last bit represents the decimal point (not used in this project).

A seven-segment display by its nature has a "font" that's quite blocky. Numbers are no problem to display, and even most of the alphabet can be represented without too much trouble. However, the letters K, M, V, W, and X don't line up well with seven segments. If you use your imagination, though, these exceptions don't hinder things too much (Figure 1).

Figure 1: Several letters don't translate directly to seven-segment displays. Here's how I chose to represent them.

Theory of Operation

Built into the code of the display box, the secret word to be revealed is part of an Arduino program. The number displayed is the ASCII representation of the character currently being sought. Players must use clip leads to connect the numbers on the top to the active (lit) connections on the bottom so that the connected posts add up to the displayed number. A "check" post is tapped with an extra clip lead, and the display will either say CORRECT or NO.

After each check, the active posts change so the clip leads will always have to be rearranged; however, the number being sought remains the same until it is discovered. Once the number is discovered, the letter is revealed on the left of the display, and the players can start working on the next number.

Construction

The 3D-printed faceplate (Figure 2) has places for the electrical connections. Each 1-inch #8 bolt connects directly to an Arduino pin (Figure 3); the bottom row also connects to an LED to show whether that post is active. Some 1x3 lumber cut at 45° angles form the rest of the box, with scrap plywood used for the back. Because this is a short-term project, I didn't worry about battery access, but I did put a power switch on the outside.

Figure 2: The puzzle faceplate after I installed the LEDs. The empty rows of holes will house bolts as electrical contacts. The holes around the outside are to mount the panel on top of the project box.
Figure 3: The wiring for the puzzle box. Note that most connections just lead from the GPIO to more robust hardware on the panel. The top set of connections use PWM pins 2 through 9, with the check post on pin 10. The bottom set of connections use analog pins A2 through A9. The red line is the 5V power connection.

Code

In Listing 1 [1], the include in line 1 brings in an external library. In this case, that is LedControl.h, which controls the display driver attached to the seven-segment display.

Listing 1

cryptexCode.c

001 #include <LedControl.h>
002
003 String puzzle = "PASSWORD";
004 int iLetterIndex = 0;
005 char cLastLetter = 0;
006 LedControl lc=LedControl(14,15,16,1);
007
008 void displayTarget()
009 {
010   int i;
011   String sNumber = String ( puzzle [ iLetterIndex ] , DEC );
012
013   Serial.println ( puzzle [ iLetterIndex ] , DEC );
014
015   for ( i = 0 ; i < sNumber.length() ; i ++ )
016   {
017     showDigit ( 2-i , puzzle [ iLetterIndex ] );
018   }
019 }
020
021 unsigned int countBits(unsigned int n)
022 {
023     unsigned int iCount = 0;
024     while (n) {
025         iCount += n & 1;
026         n >>= 1;
027     }
028     return iCount;
029 }
030
031 void rotateOutputs()
032 {
033   int iNeededBits = 0;
034   int iCurrentBits = 0;
035   int iRandom = 0;
036
037   iNeededBits = countBits ( puzzle [ iLetterIndex ] );
038   while ( iNeededBits != countBits ( iRandom ) )
039   {
040     iRandom = random ( 0 , 255 );
041   }
042
043   Serial.println ( iRandom, BIN );
044   if ( ( iRandom & 1 ) != 0 ) digitalWrite ( 2 , HIGH );
045   else digitalWrite ( 2 , LOW );
046
047   if ( ( iRandom & 2 ) != 0 ) digitalWrite ( 3 , HIGH );
048   else digitalWrite ( 3 , LOW );
049
050   if ( ( iRandom & 4 ) != 0 ) digitalWrite ( 4 , HIGH );
051   else digitalWrite ( 4 , LOW );
052
053   if ( ( iRandom & 8 ) != 0 ) digitalWrite ( 5 , HIGH );
054   else digitalWrite ( 5 , LOW );
055
056   if ( ( iRandom & 16 ) != 0 ) digitalWrite ( 6 , HIGH );
057   else digitalWrite ( 6 , LOW );
058
059   if ( ( iRandom & 32 ) != 0 ) digitalWrite ( 7 , HIGH );
060   else digitalWrite ( 7 , LOW );
061
062   if ( ( iRandom & 64 ) != 0 ) digitalWrite ( 8 , HIGH );
063   else digitalWrite ( 8 , LOW );
064
065   if ( ( iRandom & 128 ) != 0 ) digitalWrite ( 9 , HIGH );
066   else digitalWrite ( 9 , LOW );
067 }
068
069 void ledYES()
070 {
071    showDigit ( 2 , 'Y' );
072    showDigit ( 1 , 'E' );
073    showDigit ( 0 , 'S' );
074 }
075
076 void ledNO()
077 {
078    showDigit ( 1 , 'N' );
079    showDigit ( 0 , 'O' );
080 }
081
082 void showDigit ( int iPosition , char cLetter )
083 {
084   int iPattern = 0;
085
086   switch ( cLetter )
087   {
088     case 'A': iPattern = 0b01110111;break;
089     case 'B': iPattern = 0b00011111;break;
090     case 'C': iPattern = 0b00001101;break;
091     case 'D': iPattern = 0b00111101;break;
092     case 'E': iPattern = 0b01001111;break;
093     case 'F': iPattern = 0b01000111;break;
094     case 'G': iPattern = 0b01011110;break;
095     case 'H': iPattern = 0b00110111;break;
096     case 'I': iPattern = 0b00010000;break;
097     case 'J': iPattern = 0b00111100;break;
098     case 'K': iPattern = 0b00000111;break;
099     case 'L': iPattern = 0b00001110;break;
100     case 'M': iPattern = 0b01010101;break;
101     case 'N': iPattern = 0b00010101;break;
102     case 'O': iPattern = 0b00011101;break;
103     case 'P': iPattern = 0b01100111;break;
104     case 'Q': iPattern = 0b01110011;break;
105     case 'R': iPattern = 0b00000101;break;
106     case 'S': iPattern = 0b01011011;break;
107     case 'T': iPattern = 0b00001111;break;
108     case 'U': iPattern = 0b00011100;break;
109     case 'V': iPattern = 0b00011000;break;
110     case 'W': iPattern = 0b01011000;break;
111     case 'X': iPattern = 0b00010010;break;
112     case 'Y': iPattern = 0b00111011;break;
113     case 'Z': iPattern = 0b01101100;break;
114     case '0': iPattern = 0b01111110;break;
115     case '1': iPattern = 0b00110000;break;
116     case '2': iPattern = 0b01101101;break;
117     case '3': iPattern = 0b01111001;break;
118     case '4': iPattern = 0b00110011;break;
119     case '5': iPattern = 0b01011011;break;
120     case '6': iPattern = 0b01011111;break;
121     case '7': iPattern = 0b01110000;break;
122     case '8': iPattern = 0b01111111;break;
123     case '9': iPattern = 0b01111011;break;
124   }
125   lc.setRow ( 0 , iPosition , iPattern );
126 }
127
128 void setup() {
129   // put your setup code here, to run once:
130   Serial.begin ( 9600 );
131
132   pinMode ( A0 , INPUT_PULLUP );
133   pinMode ( A1 , INPUT_PULLUP );
134   pinMode ( A2 , INPUT_PULLUP );
135   pinMode ( A3 , INPUT_PULLUP );
136   pinMode ( A4 , INPUT_PULLUP );
137   pinMode ( A5 , INPUT_PULLUP );
138   pinMode ( A6 , INPUT_PULLUP );
139   pinMode ( A7 , INPUT_PULLUP );
140
141   pinMode ( 10 , INPUT_PULLUP );
142
143   pinMode ( 14 , OUTPUT );  // display DATA
144   pinMode ( 15 , OUTPUT );  // display CLK
145   pinMode ( 16 , OUTPUT );  // display CS
146
147   digitalWrite ( 16 , HIGH );
148
149   pinMode ( 2 , OUTPUT );
150   pinMode ( 3 , OUTPUT );
151   pinMode ( 4 , OUTPUT );
152   pinMode ( 5 , OUTPUT );
153   pinMode ( 6 , OUTPUT );
154   pinMode ( 7 , OUTPUT );
155   pinMode ( 8 , OUTPUT );
156   pinMode ( 9 , OUTPUT );
157
158   randomSeed ( analogRead ( 12 ) );
159   rotateOutputs();
160
161   lc.shutdown(0,false);
162   lc.setIntensity ( 0 , 8 );
163   lc.clearDisplay ( 0 );
164 }
165
166 void loop() {
167   // put your main code here, to run repeatedly:
168   if ( cLastLetter != puzzle [ iLetterIndex ] )
169   {
170     displayTarget();
171     cLastLetter = puzzle [ iLetterIndex ];
172   }
173
174   if ( digitalRead ( 10 ) == LOW )
175   {
176     int iPort = 0;
177
178     if ( digitalRead ( A7 ) == 0 ) iPort += 1;
179     if ( digitalRead ( A6 ) == 0 ) iPort += 2;
180     if ( digitalRead ( A5 ) == 0 ) iPort += 4;
181     if ( digitalRead ( A4 ) == 0 ) iPort += 8;
182     if ( digitalRead ( A3 ) == 0 ) iPort += 16;
183     if ( digitalRead ( A2 ) == 0 ) iPort += 32;
184     if ( digitalRead ( A1 ) == 0 ) iPort += 64;
185     if ( digitalRead ( A0 ) == 0 ) iPort += 128;
186
187     if ( cLastLetter == iPort )
188     {
189       iLetterIndex += 1;
190       Serial.println ( "CORRECT" );
191       ledYES();
192       delay ( 1000 );
193       for ( i = 0 , i < iLetterIndex - 1 , i ++ )
194       {
195          showDigit ( 7 - i , puzzle [ i ] );
196       }
197       Serial.println ( puzzle.substring ( 0 , iLetterIndex ) );
198       rotateOutputs();
199     }
200     else
201     {
202       Serial.println ( "INCORRECT" );
203       ledNO();
204       delay ( 1000 );
205       displayTarget();
206       rotateOutputs();
207     }
208     while ( digitalRead ( 10 ) == LOW ) delay ( 100 );
209   }
210 }

The next several lines define the string puzzle, which I've set as PASSWORD, as the word that will be revealed as the game is played; the iLetterIndex variable, which is the letter position currently being discovered in the puzzle; and cLastLetter, which is the ASCII value of the character currently being discovered. Finally, lc is an instance of LedControl included on line 1. Its four arguments are the pin numbers for data, clock, chip select, and the number of 7219 chips in the series (in this case, just 1).

The displayTarget function (lines 8-19) sends to the display the number that the players are trying to find. The int i line is defined inside the function, so it is local to the enclosing function. In defining the sNumber string, the character position currently being sought is iLetterIndex, and puzzle is the secret word. This single-character string is what the players are currently seeking. The argument DEC specifies a decimal value of the character, or its value in ASCII, rather than the character itself and will be shown on the LED display.

Serial.println prints the same value to the serial monitor (purely for debugging). Because the serial monitor is inaccessible and the physical connection is sealed in a box, I didn't worry about removing it for the final version of the code.

The for loop iterates over each character in the string to be displayed, and line 17 sends it to the display. I'll talk about showDigit and how it works a little later.

countBits

The countBits function (lines 21-29) returns the number of 1s in a binary representation of the number n, which it figures out through a series of binary operations. After defining iCount, the function sets up a while loop with the condition n. In C, a while loop runs while its condition is not zero. Because the provided condition here is just a number, it will run until the number becomes zero.

In the binary AND (&) of the number n with 1, if the low-order bit (the least significant bit, or bit 1 in this case) is set, this operation evaluates to 1; otherwise, it evaluates to  . Therefore, iCount increments by either 1 or 0 depending on whether the bit is set. The right shift operator ( >>= ) shifts iCount, discarding the low-order bit and moving everything over one place. The loop repeats, checking the new low-order bit again. Eventually, when the entire value has been right shifted, only 0s are left in n. At that point, n = 0, the while loop exits, and return iCount returns the number of bits that are set. You'll see how this is used in the next function.

rotateOutputs

The rotateOutputs function (lines 31-67) picks a random number until it finds one with the appropriate number of bits; then, it turns on the associate output pins (the lower row of electrical connections in the puzzle). These connections are used in the current round of the game, indicated by LEDs.

The three variables are the number of bits to be set (iNeededBits), the number of bits in the generated random number (iCurrentBits), and the current random number (iRandom).

The number of bits needed (iNeededBits) is set by calling countBits with the current character the players seek. The while (lines 38-41) loops until iNeededBits is not equal to countBits(iRandom), which was initially set to zero, but is set to random(0,255) within the loop. In this way, random numbers are chosen until one is found in which the number of set bits matches the goal; then the loop exits.

Line 43 prints the random number to the serial monitor for debugging, and lines 44-66 update all of the electrical connections so that they are all either HIGH or LOW, depending on the value of iRandom set above. The binary ANDs (&) with 1 (line 44), 2 (line 47), …, 128 (line 65), checking only one bit in the number at a time. For each bit, digitalWrite either turns the appropriate pin on or off.

ledYES and ledNO

The ledYES and ledNO utility functions (lines 69-80) just display YES or NO on the display. One or the other is called when the player checks to see whether the current wiring is correct. In each case, the showDigit function is called with position (first argument) and character (second argument) hard coded.

showDigit

The showDigit function (lines 82-126) is responsible for drawing the provided character on the display. I start by defining int iPattern and then use switch to find the character provided in cLetter.

On each line of the switch function, case says: If I match '<this>' then do whatever is after the colon (:). If it does not match, it moves on to the next case statement until it finds a match or drops off the end without a match. Once a match is found, iPattern is set to a number represented in binary with the 0b at the front (i.e., much like a preceding 0x denotes hex notation).

The binary number specifies which segments of the seven-segment display should be illuminated to display this character. Once the pattern is defined, the break exits the switch block. Without break, C would keep evaluating the rest of the case statements and possibly find another match. Here, the function exits as soon as a match is found.

Finally, line 125 calls lc.setRow, which is the LED controller's function to turn on a set of LEDs. setRow takes three arguments: the chip number (always 0 in this case), the position in which to place this character (iPosition), and which segments to illuminate (iPattern).

Setup

In the Arduino world, the special setup function (lines 128-164) is called once when the Arduino powers on, allowing you to initialize variables, turn on hardware, and make sure everything is properly configured before your main program runs. To begin, the code initializes the serial monitor in the Arduino IDE with Serial.begin (9600) for debugging messages. The baud rate (speed) of the serial connection is 9600, but this number doesn't really matter, as long as it matches what is selected in the serial monitor.

The pinMode of analog pins A0 through A7 are set to INPUT_PULLUP. As the value suggests, this makes the pin an input and also enables its internal pull-up resistor. Even though I'm using the Arduino analog pins, I'm just using them as digital inputs in this case. Pin 10 is set in the same way and will be the "check" input to determine whether the current wiring is correct.

The next series of pinModes sets a number of GPIO pins to OUTPUT. Pins 14, 15, and 16 are the data, clock, and chip select lines of the LED driver, and pins 2 through 9 are the top row of electrical connections on the puzzle. The digitalWrite sets pin 16 to HIGH, which allows the LED driver to listen to incoming data. Because the project only has one driver, it can be left on indefinitely; in this case, it could even be connected directly to V+, but this arrangement was more convenient.

The randomSeed function initializes the Arduino's random number engine. An analogRead of an unused digital input essentially picks up static, causing the return of a random number, which is used as a seed.

The rotateOutputs was defined earlier, so when the puzzle starts, the lower row of electrical connections is ready to go.

The last couple of lines set up the LED driver. An lc.shutdown set to false means the LEDs should not be shutdown. The lc.setIntensity sets the brightness of the display. The range is 0 to 16, so a value of 8 sets brightness at 50 percent. Finally, lc.clearDisplay erases anything that was previously on the display. The first two functions are called with a   as the first argument. The 7219 driver chip can be daisy-chained, so you have to specify the chip in the chain to which you are talking. Because this setup only has one chip, the value will always be  .

Loop

After the setup function finishes, the Arduino loop function (lines 166-210) loops continuously until power is removed. In this function resides the main logic of the program.

To begin, I check to see whether cLastLetter does not equal the current puzzle character, displayTarget (i.e., show the value the player is trying to achieve), and update cLastLetter to the current puzzle character.

Next, I check to see whether pin 10 is LOW. If so, the player wants to know whether their current wiring is correct. To check, I initialize iPort, then digitalRead each of the input pins, and add the appropriate power of 2 if the pin is LOW (lines 178-185). For a pin to be LOW, the player had to connect a clip lead between this connection and one of the active connections on the lower row indicated by LEDs.

Once I've calculated iPort, I check to see if it equals cLastLetter, which means the players have found the correct combination. In that case, I increment iLetterIndex, debug print "CORRECT", display YES on the LEDs, and then wait for 1 second (line 192).

The for loops over the characters discovered so far. I call showDigit and provide the position as 7 - i, which makes sure the drawing starts from the left side of the display. The second argument, puzzle [ i ], is the character at that position. As each character is discovered it is added to the display.

Finally, rotateOutputs ensures a different set of connections will be active for the next round. On the next iteration of the loop, iLetterIndex has been updated, so a new number will be displayed for the players to find.

If the players did not guess correctly, the else block (line 200) runs instead, debug prints INCORRECT, displays NO on the LEDs, waits for 1 second, redisplays the target value, and finally picks a new set of active connections with rotateOutputs.

Line 208 loops infinitely while a digitalRead of pin 10 returns LOW and delays a tenth of a second before checking again. Once pin 10 returns to HIGH (the clip is removed), the loop continues.

Designing for Children

Once your adventurers have decoded the puzzle, they can use the clue they discovered to unlock their next challenge. For the same escape room, I 3D-printed a second challenge comprising of a couple of maze boxes (Figure 4) that could be opened by twisting, pushing, and pulling. Young makers are very enthusiastic but often don't know the limits – that come with experience and maturity – of the materials involved. However, they can reveal some interesting failure points that you might not have considered. Figure 5 shows some of the pieces after being used in a classroom for only a couple of hours.

Figure 4: These 3D-printed maze boxes were in a different part of the escape room. This is how they looked immediately after printing.
Figure 5: After several hours of hands-on use, the maze boxes show signs of abuse. Absent direction (and sometimes even then), children tend to resort to the use of extra force to try to solve the problem at hand.

The Author

Scott Sumner is the Assistant Manager of the Charlie Noble Planetarium at the Fort Worth Museum of Science and History. He enjoys using Python to solve as many problems as possible.