feat: add Dot Link puzzle game

Introduce Dot Link, a Flow-Free-style single-player puzzle game where players connect matching colored dots to fill the grid. Includes:
- Pure JS puzzle engine with deterministic board generation, DFS solver, and win validation (`DotLinkLogic.js`)
- Phaser 3 UI scene featuring a cyberpunk/synthwave theme, level select, daily runs, hint system, and smooth path-drawing mechanics
- Deterministic bank of 100 hand-tuned puzzles (`dotlink.json`)
- Node scripts for puzzle generation and verification
- Full integration into the app's preload, routing, and game registry
This commit is contained in:
Brian Fertig 2026-06-14 05:18:03 -06:00
parent f5b0c545f4
commit 59ee3eca0c
11 changed files with 41329 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

39833
public/data/dotlink.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,928 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import {
makeRng, dateSeed, localDateString, generateBoard, isSolved, manhattanAdjacent,
} from './DotLinkLogic.js';
// ── Cyberpunk / synthwave palette ───────────────────────────────────────────
const C = {
bg: 0x05060f,
bgGlow: 0x1a0a3a,
sun: 0xff2d95,
grid: 0x2b1a55,
panel: 0x0a0b1e,
panelEdge: 0x3a2c66,
neon: 0x00f0ff,
neonHex: '#00f0ff',
magenta: 0xff2bd6,
magentaHex: '#ff2bd6',
textHex: '#d8e8ff',
dimHex: '#7a86b8',
goldHex: '#ffd24a',
};
// Up to 12 distinct neon flow colours.
const FLOW = [
{ n: 0x00f0ff, hex: '#00f0ff' }, // cyan
{ n: 0xff2bd6, hex: '#ff2bd6' }, // magenta
{ n: 0x39ff14, hex: '#39ff14' }, // lime
{ n: 0xff8a00, hex: '#ff8a00' }, // orange
{ n: 0xb14aff, hex: '#b14aff' }, // violet
{ n: 0xffe600, hex: '#ffe600' }, // yellow
{ n: 0xff2d6f, hex: '#ff2d6f' }, // hot pink
{ n: 0x2d9bff, hex: '#2d9bff' }, // azure
{ n: 0x00ffc6, hex: '#00ffc6' }, // teal
{ n: 0xff4d4d, hex: '#ff4d4d' }, // red
{ n: 0xc6ff00, hex: '#c6ff00' }, // chartreuse
{ n: 0xffffff, hex: '#ffffff' }, // white
];
const DAILY_STAGES = [
{ key: 'easy', label: 'EASY', rows: 5, cols: 5, colors: 4 },
{ key: 'medium', label: 'MEDIUM', rows: 7, cols: 7, colors: 6 },
{ key: 'difficult', label: 'DIFFICULT', rows: 8, cols: 8, colors: 8 },
{ key: 'hard', label: 'HARD', rows: 10, cols: 10, colors: 9 },
{ key: 'legendary', label: 'LEGENDARY', rows: 11, cols: 11, colors: 11 },
];
const D = { chrome: 0, glow: 1, core: 2, dots: 3, hud: 5, overlay: 20, overlayUI: 22 };
const DAILY_BEST_KEY = 'dotlink-daily-best';
const dailyRecKey = (date) => `dotlink-daily-${date}`;
function lsGet(key) { try { return localStorage.getItem(key); } catch (_) { return null; } }
function lsSet(key, val) { try { localStorage.setItem(key, val); } catch (_) { /* ignore */ } }
function fmtTime(totalSec) {
const m = Math.floor(totalSec / 60);
const s = String(Math.floor(totalSec % 60)).padStart(2, '0');
return `${m}:${s}`;
}
export default class DotLinkGame extends Phaser.Scene {
constructor() { super('DotLinkGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'dotlink', name: 'Dot Link' };
this.bank = [];
this.levelsCompleted = 0;
this.canPersist = true;
this.view = 'home';
// play state
this.board = null;
this.solution = null;
this.geom = null;
this.paths = null;
this.owner = null;
this.endpointGrid = null;
this.draw = null;
this.drawing = false;
this.overlayUp = false;
// daily
this.dailyBoards = null;
this.dailyIndex = 0;
this.dailyElapsed = 0;
this.dailyDate = localDateString();
this.timerEvent = null;
}
async create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.buildTextures();
this.buildBackground();
const raw = this.cache.json.get('dotlink');
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
try {
const res = await api.get('/puzzles/dotlink/progress');
this.levelsCompleted = res?.levelsCompleted ?? 0;
} catch (_) {
this.canPersist = false;
this.levelsCompleted = 0;
}
this.layer = this.add.container(0, 0);
this.registerInput();
this.showHome();
}
// ── Background & textures ───────────────────────────────────────────────────
buildTextures() {
if (!this.textures.exists('dlScan')) {
const s = this.make.graphics({ x: 0, y: 0, add: false });
s.fillStyle(0x00f0ff, 0.06); s.fillRect(0, 0, GAME_WIDTH, 2);
s.generateTexture('dlScan', GAME_WIDTH, 6);
s.destroy();
}
}
buildBackground() {
// Deep gradient via stacked rectangles (indigo -> black).
const top = Phaser.Display.Color.ValueToColor(0x140a33);
const bot = Phaser.Display.Color.ValueToColor(C.bg);
const g = this.add.graphics().setDepth(-20);
const steps = 120;
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1);
const col = Phaser.Display.Color.Interpolate.ColorWithColor(top, bot, 100, Math.round(t * 100));
g.fillStyle(Phaser.Display.Color.GetColor(col.r, col.g, col.b), 1);
g.fillRect(0, Math.floor(t * GAME_HEIGHT), GAME_WIDTH, Math.ceil(GAME_HEIGHT / steps) + 1);
}
// Synthwave "sun" glow low on the screen.
const sun = this.add.graphics().setDepth(-19);
for (let r = 520; r > 0; r -= 18) {
sun.fillStyle(C.sun, 0.015);
sun.fillCircle(GAME_WIDTH / 2, GAME_HEIGHT + 120, r);
}
sun.setBlendMode(Phaser.BlendModes.ADD);
// Perspective grid: horizontals condensing toward a horizon + verticals.
const horizon = GAME_HEIGHT * 0.62;
const grid = this.add.graphics().setDepth(-18);
grid.lineStyle(1, C.grid, 0.55);
for (let i = 1; i <= 22; i++) {
const y = horizon + (i * i) * 1.7;
if (y > GAME_HEIGHT) break;
grid.lineBetween(0, y, GAME_WIDTH, y);
}
for (let i = -14; i <= 14; i++) {
const x = GAME_WIDTH / 2 + i * 64;
grid.lineBetween(GAME_WIDTH / 2 + i * 14, horizon, x, GAME_HEIGHT);
}
grid.setAlpha(0.5);
// Scrolling scanlines.
const scan = this.add.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 'dlScan')
.setDepth(-10).setAlpha(0.5);
scan.setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({ targets: scan, tilePositionY: GAME_HEIGHT, duration: 11000, repeat: -1, ease: 'Linear' });
}
// Neon title with a magenta chromatic-aberration ghost.
neonTitle(cx, y, text, size = 64) {
const main = this.add.text(cx, y, text, {
fontFamily: 'Righteous', fontSize: `${size}px`, color: C.neonHex,
}).setOrigin(0.5);
main.postFX.addGlow(C.neon, 6, 0, false, 0.1, 12);
const ghost = this.add.text(cx + 3, y + 2, text, {
fontFamily: 'Righteous', fontSize: `${size}px`, color: C.magentaHex,
}).setOrigin(0.5).setAlpha(0.35).setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({ targets: ghost, x: cx - 3, duration: 1700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
this.layer.add([ghost, main]);
return main;
}
clearLayer() {
this.drawing = false;
this.draw = null;
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
this.glowGfx = null; this.coreGfx = null; this.dotsGfx = null;
this.timerText = null; this.stageChips = null;
this.layer.removeAll(true);
}
// ── HOME (mode select) ──────────────────────────────────────────────────────
showHome() {
this.view = 'home';
this.board = null;
this.overlayUp = false;
this.clearLayer();
const cx = GAME_WIDTH / 2;
this.neonTitle(cx, 150, 'D O T L I N K', 90);
const sub = this.add.text(cx, 240, 'Link every pair · fill the grid · don\'t cross the streams', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: C.dimHex,
}).setOrigin(0.5);
this.layer.add(sub);
const cardW = 520;
const cardH = 460;
const gap = 80;
const leftX = cx - cardW / 2 - gap / 2;
const rightX = cx + cardW / 2 + gap / 2;
const cardY = 640;
this.modeCard(leftX, cardY, cardW, cardH, C.neon, C.neonHex, 'LEVELS',
`Campaign of 100 hand-picked puzzles\nof rising difficulty.`,
`Cleared ${this.levelsCompleted} / ${this.bank.length}`,
() => this.showLevelSelect());
const best = lsGet(DAILY_BEST_KEY);
const todayDone = lsGet(dailyRecKey(this.dailyDate));
let dailyInfo = best ? `Best run: ${fmtTime(Number(best))}` : 'No run yet';
if (todayDone) dailyInfo += `\nToday: ${fmtTime(Number(todayDone))}`;
this.modeCard(rightX, cardY, cardW, cardH, C.magenta, C.magentaHex, "TODAY'S RUN",
`Five daily puzzles — same for everyone.\nRace the clock for your best time.`,
dailyInfo,
() => this.showDaily());
const back = new Button(this, cx, GAME_HEIGHT - 70, 'Back to Menu', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 240, height: 56, fontSize: 24 });
this.layer.add(back);
}
modeCard(x, y, w, h, accentN, accentHex, title, body, info, onClick) {
const g = this.add.graphics();
const draw = (hover) => {
g.clear();
g.fillStyle(C.panel, 0.92);
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 22);
g.lineStyle(hover ? 5 : 3, accentN, 1);
g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 22);
};
draw(false);
g.postFX.addGlow(accentN, 4, 0, false, 0.06, 10);
const t = this.add.text(x, y - h / 2 + 70, title, {
fontFamily: 'Righteous', fontSize: '56px', color: accentHex,
}).setOrigin(0.5);
const b = this.add.text(x, y - 10, body, {
fontFamily: '"Julius Sans One"', fontSize: '25px', color: C.textHex, align: 'center', lineSpacing: 10,
}).setOrigin(0.5);
const inf = this.add.text(x, y + h / 2 - 86, info, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.goldHex, align: 'center', lineSpacing: 8,
}).setOrigin(0.5);
// Decorative mini dot-pair preview.
const py = y + h / 2 - 150;
const prev = this.add.graphics();
prev.lineStyle(8, accentN, 0.9);
prev.lineBetween(x - 120, py, x + 120, py);
prev.fillStyle(accentN, 1);
prev.fillCircle(x - 120, py, 14);
prev.fillCircle(x + 120, py, 14);
prev.setBlendMode(Phaser.BlendModes.ADD);
const hit = this.add.rectangle(x, y, w, h, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
hit.on('pointerover', () => draw(true));
hit.on('pointerout', () => draw(false));
hit.on('pointerup', () => { playSound(this, SFX.PIECE_CLICK); onClick(); });
this.layer.add([g, t, b, inf, prev, hit]);
}
// ── LEVEL SELECT ────────────────────────────────────────────────────────────
showLevelSelect() {
this.view = 'select';
this.board = null;
this.overlayUp = false;
this.clearLayer();
const cx = GAME_WIDTH / 2;
this.neonTitle(cx, 80, 'L E V E L S', 60);
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
const prog = this.add.text(cx, 138, `Cleared ${this.levelsCompleted} / ${this.bank.length}`, {
fontFamily: 'Righteous', fontSize: '24px', color: C.textHex,
}).setOrigin(0.5);
this.layer.add(prog);
if (!this.bank.length) {
const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node server/scripts/genDotLink.js', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
}).setOrigin(0.5);
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.showHome(), { variant: 'ghost' });
this.layer.add([msg, back]);
return;
}
const COLS = 20;
const SIZE = 78;
const GAP = 10;
const gridW = COLS * SIZE + (COLS - 1) * GAP;
const left = cx - gridW / 2 + SIZE / 2;
const top = 220;
this.bank.forEach((p, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = left + col * (SIZE + GAP);
const y = top + row * (SIZE + GAP);
const level = p.level;
const cleared = level <= this.levelsCompleted;
const playable = level <= nextLevel;
const accent = cleared ? FLOW[2].n : playable ? C.neon : C.panelEdge;
const fill = cleared ? 0x10241a : playable ? 0x0d1430 : 0x0a0b16;
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, accent, 1);
const num = this.add.text(x, y - 6, String(level), {
fontFamily: 'Righteous', fontSize: '26px',
color: playable || cleared ? C.textHex : '#3b4366',
}).setOrigin(0.5);
const tag = this.add.text(x, y + 22, cleared ? '✓' : playable ? `${p.rows}×${p.cols}` : '🔒', {
fontFamily: '"Julius Sans One"', fontSize: '13px',
color: cleared ? '#7df0a8' : playable ? C.dimHex : '#3b4366',
}).setOrigin(0.5);
this.layer.add([tile, num, tag]);
if (playable) {
tile.setInteractive({ useHandCursor: true });
tile.on('pointerover', () => tile.setStrokeStyle(4, C.magenta, 1));
tile.on('pointerout', () => tile.setStrokeStyle(3, accent, 1));
tile.on('pointerup', () => this.playLevel(level));
}
});
const resume = new Button(this, cx - 150, GAME_HEIGHT - 70, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel),
{ width: 300, height: 58, fontSize: 24 });
const back = new Button(this, cx + 180, GAME_HEIGHT - 70, 'Back', () => this.showHome(),
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
const reset = new Button(this, 230, GAME_HEIGHT - 70, 'Reset Progress', () => this.confirmReset(),
{ variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex });
this.layer.add([resume, back, reset]);
if (!this.canPersist) {
const note = this.add.text(cx, GAME_HEIGHT - 24, 'Sign in to save your progress across devices.', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: C.dimHex,
}).setOrigin(0.5);
this.layer.add(note);
}
}
confirmReset() {
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.66).setInteractive().setDepth(D.overlay);
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(C.panel, 0.98); panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20);
panel.lineStyle(3, COLORS.danger, 1); panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20);
const title = this.add.text(cx, cy - 90, 'Reset Progress?', {
fontFamily: 'Righteous', fontSize: '50px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const msg = this.add.text(cx, cy - 8, 'This clears every level you have cleared\nand starts you back at Level 1.', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: C.textHex, align: 'center', lineSpacing: 6,
}).setOrigin(0.5).setDepth(D.overlayUI);
const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => {
api.post('/puzzles/dotlink/reset').catch(() => {});
this.levelsCompleted = 0;
this.showLevelSelect();
}, { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }).setDepth(D.overlayUI);
const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(),
{ variant: 'ghost', width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
this.layer.add([dim, panel, title, msg, yes, no]);
}
// ── DAILY ───────────────────────────────────────────────────────────────────
generateDaily() {
const base = dateSeed(this.dailyDate);
this.dailyBoards = DAILY_STAGES.map((s, i) => {
const rng = makeRng((base ^ Math.imul(0x9e3779b9, i + 1)) >>> 0);
return generateBoard(s.rows, s.cols, s.colors, rng);
});
}
showDaily() {
this.view = 'daily-intro';
this.board = null;
this.overlayUp = false;
this.clearLayer();
if (!this.dailyBoards) this.generateDaily();
const cx = GAME_WIDTH / 2;
this.neonTitle(cx, 110, "TODAY'S RUN", 64);
const dateText = this.add.text(cx, 178, this.dailyDate, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: C.dimHex,
}).setOrigin(0.5);
this.layer.add(dateText);
// Five stage chips.
const chipW = 300; const chipH = 110; const gap = 24;
const totalW = DAILY_STAGES.length * chipW + (DAILY_STAGES.length - 1) * gap;
const startX = cx - totalW / 2 + chipW / 2;
const chipY = 360;
DAILY_STAGES.forEach((s, i) => {
const x = startX + i * (chipW + gap);
const accent = FLOW[i * 2 % FLOW.length].n;
const g = this.add.graphics();
g.fillStyle(C.panel, 0.9); g.fillRoundedRect(x - chipW / 2, chipY - chipH / 2, chipW, chipH, 16);
g.lineStyle(3, accent, 1); g.strokeRoundedRect(x - chipW / 2, chipY - chipH / 2, chipW, chipH, 16);
const lbl = this.add.text(x, chipY - 18, s.label, {
fontFamily: 'Righteous', fontSize: '34px', color: '#' + accent.toString(16).padStart(6, '0'),
}).setOrigin(0.5);
const sz = this.add.text(x, chipY + 28, `${s.rows}×${s.cols} · ${s.colors} links`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: C.dimHex,
}).setOrigin(0.5);
this.layer.add([g, lbl, sz]);
});
const best = lsGet(DAILY_BEST_KEY);
const todayDone = lsGet(dailyRecKey(this.dailyDate));
const info = this.add.text(cx, 500,
`${best ? `Personal best: ${fmtTime(Number(best))}` : 'Set your first time today.'}` +
`${todayDone ? ` · Today solved in ${fmtTime(Number(todayDone))}` : ''}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: C.goldHex,
}).setOrigin(0.5);
const rules = this.add.text(cx, 560,
'Solve all five in order. The clock runs across the whole set.', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.dimHex,
}).setOrigin(0.5);
this.layer.add([info, rules]);
const start = new Button(this, cx, 690, todayDone ? 'Play Again' : 'Start Run', () => this.startDailyRun(),
{ width: 320, height: 66, fontSize: 30 });
const back = new Button(this, cx, 780, 'Back', () => this.showHome(),
{ variant: 'ghost', width: 200, height: 56, fontSize: 24 });
this.layer.add([start, back]);
}
startDailyRun() {
this.dailyIndex = 0;
this.dailyElapsed = 0;
this.startDailyStage(0);
}
startDailyStage(i) {
this.dailyIndex = i;
const stage = DAILY_STAGES[i];
const g = this.dailyBoards[i];
this.view = 'play';
this.beginBoard(g.board, g.solution, {
mode: 'daily',
title: stage.label,
subtitle: `Puzzle ${i + 1} / ${DAILY_STAGES.length}`,
onWin: () => this.onDailyStageSolved(),
});
// (Re)start the run timer on the first stage; keep it running across stages.
if (!this.timerEvent) {
this.timerEvent = this.time.addEvent({
delay: 1000, loop: true, callback: () => {
this.dailyElapsed++;
if (this.timerText) this.timerText.setText(`${fmtTime(this.dailyElapsed)}`);
},
});
}
}
onDailyStageSolved() {
this.overlayUp = true; // freeze input during the transition (clock keeps running)
playSound(this, SFX.VICTORY_SHORT);
if (this.dailyIndex < DAILY_STAGES.length - 1) {
this.flashBanner(`${DAILY_STAGES[this.dailyIndex].label} CLEARED`, () => {
this.startDailyStage(this.dailyIndex + 1);
});
} else {
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
this.finishDailyRun();
}
}
finishDailyRun() {
const total = this.dailyElapsed;
const prevBest = Number(lsGet(DAILY_BEST_KEY) ?? 0);
const isBest = !prevBest || total < prevBest;
if (isBest) lsSet(DAILY_BEST_KEY, String(total));
lsSet(dailyRecKey(this.dailyDate), String(total));
api.post('/history/single-player', {
slug: 'dotlink', score: total, opponentScores: [], result: 'win',
}).catch(() => {});
this.overlayUp = true;
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7).setDepth(D.overlay).setInteractive();
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(C.panel, 0.98); panel.fillRoundedRect(cx - 360, cy - 220, 720, 440, 22);
panel.lineStyle(3, C.magenta, 1); panel.strokeRoundedRect(cx - 360, cy - 220, 720, 440, 22);
panel.postFX.addGlow(C.magenta, 8, 0, false, 0.06, 12);
this.layer.add([dim, panel]);
this.neonTitle(cx, cy - 130, 'RUN COMPLETE', 60);
const stat = this.add.text(cx, cy - 30,
`All five solved in ${fmtTime(total)}` + (isBest ? '\n★ NEW PERSONAL BEST ★' : `\nPersonal best: ${fmtTime(prevBest)}`), {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: isBest ? C.goldHex : C.textHex, align: 'center', lineSpacing: 12,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add(stat);
const again = new Button(this, cx - 130, cy + 110, 'Replay', () => this.showDaily(),
{ width: 220, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI);
const home = new Button(this, cx + 130, cy + 110, 'Home', () => this.showHome(),
{ width: 220, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
this.layer.add([again, home]);
}
flashBanner(text, onDone) {
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
const t = this.add.text(cx, cy, text, {
fontFamily: 'Righteous', fontSize: '72px', color: C.neonHex,
}).setOrigin(0.5).setAlpha(0).setScale(0.7).setDepth(D.overlayUI);
t.postFX.addGlow(C.neon, 8, 0, false, 0.1, 14);
this.layer.add(t);
this.tweens.add({
targets: t, alpha: 1, scale: 1, duration: 360, ease: 'Back.easeOut',
onComplete: () => this.tweens.add({
targets: t, alpha: 0, scale: 1.15, delay: 520, duration: 320,
onComplete: () => { t.destroy(); onDone(); },
}),
});
}
// ── LEVELS PLAY ─────────────────────────────────────────────────────────────
playLevel(level) {
const lv = this.bank.find((p) => p.level === level);
if (!lv) return;
this.level = level;
this.view = 'play';
this.beginBoard({ rows: lv.rows, cols: lv.cols, endpoints: lv.endpoints }, lv.solution, {
mode: 'level',
title: `Level ${level}`,
subtitle: `${lv.rows} × ${lv.cols} · ${lv.colors} links`,
onWin: () => this.onLevelSolved(),
});
}
onLevelSolved() {
this.overlayUp = true;
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
api.post('/puzzles/dotlink/complete', { level: this.level })
.then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); })
.catch(() => {});
api.post('/history/single-player', { slug: 'dotlink', score: this.level, opponentScores: [], result: 'win' }).catch(() => {});
playSound(this, SFX.VICTORY_SHORT);
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.66).setDepth(D.overlay).setInteractive();
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(C.panel, 0.98); panel.fillRoundedRect(cx - 320, cy - 190, 640, 380, 22);
panel.lineStyle(3, FLOW[2].n, 1); panel.strokeRoundedRect(cx - 320, cy - 190, 640, 380, 22);
panel.postFX.addGlow(FLOW[2].n, 8, 0, false, 0.06, 12);
this.layer.add([dim, panel]);
this.neonTitle(cx, cy - 110, 'LINKED!', 64);
const hasNext = this.level < this.bank.length;
const stat = this.add.text(cx, cy - 30, hasNext ? `Level ${this.level} complete` : 'You cleared every level. Legend.', {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: C.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add(stat);
const btns = [];
if (hasNext) {
btns.push(new Button(this, cx, cy + 50, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1),
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI));
}
const replay = new Button(this, cx - 120, cy + 130, 'Replay', () => this.playLevel(this.level),
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
const levels = new Button(this, cx + 120, cy + 130, 'Levels', () => this.showLevelSelect(),
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
btns.push(replay, levels);
this.layer.add(btns);
}
// ── Board setup & rendering ──────────────────────────────────────────────────
beginBoard(board, solution, opts) {
this.clearLayer();
this.board = board;
this.solution = solution ?? null;
this.onWin = opts.onWin;
this.mode = opts.mode;
this.overlayUp = false;
this.drawing = false;
this.draw = null;
const { rows, cols } = board;
this.paths = Array.from({ length: board.endpoints.length }, () => []);
this.owner = new Int16Array(rows * cols).fill(-1);
this.endpointGrid = new Int16Array(rows * cols).fill(-1);
this.endA = []; this.endB = [];
board.endpoints.forEach((e, k) => {
const a = e.a[0] * cols + e.a[1];
const b = e.b[0] * cols + e.b[1];
this.endpointGrid[a] = k; this.endpointGrid[b] = k;
this.endA[k] = a; this.endB[k] = b;
});
this.computeGeom();
this.drawChrome();
this.drawHud(opts.title, opts.subtitle);
// Path graphics layers (back to front): glow then core then dots.
this.glowGfx = this.add.graphics().setBlendMode(Phaser.BlendModes.ADD);
this.coreGfx = this.add.graphics();
this.dotsGfx = this.add.graphics();
this.layer.add([this.glowGfx, this.coreGfx, this.dotsGfx]);
this.drawDots();
this.redrawPaths();
}
computeGeom() {
const { rows, cols } = this.board;
const maxW = 1000; const maxH = 720;
const cell = Math.floor(Math.min(maxW / cols, maxH / rows));
const boardW = cell * cols; const boardH = cell * rows;
const ox = Math.round((GAME_WIDTH - boardW) / 2);
const oy = Math.round(225 + (maxH - boardH) / 2);
this.geom = { cell, ox, oy, boardW, boardH };
}
cellCenter(idx) {
const { cell, ox, oy } = this.geom;
const cols = this.board.cols;
const r = Math.floor(idx / cols); const c = idx % cols;
return { x: ox + c * cell + cell / 2, y: oy + r * cell + cell / 2 };
}
cellFromXY(x, y) {
const { cell, ox, oy } = this.geom;
const { rows, cols } = this.board;
const c = Math.floor((x - ox) / cell);
const r = Math.floor((y - oy) / cell);
if (r < 0 || r >= rows || c < 0 || c >= cols) return -1;
return r * cols + c;
}
drawChrome() {
const { cell, ox, oy, boardW, boardH } = this.geom;
const g = this.add.graphics().setDepth(0);
g.fillStyle(C.panel, 0.85);
g.fillRoundedRect(ox - 18, oy - 18, boardW + 36, boardH + 36, 18);
g.lineStyle(3, C.panelEdge, 1);
g.strokeRoundedRect(ox - 18, oy - 18, boardW + 36, boardH + 36, 18);
g.lineStyle(1, C.grid, 0.9);
for (let c = 0; c <= this.board.cols; c++) g.lineBetween(ox + c * cell, oy, ox + c * cell, oy + boardH);
for (let r = 0; r <= this.board.rows; r++) g.lineBetween(ox, oy + r * cell, ox + boardW, oy + r * cell);
g.postFX.addGlow(C.neon, 3, 0, false, 0.04, 6);
this.layer.add(g);
}
drawHud(title, subtitle) {
const left = this.geom.ox - 18;
const t = this.add.text(left, 96, title, {
fontFamily: 'Righteous', fontSize: '48px', color: C.neonHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
const s = this.add.text(left, 144, subtitle, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.dimHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
this.layer.add([t, s]);
if (this.mode === 'daily') {
this.timerText = this.add.text(this.geom.ox + this.geom.boardW + 18, 96, `${fmtTime(this.dailyElapsed)}`, {
fontFamily: 'Righteous', fontSize: '40px', color: C.goldHex,
}).setOrigin(1, 0.5).setDepth(D.hud);
this.layer.add(this.timerText);
}
// Side controls.
const BTN_W = 160; const BTN_H = 54; const GAP = 12;
const bx = left / 2 + 20;
let by = GAME_HEIGHT / 2 - (3 * BTN_H + 2 * GAP) / 2;
const clear = new Button(this, bx, by, 'Clear', () => this.clearAllPaths(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
by += BTN_H + GAP;
const hint = new Button(this, bx, by, 'Hint', () => this.useHint(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
by += BTN_H + GAP;
const quit = new Button(this, bx, by, this.mode === 'daily' ? 'Quit Run' : 'Levels',
() => (this.mode === 'daily' ? this.showDaily() : this.showLevelSelect()),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
this.layer.add([clear, hint, quit]);
}
drawDots() {
const g = this.dotsGfx;
g.clear();
const rDot = this.geom.cell * 0.30;
for (let k = 0; k < this.board.endpoints.length; k++) {
const col = FLOW[k % FLOW.length].n;
for (const idx of [this.endA[k], this.endB[k]]) {
const { x, y } = this.cellCenter(idx);
g.fillStyle(col, 0.18); g.fillCircle(x, y, rDot * 1.7);
g.fillStyle(col, 1); g.fillCircle(x, y, rDot);
g.fillStyle(0xffffff, 0.55); g.fillCircle(x - rDot * 0.28, y - rDot * 0.28, rDot * 0.34);
}
}
}
redrawPaths() {
const glow = this.glowGfx; const core = this.coreGfx;
glow.clear(); core.clear();
const cell = this.geom.cell;
const coreW = cell * 0.34; const glowW = cell * 0.6;
for (let k = 0; k < this.paths.length; k++) {
const path = this.paths[k];
if (!path.length) continue;
const col = FLOW[k % FLOW.length].n;
this.strokePath(glow, path, col, glowW, 0.22);
this.strokePath(core, path, col, coreW, 1);
}
}
strokePath(g, path, col, width, alpha) {
if (path.length === 1) {
const p = this.cellCenter(path[0]);
g.fillStyle(col, alpha); g.fillCircle(p.x, p.y, width / 2);
return;
}
g.lineStyle(width, col, alpha);
g.beginPath();
for (let i = 0; i < path.length; i++) {
const p = this.cellCenter(path[i]);
if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y);
}
g.strokePath();
// Round the joints/caps.
g.fillStyle(col, alpha);
for (let i = 0; i < path.length; i++) {
const p = this.cellCenter(path[i]);
g.fillCircle(p.x, p.y, width / 2);
}
}
// ── Interaction ──────────────────────────────────────────────────────────────
registerInput() {
this.input.on('pointerdown', (ptr) => this.onPointerDown(ptr));
this.input.on('pointermove', (ptr) => { if (ptr.isDown) this.onPointerMove(ptr); });
this.input.on('pointerup', () => this.onPointerUp());
}
inPlay() { return this.view === 'play' && this.board && !this.overlayUp; }
pathCells(k) { return this.paths[k]; }
colorAt(idx) { return this.owner[idx]; }
resetColor(k) {
for (const cell of this.paths[k]) this.owner[cell] = -1;
this.paths[k] = [];
}
truncateColorTo(k, index) {
const path = this.paths[k];
for (let i = index + 1; i < path.length; i++) this.owner[path[i]] = -1;
path.length = index + 1;
}
onPointerDown(ptr) {
if (!this.inPlay()) return;
const idx = this.cellFromXY(ptr.x, ptr.y);
if (idx < 0) return;
const ec = this.endpointGrid[idx];
if (ec >= 0) {
// Start a fresh path from this endpoint.
this.resetColor(ec);
this.paths[ec] = [idx];
this.owner[idx] = ec;
const start = idx;
const target = start === this.endA[ec] ? this.endB[ec] : this.endA[ec];
this.draw = { color: ec, target };
this.drawing = true;
playSound(this, SFX.PIECE_CLICK);
this.redrawPaths();
return;
}
const oc = this.owner[idx];
if (oc >= 0) {
// Continue an existing path from the touched cell.
const path = this.paths[oc];
const pi = path.indexOf(idx);
if (pi < 0) return;
this.truncateColorTo(oc, pi);
const start = path[0];
const target = start === this.endA[oc] ? this.endB[oc] : this.endA[oc];
this.draw = { color: oc, target };
this.drawing = true;
this.redrawPaths();
}
}
onPointerMove(ptr) {
if (!this.inPlay() || !this.drawing || !this.draw) return;
const target = this.cellFromXY(ptr.x, ptr.y);
if (target < 0) return;
const cols = this.board.cols;
let guard = 0;
while (guard++ < 80) {
const path = this.paths[this.draw.color];
const head = path[path.length - 1];
if (head === target) break;
const next = this.stepToward(head, target, cols);
if (next < 0) break;
const moved = this.tryEnter(next);
if (!moved) break;
if (!this.drawing) break; // completed
}
}
stepToward(head, target, cols) {
const hr = Math.floor(head / cols); const hc = head % cols;
const tr = Math.floor(target / cols); const tc = target % cols;
const dr = tr - hr; const dc = tc - hc;
// Prefer the axis with the larger remaining distance.
if (Math.abs(dr) >= Math.abs(dc) && dr !== 0) return (hr + Math.sign(dr)) * cols + hc;
if (dc !== 0) return hr * cols + (hc + Math.sign(dc));
if (dr !== 0) return (hr + Math.sign(dr)) * cols + hc;
return -1;
}
// Attempt to extend the active path into `next`. Returns true if the path
// changed. Sets this.drawing=false when the link is completed.
tryEnter(next) {
const k = this.draw.color;
const path = this.paths[k];
const head = path[path.length - 1];
if (!manhattanAdjacent(idxToRC(head, this.board.cols), idxToRC(next, this.board.cols))) return false;
const ec = this.endpointGrid[next];
if (ec === k && next === this.draw.target) {
// Completed link.
this.owner[next] = k;
path.push(next);
this.drawing = false;
this.redrawPaths();
this.pulseCell(next, FLOW[k % FLOW.length].n);
playSound(this, SFX.CARD_PLACE);
this.checkWin();
return true;
}
if (ec >= 0) return false; // another colour's dot, or our own start dot
// Already part of our own path -> truncate back to it (backtrack / un-cross).
const selfIdx = path.indexOf(next);
if (selfIdx >= 0) {
this.truncateColorTo(k, selfIdx);
this.redrawPaths();
return true;
}
// Overwriting another colour's pipe: erase it from the touched cell onward.
const oc = this.owner[next];
if (oc >= 0 && oc !== k) this.cutAt(oc, next);
this.owner[next] = k;
path.push(next);
this.redrawPaths();
return true;
}
// Remove `cell` and everything drawn after it from colour `oc`'s path.
cutAt(oc, cell) {
const path = this.paths[oc];
const i = path.indexOf(cell);
if (i < 0) return;
for (let j = i; j < path.length; j++) this.owner[path[j]] = -1;
path.length = i;
}
pulseCell(idx, col) {
const { x, y } = this.cellCenter(idx);
const ring = this.add.circle(x, y, this.geom.cell * 0.3, col, 0).setStrokeStyle(4, col, 0.9).setDepth(D.hud);
this.layer.add(ring);
this.tweens.add({
targets: ring, scale: 2.4, alpha: 0, duration: 460, ease: 'Cubic.easeOut',
onComplete: () => ring.destroy(),
});
}
onPointerUp() {
if (!this.inPlay()) return;
this.drawing = false;
this.draw = null;
this.checkWin();
}
clearAllPaths() {
if (!this.inPlay()) return;
for (let k = 0; k < this.paths.length; k++) this.resetColor(k);
this.drawing = false; this.draw = null;
this.redrawPaths();
playSound(this, SFX.CARD_SHUFFLE);
}
useHint() {
if (!this.inPlay() || !this.solution) return;
// Fill in the first colour whose current path doesn't match its solution.
for (let k = 0; k < this.solution.length; k++) {
const sol = this.solution[k];
const cur = this.paths[k];
const same = cur.length === sol.length && cur.every((idx, i) => idx === sol[i][0] * this.board.cols + sol[i][1]);
if (same) continue;
this.resetColor(k);
const cells = sol.map((rc) => rc[0] * this.board.cols + rc[1]);
for (const cell of cells) {
const oc = this.owner[cell];
if (oc >= 0 && oc !== k) this.cutAt(oc, cell);
this.owner[cell] = k;
}
this.paths[k] = cells;
this.redrawPaths();
playSound(this, SFX.PIECE_CLICK);
this.checkWin();
return;
}
}
checkWin() {
if (this.overlayUp || !this.board) return;
const asRC = this.paths.map((p) => p.map((idx) => idxToRC(idx, this.board.cols)));
if (isSolved(this.board, asRC)) {
this.drawing = false; this.draw = null;
if (this.onWin) this.onWin();
}
}
}
function idxToRC(idx, cols) { return [Math.floor(idx / cols), idx % cols]; }

View File

@ -0,0 +1,348 @@
// Dot Link — pure puzzle engine (no Phaser). Shared by the Phaser scene, the
// offline bank generator (server/scripts/genDotLink.js) and the verifier.
//
// "Dot Link" is a Flow-Free / Numberlink puzzle: every colour has two endpoint
// dots; connect each pair with a non-crossing path so that EVERY cell of the
// grid is covered exactly once.
//
// Board model:
// { rows, cols, endpoints: [ { color, a:[r,c], b:[r,c] }, ... ] }
// A solution assigns every cell to exactly one colour such that each colour
// forms a single simple path between its two endpoints and no cell is empty.
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
export function makeRng(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Hash an arbitrary string (e.g. a date "2026-06-14") to a uint32 seed so that
// everyone playing on the same day gets the same daily boards.
export function dateSeed(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
// Local YYYY-MM-DD for a Date (defaults to now).
export function localDateString(d = new Date()) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// ── Grid helpers ─────────────────────────────────────────────────────────────
function buildNeighbors(rows, cols) {
const nbr = new Array(rows * cols);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const list = [];
if (r > 0) list.push((r - 1) * cols + c);
if (r < rows - 1) list.push((r + 1) * cols + c);
if (c > 0) list.push(r * cols + (c - 1));
if (c < cols - 1) list.push(r * cols + (c + 1));
nbr[r * cols + c] = list;
}
}
return nbr;
}
export function manhattanAdjacent(a, b) {
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) === 1;
}
// ── Solver / solution counter ────────────────────────────────────────────────
// Grows each colour's path from endpoint a toward endpoint b. Counts solutions
// up to `countLimit` (default 2 — enough to decide uniqueness). Aborts and
// reports `aborted:true` if it exceeds `maxNodes` search nodes.
//
// Returns { count, aborted, solvable }.
export function solve(board, opts = {}) {
const { rows, cols } = board;
const N = rows * cols;
const colors = board.endpoints.length;
const countLimit = opts.countLimit ?? 2;
const maxNodes = opts.maxNodes ?? 400000;
const nbr = buildNeighbors(rows, cols);
const idx = (rc) => rc[0] * cols + rc[1];
const grid = new Int16Array(N).fill(-1); // cell -> colour or -1 empty
const headAt = new Int16Array(N).fill(-1); // cell -> colour whose active head sits here
const goalAt = new Int16Array(N).fill(-1); // cell -> colour whose goal sits here (fixed)
const heads = new Int32Array(colors);
const goals = new Int32Array(colors);
const connected = new Uint8Array(colors);
for (let k = 0; k < colors; k++) {
const e = board.endpoints[k];
const a = idx(e.a);
const b = idx(e.b);
if (grid[a] !== -1 || grid[b] !== -1) {
return { count: 0, aborted: false, solvable: false }; // overlapping endpoints
}
grid[a] = k; grid[b] = k;
heads[k] = a; goals[k] = b;
headAt[a] = k;
goalAt[b] = k;
}
let count = 0;
let nodes = 0;
let aborted = false;
// Reusable BFS buffers (stamp-based to avoid per-call clears).
const reachStamp = new Int32Array(N); // empty cell reachable by some head
const seenStamp = new Int32Array(N);
const queue = new Int32Array(N);
let stamp = 0;
// Two safe, strong prunes (never reject a real solution):
// (1) Degree: every empty cell needs >=2 open connections (empty neighbour,
// active head, or open goal endpoint) since it must be an interior path
// cell of degree 2.
// (2) Reachability: each unconnected colour's head must still reach its goal
// through empty cells, AND every empty cell must be reachable from some
// head through empty cells (or it can never be filled).
function pruneOk() {
for (let i = 0; i < N; i++) {
if (grid[i] !== -1) continue;
let deg = 0;
const ns = nbr[i];
for (let j = 0; j < ns.length; j++) {
const nb = ns[j];
if (grid[nb] === -1) { deg++; if (deg >= 2) break; continue; }
const h = headAt[nb];
if (h !== -1 && !connected[h]) { deg++; if (deg >= 2) break; continue; }
const g = goalAt[nb];
if (g !== -1 && !connected[g]) { deg++; if (deg >= 2) break; }
}
if (deg < 2) return false;
}
const reachTag = ++stamp;
for (let k = 0; k < colors; k++) {
if (connected[k]) continue;
const head = heads[k];
const goal = goals[k];
const seenTag = ++stamp;
let qh = 0; let qt = 0;
let reachedGoal = false;
const hns = nbr[head];
for (let j = 0; j < hns.length; j++) {
const nb = hns[j];
if (nb === goal) { reachedGoal = true; }
else if (grid[nb] === -1 && seenStamp[nb] !== seenTag) {
seenStamp[nb] = seenTag; reachStamp[nb] = reachTag; queue[qt++] = nb;
}
}
while (qh < qt) {
const cur = queue[qh++];
const cns = nbr[cur];
for (let j = 0; j < cns.length; j++) {
const nb = cns[j];
if (nb === goal) { reachedGoal = true; continue; }
if (grid[nb] === -1 && seenStamp[nb] !== seenTag) {
seenStamp[nb] = seenTag; reachStamp[nb] = reachTag; queue[qt++] = nb;
}
}
}
if (!reachedGoal) return false;
}
for (let i = 0; i < N; i++) {
if (grid[i] === -1 && reachStamp[i] !== reachTag) return false;
}
return true;
}
function dfs() {
nodes++;
if (nodes > maxNodes) { aborted = true; return; }
// Most-constrained colour to extend (fewest moves). Bail if any unconnected
// colour has no move.
let allConnected = true;
let bestK = -1;
let bestMoves = null;
let bestLen = Infinity;
for (let k = 0; k < colors; k++) {
if (connected[k]) continue;
allConnected = false;
const h = heads[k];
const g = goals[k];
const moves = [];
const ns = nbr[h];
for (let j = 0; j < ns.length; j++) {
const nb = ns[j];
if (nb === g) moves.push(nb);
else if (grid[nb] === -1) moves.push(nb);
}
if (moves.length === 0) return; // stuck colour
if (moves.length < bestLen) { bestLen = moves.length; bestK = k; bestMoves = moves; }
}
if (allConnected) {
for (let i = 0; i < N; i++) if (grid[i] === -1) return; // unfilled -> dead
count++;
return;
}
if (!pruneOk()) return;
const k = bestK;
const h = heads[k];
const g = goals[k];
for (let m = 0; m < bestMoves.length; m++) {
const n = bestMoves[m];
if (n === g) {
// Connect: head reaches its goal.
headAt[h] = -1;
connected[k] = 1;
dfs();
connected[k] = 0;
headAt[h] = k;
} else {
grid[n] = k; headAt[h] = -1; headAt[n] = k; heads[k] = n;
dfs();
heads[k] = h; headAt[n] = -1; headAt[h] = k; grid[n] = -1;
}
if (aborted || count >= countLimit) return;
}
}
dfs();
return { count, aborted, solvable: count > 0 };
}
// ── Random board generation ──────────────────────────────────────────────────
// Generate a random Hamiltonian path over the whole grid via the "backbite"
// algorithm, then cut it into `colors` contiguous segments. Each segment's two
// ends become a colour's endpoints — guaranteeing a full-cover solution exists.
function randomHamiltonian(rows, cols, rng) {
const N = rows * cols;
const nbr = buildNeighbors(rows, cols);
const path = [];
for (let r = 0; r < rows; r++) {
if (r % 2 === 0) for (let c = 0; c < cols; c++) path.push(r * cols + c);
else for (let c = cols - 1; c >= 0; c--) path.push(r * cols + c);
}
const pos = new Int32Array(N);
for (let i = 0; i < N; i++) pos[path[i]] = i;
const reverse = (lo, hi) => {
while (lo < hi) {
const a = path[lo], b = path[hi];
path[lo] = b; path[hi] = a;
pos[b] = lo; pos[a] = hi;
lo++; hi--;
}
};
const iters = Math.max(2000, N * 30);
for (let it = 0; it < iters; it++) {
const L = path.length;
if (rng() < 0.5) {
// Backbite the tail.
const t = path[L - 1];
const cand = nbr[t];
const m = cand[Math.floor(rng() * cand.length)];
const k = pos[m];
if (k < L - 2) reverse(k + 1, L - 1);
} else {
// Backbite the head.
const hcell = path[0];
const cand = nbr[hcell];
const m = cand[Math.floor(rng() * cand.length)];
const k = pos[m];
if (k > 1) reverse(0, k - 1);
}
}
return path;
}
// Cut the Hamiltonian path into `colors` contiguous segments. Returns both the
// puzzle (endpoints) and the reference `solution` (each colour's full cell list)
// — the partition is itself a valid full-cover solution.
function cutIntoColors(ham, colors, rows, cols, rng) {
const N = ham.length;
if (colors > Math.floor(N / 2)) return null; // need >=2 cells per colour
const lens = new Array(colors).fill(2);
let rem = N - 2 * colors;
while (rem > 0) { lens[Math.floor(rng() * colors)]++; rem--; }
const endpoints = [];
const solution = [];
let p = 0;
for (let k = 0; k < colors; k++) {
const cells = [];
for (let j = 0; j < lens[k]; j++) {
const cell = ham[p + j];
cells.push([Math.floor(cell / cols), cell % cols]);
}
endpoints.push({ color: k, a: cells[0], b: cells[cells.length - 1] });
solution.push(cells);
p += lens[k];
}
return { board: { rows, cols, endpoints }, solution };
}
// Produce a board for the given size/colour count. Always returns a valid,
// solvable, full-cover board plus its reference solution. When `uniqueCheck` is
// set (cheap only on small grids) it prefers a board the search solver proves
// uniquely solvable, falling back to the first candidate otherwise.
// Deterministic for a given rng sequence. Returns { board, solution, unique }.
export function generateBoard(rows, cols, colors, rng, opts = {}) {
const cells = rows * cols;
const uniqueCheck = opts.uniqueCheck ?? (cells <= 64);
const tries = uniqueCheck ? (opts.tries ?? 24) : 1;
const maxNodes = opts.maxNodes ?? 200000;
let fallback = null;
for (let t = 0; t < tries; t++) {
const ham = randomHamiltonian(rows, cols, rng);
const cut = cutIntoColors(ham, colors, rows, cols, rng);
if (!cut) return null;
if (!fallback) fallback = cut;
if (!uniqueCheck) break;
const res = solve(cut.board, { countLimit: 2, maxNodes });
if (!res.aborted && res.count === 1) {
return { board: cut.board, solution: cut.solution, unique: true };
}
}
return { board: fallback.board, solution: fallback.solution, unique: false };
}
// ── Win check for live play ──────────────────────────────────────────────────
// `paths` maps colour index -> array of [r,c] cells the player has drawn for
// that colour (endpoints included). Returns true when every colour links its
// two endpoints with a contiguous simple path, every cell is covered exactly
// once, and nothing overlaps.
export function isSolved(board, paths) {
const { rows, cols } = board;
const owner = new Int16Array(rows * cols).fill(-1);
for (let k = 0; k < board.endpoints.length; k++) {
const path = paths[k];
if (!path || path.length < 2) return false;
const e = board.endpoints[k];
const first = path[0];
const last = path[path.length - 1];
const endsOk =
(first[0] === e.a[0] && first[1] === e.a[1] && last[0] === e.b[0] && last[1] === e.b[1]) ||
(first[0] === e.b[0] && first[1] === e.b[1] && last[0] === e.a[0] && last[1] === e.a[1]);
if (!endsOk) return false;
for (let i = 0; i < path.length; i++) {
const cell = path[i][0] * cols + path[i][1];
if (owner[cell] !== -1) return false; // overlap
owner[cell] = k;
if (i > 0 && !manhattanAdjacent(path[i - 1], path[i])) return false; // broken
}
}
for (let i = 0; i < owner.length; i++) if (owner[i] === -1) return false; // unfilled
return true;
}

View File

@ -75,6 +75,7 @@ import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js';
import SlotsGame from './games/slots/SlotsGame.js'; import SlotsGame from './games/slots/SlotsGame.js';
import CribbageGame from './games/cribbage/CribbageGame.js'; import CribbageGame from './games/cribbage/CribbageGame.js';
import CanastaGame from './games/canasta/CanastaGame.js'; import CanastaGame from './games/canasta/CanastaGame.js';
import DotLinkGame from './games/dotlink/DotLinkGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -163,6 +164,7 @@ const config = {
SlotsGame, SlotsGame,
CribbageGame, CribbageGame,
CanastaGame, CanastaGame,
DotLinkGame,
], ],
}; };

View File

@ -22,7 +22,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', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame' }; 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', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame' };
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

@ -68,6 +68,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('blockfighter', '/data/blockfighter.json'); this.load.json('blockfighter', '/data/blockfighter.json');
this.load.json('jewelquest', '/data/jewelquest.json'); this.load.json('jewelquest', '/data/jewelquest.json');
this.load.json('zuma', '/data/zuma.json'); this.load.json('zuma', '/data/zuma.json');
this.load.json('dotlink', '/data/dotlink.json');
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3'); this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3'); this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');

View File

@ -90,3 +90,4 @@ registerGame({ slug: 'minimotorways', name: 'Mini Motorways', category: 'logic',
registerGame({ slug: 'slots', name: 'Slot Machines', category: 'casino', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 63 }); registerGame({ slug: 'slots', name: 'Slot Machines', category: 'casino', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 63 });
registerGame({ slug: 'cribbage', name: 'Cribbage', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 64 }); registerGame({ slug: 'cribbage', name: 'Cribbage', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 64 });
registerGame({ slug: 'canasta', name: 'Canasta', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 65 }); registerGame({ slug: 'canasta', name: 'Canasta', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 65 });
registerGame({ slug: 'dotlink', name: 'Dot Link', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 66 });

View File

@ -0,0 +1,111 @@
// Offline generator for Dot Link puzzles.
//
// Builds a smooth easy->legendary curve of 100 Flow-Free boards by growing a
// random Hamiltonian path over each grid (backbite) and cutting it into colour
// segments, then preferring boards the solver proves uniquely solvable. Writes
// ordered levels to public/data/dotlink.json.
//
// Usage:
// node server/scripts/genDotLink.js [seed] [outFile]
//
// Deterministic: same seed -> same bank.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeRng, generateBoard, isSolved } from '../../public/src/games/dotlink/DotLinkLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[3]
? path.resolve(process.argv[3])
: path.join(__dirname, '../../public/data/dotlink.json');
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0xd07117e5;
// Difficulty curve: grid size grows, colour count grows. counts sum to 100.
const TIERS = [
{ count: 12, rows: 5, cols: 5, colorsMin: 4, colorsMax: 5 },
{ count: 14, rows: 6, cols: 6, colorsMin: 5, colorsMax: 6 },
{ count: 14, rows: 7, cols: 7, colorsMin: 6, colorsMax: 7 },
{ count: 14, rows: 8, cols: 8, colorsMin: 7, colorsMax: 8 },
{ count: 14, rows: 9, cols: 9, colorsMin: 8, colorsMax: 9 },
{ count: 12, rows: 10, cols: 10, colorsMin: 9, colorsMax: 10 },
{ count: 12, rows: 11, cols: 11, colorsMin: 10, colorsMax: 11 },
{ count: 8, rows: 11, cols: 11, colorsMin: 12, colorsMax: 12 },
];
function canonKey(board) {
return board.endpoints
.map((e) => {
const a = e.a[0] * board.cols + e.a[1];
const b = e.b[0] * board.cols + e.b[1];
return a < b ? `${a}-${b}` : `${b}-${a}`;
})
.sort()
.join('|');
}
const rng = makeRng(SEED);
console.log(`[dotlink] generating with seed 0x${SEED.toString(16)}`);
const levels = [];
const seen = new Set();
let uniqueCount = 0;
const startedAt = Date.now();
for (const tier of TIERS) {
for (let i = 0; i < tier.count; i++) {
const span = tier.colorsMax - tier.colorsMin;
const colors = tier.count > 1
? tier.colorsMin + Math.round((i / (tier.count - 1)) * span)
: tier.colorsMin;
// generateBoard always returns a valid (solvable) board with its reference
// solution; prefer one that is uniquely solvable (only checked on small
// grids) and not a duplicate of an earlier level.
let chosen = null;
for (let attempt = 0; attempt < 4; attempt++) {
const g = generateBoard(tier.rows, tier.cols, colors, rng, { tries: 24, maxNodes: 200000 });
if (!g) continue;
const dup = seen.has(canonKey(g.board));
if (!chosen || (!dup && (g.unique || !chosen.unique))) chosen = g;
if (g.unique && !dup) break;
}
if (!chosen) {
console.error(`[dotlink] FAILED to generate a ${tier.rows}x${tier.cols}/${colors} board`);
process.exit(1);
}
seen.add(canonKey(chosen.board));
if (chosen.unique) uniqueCount++;
levels.push({
level: levels.length + 1,
rows: chosen.board.rows,
cols: chosen.board.cols,
colors,
unique: !!chosen.unique,
endpoints: chosen.board.endpoints,
solution: chosen.solution,
});
process.stdout.write(`\r[dotlink] built ${levels.length}/100 (unique ${uniqueCount}) `);
}
}
process.stdout.write('\n');
// Sanity pass: every level's stored reference solution must validate.
let unsolvable = 0;
for (const lv of levels) {
if (!isSolved(lv, lv.solution)) unsolvable++;
}
const payload = {
generatedAt: new Date().toISOString(),
seed: SEED,
count: levels.length,
levels,
};
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
const secs = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log(`[dotlink] wrote ${levels.length} levels (${uniqueCount} unique, ${unsolvable} unsolvable) in ${secs}s -> ${OUT_FILE}`);
if (unsolvable > 0) process.exit(1);

View File

@ -0,0 +1,104 @@
// Verifier for Dot Link (Node only — no browser).
//
// Asserts every level in public/data/dotlink.json is solvable with a full-cover
// solution, reports how many are uniquely solvable, and confirms the daily
// board generator is deterministic for a fixed seed.
//
// Usage: node server/scripts/verifyDotLink.js
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
makeRng, dateSeed, generateBoard, solve, isSolved,
} from '../../public/src/games/dotlink/DotLinkLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FILE = path.join(__dirname, '../../public/data/dotlink.json');
let failures = 0;
const fail = (msg) => { console.error(`${msg}`); failures++; };
// ── Bank checks ──────────────────────────────────────────────────────────────
const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'));
const levels = raw.levels ?? [];
console.log(`[verify] ${FILE}`);
console.log(`[verify] ${levels.length} levels (seed 0x${(raw.seed >>> 0).toString(16)})`);
if (levels.length !== 100) fail(`expected 100 levels, found ${levels.length}`);
let unique = 0;
let prevCells = 0;
for (const lv of levels) {
const cells = lv.rows * lv.cols;
// Endpoints in-bounds and distinct.
const used = new Set();
for (const e of lv.endpoints) {
for (const p of [e.a, e.b]) {
if (p[0] < 0 || p[0] >= lv.rows || p[1] < 0 || p[1] >= lv.cols) fail(`L${lv.level}: endpoint out of bounds`);
const key = p[0] * lv.cols + p[1];
if (used.has(key)) fail(`L${lv.level}: endpoint overlap`);
used.add(key);
}
}
// Solvable: the stored reference solution must be a valid full-cover solution
// whose colour endpoints match the puzzle's.
if (!lv.solution || lv.solution.length !== lv.endpoints.length) {
fail(`L${lv.level}: missing/mismatched solution`);
} else if (!isSolved(lv, lv.solution)) {
fail(`L${lv.level}: stored solution does not validate`);
}
// Uniqueness (best-effort; only cheap on small grids).
if (cells <= 64) {
const two = solve(lv, { countLimit: 2, maxNodes: 600000 });
if (!two.aborted && two.count === 1) unique++;
}
// Difficulty should be non-decreasing in grid size.
if (cells < prevCells) fail(`L${lv.level}: grid shrank vs previous level`);
prevCells = cells;
}
console.log(`[verify] all ${levels.length} reference solutions validate; ${unique} small boards provably unique`);
// isSolved must reject an obviously incomplete board.
if (isSolved(levels[0], levels[0].endpoints.map((e) => [e.a]))) {
fail('isSolved accepted an incomplete board');
}
// ── Daily determinism ────────────────────────────────────────────────────────
function dailyBoards(dateStr) {
const STAGES = [
{ rows: 5, cols: 5, colors: 4 },
{ rows: 7, cols: 7, colors: 6 },
{ rows: 8, cols: 8, colors: 8 },
{ rows: 10, cols: 10, colors: 9 },
{ rows: 11, cols: 11, colors: 11 },
];
const base = dateSeed(dateStr);
return STAGES.map((s, i) => {
const rng = makeRng((base ^ Math.imul(0x9e3779b9, i + 1)) >>> 0);
return generateBoard(s.rows, s.cols, s.colors, rng);
});
}
const day = '2026-06-14';
const a = dailyBoards(day);
const b = dailyBoards(day);
if (a.some((x) => !x)) fail('daily generation returned a null board');
if (JSON.stringify(a) !== JSON.stringify(b)) fail('daily boards are not deterministic for the same date');
const c = dailyBoards('2026-06-15');
if (JSON.stringify(a.map((g) => g.board)) === JSON.stringify(c.map((g) => g.board))) {
fail('different dates produced identical daily boards');
}
for (const g of a) {
if (g && !isSolved(g.board, g.solution)) fail('a daily board solution does not validate');
}
console.log('[verify] daily boards: deterministic per date, distinct across dates, solvable');
if (failures > 0) {
console.error(`\n[verify] FAILED with ${failures} problem(s).`);
process.exit(1);
}
console.log('\n[verify] All Dot Link checks passed.');