MojAbble

Systems Documentation

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.

01 Architecture Overview

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.

index.html HTML Shell + CSS + UI Overlay main.js Game Controller State Machine • Input • Loop engine.js Game Logic Core Board • Tiles • Layout • Score render.js Visual Engine Canvas Renderer • Particles • FX words.js + words.txt 267K Dictionary • Async Load • Scoring audio.js Procedural Web Audio API All modules export to window.MojAbble Script load order: words.js → engine.js → render.js → audio.js → main.js
Fig 1. Module architecture and dependency flow

02 File Map

Each file's role, contents, and size. Zero external dependencies.

index.html
~24 KB
HTML shell with full-viewport canvas, UI overlay (score, combo, word area, word stats toast, buttons), start screen with difficulty selector, player name input, global/local leaderboard tabs, multiplayer screens (lobby, waiting, opponent bar, countdown, match result), game-over screen, dictionary badge, and all CSS including animations, difficulty selector styles, word stats panel, and mobile breakpoints.
UI CSS
js/words.js
~2.4 KB
Dictionary loader with offensive-word filter. Fetches 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).
LOGIC DATA
words.txt
~2.6 MB
267,627 words from the SOWPODS / Collins Scrabble Words competitive tournament dictionary. All words 3+ letters with no upper length cap. One word per line, sorted alphabetically. This is the same dictionary used in international competitive Scrabble (World Scrabble Championship, etc.).
DATA
scores.php
~4 KB
Global leaderboard backend. Flat JSON file storage in _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.
BACKEND DATA
room.php
~6 KB
Multiplayer room backend. Flat JSON file storage in _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.
BACKEND DATA
js/engine.js
~17 KB
Core game state: 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.
LOGIC STATE
js/render.js
~18 KB
Canvas 2D renderer with DPR-aware scaling, 3D-effect tile drawing, Particle system, ScorePopup floaters, Effects manager (screen shake, flash, bg pulse, ambient particles), scrolling word-tiling background (offscreen canvas pre-render), and easing functions.
RENDER
js/audio.js
~5.4 KB
Procedural sound via Web Audio API. Seven distinct sounds: tick, select (pitch-rising), deselect, word-success (chord arpeggio), word-fail (buzz), combo (rising tone), celebration (ascending scale). Lazy-inits AudioContext on first interaction.
AUDIO
js/main.js
~24 KB
Top-level 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.
CONTROLLER UI

03 Game State Machine

Three states, driven by the Game.state property in main.js.

MENU Start screen visible PLAYING Game loop active GAMEOVER Stats overlay shown startGame() no moves / board clear Play Again
Fig 2. Game state transitions

04 Data Flow Pipeline

From player click to screen pixels — the full action-response cycle.

Player Input Input Handler (main.js) click / touch / keyboard select tile submit word Board.selectTile() Update word area DOM Audio.playSelect() Effects.sparkleTile() WordValidator.isValid() valid invalid ScoreManager.submitWord() Explode + Shake + Flash Board.removeSelected() Check game-over Shake + Buzz Reset combo Every frame (~60fps): Renderer.render(board, effects, dt)
Fig 3. Player action data flow — select tile (left) and submit word (right)

05 Tile Lifecycle

Every tile goes through a sequence of states from board initialization to removal.

BLOCKED Dimmed, no hover FREE Pulsing glow HOVERED Lifted + highlight SELECTED Orange glow, float REMOVING Scale up + fade neighbors removed mouse enter click word valid timer ≥ 1.0 REMOVED
Fig 4. Tile state lifecycle (solid = forward, dashed = reversible)

Free Tile Detection

A tile is free when two conditions are met:

RuleCheckMeaning
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

06 Board Layout — Classic Pyramid

76 tiles across 4 layers. The Layouts.classic() function returns position arrays; adding new layouts is a single new function.

Layer 0 — 48 tiles (diamond) Layer 1 — 20 tiles Layer 2 — 6 tiles Layer 3 — 2 tiles (peak) Stacked Side View Layer 0 Layer 1 Layer 2 Layer 3 LAYER_DY = 6px
Fig 5. Classic pyramid layout — top-down per layer (left) and stacking cross-section (bottom)

