A browser-based word game fusing Mahjong Solitaire tile layouts with Scrabble letter scoring. Built with vanilla JS, Canvas 2D, and the Web Audio API — zero dependencies.
Six files (five JS modules + one data file), one global namespace (window.MojAbble),
loaded via plain <script> tags in dependency order. Dictionary loaded async at boot.
Each file's role, contents, and size. Zero external dependencies.
words.txt
at boot, hashes each word via FNV-1a, checks against a 57-entry blocked-hash
Set, then builds the valid-word Set for O(1) lookup.
No plain-text slurs in source - only opaque 8-hex-char hashes. Words with
legitimate primary meanings (cock, ass, hell, damn) kept per Scrabble TWL convention.
Exports WordValidator with scoring methods and loadDictionary() (async).
Board (tile management, free-tile detection, difficulty-aware letter generation),
Tile (position, letter, animation state),
Layouts (pyramid definition), ScoreManager (combos, stats),
three difficulty-tuned letter bag distributions, and the shared constants object C.
Particle system, ScorePopup floaters,
Effects manager (screen shake, flash, bg pulse, ambient particles),
scrolling word-tiling background (offscreen canvas pre-render),
and easing functions.
AudioContext on first interaction.
Game controller: state machine
(menu/playing/gameover), requestAnimationFrame loop,
mouse + touch + keyboard input, difficulty selection, DOM UI sync,
word stats toast (rarity + dictionary API integration),
and all juice orchestration.
Three states, driven by the Game.state property in main.js.
From player click to screen pixels — the full action-response cycle.
Every tile goes through a sequence of states from board initialization to removal.
A tile is free when two conditions are met:
| Rule | Check | Meaning |
|---|---|---|
| No tile above | !tiles.some(t.layer === this.layer+1 && t.col === this.col && t.row === this.row) |
Nothing stacked on top |
| At least one open side | !hasLeft || !hasRight |
Left or right neighbor missing on same layer |
76 tiles across 4 layers. The Layouts.classic() function returns
position arrays; adding new layouts is a single new function.
Scrabble letter values, length bonuses, and a combo multiplier that rewards consecutive valid words.
| 1 pt | 2 pt | 3 pt | 4 pt | 5 pt | 8 pt | 10 pt |
|---|---|---|---|---|---|---|
| A E I L N O R S T U | D G | B C M P | F H V W Y | K | J X | Q Z |
| Word Length | 3 | 4 | 5 | 6 | 7 | 8 | 9+ |
|---|---|---|---|---|---|---|---|
| Bonus | 0 | +5 | +15 | +30 | +50 | +80 | +80 + 40×(n−8) |
| Property | Detail |
|---|---|
| Window | 8 seconds between valid words to maintain combo |
| Multiplier | Equal to combo count (1x, 2x, 3x, ...) |
| Reset | On invalid word submission or timeout |
| Board clear bonus | +500 flat points |
Three escape valves prevent dead-end boards, each with a strategic cost:
Three difficulty-tuned letter pools govern tile generation, each designed around English letter frequency, digraph availability, and word-formation probability for a 68-tile board.
The letter bag is the single most important factor in whether a board feels playable. Three constraints guide every distribution:
| Constraint | Why It Matters |
|---|---|
| Vowel floor | English words average ~40% vowels. Below 25%, most 3-letter words become impossible. Each difficulty sets a minimum vowel ratio enforced after sampling. |
| Digraph coverage | The top English bigrams (TH, HE, IN, ER, AN, RE, ON, AT, EN, ST) must be statistically likely. If any letter in a common pair is absent or rare, word options collapse. |
| Frustration letter budget | Letters like Q, X, Z, J score high but pair with very few others. Too many of these create boards where points exist on paper but no valid words can form. |
| Letter | Pts | Easy | Normal | Hard | English % |
|---|---|---|---|---|---|
| A | 1 | 9 | 8 | 5 | 8.2% |
| B | 3 | 1 | 2 | 2 | 1.5% |
| C | 3 | 2 | 2 | 3 | 2.8% |
| D | 2 | 4 | 3 | 3 | 4.3% |
| E | 1 | 12 | 11 | 7 | 12.7% |
| F | 4 | 1 | 2 | 3 | 2.2% |
| G | 2 | 2 | 3 | 3 | 2.0% |
| H | 4 | 3 | 2 | 3 | 6.1% |
| I | 1 | 8 | 8 | 5 | 7.0% |
| J | 8 | 0 | 1 | 2 | 0.2% |
| K | 5 | 0 | 1 | 2 | 0.8% |
| L | 1 | 5 | 4 | 3 | 4.0% |
| M | 3 | 2 | 2 | 3 | 2.4% |
| N | 1 | 6 | 5 | 4 | 6.7% |
| O | 1 | 8 | 7 | 5 | 7.5% |
| P | 3 | 2 | 2 | 3 | 1.9% |
| Q | 10 | 0 | 1 | 1 | 0.1% |
| R | 1 | 6 | 5 | 4 | 6.0% |
| S | 1 | 6 | 4 | 3 | 6.3% |
| T | 1 | 6 | 5 | 4 | 9.1% |
| U | 1 | 4 | 4 | 3 | 2.8% |
| V | 4 | 0 | 2 | 3 | 1.0% |
| W | 4 | 1 | 2 | 3 | 2.4% |
| X | 8 | 0 | 1 | 2 | 0.2% |
| Y | 4 | 2 | 2 | 3 | 2.0% |
| Z | 10 | 0 | 1 | 2 | 0.1% |
| Pool size | 90 | 90 | 84 | ||
| Vowel % | 46% | 42% | 30% | ||
| Vowel floor | 40% | 35% | 25% | ||
| Frustration letters | 0 | 5 | 14 | ||
Shuffle redistributes letters across all remaining tiles (not just free ones), preserving the session's letter pool exactly. This is critical: the 68 letters drawn at game start are the only letters for the entire session. Shuffle changes where letters sit, not which letters exist.
Free tiles receive an animated flip effect (spiraling outward from center). Blocked tiles change silently underneath. This means a shuffle can surface previously buried vowels or push frustration letters deeper into the stack - it is genuinely strategic, not just cosmetic.
Letters are drawn without replacement from the difficulty pool until the pool is exhausted, then it refills. For a 68-tile board from a 90-tile pool, ~76% of the pool is used per pass, meaning letter ratios closely track pool weights.
After sampling, a vowel floor check runs: if the vowel count falls below the difficulty minimum (40%/35%/25%), random consonants are replaced with weighted vowels (E > A > I > O > U bias) until the floor is met. This prevents unplayable consonant-heavy boards while keeping each difficulty's character intact.
| Decision | Reasoning |
|---|---|
| Easy removes 8 letters entirely | Players should never see Q, X, Z, J, K, V. These letters have very few valid pairings (Q needs U, X/Z/J need specific vowel positions). Removing them guarantees every tile contributes to possible words. |
| Easy has 6 S tiles | S is the most versatile consonant in English - it pluralizes nearly any noun, conjugates most verbs, and appears in common clusters (SH, ST, SP, SN, SC, SK, SL, SM, SW). Extra S tiles create the most word options per tile. |
| Normal mirrors Scrabble | 70+ years of competitive play have validated these ratios. The one-each of J, Q, X, Z creates tension without frustration. This is the baseline players expect from word games. |
| Hard doubles frustration letters | At 2x Scrabble rate, hard boards average ~14 difficult tiles out of 68 (21%). Players must know words like VEX, JINX, QUIZ, WAX, ZAP. The 25% vowel floor ensures at least 17 vowels exist, enough for ~5 three-letter words even in worst case. |
| Pool sizes differ (90/90/84) | Hard's flatter distribution uses fewer total tiles in its pool, meaning the 68-tile sample covers 81% of the pool (vs 76% for Easy/Normal). This makes Hard boards more predictable in letter balance - the challenge comes from which letters, not from random spikes. |
MojAbble filters offensive words from the dictionary at load time using a hashed blocklist. The source code never contains readable slurs - only opaque FNV-1a hashes.
Storing a plain-text list of slurs in source creates problems: it shows up in code search, triggers CI profanity scanners, and makes the repo itself carry offensive content. Hashing avoids all of this. The approach matches industry practice used by GitHub, Discord, and commercial word games - blocked terms are stored as irreversible hashes so source code stays clean while filtering remains effective.
FNV-1a (Fowler-Noll-Vo, variant 1a) is a fast, non-cryptographic hash function. For each
input string it produces a 32-bit integer, displayed as an 8-character hex string
(e.g., a4705559). Across the 57 blocked words, there are zero collisions -
every word maps to a unique hash. The function runs in O(n) time where n is word length,
adding negligible overhead to dictionary loading.
function _fnv1a(s) {
let h = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i); // XOR with byte
h = Math.imul(h, 0x01000193); // multiply by FNV prime
}
return (h >>> 0).toString(16).padStart(8, '0');
}
| Step | Detail |
|---|---|
| 1. Fetch | words.txt loaded via fetch() - 80,272 words, one per line |
| 2. Split | Text split on newlines, filtered to words with length ≥ 3 |
| 3. Hash check | Each word passed through _fnv1a(), result checked against _BLOCKED Set (57 hex strings) |
| 4. Build Set | Surviving words added to VALID_WORDS Set for O(1) lookup during play |
| Category | Decision | Reasoning |
|---|---|---|
| Blocked | Racial/ethnic slurs, orientation slurs, ableist slurs, strong profanity | No legitimate word-game context. Players should never see these on the board or in valid-word feedback. |
| Kept | Words with legitimate primary meanings (cock, ass, hell, damn) | These are standard Scrabble TWL entries with non-offensive primary definitions (a rooster, a donkey, a theological concept, a verb). Blocking them would surprise word-game players and reduce valid plays. |
To block a new word: compute its FNV-1a hash (run _fnv1a('word') in a browser console),
then add the resulting 8-character hex string to the _BLOCKED Set in words.js.
No other changes needed - the filtering pipeline picks it up automatically on next dictionary load.
Two localStorage-backed leaderboards persist across sessions: a top-10 score list and a top-10 rarest-words hall of fame. Both display on the start screen and game-over screen.
| Key | Contents | Max Entries |
|---|---|---|
mojabble_scores |
Top 10 scores, sorted descending by score | 10 |
mojabble_rare |
Top 10 rarest words, sorted descending by rarity score | 10 |
Each entry is a JSON object stored in an array:
// mojabble_scores entry
{
s: 1250, // final score (rounded)
w: 14, // words found
bw: "QUARTZ", // best word (uppercase)
bws: 82, // best word score
mc: 5, // max combo reached
d: "normal", // difficulty
dt: "4/11/2026" // date string
}
// mojabble_rare entry
{
w: "QUARTZ", // word (uppercase)
r: 82, // rarity score (baseScore + lengthBonus)
d: "normal", // difficulty
dt: "4/11/2026" // date string
}
A word's rarity score is baseScore + lengthBonus (before combo multiplier).
This means rarity reflects intrinsic word difficulty - rare letters and long words -
not streak bonuses. The rarest words list only stores unique words; submitting the same
word twice does not create a duplicate entry.
After every valid word, a toast panel slides in showing three pieces of information:
| Element | Content |
|---|---|
| Rarity rating | Star-based tier: COMMON (<8), UNCOMMON (8+), RARE (16+), EPIC (29+), LEGENDARY (51+) |
| Score breakdown | +baseScore +lengthBonus ×combo = total |
| Definition/etymology | Fetched async from Free Dictionary API (dictionaryapi.dev). Falls back to letter stats (length, vowel %, unique letters) if API fails or word not found. |
The toast auto-dismisses after 6 seconds. Each new word submission cancels the previous timer and replaces the toast content immediately.
Every player action triggers layered feedback across visual, audio, and UI channels.
The Effects class in render.js manages particles and screen effects;
main.js orchestrates timing.
Single <canvas>, 2D context, DPR-aware. The game loop runs at ~60fps
via requestAnimationFrame.
100% procedural via Web Audio API — no audio files. AudioContext
is lazy-initialized on first user interaction to comply with autoplay policies.
| Sound | Oscillator | Frequency | Duration | Notes |
|---|---|---|---|---|
playTick() |
sine | 800 → 400 Hz | 60ms | Soft UI click |
playSelect(n) |
triangle | (400 + n×80) → ×1.5 | 120ms | Pitch rises with word length |
playDeselect() |
sine | 500 → 300 Hz | 100ms | Descending note |
playWordSuccess() |
triangle + sine | 440 × [1, 1.25, 1.5, 2] | 4×70ms arpeggio | Major chord, extra shimmer if >30pts |
playWordFail() |
sawtooth | 150 → 120 Hz | 2×150ms | Harsh buzz |
playCombo(lvl) |
sine | (600 + lvl×100) → ×2 | 200ms | Rising tone per combo level |
playCelebration() |
sine | 440 × major scale | 8×80ms scale | Full ascending octave |
All classes live under window.MojAbble. Seven classes, one constants object, one validator.
startGame()submitWord()clearSelection()deselectLast()shuffleFreeTiles() — −50ptsswapTiles() — −25pts/tiletoggleHighlight() — Light on/offgiveUp()_invalidWord()_wordSuccess(result)_showWordStats(word, result) — stats toast + API fetch_fetchWordFact(word) — Free Dictionary API_fallbackFact(word) — letter stats if API fails_boardCleared()_gameOver()_updateWordArea()_updateComboDisplay()_loop(timestamp)init(layoutName, difficulty) — generates board with difficulty bagcenterOnCanvas(w, h)isFree(tile) — the Mahjong rule checkselectTile(tile)deselectTile(tile)deselectAll()deselectLast()removeSelected()finalizeRemoval(tile)getCurrentWord()getTileAtPos(sx, sy)getRenderOrder()canFormWord()getRemainingCount()shuffleFreeLetters() — redistribute ALL active tiles' lettersswapSelectedLetters() — swapgetScreenPos(offsetX, offsetY)resize()render(board, effects, dt)setBgWord(word) — set scrolling word tiling_renderBgTile() — pre-render to offscreen canvas_drawBg(ctx, effects) — gradient + word tiling_drawTile(ctx, tile, board)_drawTileBody(...)_roundRect(ctx, ...)update(dt)explodeTile(x, y, color, intensity)sparkleTile(x, y)addPopup(x, y, text, color, size)shake(intensity, duration)flashScreen(color, alpha)pulseBg(amount)celebrate(cx, cy)drawParticles(ctx)drawPopups(ctx)drawFlash(ctx, w, h)reset()submitWord(word, time) → scoring breakdownupdateDisplay(dt) — smooth countercheckComboTimeout(time)playTick()playSelect(wordLen)playDeselect()playWordSuccess(score, combo)playWordFail()playCombo(level)playCelebration()words.txt at boot via loadDictionary(). During load, each word is hashed (FNV-1a) and checked against a 57-entry blocked-hash Set - blocked words are filtered out before building VALID_WORDS. O(1) Set lookup + Scrabble scoring.isValid(word) → booleangetWordScore(word) → letter sumgetLengthBonus(len) → bonus pointsgetLetterScore(letter) → single letter ptsloaded → boolean (getter)MojAbble.loadDictionary() → Promise (fetches words.txt, populates Set)
Architecture decisions that make future expansion straightforward.
| Feature | Where to change | Effort |
|---|---|---|
| New layouts (turtle, fortress, bridge) | Add a function to Layouts in engine.js that returns a new position array |
Low |
| Full dictionary ✓ Done | 80K words loaded from words.txt via async fetch (enable1, public domain) |
Done |
| ES modules / bundler | Replace IIFE pattern with export/import, add Vite/Rollup |
Medium |
| Multiplayer / leaderboard | ScoreManager already tracks all stats; add API calls in main.js _gameOver() |
Medium |
| Timed mode / challenge mode | Add new state to Game state machine, countdown in _loop() |
Low |
| Tile themes / skins | Swap C.COLORS object + adjust _drawTileBody |
Low |
| Power-ups (wildcard, bomb, shuffle) | Add tile types to Tile class, special rendering in Renderer, actions in Game |
Medium |
| Mobile-first UI ✓ Done | 4-breakpoint responsive CSS, touch input, auto-scaling board, mobile word stats | Done |
| Difficulty modes ✓ Done | Three letter bags (Easy/Normal/Hard) with tuned distributions. Selector on start screen. | Done |
| Word stats + definitions ✓ Done | Post-word toast with rarity, score breakdown, definition/etymology via Free Dictionary API | Done |
| Offensive word filter ✓ Done | FNV-1a hashed blocklist (57 entries). No plain-text slurs in source. Filtered at dictionary load. | Done |
| Local scoreboard ✓ Done | localStorage top 10 scores (mojabble_scores). Shown on start screen + game over. |
Done |
| Rarest words hall of fame ✓ Done | localStorage top 10 rarest words (mojabble_rare). Persists across sessions. |
Done |
MojAbble was built from scratch in a single pairing session — human + Claude, ~35 minutes start to finish.