DEV Community

Morse Zone
Morse Zone

Posted on

Morse Code over WebSockets: Build a Live Chat App That Speaks in Dots and Dashes

Morse Code over WebSockets: Build a Live Chat App That Speaks in Dots and Dashes

Text chat is instant, but it is also silent. Every message arrives fully formed, with no trace of the hand that typed it. Morse code is the opposite: you hear the rhythm of the person sending, the hesitation before a hard letter, the rush of a practiced fist. What if a chat app kept that human texture? In this article we will build a small live chat app where messages travel as Morse code over WebSockets — keyed by hand in the browser, beeped out loud on arrival, and decoded back into text for anyone who prefers to read.

No radio licence is needed. Nothing here transmits over the air. It is a web app, a weekend project, and a surprisingly good way to learn how real-time messaging actually works.

Why WebSockets, and why Morse?

A normal web chat polls the server: "anything new?" every few seconds. WebSockets flip that around — the server pushes each message to every connected browser the moment it arrives. That push model is exactly how Morse works on the air: when someone keys, everyone listening hears it immediately. The two ideas fit together naturally.

Morse also forces good design decisions. A message is not a string; it is a timed sequence of short and long signals with strict gaps. That gives us three honest engineering problems to solve: encode text into timings, transport those timings with low latency, and decode them back into readable text on the other end. Each one is small enough to build in an afternoon and real enough to teach something.

The protocol: timing is the message

Before writing any code, pin down the timing rules. The ITU standard defines five rules, and our app will use all of them:

  1. A dit (dot) lasts one time unit.
  2. A dah (dash) lasts three time units.
  3. The gap between signals inside one letter is one unit.
  4. The gap between letters is three units.
  5. The gap between words is seven units.

Pick a speed. At 15 words per minute, one unit is 80 milliseconds (the PARIS standard word is 50 units, so one unit is 1200 / WPM milliseconds). Our app will use 15 WPM by default: a dit is 80 ms, a dah is 240 ms, and the gaps follow from there. Make the speed adjustable later — it is a one-line change once the timing is centralised.

The wire format is simple. Each key event is an object with a type and a duration in milliseconds:

{ "type": "dit", "duration": 80 }
{ "type": "dah", "duration": 240 }
{ "type": "gap", "duration": 240 }
Enter fullscreen mode Exit fullscreen mode

The server never needs to understand Morse. It just relays these events to every other client in the room, in order, as fast as they arrive. Decoding happens in the browser, which keeps the server dumb and fast — the same principle that made the telegraph network scale.

The server: a WebSocket relay in 40 lines

We will use Node.js with the ws library. The server keeps a set of connected clients and broadcasts every incoming message to all of them except the sender. That is the whole job.

// server.js
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });
const clients = new Set();

wss.on('connection', (socket) => {
  clients.add(socket);
  console.log(`Client connected (${clients.size} online)`);

  // Announce the new arrival as Morse, of course.
  broadcast({ type: 'system', text: 'A new operator joined the room.' });

  socket.on('message', (raw) => {
    let event;
    try {
      event = JSON.parse(raw.toString());
    } catch {
      return; // Ignore malformed frames; never crash the room.
    }
    broadcast(event, socket);
  });

  socket.on('close', () => {
    clients.delete(socket);
    console.log(`Client left (${clients.size} online)`);
  });
});

function broadcast(event, except) {
  const payload = JSON.stringify(event);
  for (const client of clients) {
    if (client !== except && client.readyState === WebSocket.OPEN) {
      client.send(payload);
    }
  }
}

console.log('Morse chat relay listening on ws://localhost:8080');
Enter fullscreen mode Exit fullscreen mode

A few decisions worth noting. Malformed frames are dropped, not answered — a chat room must never crash because one client misbehaved. The sender does not get their own events echoed back; their own key already plays locally, and an echo would double every beep. And the server stores no history. Morse on the air is ephemeral — if you were not listening, you missed it — and our room works the same way.

The client: keying with mouse and keyboard

The browser client has two jobs: turn key presses into timed events, and turn incoming events into sound and text. The key is a single button (or the spacebar). Press down, start a timer; release, measure the duration. Short press, dit. Long press, dah. The threshold is the midpoint between one unit and three units — at 15 WPM, anything under 160 ms is a dit.

// keyer.js
const UNIT = 80; // milliseconds at 15 WPM
let keyDownAt = null;

function onKeyDown() {
  if (keyDownAt !== null) return; // Ignore key auto-repeat.
  keyDownAt = performance.now();
  startTone();
}

function onKeyUp() {
  if (keyDownAt === null) return;
  const duration = performance.now() - keyDownAt;
  keyDownAt = null;
  stopTone();

  const signal = duration < UNIT * 2 ? 'dit' : 'dah';
  const event = { type: signal, duration: Math.round(duration) };
  socket.send(JSON.stringify(event));
  feedDecoder(event); // Decode your own sending too.
}
Enter fullscreen mode Exit fullscreen mode