07 Scoring System

Scrabble letter values, length bonuses, and a combo multiplier that rewards consecutive valid words.

Total = (BaseScore + LengthBonus) × ComboMultiplier
BaseScore = sum of letter point values  |  LengthBonus = bonus for words ≥ 4 letters  |  ComboMultiplier = consecutive valid words within 8s

Letter Values

1 pt2 pt3 pt4 pt5 pt8 pt10 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

Length Bonuses

Word Length3456789+
Bonus0+5+15+30+50+80+80 + 40×(n−8)

Combo System

PropertyDetail
Window8 seconds between valid words to maintain combo
MultiplierEqual to combo count (1x, 2x, 3x, ...)
ResetOn invalid word submission or timeout
Board clear bonus+500 flat points

Stuck Mechanics

Three escape valves prevent dead-end boards, each with a strategic cost:

SHUFFLE Redistribute ALL tile letters −50 points Combo resets to 0 Session letter pool preserved Board.shuffleFreeLetters() SWAP Replace selected tile letters −25 points × tile count Combo resets to 0 New letters from weighted pool Board.swapSelectedLetters() GIVE UP End the game voluntarily No point penalty Triggers game-over screen Final score preserved Game._gameOver()
Fig 5b. Three stuck-escape mechanics and their costs

08 Letter Bag Design

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.

Design Principles

The letter bag is the single most important factor in whether a board feels playable. Three constraints guide every distribution:

ConstraintWhy 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.

The Three Bags

Letter Pts Easy Normal Hard English %
A19858.2%
B31221.5%
C32232.8%
D24334.3%
E11211712.7%
F41232.2%
G22332.0%
H43236.1%
I18857.0%
J80120.2%
K50120.8%
L15434.0%
M32232.4%
N16546.7%
O18757.5%
P32231.9%
Q100110.1%
R16546.0%
S16436.3%
T16549.1%
U14432.8%
V40231.0%
W41232.4%
X80120.2%
Y42232.0%
Z100120.1%
Pool size 90 90 84
Vowel % 46% 42% 30%
Vowel floor 40% 35% 25%
Frustration letters 0 5 14

Difficulty Profiles

EASY 18 of 26 letters used No J, K, Q, V, X, Z 46% vowels in pool 40% vowel floor enforced All top-10 digraphs covered: TH HE IN ER AN RE ON AT EN ST Expected playable words: Very High NORMAL All 26 letters present Scrabble-weighted distribution 42% vowels in pool 35% vowel floor enforced ~5 frustration letters per board Occasional J, Q, X, Z force creativity Expected playable words: Moderate HARD All 26, flatter distribution Rare letters at 2-3x normal rate 30% vowels in pool 25% vowel floor enforced ~14 frustration letters per board J:2 K:2 V:3 W:3 X:2 Z:2 Expected playable words: Low - Expert Only
Fig 6. Three difficulty bags and their statistical profiles

Shuffle Mechanics

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.

Sampling & Validation

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.

Why These Numbers

DecisionReasoning
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.

09 Content Moderation

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.

Why Hashing

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.

The FNV-1a Hash

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)
}

Filtering Pipeline

StepDetail
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

Policy Decisions

CategoryDecisionReasoning
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.

Adding New Blocked Words

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.

10 Scoreboards & Persistence

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.

Global Leaderboard (Server)

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.

EndpointMethodPurposeMax 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.

Local Leaderboard (localStorage)

KeyContentsMax 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

Board Tab UI

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.

Data Structures

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 }

Rarity Scoring

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.

Word Stats Toast

After every valid word, a toast panel slides in showing three pieces of information:

ElementContent
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.

11 Effects & Juice Pipeline

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.

EVENT VISUAL AUDIO UI Tile Select sparkleTile() • tile floats up playSelect(pitch++) Letter added to word area Word Valid explodeTile() per tile shake(intensity) • flashScreen() pulseBg() • ScorePopup playWordSuccess() playCombo() if combo>1 Score counter animates Combo display scales up Word Invalid tile.shakeX animation flashScreen(red) playWordFail() Word area shakes + red flash Board Clear celebrate() — 60 rainbow particles shake(12) • flashScreen(gold) playCelebration() +500 bonus popup Shuffle sparkleTile() all free • shake(4) flashScreen(blue) playDeselect() −50 popup • combo reset Swap sparkleTile() selected • shake(3) playDeselect() −25×n popup • combo reset Ambient 15 floating particles (always)
Fig 6. Juice matrix — every event triggers visual, audio, and UI responses simultaneously

