Play hub Phase 2: Word Search (LLM theme/words, code places the grid)

A calm second daily game, same philosophy as Daily Word — LLM proposes, code
disposes.

* LLM proposes a hopeful theme + ~8 words; code validates (alpha/length/dedup)
  and PLACES every word in a date-seeded grid, so the puzzle is always solvable.
  Curated fallback themes if the LLM is thin. Only placed words are returned;
  the solution cells (placements) are never sent to the client.
* GET /api/puzzle/wordsearch → {theme, words, grid, size}. No answer to hide:
  the grid and word list are meant to be seen — the play is finding them, which
  the client validates by reading the selected line off the grid.
* WordSearchGame.svelte: pointer-drag selection snapped to the 8 straight
  directions (mouse + touch), found-word highlighting, no-fail, no pressure
  timer — time is recorded quietly and shown at the end with a personal best.
  Spoiler-free share. localStorage progress (restores found cells + timer).
* Hub's Word Search card is now live with today's status; cycle pre-generates
  both games with the LLM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-10 20:15:19 -04:00
parent 1bc9925e40
commit 90cd0291a3
5 changed files with 390 additions and 9 deletions
@@ -0,0 +1,221 @@
<script>
import { getJSON } from '$lib/api.js';
let { onstatus } = $props();
let theme = $state('');
let words = $state([]);
let grid = $state([]); // array of row strings
let size = $state(10);
let date = $state('');
let found = $state([]); // found words
let foundCells = $state(new Set()); // "r,c"
let sel = $state([]); // current selection cells [[r,c]]
let selecting = false;
let startTime = 0;
let resultMs = $state(0);
let best = $state(0);
let loading = $state(true);
let ready = $state(false);
let okFlash = $state(false);
let gridEl;
const stateKey = $derived(`goodnews:wordsearch:${date}`);
const BEST_KEY = 'goodnews:wordsearch:best';
const status = $derived(words.length && found.length === words.length ? 'done' : 'playing');
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
async function load() {
loading = true; ready = false;
try {
const p = await getJSON('/api/puzzle/wordsearch');
theme = p.theme; words = p.words; grid = p.grid; size = p.size; date = p.date;
restore();
if (!startTime) startTime = Date.now();
try { best = JSON.parse(localStorage.getItem(BEST_KEY) || '0'); } catch { best = 0; }
} catch {
theme = ''; words = [];
}
loading = false;
requestAnimationFrame(() => (ready = true));
}
function restore() {
try {
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (s && Array.isArray(s.found)) {
found = s.found;
foundCells = new Set(s.cells || []);
startTime = s.startTime || 0;
resultMs = s.ms || 0;
}
} catch { /* ignore */ }
onstatus?.(summary());
}
function persist() {
try {
localStorage.setItem(stateKey, JSON.stringify(
{ found, cells: [...foundCells], startTime, ms: resultMs, status }));
} catch { /* ignore */ }
onstatus?.(summary());
}
function summary() { return { game: 'wordsearch', date, status, found: found.length, total: words.length, ms: resultMs }; }
function cellAt(e) {
const rect = gridEl.getBoundingClientRect();
const cw = rect.width / size;
const c = Math.min(size - 1, Math.max(0, Math.floor((e.clientX - rect.left) / cw)));
const r = Math.min(size - 1, Math.max(0, Math.floor((e.clientY - rect.top) / cw)));
return [r, c];
}
// Snap a drag to the nearest of the 8 straight directions.
function lineFrom(start, end) {
const [r0, c0] = start, [r1, c1] = end;
let dr = r1 - r0, dc = c1 - c0;
if (dr === 0 && dc === 0) return [start];
const adr = Math.abs(dr), adc = Math.abs(dc);
if (adr > adc * 2) dc = 0;
else if (adc > adr * 2) dr = 0;
else { const m = Math.max(adr, adc); dr = Math.sign(dr) * m; dc = Math.sign(dc) * m; }
const steps = Math.max(Math.abs(dr), Math.abs(dc));
const sr = Math.sign(dr), sc = Math.sign(dc), cells = [];
for (let i = 0; i <= steps; i++) {
const r = r0 + sr * i, c = c0 + sc * i;
if (r < 0 || r >= size || c < 0 || c >= size) break;
cells.push([r, c]);
}
return cells.length ? cells : [start];
}
function down(e) {
if (status === 'done') return;
selecting = true;
if (!startTime) startTime = Date.now();
sel = [cellAt(e)];
gridEl.setPointerCapture?.(e.pointerId);
e.preventDefault();
}
function move(e) {
if (!selecting) return;
sel = lineFrom(sel[0], cellAt(e));
}
function up() {
if (!selecting) return;
selecting = false;
evaluate(sel);
sel = [];
}
function evaluate(cells) {
if (cells.length < 2) return;
const word = cells.map(([r, c]) => grid[r][c]).join('');
const rev = [...word].reverse().join('');
const hit = words.includes(word) && !found.includes(word) ? word
: words.includes(rev) && !found.includes(rev) ? rev : null;
if (!hit) return;
found = [...found, hit];
const fc = new Set(foundCells);
cells.forEach(([r, c]) => fc.add(r + ',' + c));
foundCells = fc;
okFlash = true; setTimeout(() => (okFlash = false), 500);
if (found.length === words.length) finish();
persist();
}
function finish() {
resultMs = startTime ? Date.now() - startTime : 0;
if (resultMs && (!best || resultMs < best)) {
best = resultMs;
try { localStorage.setItem(BEST_KEY, JSON.stringify(best)); } catch { /* ignore */ }
}
}
function fmt(ms) {
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
let copied = $state(false);
function share() {
const text = `Upbeat Bytes · Word Search ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
}
$effect(() => { load(); });
</script>
<div class="wordsearch" class:ready>
{#if loading}
<p class="muted">Loading todays word search…</p>
{:else if !words.length}
<p class="muted">Could not load todays word search.</p>
{:else}
<p class="theme"><span class="lbl">Todays theme</span>{theme}</p>
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{size}"
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
{#each grid as rowStr, r (r)}
{#each rowStr.split('') as ch, c (c)}
<div class="cell" class:found={foundCells.has(r + ',' + c)} class:sel={selSet.has(r + ',' + c)}>{ch}</div>
{/each}
{/each}
</div>
<ul class="words">
{#each words as w (w)}
<li class:got={found.includes(w)}>{w}</li>
{/each}
</ul>
{#if status === 'done'}
<div class="result rise">
<p class="rmark">✦ all found ✦</p>
<div class="times">
<div><span class="n">{fmt(resultMs)}</span><span class="l">your time</span></div>
{#if best}<div><span class="n">{fmt(best)}</span><span class="l">best</span></div>{/if}
</div>
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
</div>
{:else}
<p class="hint">{found.length} / {words.length} found · drag across the letters</p>
{/if}
{/if}
</div>
<style>
.wordsearch { max-width: 460px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
.wordsearch.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.theme { text-align: center; font-family: var(--serif); font-size: 1.3rem; color: var(--accent-deep); margin: 0 0 14px; }
.theme .lbl { display: block; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.64rem;
font-family: var(--label); color: var(--muted); margin-bottom: 2px; }
.grid {
display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 2px;
width: min(100%, 360px); margin: 0 auto 16px; touch-action: none; user-select: none;
-webkit-user-select: none; border-radius: 10px; padding: 6px; background: var(--surface);
border: 1px solid var(--line);
}
.grid.done { opacity: 0.85; }
.cell {
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
font-family: var(--label); font-weight: 600; font-size: clamp(0.8rem, 3.6vw, 1.05rem);
color: var(--ink); border-radius: 6px; background: transparent; text-transform: uppercase;
}
.cell.found { background: var(--accent-soft); color: var(--accent-deep); }
.cell.sel { background: var(--accent); color: #fff; }
.words { list-style: none; display: flex; flex-wrap: wrap; gap: 7px 12px; justify-content: center;
padding: 0; margin: 0 0 14px; }
.words li { font-family: var(--label); font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink); }
.words li.got { color: var(--muted); text-decoration: line-through; }
.hint { text-align: center; color: var(--muted); font-size: 0.84rem; margin: 0; }
.result { text-align: center; }
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 12px; }
.times { display: flex; justify-content: center; gap: 26px; margin: 0 0 16px; }
.times .n { display: block; font-size: 1.5rem; font-weight: 700; font-family: var(--label); }
.times .l { color: var(--muted); font-size: 0.78rem; }
.share { background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 11px 26px; font: inherit; font-weight: 600; cursor: pointer; }
.share:hover { background: var(--accent-deep); }
.muted { color: var(--muted); text-align: center; }
</style>