The tone is generated with the Web Audio API — an oscillator at 600 Hz, the classic sidetone pitch, started on key-down and stopped on key-up. Hearing your own keying is not a gimmick; it is how operators learn timing. If your dits sound uneven, your timing is uneven, and the ear notices before the decoder does.

// sidetone.js
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let oscillator = null;

function startTone() {
  oscillator = audioCtx.createOscillator();
  oscillator.frequency.value = 600;
  oscillator.connect(audioCtx.destination);
  oscillator.start();
}

function stopTone() {
  if (!oscillator) return;
  oscillator.stop();
  oscillator = null;
}
Enter fullscreen mode Exit fullscreen mode

One browser quirk to handle: the spacebar scrolls the page by default, so call event.preventDefault() on the keydown handler. Another: AudioContext starts suspended until the user interacts with the page, so create it inside the first click handler, not at load time.

The decoder: turning timings back into letters

Decoding is the most interesting part. The client keeps a buffer of incoming signals and a timer. When a gap longer than two units passes with no new signal, the buffer holds one complete letter — look it up in a table and append it to the message. A gap longer than six units means a word boundary, so add a space.

// decoder.js
const MORSE_TABLE = {
  '.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E',
  '..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J',
  '-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O',
  '.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T',
  '..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y',
  '--..': 'Z', '-----': '0', '.----': '1', '..---': '2',
  '...--': '3', '....-': '4', '.....': '5', '-....': '6',
  '--...': '7', '---..': '8', '----.': '9'
};

let signalBuffer = '';
let letterTimer = null;
let messageSoFar = '';

function feedDecoder(event) {
  if (event.type !== 'dit' && event.type !== 'dah') return;
  signalBuffer += event.type === 'dit' ? '.' : '-';

  clearTimeout(letterTimer);
  letterTimer = setTimeout(() => {
    const letter = MORSE_TABLE[signalBuffer] || '�';
    messageSoFar += letter;
    signalBuffer = '';
    renderMessage(messageSoFar);
  }, UNIT * 2);
}
Enter fullscreen mode Exit fullscreen mode

The timeout approach has a subtle benefit: it decodes at the speed of the sender, not at a fixed clock. A slow beginner and a fast operator can share the same room because each receiver adapts to the gaps it actually hears. The trade-off is latency — a letter only appears after the gap timer fires — but at 15 WPM that delay is 160 ms, invisible in practice.

For word gaps, watch the time between events. If more than six units pass between the end of one signal and the start of the next, insert a space before decoding continues. In the real implementation, track lastEventAt and check it in feedDecoder before appending the new signal.

Putting the room together

The full client is one HTML file: a key button, a live transcript area, and a list of who is online. Incoming events from other operators play through the same sidetone code, so you hear their keying exactly as they sent it — wobbles and all. The transcript shows decoded text with the raw dots and dashes underneath, which is how beginners learn to trust their ears: they see the letter appear a beat after they heard the rhythm.

A few features worth adding once the basics work:

  • A speed control. Changing UNIT from 80 ms to 60 ms takes the room from 15 to 20 WPM. Let each user pick their own speed; the decoder adapts because it measures gaps, not absolute timings.
  • A practice mode. A bot operator that sends a random word every ten seconds, so an empty room is still useful. The server can do this with a setInterval and the same broadcast path as human senders.
  • A transcript log. Store decoded messages in the room (not the raw timings — the text is what people re-read). A simple array on the server, sent to each new client on connect, turns the ephemeral room into a readable conversation.
  • Sound on/off and volume. Not every roommate wants to hear 600 Hz beeps at midnight. A mute toggle is a kindness, and it is three lines of code.

What Morse teaches about real-time systems

Three lessons from this project transfer directly to production work.

First, push beats poll. The telegraph did not ask the wire if there was a message; the wire announced it. WebSockets do the same for the browser, and the latency difference is the difference between a conversation and a noticeboard.

Second, keep the server dumb. Our relay does not know Morse code, does not decode anything, and stores nothing. Every feature lives in the client, where it can be changed without redeploying the room. The telegraph network scaled for the same reason: the intelligence was at the endpoints.

Third, timing is data. In most chat apps the message is the text. Here the message is the rhythm, and the text is derived from it. Once you start thinking about inter-arrival times as information — typing indicators, keystroke dynamics, heartbeat intervals — you start seeing protocols everywhere.

Try it yourself

The whole project is under 200 lines: a 40-line server, a keyer, a sidetone, and a decoder. Clone it, run node server.js, open the page in two browser windows, and send your name to yourself. Hearing your own name come back as rhythm — dot-dash, dash-dash-dot — is a small, strange delight, and it is the same delight that kept Morse code alive for 180 years.

If you want to check your timing before you build, a free Morse code translator turns any text into dots and dashes instantly, so you can hear what correct spacing sounds like before you try to produce it yourself. Then build the room, invite a friend, and have the slowest, most deliberate conversation on the internet.


Morse Zone builds free Morse code tools and learning guides at morsezone.com.

Top comments (0)