Particle System Details

Particle
Square with rotation, gravity (200), friction (0.98), fade by life ratio. Drawn as filled rotated rect.
ScorePopup
Punch-scale-in (easeOutBack ×1.3 then settle), float upward, fade after 1.5s.
Screen Shake
Random (x,y) offset per frame, intensity decays linearly over duration. Canvas translate transform.
Flash Overlay
Full-screen color rect, alpha decays at 4/s. Drawn after all game content (unaffected by shake).
Bg Pulse
Shifts gradient RGB channels, decays at 2/s. Subtle but noticeable on big plays.
Ambient
Max 15 particles floating upward (gravity: −10), spawned at canvas bottom, hue 40–60, 3–7s life.

12 Render Pipeline

Single <canvas>, 2D context, DPR-aware. The game loop runs at ~60fps via requestAnimationFrame.

ctx.save() + ctx.translate(shake.x, shake.y) 1. Draw background gradient (with bgPulse color shift) 2. Draw ambient particles (behind tiles) 3. Draw tiles (sorted: layer asc, row asc, col asc) shadow → 3D side → face → overlays → letter → score 4. Draw effect particles + score popups (in front of tiles) 5. ctx.restore() → Draw flash overlay (unshaken) Canvas: width = innerWidth × DPR | ctx.setTransform(DPR, 0, 0, DPR, 0, 0) for sharp rendering on retina
Fig 7. Per-frame canvas render pipeline

13 Audio System

100% procedural via Web Audio API — no audio files. AudioContext is lazy-initialized on first user interaction to comply with autoplay policies.

SoundOscillatorFrequencyDurationNotes
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

14 Class Reference

All classes live under window.MojAbble. Seven classes, one constants object, one validator.

Game main.js
Top-level controller. Owns all subsystems, runs the game loop, handles input, orchestrates juice.
  • startGame()
  • submitWord()
  • clearSelection()
  • deselectLast()
  • shuffleFreeTiles() — −50pts
  • swapTiles() — −25pts/tile
  • toggleHighlight() — Light on/off
  • giveUp()
  • _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)
Board engine.js
Manages all tiles, their layout positions, selection state, and free-tile detection. The core game-state object.
  • init(layoutName, difficulty) — generates board with difficulty bag
  • centerOnCanvas(w, h)
  • isFree(tile) — the Mahjong rule check
  • selectTile(tile)
  • deselectTile(tile)
  • deselectAll()
  • deselectLast()
  • removeSelected()
  • finalizeRemoval(tile)
  • getCurrentWord()
  • getTileAtPos(sx, sy)
  • getRenderOrder()
  • canFormWord()
  • getRemainingCount()
  • shuffleFreeLetters() — redistribute ALL active tiles' letters
  • swapSelectedLetters() — swap
Tile engine.js
Single tile on the board. Holds grid position, letter, score, selection/removal state, and per-tile animation values.
  • getScreenPos(offsetX, offsetY)
Renderer render.js
Canvas 2D rendering engine. Handles DPR scaling, tile drawing with 3D depth, scrolling word background, and composites all visual layers.
  • 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, ...)
Effects render.js
Manages all screen-level effects: particles, popups, screen shake, flash overlay, background pulse, and ambient particles.
  • 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)
ScoreManager engine.js
Tracks score, combos, and session stats. Provides smooth score display interpolation.
  • reset()
  • submitWord(word, time) → scoring breakdown
  • updateDisplay(dt) — smooth counter
  • checkComboTimeout(time)
AudioManager audio.js
Procedural audio via Web Audio API. Lazy-initializes AudioContext. Seven sound methods, no external files.
  • playTick()
  • playSelect(wordLen)
  • playDeselect()
  • playWordSuccess(score, combo)
  • playWordFail()
  • playCombo(level)
  • playCelebration()
