A browser-based word game fusing Mahjong Solitaire tile layouts with Scrabble letter scoring. Uses the full SOWPODS / Collins Scrabble Words tournament dictionary (267,627 words, 3+ letters, no length cap). Built with vanilla JS, Canvas 2D, and the Web Audio API — zero external JS dependencies.
Six files (five JS modules + one data file) plus a PHP leaderboard backend, one global namespace (window.MojAbble),
loaded via plain <script> tags in dependency order. Dictionary loaded async at boot.
Global high scores persisted server-side via flat JSON files.
Each file's role, contents, and size. Zero external dependencies.
words.txt
(SOWPODS / Collins Scrabble Words competitive tournament dictionary, 267,627 words,
3+ letters, no length cap) 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).
_scores/ directory
with flock-based concurrency. Two endpoints: GET ?action=scores returns
top 50 scores + rare words, POST ?action=submit saves a new score.
Rate-limited (5s per IP), input-validated, no database required.
_rooms/ directory.
Five endpoints: create (new room + seed), join (enter room),
poll (opponent state), update (sync score),
finish (end match). 4-char room codes, 30-second countdown when
first player finishes, auto-cleanup of rooms older than 30 minutes.
Board (tile management, free-tile detection, difficulty-aware letter generation with seeded PRNG for multiplayer),
Tile (position, letter, animation state),
Layouts (pyramid definition), ScoreManager (combos, stats),
mulberry32 (deterministic PRNG),
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),
global/local leaderboard tabs, server score submission,
player name persistence, multiplayer room flow (create/join/poll/finish),
opponent score overlay, countdown, match result, 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');
}
// words.js - loadDictionary()
const words = text.split(/\r?\n/)
.filter(w => w.length >= 3 && !_isBlocked(w));
VALID_WORDS = new Set(words); // 267,627 words, O(1) lookup
function _isBlocked(word) {
return _BLOCKED.has(_fnv1a(word));
// _BLOCKED = Set of 57 hex strings (no plaintext slurs)
}
| Step | Detail |
|---|---|
| 1. Fetch | words.txt loaded via fetch() - 267,627 words (SOWPODS/Collins), 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-tier leaderboard system: a global server-side leaderboard shared by all visitors and a local localStorage leaderboard for personal records. Both track top scores and rarest words. Players enter a name (persisted in localStorage) that appears on the global board.
Powered by scores.php, a single-file PHP backend using flat JSON storage
in a _scores/ directory. No database, no accounts. Score submission happens
automatically on game over; the global board is fetched on page load and after each game.
| Endpoint | Method | Purpose | Max Entries |
|---|---|---|---|
?action=scores |
GET | Returns top scores + rarest words | 50 each |
?action=submit |
POST | Submits a new score + optional rare word | - |
Protections: rate limiting (1 submission per 5 seconds per IP), score range validation (1 - 999,999), name sanitization (alphanumeric, max 16 chars), flock-based file concurrency. The server is best-effort - if unreachable, the game functions normally with local scores only.
| Key | Contents | Max Entries |
|---|---|---|
mojabble_scores |
Top 10 personal scores, sorted descending | 10 |
mojabble_rare |
Top 10 personal rarest words, sorted descending | 10 |
mojabble_name |
Player name string (persisted for server submissions) | 1 |
Both the start screen and game-over screen show a Global / Local tab switcher above the scoreboards. Global is the default view. Tabs swap which dataset renders into the existing score and rare-word lists.
Server and local entries share a similar shape. Select a tab to view each format:
// mojabble_scores entry (localStorage + server)
{
n: "Player", // player name
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: "2026-05-19", // date string
ts: 1716134400 // unix timestamp (server only)
}
// mojabble_rare entry (localStorage + server)
{
w: "QUARTZ", // word (uppercase)
r: 82, // rarity score (baseScore + lengthBonus)
n: "Player", // who found it (server only)
d: "normal", // difficulty
dt: "2026-05-19" // date string
}
// GET scores.php?action=scores
// Response:
{
scores: [ ... ], // top 50 score entries
rare: [ ... ] // top 50 rare word entries
}
// POST scores.php?action=submit
// Request body:
{
n: "Player", // player name (max 16 chars)
s: 1250, // score (1 - 999,999)
w: 14, // words found (1 - 500)
bw: "QUARTZ", // best word
bws: 82, // best word score
mc: 5, // max combo (0 - 200)
d: "normal", // difficulty (easy|normal|hard)
rw: "QUIXOTIC", // session's rarest word (optional)
rr: 85 // rarest word score (optional)
}
// Response: { ok: true, rank: 3 }
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. The session's best rare word is submitted
to the server along with the final score.
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_fallbackFact(word) — letter stats if API fails_boardCleared()_gameOver()_getPlayerName() — read + persist name_submitToServer(payload) — POST to scores.php_fetchGlobalScores() — GET global leaderboard_renderBoards(prefix, mode, score)_initBoardTabs(prefix) — Global/Local tabs_escHtml(s) — XSS-safe text_mpShowLobby() — open multiplayer screen_mpCreateRoom() — POST room.php create_mpJoinRoom() — POST room.php join_mpStartPolling() — 2s opponent poll_mpFinish() — send final score_mpShowResult(opp) — match result screen_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 tournament dictionary ✓ Done | 267,627 words from SOWPODS / Collins Scrabble Words - the international competitive tournament dictionary. 3+ letters, no length cap. | Done |
| ES modules / bundler | Replace IIFE pattern with export/import, add Vite/Rollup |
Medium |
| Global leaderboard ✓ Done | Server-side PHP backend (scores.php) with flat JSON storage. Top 50 global scores + rare words. Player names, rate limiting, Global/Local tab UI. |
Done |
| Head-to-head multiplayer ✓ Done | Room-based 1v1 race mode via room.php. Seeded PRNG (mulberry32) gives both players identical boards. 2-second poll for live opponent score. 30-second countdown when first player finishes. Match result screen. |
Done |
| 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 + Global scoreboard ✓ Done | localStorage top 10 personal scores + server-side top 50 global scores. Global/Local tab switcher on start + game-over screens. | Done |
| Rarest words hall of fame ✓ Done | Both local (top 10) and global (top 50) rarest words. Session's best rare word submitted to server alongside final score. | Done |
Key milestones during MojAbble's initial development.
Notable updates to MojAbble, most recent first.
room.php with flat JSON storage and 2-second polling.scores.php PHP backend with flat JSON storage. Top 50 global high scores + top 50 rarest words, shared across all visitors. Rate-limited, input-validated, no database.mulberry32 deterministic PRNG in engine.js. When a seed is provided (multiplayer), both players generate identical tile layouts.