feat: add Ghost word game with server-side AI and skill profiles

- Implement client and server logic for Ghost game (`GhostGame`, `GhostLogic`, `GhostAI`, `ghostEngine`).
- Add perfect-play search and 5-tier skill system to the server-side dictionary engine.
- Register Ghost in the game registry and route it through `GameRoomScene`.
- Add `playIntro` option to `Portrait.js` and `skipIntro` to Wordle to control intro speeches on round restarts.
- Improve Scrabble rack reordering layout and gap handling during drag-and-drop.
- Expose Ghost API endpoints (`/ghost/judge`, `/ghost/ai-move`) in `wordRoutes.js`.
This commit is contained in:
Brian Fertig 2026-05-28 19:05:36 -06:00
parent dd384d6c15
commit 371724dc84
12 changed files with 785 additions and 19 deletions

View File

@ -0,0 +1,40 @@
// Thin client wrapper around the server Ghost solver. The trie + perfect-play
// search live in server/words/ghostEngine.js; here we just ship the fragment and
// pace the AI so turns feel deliberate.
import { api } from '../../services/api.js';
// Ask the server for the AI's next letter. Resolves to:
// { letter, isWord, isPrefix } (letter is null when the AI is at a dead end)
export async function requestAILetter({ fragment, skill }) {
try {
return await api.post('/words/ghost/ai-move', { fragment, skill });
} catch (err) {
console.error('[ghost] ai-move request failed:', err);
return { letter: null, isWord: false, isPrefix: false };
}
}
// Grade a fragment a player just formed: { isWord, isPrefix }.
export async function judgeFragment(fragment) {
try {
return await api.post('/words/ghost/judge', { fragment });
} catch (err) {
console.error('[ghost] judge request failed:', err);
return { isWord: false, isPrefix: true }; // fail open: treat as a safe continuation
}
}
// "Thinking" pause before the AI commits, in ms. Stronger players act faster.
const THINK_DELAY = {
1: [1600, 2600],
2: [1300, 2200],
3: [1000, 1800],
4: [800, 1400],
5: [550, 1100],
};
export function nextThinkDelay(skill) {
const [min, max] = THINK_DELAY[skill] ?? THINK_DELAY[3];
return min + Math.random() * (max - min);
}

View File