WordValidator words.js
Static object (not a class). Loads 267K-word SOWPODS tournament dictionary from 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) → boolean
  • getWordScore(word) → letter sum
  • getLengthBonus(len) → bonus points
  • getLetterScore(letter) → single letter pts
  • loaded → boolean (getter)

MojAbble.loadDictionary() → Promise (fetches words.txt, populates Set)

15 Path to Scale

Architecture decisions that make future expansion straightforward.

FeatureWhere to changeEffort
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

16 Build Timeline

Key milestones during MojAbble's initial development.

0:00 — Concept "Mahjong + Scrabble, English letters towered in a pyramid layout." Agreed on: pyramid layout, Scrabble scoring, single player, path to scale. 0:03 — Architecture Planned 5 files, namespace pattern, Canvas 2D + Web Audio API, zero dependencies. Decision: plain <script> tags (no bundler) so you can double-click index.html to play. 0:05 — Core Engine Written index.html, words.js (3000+ words), engine.js (Board, Tile, Layout, ScoreManager), render.js (Renderer, Particle, Effects), audio.js (7 procedural sounds), main.js (Game loop). All 5 source files created in parallel batches. ~78KB total. 0:12 — First Playable Game boots, tiles render with 3D effect, click-to-select works, word validation fires, particles explode, score counts, combos chain. Fixed DPR bug + resize handler. 0:18 — Systems Docs Created Full HTML documentation: 12 sections, 7 SVG diagrams, class reference, scaling guide. Docs icon added to game: animated tile with eyes, sparkles, speech bubble on hover. 0:25 — Stuck Mechanics Added Three escape valves: Shuffle (−50 pts), Swap (−25/tile), Give Up. New Board methods, button styling, docs updated with stuck-mechanics diagram. 0:35 — Juice Pass Flip animations for shuffle/swap, selection punch + ring burst, staggered tile removal with fly-up + rotate, red particle spray on invalid, per-tile score popups, elastic easing. Timeline added to docs. Ship it.
Fig 8. Build timeline — initial development milestones

17 Changelog

Notable updates to MojAbble, most recent first.

May 19, 2026
  • NEW Head-to-head multiplayer — Room-based 1v1 race mode. Create a room, share the 4-character code, both players get the identical board (seeded PRNG). Live opponent score overlay, 30-second countdown when first player finishes, match result screen with win/lose/tie. Powered by room.php with flat JSON storage and 2-second polling.
  • NEW Global server-side leaderboardscores.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.
  • NEW Player name system — Name input on start screen, persisted in localStorage, submitted with every server score. Names appear on global leaderboard entries.
  • NEW Global / Local leaderboard tabs — Both start screen and game-over screen have a tab switcher to toggle between global (server) and local (localStorage) scoreboards.
  • NEW Seeded board generationmulberry32 deterministic PRNG in engine.js. When a seed is provided (multiplayer), both players generate identical tile layouts.
  • UPDATE Tournament dictionary confirmed — Dictionary labelled as SOWPODS / Collins Scrabble Words (267,627 words). Badge added to start screen. Docs updated throughout.
  • UPDATE Word length documented — Minimum 3 letters, no maximum cap. Length bonus formula scales for 9+ letter words (+80 + 40×(n-8)).
  • UPDATE Docs: tabbed code blocks — Data structures and code examples now use a tabbed interface for cleaner browsing.
  • FIX Keyboard guard for name input — Enter/Space no longer triggers game start while typing in the player name field.
April 2026 — Initial Build
  • NEW Core game: Mahjong tile mechanics + Scrabble scoring, 68-tile pyramid board, 3 difficulty modes.
  • NEW Canvas 2D renderer with 3D tile depth, particle effects, screen shake, score popups.
  • NEW Procedural audio via Web Audio API (7 sounds, no audio files).
  • NEW Word stats toast with rarity tiers + Free Dictionary API definitions.
  • NEW Stuck mechanics: Shuffle, Swap, Give Up with strategic costs.
  • NEW Offensive word filter (FNV-1a hashed blocklist, 57 entries).
  • NEW Local scoreboards (top 10 scores + top 10 rarest words) via localStorage.
  • NEW Full systems documentation with SVG diagrams.
MojAbble Systems Documentation • May 2026