An ASCII puzzle for an escape room challenge
Jail Break
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).
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.
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 pinMode
s 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.
Infos
- Code for this article: ftp://ftp.linux-magazine.com/pub/listings/linux-magazine.com/243/