A free Morse code trainer that runs entirely in your browser — real CW audio, a keyboard straight key, and a signal tree that lights up as you master each character.
Open it → · Learn in 7 days · 60-second challenge · Send something
Most Morse code sites are a translator with an alphabet chart bolted on. MorseForge is a trainer: you learn the code in the order it is actually structured, you hear it as real CW tones, and you send it back with your own keyboard.
No account. No ads. No server — your progress never leaves your device.
| Learn it in 7 days | Two characters on day one, all 26 letters and 10 digits by day seven |
| The tree | The real binary structure of Morse, drawn as a circuit. Pick a node to hear it and drill it |
| Telegraph | Your keyboard is the key. Tap for a dot, hold for a dash, decoded live |
| Copy practice | Whole lines — code groups, words, on-air phrases, callsigns — graded character by character |
| Daily challenge | Eight lines a day, generated from the date so everyone gets the same one, with no backend |
| Games | Signal Defender for copying under pressure, Morse Runner for sending under it |
| Translator | Text ↔ Morse, with audio and a signal lamp at 5–40 WPM |
| Alphabet | Every character per ITU-R M.1677-1, click to hear |
| Words | 22 words and phrases, each playable and explained |
No signup, nothing to install, and it works on a phone.
- Hear one character — the guided lesson shows it, plays it, then asks you to send it back.
- Send your own — hold the space bar. Tap for a dot, hold for a dash. It decodes as you go.
- Take the 60-second challenge — and send the link to someone, with your score baked into it.
A few decisions that are load-bearing, and would look arbitrary without the reasoning.
Characters are taught by code length, not alphabetically. Morse is a prefix tree — a dot branches left, a dash branches right — so the seven-day plan walks one layer per day. That is why day one is E and T, and why Q and Z come last.
Three skills are tracked separately. Recognising a printed .-, hearing it
at speed, and keying it are different abilities that improve at different rates.
A character only reaches Mastered with evidence in all three, so two hundred
perfect recognitions alone will never master it.
Farnsworth timing is on by default. Characters play at full speed with the gaps stretched. Slowing the characters themselves teaches people to count individual beeps, and that habit becomes a hard ceiling around 10 WPM.
How patient the key is has nothing to do with WPM. At 15 WPM a character
ends after 240 ms of silence — correct for a competent operator, hopeless for a
beginner who means to send S and, three seconds of hesitation later, has sent
E E E. Keying patience is its own setting, defaulting to 1.6 seconds, with a
manual mode that removes the clock entirely.
Only transform and opacity animate. Three things were breaking that and
one of them carried a comment claiming it was not: the key faded its
background-colour and box-shadow, the power meter animated width, and a
toggle used transition-all. Animating a layout property runs layout on every
frame; box-shadow is among the most expensive things to animate at all. The
key now has no transition whatsoever, which is also more correct — a key is
down or up, and easing that blurs the boundary a learner is timing against.
The pages people find in a search ship no framework. The 22 word pages and the alphabet chart used to load 190 KB of React to attach a click handler to markup that was already complete. They are now 6–8 KB: the audio engine and about a hundred lines of plain DOM code. React stays where it earns its keep — the telegraph, the tree, practice — and the two players share one implementation of the timing so they cannot drift apart.
Assistants get Markdown, not a stripped-down web page. Every article is
served twice: as HTML for people, and at the same URL with .md appended as the
source it was rendered from, linked with rel="alternate". There is also
/llms-full.txt — the complete text of the site in one file, generated from the
same alphabet, curriculum and word notes the app runs on, so it cannot describe
a product that does not exist. A model answering a question about Morse code
should not have to fetch twenty pages and strip navigation out of each one; that
stripping is where quotes get mangled.
Every page is prerendered to real HTML. GPTBot, ClaudeBot and PerplexityBot do not execute JavaScript. A client-rendered app is invisible to them, so the whole site is static and only the interactive parts hydrate — the seven-day plan, the blog and the word index ship zero bytes of JavaScript, and pages that do load React only do so because there is genuinely something to interact with.
The daily challenge has no server behind it. The UTC date is the seed, so every browser runs the same generator and gets the same eight lines, the same speed and the same pass mark. It works offline, it cannot go down, and there is nothing to pay for. The streak counts attempts rather than passes, because showing up is the only thing a streak is any good at encouraging.
A dropped character costs one mark, not the rest of the line. Copy practice
compares what was sent against what you typed by aligning them first, so missing
the second letter of HELLO is scored as one miss rather than as four wrong
answers. Grading it position by position would tell a learner they got 20% of a
line they had almost entirely right.
Nothing is written twice. The alphabet is one object. The tree, the
curriculum, the word pages, the translator, llms.txt and every code printed
anywhere all derive from it, so a reference page cannot disagree with the
trainer.
src/
├── engine/ Framework-agnostic core, fully unit-tested
│ ├── timingEngine.ts PARIS WPM + ARRL Farnsworth
│ ├── morseEncoder.ts Text → Morse
│ ├── morseDecoder.ts Morse → text, plus a live decoder for the key
│ ├── audioEngine.ts Web Audio CW tone, envelope-shaped
│ └── keyEngine.ts Straight key: short/long press, gap detection
│
├── data/ Single sources of truth
│ ├── morseAlphabet.ts ITU-R M.1677-1
│ ├── tree.ts Derived from the alphabet, never hand-authored
│ ├── curriculum.ts The 7-day plan
│ └── words.ts The word pages
│
├── game/ mastery.ts · spacedRepetition.ts
├── storage/ Versioned, validated localStorage
├── components/ React islands
├── pages/ Astro routes
└── pwa/ Build-time manifest + service worker generation
Stack: Astro 7 (static output) · React 19 islands · Tailwind 4 · TypeScript strict · Vitest · Web Audio API
No backend, no database, no analytics beyond anonymous, cookieless Cloudflare Web Analytics.
git clone https://github.com/iamcanturk/morse.git
cd morse
npm install
npm run dev| Command | Does |
|---|---|
npm run dev |
Dev server on :4321 |
npm run build |
Static build to dist/, plus manifest and service worker |
npm run verify |
Format check, type check, tests — run this before committing |
npm test |
Vitest |
npm run deploy |
Verify, build, rsync to the server, assert the site responds |
Requires Node 20.11+.
661 tests, covering the parts where being wrong is silent:
- Timing — sending PARIS once takes exactly
60/WPMseconds, checked at 5/12/20/35 WPM and under Farnsworth. Every speed readout depends on it. - Codec — every character round-trips through encode and decode.
- Key — an OS auto-repeat keydown mid-hold yields one dash, not a burst of dots.
- Storage — a hand-edited export claiming more correct answers than attempts is clamped rather than shown as 16,000% accuracy.
- Content — no day of the plan can suggest a practice word containing a letter it has not taught yet, and factual claims made in prose on the public pages are asserted against the alphabet.
- Copy grading — a dropped character costs one mark, not every character after it: the two lines are aligned by edit distance before they are compared.
- Keyboard — binding the key to Space must not stop every button and link
on the page being activated by Space, which is what a
windowlistener with an unconditionalpreventDefaultdoes. - Games — playable, not just correct. Whole runs are simulated against a player who can only deal with one prompt at a time, because a version that could answer several at once made an impossible game look survivable and hid a real balance bug. A competent player must last; a passive one must lose.
- Analytics wiring — every event declared in the closed
EventNameunion must actually be fired from somewhere. A declared-but-unsent event is invisible: TypeScript is happy, every test passes, and the only symptom is a number that stays at zero, which looks exactly like nobody using the feature. - Links — every internal reference in the built output must resolve. 2,301 of them across 47 pages, checked against what the build actually produced.
- Reachability —
npm run verifyfails on a module undersrc/data,src/game,src/engine,src/storageorsrc/analyticsthat no page can reach. Three features once shipped fully tested and completely unreachable; coverage is exactly the wrong instrument for catching that.
Static files on CloudPanel behind Cloudflare. The
nginx vhost is version-controlled in deploy/ —
the default static template caches sw.js for ten years, which would pin every
returning visitor to whichever build they first loaded.
Contributions are genuinely wanted, and there are specific things that would help more than others.
If you are a licensed operator, the thing I am least sure of is whether the on-air conventions are right — the Q-codes, the prosigns, what an actual exchange sounds like. I am not licensed. Being told where this is wrong is the single most valuable thing anyone can do here, and an issue saying "the RST phrasing is off" is a real contribution.
If you learn Morse and it does not work for you, say so and say where. The place people quit is the most important information this project can get, and it is invisible from the inside — nothing here reports back, by design.
If you write code, the ideas that would move this furthest:
- Iambic keyer and paddle support — the biggest single gap. Straight key only right now, and paddles are what most operators actually use.
- Translations — everything is English. Say which language before you start, so two people do not translate the same thing.
- Screen-reader testing. The markup has been audited and the obvious failures are fixed, but it has never been driven by someone who uses one daily. That gap does not close by reading the code.
- Something small? Open an issue describing what you want to work on and I will scope it with you. There is no stale backlog here — anything that could be fixed in an afternoon has been, so a curated list of easy tasks would be a list of things nobody needed.
Read CONTRIBUTING.md first — it is short. The one rule worth
knowing up front: npm run verify has to pass, and a pre-push hook enforces it,
so a broken build cannot reach the repo.
- Star the repo. It is the cheapest signal that this is worth someone's time, and it is most of how anyone finds it.
- Link to it from a club page, a wiki, a forum answer.
- Tell someone learning Morse. Word of mouth beats everything else here, because there is no advertising budget and no growth loop.
Built and maintained by Can Türk — I build things on the web, mostly the kind that work without an account.
If MorseForge is useful to you, a ⭐ or a link back to morse.iamcanturk.dev genuinely helps — and if you build something with it, I would like to see it.
Available for work: iamcanturk.dev.
Morse codes and timing follow ITU-R M.1677-1, the ITU recommendation defining International Morse code. Farnsworth timing follows the ARRL standard. The Koch ordering follows Ludwig Koch's original method.
Built with Astro, React and Tailwind CSS. Fonts are Space Grotesk and JetBrains Mono, self-hosted.
MIT © Can Türk — use it, fork it, ship it. Attribution is appreciated but not required.
