Learning Goals 5 min
By the end of this lesson you will be able to:
- Encode any letter A–Z (plus 0–9) as Morse code — a sequence of dots and dashes with strictly defined timing — and explain the four time units the standard uses.
- Store the 26-letter Morse alphabet as a lookup table indexed by
letter - 'A'— combining the L01-27 character-arithmetic trick with the L01-33 arrays-of-data pattern. - Write a player function that walks any string of text character by character, blinks the LED with the correct pattern and timing for each letter, and pauses appropriately for spaces.
Warm-Up 10 min
From this lesson on, your sketches don't just react to the world — they say something into it. Morse code is the oldest information-coding system that's still used today (mostly for distress signals and aviation). And it fits Arduino like a glove: an LED already blinks; all you have to do is blink it at the right rhythm.
Quick-fire puzzle
You've seen Morse in films — the SOS that ships and aircraft tap out: short-short-short, long-long-long, short-short-short. Three dots, three dashes, three dots.
- What makes a "dot" different from a "dash" — is it the brightness, the length, the colour, or something else?
- How does a listener know where one letter ends and the next begins, if both are just sequences of beeps?
- If you wanted to send "SOS" with your LED, what's the shortest list of timing rules you'd need to follow?
Reveal the answer
- Length. A dot is a brief on-pulse; a dash is a longer one — exactly three times the dot's length, by international agreement. Brightness and colour don't matter.
- Gaps. The space between two beeps inside a letter is short; the space between two letters is longer; the space between two words is longer still. Morse defines exact ratios for all three.
- You need a dot length in milliseconds. Everything else follows from it — dash = 3 × dot, intra-letter gap = 1 × dot, inter-letter gap = 3 × dot, word gap = 7 × dot. Pick the dot length (today: 200 ms for visibility) and Morse is fully determined.
That's the whole point of Morse: it's a tiny self-contained encoding system. One LED, one timing rule, and you can send any letter or digit. Today you'll build the encoder.
New Concept — the alphabet as a lookup table 15 min
The big idea — Morse is data + timing
Morse has two pieces, both shaped exactly like ideas you've already met:
- The alphabet — a fixed table mapping each letter to its dot-dash pattern. Stored once, looked up many times. The L01-33 / L01-34 pattern.
- The timing — the four standard durations (dot, dash, intra-letter gap, inter-letter gap, word gap). Each is a multiple of one number — the dot length.
Combine the two with a small player function and you can send any text.
The four time units
Pick a dot length you can see (200 ms is comfortable for a blinking LED). Everything else is a multiple of it:
| Element | Units | Time @ 200 ms dot |
|---|---|---|
| Dot (on) | 1 | 200 ms |
| Dash (on) | 3 | 600 ms |
| Gap between dots/dashes within a letter (off) | 1 | 200 ms |
| Gap between letters (off) | 3 | 600 ms |
| Gap between words (off) | 7 | 1400 ms |
One dot length, four derived numbers. Edit one constant, the whole "speed" of the transmission changes proportionally — just like the L01-33 melody's tempo multiplier.
The alphabet as a string array
For 26 letters, the cleanest data structure is an array of 26 strings — one per letter — each string a short sequence of . and - characters:
const char* morseTable[26] = {
".-", // A — index 0
"-...", // B — index 1
"-.-.", // C — index 2
// …
"--.." // Z — index 25
};To look up letter 'M', you index the array at 'M' - 'A' = 12 (the L01-27 character-arithmetic trick — letters are just consecutive numbers, and subtracting 'A' gives a position 0–25). One small line:
const char* pattern = morseTable[letter - 'A'];What a string really is
The type const char* means "a pointer to a sequence of characters". In practice, it's an array of characters with a special "I'm done" marker — a null character '\0' at the end. You can walk it with a for loop just like any array, but the stopping condition is "until we see the marker":
for (int i = 0; pattern[i] != '\0'; i = i + 1) {
// pattern[i] is one character: '.' or '-'
}Two things to remember:
- Every string literal (
"abc",".-") silently has a hidden'\0'stuck on the end. So".-"is really three bytes:'.','-','\0'. - You never have to count length yourself — the loop just stops when it hits the marker.
This is the same trick C and C++ have used for fifty years and you'll find it everywhere from Linux kernel code to Arduino libraries.
The player function — walk text, walk pattern, blink
Two nested loops do the whole job. Outer loops over the message's letters; inner loops over each letter's dots and dashes.
void playMorse(const char* text) {
for (int i = 0; text[i] != '\0'; i = i + 1) {
playLetter(text[i]);
}
}
void playLetter(char c) {
if (c == ' ') { delay(WORD_GAP); return; }
c = toupper(c);
if (c < 'A' || c > 'Z') return; // skip punctuation
const char* pattern = morseTable[c - 'A'];
// blink each '.' or '-' in pattern; gap between elements
}toupper(c) turns lowercase letters into uppercase — exactly the case toggle from L01-27, packaged as a one-liner. isalpha(c) from L01-27 would also work to filter out non-letters.
Why it matters
Morse is the simplest possible example of character encoding — turning text into a signal. The same idea underlies ASCII (L01-27), UTF-8 (Unicode), bar codes, QR codes, Wi-Fi packets, and every text-over-radio system on earth. Today's lesson is the smallest version: 26 patterns, two timing rules, one LED. The structure scales up to entire computer-network protocols.
Worked Example — SOS, then your name 25 min
The wiring
One LED on D9 with a 220 Ω resistor (the L01-07 circuit). That's it. Optional: a piezo buzzer on D8 if you want audio Morse too.
The full sketch
// Morse Code Blinker — encodes A-Z and 0-9
const int LED_PIN = 9;
// Timing — everything is a multiple of DOT_MS
const int DOT_MS = 200;
const int DASH_MS = DOT_MS * 3;
const int ELEMENT_GAP_MS = DOT_MS;
const int LETTER_GAP_MS = DOT_MS * 3;
const int WORD_GAP_MS = DOT_MS * 7;
// Lookup: A=0, B=1, ... Z=25
const char* morseTable[26] = {
".-", "-...", "-.-.", "-..", ".", "..-.",
"--.", "....", "..", ".---", "-.-", ".-..",
"--", "-.", "---", ".--.", "--.-", ".-.",
"...", "-", "..-", "...-", ".--", "-..-",
"-.--", "--.."
};
// Digits 0=0 .. 9=9
const char* digitTable[10] = {
"-----", ".----", "..---", "...--", "....-",
".....", "-....", "--...", "---..", "----."
};
// === Element-level helpers ===
void blinkMs(int ms) {
digitalWrite(LED_PIN, HIGH);
delay(ms);
digitalWrite(LED_PIN, LOW);
}
void playPattern(const char* pattern) {
for (int i = 0; pattern[i] != '\0'; i = i + 1) {
if (pattern[i] == '.') blinkMs(DOT_MS);
else if (pattern[i] == '-') blinkMs(DASH_MS);
delay(ELEMENT_GAP_MS); // gap after each element
}
}
// === Letter-level ===
void playLetter(char c) {
if (c == ' ') {
delay(WORD_GAP_MS - LETTER_GAP_MS); // extra silence beyond the letter gap
return;
}
c = toupper(c);
if (c >= 'A' && c <= 'Z') {
playPattern(morseTable[c - 'A']);
delay(LETTER_GAP_MS - ELEMENT_GAP_MS);
}
else if (c >= '0' && c <= '9') {
playPattern(digitTable[c - '0']);
delay(LETTER_GAP_MS - ELEMENT_GAP_MS);
}
// any other character is silently ignored
}
// === Top level ===
void playMorse(const char* text) {
for (int i = 0; text[i] != '\0'; i = i + 1) {
playLetter(text[i]);
}
}
void setup() {
pinMode(LED_PIN, OUTPUT);
playMorse("SOS");
}
void loop() { }Walk through what each piece does
- Timing constants: everything is
DOT_MS× a small integer. ChangeDOT_MSto 100 and the whole message plays at double speed. morseTable: 26 strings, indexed byletter - 'A'. The L01-27 trick used as an array index.digitTable: 10 more strings, for 0–9, indexed bydigit - '0'. Same shape.blinkMs(ms): the absolute primitive — one on-pulse of lengthms. Used for both dots and dashes.playPattern: walks one pattern string, blinks each element with the right length, leaves an element-gap after each. Stops at'\0'.playLetter: handles space (word gap) and routes letters or digits to the right lookup. Adds the bigger letter gap after.playMorse: walks the whole message, callsplayLetterfor each character.setup(): plays"SOS"once at power-on, then sits silent. Press the reset button to play it again.
Notice the gap-stacking maths. playPattern already adds an ELEMENT_GAP_MS after the last element. The letter gap is LETTER_GAP_MS = 3 × dot, but one dot's worth was already added inside playPattern. So the extra delay in playLetter is LETTER_GAP_MS - ELEMENT_GAP_MS = 2 × dot. Same trick for word gaps. The total silence between letters is exactly 3 dots; between words, exactly 7. Get the arithmetic wrong and an experienced Morse listener will hear the timing as "off".
Upload and listen with your eyes
- Upload. As soon as the chip resets, the LED starts SOS: short-short-short (dot dot dot = S), then after a gap, long-long-long (dash dash dash = O), then short-short-short again.
- Time it with your phone's stopwatch. With
DOT_MS = 200, the whole SOS takes about 4 seconds. - Now change
setup()toplayMorse("SOS HELP");. Re-upload. After SOS plays, you should hear a clearly bigger gap (the word break) before HELP starts blinking. - Try
playMorse("ALJAY");(or your own name). Listen for the inter-letter gaps. They should feel obviously longer than the gaps within each letter. - Change
DOT_MSto 100, re-upload. Everything plays at double speed — same patterns, just faster. The proportional timing keeps the message readable.
Trace one letter on paper
Walk through what playLetter('B') does, second by second, with DOT_MS = 200. Recall: 'B' in Morse is "-..." (dash, dot, dot, dot).
| Step | LED state | Duration (ms) | Running total (ms) |
|---|---|---|---|
| Dash on | HIGH | 600 | 600 |
| Element gap | LOW | 200 | 800 |
| Dot 1 on | HIGH | 200 | 1000 |
| Element gap | LOW | 200 | ____ |
| Dot 2 on | HIGH | ____ | ____ |
| Element gap | LOW | ____ | ____ |
| Dot 3 on | HIGH | ____ | ____ |
| Element gap (last) | LOW | ____ | ____ |
| Extra inter-letter gap | LOW | 400 | ____ |
Fill in the blanks. The total for letter B comes out to ____ ms. (Should be 2800 ms = 14 × DOT — quite a long letter compared to 'E' which is one dot + gap = ~400 ms total. The most common letters are deliberately the shortest patterns. That's also why Samuel Morse designed it that way 180 years ago.)
Try It Yourself — three Morse upgrades 15 min
Goal: Add an audio Morse mode using a piezo on D8. Each "dot" or "dash" beeps the buzzer at the same time the LED lights, with a frequency of 700 Hz.
Plan: change one function — blinkMs — so it also calls tone:
const int BUZZER_PIN = 8;
void blinkMs(int ms) {
digitalWrite(LED_PIN, HIGH);
tone(BUZZER_PIN, 700);
delay(ms);
digitalWrite(LED_PIN, LOW);
noTone(BUZZER_PIN);
}Don't forget pinMode(BUZZER_PIN, OUTPUT) in setup.
Questions:
- Why does the audio version need
noTonebut the LED version uses two separatedigitalWritecalls? ____ (Hint:toneruns in the background;digitalWritedoesn't.) - Real Morse operators learn by listening, not by watching. Try closing your eyes — can you "hear" SOS? Is audio Morse easier or harder to decode than visual? ____
- What 700 Hz frequency choice — is it close to the actual radio-Morse standard? Look it up. ____ (Hint: the standard "sidetone" used by Morse training tapes is around 600–800 Hz.)
Goal: A "my name on loop" sketch. Replace setup()'s one-shot "SOS" with loop() playing your full name plus a 3-second pause between repeats.
void setup() {
pinMode(LED_PIN, OUTPUT);
}
void loop() {
playMorse("YOUR NAME HERE"); // e.g. "ALJAY"
delay(3000);
}Questions:
- How long does your name take to play, at
DOT_MS = 200? Time it with a stopwatch. ____ - Try replacing
DOT_MSwith100. How much faster? Is it still readable? At what dot length does it become unreadable? ____ - The lesson said the most common letters have the shortest patterns. Look up the Morse for E, T, A vs Z, Q, X. Why this design choice? ____ (Hint: shorter common letters = faster transmission overall.)
Goal: Take input from the Serial Monitor and Morse it out. Whatever you type and Send, the LED blinks as Morse code.
Plan: reuse the L01-26 standard input pattern. Buffer incoming characters into a small array; when you see a newline (or after 1 second of no input), play the whole buffer as Morse, then reset.
const int BUF_SIZE = 40;
char buf[BUF_SIZE];
int bufLen = 0;
unsigned long lastChar = 0;
void loop() {
if (Serial.available() > 0) {
char c = Serial.read();
if (bufLen < BUF_SIZE - 1 && c != '\n') {
buf[bufLen] = c;
bufLen = bufLen + 1;
lastChar = millis();
}
}
if (bufLen > 0 && millis() - lastChar > 500) {
buf[bufLen] = '\0'; // null-terminate the buffer
Serial.print("Sending: ");
Serial.println(buf);
playMorse(buf);
bufLen = 0;
}
}Questions:
- Why null-terminate the buffer before passing it to
playMorse? ____ - What happens if you type a message longer than 39 characters? ____ (Hint: the
bufLen < BUF_SIZE - 1guard.) - Set the Monitor's line-ending dropdown to "Newline". Why does that affect when the message plays? ____
Mini-Challenge — the "did I send it right?" feedback 10 min
"Echo the Morse pattern to the Monitor as it plays"
Modify playPattern so that as each element blinks, the matching character ('.' or '-') prints to the Serial Monitor. Add a space between letters and a slash / between words. The Monitor scrolls a live transcript of what the LED is blinking — a great teaching tool, and a debugging aid for "wait, did I encode that letter right?"
Target output for playMorse("SOS HELP");:
... --- ... / .... . .-.. .--.Your task:
- In
setup(), addSerial.begin(9600);. - In
playPattern, print each element character withSerial.print('.')orSerial.print('-')as it plays. - In
playLetter, after a letter (or digit) finishes, print a space withSerial.print(' '). - When you encounter
' 'in the text, print"/ "instead of just a space-gap. - After the whole message finishes, print a newline.
It works if:
- The Monitor's transcript matches the standard Morse for whatever message you send.
- Letter gaps appear as single spaces, word gaps as
/with spaces around it. - You can copy the transcript into a Morse-checking website and it parses cleanly back to your original message.
Reveal the modified helpers
void playPattern(const char* pattern) {
for (int i = 0; pattern[i] != '\0'; i = i + 1) {
if (pattern[i] == '.') {
blinkMs(DOT_MS);
Serial.print('.');
}
else if (pattern[i] == '-') {
blinkMs(DASH_MS);
Serial.print('-');
}
delay(ELEMENT_GAP_MS);
}
}
void playLetter(char c) {
if (c == ' ') {
delay(WORD_GAP_MS - LETTER_GAP_MS);
Serial.print("/ ");
return;
}
c = toupper(c);
if (c >= 'A' && c <= 'Z') {
playPattern(morseTable[c - 'A']);
delay(LETTER_GAP_MS - ELEMENT_GAP_MS);
Serial.print(' ');
}
else if (c >= '0' && c <= '9') {
playPattern(digitTable[c - '0']);
delay(LETTER_GAP_MS - ELEMENT_GAP_MS);
Serial.print(' ');
}
}
void playMorse(const char* text) {
for (int i = 0; text[i] != '\0'; i = i + 1) {
playLetter(text[i]);
}
Serial.println();
}Three tiny additions — one print per element, one per letter, one for the word break — turn the silent blinker into a live transcript machine. This is the L01-25 print-debugging technique used as a feature: making invisible state visible. The Monitor now narrates what the LED is doing. A Morse expert can read the transcript at a glance and tell you instantly whether the encoding is correct, without watching the LED at all.
Recap 5 min
Morse code is a 180-year-old character encoding system: 26 letters and 10 digits, each as a short pattern of dots and dashes, with exact timing rules expressed as multiples of a single "dot length". A 26-string lookup table indexed by letter - 'A' stores the alphabet; a small player function walks any text character by character, blinks the LED with the right pattern, and inserts the right gaps. The whole project bolts together the L01-27 character-arithmetic trick, the L01-33 array-of-data pattern, basic C string handling, and timing. The same shape — lookup + walk + render — drives every encoder you'll ever write, from QR codes to network packets.
- Morse code
- An encoding of text as a sequence of short and long signals (dots and dashes) with specific timing. Invented by Samuel Morse in the 1830s for telegraphy; still legally required equipment on certain aviation and maritime channels.
- Dot / dash
- The two elements of Morse code. A dash is always exactly 3 times the length of a dot. By convention, the dot length is the only free parameter; everything else is a multiple.
- Element gap / letter gap / word gap
- The three Morse silence durations: 1 dot between elements of a letter, 3 dots between letters, 7 dots between words. The ratios are universal across every Morse system on earth.
- Lookup table
- An array used to look up a value by its index. Today's
morseTable[26]usesletter - 'A'as the index — pulling together L01-27 (characters as numbers) and L01-33 (data in arrays). - C string
- An array of characters terminated by a special
'\0'(null) character. Walked with aforloop whose stop condition iss[i] != '\0'. The standard string format in C and Arduino, hidden by every"text"literal you've ever written. - Null terminator
- The hidden
'\0'byte that every string literal silently ends with. Telling the string "I'm done here". Loops use it as their natural stop condition. toupper(c)/tolower(c)- Built-in helpers that return the uppercase / lowercase version of a character (or the unchanged character if it isn't a letter). The clean alternative to manual ±32 from L01-27.
Homework 5 min
The personal beacon. Build a sketch that plays a short personal Morse message at regular intervals, using the worked example as a base. Pick one of these scenarios:
- Bedroom doorbell: your name in Morse, repeating every 30 seconds, audio on.
- Workshop ID tag: your initials, repeating every 10 seconds, LED only.
- Hidden treasure beacon:
"X MARKS", repeating every 5 seconds with a long-then-short blink at the start as a "calling tone". - Conway's QSO:
"CQ DE [your name] K"— the actual amateur-radio phrase "calling any station, this is [me], over". Plays every minute.
Whichever you pick, the sketch should be a small modification of the worked example: put the message in a const char* at the top, call playMorse in loop(), and pause with delay between plays.
Also: a design reflection on paper.
- Look up the Morse for the punctuation mark
.(full stop) — it's".-.-.-". Why isn't your lookup table handling it? What two changes would you make to support common punctuation? ____ - QR codes encode much more text than Morse using a 2D dot grid. Morse uses 1D timing. What does Morse lose by being purely 1D? ____ (Hint: density.)
- The lesson said timing is the only free parameter. If you wanted Morse readable across noisy radio (where short and long pulses sometimes get mistaken), would you make dots/dashes longer or shorter? Why? ____
- Sketch in your notebook the lookup-table layout (26 entries) for an entirely made-up alphabet of your own — three elements per letter, where each element is one of {dot, dash, pause}. How many distinct letters could you encode with three elements? ____ (Hint: 3³ = 27.)
Bring back next class:
- The saved
.inofile (call itmy-morse-beacon). - A 30-second phone video showing one full message play (with the Mini-Challenge transcript visible in the Monitor if you got it working).
- Your four written reflection answers, in your notebook.
Heads up for next class: L01-46 "Traffic Light + Crossing" is the last Build before the recap. Three LEDs (red/amber/green) plus a pedestrian button — a real-world finite state machine with the cleanest possible "fact: this is how the world works" mapping. Final polished build of Level 1.