feat: add Boggle word game with AI opponents and board solver

- Implement full Boggle frontend using Phaser, featuring a wooden tray UI, player word sheet, opponent cards, and an animated hourglass timer.
- Add AI scheduling system that simulates opponent word finds based on configurable skill levels and word length/rarity weighting.
- Introduce pure game logic for adjacency validation, path checking, and classic scoring with duplicate-word resolution.
- Add backend board generation and exhaustive solver using a dictionary trie, exposing `/api/words/boggle/start` for board setup.
- Register Boggle in the game registry, scene router, and opponent selection UI.
- Include tutorial assets for Parchisi.
This commit is contained in:
Brian Fertig 2026-05-31 15:17:00 -06:00
parent f5e74d7970
commit 63df39442c
11 changed files with 1098 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

View File

@ -0,0 +1,60 @@
// Boggle AI — no Phaser. Given the board solution set, produce a time-ordered
// schedule of words an opponent "finds" during the round. Higher skill finds more
// words and weights longer/rarer words more heavily.
// Per-skill profile:
// fraction — share of the solution set the AI ultimately finds
// lengthBias — exponent applied to word length when weighting picks
// (higher => strongly prefers longer words)
const SKILL = {
1: { fraction: 0.02, lengthBias: 0.4 },
2: { fraction: 0.03, lengthBias: 0.8 },
3: { fraction: 0.06, lengthBias: 1.3 },
4: { fraction: 0.10, lengthBias: 1.9 },
5: { fraction: 0.15, lengthBias: 2.6 },
};
function profile(skill) {
return SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3];
}
// Weighted sampling without replacement.
function pickWeighted(pool, weights, count) {
const chosen = [];
const idx = pool.map((_, i) => i);
const w = [...weights];
for (let k = 0; k < count && idx.length; k++) {
let total = 0;
for (const i of idx) total += w[i];
let roll = Math.random() * total;
let pickPos = 0;
for (let p = 0; p < idx.length; p++) {
roll -= w[idx[p]];
if (roll <= 0) { pickPos = p; break; }
}
chosen.push(pool[idx[pickPos]]);
idx.splice(pickPos, 1);
}
return chosen;
}
// solutions: [{ word, path }] (path unused by the AI but kept for parity)
// Returns: [{ atMs, word }] sorted ascending by atMs.
export function scheduleFinds(solutions, skill, durationMs) {
if (!solutions?.length) return [];
const { fraction, lengthBias } = profile(skill);
const words = solutions.map((s) => s.word);
const weights = words.map((w) => Math.pow(w.length, lengthBias));
const target = Math.max(1, Math.round(words.length * fraction));
const picks = pickWeighted(words, weights, target);
// Spread finds across the middle ~90% of the round with jitter so the count
// ticks up naturally rather than all at once.
const lead = durationMs * 0.05;
const span = durationMs * 0.9;
return picks
.map((word) => ({ word, atMs: lead + Math.random() * span }))
.sort((a, b) => a.atMs - b.atMs);
}

View File

@ -0,0 +1,839 @@
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, areAdjacent, resolveRound } from './BoggleLogic.js';
import { scheduleFinds } from './BoggleAI.js';
// ── Palette ───────────────────────────────────────────────────────────────────
const FELT = 0x14241c; // dark green table
const TRAY_WOOD = 0x4a2f1a;
const TRAY_EDGE = 0x2c1a0d;
const CUBE_FACE = 0xf2e4c2; // ivory cube
const CUBE_TOP = 0xfff6e0;
const CUBE_LETTER = '#2a1c0c';
const SELECT_GOLD = 0xf2c14e;
const PAPER = 0xf6efdd;
const PAPER_EDGE = 0xe6dcc0;
const INK = '#3a2a18';
const TITLE_RED = '#b03a2e';
const SAND = 0xe0a23a;
const GLASS = 0xcfe6ee;
const D = { bg: 0, tray: 4, cube: 8, line: 11, hud: 14, sheet: 16, sheetTxt: 17, ui: 22, overlay: 40, overlayUI: 42 };
// ── Tray geometry ───────────────────────────────────────────────────────────────
const TRAY_CX = 720, TRAY_CY = 600;
const CUBE = 140, GAP = 16;
const SPAN = 4 * CUBE + 3 * GAP; // 608
const ORIGIN_X = TRAY_CX - SPAN / 2;
const ORIGIN_Y = TRAY_CY - SPAN / 2;
// ── Player sheet geometry ─────────────────────────────────────────────────────
const PS_X = 1120, PS_Y = 110, PS_W = 760, PS_H = 870;
const PER_COL = 24;
// ── Opponent cards (left column) ──────────────────────────────────────────────
const OPP_X = 40, OPP_Y = 300, OPP_W = 340, OPP_H = 150, OPP_GAP = 24;
// ── Hourglass ─────────────────────────────────────────────────────────────────
const HX = 720, HY = 150, HW = 64, HH = 86;
const TIMER_OPTIONS = [
{ label: '90 sec', ms: 90000 },
{ label: '2 min', ms: 120000 },
{ label: '3 min', ms: 180000 },
];
export default class BoggleGame extends Phaser.Scene {
constructor() { super('BoggleGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
const opps = (data.opponents ?? []).slice(0, 3);
this.players = [
{ id: 'player', name: 'You', isHuman: true },
...opps.map((o) => ({
id: o.id, name: o.name ?? 'Rival', skill: o.skill ?? 3,
spriteIndex: o.spriteIndex ?? 0, isHuman: false,
})),
];
this.round = 1;
this.totalRounds = 3;
this.timerMs = 90000; // default; chosen on the first pre-round panel
this.matchTotal = {};
this.players.forEach((p) => { this.matchTotal[p.id] = 0; });
// per-round state (reset in beginRound)
this.playing = false;
this.path = [];
this.found = {};
this.roundObjs = [];
this.preObjs = [];
this.scoreObjs = [];
this.timerTween = null;
this.aiSchedules = [];
}
create() {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.bg);
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
this.add.text(40, 36, 'BOGGLE', {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex,
}).setDepth(D.ui);
this.events.once('shutdown', () => this.cleanup());
this.showPreRound();
}
cleanup() {
this.timerTween?.remove();
this.timerTween = null;
}
// ── Pre-round panel ───────────────────────────────────────────────────────────
showPreRound() {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const first = this.round === 1;
const panelH = first ? 560 : Math.max(520, 440 + this.players.length * 46);
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(PAPER, 1);
panel.fillRoundedRect(cx - 460, cy - panelH / 2, 920, panelH, 22);
panel.lineStyle(3, PAPER_EDGE, 1);
panel.strokeRoundedRect(cx - 460, cy - panelH / 2, 920, panelH, 22);
panel.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 12, 0.6);
this.preObjs.push(panel);
const top = cy - panelH / 2;
this.preObjs.push(this.add.text(cx, top + 70, 'Boggle', {
fontFamily: 'YummyCupcakes', fontSize: '88px', color: TITLE_RED,
}).setOrigin(0.5).setDepth(D.overlayUI));
this.preObjs.push(this.add.text(cx, top + 150, `Round ${this.round} of ${this.totalRounds}`, {
fontFamily: 'YummyCupcakes', fontSize: '42px', color: INK,
}).setOrigin(0.5).setDepth(D.overlayUI));
if (first) {
this.preObjs.push(this.add.text(cx, top + 230, 'Choose round length', {
fontFamily: 'YummyCupcakes', fontSize: '36px', color: INK,
}).setOrigin(0.5).setDepth(D.overlayUI));
this.timerChips = [];
TIMER_OPTIONS.forEach((opt, i) => {
const x = cx - 270 + i * 270;
const y = top + 310;
const bg = this.add.graphics().setDepth(D.overlayUI);
const txt = this.add.text(x, y, opt.label, {
fontFamily: 'YummyCupcakes', fontSize: '34px', color: INK,
}).setOrigin(0.5).setDepth(D.overlayUI + 1);
const hit = this.add.rectangle(x, y, 230, 64, 0xffffff, 0.001)
.setDepth(D.overlayUI + 1).setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => { this.timerMs = opt.ms; this.refreshTimerChips(); playSound(this, SFX.PIECE_CLICK); });
this.timerChips.push({ ms: opt.ms, x, y, bg, txt });
this.preObjs.push(bg, txt, hit);
});
this.refreshTimerChips();
} else {
// Standings so far
const standings = [...this.players].sort((a, b) => this.matchTotal[b.id] - this.matchTotal[a.id]);
standings.forEach((p, i) => {
this.preObjs.push(this.add.text(cx, top + 220 + i * 46,
`${p.name}: ${this.matchTotal[p.id]}`, {
fontFamily: 'YummyCupcakes', fontSize: '34px',
color: p.isHuman ? TITLE_RED : INK,
}).setOrigin(0.5).setDepth(D.overlayUI));
});
}
const begin = new Button(this, cx, top + panelH - 130, 'Begin Round',
() => this.beginRound(), { width: 320, height: 70, fontSize: 30 });
begin.setDepth(D.overlayUI);
this.preObjs.push(begin);
const leave = new Button(this, cx, top + panelH - 50, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 });
leave.setDepth(D.overlayUI);
this.preObjs.push(leave);
}
refreshTimerChips() {
this.timerChips.forEach((c) => {
const sel = c.ms === this.timerMs;
c.bg.clear();
if (sel) { c.bg.fillStyle(0xb03a2e, 0.9); c.bg.fillRoundedRect(c.x - 115, c.y - 32, 230, 64, 12); }
c.bg.lineStyle(2, 0x9a7a4a, 0.9);
c.bg.strokeRoundedRect(c.x - 115, c.y - 32, 230, 64, 12);
c.txt.setColor(sel ? '#fff6e6' : INK);
});
}
destroyPre() {
this.preObjs.forEach((o) => o.destroy());
this.preObjs = [];
this.timerChips = null;
}
// ── Begin a round ─────────────────────────────────────────────────────────────
async beginRound() {
this.destroyPre();
playSound(this, SFX.PIECE_CLICK);
let data;
try {
data = await api.get('/words/boggle/start');
} catch (err) {
console.error('[boggle] failed to fetch board:', err);
this.showPreRound();
return;
}
this.board = data.board; // 4×4 face tokens
this.solutions = data.solutions ?? [];
this.validWords = new Set(this.solutions.map((s) => s.word));
// reset per-round state
this.playing = false;
this.path = [];
this.found = {};
this.players.forEach((p) => { this.found[p.id] = new Set(); });
this.playerSheetWords = [];
this.buildTray();
this.buildPlayerSheet();
this.buildOpponentCards();
this.buildWordBanner();
this.buildControls();
this.buildHourglass();
this.shuffleReveal();
}
destroyRound() {
this.roundObjs.forEach((o) => o.destroy());
this.roundObjs = [];
}
// ── Tray + cubes ──────────────────────────────────────────────────────────────
buildTray() {
const g = this.add.graphics().setDepth(D.tray);
g.fillStyle(TRAY_EDGE, 1);
g.fillRoundedRect(ORIGIN_X - 34, ORIGIN_Y - 34, SPAN + 68, SPAN + 68, 28);
g.fillStyle(TRAY_WOOD, 1);
g.fillRoundedRect(ORIGIN_X - 22, ORIGIN_Y - 22, SPAN + 44, SPAN + 44, 22);
g.lineStyle(3, TRAY_EDGE, 1);
g.strokeRoundedRect(ORIGIN_X - 22, ORIGIN_Y - 22, SPAN + 44, SPAN + 44, 22);
g.postFX.addShadow(0, 8, 0.02, 1.2, 0x000000, 14, 0.7);
this.roundObjs.push(g);
this.cubes = [];
for (let r = 0; r < 4; r++) {
this.cubes[r] = [];
for (let c = 0; c < 4; c++) {
const { x, y } = this.cubeCenter(r, c);
const cont = this.add.container(x, y).setDepth(D.cube);
const face = this.add.graphics();
this.drawCubeFace(face, false);
const letter = this.add.text(0, 4, '', {
fontFamily: 'Righteous', fontSize: '64px', color: CUBE_LETTER,
}).setOrigin(0.5);
cont.add([face, letter]);
cont.setSize(CUBE, CUBE);
cont.setInteractive({ useHandCursor: true });
cont.on('pointerdown', () => this.onCubeClick(r, c));
cont._face = face;
cont._letter = letter;
this.cubes[r][c] = cont;
this.roundObjs.push(cont);
}
}
this.selGfx = this.add.graphics().setDepth(D.cube - 1);
this.lineGfx = this.add.graphics().setDepth(D.line);
this.roundObjs.push(this.selGfx, this.lineGfx);
}
drawCubeFace(g, selected) {
g.clear();
const s = CUBE / 2;
g.fillStyle(TRAY_EDGE, 0.5);
g.fillRoundedRect(-s + 3, -s + 5, CUBE, CUBE, 16); // drop
g.fillStyle(selected ? SELECT_GOLD : CUBE_FACE, 1);
g.fillRoundedRect(-s, -s, CUBE, CUBE, 16);
g.fillStyle(CUBE_TOP, selected ? 0.5 : 0.7);
g.fillRoundedRect(-s + 8, -s + 8, CUBE - 16, CUBE * 0.4, 12); // top sheen
g.lineStyle(3, selected ? 0xb8860b : TRAY_EDGE, selected ? 1 : 0.5);
g.strokeRoundedRect(-s, -s, CUBE, CUBE, 16);
}
cubeCenter(r, c) {
return { x: ORIGIN_X + c * (CUBE + GAP) + CUBE / 2, y: ORIGIN_Y + r * (CUBE + GAP) + CUBE / 2 };
}
// ── Shuffle + reveal animation ──────────────────────────────────────────────────
shuffleReveal() {
playSound(this, SFX.CARD_SHUFFLE);
const rand = () => String.fromCharCode(65 + Math.floor(Math.random() * 26));
// initial pop-in
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
const cube = this.cubes[r][c];
cube.setScale(0.2); cube.setAlpha(0.4);
this.tweens.add({ targets: cube, scale: 1, alpha: 1, duration: 260, delay: (r * 4 + c) * 12, ease: 'Back.easeOut' });
}
// spin through random letters
const cycle = this.time.addEvent({
delay: 60, repeat: 13,
callback: () => {
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
this.cubes[r][c]._letter.setText(rand());
}
},
});
this.time.delayedCall(60 * 14 + 200, () => this.revealLetters());
}
revealLetters() {
let last = 0;
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
const cube = this.cubes[r][c];
const delay = (r * 4 + c) * 45;
last = Math.max(last, delay);
this.time.delayedCall(delay, () => {
this.tweens.add({
targets: cube, scaleX: 0, duration: 90, yoyo: true, ease: 'Quad.easeIn',
onYoyo: () => { cube._letter.setText(this.board[r][c]); },
});
playSound(this, SFX.PIECE_CLICK);
});
}
this.time.delayedCall(last + 260, () => this.startTimer());
}
// ── Hourglass + timer ───────────────────────────────────────────────────────────
buildHourglass() {
this.hg = this.add.container(HX, HY).setDepth(D.hud);
this.hgFrame = this.add.graphics();
this.hgSand = this.add.graphics();
this.hg.add([this.hgSand, this.hgFrame]);
this.drawHourglassFrame();
this.drawSand(1);
this.roundObjs.push(this.hg);
this.timerText = this.add.text(HX, HY + HH + 30, this.fmtTime(this.timerMs), {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hud);
this.roundObjs.push(this.timerText);
}
drawHourglassFrame() {
const g = this.hgFrame;
g.clear();
g.fillStyle(0x6b4a2a, 1);
g.fillRoundedRect(-HW - 10, -HH - 16, (HW + 10) * 2, 14, 5); // top cap
g.fillRoundedRect(-HW - 10, HH + 2, (HW + 10) * 2, 14, 5); // bottom cap
g.lineStyle(5, 0x6b4a2a, 1);
g.beginPath(); g.moveTo(-HW, -HH); g.lineTo(HW, -HH); g.lineTo(0, 0); g.lineTo(-HW, -HH); g.strokePath();
g.beginPath(); g.moveTo(-HW, HH); g.lineTo(HW, HH); g.lineTo(0, 0); g.lineTo(-HW, HH); g.strokePath();
}
// p = fraction of time remaining (1 → full top, 0 → empty)
drawSand(p) {
const g = this.hgSand;
g.clear();
g.fillStyle(GLASS, 0.18);
g.fillTriangle(-HW, -HH, HW, -HH, 0, 0);
g.fillTriangle(-HW, HH, HW, HH, 0, 0);
g.fillStyle(SAND, 1);
// top sand: shrinking downward triangle anchored at the neck
if (p > 0.001) {
g.fillTriangle(0, 0, -HW * p, -HH * p, HW * p, -HH * p);
}
// bottom sand: growing pile rising from the base
const fill = 1 - p;
if (fill > 0.001) {
const apexY = HH - HH * fill;
g.fillTriangle(-HW, HH, HW, HH, 0, apexY);
}
// falling stream
if (p > 0.02 && p < 0.99) {
g.fillStyle(SAND, 0.9);
g.fillRect(-2, 0, 4, HH);
}
}
startTimer() {
// flip the hourglass over, then start draining
this.tweens.add({
targets: this.hg, angle: 180, duration: 650, ease: 'Back.easeOut',
onComplete: () => { this.hg.angle = 0; this.runCountdown(); },
});
}
runCountdown() {
this.playing = true;
// build AI find schedules
this.aiSchedules = this.players.filter((p) => !p.isHuman).map((p) => ({
id: p.id, queue: scheduleFinds(this.solutions, p.skill, this.timerMs), idx: 0,
}));
const state = { t: 0 };
this.timerTween = this.tweens.add({
targets: state, t: 1, duration: this.timerMs, ease: 'Linear',
onUpdate: () => {
const remaining = 1 - state.t;
this.drawSand(remaining);
this.timerText.setText(this.fmtTime(this.timerMs * remaining));
this.advanceAI(state.t * this.timerMs);
},
onComplete: () => this.endRound(),
});
}
advanceAI(elapsedMs) {
for (const sched of this.aiSchedules) {
while (sched.idx < sched.queue.length && sched.queue[sched.idx].atMs <= elapsedMs) {
const w = sched.queue[sched.idx].word;
sched.idx++;
if (!this.found[sched.id].has(w)) {
this.found[sched.id].add(w);
this.bumpOppCount(sched.id);
}
}
}
}
fmtTime(ms) {
const s = Math.max(0, Math.ceil(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
// ── Player sheet ──────────────────────────────────────────────────────────────
buildPlayerSheet() {
const g = this.add.graphics().setDepth(D.sheet);
g.fillStyle(PAPER, 1);
g.fillRoundedRect(PS_X, PS_Y, PS_W, PS_H, 18);
g.lineStyle(3, PAPER_EDGE, 1);
g.strokeRoundedRect(PS_X, PS_Y, PS_W, PS_H, 18);
g.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 12, 0.6);
this.roundObjs.push(g);
this.roundObjs.push(this.add.text(PS_X + PS_W / 2, PS_Y + 56, 'Your Words', {
fontFamily: 'YummyCupcakes', fontSize: '56px', color: TITLE_RED,
}).setOrigin(0.5).setDepth(D.sheetTxt));
this.playerScoreText = this.add.text(PS_X + PS_W / 2, PS_Y + 104, 'Score: 0', {
fontFamily: 'YummyCupcakes', fontSize: '34px', color: INK,
}).setOrigin(0.5).setDepth(D.sheetTxt);
this.roundObjs.push(this.playerScoreText);
}
addPlayerWord(word) {
const idx = this.playerSheetWords.length;
const col = Math.floor(idx / PER_COL);
const row = idx % PER_COL;
const x = PS_X + 40 + col * ((PS_W - 80) / 2);
const y = PS_Y + 150 + row * 30;
const t = this.add.text(x, y, word, {
fontFamily: 'YummyCupcakes', fontSize: '28px', color: INK,
}).setOrigin(0, 0.5).setDepth(D.sheetTxt);
t.setScale(1.4); t.setAlpha(0);
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
this.playerSheetWords.push(t);
this.roundObjs.push(t);
const total = [...this.found.player].reduce((a, w) => a + scoreWord(w), 0);
this.playerScoreText.setText(`Score: ${total}`);
}
// ── Opponent cards ──────────────────────────────────────────────────────────────
buildOpponentCards() {
this.oppCards = {};
const opps = this.players.filter((p) => !p.isHuman);
opps.forEach((p, i) => {
const y = OPP_Y + i * (OPP_H + OPP_GAP);
const g = this.add.graphics().setDepth(D.hud);
g.fillStyle(COLORS.panel, 0.95);
g.fillRoundedRect(OPP_X, y, OPP_W, OPP_H, 16);
g.lineStyle(2, COLORS.accent, 0.8);
g.strokeRoundedRect(OPP_X, y, OPP_W, OPP_H, 16);
this.roundObjs.push(g);
const spr = this.add.sprite(OPP_X + 70, y + OPP_H / 2, 'opponents', p.spriteIndex)
.setDepth(D.hud + 1);
const scale = 116 / spr.height;
spr.setScale(scale);
const maskG = this.make.graphics();
maskG.fillCircle(OPP_X + 70, y + OPP_H / 2, 56);
spr.setMask(maskG.createGeometryMask());
this.roundObjs.push(spr);
const ring = this.add.graphics().setDepth(D.hud + 1);
ring.lineStyle(3, COLORS.accent, 1); ring.strokeCircle(OPP_X + 70, y + OPP_H / 2, 56);
this.roundObjs.push(ring);
this.roundObjs.push(this.add.text(OPP_X + 142, y + 34, p.name, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.hud + 1));
// skill pips
for (let s = 0; s < 5; s++) {
const pip = this.add.graphics().setDepth(D.hud + 1);
const filled = s < p.skill;
pip.fillStyle(filled ? COLORS.gold : 0x000000, filled ? 1 : 0.25);
pip.fillCircle(OPP_X + 150 + s * 18, y + 64, 6);
pip.lineStyle(1, COLORS.gold, 0.8); pip.strokeCircle(OPP_X + 150 + s * 18, y + 64, 6);
this.roundObjs.push(pip);
}
const count = this.add.text(OPP_X + OPP_W - 30, y + OPP_H / 2 + 6, '0', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
}).setOrigin(1, 0.5).setDepth(D.hud + 1);
const label = this.add.text(OPP_X + OPP_W - 30, y + OPP_H / 2 + 42, 'words', {
fontFamily: 'YummyCupcakes', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(1, 0.5).setDepth(D.hud + 1);
this.roundObjs.push(count, label);
this.oppCards[p.id] = { count };
});
}
bumpOppCount(id) {
const card = this.oppCards[id];
if (!card) return;
card.count.setText(String(this.found[id].size));
this.tweens.add({ targets: card.count, scale: { from: 1.5, to: 1 }, duration: 220, ease: 'Back.easeOut' });
}
// ── Current-word banner + controls ──────────────────────────────────────────────
buildWordBanner() {
this.wordBanner = this.add.text(TRAY_CX, ORIGIN_Y - 70, '', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.ui);
this.feedback = this.add.text(TRAY_CX, ORIGIN_Y + SPAN + 70, '', {
fontFamily: 'YummyCupcakes', fontSize: '36px', color: '#fff',
}).setOrigin(0.5).setDepth(D.ui).setAlpha(0);
this.roundObjs.push(this.wordBanner, this.feedback);
}
buildControls() {
const y = ORIGIN_Y + SPAN + 150;
const submit = new Button(this, TRAY_CX + 120, y, 'Submit Word',
() => this.submitWord(), { width: 220, height: 64, fontSize: 26 });
submit.setDepth(D.ui);
const clear = new Button(this, TRAY_CX - 120, y, 'Clear',
() => this.clearPath(), { variant: 'ghost', width: 200, height: 64, fontSize: 26 });
clear.setDepth(D.ui);
const leave = new Button(this, GAME_WIDTH - 100, GAME_HEIGHT - 44, 'Leave',
() => this.scene.start('GameMenu'), { variant: 'ghost', width: 160, height: 48, fontSize: 20 });
leave.setDepth(D.ui);
this.roundObjs.push(submit, clear, leave);
}
// ── Selection input ───────────────────────────────────────────────────────────
onCubeClick(r, c) {
if (!this.playing) return;
const cell = [r, c];
const path = this.path;
if (path.length === 0) { path.push(cell); }
else {
const last = path[path.length - 1];
if (last[0] === r && last[1] === c) { path.pop(); } // undo
else if (path.some(([pr, pc]) => pr === r && pc === c)) { return; } // used (not last)
else if (areAdjacent(last, cell)) { path.push(cell); }
else { this.flash('Not adjacent', COLORS.dangerHex); return; }
}
playSound(this, SFX.PIECE_CLICK);
this.redrawSelection();
}
redrawSelection() {
// cube highlights
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
const sel = this.path.some(([pr, pc]) => pr === r && pc === c);
this.drawCubeFace(this.cubes[r][c]._face, sel);
}
// connecting line + nodes
this.lineGfx.clear();
if (this.path.length) {
const pts = this.path.map(([r, c]) => this.cubeCenter(r, c));
this.lineGfx.lineStyle(12, SELECT_GOLD, 0.85);
for (let i = 1; i < pts.length; i++) {
this.lineGfx.lineBetween(pts[i - 1].x, pts[i - 1].y, pts[i].x, pts[i].y);
}
pts.forEach((p, i) => {
this.lineGfx.fillStyle(i === pts.length - 1 ? 0xffffff : SELECT_GOLD, 1);
this.lineGfx.fillCircle(p.x, p.y, i === pts.length - 1 ? 16 : 11);
});
}
this.wordBanner.setText(this.currentWord());
}
currentWord() {
return this.path.map(([r, c]) => this.board[r][c].toUpperCase()).join('');
}
clearPath() {
this.path = [];
this.redrawSelection();
}
submitWord() {
if (!this.playing) return;
const w = this.currentWord();
if (w.length < 3) { this.flash('Too short', COLORS.dangerHex); return; }
if (this.found.player.has(w)) { this.flash('Already found', COLORS.mutedHex); return; }
if (!this.validWords.has(w)) { this.flash('Not a word', COLORS.dangerHex); return; }
this.found.player.add(w);
this.addPlayerWord(w);
this.flash(`+${scoreWord(w)} ${w}`, COLORS.successHex);
playSound(this, SFX.PENCIL_WRITE);
this.clearPath();
}
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: 900, duration: 400 });
}
// ── End of round → scoring ──────────────────────────────────────────────────────
endRound() {
this.playing = false;
this.timerTween = null;
this.clearPath();
// ensure all scheduled AI words counted
for (const sched of this.aiSchedules) {
while (sched.idx < sched.queue.length) {
this.found[sched.id].add(sched.queue[sched.idx].word);
sched.idx++;
}
this.bumpOppCount(sched.id);
}
this.time.delayedCall(500, () => this.showScoring());
}
showScoring() {
const sheets = this.players.map((p) => ({ found: this.found[p.id] }));
const results = resolveRound(sheets); // aligned with this.players order
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setDepth(D.overlay);
this.scoreObjs.push(dim);
this.scoreObjs.push(this.add.text(GAME_WIDTH / 2, 70, `Round ${this.round} — Scoring`, {
fontFamily: 'YummyCupcakes', fontSize: '66px', color: '#fff6e6',
}).setOrigin(0.5).setDepth(D.overlayUI));
const n = this.players.length;
const colW = Math.min(420, (GAME_WIDTH - 120) / n);
const totalW = colW * n;
const startX = (GAME_WIDTH - totalW) / 2;
const topY = 170;
// build columns and a flat list of animation steps
const steps = [];
this.scoreCols = this.players.map((p, pi) => {
const cx = startX + pi * colW + colW / 2;
const header = this.add.graphics().setDepth(D.overlayUI);
header.fillStyle(p.isHuman ? 0xb03a2e : COLORS.panel, 0.95);
header.fillRoundedRect(cx - colW / 2 + 10, topY, colW - 20, 70, 12);
header.lineStyle(2, COLORS.accent, 0.8);
header.strokeRoundedRect(cx - colW / 2 + 10, topY, colW - 20, 70, 12);
this.scoreObjs.push(header);
this.scoreObjs.push(this.add.text(cx, topY + 24, p.name, {
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI + 1));
const scoreText = this.add.text(cx, topY + 52, '0 pts', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI + 1);
this.scoreObjs.push(scoreText);
// Notebook paper background behind the word list
const nbX = cx - colW / 2 + 10;
const nbY = topY + 80;
const nbW = colW - 20;
const nbH = GAME_HEIGHT - 170 - nbY;
const nb = this.add.graphics().setDepth(D.overlayUI);
nb.fillStyle(0x000000, 0.22);
nb.fillRoundedRect(nbX + 4, nbY + 4, nbW, nbH, 8);
nb.fillStyle(0xfdf0d0, 1);
nb.fillRoundedRect(nbX, nbY, nbW, nbH, 8);
nb.lineStyle(1, 0x9bbfd4, 0.45);
for (let ly = topY + 100; ly < nbY + nbH - 10; ly += 30) {
nb.lineBetween(nbX + 36, ly, nbX + nbW - 12, ly);
}
nb.lineStyle(1.5, 0xe8a8a8, 0.65);
nb.lineBetween(nbX + 32, nbY + 10, nbX + 32, nbY + nbH - 10);
nb.lineStyle(1, 0xc8b080, 0.5);
nb.strokeRoundedRect(nbX, nbY, nbW, nbH, 8);
this.scoreObjs.push(nb);
const res = results[pi];
const colState = { running: 0, scoreText };
res.words.forEach((entry, wi) => {
const wy = topY + 100 + wi * 30;
if (wy > GAME_HEIGHT - 160) return; // overflow guard
const wt = this.add.text(cx, wy, entry.word, {
fontFamily: 'YummyCupcakes', fontSize: '26px',
color: '#8a7a60',
}).setOrigin(0.5).setDepth(D.overlayUI + 1).setAlpha(0.25);
this.scoreObjs.push(wt);
steps.push({ entry, wt, colState });
});
return colState;
});
this.scoreSteps = steps;
this.scoreStepIdx = 0;
const skip = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Skip',
() => this.finishScoring(), { variant: 'ghost', width: 200, height: 56, fontSize: 24 });
skip.setDepth(D.overlayUI + 2);
this.scoreObjs.push(skip);
this.skipBtn = skip;
this.results = results;
this.stepScore();
}
stepScore() {
if (this.scoreStepIdx >= this.scoreSteps.length) { this.finishScoring(); return; }
const { entry, wt, colState } = this.scoreSteps[this.scoreStepIdx++];
wt.setAlpha(1);
this.tweens.add({ targets: wt, scale: { from: 1.3, to: 1 }, duration: 120, ease: 'Back.easeOut' });
if (entry.dup) {
wt.setColor('#7a7064');
const b = wt.getBounds();
const strike = this.add.graphics().setDepth(D.overlayUI + 2);
strike.lineStyle(3, COLORS.danger, 0.9);
strike.lineBetween(b.left - 4, b.centerY, b.right + 4, b.centerY);
this.scoreObjs.push(strike);
playSound(this, SFX.PIECE_CLICK);
} else {
wt.setColor('#3a2a18');
colState.running += entry.score;
colState.scoreText.setText(`${colState.running} pts`);
this.tweens.add({ targets: colState.scoreText, scale: { from: 1.4, to: 1 }, duration: 160, ease: 'Back.easeOut' });
playSound(this, SFX.PENCIL_WRITE);
}
this.scoreTimer = this.time.delayedCall(110, () => this.stepScore());
}
finishScoring() {
this.scoreTimer?.remove();
// reveal any remaining (skipped) words instantly + finalize totals
while (this.scoreStepIdx < this.scoreSteps.length) {
const { entry, wt, colState } = this.scoreSteps[this.scoreStepIdx++];
wt.setAlpha(1);
if (entry.dup) { wt.setColor('#7a7064'); }
else { wt.setColor('#3a2a18'); colState.running += entry.score; }
}
this.scoreCols.forEach((cs) => cs.scoreText.setText(`${cs.running} pts`));
this.skipBtn?.destroy(); this.skipBtn = null;
// commit round scores to match totals
this.results.forEach((res, pi) => { this.matchTotal[this.players[pi].id] += res.roundScore; });
const label = this.round < this.totalRounds ? 'Continue' : 'Final Results';
const cont = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 70, label,
() => this.afterScoring(), { width: 280, height: 60, fontSize: 26 });
cont.setDepth(D.overlayUI + 2);
this.scoreObjs.push(cont);
}
afterScoring() {
this.scoreObjs.forEach((o) => o.destroy());
this.scoreObjs = [];
this.destroyRound();
if (this.round < this.totalRounds) {
this.round++;
this.showPreRound();
} else {
this.showFinal();
}
}
// ── Final results ──────────────────────────────────────────────────────────────
showFinal() {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const standings = [...this.players].sort((a, b) => this.matchTotal[b.id] - this.matchTotal[a.id]);
const topScore = this.matchTotal[standings[0].id];
const winners = standings.filter((p) => this.matchTotal[p.id] === topScore);
const human = this.matchTotal['player'];
const playerWon = human === topScore;
const result = playerWon ? (winners.length > 1 ? 'draw' : 'win') : 'loss';
this.recordResult(result);
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7).setDepth(D.overlay);
const panel = this.add.graphics().setDepth(D.overlayUI);
panel.fillStyle(PAPER, 1); panel.fillRoundedRect(cx - 420, cy - 320, 840, 640, 22);
panel.lineStyle(3, PAPER_EDGE, 1); panel.strokeRoundedRect(cx - 420, cy - 320, 840, 640, 22);
const title = playerWon
? (winners.length > 1 ? 'Tie Game!' : 'You Win!')
: `${standings[0].name} Wins!`;
this.add.text(cx, cy - 240, title, {
fontFamily: 'YummyCupcakes', fontSize: '88px', color: TITLE_RED,
}).setOrigin(0.5).setDepth(D.overlayUI + 1);
standings.forEach((p, i) => {
this.add.text(cx, cy - 130 + i * 56, `${i + 1}. ${p.name}`, {
fontFamily: 'YummyCupcakes', fontSize: '40px',
color: p.isHuman ? TITLE_RED : INK,
}).setOrigin(0, 0.5).setDepth(D.overlayUI + 1).setX(cx - 300);
this.add.text(cx + 300, cy - 130 + i * 56, `${this.matchTotal[p.id]}`, {
fontFamily: 'Righteous', fontSize: '40px', color: INK,
}).setOrigin(1, 0.5).setDepth(D.overlayUI + 1);
});
new Button(this, cx - 160, cy + 240, 'Play Again',
() => this.scene.restart(this._initData), { width: 280, height: 60, fontSize: 26 })
.setDepth(D.overlayUI + 1);
new Button(this, cx + 160, cy + 240, 'Leave',
() => this.scene.start('GameMenu'), { variant: 'ghost', width: 280, height: 60, fontSize: 26 })
.setDepth(D.overlayUI + 1);
}
async recordResult(result) {
try {
await api.post('/history/single-player', {
slug: 'boggle',
score: this.matchTotal['player'],
opponentScores: this.players.filter((p) => !p.isHuman).map((p) => this.matchTotal[p.id]),
result,
});
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,60 @@
// Pure Boggle logic — no Phaser, no network. Unit-testable.
// Classic Boggle scoring by letter count. Because solution words store "Qu" as the
// two letters Q + U, word.length already counts Qu as two letters automatically.
export function scoreWord(word) {
const n = word.length;
if (n < 3) return 0;
if (n <= 4) return 1;
if (n === 5) return 2;
if (n === 6) return 3;
if (n === 7) return 5;
return 11; // 8+
}
// Two board cells are adjacent if they differ by at most one row and one column
// (8-neighbour), and are not the same cell.
export function areAdjacent(a, b) {
const dr = Math.abs(a[0] - b[0]);
const dc = Math.abs(a[1] - b[1]);
return (dr <= 1 && dc <= 1) && !(dr === 0 && dc === 0);
}
// A path is valid if every step is to an adjacent cell and no cell repeats.
export function isValidPath(path) {
const seen = new Set();
for (let i = 0; i < path.length; i++) {
const key = path[i][0] + ',' + path[i][1];
if (seen.has(key)) return false;
seen.add(key);
if (i > 0 && !areAdjacent(path[i - 1], path[i])) return false;
}
return true;
}
// Given each player's found words, apply the classic duplicate rule: any word
// found by 2+ players scores 0 for everyone. Returns per-player scored results in
// the same order as `sheets`.
// sheets: [{ found: Iterable<string> }]
// => [{ words: [{ word, score, dup }], roundScore }]
export function resolveRound(sheets) {
const counts = new Map();
const lists = sheets.map((s) => {
const arr = [...new Set([...s.found].map((w) => w.toUpperCase()))];
for (const w of arr) counts.set(w, (counts.get(w) ?? 0) + 1);
return arr;
});
return lists.map((arr) => {
let roundScore = 0;
const words = arr
.sort((a, b) => b.length - a.length || a.localeCompare(b))
.map((word) => {
const dup = (counts.get(word) ?? 0) >= 2;
const score = dup ? 0 : scoreWord(word);
roundScore += score;
return { word, score, dup };
});
return { words, roundScore };
});
}

View File

@ -44,6 +44,7 @@ import GoGame from './games/go/GoGame.js';
import BattleshipGame from './games/battleship/BattleshipGame.js'; import BattleshipGame from './games/battleship/BattleshipGame.js';
import MastermindGame from './games/mastermind/MastermindGame.js'; import MastermindGame from './games/mastermind/MastermindGame.js';
import Connect4Game from './games/connect4/Connect4Game.js'; import Connect4Game from './games/connect4/Connect4Game.js';
import BoggleGame from './games/boggle/BoggleGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -101,6 +102,7 @@ const config = {
BattleshipGame, BattleshipGame,
MastermindGame, MastermindGame,
Connect4Game, Connect4Game,
BoggleGame,
], ],
}; };

View File

@ -21,7 +21,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { 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' }; 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' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -376,7 +376,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// Skill control: pips always show the level; the +/- buttons appear only // Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill. // when this opponent is selected. Enabled for games with a 15 AI skill.
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4'].includes(this.gameDef.slug)) { if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1'; bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div'); const skillRow = document.createElement('div');

View File

@ -58,3 +58,4 @@ registerGame({ slug: 'go', name: 'Go', category: 'tabletop', minPlayers: 2, maxP
registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true }); registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true }); registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 }); registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });

View File

@ -0,0 +1,117 @@
// Boggle engine: dictionary trie, dice rolling, and board solving.
// Pure logic — no Express. Initialized once at server start from the ENABLE list.
// Standard 16-die Boggle set. The Q die face is treated as the token "Qu".
const DICE = [
'AAEEGN', 'ABBJOO', 'ACHOPS', 'AFFKPS',
'AOOTTW', 'CIMOTU', 'DEILRX', 'DELRVY',
'DISTTY', 'EEGHNW', 'EEINSU', 'EHRTVW',
'EIOSST', 'ELRTTY', 'HIMNQU', 'HLNNRZ',
];
const MIN_LEN = 3; // minimum scoring word length
// ── Trie ──────────────────────────────────────────────────────────────────────
// Node: { word: boolean, kids: Map<char, node> }. Used for fast prefix pruning
// during the board DFS while keeping memory far below a full prefix Set.
let root = null;
function makeNode() {
return { word: false, kids: new Map() };
}
export function initBoggleDictionary(words) {
root = makeNode();
for (const raw of words) {
const w = String(raw).toUpperCase();
if (w.length < MIN_LEN || w.length > 16 || !/^[A-Z]+$/.test(w)) continue;
let node = root;
for (const ch of w) {
let next = node.kids.get(ch);
if (!next) { next = makeNode(); node.kids.set(ch, next); }
node = next;
}
node.word = true;
}
return root;
}
// ── Board rolling ───────────────────────────────────────────────────────────────
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;
}
// Returns a 4×4 grid (array of 4 rows, each 4 face tokens). Each cell is a single
// uppercase letter, except the Q die which yields the token "Qu".
export function rollBoard() {
const dice = shuffle([...DICE]);
const faces = dice.map((die) => {
const ch = die[Math.floor(Math.random() * die.length)];
return ch === 'Q' ? 'Qu' : ch;
});
const grid = [];
for (let r = 0; r < 4; r++) grid.push(faces.slice(r * 4, r * 4 + 4));
return grid;
}
// ── Solving ───────────────────────────────────────────────────────────────────
const NEIGHBORS = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1],
];
// Walk a trie down every character of a face token (handles the two-letter "Qu").
// Returns the resulting node, or null if the prefix leaves the trie.
function stepNode(node, token) {
let n = node;
for (const ch of token.toUpperCase()) {
n = n.kids.get(ch);
if (!n) return null;
}
return n;
}
// Returns every valid word (length >= 3) found on the board, each with one
// representative path: [{ word, path: [[r,c], ...] }]. De-duped by word.
export function solveBoard(grid) {
if (!root) return [];
const size = grid.length;
const found = new Map(); // word -> path
const visited = Array.from({ length: size }, () => new Array(size).fill(false));
const dfs = (r, c, node, word, path) => {
const next = stepNode(node, grid[r][c]);
if (!next) return;
const nextWord = word + grid[r][c].toUpperCase();
const nextPath = [...path, [r, c]];
if (next.word && nextWord.length >= MIN_LEN && !found.has(nextWord)) {
found.set(nextWord, nextPath);
}
visited[r][c] = true;
for (const [dr, dc] of NEIGHBORS) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nc < 0 || nr >= size || nc >= size) continue;
if (visited[nr][nc]) continue;
dfs(nr, nc, next, nextWord, nextPath);
}
visited[r][c] = false;
};
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
dfs(r, c, root, '', []);
}
}
return [...found.entries()].map(([word, path]) => ({ word, path }));
}