@ -0,0 +1,462 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { api } from '../../services/api.js';
import { auth } from '../../services/auth.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
GHOST_LETTERS, createInitialState, appendLetter, concedeRound, other,
} from './GhostLogic.js';
import { requestAILetter, judgeFragment, nextThinkDelay } from './GhostAI.js';
// ── Layout ───────────────────────────────────────────────────────────────────
const TILE_W = 84;
const TILE_H = 96;
const GAP = 10;
const ROW_Y = 440;
const PLR_X = 200;
const AI_X = GAME_WIDTH - 200;
const PORT_Y = 300;
const PORT_R = 78;
const KEY_W = 64;
const KEY_H = 74;
const KEY_G = 8;
const KBD_Y = 660;
const DEPTH = { bg: 0, tile: 2, ui: 10 };
const VD = DEPTH.ui + 20;
const C = {
tileBg: 0x1a1a1b,
border: 0x565758,
player: 0x538d4e, // green — letters you played
ai: 0xb59f3b, // gold — letters the AI played
key: 0x818384,
keyDim: 0x3a3a3c,
};
const KEYBOARD_ROWS = [
['Q','W','E','R','T','Y','U','I','O','P'],
['A','S','D','F','G','H','J','K','L'],
['Z','X','C','V','B','N','M'],
];
const TARGET = GHOST_LETTERS.length; // 5 — spelling GHOST loses the match
// ── Scene ──────────────────────────────────────────────────────────────────────
export default class GhostGame extends Phaser.Scene {
constructor() { super('GhostGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.opponent = data.opponents?.[0] ?? null;
this.skill = this.opponent?.skill ?? 3;
this.playerGhost = data.playerGhost ?? 0; // letters earned (losses)
this.aiGhost = data.aiGhost ?? 0;
this.startingPlayer = data.startingPlayer ?? 'player';
this.skipIntro = data.skipIntro ?? false; // suppress intro speech on round restarts
this.gs = null;
this.tileObjs = [];
this.busy = false; // an async judge / AI turn is in flight
this.roundEnded = false;
this.aiTimer = null;
this.opponentPortrait = null;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
this.gs = createInitialState({ startingPlayer: this.startingPlayer });
this.buildBackground();
this.buildPortraits();
this.buildMeters();
this.buildKeyboard();
this.buildTexts();
this.buildControls();
this.setupInput();
if (this.gs.turn === 'ai') this.scheduleAITurn();
else this.setTurnIndicator('player');
}
// ── Build ──────────────────────────────────────────────────────────────────
buildParticleTexture() {
if (this.textures.exists('ghostParticle')) return;
const g = this.make.graphics({ add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('ghostParticle', 10, 10);
g.destroy();
}
buildBackground() {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg)
.setDepth(DEPTH.bg);
this.add.text(GAME_WIDTH / 2, 70, 'GHOST', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.add.text(GAME_WIDTH / 2, 116, 'add a letter — dont finish a word', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildPortraits() {
createPlayerPortrait(this, PLR_X, PORT_Y, PORT_R, DEPTH.ui, 'GhostGame');
this.add.text(PLR_X, PORT_Y + PORT_R + 12, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 1);
this.opponentPortrait = createOpponentPortrait(this, this.opponent, AI_X, PORT_Y, PORT_R, DEPTH.ui, { playIntro: !this.skipIntro });
this.add.text(AI_X, PORT_Y + PORT_R + 12, this.opponent?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 1);
}
buildMeters() {
const my = PORT_Y + PORT_R + 56;
this.playerMeter = this.createMeter(PLR_X, my);
this.aiMeter = this.createMeter(AI_X, my);
this.updateMeter(this.playerMeter, this.playerGhost);
this.updateMeter(this.aiMeter, this.aiGhost);
}
createMeter(cx, y) {
const SP = 40;
const startX = cx - (TARGET - 1) * SP / 2;
return GHOST_LETTERS.map((ch, i) =>
this.add.text(startX + i * SP, y, ch, {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui).setAlpha(0.25),
);
}
updateMeter(meter, count) {
meter.forEach((t, i) => {
const lit = i < count;
t.setColor(lit ? COLORS.dangerHex : COLORS.mutedHex);
t.setAlpha(lit ? 1 : 0.25);
});
}
buildKeyboard() {
this.keyObjs = {};
KEYBOARD_ROWS.forEach((keys, rowIdx) => {
const rowW = keys.length * (KEY_W + KEY_G) - KEY_G;
let x = GAME_WIDTH / 2 - rowW / 2;
keys.forEach(key => {
const cx = x + KEY_W / 2;
const cy = KBD_Y + rowIdx * (KEY_H + KEY_G) + KEY_H / 2;
x += KEY_W + KEY_G;
const container = this.add.container(cx, cy).setDepth(DEPTH.ui);
const bg = this.add.rectangle(0, 0, KEY_W, KEY_H, C.key);
bg.setInteractive({ cursor: 'pointer', useHandCursor: true });
const lbl = this.add.text(0, 0, key, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
container.add([bg, lbl]);
this.keyObjs[key] = { container, bg, lbl };
bg.on('pointerdown', () => this.playLetter(key));
bg.on('pointerover', () => { if (this.canPlay()) bg.setFillStyle(0x9a9b9c); });
bg.on('pointerout', () => bg.setFillStyle(this.canPlay() ? C.key : C.keyDim));
});
});
this.refreshKeyboardEnabled();
}
buildTexts() {
this.turnText = this.add.text(GAME_WIDTH / 2, 210, '', {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.statusText = this.add.text(GAME_WIDTH / 2, ROW_Y + TILE_H / 2 + 60, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildControls() {
new Button(this, GAME_WIDTH - 100, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 160, height: 44, fontSize: 20,
}).setDepth(DEPTH.ui);
}
// ── Input ──────────────────────────────────────────────────────────────────
setupInput() {
this.input.keyboard.on('keydown', (evt) => {
if (/^[a-zA-Z]$/.test(evt.key)) this.playLetter(evt.key.toUpperCase());
});
}
canPlay() {
return !this.busy && !this.roundEnded && this.gs?.status === 'playing' && this.gs.turn === 'player';
}
refreshKeyboardEnabled() {
const on = this.canPlay();
for (const key in this.keyObjs) {
this.keyObjs[key].bg.setFillStyle(on ? C.key : C.keyDim);
}
}
setTurnIndicator(who) {
if (who === 'player') {
this.turnText.setText('Your turn');
this.statusText.setText('');
} else {
this.turnText.setText(`${this.opponent?.name ?? 'CPU'} is thinking…`);
}
this.refreshKeyboardEnabled();
}
// ── Turn flow ────────────────────────────────────────────────────────────────
async playLetter(letter) {
if (!this.canPlay()) return;
this.busy = true;
this.refreshKeyboardEnabled();
this.gs = appendLetter(this.gs, letter, 'player');
this.addTile(letter, 'player');
playSound(this, SFX.PENCIL_WRITE);
const { isWord, isPrefix } = await judgeFragment(this.gs.fragment);
if (!this.scene.isActive()) return;
if (isWord) return this.endRound('player', 'word');
if (!isPrefix) return this.endRound('player', 'off-dictionary');
this.busy = false;
this.scheduleAITurn();
}
scheduleAITurn() {
if (this.roundEnded) return;
this.busy = true;
this.setTurnIndicator('ai');
this.aiTimer = this.time.delayedCall(nextThinkDelay(this.skill), () => this.doAITurn());
}
async doAITurn() {
if (this.roundEnded) return;
const { letter, isWord, isPrefix } = await requestAILetter({ fragment: this.gs.fragment, skill: this.skill });
if (!this.scene.isActive()) return;
if (letter == null) return this.endRound('ai', 'stumped');
this.gs = appendLetter(this.gs, letter, 'ai');
this.addTile(letter, 'ai');
playSound(this, SFX.PIECE_CLICK);
if (isWord) return this.endRound('ai', 'word');
if (!isPrefix) return this.endRound('ai', 'off-dictionary');
this.busy = false;
this.setTurnIndicator('player');
}
// ── Fragment tiles ───────────────────────────────────────────────────────────
addTile(letter, who) {
const container = this.add.container(0, ROW_Y).setDepth(DEPTH.tile);
const bg = this.add.rectangle(0, 0, TILE_W, TILE_H, who === 'player' ? C.player : C.ai)
.setStrokeStyle(2, C.border);
const label = this.add.text(0, 0, letter, {
fontFamily: 'Righteous', fontSize: '46px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
container.add([bg, label]);
this.tileObjs.push({ container });
this.layoutFragment();
container.setScale(0);
this.tweens.add({ targets: container, scale: 1, duration: 170, ease: 'Back.easeOut' });
}
layoutFragment() {
const n = this.tileObjs.length;
const totalW = n * TILE_W + (n - 1) * GAP;
const startX = GAME_WIDTH / 2 - totalW / 2 + TILE_W / 2;
this.tileObjs.forEach((t, i) => { t.container.x = startX + i * (TILE_W + GAP); });
}
shakeFragment() {
const containers = this.tileObjs.map(t => t.container);
const startXs = containers.map(c => c.x);
this.tweens.add({
targets: containers, x: (_t, _k, _v, i) => startXs[i] + 8,
duration: 50, yoyo: true, repeat: 3, ease: 'Sine.easeInOut',
onComplete: () => containers.forEach((c, i) => { c.x = startXs[i]; }),
});
}
// ── Round end → G-H-O-S-T scoring ─────────────────────────────────────────────
endRound(loser, reason) {
if (this.roundEnded) return;
this.roundEnded = true;
this.busy = true;
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
this.gs = concedeRound(this.gs, loser, reason);
this.refreshKeyboardEnabled();
this.shakeFragment();
playSound(this, SFX.PIECE_CLICK);
if (loser === 'player') this.playerGhost++;
else this.aiGhost++;
const meter = loser === 'player' ? this.playerMeter : this.aiMeter;
const count = loser === 'player' ? this.playerGhost : this.aiGhost;
this.updateMeter(meter, count);
const lit = meter[count - 1];
if (lit) this.tweens.add({ targets: lit, scale: 1.6, duration: 180, yoyo: true, ease: 'Back.easeOut' });
this.opponentPortrait?.playEmotion(loser === 'player' ? 'happy' : 'upset');
const matchOver = this.playerGhost >= TARGET || this.aiGhost >= TARGET;
if (matchOver) this.recordResult(this.aiGhost >= TARGET ? 'win' : 'loss');
this.time.delayedCall(800, () => {
if (matchOver) this.showVictoryScreen(this.aiGhost >= TARGET);
else this.showRoundResult(loser, reason);
});
}
ghostStr(count) {
return GHOST_LETTERS.map((l, i) => (i < count ? l : '·')).join(' ');
}
showRoundResult(loser, reason) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const oppName = this.opponent?.name ?? 'CPU';
const loserName = loser === 'player' ? 'You' : oppName;
const verb = loser === 'player' ? 'earn' : 'earns';
let headline, detail;
if (reason === 'word') {
headline = `${loserName} completed a word`;
detail = `${this.gs.fragment}” is a word`;
} else if (reason === 'off-dictionary') {
headline = `${loserName} ran off the dictionary`;
detail = `${this.gs.fragment}” starts no valid word`;
} else {
headline = `${loserName} got stuck`;
detail = `nothing safe follows “${this.gs.fragment}`;
}
const earnedLetter = GHOST_LETTERS[(loser === 'player' ? this.playerGhost : this.aiGhost) - 1];
const earnLine = `${loserName} ${verb} a “${earnedLetter}`;
const seriesLine = `You ${this.ghostStr(this.playerGhost)} ${oppName} ${this.ghostStr(this.aiGhost)}`;
const panel = this.add.rectangle(cx, cy, 720, 240, 0x0a0e14, 0.94)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.ui + 10);
const t1 = this.add.text(cx, cy - 70, headline, {
fontFamily: 'Righteous', fontSize: '36px',
color: loser === 'player' ? COLORS.dangerHex : '#6aff88',
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
const t2 = this.add.text(cx, cy - 18, detail, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
const t3 = this.add.text(cx, cy + 22, earnLine, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
const t4 = this.add.text(cx, cy + 64, seriesLine, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
this.time.delayedCall(2600, () => {
[panel, t1, t2, t3, t4].forEach(o => o.destroy());
this.startNextRound();
});
}
startNextRound() {
this.scene.restart({
...this._initData,
playerGhost: this.playerGhost,
aiGhost: this.aiGhost,
startingPlayer: other(this.startingPlayer),
skipIntro: true,
});
}
// ── Victory ────────────────────────────────────────────────────────────────
showVictoryScreen(playerWon) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const PW = 840, PH = 560;
const top = cy - PH / 2, bot = cy + PH / 2;
const fw = this.add.particles(cx, cy, 'ghostParticle', {
speed: { min: 80, max: 480 }, lifespan: 1400,
scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 },
quantity: 3, frequency: 35, angle: { min: 0, max: 360 },
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44],
emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-GAME_WIDTH / 2, -GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT) },
}).setDepth(VD - 1);
this.time.delayedCall(3200, () => { fw.stop(); this.time.delayedCall(1400, () => fw.destroy()); });
this.add.rectangle(cx, cy, PW, PH, 0x0a0e14, 0.94).setStrokeStyle(3, COLORS.accent).setDepth(VD);
this.add.text(cx, top + 62, playerWon ? 'You Win!' : `${this.opponent?.name ?? 'CPU'} Wins!`, {
fontFamily: 'Righteous', fontSize: '46px', color: playerWon ? '#ffd700' : COLORS.textHex,
}).setOrigin(0.5).setDepth(VD + 1);
const WIN_R = 92, LOSE_R = 64;
const portY = top + 215;
const plrX = cx - 195, oppX = cx + 195;
const plrPortrait = createPlayerPortrait(this, plrX, portY, playerWon ? WIN_R : LOSE_R, VD + 2, 'GhostGame');
this.add.text(plrX, portY + (playerWon ? WIN_R : LOSE_R) + 12, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(VD + 2);
if (!playerWon) plrPortrait.fadeToEliminated(900);
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, playerWon ? LOSE_R : WIN_R, VD + 2, { playIntro: false });
this.add.text(oppX, portY + (playerWon ? LOSE_R : WIN_R) + 12, this.opponent?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(VD + 2);
if (playerWon) oppPortrait.fadeToEliminated(900); else oppPortrait.playEmotion('happy');
this.add.text(cx, portY + WIN_R + 64,
`You ${this.ghostStr(this.playerGhost)} ${this.opponent?.name ?? 'CPU'} ${this.ghostStr(this.aiGhost)}`, {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(VD + 1);
const btnsY = bot - 64;
new Button(this, cx - 130, btnsY, 'Play Again', () => {
this.scene.restart({ ...this._initData, playerGhost: 0, aiGhost: 0, startingPlayer: 'player', skipIntro: false });
}, { width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
}
// ── History ──────────────────────────────────────────────────────────────────
async recordResult(result) {
try {
await api.post('/history/single-player', {
slug: 'ghost',
score: result === 'win' ? 100 : 0,
opponentScores: [0],
result,
});
} catch { /* best effort */ }
}
shutdown() {
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
this.opponentPortrait?.destroy();
}
}

View File

@ -0,0 +1,43 @@
// Pure Ghost logic — no Phaser, no network. Word judging (did a letter complete a
// word / go off-dictionary) is decided by the server, then recorded here via
// concedeRound; this module only tracks the fragment, whose turn it is, and the
// running G-H-O-S-T score.
export const MIN_LEN = 4;
export const GHOST_LETTERS = ['G', 'H', 'O', 'S', 'T'];
export function other(who) {
return who === 'player' ? 'ai' : 'player';
}
export function createInitialState({ startingPlayer = 'player' } = {}) {
return {
fragment: '',
turn: startingPlayer, // 'player' | 'ai'
history: [], // [{ who, letter }]
status: 'playing', // 'playing' | 'over'
loser: null, // 'player' | 'ai'
reason: null, // 'word' | 'off-dictionary' | 'stumped'
};
}
// Append a letter played by `who`, then hand the turn to the other player.
export function appendLetter(state, letter, who) {
if (state.status !== 'playing') return state;
const L = letter.toUpperCase();
return {
...state,
fragment: state.fragment + L,
history: [...state.history, { who, letter: L }],
turn: other(who),
};
}
// End the round with a loser and a reason.
export function concedeRound(state, loser, reason) {
return { ...state, status: 'over', loser, reason };
}
export function isRoundOver(state) {
return state.status === 'over';
}

View File

@ -293,14 +293,20 @@ export default class ScrabbleGame extends Phaser.Scene {
// ── Rack (human) ─────────────────────────────────────────────────────────────
// Shared tray geometry: x of slot 0's center, and center-to-center slot pitch.
rackLayout() {
const w = RACK_SIZE * RACK_TILE + (RACK_SIZE - 1) * RACK_GAP;
return { startX: GAME_WIDTH / 2 - w / 2 + RACK_TILE / 2, slot: RACK_TILE + RACK_GAP };
}
renderRack() {
for (const o of this.rackObjs) o.destroy();
this.rackObjs = [];
this._dragInsertIndex = null;
const rack = this.players[0].rack;
const w = RACK_SIZE * RACK_TILE + (RACK_SIZE - 1) * RACK_GAP;
const startX = GAME_WIDTH / 2 - w / 2 + RACK_TILE / 2;
const { startX, slot } = this.rackLayout();
rack.forEach((token, i) => {
const x = startX + i * (RACK_TILE + RACK_GAP);
const x = startX + i * slot;
const letter = token === BLANK ? '' : token;
const value = token === BLANK ? 0 : (LETTER_VALUES[token] ?? 0);
const tile = this.makeTile(RACK_TILE, letter, value);
@ -326,14 +332,28 @@ export default class ScrabbleGame extends Phaser.Scene {
if (!this.canHumanAct() || this.exchangeMode || obj._rackIndex === undefined) return;
obj.setDepth(DEPTH.drag);
obj._dragging = true;
obj._fromIndex = obj._rackIndex;
this._dragInsertIndex = obj._fromIndex;
});
this.input.on('drag', (_p, obj, dx, dy) => {
if (!obj._dragging) return;
obj.setPosition(dx, dy);
const sq = this.squareAt(obj.x, obj.y);
if (sq && this.squareFree(sq.row, sq.col)) {
// Pointer is over a free board square — close any tray gap we opened.
this.clearRackGap(obj);
} else if (this.inRackZone(obj.y)) {
// Tray-reorder mode — slide neighbours aside to reveal the drop slot.
this.updateRackGap(obj, this.rackInsertIndexAt(obj.x));
} else {
// Dead zone (e.g. over an occupied square) — no gap.
this.clearRackGap(obj);
}
});
this.input.on('dragend', (_p, obj) => {
if (!obj._dragging) return;
obj._dragging = false;
const insert = this._dragInsertIndex;
const sq = this.squareAt(obj.x, obj.y);
if (sq && this.squareFree(sq.row, sq.col)) {
// Defer: placeTentative rebuilds the rack and destroys this very tile,
@ -341,13 +361,73 @@ export default class ScrabbleGame extends Phaser.Scene {
obj.setVisible(false);
const token = obj._token, row = sq.row, col = sq.col;
this.time.delayedCall(0, () => this.placeTentative(token, row, col));
} else if (this.inRackZone(obj.y) && insert !== null && insert !== obj._fromIndex) {
this.reorderRack(obj._fromIndex, insert);
} else {
// No move — snap the dragged tile home and close the gap.
obj.setDepth(DEPTH.tile);
this.clearRackGap(obj);
this.tweens.add({ targets: obj, x: obj._homeX, y: obj._homeY, duration: 140, ease: 'Back.easeOut' });
}
});
}
// True when y is below the board, i.e. in the tray region (not over the grid).
inRackZone(y) {
return y > BOARD_Y0 + BOARD_PX;
}
// Which rack slot would the dragged tile land on, given its current x.
rackInsertIndexAt(x) {
const { startX, slot } = this.rackLayout();
const max = this.players[0].rack.length - 1;
const i = Math.round((x - startX) / slot);
return Math.max(0, Math.min(max, i));
}
// Lay out every non-dragged tile, leaving slot `insertIndex` empty for the drop.
updateRackGap(draggedObj, insertIndex) {
this._dragInsertIndex = insertIndex;
const { startX, slot } = this.rackLayout();
// Tiles in their natural rack order, excluding the one being dragged.
const others = this.rackObjs.filter(t => t !== draggedObj);
let s = 0;
for (const tile of others) {
if (s === insertIndex) s++; // reserve the gap slot
const tx = startX + s * slot;
if (tile._homeX !== tx) { // only retween when the target changes
tile._homeX = tx;
this.tweens.add({ targets: tile, x: tx, duration: 120, ease: 'Cubic.easeOut' });
}
s++;
}
}
// Restore all non-dragged tiles to their natural slots (gap closed).
clearRackGap(draggedObj) {
if (this._dragInsertIndex === null) return;
this._dragInsertIndex = null;
const { startX, slot } = this.rackLayout();
for (const tile of this.rackObjs) {
if (tile === draggedObj) continue;
const tx = startX + tile._rackIndex * slot;
if (tile._homeX !== tx) {
tile._homeX = tx;
this.tweens.add({ targets: tile, x: tx, duration: 120, ease: 'Cubic.easeOut' });
}
}
}
reorderRack(fromIndex, toIndex) {
const rack = this.players[0].rack;
const [tok] = rack.splice(fromIndex, 1);
// `toIndex` is the desired final-array slot for the tile; updateRackGap laid the
// remaining tiles out around that slot, so insert directly there.
rack.splice(toIndex, 0, tok);
playSound(this, SFX.PIECE_CLICK);
this.renderRack();
}
squareAt(x, y) {
const col = Math.floor((x - BOARD_X0) / SQ);
const row = Math.floor((y - BOARD_Y0) / SQ);

View File

@ -69,6 +69,7 @@ export default class WordleGame extends Phaser.Scene {
this.playfield = data.playfield ?? null;
this.playerWins = data.playerWins ?? 0; // series score
this.aiWins = data.aiWins ?? 0;
this.skipIntro = data.skipIntro ?? false; // suppress intro speech on round restarts
// round-level state
this.gs = null;
@ -159,7 +160,7 @@ export default class WordleGame extends Phaser.Scene {
}).setOrigin(0.5, 0).setDepth(depth + 1);
const opx = GAME_WIDTH - 120;
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth);
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth, { playIntro: !this.skipIntro });
this.add.text(opx, py + r + 10, this.opponent?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(depth + 1);
@ -337,12 +338,9 @@ export default class WordleGame extends Phaser.Scene {
this.updateKeyboardColors(this.gs);
playSound(this, SFX.PENCIL_WRITE);
const { over, won } = isGameOver(this.gs);
const { over } = isGameOver(this.gs);
if (over) {
this.playerDone = true;
if (!this.aiDone) {
this.opponentPortrait?.playEmotion(won ? 'upset' : 'happy');
}
this.animating = false;
this.checkAndHandleGameOver();
} else {
@ -377,12 +375,9 @@ export default class WordleGame extends Phaser.Scene {
await this.animateTileFlip(this.aiTiles[row], guess, evaluation);
playSound(this, SFX.PIECE_CLICK);
const { over, won } = isGameOver(this.aiGs);
const { over } = isGameOver(this.aiGs);
if (over) {
this.aiDone = true;
if (!this.playerDone) {
this.opponentPortrait?.playEmotion(won ? 'happy' : 'upset');
}
this.checkAndHandleGameOver();
} else {
this.scheduleAITurn();
@ -498,6 +493,9 @@ export default class WordleGame extends Phaser.Scene {
isDraw = true;
}
// Opponent reacts to the finished round: happy if the AI won, upset if it lost.
if (!isDraw) this.opponentPortrait?.playEmotion(roundWon ? 'upset' : 'happy');
// Update series scores (draws give no point)
if (!isDraw) {
if (roundWon) this.playerWins++;
@ -559,7 +557,7 @@ export default class WordleGame extends Phaser.Scene {
}
startNextRound() {
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins });
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins, skipIntro: true });
}
// ── Fireworks victory screen ───────────────────────────────────────────────
@ -621,7 +619,7 @@ export default class WordleGame extends Phaser.Scene {
if (!playerWon) plrPortrait.fadeToEliminated(900);
// Opponent portrait (right)
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2);
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2, { playIntro: false });
this.add.text(oppX, portY + oppR + 12, this.opponent?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(VD + 2);
@ -636,7 +634,7 @@ export default class WordleGame extends Phaser.Scene {
// ── Buttons ────────────────────────────────────────────────────────────
const btnsY = bot - 64;
new Button(this, cx - 130, btnsY, 'Play Again', () => {
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0 });
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0, skipIntro: false });
}, { width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'),

View File

@ -33,6 +33,7 @@ import CheckersGame from './games/checkers/CheckersGame.js';
import ChessGame from './games/chess/ChessGame.js';
import WordleGame from './games/wordle/WordleGame.js';
import ScrabbleGame from './games/scrabble/ScrabbleGame.js';
import GhostGame from './games/ghost/GhostGame.js';
const config = {
type: Phaser.AUTO,
@ -79,6 +80,7 @@ const config = {
ChessGame,
WordleGame,
ScrabbleGame,
GhostGame,
],
};

View File

@ -17,7 +17,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', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame' };
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', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

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

View File

@ -38,7 +38,7 @@ function drawBacking(scene, x, y, radius, depth) {
// ── Opponent portrait (video + sprite fallback) ───────────────────────────────
// Returns { playEmotion(emotion), destroy() }
export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth) {
export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth, { playIntro = true } = {}) {
injectEmotionStyles();
const size = radius * 2;
@ -220,7 +220,7 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
scene.events.once('shutdown', destroy);
if (opponent?.speech?.intro?.length) {
if (playIntro && opponent?.speech?.intro?.length) {
const clips = opponent.speech.intro;
enqueueSpeech(clips[Math.floor(Math.random() * clips.length)], {
onStart: () => startVisualizer('intro'),

View File

@ -46,3 +46,4 @@ registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlay
registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });

116
server/words/ghostEngine.js Normal file
View File

@ -0,0 +1,116 @@
// Server-side Ghost dictionary + perfect-play AI.
//
// Ghost: players alternate appending one letter to a shared fragment. The player
// who appends a letter LOSES the round if that letter completes a valid word
// (length >= MIN_LEN) or makes the fragment no longer a prefix of any valid word.
//
// We build a trie of every ENABLE word of length >= MIN_LEN and precompute, once,
// a game-theoretic value per node: `node.loss` is true when the player whose turn
// it is to move FROM that fragment loses under optimal play. With that table every
// AI move is O(26): walk to the fragment's node and read its children's values.
const MIN_LEN = 4;
let TRIE = null;
export function initGhostDictionary(words) {
TRIE = { children: Object.create(null), terminal: false, loss: false };
for (const w of words) {
if (w.length < MIN_LEN) continue;
let node = TRIE;
for (const ch of w) {
node = node.children[ch] || (node.children[ch] = { children: Object.create(null), terminal: false, loss: false });
}
node.terminal = true;
}
computeLoss(TRIE);
}
export function dictionaryReady() {
return TRIE !== null;
}
// Post-order: a node is a LOSS for the player to move iff it has no "winning" move.
// A winning move is appending a letter L whose child is NOT a word (a safe
// continuation) AND hands the opponent a losing position. Letters whose child is
// terminal complete a word and so lose for the mover — never winning moves.
function computeLoss(node) {
let win = false;
for (const L in node.children) {
const child = node.children[L];
computeLoss(child);
if (!child.terminal && child.loss) win = true;
}
node.loss = !win;
}
function nodeFor(fragment) {
let node = TRIE;
for (const ch of fragment) {
node = node.children[ch];
if (!node) return null;
}
return node;
}
// ── Public: judge a completed fragment (used to grade the human's letter) ──────
// Returns { isWord, isPrefix }:
// isWord — fragment is a complete valid word (length >= MIN_LEN) -> mover loses
// isPrefix — fragment is a prefix of some valid word; if false the mover went
// "off-dictionary" and loses.
export function judge(fragment) {
const F = String(fragment).toUpperCase();
const node = nodeFor(F);
if (!node) return { isWord: false, isPrefix: false };
return { isWord: !!node.terminal, isPrefix: true };
}
// ── Public: choose the AI's letter ─────────────────────────────────────────────
// Skill profiles. `blunder` = chance of a careless pick from ALL legal letters
// (which may complete a word and lose). `foresight` = chance, on a careful turn,
// of choosing a move that provably hands the opponent a losing position.
const SKILL = {
5: { blunder: 0.00, foresight: 1.0 },
4: { blunder: 0.05, foresight: 1.0 },
3: { blunder: 0.18, foresight: 0.5 },
2: { blunder: 0.40, foresight: 0.0 },
1: { blunder: 0.65, foresight: 0.0 },
};
// Returns { letter, isWord, isPrefix } for the resulting fragment, mirroring judge().
// letter is null only when the AI is at a dead end (should already have ended).
export function chooseLetter(fragment, skill) {
const F = String(fragment).toUpperCase();
const node = nodeFor(F);
if (!node) return { letter: null, isWord: false, isPrefix: false };
const legal = Object.keys(node.children); // every child is a valid prefix
if (legal.length === 0) return { letter: null, isWord: false, isPrefix: false };
const profile = SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3];
const safe = legal.filter(L => !node.children[L].terminal); // don't complete a word
let letter;
if (safe.length === 0) {
// Forced: every legal letter completes a word -> the AI self-loses this turn.
letter = randomFrom(legal);
} else if (Math.random() < profile.blunder) {
// Careless: pick any legal letter, which may accidentally complete a word.
letter = randomFrom(legal);
} else {
// Careful: never complete a word; use foresight to hand off a losing position.
const winning = safe.filter(L => node.children[L].loss);
letter = (winning.length && Math.random() < profile.foresight)
? randomFrom(winning)
: randomFrom(safe);
}
const child = node.children[letter];
return { letter, isWord: !!child.terminal, isPrefix: true };
}
function randomFrom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}

View File

@ -3,6 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { initScrabbleDictionary, isValidWord, chooseMove } from './scrabbleEngine.js';
import { initGhostDictionary, judge as ghostJudge, chooseLetter as ghostChooseLetter } from './ghostEngine.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
@ -119,6 +120,11 @@ function loadWordLists() {
initScrabbleDictionary(scrabbleWords);
console.log(`[words] loaded ${scrabbleWords.size} Scrabble words (215 letters)`);
// Ghost dictionary: every ENABLE word of length >= 4 (shorter words don't count).
const ghostWords = allWords.filter(w => w.length >= 4 && /^[A-Z]+$/.test(w));
initGhostDictionary(ghostWords);
console.log(`[words] loaded ${ghostWords.length} Ghost words (4+ letters)`);
// 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));
@ -179,4 +185,22 @@ router.post('/scrabble/ai-move', (req, res) => {
res.json(move);
});
// ── Ghost ───────────────────────────────────────────────────────────────────
// POST /api/words/ghost/judge { fragment: string }
// Grades the fragment a player just formed: a completed word, or off-dictionary,
// both lose the round for whoever made the move.
router.post('/ghost/judge', (req, res) => {
const fragment = String(req.body?.fragment ?? '');
res.json(ghostJudge(fragment));
});
// POST /api/words/ghost/ai-move { fragment: string, skill: number }
// Returns the AI's letter and how the resulting fragment grades.
router.post('/ghost/ai-move', (req, res) => {
const fragment = String(req.body?.fragment ?? '');
const skill = Number(req.body?.skill) || 3;
res.json(ghostChooseLetter(fragment, skill));
});
export default router;