Games batch: neutral words/themes, Word Search sizes + per-word colours

From playtesting findings:
* Pools nearly doubled (115/104 → 228/201) with calm/neutral everyday words
  (claps, dance, drench, beach…), not just strictly-upbeat ones — more variety,
  ~7-month runway. The post-solve "why" prompt reworded to fit neutral words.
* Word Search now stores one theme + word list per day; the grid is built per
  request for three SIZE tiers — Small (8×8, 6 words), Medium (11×11, 9),
  Large (14×14, 13). Large packs more words = a longer sit ("too fast" fix).
  All sizes share the day's theme; every size still code-placed + solvable.
* Word Search themes can now be neutral everyday scenes ("Around the house",
  "At the beach", "In the kitchen", "A walk outdoors", "Making music"…), not
  only hopeful — same shape as the articles.
* Each found word gets its own colour from a calm palette, in the grid and its
  word-list chip. Per-size local progress + best time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-10 20:32:53 -04:00
parent 33d5d55c33
commit f43f645d69
6 changed files with 376 additions and 112 deletions
+1 -1
View File
@@ -1435,7 +1435,7 @@ def create_app() -> FastAPI:
if game == "word" and variant in games.WORD_VARIANTS:
return games.word_puzzle_response(conn, local_today(), variant)
if game == "wordsearch":
return games.wordsearch_response(conn, local_today())
return games.wordsearch_response(conn, local_today(), variant)
raise HTTPException(status_code=404, detail="no such puzzle")
@app.post("/api/puzzle/word/guess")
+210
View File
@@ -1,35 +1,70 @@
{
"5": [
"acorn",
"adore",
"agile",
"album",
"alive",
"amaze",
"ample",
"angel",
"apple",
"arise",
"aroma",
"atlas",
"award",
"aware",
"balls",
"beach",
"beads",
"beams",
"beans",
"bench",
"berry",
"bless",
"bliss",
"bloom",
"blush",
"boats",
"bonus",
"books",
"boots",
"brave",
"bread",
"brisk",
"brook",
"brush",
"cabin",
"candy",
"chair",
"chalk",
"charm",
"cheer",
"chime",
"choir",
"claps",
"clean",
"clear",
"climb",
"clock",
"cloud",
"coast",
"coats",
"cocoa",
"coins",
"couch",
"craft",
"cream",
"daisy",
"dance",
"dawns",
"desks",
"doors",
"dream",
"dress",
"drift",
"drive",
"drums",
"eager",
"earth",
"elate",
@@ -38,32 +73,53 @@
"fancy",
"favor",
"feast",
"fence",
"ferns",
"field",
"frame",
"fresh",
"frost",
"fruit",
"games",
"gifts",
"glade",
"gleam",
"glide",
"globe",
"glory",
"glows",
"grace",
"grand",
"grass",
"green",
"grins",
"happy",
"heals",
"heart",
"hikes",
"homes",
"honey",
"honor",
"hopes",
"humor",
"ideal",
"jeans",
"jewel",
"jolly",
"juice",
"kites",
"lamps",
"laugh",
"learn",
"lemon",
"light",
"linen",
"lucky",
"lunch",
"magic",
"mango",
"maple",
"melon",
"mends",
"mercy",
"merit",
@@ -71,114 +127,240 @@
"mirth",
"music",
"noble",
"novel",
"oasis",
"ocean",
"olive",
"paint",
"paper",
"parks",
"party",
"paths",
"patio",
"peace",
"peach",
"petal",
"phone",
"photo",
"piano",
"plane",
"plant",
"plate",
"plays",
"pluck",
"poise",
"porch",
"pride",
"prize",
"proud",
"quiet",
"quilt",
"rainy",
"ready",
"relax",
"renew",
"rings",
"river",
"roads",
"rooms",
"roots",
"sails",
"salad",
"sandy",
"savor",
"scarf",
"seeds",
"share",
"shelf",
"shell",
"shine",
"ships",
"shirt",
"shoes",
"shore",
"smile",
"snack",
"socks",
"songs",
"space",
"spark",
"spoon",
"stalk",
"stars",
"still",
"story",
"study",
"sugar",
"sunny",
"sweet",
"swift",
"swing",
"table",
"teach",
"thank",
"tides",
"toast",
"towel",
"towns",
"trail",
"train",
"treat",
"trust",
"truth",
"tulip",
"unity",
"value",
"vases",
"vigor",
"vines",
"vital",
"vivid",
"walks",
"watch",
"water",
"waves",
"winds",
"windy",
"wings",
"witty",
"words",
"worth",
"youth",
"yummy",
"zesty"
],
"6": [
"acorns",
"adored",
"almond",
"anchor",
"artist",
"ascend",
"aspire",
"avenue",
"bakery",
"ballad",
"banana",
"basket",
"beauty",
"bestow",
"better",
"bottle",
"bouncy",
"branch",
"breeze",
"bridge",
"bright",
"bubbly",
"butter",
"button",
"calmly",
"candle",
"canvas",
"caring",
"carpet",
"carrot",
"celery",
"cheery",
"cheese",
"cherry",
"cherub",
"chirpy",
"cinema",
"clever",
"closet",
"cookie",
"cotton",
"cradle",
"crayon",
"dainty",
"dancer",
"dazzle",
"devote",
"dinner",
"divine",
"drawer",
"dreamy",
"drench",
"earthy",
"encore",
"esteem",
"fabric",
"family",
"flower",
"fondly",
"forest",
"friend",
"garden",
"garlic",
"gather",
"genial",
"gentle",
"ginger",
"giving",
"glossy",
"gloves",
"golden",
"grapes",
"growth",
"guitar",
"hanger",
"harbor",
"health",
"heaven",
"hockey",
"honest",
"island",
"jacket",
"joyful",
"joyous",
"kettle",
"kindly",
"lagoon",
"laurel",
"leaves",
"lively",
"lovely",
"loving",
"marina",
"market",
"meadow",
"mellow",
"melody",
"mended",
"mirror",
"mitten",
"morale",
"muffin",
"museum",
"napkin",
"nature",
"needle",
"nestle",
"nicely",
"nuzzle",
"orange",
"pantry",
"pebble",
"pencil",
"pepper",
"petals",
"pillow",
"please",
"plenty",
"potato",
"praise",
"pretty",
"prized",
"purify",
"purity",
"puzzle",
"quaint",
"racket",
"raisin",
"ramble",
"reborn",
"recipe",
"refine",
"relish",
"renews",
@@ -186,37 +368,65 @@
"rescue",
"revive",
"reward",
"ribbon",
"riddle",
"ripple",
"rocker",
"sandal",
"saucer",
"savior",
"savory",
"secure",
"serene",
"settle",
"shrubs",
"simmer",
"simple",
"singer",
"sketch",
"smiled",
"smooth",
"soccer",
"soothe",
"spirit",
"spring",
"sprout",
"stairs",
"steady",
"stream",
"street",
"stroll",
"strong",
"subway",
"summit",
"sunset",
"superb",
"supple",
"teapot",
"tender",
"tennis",
"thanks",
"thread",
"thrive",
"tomato",
"travel",
"tunnel",
"unfold",
"united",
"uplift",
"valley",
"valued",
"velvet",
"verity",
"violin",
"vision",
"voyage",
"walnut",
"wander",
"warmly",
"warmth",
"willow",
"window",
"wisdom",
"wonder",
"zenith",
+74 -50
View File
@@ -58,8 +58,9 @@ def _why(client, word: str) -> str | None:
return None
try:
msg = [
{"role": "system", "content": "You write one short, warm, plain sentence (no preamble, no quotes) "
"on why a given word is a hopeful or uplifting one to sit with."},
{"role": "system", "content": "You write one short, warm, plain sentence (no preamble, no quotes) "
"a calm or gently interesting little note about the given word: what it "
"evokes, where we meet it in everyday life, or why it's pleasant to sit with."},
{"role": "user", "content": f"Word: {word}"},
]
text = (client.chat_text(msg) or "").strip().strip('"').replace("\n", " ")
@@ -155,17 +156,31 @@ def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, gue
# are inherently visible; the play is finding them.
# ---------------------------------------------------------------------------
WORDSEARCH_SIZE = 10
WORDSEARCH_COUNT = 8
_DIRS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
# Curated fallbacks (all uppercase alpha, 48 letters) used if the LLM is thin.
# Size tiers: bigger grid → more words → a longer sit. Built per-request from one
# stored theme+word list, so all three sizes share the day's theme.
WS_TIERS = {"small": {"grid": 8, "count": 6}, "med": {"grid": 11, "count": 9}, "large": {"grid": 14, "count": 13}}
# Curated fallbacks — calm and neutral everyday scenes, not just upbeat. ~13
# words each (48 letters, uppercase) so the large tier has enough to place.
_WS_FALLBACKS = [
("Kindness", ["KIND", "CARE", "GIVE", "SHARE", "GENTLE", "WARMTH", "THANKS", "FRIEND"]),
("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS"]),
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE"]),
("Bright skies", ["SUNNY", "CLOUD", "BREEZE", "LIGHT", "SHINE", "RAINBOW", "WARMTH", "DAWN"]),
("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW"]),
("Around the house", ["TABLE", "CHAIR", "CLOCK", "SHELF", "COUCH", "LAMP", "PILLOW", "WINDOW",
"CARPET", "MIRROR", "CANDLE", "KETTLE", "DRAWER"]),
("At the beach", ["WAVES", "SHELL", "SANDY", "TIDE", "SHORE", "TOWEL", "BREEZE", "SUNSET",
"PEBBLE", "CORAL", "OCEAN", "SAILS", "PIER"]),
("In the kitchen", ["BREAD", "SPOON", "PLATE", "KETTLE", "FLOUR", "APRON", "WHISK", "SUGAR",
"BUTTER", "RECIPE", "SIMMER", "PANTRY", "TEAPOT"]),
("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS",
"MEADOW", "SPROUT", "HEDGE", "TROWEL", "VINES"]),
("A walk outdoors", ["TRAIL", "MEADOW", "BROOK", "BIRDS", "BREEZE", "PEBBLE", "FOREST", "MAPLE",
"ACORN", "STREAM", "BRANCH", "VALLEY", "PATH"]),
("Making music", ["PIANO", "DRUMS", "CHOIR", "MELODY", "GUITAR", "VIOLIN", "SINGER", "BALLAD",
"RHYTHM", "ENCORE", "TEMPO", "NOTES", "SONG"]),
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE",
"CALM", "HUSH", "DRIFT", "EASE", "DREAM"]),
("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW",
"PLAY", "GRIN", "BEAM", "GLEE", "WARM"]),
]
@@ -175,10 +190,13 @@ def _ws_propose(client) -> tuple[str, list[str]] | None:
return None
try:
msg = [
{"role": "system", "content": "You set up a calm, hopeful word search. Reply exactly as two lines:\n"
"THEME: <2-4 word theme>\nWORDS: W1, W2, W3, W4, W5, W6, W7, W8\n"
"Each word a single real word, 4-8 letters, UPPERCASE, related to the theme, no phrases."},
{"role": "user", "content": "Give me one gentle, uplifting theme."},
{"role": "system", "content": "You set up a calm word search. The theme can be uplifting OR just a "
"pleasant everyday scene (e.g. 'Around the house', 'At the beach', "
"'In the kitchen'). Reply exactly as two lines:\n"
"THEME: <2-4 word theme>\nWORDS: W1, W2, ... W13\n"
"Give 13 single real words, 4-8 letters, UPPERCASE, related to the theme, "
"nothing negative or unpleasant, no phrases."},
{"role": "user", "content": "Give me one calm theme."},
]
text = client.chat_text(msg) or ""
theme, words = None, []
@@ -190,15 +208,15 @@ def _ws_propose(client) -> tuple[str, list[str]] | None:
words = [w.strip().upper() for w in re.split(r"[,\s]+", s.split(":", 1)[1]) if w.strip()]
words = [w for w in dict.fromkeys(words) if w.isalpha() and 4 <= len(w) <= 8]
if theme and len(words) >= 6:
return theme, words[:WORDSEARCH_COUNT]
return theme, words
except Exception: # noqa: BLE001 — fall back to a curated theme
pass
return None
def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None) -> dict:
"""Ensure today's word search exists. Idempotent. Code places every word, so
the puzzle is guaranteed solvable; only placed words are returned."""
"""Ensure today's theme + word list exists (idempotent). The per-size grid is
built at request time, so one LLM call serves all three sizes."""
existing = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='wordsearch' AND variant=''", (date,)
).fetchone()
@@ -207,34 +225,8 @@ def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None)
rng = random.Random(_seed(date, "wordsearch"))
proposed = _ws_propose(client)
theme, words = proposed if proposed else _WS_FALLBACKS[rng.randrange(len(_WS_FALLBACKS))]
words = [w.upper() for w in words]
size = WORDSEARCH_SIZE
grid: list[list[str | None]] = [[None] * size for _ in range(size)]
placed = []
for word in sorted(words, key=len, reverse=True):
for _ in range(400):
dr, dc = rng.choice(_DIRS)
r0, c0 = rng.randrange(size), rng.randrange(size)
cells = [(r0 + dr * i, c0 + dc * i) for i in range(len(word))]
if any(not (0 <= r < size and 0 <= c < size) for r, c in cells):
continue
if all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)):
for i, (r, c) in enumerate(cells):
grid[r][c] = word[i]
placed.append({"word": word, "cells": cells})
break
for r in range(size):
for c in range(size):
if grid[r][c] is None:
grid[r][c] = chr(65 + rng.randrange(26))
payload = {
"theme": theme,
"words": sorted((p["word"] for p in placed), key=len, reverse=True),
"grid": ["".join(row) for row in grid],
"size": size,
"placements": placed, # stored, not sent to the client
}
words = [w.upper() for w in dict.fromkeys(words) if w.isalpha() and 4 <= len(w) <= 8]
payload = {"theme": theme, "words": words}
conn.execute(
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'wordsearch', '', ?)",
(date, json.dumps(payload)),
@@ -246,12 +238,44 @@ def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None)
return json.loads(row["payload_json"])
def wordsearch_response(conn: sqlite3.Connection, date: str) -> dict:
"""Public shape: theme + word list + grid. The grid is meant to be seen — the
play is finding the words — so there's nothing to hide here (no placements)."""
def _build_grid(words: list[str], size: int, seed: int) -> tuple[list[str], list[str]]:
"""Place words in a size×size grid (date-seeded, deterministic) and fill the
rest. Returns (rows, placed_words). Every returned word is genuinely placed."""
rng = random.Random(seed)
grid: list[list[str | None]] = [[None] * size for _ in range(size)]
placed = []
for word in sorted(words, key=len, reverse=True):
if len(word) > size:
continue
for _ in range(400):
dr, dc = rng.choice(_DIRS)
r0, c0 = rng.randrange(size), rng.randrange(size)
cells = [(r0 + dr * i, c0 + dc * i) for i in range(len(word))]
if any(not (0 <= r < size and 0 <= c < size) for r, c in cells):
continue
if all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)):
for i, (r, c) in enumerate(cells):
grid[r][c] = word[i]
placed.append(word)
break
for r in range(size):
for c in range(size):
if grid[r][c] is None:
grid[r][c] = chr(65 + rng.randrange(26))
return ["".join(row) for row in grid], placed
def wordsearch_response(conn: sqlite3.Connection, date: str, size: str = "med") -> dict:
"""Public shape for a size tier: theme + placed words + grid. The grid is meant
to be seen — the play is finding the words — so there's nothing to hide."""
if size not in WS_TIERS:
size = "med"
p = generate_wordsearch_puzzle(conn, date) # on-demand (curated fallback) if missing
return {"game": "wordsearch", "date": date, "theme": p["theme"],
"words": p["words"], "grid": p["grid"], "size": p["size"]}
tier = WS_TIERS[size]
usable = [w for w in p["words"] if len(w) <= tier["grid"]][:tier["count"]]
grid, placed = _build_grid(usable, tier["grid"], _seed(date, "wordsearch", size))
return {"game": "wordsearch", "date": date, "size": size, "theme": p["theme"],
"words": placed, "grid": grid}
def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> int: