feat: add Mini Crossword game (5x5 curated puzzles, single-player)

- Frontend: Phaser scene for grid/clue rendering, keyboard navigation, win detection, and difficulty selection.
- Logic: Pure helpers for slot mapping, cell validation, and answer checking.
- Backend: Puzzle engine, JSON bank of 15 puzzles across 3 difficulties, API route, and game registry.
- Integrates into existing game menu and routing.
This commit is contained in:
Brian Fertig 2026-06-01 18:27:50 -06:00
parent 3e975521fb
commit 9dda7f4487
8 changed files with 960 additions and 1 deletions

View File

@ -0,0 +1,453 @@
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 {
numberMap, cellsForSlot, slotAt, isSolved, wrongCells,
} from './MiniCrosswordLogic.js';
// ── Palette / styling ──────────────────────────────────────────────────────────
const FELT = 0x12100a; // deep background
const PAPER = 0x1e1a12; // panels
const GOLD = 0xd4a017;
const GOLD_DARK = 0xb8860b;
const CELL = 0xf2ead8; // empty letter cell
const CELL_WORD = 0xefdca6; // cells of the active word
const CELL_SEL = 0xf3c75a; // the selected cell
const BLOCK_FILL = 0x0c0a07; // black square
const EDGE = 0x4a4030;
const INK_DARK = '#1a1208'; // letters on light cells
const TITLE_GOLD = '#d4a017';
const DEPTH = { bg: 0, panel: 2, cell: 8, cellTxt: 10, ui: 20, overlay: 40, overlayUI: 42 };
// ── Grid geometry ────────────────────────────────────────────────────────────────
const CELL_SIZE = 116;
const GRID_LEFT = 300;
const GRID_TOP = 300;
export default class MiniCrosswordGame extends Phaser.Scene {
constructor() { super('MiniCrosswordGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.puzzle = null;
this.grid = []; // solution rows (strings)
this.across = [];
this.down = [];
this.entries = {}; // "r,c" -> letter
this.cells = {}; // "r,c" -> { rect, txt }
this.numbers = {}; // "r,c" -> number
this.sel = { row: 0, col: 0 };
this.dir = 'across';
this.ended = false;
this.startObjs = [];
}
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, 'MINI CROSSWORD', {
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 ──────────────────────────────────────────────────────────────
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, GOLD_DARK, 1);
sheet.strokeRoundedRect(cx - 480, cy - 280, 960, 560, 20);
this.startObjs.push(sheet);
this.startObjs.push(this.add.text(cx, cy - 190, 'Mini Crossword', {
fontFamily: 'Righteous', fontSize: '88px', color: TITLE_GOLD,
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy - 90,
'Fill the 5×5 grid so every across and down answer\nmatches its clue. Click a cell or clue to start typing.', {
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'], ['Medium', 'medium'], ['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: GOLD });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
});
}
async startPuzzle(difficulty) {
let data;
try {
data = await api.get(`/words/minicrossword/start?difficulty=${difficulty}`);
} catch (err) {
console.error('[minicrossword] failed to fetch puzzle:', err);
return;
}
if (!data?.grid?.length) {
console.error('[minicrossword] empty puzzle payload');
return;
}
this.startObjs.forEach((o) => o.destroy());
this.startObjs = [];
this.puzzle = data;
this.grid = data.grid;
this.across = data.across ?? [];
this.down = data.down ?? [];
this.numbers = numberMap(this.across, this.down);
this.buildBoard();
}
// ── Board ──────────────────────────────────────────────────────────────────────
buildBoard() {
this.buildClueBanner();
this.buildGrid();
this.buildCluePanel();
this.buildControls();
this.bindKeyboard();
// Start on the first across answer.
const first = this.across[0] ?? this.down[0];
if (first) {
this.dir = this.across[0] ? 'across' : 'down';
this.setSelection(first.row, first.col, false);
}
this.refresh();
}
cellCenter(r, c) {
return {
x: GRID_LEFT + c * CELL_SIZE + CELL_SIZE / 2,
y: GRID_TOP + r * CELL_SIZE + CELL_SIZE / 2,
};
}
buildGrid() {
for (let r = 0; r < this.grid.length; r++) {
for (let c = 0; c < this.grid[r].length; c++) {
const { x, y } = this.cellCenter(r, c);
const block = this.grid[r][c] === '#';
const rect = this.add.rectangle(x, y, CELL_SIZE - 4, CELL_SIZE - 4,
block ? BLOCK_FILL : CELL).setStrokeStyle(3, EDGE).setDepth(DEPTH.cell);
if (block) continue;
rect.setInteractive({ useHandCursor: true });
rect.on('pointerdown', () => this.onCellClick(r, c));
const txt = this.add.text(x, y + 6, '', {
fontFamily: 'Righteous', fontSize: '58px', color: INK_DARK,
}).setOrigin(0.5).setDepth(DEPTH.cellTxt);
const num = this.numbers[`${r},${c}`];
if (num) {
this.add.text(x - CELL_SIZE / 2 + 12, y - CELL_SIZE / 2 + 8, String(num), {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: INK_DARK,
}).setOrigin(0, 0).setDepth(DEPTH.cellTxt);
}
this.cells[`${r},${c}`] = { rect, txt };
}
}
}
buildClueBanner() {
const gridW = this.grid[0].length * CELL_SIZE;
this.banner = this.add.text(GRID_LEFT + gridW / 2, GRID_TOP - 70, '', {
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
align: 'center', wordWrap: { width: gridW + 40 },
}).setOrigin(0.5, 1).setDepth(DEPTH.ui);
}
buildCluePanel() {
const PX = 1010, PY = 150, PW = 850, PH = 760;
const g = this.add.graphics().setDepth(DEPTH.panel);
g.fillStyle(PAPER, 1);
g.fillRoundedRect(PX, PY, PW, PH, 18);
g.lineStyle(3, EDGE, 1);
g.strokeRoundedRect(PX, PY, PW, PH, 18);
this.clueTexts = {}; // "across:idx" / "down:idx" -> text object
const columns = [
{ title: 'ACROSS', dir: 'across', slots: this.across, x: PX + 40 },
{ title: 'DOWN', dir: 'down', slots: this.down, x: PX + 440 },
];
for (const col of columns) {
this.add.text(col.x, PY + 36, col.title, {
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
}).setDepth(DEPTH.cellTxt);
col.slots.forEach((slot, i) => {
const y = PY + 100 + i * 76;
const t = this.add.text(col.x, y, `${slot.number}. ${slot.clue}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
wordWrap: { width: 360 },
}).setDepth(DEPTH.cellTxt);
t.setInteractive({ useHandCursor: true });
t.on('pointerdown', () => this.selectClue(col.dir, slot));
this.clueTexts[`${col.dir}:${i}`] = { t, slot };
});
}
}
buildControls() {
const y = GRID_TOP + this.grid.length * CELL_SIZE + 70;
this.checkBtn = new Button(this, GRID_LEFT + 120, y, 'Check',
() => this.checkGrid(), { width: 200, height: 60, fontSize: 24 });
this.revealBtn = new Button(this, GRID_LEFT + 350, y, 'Reveal Word',
() => this.revealWord(), { width: 220, height: 60, fontSize: 24, bgHover: GOLD });
[this.checkBtn, this.revealBtn].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);
}
// ── Selection & active word ─────────────────────────────────────────────────────
activeSlot() {
const slots = this.dir === 'across' ? this.across : this.down;
let slot = slotAt(slots, this.dir, this.sel.row, this.sel.col);
if (!slot) {
// No word in this direction through the cell — try the other direction.
const other = this.dir === 'across' ? 'down' : 'across';
const otherSlots = other === 'across' ? this.across : this.down;
slot = slotAt(otherSlots, other, this.sel.row, this.sel.col);
if (slot) this.dir = other;
}
return slot;
}
setSelection(r, c, toggleIfSame = true) {
if (this.grid[r][c] === '#') return;
if (toggleIfSame && this.sel.row === r && this.sel.col === c) {
this.dir = this.dir === 'across' ? 'down' : 'across';
}
this.sel = { row: r, col: c };
this.refresh();
}
onCellClick(r, c) {
if (this.ended) return;
this.setSelection(r, c, true);
}
selectClue(dir, slot) {
if (this.ended) return;
this.dir = dir;
this.sel = { row: slot.row, col: slot.col };
this.refresh();
}
// ── Rendering refresh ────────────────────────────────────────────────────────────
refresh() {
const slot = this.activeSlot();
const wordKeys = new Set(
slot ? cellsForSlot(slot, this.dir).map((p) => `${p.row},${p.col}`) : [],
);
const selKey = `${this.sel.row},${this.sel.col}`;
for (const [key, cell] of Object.entries(this.cells)) {
let fill = CELL;
if (key === selKey) fill = CELL_SEL;
else if (wordKeys.has(key)) fill = CELL_WORD;
cell.rect.setFillStyle(fill);
cell.txt.setText(this.entries[key] ?? '');
}
// Highlight the active clue in the side panel.
if (this.clueTexts) {
for (const { t } of Object.values(this.clueTexts)) t.setColor(COLORS.textHex);
if (slot) {
const slots = this.dir === 'across' ? this.across : this.down;
const idx = slots.indexOf(slot);
const entry = this.clueTexts[`${this.dir}:${idx}`];
if (entry) entry.t.setColor(TITLE_GOLD);
this.banner.setText(`${slot.number} ${this.dir === 'across' ? 'Across' : 'Down'}: ${slot.clue}`);
}
}
}
// ── Keyboard input ───────────────────────────────────────────────────────────────
bindKeyboard() {
this.keyHandler = (event) => {
if (this.ended) return;
const key = event.key;
if (/^[a-zA-Z]$/.test(key)) {
this.typeLetter(key.toUpperCase());
} else if (key === 'Backspace') {
event.preventDefault();
this.backspace();
} else if (key === ' ') {
event.preventDefault();
this.dir = this.dir === 'across' ? 'down' : 'across';
this.refresh();
} else if (key.startsWith('Arrow')) {
event.preventDefault();
this.moveArrow(key);
}
};
this.input.keyboard.on('keydown', this.keyHandler);
}
typeLetter(letter) {
const key = `${this.sel.row},${this.sel.col}`;
this.entries[key] = letter;
playSound(this, SFX.PIECE_CLICK);
this.advance();
this.refresh();
if (isSolved(this.grid, this.entries)) this.handleWin();
}
backspace() {
const key = `${this.sel.row},${this.sel.col}`;
if (this.entries[key]) {
delete this.entries[key];
} else {
this.advance(-1);
delete this.entries[`${this.sel.row},${this.sel.col}`];
}
this.refresh();
}
// Step to the next (dir=1) or previous (dir=-1) cell of the active word.
advance(step = 1) {
const slot = this.activeSlot();
if (!slot) return;
const cells = cellsForSlot(slot, this.dir);
const idx = cells.findIndex((p) => p.row === this.sel.row && p.col === this.sel.col);
const next = cells[idx + step];
if (next) this.sel = { row: next.row, col: next.col };
}
moveArrow(arrow) {
let { row, col } = this.sel;
const deltas = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
const [dr, dc] = deltas[arrow];
// Prefer the direction the arrow implies.
this.dir = (dr !== 0) ? 'down' : 'across';
let nr = row + dr, nc = col + dc;
while (nr >= 0 && nr < this.grid.length && nc >= 0 && nc < this.grid[0].length) {
if (this.grid[nr][nc] !== '#') { this.sel = { row: nr, col: nc }; break; }
nr += dr; nc += dc;
}
this.refresh();
}
// ── Helpers (Check / Reveal) ─────────────────────────────────────────────────────
checkGrid() {
if (this.ended) return;
const wrong = wrongCells(this.grid, this.entries);
if (!wrong.length) { playSound(this, SFX.PIECE_CLICK); return; }
wrong.forEach((key) => {
const cell = this.cells[key];
if (!cell) return;
cell.txt.setColor('#e06c75');
this.tweens.add({
targets: cell.txt, alpha: { from: 1, to: 0.2 }, yoyo: true, repeat: 2,
duration: 160, onComplete: () => cell.txt.setColor(INK_DARK).setAlpha(1),
});
});
playSound(this, SFX.CARD_SHUFFLE);
}
revealWord() {
if (this.ended) return;
const slot = this.activeSlot();
if (!slot) return;
cellsForSlot(slot, this.dir).forEach((p) => {
this.entries[`${p.row},${p.col}`] = this.grid[p.row][p.col];
});
playSound(this, SFX.PENCIL_WRITE);
this.refresh();
if (isSolved(this.grid, this.entries)) this.handleWin();
}
// ── Win ──────────────────────────────────────────────────────────────────────────
handleWin() {
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 - 200, 800, 400, 20);
panel.lineStyle(3, GOLD_DARK, 1);
panel.strokeRoundedRect(cx - 400, cy - 200, 800, 400, 20);
this.add.text(cx, cy - 90, 'Solved!', {
fontFamily: 'Righteous', fontSize: '96px', color: TITLE_GOLD,
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
this.add.text(cx, cy + 10, 'You completed the mini crossword.', {
fontFamily: '"Julius Sans One"', fontSize: '32px', 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: GOLD }).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: 'minicrossword', score: this.grid.length * this.grid[0].length, opponentScores: [], result,
});
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,68 @@
// Pure helpers for Mini Crossword — no Phaser. The server returns the solution
// grid plus numbered across/down slots; these helpers operate on that data and
// the player's per-cell entries (keyed "row,col").
export const BLOCK = '#';
export function isBlock(grid, r, c) {
const row = grid[r];
return !row || c < 0 || c >= row.length || row[c] === BLOCK;
}
// Maps a starting cell "row,col" to its clue number for on-grid labels.
export function numberMap(across, down) {
const map = {};
for (const s of [...across, ...down]) map[`${s.row},${s.col}`] = s.number;
return map;
}
// The ordered cells a slot covers, given its direction.
export function cellsForSlot(slot, dir) {
const cells = [];
for (let i = 0; i < slot.len; i++) {
cells.push(dir === 'across'
? { row: slot.row, col: slot.col + i }
: { row: slot.row + i, col: slot.col });
}
return cells;
}
// The slot in a given direction that contains (r,c), or null.
export function slotAt(slots, dir, r, c) {
return slots.find((s) =>
cellsForSlot(s, dir).some((p) => p.row === r && p.col === c)) || null;
}
// All non-block cells have a letter?
export function isComplete(grid, entries) {
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[r].length; c++) {
if (grid[r][c] !== BLOCK && !entries[`${r},${c}`]) return false;
}
}
return true;
}
// Every filled cell matches the solution grid?
export function isSolved(grid, entries) {
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[r].length; c++) {
if (grid[r][c] === BLOCK) continue;
if (entries[`${r},${c}`] !== grid[r][c]) return false;
}
}
return true;
}
// "row,col" keys whose current entry is present but wrong (for Check).
export function wrongCells(grid, entries) {
const wrong = [];
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[r].length; c++) {
if (grid[r][c] === BLOCK) continue;
const e = entries[`${r},${c}`];
if (e && e !== grid[r][c]) wrong.push(`${r},${c}`);
}
}
return wrong;
}

View File

@ -48,6 +48,7 @@ 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';
import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
const config = {
type: Phaser.AUTO,
@ -109,6 +110,7 @@ const config = {
OldMaidGame,
BlokusGame,
SpellingBeeGame,
MiniCrosswordGame,
],
};

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', spellingbee: 'SpellingBeeGame' };
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', minicrossword: 'MiniCrosswordGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -0,0 +1,287 @@
[
{
"id": "easy-001",
"difficulty": "easy",
"grid": ["APART", "POWER", "AWARE", "RERUN", "TREND"],
"across": [
"Separated, as two people",
"Electrical energy",
"Conscious of; in the know",
"TV episode shown again",
"Current fashion or direction"
],
"down": [
"Not together",
"Strength or might",
"Mindful",
"Summer TV staple",
"What goes viral, perhaps"
]
},
{
"id": "easy-002",
"difficulty": "easy",
"grid": ["SMART", "POLAR", "OLIVE", "RAVEN", "TREND"],
"across": [
"Clever",
"Like a bear at the North Pole",
"Green martini garnish",
"Edgar Allan Poe's black bird",
"Hot new fashion"
],
"down": [
"Baseball or soccer, e.g.",
"Back tooth",
"Living and breathing",
"Glossy black bird",
"Topic that's taking off online"
]
},
{
"id": "easy-003",
"difficulty": "easy",
"grid": ["START", "THROW", "ARGUE", "ROUTE", "TWEED"],
"across": [
"Begin",
"Toss a ball",
"Quarrel",
"Path a bus takes",
"Rough wool fabric for jackets"
],
"down": [
"A race's beginning",
"Pitch, as a baseball",
"Bicker",
"Mail carrier's territory",
"Tweedy jacket cloth"
]
},
{
"id": "easy-004",
"difficulty": "easy",
"grid": ["CLOSE", "LLAMA", "OASIS", "SMILE", "EASEL"],
"across": [
"Shut, as a door",
"Andean pack animal",
"Green spot in the desert",
"Happy expression",
"Painter's stand"
],
"down": [
"Nearby",
"Spitting Andean beast",
"Desert refuge",
"What a camera asks you to do",
"Stand for a canvas"
]
},
{
"id": "easy-005",
"difficulty": "easy",
"grid": ["MAPLE", "AGAIN", "PAINT", "LINER", "ENTRY"],
"across": [
"Syrup-producing tree",
"Once more",
"What an artist applies",
"Big cruise ship",
"Doorway, or a diary post"
],
"down": [
"Tree on Canada's flag",
"Time and time ___",
"Wall color in a can",
"Ocean-crossing vessel",
"Way in"
]
},
{
"id": "medium-001",
"difficulty": "medium",
"grid": ["DROVE", "RAVEN", "OVERT", "VERVE", "ENTER"],
"across": [
"Operated the car; also, a herd",
"Bird that quoth 'Nevermore'",
"Out in the open, not hidden",
"Energy and enthusiasm",
"Key pressed to confirm"
],
"down": [
"Chauffeured",
"Corvid in a Poe poem",
"Undisguised",
"Pep and flair",
"Go in"
]
},
{
"id": "medium-002",
"difficulty": "medium",
"grid": ["GRASP", "RUMOR", "AMPLE", "SOLVE", "PREEN"],
"across": [
"Grip firmly; comprehend",
"Unverified bit of gossip",
"More than enough",
"Crack, as a puzzle",
"Groom feathers, as a bird does"
],
"down": [
"Get a handle on",
"Word on the street",
"Plentiful",
"Figure out",
"Primp"
]
},
{
"id": "medium-003",
"difficulty": "medium",
"grid": ["SHADE", "HELIX", "ALIVE", "RIVER", "EXERT"],
"across": [
"Shadow cast by a tree",
"DNA's spiral shape",
"Not dead",
"The Nile or the Amazon",
"Put forth, as effort"
],
"down": [
"Portion; or post online",
"Double ___ (DNA structure)",
"Kicking, so to speak",
"One leaping off a board",
"Apply, as force"
]
},
{
"id": "medium-004",
"difficulty": "medium",
"grid": ["CRANE", "RESIN", "ASSET", "NIECE", "ENTER"],
"across": [
"Construction lifting machine; or a tall bird",
"Sticky secretion that becomes amber",
"Valuable item on a balance sheet",
"Your sibling's daughter",
"Type in, as data"
],
"down": [
"Stretch the neck to see",
"Pine-tree ooze",
"A plus on the books",
"Nephew's sister",
"Walk into a room"
]
},
{
"id": "medium-005",
"difficulty": "medium",
"grid": ["EVADE", "VEGAN", "AGENT", "DANCE", "ENTER"],
"across": [
"Dodge, as a question",
"One who eats no animal products",
"Spy, or an actor's rep",
"Waltz or tango",
"Sign up for, as a contest"
],
"down": [
"Slip away from",
"Plant-based eater",
"007, for one",
"Boogie",
"Join, as a race"
]
},
{
"id": "hard-001",
"difficulty": "hard",
"grid": ["CRONE", "RIVAL", "OVOID", "NAIVE", "ELDER"],
"across": [
"Witchy old woman of folklore",
"Competitor to beat",
"Egg-shaped",
"Innocently unworldly",
"Respected senior; also a berry bush"
],
"down": [
"Hag of fairy tales",
"Archnemesis",
"Like an egg's outline",
"Wet behind the ears",
"Tribal sage"
]
},
{
"id": "hard-002",
"difficulty": "hard",
"grid": ["GRUFF", "RIVER", "UVULA", "FELON", "FRANK"],
"across": [
"Brusque and surly",
"The Mississippi, for one",
"Dangly bit at the back of the throat",
"Convicted criminal",
"Candidly blunt; or a hot dog"
],
"down": [
"Hoarse and curt",
"Flowing waterway",
"Throat's little hanging punching bag",
"One with a rap sheet",
"Straight-talking; ballpark sausage"
]
},
{
"id": "hard-003",
"difficulty": "hard",
"grid": ["PLUMB", "LUNAR", "UNTIE", "MAIZE", "BREED"],
"across": [
"Measure the depth of; dead vertical",
"Of the moon",
"Loosen, as shoelaces",
"Corn, by another name",
"Raise animals; a dog variety"
],
"down": [
"Perfectly upright",
"___ eclipse",
"Undo a knot",
"Native American staple grain",
"Labrador or poodle, e.g."
]
},
{
"id": "hard-004",
"difficulty": "hard",
"grid": ["SARGE", "ACORN", "ROBOT", "GROVE", "ENTER"],
"across": [
"Nickname for a drill instructor",
"Oak tree's nut",
"Automaton like R2-D2",
"Small cluster of trees",
"Make an entrance"
],
"down": [
"Boot-camp boss, informally",
"Squirrel's buried snack",
"Mechanical worker",
"Orange ___ in Florida",
"Step inside"
]
},
{
"id": "hard-005",
"difficulty": "hard",
"grid": ["SHALE", "HELIX", "ALIVE", "LIVER", "EXERT"],
"across": [
"Rock that yields oil and gas",
"Spiral, as of DNA",
"Full of life",
"Organ that filters toxins",
"Bring to bear, as influence"
],
"down": [
"Sedimentary fracking rock",
"Corkscrew shape",
"Among the living",
"Onions' classic skillet partner",
"Wield, as pressure"
]
}
]

View File

@ -63,3 +63,4 @@ registerGame({ slug: 'boggle', name: 'Boggle', category: 'wo
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 });
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });

View File

@ -0,0 +1,133 @@
// Mini Crossword engine: serves curated 5x5 puzzles from a hand-authored bank.
// Pure logic — no Express. Loaded once at server start.
//
// A puzzle is authored as { id, difficulty, grid:[5 row strings], across:[5],
// down:[5] }. Grids are fixed 5x5; a '#' marks a black square. Across/Down clue
// arrays are ordered by row index / column index respectively. This engine
// derives the standard crossword numbering and pairs each clue with its slot.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUZZLE_PATH = path.join(__dirname, '../data/crosswords/minicrossword.json');
const SIZE = 5;
const BLOCK = '#';
const DIFFICULTIES = ['easy', 'medium', 'hard'];
let byDifficulty = { easy: [], medium: [], hard: [] };
let allPuzzles = [];
// ── Slot extraction & numbering ───────────────────────────────────────────────
// Returns true when (r,c) is a letter cell (not a black square).
function isCell(grid, r, c) {
return r >= 0 && r < SIZE && c >= 0 && c < SIZE && grid[r][c] !== BLOCK;
}
// Walks the grid in reading order and builds the numbered across/down slots.
// A cell starts an across word when it has no playable neighbour to its left and
// at least one to its right; likewise a down word vertically. Both kinds of
// starting cell share a single incrementing clue number (standard convention).
function deriveSlots(grid) {
const across = [];
const down = [];
let number = 0;
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (!isCell(grid, r, c)) continue;
const startsAcross = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1);
const startsDown = !isCell(grid, r - 1, c) && isCell(grid, r + 1, c);
if (!startsAcross && !startsDown) continue;
number += 1;
if (startsAcross) {
let answer = '';
let cc = c;
while (isCell(grid, r, cc)) { answer += grid[r][cc]; cc += 1; }
across.push({ number, row: r, col: c, len: answer.length, answer });
}
if (startsDown) {
let answer = '';
let rr = r;
while (isCell(grid, rr, c)) { answer += grid[rr][c]; rr += 1; }
down.push({ number, row: r, col: c, len: answer.length, answer });
}
}
}
return { across, down };
}
// ── Validation ────────────────────────────────────────────────────────────────
function validatePuzzle(p) {
if (!Array.isArray(p.grid) || p.grid.length !== SIZE) {
throw new Error(`puzzle ${p.id}: grid must have ${SIZE} rows`);
}
for (const row of p.grid) {
if (typeof row !== 'string' || row.length !== SIZE || !/^[A-Z#]{5}$/.test(row)) {
throw new Error(`puzzle ${p.id}: each row must be ${SIZE} chars of A-Z or '#'`);
}
}
const { across, down } = deriveSlots(p.grid);
if (!Array.isArray(p.across) || p.across.length !== across.length) {
throw new Error(`puzzle ${p.id}: expected ${across.length} across clues, got ${p.across?.length}`);
}
if (!Array.isArray(p.down) || p.down.length !== down.length) {
throw new Error(`puzzle ${p.id}: expected ${down.length} down clues, got ${p.down?.length}`);
}
return { across, down };
}
// ── Initialization ────────────────────────────────────────────────────────────
export function initMiniCrosswordPuzzles() {
byDifficulty = { easy: [], medium: [], hard: [] };
allPuzzles = [];
let raw;
try {
raw = fs.readFileSync(PUZZLE_PATH, 'utf8');
} catch {
console.warn('[words] Mini Crossword puzzle bank not found.');
return { puzzles: 0 };
}
const bank = JSON.parse(raw);
for (const p of bank) {
validatePuzzle(p);
const diff = DIFFICULTIES.includes(p.difficulty) ? p.difficulty : 'medium';
byDifficulty[diff].push(p);
allPuzzles.push(p);
}
return { puzzles: allPuzzles.length };
}
// ── Puzzle selection ──────────────────────────────────────────────────────────
// Returns a random puzzle for the requested difficulty, packaged with derived
// numbering. Each clue entry carries its number, start cell, length and answer.
export function getPuzzle(difficulty = 'medium') {
const bucket = byDifficulty[difficulty]?.length ? byDifficulty[difficulty] : allPuzzles;
if (!bucket.length) {
return { id: null, difficulty, rows: SIZE, cols: SIZE, grid: [], across: [], down: [] };
}
const p = bucket[Math.floor(Math.random() * bucket.length)];
const { across, down } = deriveSlots(p.grid);
return {
id: p.id,
difficulty: p.difficulty,
rows: SIZE,
cols: SIZE,
grid: p.grid,
across: across.map((slot, i) => ({ ...slot, clue: p.across[i] })),
down: down.map((slot, i) => ({ ...slot, clue: p.down[i] })),
};
}

View File

@ -19,6 +19,7 @@ import {
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
@ -155,6 +156,10 @@ function loadWordLists() {
const beeStats = initSpellingBeeDictionary(allWords);
console.log(`[words] loaded ${beeStats.words} Spelling Bee words (${beeStats.pangrams} pangram sets)`);
// Mini Crossword: curated 5x5 puzzle bank (independent of the ENABLE list).
const crosswordStats = initMiniCrosswordPuzzles();
console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`);
// 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));
@ -208,6 +213,16 @@ router.get('/spellingbee/start', (req, res) => {
res.json(spellingBeeGenerate(difficulty));
});
// ── Mini Crossword ────────────────────────────────────────────────────────────
// GET /api/words/minicrossword/start?difficulty=easy|medium|hard
// Returns a curated 5x5 puzzle (grid + numbered across/down clues with answers).
router.get('/minicrossword/start', (req, res) => {
const VALID = ['easy', 'medium', 'hard'];
const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'medium';
res.json(miniCrosswordGet(difficulty));
});
// ── Scrabble ──────────────────────────────────────────────────────────────────
// POST /api/words/scrabble/validate { words: string[] }