View File

@ -17,6 +17,7 @@ import {
listThemes as wordSearchThemes, listThemes as wordSearchThemes,
} from './wordSearchEngine.js'; } from './wordSearchEngine.js';
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js'; import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt'); const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
@ -144,6 +145,11 @@ function loadWordLists() {
initWordLadderDictionary(ladderThree, ladderFour); initWordLadderDictionary(ladderThree, ladderFour);
console.log(`[words] loaded Word Ladder dictionaries (${ladderThree.length} 3-letter, ${ladderFour.length} 4-letter)`); console.log(`[words] loaded Word Ladder dictionaries (${ladderThree.length} 3-letter, ${ladderFour.length} 4-letter)`);
// Boggle dictionary: every ENABLE word of length 316 (Boggle scores words 3+).
const boggleWords = allWords.filter(w => w.length >= 3 && w.length <= 16 && /^[A-Z]+$/.test(w));
initBoggleDictionary(boggleWords);
console.log(`[words] loaded ${boggleWords.length} Boggle words (316 letters)`);
// Answer pool: prefer curated common words that are also in ENABLE; // Answer pool: prefer curated common words that are also in ENABLE;
// supplement with additional ENABLE words up to a healthy pool size. // supplement with additional ENABLE words up to a healthy pool size.
const curated = [...COMMON_WORDS].filter(w => enableFive.has(w)); const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));
@ -175,6 +181,17 @@ router.post('/wordle/validate', (req, res) => {
res.json({ valid: allFiveLetterWords.has(word) }); res.json({ valid: allFiveLetterWords.has(word) });
}); });
// ── Boggle ──────────────────────────────────────────────────────────────────────
// GET /api/words/boggle/start
// Rolls a fresh 4×4 board and returns it with the full solution set (every valid
// word and one representative path). The client validates player words and drives
// the AI from this set — no per-word round trips needed.
router.get('/boggle/start', (_req, res) => {
const board = rollBoard();
res.json({ board, solutions: solveBoard(board) });
});
// ── Scrabble ────────────────────────────────────────────────────────────────── // ── Scrabble ──────────────────────────────────────────────────────────────────
// POST /api/words/scrabble/validate { words: string[] } // POST /api/words/scrabble/validate { words: string[] }