MojAbble

Systems Documentation

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.

01 Architecture Overview

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.

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 80K 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, game-over screen, 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 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
~608 KB (195 KB gzipped)
80,272 English words (3–8 letters) from the enable1 public-domain dictionary. One word per line, sorted alphabetically. Served gzipped automatically by any static host.
DATA
js/engine.js
~16 KB
Core game state: 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.
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
~20 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), 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');
}

Filtering Pipeline

StepDetail
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

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

localStorage Keys

KeyContentsMax Entries
mojabble_scores Top 10 scores, sorted descending by score 10
mojabble_rare Top 10 rarest words, sorted descending by rarity score 10

Data Structures

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
}

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.

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
  • _fetchWordFact(word) — Free Dictionary API
  • _fallbackFact(word) — letter stats if API fails
  • _boardCleared()
  • _gameOver()
  • _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 80K-word 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 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

16 Build Timeline

MojAbble was built from scratch in a single pairing session — human + Claude, ~35 minutes start to finish.

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 — single session, human + Claude pairing
Built in ~35 minutes
Human + Claude — concept to playable game with full docs, single session.
MojAbble Systems Documentation • April 2026