feat: add Spelling Bee solo word puzzle game

- Implement client-side Phaser scene and pure scoring/rank logic for the honeycomb-style word game.
- Add server-side puzzle generation engine with difficulty bands (easy/normal/hard) and dictionary pre-filtering.
- Register game metadata, frontend scene routing, and backend API endpoint (`/words/spellingbee/start`).
- Implements NYT-style rules: 7 letters (1 required center), 4+ length words, pangram bonus, and tiered rank progression.
This commit is contained in:
Brian Fertig 2026-05-31 21:00:18 -06:00
parent d6d7bc818b
commit 50292b4a69
7 changed files with 682 additions and 1 deletions

View File

@ -0,0 +1,467 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { api } from '../../services/api.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { scoreWord, isPangram, buildTiers, rankFor } from './SpellingBeeLogic.js';
// ── Palette / styling ────────────────────────────────────────────────────────
const FELT = 0x12100a; // deep background
const HONEY = 0xf2c14e; // center hex / accents (honey gold)
const HONEY_DARK = 0xb8860b;
const COMB = 0x2a2418; // outer hex fill
const COMB_EDGE = 0x4a4030;
const INK = '#f2ead8'; // light letter text on combs
const INK_DARK = '#1a1208'; // dark letter on the honey center hex
const TITLE_GOLD = '#f2c14e';
const PAPER = 0x1e1a12; // panels
const DEPTH = { bg: 0, panel: 2, comb: 8, combTxt: 10, word: 12, ui: 20, overlay: 40, overlayUI: 42 };
// ── Honeycomb geometry ─────────────────────────────────────────────────────────
const HIVE_X = 620, HIVE_Y = 600; // center of the flower
const HEX_R = 96; // center-to-vertex radius
const HEX_D = Math.round(HEX_R * Math.sqrt(3)); // flush edge-to-edge distance ≈ 166
export default class SpellingBeeGame extends Phaser.Scene {
constructor() { super('SpellingBeeGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.center = '';
this.outer = [];
this.letters = [];
this.validWords = new Set();
this.pangramSet = new Set();
this.maxScore = 0;
this.tiers = [];
this.current = ''; // word being built
this.found = new Set();
this.score = 0;
this.ended = false;
this.startObjs = [];
this.curLetters = []; // per-char text objects for the current word
this.foundTexts = [];
this.hexes = {}; // letter -> { container, gfx, isCenter }
}
create() {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT)
.setDepth(DEPTH.bg);
this.add.text(40, 36, 'SPELLING BEE', {
fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD,
}).setDepth(DEPTH.ui);
this.events.once('shutdown', () => this.cleanup());
this.showStartPanel();
}
cleanup() {
if (this.keyHandler) {
this.input.keyboard.off('keydown', this.keyHandler);
this.keyHandler = null;
}
}
// ── Start panel (opponent-select is skipped for this solo game) ─────────────
showStartPanel() {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const sheet = this.add.graphics().setDepth(DEPTH.panel);
sheet.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.6);
sheet.fillStyle(PAPER, 1);
sheet.fillRoundedRect(cx - 480, cy - 280, 960, 560, 20);
sheet.lineStyle(3, HONEY_DARK, 1);
sheet.strokeRoundedRect(cx - 480, cy - 280, 960, 560, 20);
this.startObjs.push(sheet);
this.startObjs.push(this.add.text(cx, cy - 190, 'Spelling Bee', {
fontFamily: 'Righteous', fontSize: '88px', color: TITLE_GOLD,
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy - 90,
'Make words of 4+ letters using the given letters.\nEvery word must include the center letter.', {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.mutedHex,
align: 'center', lineSpacing: 8,
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy + 10, 'Choose difficulty', {
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
[['Easy', 'easy'], ['Normal', 'normal'], ['Hard', 'hard']].forEach(([label, id], i) => {
const b = new Button(this, cx - 270 + i * 270, cy + 110, label,
() => this.startPuzzle(id),
{ width: 230, height: 68, fontSize: 28, bgHover: HONEY });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
});
}
async startPuzzle(difficulty) {
let data;
try {
data = await api.get(`/words/spellingbee/start?difficulty=${difficulty}`);
} catch (err) {
console.error('[spellingbee] failed to fetch puzzle:', err);
return;
}
this.startObjs.forEach((o) => o.destroy());
this.startObjs = [];
this.center = data.center;
this.outer = data.outer ?? [];
this.letters = data.letters ?? [];
this.validWords = new Set((data.validWords ?? []).map((w) => w.toUpperCase()));
this.pangramSet = new Set((data.pangrams ?? []).map((w) => w.toUpperCase()));
this.maxScore = data.maxScore ?? 0;
this.tiers = buildTiers(this.maxScore);
this.buildBoard();
}
// ── Board ──────────────────────────────────────────────────────────────────
buildBoard() {
this.buildCurrentWord();
this.buildHive();
this.buildControls();
this.buildFoundPanel();
this.buildRankBar();
this.bindKeyboard();
this.refreshRank();
}
buildCurrentWord() {
// Feedback banner sits just above the current-word row.
this.feedback = this.add.text(HIVE_X, HIVE_Y - 370, '', {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.word).setAlpha(0);
this.curRowY = HIVE_Y - 325;
this.renderCurrentWord();
}
hexPoints(r) {
// Pointy-top hexagon: first vertex at the top, stepping 60°.
const pts = [];
for (let i = 0; i < 6; i++) {
const a = Phaser.Math.DegToRad(60 * i - 90);
pts.push(Math.cos(a) * r, Math.sin(a) * r);
}
return pts;
}
drawHex(g, fill, stroke) {
const p = this.hexPoints(HEX_R);
g.clear();
g.fillStyle(fill, 1);
g.lineStyle(4, stroke, 1);
g.beginPath();
g.moveTo(p[0], p[1]);
for (let i = 2; i < p.length; i += 2) g.lineTo(p[i], p[i + 1]);
g.closePath();
g.fillPath();
g.strokePath();
}
makeHex(letter, x, y, isCenter) {
const cont = this.add.container(x, y).setDepth(DEPTH.comb);
const gfx = this.add.graphics();
this.drawHex(gfx, isCenter ? HONEY : COMB, isCenter ? HONEY_DARK : COMB_EDGE);
const txt = this.add.text(0, 2, letter, {
fontFamily: 'Righteous', fontSize: '58px',
color: isCenter ? INK_DARK : INK,
}).setOrigin(0.5);
cont.add([gfx, txt]);
cont.setInteractive(new Phaser.Geom.Circle(0, 0, HEX_R * 0.9), Phaser.Geom.Circle.Contains);
cont.on('pointerdown', () => {
this.typeLetter(letter);
this.tweens.add({ targets: cont, scale: { from: 0.88, to: 1 }, duration: 160, ease: 'Back.easeOut' });
});
this.hexes[letter] = { container: cont, gfx, txt, isCenter };
return cont;
}
buildHive() {
this.makeHex(this.center, HIVE_X, HIVE_Y, true);
// Outer six placed at edge-midpoint directions (0°, 60°, 120°…) for flush contact.
this.outer.forEach((letter, i) => {
const a = Phaser.Math.DegToRad(60 * i);
const x = HIVE_X + Math.cos(a) * HEX_D;
const y = HIVE_Y + Math.sin(a) * HEX_D;
this.makeHex(letter, x, y, false);
});
}
buildControls() {
const y = HIVE_Y + 300;
this.delBtn = new Button(this, HIVE_X - 230, y, 'Delete',
() => this.deleteLetter(), { width: 180, height: 64, fontSize: 26 });
this.shuffleBtn = new Button(this, HIVE_X, y, 'Shuffle',
() => this.shuffleHive(), { width: 180, height: 64, fontSize: 26 });
this.enterBtn = new Button(this, HIVE_X + 230, y, 'Enter',
() => this.submitWord(), { width: 180, height: 64, fontSize: 26, bgHover: HONEY });
[this.delBtn, this.shuffleBtn, this.enterBtn].forEach((b) => b.setDepth(DEPTH.ui));
const leaveBtn = new Button(this, GAME_WIDTH - 90, GAME_HEIGHT - 44, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 140, height: 52, fontSize: 20 });
leaveBtn.setDepth(DEPTH.ui);
}
// ── Current word rendering (center letter highlighted) ──────────────────────
renderCurrentWord() {
this.curLetters.forEach((t) => t.destroy());
this.curLetters = [];
if (!this.current) {
this.curLetters.push(this.add.text(HIVE_X, this.curRowY, 'Type a word…', {
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.word));
return;
}
const chars = this.current.split('');
const widths = chars.map((c) => (c === 'M' || c === 'W' ? 50 : 40));
const total = widths.reduce((a, b) => a + b, 0);
let x = HIVE_X - total / 2;
chars.forEach((c, i) => {
const inPuzzle = this.letters.includes(c);
const color = c === this.center ? TITLE_GOLD : (inPuzzle ? INK : COLORS.dangerHex);
const t = this.add.text(x + widths[i] / 2, this.curRowY, c, {
fontFamily: 'Righteous', fontSize: '56px', color,
}).setOrigin(0.5).setDepth(DEPTH.word);
this.curLetters.push(t);
x += widths[i];
});
}
// ── Input ──────────────────────────────────────────────────────────────────
bindKeyboard() {
this.keyHandler = (event) => {
if (this.ended) return;
const key = event.key;
if (key === 'Enter') { this.submitWord(); }
else if (key === 'Backspace') { event.preventDefault(); this.deleteLetter(); }
else if (/^[a-zA-Z]$/.test(key)) { this.typeLetter(key.toUpperCase()); }
};
this.input.keyboard.on('keydown', this.keyHandler);
}
typeLetter(letter) {
if (this.ended) return;
this.current += letter.toUpperCase();
playSound(this, SFX.PIECE_CLICK);
this.renderCurrentWord();
}
deleteLetter() {
if (this.ended || !this.current) return;
this.current = this.current.slice(0, -1);
this.renderCurrentWord();
}
shuffleHive() {
if (this.ended) return;
Phaser.Utils.Array.Shuffle(this.outer);
// Reassign the shuffled letters onto the six fixed outer-hex containers.
const centerEntry = Object.values(this.hexes).find((h) => h.isCenter);
const outerHexes = Object.values(this.hexes).filter((h) => !h.isCenter);
this.hexes = { [this.center]: centerEntry };
outerHexes.forEach((h, i) => {
const letter = this.outer[i];
h.txt.setText(letter);
h.container.removeAllListeners('pointerdown');
h.container.on('pointerdown', () => {
this.typeLetter(letter);
this.tweens.add({ targets: h.container, scale: { from: 0.88, to: 1 }, duration: 160, ease: 'Back.easeOut' });
});
this.hexes[letter] = h;
});
playSound(this, SFX.CARD_SHUFFLE);
}
// ── Submission ───────────────────────────────────────────────────────────────
submitWord() {
if (this.ended) return;
const w = this.current.toUpperCase();
this.clearCurrent();
if (w.length < 4) { return this.flash('Too short', COLORS.dangerHex); }
if (!w.includes(this.center)) { return this.flash('Missing center letter', COLORS.dangerHex); }
if ([...w].some((c) => !this.letters.includes(c))) { return this.flash('Bad letters', COLORS.dangerHex); }
if (this.found.has(w)) { return this.flash('Already found', COLORS.mutedHex); }
if (!this.validWords.has(w)) { return this.flash('Not in word list', COLORS.dangerHex); }
this.found.add(w);
const pts = scoreWord(w);
this.score += pts;
this.addFoundWord(w);
playSound(this, SFX.PENCIL_WRITE);
if (isPangram(w)) {
this.flash(`Pangram! +${pts}`, COLORS.goldHex);
this.pulseHive();
} else {
this.flash(`+${pts} ${w}`, '#7ad17a');
}
this.refreshRank();
}
clearCurrent() {
this.current = '';
this.renderCurrentWord();
}
flash(msg, colorHex) {
this.feedback.setText(msg).setColor(colorHex).setAlpha(1).setScale(1.2);
this.tweens.killTweensOf(this.feedback);
this.tweens.add({ targets: this.feedback, scale: 1, duration: 160, ease: 'Back.easeOut' });
this.tweens.add({ targets: this.feedback, alpha: 0, delay: 1000, duration: 400 });
}
pulseHive() {
Object.values(this.hexes).forEach((h) => {
this.tweens.add({ targets: h.container, scale: { from: 1, to: 1.12 }, duration: 180, yoyo: true, ease: 'Sine.easeInOut' });
});
}
// ── Found-words panel ──────────────────────────────────────────────────────
buildFoundPanel() {
this.FP = { x: 1240, y: 150, w: 620, h: 800, perCol: 18 };
const g = this.add.graphics().setDepth(DEPTH.panel);
g.fillStyle(PAPER, 1);
g.fillRoundedRect(this.FP.x, this.FP.y, this.FP.w, this.FP.h, 18);
g.lineStyle(3, COMB_EDGE, 1);
g.strokeRoundedRect(this.FP.x, this.FP.y, this.FP.w, this.FP.h, 18);
this.foundHeader = this.add.text(this.FP.x + this.FP.w / 2, this.FP.y + 44,
'Found: 0', {
fontFamily: 'Righteous', fontSize: '38px', color: TITLE_GOLD,
}).setOrigin(0.5).setDepth(DEPTH.combTxt);
}
addFoundWord(word) {
const idx = this.foundTexts.length;
const col = Math.floor(idx / this.FP.perCol);
const row = idx % this.FP.perCol;
const x = this.FP.x + 40 + col * ((this.FP.w - 80) / 3);
const y = this.FP.y + 110 + row * 36;
const pangram = isPangram(word);
const t = this.add.text(x, y, word, {
fontFamily: '"Julius Sans One"', fontSize: '26px',
color: pangram ? TITLE_GOLD : INK,
fontStyle: pangram ? 'bold' : 'normal',
}).setOrigin(0, 0.5).setDepth(DEPTH.combTxt);
t.setScale(1.4).setAlpha(0);
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
this.foundTexts.push(t);
this.foundHeader.setText(`Found: ${this.found.size}`);
}
// ── Rank bar ───────────────────────────────────────────────────────────────
buildRankBar() {
this.RB = { x: 360, y: 150, w: 760 };
this.rankLabel = this.add.text(this.RB.x, this.RB.y - 50, '', {
fontFamily: 'Righteous', fontSize: '40px', color: TITLE_GOLD,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
this.scoreLabel = this.add.text(this.RB.x + this.RB.w, this.RB.y - 50, '', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(DEPTH.ui);
this.rankGfx = this.add.graphics().setDepth(DEPTH.ui);
this.rankDots = this.tiers.map((tier, i) => {
const x = this.RB.x + (this.RB.w * i) / (this.tiers.length - 1);
return { x, tier };
});
}
refreshRank() {
const { current, isGenius } = rankFor(this.score, this.maxScore);
this.rankLabel.setText(current.name);
this.scoreLabel.setText(`${this.score}`);
const g = this.rankGfx;
g.clear();
// Track line.
g.lineStyle(4, COMB_EDGE, 1);
g.lineBetween(this.RB.x, this.RB.y, this.RB.x + this.RB.w, this.RB.y);
// Tier dots — filled up through the current rank.
let reachedIdx = 0;
this.rankDots.forEach((d, i) => { if (this.score >= d.tier.threshold) reachedIdx = i; });
this.rankDots.forEach((d, i) => {
const reached = i <= reachedIdx;
const isCur = i === reachedIdx;
g.fillStyle(reached ? HONEY : COMB, 1);
g.lineStyle(3, reached ? HONEY_DARK : COMB_EDGE, 1);
g.fillCircle(d.x, this.RB.y, isCur ? 16 : 10);
g.strokeCircle(d.x, this.RB.y, isCur ? 16 : 10);
});
if (isGenius && !this.ended) this.handleGenius();
}
// ── Genius (win) ─────────────────────────────────────────────────────────────
handleGenius() {
this.ended = true;
this.cleanup();
playSound(this, SFX.CASINO_WIN);
this.recordResult('win');
this.time.delayedCall(400, () => this.showWin());
}
showWin() {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setDepth(DEPTH.overlay);
const panel = this.add.graphics().setDepth(DEPTH.overlay + 1);
panel.fillStyle(PAPER, 1);
panel.fillRoundedRect(cx - 400, cy - 220, 800, 440, 20);
panel.lineStyle(3, HONEY_DARK, 1);
panel.strokeRoundedRect(cx - 400, cy - 220, 800, 440, 20);
this.add.text(cx, cy - 120, 'Genius!', {
fontFamily: 'Righteous', fontSize: '96px', color: TITLE_GOLD,
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
this.add.text(cx, cy - 10,
`${this.found.size} words · ${this.score} points`, {
fontFamily: '"Julius Sans One"', fontSize: '36px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
new Button(this, cx - 160, cy + 120, 'New Puzzle',
() => this.scene.restart(this._initData),
{ width: 280, height: 64, fontSize: 26, bgHover: HONEY }).setDepth(DEPTH.overlayUI);
new Button(this, cx + 160, cy + 120, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 280, height: 64, fontSize: 26 }).setDepth(DEPTH.overlayUI);
}
async recordResult(result) {
try {
await api.post('/history/single-player', {
slug: 'spellingbee', score: this.score, opponentScores: [], result,
});
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,55 @@
// Spelling Bee logic: scoring and rank tiers. Pure — no Phaser.
// Rank tiers as a fraction of the puzzle's maximum possible score (NYT scale).
// Queen Bee (100%) is intentionally omitted — Genius is the achievable goal.
const TIER_TABLE = [
{ name: 'Beginner', pct: 0.00 },
{ name: 'Good Start', pct: 0.02 },
{ name: 'Moving Up', pct: 0.05 },
{ name: 'Good', pct: 0.08 },
{ name: 'Solid', pct: 0.15 },
{ name: 'Nice', pct: 0.25 },
{ name: 'Great', pct: 0.40 },
{ name: 'Amazing', pct: 0.50 },
{ name: 'Genius', pct: 0.70 },
];
// Number of distinct letters in a word.
function distinctCount(word) {
return new Set(word.toUpperCase()).size;
}
// A pangram uses all 7 distinct puzzle letters.
export function isPangram(word) {
return distinctCount(word) === 7;
}
// 4-letter word = 1 pt; longer words = 1 pt per letter; pangram earns +7.
export function scoreWord(word) {
const w = String(word);
let s = w.length === 4 ? 1 : w.length;
if (isPangram(w)) s += 7;
return s;
}
// Concrete tier thresholds (rounded score needed to reach each tier).
export function buildTiers(maxScore) {
return TIER_TABLE.map((t) => ({
name: t.name,
threshold: Math.round(t.pct * maxScore),
}));
}
// Current tier for a score plus the next tier (null once Genius is reached).
export function rankFor(score, maxScore) {
const tiers = buildTiers(maxScore);
let currentIdx = 0;
for (let i = 0; i < tiers.length; i++) {
if (score >= tiers[i].threshold) currentIdx = i;
}
return {
current: tiers[currentIdx],
next: tiers[currentIdx + 1] ?? null,
isGenius: currentIdx === tiers.length - 1,
};
}

View File

@ -47,6 +47,7 @@ import Connect4Game from './games/connect4/Connect4Game.js';
import BoggleGame from './games/boggle/BoggleGame.js';
import OldMaidGame from './games/oldmaid/OldMaidGame.js';
import BlokusGame from './games/blokus/BlokusGame.js';
import SpellingBeeGame from './games/spellingbee/SpellingBeeGame.js';
const config = {
type: Phaser.AUTO,
@ -107,6 +108,7 @@ const config = {
BoggleGame,
OldMaidGame,
BlokusGame,
SpellingBeeGame,
],
};

View File

@ -21,7 +21,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -62,3 +62,4 @@ registerGame({ slug: 'connect4', name: 'Connect 4', category: 'ta
registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 34 });
registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 35 });
registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 36 });
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });

View File

@ -0,0 +1,140 @@
// Spelling Bee engine: NYT-style honeycomb puzzle generation.
// Pure logic — no Express. Initialized once at server start from the ENABLE list.
//
// A puzzle is 7 distinct letters (one "center" required in every word) built
// around a pangram (a word using all 7 letters). Valid words are length >= 4,
// contain the center letter, and use only the 7 puzzle letters (repeats allowed).
// The letter S is excluded (NYT convention) to avoid trivial plurals.
const MIN_LEN = 4;
const A_CODE = 'A'.charCodeAt(0);
const S_BIT = 1 << ('S'.charCodeAt(0) - A_CODE);
// Difficulty target bands for the valid-word count (smaller pool = harder).
const BANDS = {
easy: { min: 40, max: 90 },
normal: { min: 20, max: 45 },
hard: { min: 10, max: 25 },
};
let words = []; // [{ w, mask }] — candidate words (len>=4, no S, <=7 distinct)
let pangramMasks = []; // distinct 7-letter masks that have at least one pangram
// ── Helpers ───────────────────────────────────────────────────────────────────
function wordMask(w) {
let mask = 0;
for (let i = 0; i < w.length; i++) {
mask |= 1 << (w.charCodeAt(i) - A_CODE);
}
return mask;
}
function popcount(n) {
let c = 0;
while (n) { n &= n - 1; c++; }
return c;
}
function maskLetters(mask) {
const letters = [];
for (let i = 0; i < 26; i++) {
if (mask & (1 << i)) letters.push(String.fromCharCode(A_CODE + i));
}
return letters;
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Scoring (mirrors the client SpellingBeeLogic): 4 letters = 1 pt; longer words
// = 1 pt per letter; pangram (uses all 7 distinct letters) earns a +7 bonus.
function scoreWord(w, isPangram) {
let s = w.length === 4 ? 1 : w.length;
if (isPangram) s += 7;
return s;
}
// ── Initialization ──────────────────────────────────────────────────────────────
export function initSpellingBeeDictionary(rawWords) {
words = [];
const seenPangram = new Set();
pangramMasks = [];
for (const raw of rawWords) {
const w = String(raw).toUpperCase();
if (w.length < MIN_LEN || !/^[A-Z]+$/.test(w)) continue;
const mask = wordMask(w);
if (mask & S_BIT) continue; // S is excluded from puzzles entirely
const distinct = popcount(mask);
if (distinct > 7) continue; // can never fit a 7-letter puzzle
words.push({ w, mask });
if (distinct === 7 && !seenPangram.has(mask)) {
seenPangram.add(mask);
pangramMasks.push(mask);
}
}
return { words: words.length, pangrams: pangramMasks.length };
}
// ── Puzzle generation ───────────────────────────────────────────────────────────
function buildPuzzle(puzzleMask, centerBit) {
const validWords = [];
const pangrams = [];
let maxScore = 0;
const notMask = ~puzzleMask;
for (const { w, mask } of words) {
if ((mask & notMask) !== 0) continue; // uses a letter outside the puzzle
if ((mask & centerBit) === 0) continue; // missing the center letter
const isPangram = mask === puzzleMask; // a <=7-distinct word filling all 7
validWords.push(w);
if (isPangram) pangrams.push(w);
maxScore += scoreWord(w, isPangram);
}
return { validWords, pangrams, maxScore };
}
export function generatePuzzle(difficulty = 'normal') {
const band = BANDS[difficulty] ?? BANDS.normal;
if (!pangramMasks.length) {
return { center: '', outer: [], letters: [], validWords: [], pangrams: [], maxScore: 0 };
}
let best = null;
for (let attempt = 0; attempt < 40; attempt++) {
const puzzleMask = pangramMasks[Math.floor(Math.random() * pangramMasks.length)];
const letters = maskLetters(puzzleMask);
const center = letters[Math.floor(Math.random() * letters.length)];
const centerBit = 1 << (center.charCodeAt(0) - A_CODE);
const { validWords, pangrams, maxScore } = buildPuzzle(puzzleMask, centerBit);
const candidate = { center, letters, validWords, pangrams, maxScore };
// Track the best-so-far by closeness to the band midpoint, as a fallback.
if (!best || Math.abs(validWords.length - (band.min + band.max) / 2)
< Math.abs(best.validWords.length - (band.min + band.max) / 2)) {
best = candidate;
}
if (validWords.length >= band.min && validWords.length <= band.max) {
best = candidate;
break;
}
}
const outer = shuffle(best.letters.filter((l) => l !== best.center));
return {
center: best.center,
outer,
letters: best.letters,
validWords: best.validWords,
pangrams: best.pangrams,
maxScore: best.maxScore,
};
}

View File

@ -18,6 +18,7 @@ import {
} from './wordSearchEngine.js';
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
@ -150,6 +151,10 @@ function loadWordLists() {
initBoggleDictionary(boggleWords);
console.log(`[words] loaded ${boggleWords.length} Boggle words (316 letters)`);
// Spelling Bee dictionary: ENABLE words of length 4+, no S, ≤7 distinct letters.
const beeStats = initSpellingBeeDictionary(allWords);
console.log(`[words] loaded ${beeStats.words} Spelling Bee words (${beeStats.pangrams} pangram sets)`);
// Answer pool: prefer curated common words that are also in ENABLE;
// supplement with additional ENABLE words up to a healthy pool size.
const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));
@ -192,6 +197,17 @@ router.get('/boggle/start', (_req, res) => {
res.json({ board, solutions: solveBoard(board) });
});
// ── Spelling Bee ────────────────────────────────────────────────────────────────
// GET /api/words/spellingbee/start?difficulty=easy|normal|hard
// Builds a honeycomb puzzle (7 letters incl. a required center) and returns it
// with the full valid-word list so the client validates locally.
router.get('/spellingbee/start', (req, res) => {
const VALID = ['easy', 'normal', 'hard'];
const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'normal';
res.json(spellingBeeGenerate(difficulty));
});
// ── Scrabble ──────────────────────────────────────────────────────────────────
// POST /api/words/scrabble/validate { words: string[] }