Real-time multiplayer web app where players race to solve cryptograms (Aristocrat, Caesar, Affine, Porta, Baconian, Nihilist, Hill, Checkerboard, Xenocrypt, Fractionated Morse, etc). See README.md for the player-facing feature list.
- Frontend: SvelteKit (Svelte 5), Sveltestrap, Chart.js
- Backend: Node.js, Express, Socket.IO
- Data: MongoDB via Mongoose, Redis (rate limiting / ephemeral state)
- Auth/Storage: JWT + Argon2/bcrypt, AWS S3 (profile pictures)
- Deploy: Fly.io, via
Dockerfile/buildServer.js - Misc: a Python subprocess (
python-shell) generates/checks math practice problems for math-heavy ciphers (Affine, Hill) — see "Python bot" below.
npm run dev— start SvelteKit dev servernpm run build—vite buildthenbuildServer.jsbundles the Node servernpm start— run the built server (dist/server.js)npm run format/format:check— Prettier- No test suite exists in this repo (no
*.test.*/*.spec.*files, notestscript). Don't assumenpm testworks, and don't invent tests unless asked — correctness for cipher logic is currently verified manually/by inspection only. - Formatting is auto-applied on commit via Husky (
.husky/pre-commit→lint-staged→ Prettier). Don't hand-wrap/format diffs — the hook will reformat on commit anyway.
src/— SvelteKit app (routes, Svelte components, browser-side utils insrc/lib/util).src/lib/server/— SvelteKit-only server code (e.g. rate limiter) used inside+server.js/+page.server.jsendpoints.shared-server/— Node code shared between the SvelteKit server process and the standalone Socket.IO server (server/index.js). Cipher logic, game/DB models, auth, and the Python bot bridge all live here specifically because both processes need them. Import aliases:$shared,$game,$utils(seejsconfig.json) map into this directory.server/index.js— standalone Express + Socket.IO process (real-time game/lobby traffic).ws/wsUtil.js— websocket helpers.shared-server/bots/codebusters/— Python package for generating/checking math practice problems, invoked over stdio byshared-server/bots/botService.js.
When adding backend logic, ask "does SvelteKit need this AND does the socket server need this?" —
if yes, it belongs in shared-server/, not src/lib/server/.
The single source of truth is shared-server/shared/CipherTypes.js — a cipherTypes map
keyed by display name. Everything else (Mongoose enum, quote generation, frontend routing/addon
UI, solvability checks) derives from this object's keys, so most of the wiring is automatic once
an entry exists. Fields per cipher:
| field | meaning |
|---|---|
directMap |
true = one ciphertext letter always maps to one plaintext letter (classic substitution); drives input-linking behavior in the UI |
keys |
ordered list of key names the cipher needs (e.g. ['keyword', 'polybius key']); '!' = internal key, hidden from the player; [] = no key |
addOn |
which helper table/UI component Cipher.svelte renders below the puzzle (see below) |
spacing |
how ciphertext is chunked into "words" for display (-1 = use original spacing) |
length |
[min, max] plaintext character-length range used to pick a quote from Mongo |
options |
game-setup dropdown choices (e.g. Solve/Encode/Decode); ! prefix hides an option from the frontend |
letterGap |
whether solved chunks need extra visual gap (used by non-letter-mapped ciphers like Baconian) |
bypassCheck |
true = don't require single-letter chunks for "solvable" (multi-char ciphertext units, e.g. Baconian symbols, Nihilist coordinate pairs) |
stackKey |
true = overlay a repeating keyword above each ciphertext letter (Porta, Nihilist) |
symbolSource |
'alphabet' (default) or 'ciphertext' — where the shared letter-input bank (letterInputs/letterFocus, FreqTable's rows) draws its symbols from. Only matters for directMap: true ciphers; see below |
letterComponent |
'default' or a key registered in Cipher.svelte's LETTER_COMPONENTS map — which widget renders each ciphertext position; see below |
There's no decodeQuote function. src/routes/api/validate-quote/+server.js checks a Decode-mode
answer by comparing the player's input directly to stripQuote(originalPlaintext) — the stored
plaintext, uppercased and stripped of spaces/punctuation. For Encode mode, it re-runs
encodeQuote on the plaintext and compares against the player's input. This means a new cipher
only needs an encoder, never a decoder. This holds even for the most complex cipher
(Fractionated Morse) — the UI collects per-letter guesses into one flat string via the same
generic getInputText(info.inputs) regardless of how exotic the input widget is, so the
validation endpoint never needs to know a cipher's internals.
Two cipherTypes fields exist specifically so a new cipher's frontend needs can be expressed as
config instead of another hardcoded cipherType === 'X' branch (see "Escape hatches" for what's
still hardcoded):
symbolSource. FordirectMap: trueciphers, a guess typed for one occurrence of a ciphertext symbol is shared across every other occurrence of that symbol — that shared state lives ininfo.letterInputs/info.letterFocus(built byinitLetterInputs/initLetterFocusincipherUtils.js), and it's whatFreqTable.svelterenders rows from.symbolSource: 'alphabet'(the default) builds that bank from the fixed 26/27-letter alphabet, so every letter shows even if unused — right for classic substitution (Aristocrat/Xenocrypt/Patristocrat).symbolSource: 'ciphertext'instead builds it from the distinct solvable symbols actually present in that quote'scipherTextTrim— use this when a direct-map cipher's symbol set isn't the 26-letter alphabet (e.g. homophonic substitution, where a plaintext letter may have several possible numeric codes and the bank needs one entry per code, not per letter). This only matters fordirectMap: trueciphers — non-direct ciphers store each position's answer directly on its DOM input element instead (onChangeinCipher.svelte) and never read this bank.letterComponent.Cipher.svelteresolvesLETTER_COMPONENTS[cipherTypes[cipherType] ['letterComponent']]to decide what renders each ciphertext position —'default'→ the genericLetter.svelte(a single text input); any other key must be added to theLETTER_COMPONENTSmap (currently'baconian'→BaconianLetter.svelte,'morse'→MorseLetter.svelte) and points at a bespoke widget for ciphers whose ciphertext units aren't single typed letters. Registering a new one is a two-step: add theletterComponentvalue inCipherTypes.js, and add the matching entry toLETTER_COMPONENTSinCipher.svelte.
Having read all ~12 implementations, they cluster into a handful of patterns. Identify which your new cipher resembles and copy that one's plumbing — most of the "adding a cipher" work is picking the right template, not inventing new mechanics.
- Plain substitution with a frequency table — Aristocrat, Xenocrypt, Patristocrat.
directMap: true,addOn: 'freqTable', hidden key ('!'), and anoptions: ['K', '!Random', '1','2','3']param controlling which substitution alphabet is generated (freqTableInitinCipherUtil.js:0=fully random,1/2=keyword+shift variants,3=double-shifted). Patristocrat has no encoder of its own —generateQuote.jsliterally mapscipherType: 'Patristocrat' → 'Aristocrat'before callingencodeQuote, so the entire distinction between Aristocrat and Patristocrat is display spacing (spacing: -1keeps natural word breaks vsspacing: 5regroups into fixed-width blocks) plus a longerlengthrange — not cipher logic. Xenocrypt is the same as Aristocrat but Spanish (SPANISH_ALPHABET, grapheme-aware iteration viaGraphemeSplittersinceÑetc. need it, andgetQuoteModel.jsroutes it to theSpanishQuoteMongo collection instead ofQuote). - Simple fixed-table substitution, no key — Atbash, Caesar.
keys: [],addOnis a purely static, read-only reference table component (AtbashTable.svelte,CaesarTable.svelte— just a rendered lookup table, no inputs, no props). The encoder is a few lines of modular arithmetic. If your new cipher has no key and a fixed transform, this is the template. - Math-based,
mathAddOn— Affine, Hill. ShareaddOn: 'mathAddOn', which always rendersCaesarTable(a 0–25 reference) plus a scratchpad calculator input (AffineInput.svelte/MatrixInput.svelte— these are UI-only work-shown widgets; their values are never read or validated, they don't feedcheckQuoteat all) and, if solving in Decode mode,DeterminantTable.svelte. Which scratchpad renders is hardcoded inCipher.sveltebycipherType == 'Hill', not by config. Both are also inMATH_INTENSIVE_CIPHERS(src/lib/util/constants.js), which just toggles visibility of the in-gameCalculator.sveltepopup — unrelated to the math-work scratchpad.generateQuote.jshas bespoke key-generation branches for both (Affinepicks a coprime-with-26aand anyb;Hillrejects keys whose determinant is even/divisible-by-13/non-invertible mod 26 viafindDeterminant). - Keyword overlay (
stackKey: true) — Porta, Nihilist. The repeating keyword is drawn above each ciphertext letter (initLettersWithIndicesincipherUtils.jscomputeskeyLetterper char). Porta'saddOnis a static reference table; Nihilist's (polybiusSquare) is an interactive fill-in grid the player builds themselves (PolybiusSquare.svelte— 5×5 text inputs with arrow-key navigation) — nothing incipherTypesdistinguishes "static reference table" from "interactive input grid"; that's just which component theaddOnname happens to point to. - Coordinate/Polybius ciphers,
bypassCheck: true— Nihilist, Checkerboard. Ciphertext units are multi-digit/multi-char (e.g."34"or a row+col letter pair), soisSolvableChunkcan't require length-1 chunks —bypassCheck: trueturns that check into "non-empty" instead. This has a sharp edge: becauseisSolvableChunkthen treats any non-empty chunk as real puzzle data, abypassCheck: trueencoder must strip non-letters entirely (stripQuote) before encoding — it can not pass punctuation through as its own chunk the way Caesar/Atbash/Affine do (allbypassCheck: false, where a stray punctuation chunk is harmlessly excluded downstream instead). Getting this backwards produces a real bug, not just a display glitch: the stray chunk gets miscounted intospacinggroups and gets its own unanswerable input box, which misaligns the submitted-answer string againststripQuote(plaintext)at validation time. Hit this exact bug building Homophonic — seedocs/homophonic-cipher-plan.md§5. Checkerboard'skeysis['!', '!', 'polybius key']— two hidden internal keys (the row/column header letters) plus one visible one; its square-builder (CheckerboardTable.svelte) is PolybiusSquare's layout with extra-1-indexed header inputs for the player to fill in their own row/col labels. Both also needclearQuote()inCipher.svelteto reset their grid — that reset is hardcoded (if (cipherType == 'Nihilist' || cipherType == 'Checkerboard') clearPolybius = true), not config-driven; a new interactive-grid cipher needs the same kind of explicit hook. - Custom per-letter input widget — Baconian. This is the biggest divergence from the norm:
it sets
letterComponent: 'baconian'soCipher.svelterendersBaconianLetter.svelteinstead of the genericLetter.svelte, parallel to (not driven by)addOn. Baconian ciphertext isn't fixed-alphabet substitution:encodeBaconianinCipherUtil.jsrandomly picks one of several symbol-set families per quote (dots/dashes, digits, emoji, accented letters via combining Unicode marks, punctuation) and encodes each plaintext letter as a 5-symbol A/B pattern frombaconianMap.BaconianLetterrenders that as 5 clickable A/B toggle buttons the player flips, not a text input. - Most divergent — Fractionated Morse. Also uses a custom widget
(
letterComponent: 'morse'→MorseLetter.svelte) instead ofLetter.svelte. Itskeysfield is repurposed: index 0 ('!') is the hidden keyed-alphabet seed, but index 1 ('quote starts with') is not a key at all — it's a crib string (cribPlaintext) computed by the encoder and injected back intokeys[1]bygenerateQuote.jsafter encoding.encodeFractionatedMorseis the only encoder that doesn't return a flatstring[]— it returns{ ciphertext, cribPlaintext, letterToTrigram }, andgenerateQuote.jshas a dedicated branch to unpack that shape (every other cipher's result is used as-is).generateQuote.jsalso force-runs the encoder for this cipher even whenSolve !== 'Decode', since Fractionated Morse never offers an Encode mode. On the frontend, aslotDistributionvalue ($derived.byinCipher.svelte) maps the trigram/morse stream to input-slot positions per letter, because one plaintext letter's input can span a variable-width run of trigram slots plus word-separator slots — nothing else in the app has this many-ciphertext-slots-per-plaintext-letter relationship. symbolSource: 'ciphertext'and a local-scratchpad addon — Homophonic.directMap: truelike Aristocrat, but each plaintext letter has up to 4 possible ciphertext codes (its "homophones"), so the classic 26-letter answer bank doesn't fit —symbolSource: 'ciphertext'builds it from the codes actually present in this quote instead (see "The letter-input bank" above). Its addon (HomophonicTable.svelte, a 100-cell number→letter reference grid) is deliberately not wired to the sharedinfo.letterInputsdict the wayFreqTableis for Aristocrat — it holds its own local state, same asPolybiusSquare/CheckerboardTable, and is added toclearQuote()'s hardcoded reset check alongside them. This was a real design mistake in the first pass, not just a style choice: for a 1:1 substitution cipher like Aristocrat, sharing state between the reference table and the puzzle is correct — filling in what a ciphertext letter means should update everywhere it appears. For a homophonic cipher that's too generous: it turns the reference table into an instant auto-fill for the whole puzzle instead of a scratchpad the player fills in through their own reasoning.keysis['!', 'keyword']: index 0 is the real 4-letter keyword (hidden, used only server-side to build the homophone table), index 1 is a partial-reveal crib string generated separately (seeshared-server/game/homophonicKeywords.js) — same "index 1 isn't a real key" pattern Fractionated Morse uses for its crib. Also the first cipher to demonstrate thebypassCheck: truesharp edge documented above: its encoder must strip non-letters viastripQuoterather than passing them through.
- Pick an archetype above that matches your cipher's shape and keep its files open as a reference while you work.
- Register the cipher in
cipherTypes(shared-server/shared/CipherTypes.js) with the fields from the table above. This alone makes it a validcipherTypeeverywhere (Mongoose enum inshared-server/game/Game.js, the/singleplayer/[cipherType]route guard, etc. — all derive fromObject.keys(cipherTypes)). - Write the encoder in
shared-server/shared/CipherUtil.js: add a function (follow the existingencodeXxx(plaintext, ...keys)pattern — iterate letters viaisLetter/letterToNumber/numberToLetter, orgetSplitter().splitGraphemes()if Spanish/multi-char support is needed), then register it in theencodersmap insideencodeQuote(). It must return a flat array/string of ciphertext chunks unless you're deliberately doing something as unusual as Fractionated Morse (in which casegenerateQuote.jsneeds a matching unpack branch). - Key generation: if the cipher needs a random keyword/value, add a branch in
shared-server/game/generateQuote.js(see theAffine/Hill/Checkerboardspecial cases — most ciphers just pull a randomWordfrom Mongo, but math ciphers need numeric constraints). - Frontend addon UI: reuse an existing
addOnvalue if one fits, or add a new one: create the Svelte component (pattern:src/lib/Components/Game/*Table.sveltefor static reference tables or interactive grids,*Input.sveltefor scratchpad-only work-shown widgets), then add an{:else if}branch insrc/lib/Components/Game/Cipher.sveltekeyed oncipherTypes[cipherType]['addOn']. - Custom letter-input widget? Only needed if ciphertext units aren't single alphabet
characters the player types directly (see Baconian/Fractionated Morse above). If so, build a
*Letter.sveltecomponent, add it to theLETTER_COMPONENTSmap inCipher.svelte, and setletterComponentto that key inCipherTypes.js. - More than 26 possible ciphertext symbols for a
directMap: truecipher? SetsymbolSource: 'ciphertext'so the shared letter-input bank is built from the symbols actually present in the quote instead of the fixed alphabet (see "The letter-input bank" above). - Interactive grid that needs clearing? If your addon is a fill-in grid (like
Nihilist/Checkerboard's Polybius squares), add your cipher to the hardcoded
clearQuote()check inCipher.svelteso "Clear" resets it. - Spanish support: only needed if the cipher should support Xenocrypt-style Spanish quotes —
otherwise skip (
getQuoteModel.jsalready special-casesXenocrypt→SpanishQuotemodel; a new Spanish cipher would need the same branch). - Math practice bot (optional): only relevant if the cipher is math-based like Affine/Hill.
MATH_INTENSIVE_CIPHERSinsrc/lib/util/constants.jsand the Python generators inshared-server/bots/codebusters/(GENERATORSdict in__main__.py) provide practice-problem generation, separate from the actual cipher encode/decode flow. - Add to README's cipher table for player-facing docs (not required for the app to work, but keep it in sync).
- No automated tests exist — manually verify by playing the cipher in singleplayer mode
(
/singleplayer/<CipherName>) in both Decode and Encode (if enabled), including edge cases around punctuation/spacing/case.
The config object covers most wiring, but these are hardcoded per-cipherType checks scattered
in the code that a new cipher may need to be added to explicitly:
Cipher.svelte'sslotDistribution$derived.by: Fractionated-Morse-only trigram/slot layout math.Cipher.svelte'sclearQuote():cipherType == 'Nihilist' || cipherType == 'Checkerboard'resets their interactive Polybius grids. (Candidate for the same treatment asletterComponentbelow if a third interactive-grid cipher shows up — not done yet since only two ciphers need it today.)Cipher.svelte's addon block:cipherType == 'Hill'picksMatrixInputvsAffineInputeven though both shareaddOn: 'mathAddOn'.generateQuote.js:cipherType === 'Patristocrat' → 'Aristocrat'remap before encoding;cipherType === 'Fractionated Morse'forces encoding regardless ofSolve; a dedicated branch unpacks Fractionated Morse's non-array encoder return shape intoencodedQuote/keys[1]; theAffine/Hill/Checkerboardkey-generation special cases.getQuoteModel.js:cipherType === 'Xenocrypt'routes to the Spanish quote collection.src/lib/util/constants.js:MATH_INTENSIVE_CIPHERSarray gates the in-game calculator popup.
Already generalized (no longer hardcoded): which per-position input widget renders
(Cipher.svelte used to branch on cipherType === 'Baconian'/'Fractionated Morse' directly in
the template; now it resolves letterComponent through the LETTER_COMPONENTS map — see "The
letter-input bank" above), and the letter-input bank's symbol set (initLetterInputs/
initLetterFocus used to always build from the fixed alphabet; now driven by symbolSource).
shared-server/bots/botService.js spawns shared-server/bots/codebusters_bot_server.py once
(pooled worker processes) and talks to it over stdio as line-delimited JSON. It only handles math
practice-problem generation/checking (generate/check/ping/stats actions) — it does not
solve or decode ciphers for game opponents. Auto-shuts-down after 30 min idle.
UserGame.singleplayerStatsread-modify-write races with multiplayer fields on the same document.statsUtil.js'sincrementTotal/incrementWindoUserGame.findById(userId)→ mutate →.save()on every singleplayer quote generation/solve. That's the same document thatcurrentSocketId/currentGamelive on, whichws/connectionHandler.js/joinGame.jsrewrite constantly during multiplayer matchmaking/live games — a.save()built from a stale read can clobber a concurrent multiplayer-field change (or vice versa). This predates and is unrelated to the singleplayer-quote-stats work (seedocs/singleplayer-stats-plan.md), which deliberately keeps its own new per-quote/per-user data in separate collections (QuoteStats,UserQuoteInsights) rather than adding to this same risk onUserGame. Fixing this properly doesn't require movingsingleplayerStatsto a new collection — swapping thefindById→ mutate →.save()pattern for atomicupdateOne/$inccalls (same collection) would remove the race without a data migration. Not fixed yet; flagging so it isn't lost.
- Comments and commit messages. Short sentences. Use RFC 2119 keywords (MUST/SHOULD/MAY/etc.) for obligations and constraints, not hedging prose. Commit messages: imperative-mood subject; only add a body for a fact the diff itself cannot show (the why, not the what). Write a comment only where the code needs clarification — never to narrate what the code already says.
- Formatting/linting is enforced by Husky + lint-staged + Prettier on every commit — don't spend effort hand-formatting.
- Cipher-specific letter/number helpers (
isLetter,letterToNumber,numberToLetter,stripQuote) live inCipherUtil.jsand are alphabet-aware (ENGLISH_ALPHABETvsSPANISH_ALPHABET) — reuse them rather than hand-rolling char math in a new encoder.