Add player_name column to games (migration included), with regex-based validation and anti-abuse sanitization. New /scoreboard page shows recent games from localStorage, popular categories with play counts, and top-10 per-category leaderboards sorted by completion time. Also includes two-click reverse word matching, base URL prefix support for reverse-proxy hosting, BasePageHandler refactoring, themed table CSS for all 5 themes, and comprehensive test coverage for player names and scoreboard API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
49 KiB
Word Search Game Logic Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the full word search game — database models, board generation, REST API, and interactive frontend — so a user can pick a category, generate a board, find words by typing or clicking, and see results highlighted with a timer.
Architecture: Server-side game logic with SQLAlchemy models persisted to SQLite via Alembic. Tornado REST endpoints handle game creation and guess validation. A vanilla JS frontend renders the grid, handles two input methods (text + click), and shows real-time state.
Tech Stack: Python 3.13, Tornado, SQLAlchemy, Alembic, SQLite, Bootstrap 5, vanilla JS
Spec: docs/superpowers/specs/2026-05-06-game-logic-design.md
File Map
| File | Action | Responsibility |
|---|---|---|
models/__init__.py |
Create | Exports Base |
models/category.py |
Create | Category, CategoryWord models |
models/game.py |
Create | Game, Word models |
db.py |
Create | Engine, session factory, init_db |
game/__init__.py |
Create | Package init |
game/factory.py |
Create | BoardFactory — grid generation + word placement |
handlers/api.py |
Create | CategoriesHandler, NewGameHandler, GameStateHandler, GuessHandler |
app.py |
Modify | Add DB init, API routes, db_session in settings |
templates/game.html |
Modify | Game setup UI + board rendering |
static/js/game.js |
Create | Frontend game logic (fetch, render, input handling) |
static/css/theme.css |
Modify | Add grid + game-specific styles |
alembic.ini |
Create | Alembic configuration |
alembic/ |
Create | Migrations directory (via alembic init) |
seed.py |
Create | Populate categories + words |
tests/unit/test_models.py |
Create | Model unit tests |
tests/unit/test_factory.py |
Create | BoardFactory unit tests |
tests/e2e/test_api.py |
Create | API endpoint integration tests |
tests/conftest.py |
Create | Shared fixtures (in-memory DB, app with DB) |
pyproject.toml |
Modify | Add sqlalchemy, alembic deps |
Task 1: Add dependencies
Files:
-
Modify:
pyproject.toml -
Step 1: Add sqlalchemy and alembic to project dependencies
uv add sqlalchemy alembic
- Step 2: Verify dependencies installed
uv run python -c "import sqlalchemy; import alembic; print('ok')"
Expected: ok
- Step 3: Commit
git add pyproject.toml uv.lock
git commit -m "feat: add sqlalchemy and alembic dependencies"
Task 2: SQLAlchemy models
Files:
-
Create:
models/__init__.py -
Create:
models/category.py -
Create:
models/game.py -
Create:
tests/unit/test_models.py -
Create:
tests/conftest.py -
Step 1: Write failing tests for models
Create tests/conftest.py:
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base
@pytest.fixture
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
engine.dispose()
Create tests/unit/test_models.py:
from models.category import Category, CategoryWord
from models.game import Game, Word
def test_create_category(db_session):
cat = Category(name="animals")
db_session.add(cat)
db_session.commit()
assert cat.id is not None
assert cat.name == "animals"
def test_create_category_word(db_session):
cat = Category(name="animals")
db_session.add(cat)
db_session.flush()
word = CategoryWord(category_id=cat.id, word="TIGER")
db_session.add(word)
db_session.commit()
assert word.category_id == cat.id
assert word.word == "TIGER"
def test_create_game(db_session):
cat = Category(name="animals")
db_session.add(cat)
db_session.flush()
game = Game(
category_id=cat.id,
board_size=10,
grid=[["A"] * 10 for _ in range(10)],
)
db_session.add(game)
db_session.commit()
assert game.id is not None
assert len(game.id) == 36 # UUID
assert game.status == "in_progress"
assert game.started_at is not None
assert game.completed_at is None
def test_create_word(db_session):
cat = Category(name="animals")
db_session.add(cat)
db_session.flush()
game = Game(category_id=cat.id, board_size=10, grid=[])
db_session.add(game)
db_session.flush()
word = Word(
game_id=game.id,
text="TIGER",
start_row=0,
start_col=0,
direction="E",
)
db_session.add(word)
db_session.commit()
assert word.found is False
assert word.found_at is None
def test_game_words_relationship(db_session):
cat = Category(name="animals")
db_session.add(cat)
db_session.flush()
game = Game(category_id=cat.id, board_size=10, grid=[])
db_session.add(game)
db_session.flush()
w1 = Word(game_id=game.id, text="TIGER", start_row=0, start_col=0, direction="E")
w2 = Word(game_id=game.id, text="LION", start_row=1, start_col=0, direction="S")
db_session.add_all([w1, w2])
db_session.commit()
db_session.refresh(game)
assert len(game.words) == 2
- Step 2: Run tests to verify they fail
uv run pytest tests/unit/test_models.py -v
Expected: ImportError / ModuleNotFoundError — models package doesn't exist yet.
- Step 3: Create models package and Base
Create models/__init__.py:
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
- Step 4: Create Category and CategoryWord models
Create models/category.py:
from sqlalchemy import Column, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from models import Base
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), unique=True, nullable=False)
words = relationship("CategoryWord", back_populates="category")
class CategoryWord(Base):
__tablename__ = "category_words"
__table_args__ = (UniqueConstraint("category_id", "word"),)
id = Column(Integer, primary_key=True, autoincrement=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False)
word = Column(String(20), nullable=False)
category = relationship("Category", back_populates="words")
- Step 5: Create Game and Word models
Create models/game.py:
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
)
from sqlalchemy.orm import relationship
from sqlalchemy.types import JSON
from models import Base
class Game(Base):
__tablename__ = "games"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False)
board_size = Column(Integer, nullable=False, default=10)
grid = Column(JSON, nullable=False)
status = Column(String(20), nullable=False, default="in_progress")
started_at = Column(
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
)
completed_at = Column(DateTime, nullable=True)
category = relationship("Category")
words = relationship("Word", back_populates="game")
class Word(Base):
__tablename__ = "words"
id = Column(Integer, primary_key=True, autoincrement=True)
game_id = Column(String(36), ForeignKey("games.id"), nullable=False)
text = Column(String(20), nullable=False)
start_row = Column(Integer, nullable=False)
start_col = Column(Integer, nullable=False)
direction = Column(String(2), nullable=False)
found = Column(Boolean, nullable=False, default=False)
found_at = Column(DateTime, nullable=True)
game = relationship("Game", back_populates="words")
- Step 6: Run tests to verify they pass
uv run pytest tests/unit/test_models.py -v
Expected: All 5 tests PASS.
- Step 7: Commit
git add models/ tests/conftest.py tests/unit/test_models.py
git commit -m "feat: add SQLAlchemy models for Category, CategoryWord, Game, Word"
Task 3: Database setup module
Files:
-
Create:
db.py -
Step 1: Create db.py with engine and session factory
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base
DB_PATH = "sqlite:///wordsearch.db"
def get_engine(db_url=None):
return create_engine(db_url or DB_PATH)
def get_session_factory(engine):
return sessionmaker(bind=engine)
def init_db(engine):
Base.metadata.create_all(engine)
- Step 2: Verify it imports cleanly
uv run python -c "from db import get_engine, get_session_factory, init_db; print('ok')"
Expected: ok
- Step 3: Commit
git add db.py
git commit -m "feat: add database setup module"
Task 4: Board factory
Files:
-
Create:
game/__init__.py -
Create:
game/factory.py -
Create:
tests/unit/test_factory.py -
Step 1: Write failing tests for BoardFactory
Create tests/unit/test_factory.py:
import random
from models.category import Category, CategoryWord
from game.factory import BoardFactory
DIRECTIONS = {
"E": (0, 1),
"W": (0, -1),
"S": (1, 0),
"N": (-1, 0),
"SE": (1, 1),
"SW": (1, -1),
"NE": (-1, 1),
"NW": (-1, -1),
}
def _seed_category(db_session, name, words):
cat = Category(name=name)
db_session.add(cat)
db_session.flush()
for w in words:
db_session.add(CategoryWord(category_id=cat.id, word=w))
db_session.commit()
return cat
def test_factory_creates_game_and_words(db_session):
cat = _seed_category(
db_session,
"animals",
["TIGER", "LION", "BEAR", "WOLF", "EAGLE", "HAWK", "DEER", "FROG"],
)
factory = BoardFactory(db_session)
game, words = factory.create(category_id=cat.id, board_size=10, word_count=5)
assert game.board_size == 10
assert game.category_id == cat.id
assert len(game.grid) == 10
assert all(len(row) == 10 for row in game.grid)
assert len(words) <= 5
assert len(words) >= 1
def test_factory_words_are_on_grid(db_session):
cat = _seed_category(
db_session,
"test",
["CAT", "DOG", "BAT", "RAT", "OWL", "COW", "PIG", "HEN"],
)
random.seed(42)
factory = BoardFactory(db_session)
game, words = factory.create(category_id=cat.id, board_size=10, word_count=5)
for word in words:
dr, dc = DIRECTIONS[word.direction]
extracted = ""
r, c = word.start_row, word.start_col
for _ in range(len(word.text)):
extracted += game.grid[r][c]
r += dr
c += dc
assert extracted == word.text
def test_factory_fills_all_cells(db_session):
cat = _seed_category(db_session, "small", ["AB", "CD"])
factory = BoardFactory(db_session)
game, _ = factory.create(category_id=cat.id, board_size=8, word_count=2)
for row in game.grid:
for cell in row:
assert cell.isalpha() and cell.isupper() and len(cell) == 1
def test_factory_filters_words_too_long(db_session):
cat = _seed_category(
db_session,
"mixed",
["HIPPOPOTAMUS", "CAT", "DOG"], # HIPPOPOTAMUS won't fit in 8x8
)
factory = BoardFactory(db_session)
game, words = factory.create(category_id=cat.id, board_size=8, word_count=3)
placed_texts = [w.text for w in words]
assert "HIPPOPOTAMUS" not in placed_texts
def test_factory_clamps_word_count(db_session):
cat = _seed_category(db_session, "tiny", ["CAT", "DOG"])
factory = BoardFactory(db_session)
game, words = factory.create(category_id=cat.id, board_size=10, word_count=10)
assert len(words) <= 2
- Step 2: Run tests to verify they fail
uv run pytest tests/unit/test_factory.py -v
Expected: ImportError — game.factory doesn't exist yet.
- Step 3: Create game package
Create game/__init__.py (empty file):
- Step 4: Create BoardFactory
Create game/factory.py:
import random
import string
from models.category import CategoryWord
from models.game import Game, Word
DIRECTIONS = {
"E": (0, 1),
"W": (0, -1),
"S": (1, 0),
"N": (-1, 0),
"SE": (1, 1),
"SW": (1, -1),
"NE": (-1, 1),
"NW": (-1, -1),
}
MAX_PLACEMENT_ATTEMPTS = 100
class BoardFactory:
def __init__(self, session):
self.session = session
def create(self, category_id, board_size=10, word_count=8):
candidates = (
self.session.query(CategoryWord)
.filter(CategoryWord.category_id == category_id)
.all()
)
# Filter words that fit in the grid
candidates = [cw for cw in candidates if len(cw.word) <= board_size]
random.shuffle(candidates)
selected = candidates[:word_count]
grid = [[""] * board_size for _ in range(board_size)]
placed_words = []
for cw in selected:
result = self._place_word(grid, cw.word, board_size)
if result:
row, col, direction = result
placed_words.append(
Word(
text=cw.word,
start_row=row,
start_col=col,
direction=direction,
)
)
# Fill empty cells with random letters
for r in range(board_size):
for c in range(board_size):
if grid[r][c] == "":
grid[r][c] = random.choice(string.ascii_uppercase)
game = Game(
category_id=category_id,
board_size=board_size,
grid=grid,
)
for w in placed_words:
w.game = game
return game, placed_words
def _place_word(self, grid, word, board_size):
directions = list(DIRECTIONS.keys())
for _ in range(MAX_PLACEMENT_ATTEMPTS):
direction = random.choice(directions)
dr, dc = DIRECTIONS[direction]
max_row = board_size - 1
max_col = board_size - 1
word_len = len(word)
# Calculate valid start ranges
if dr > 0:
row_range = range(0, board_size - word_len + 1)
elif dr < 0:
row_range = range(word_len - 1, board_size)
else:
row_range = range(0, board_size)
if dc > 0:
col_range = range(0, board_size - word_len + 1)
elif dc < 0:
col_range = range(word_len - 1, board_size)
else:
col_range = range(0, board_size)
if not row_range or not col_range:
continue
row = random.choice(list(row_range))
col = random.choice(list(col_range))
if self._can_place(grid, word, row, col, dr, dc):
self._do_place(grid, word, row, col, dr, dc)
return row, col, direction
return None
def _can_place(self, grid, word, row, col, dr, dc):
for i, letter in enumerate(word):
r = row + i * dr
c = col + i * dc
if grid[r][c] != "" and grid[r][c] != letter:
return False
return True
def _do_place(self, grid, word, row, col, dr, dc):
for i, letter in enumerate(word):
r = row + i * dr
c = col + i * dc
grid[r][c] = letter
- Step 5: Run tests to verify they pass
uv run pytest tests/unit/test_factory.py -v
Expected: All 5 tests PASS.
- Step 6: Commit
git add game/ tests/unit/test_factory.py
git commit -m "feat: add BoardFactory for word search grid generation"
Task 5: Alembic setup and seed data
Files:
-
Create:
alembic.ini(viaalembic init) -
Create:
alembic/env.py(modify generated file) -
Create:
seed.py -
Step 1: Initialize Alembic
uv run alembic init alembic
- Step 2: Configure alembic.ini
In alembic.ini, set the database URL:
sqlalchemy.url = sqlite:///wordsearch.db
- Step 3: Update alembic/env.py to use our models
In alembic/env.py, add the model imports so autogenerate works. Near the top of the file, after existing imports:
from models import Base
from models.category import Category, CategoryWord # noqa: F401
from models.game import Game, Word # noqa: F401
target_metadata = Base.metadata
Replace the existing target_metadata = None line with the above target_metadata = Base.metadata.
- Step 4: Generate initial migration
uv run alembic revision --autogenerate -m "initial tables"
Expected: Creates a migration file in alembic/versions/.
- Step 5: Apply migration
uv run alembic upgrade head
Expected: Creates wordsearch.db with all tables.
- Step 6: Create seed.py
Create seed.py:
from db import get_engine, get_session_factory, init_db
from models.category import Category, CategoryWord
SEED_DATA = {
"animals": [
"TIGER", "DOLPHIN", "EAGLE", "WOLF", "BEAR",
"LION", "HAWK", "DEER", "FROG", "SHARK",
"SNAKE", "WHALE", "ZEBRA", "PANDA", "OTTER",
"FALCON", "BISON", "CRANE", "MOOSE", "RAVEN",
],
"colors": [
"CRIMSON", "VIOLET", "AMBER", "SCARLET", "INDIGO",
"MAROON", "CORAL", "IVORY", "SILVER", "GOLDEN",
"BRONZE", "TEAL", "OLIVE", "PLUM", "RUST",
"SAGE", "NAVY", "JADE", "CYAN", "MAUVE",
],
"food": [
"PIZZA", "SUSHI", "MANGO", "PASTA", "TACO",
"BREAD", "STEAK", "GRAPE", "MELON", "PEACH",
"OLIVE", "LEMON", "BACON", "WAFFLE", "CREPE",
"DONUT", "SALAD", "CURRY", "BAGEL", "FUDGE",
],
}
def seed():
engine = get_engine()
init_db(engine)
Session = get_session_factory(engine)
session = Session()
for cat_name, words in SEED_DATA.items():
existing = session.query(Category).filter_by(name=cat_name).first()
if existing:
print(f"Category '{cat_name}' already exists, skipping.")
continue
cat = Category(name=cat_name)
session.add(cat)
session.flush()
for w in words:
session.add(CategoryWord(category_id=cat.id, word=w))
print(f"Seeded category '{cat_name}' with {len(words)} words.")
session.commit()
session.close()
print("Done.")
if __name__ == "__main__":
seed()
- Step 7: Run the seed script
uv run python seed.py
Expected:
Seeded category 'animals' with 20 words.
Seeded category 'colors' with 20 words.
Seeded category 'food' with 20 words.
Done.
- Step 8: Verify seed data
uv run python -c "
from db import get_engine, get_session_factory
from models.category import Category, CategoryWord
s = get_session_factory(get_engine())()
for c in s.query(Category).all():
count = s.query(CategoryWord).filter_by(category_id=c.id).count()
print(f'{c.name}: {count} words')
s.close()
"
Expected:
animals: 20 words
colors: 20 words
food: 20 words
- Step 9: Add wordsearch.db to .gitignore
Append to .gitignore:
wordsearch.db
- Step 10: Commit
git add alembic/ alembic.ini seed.py .gitignore
git commit -m "feat: add Alembic migrations and category seed data"
Task 6: API handlers
Files:
-
Create:
handlers/api.py -
Modify:
app.py -
Create:
tests/e2e/test_api.py -
Modify:
tests/conftest.py -
Step 1: Update conftest.py with app fixture that has a DB session
Update tests/conftest.py to add an app fixture with an in-memory DB:
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base
from models.category import Category, CategoryWord # noqa: F401
from models.game import Game, Word # noqa: F401
@pytest.fixture
def db_engine():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def db_session(db_engine):
Session = sessionmaker(bind=db_engine)
session = Session()
yield session
session.close()
@pytest.fixture
def app(db_engine):
from app import make_app
Session = sessionmaker(bind=db_engine)
application = make_app(session_factory=Session)
return application
- Step 2: Write failing API tests
Create tests/e2e/test_api.py:
import json
import pytest
from models.category import Category, CategoryWord
def _seed(db_session):
cat = Category(name="animals")
db_session.add(cat)
db_session.flush()
for w in ["TIGER", "LION", "BEAR", "WOLF", "EAGLE", "HAWK", "DEER", "FROG"]:
db_session.add(CategoryWord(category_id=cat.id, word=w))
db_session.commit()
return cat
@pytest.fixture
def seeded_db(db_session):
return _seed(db_session)
async def test_get_categories(http_server_client, seeded_db):
resp = await http_server_client.fetch("/api/categories")
assert resp.code == 200
data = json.loads(resp.body)
assert len(data) >= 1
assert data[0]["name"] == "animals"
async def test_create_new_game(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id, "board_size": 10})
resp = await http_server_client.fetch(
"/api/game/new", method="POST", body=body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
data = json.loads(resp.body)
assert data["board_size"] == 10
assert data["status"] == "in_progress"
assert len(data["grid"]) == 10
assert len(data["words"]) >= 1
# Words should NOT include position info
for w in data["words"]:
assert "start_row" not in w
assert "direction" not in w
async def test_get_game_state(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id})
create_resp = await http_server_client.fetch(
"/api/game/new", method="POST", body=body,
headers={"Content-Type": "application/json"},
)
game_id = json.loads(create_resp.body)["id"]
resp = await http_server_client.fetch(f"/api/game/{game_id}")
assert resp.code == 200
data = json.loads(resp.body)
assert data["id"] == game_id
async def test_text_guess_correct(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id})
create_resp = await http_server_client.fetch(
"/api/game/new", method="POST", body=body,
headers={"Content-Type": "application/json"},
)
game_data = json.loads(create_resp.body)
game_id = game_data["id"]
# Pick a word that was placed
target_word = game_data["words"][0]["text"]
guess_body = json.dumps({"word": target_word})
resp = await http_server_client.fetch(
f"/api/game/{game_id}/guess", method="POST", body=guess_body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
data = json.loads(resp.body)
assert data["correct"] is True
assert data["word"] == target_word
assert "cells" in data
async def test_text_guess_incorrect(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id})
create_resp = await http_server_client.fetch(
"/api/game/new", method="POST", body=body,
headers={"Content-Type": "application/json"},
)
game_id = json.loads(create_resp.body)["id"]
guess_body = json.dumps({"word": "ZZZZNOTAWORD"})
resp = await http_server_client.fetch(
f"/api/game/{game_id}/guess", method="POST", body=guess_body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
data = json.loads(resp.body)
assert data["correct"] is False
- Step 3: Run tests to verify they fail
uv run pytest tests/e2e/test_api.py -v
Expected: Errors — make_app doesn't accept session_factory, API routes don't exist.
- Step 4: Create handlers/api.py
Create handlers/api.py:
import json
from datetime import datetime, timezone
import tornado.web
from game.factory import BoardFactory, DIRECTIONS
from models.category import Category
from models.game import Game, Word
class BaseAPIHandler(tornado.web.RequestHandler):
def set_default_headers(self):
self.set_header("Content-Type", "application/json")
def get_session(self):
Session = self.application.settings["session_factory"]
return Session()
def write_error(self, status_code, **kwargs):
self.write(json.dumps({"error": self._reason}))
class CategoriesHandler(BaseAPIHandler):
def get(self):
session = self.get_session()
try:
categories = session.query(Category).all()
self.write(json.dumps([
{"id": c.id, "name": c.name} for c in categories
]))
finally:
session.close()
class NewGameHandler(BaseAPIHandler):
def post(self):
session = self.get_session()
try:
data = json.loads(self.request.body)
category_id = data["category_id"]
board_size = data.get("board_size", 10)
board_size = max(8, min(20, board_size))
factory = BoardFactory(session)
game, words = factory.create(
category_id=category_id,
board_size=board_size,
)
session.add(game)
for w in words:
session.add(w)
session.commit()
self.write(json.dumps(self._game_to_dict(game)))
finally:
session.close()
def _game_to_dict(self, game):
return {
"id": game.id,
"board_size": game.board_size,
"category": game.category.name,
"grid": game.grid,
"words": [
{"text": w.text, "found": w.found}
for w in game.words
],
"started_at": game.started_at.isoformat() + "Z",
"status": game.status,
"completed_at": None,
}
class GameStateHandler(BaseAPIHandler):
def get(self, game_id):
session = self.get_session()
try:
game = session.query(Game).filter_by(id=game_id).first()
if not game:
self.set_status(404)
self.write(json.dumps({"error": "Game not found"}))
return
self.write(json.dumps({
"id": game.id,
"board_size": game.board_size,
"category": game.category.name,
"grid": game.grid,
"words": [
{"text": w.text, "found": w.found}
for w in game.words
],
"started_at": game.started_at.isoformat() + "Z",
"status": game.status,
"completed_at": (
game.completed_at.isoformat() + "Z"
if game.completed_at
else None
),
}))
finally:
session.close()
class GuessHandler(BaseAPIHandler):
def post(self, game_id):
session = self.get_session()
try:
game = session.query(Game).filter_by(id=game_id).first()
if not game:
self.set_status(404)
self.write(json.dumps({"error": "Game not found"}))
return
data = json.loads(self.request.body)
if "word" in data:
result = self._handle_text_guess(session, game, data["word"])
elif "cells" in data:
result = self._handle_click_guess(session, game, data["cells"])
else:
self.set_status(400)
self.write(json.dumps({"error": "Provide 'word' or 'cells'"}))
return
self.write(json.dumps(result))
finally:
session.close()
def _handle_text_guess(self, session, game, guess_text):
guess_text = guess_text.upper().strip()
word = (
session.query(Word)
.filter_by(game_id=game.id, text=guess_text, found=False)
.first()
)
if not word:
return self._miss_response(game, session)
return self._mark_found(session, game, word)
def _handle_click_guess(self, session, game, cells):
# Extract letters from the grid at the given coordinates
grid = game.grid
try:
letters = "".join(grid[c["row"]][c["col"]] for c in cells)
except (IndexError, KeyError):
return self._miss_response(game, session)
# Find a matching word
word = (
session.query(Word)
.filter_by(game_id=game.id, text=letters, found=False)
.first()
)
if not word:
return self._miss_response(game, session)
# Verify the cells match the word's actual placement
dr, dc = DIRECTIONS[word.direction]
expected_cells = [
{"row": word.start_row + i * dr, "col": word.start_col + i * dc}
for i in range(len(word.text))
]
if cells != expected_cells:
return self._miss_response(game, session)
return self._mark_found(session, game, word)
def _mark_found(self, session, game, word):
word.found = True
word.found_at = datetime.now(timezone.utc)
# Check if all words found
unfound = (
session.query(Word)
.filter_by(game_id=game.id, found=False)
.count()
)
if unfound == 0:
game.status = "completed"
game.completed_at = datetime.now(timezone.utc)
session.commit()
dr, dc = DIRECTIONS[word.direction]
cells = [
{"row": word.start_row + i * dr, "col": word.start_col + i * dc}
for i in range(len(word.text))
]
return {
"correct": True,
"word": word.text,
"cells": cells,
"words": [
{"text": w.text, "found": w.found}
for w in game.words
],
"status": game.status,
"completed_at": (
game.completed_at.isoformat() + "Z"
if game.completed_at
else None
),
}
def _miss_response(self, game, session):
return {
"correct": False,
"word": None,
"cells": [],
"words": [
{"text": w.text, "found": w.found}
for w in game.words
],
"status": game.status,
"completed_at": None,
}
- Step 5: Update app.py to accept session_factory and add API routes
Replace app.py with:
import os
import click
import tornado.ioloop
import tornado.web
from db import get_engine, get_session_factory, init_db
from handlers.main import MainHandler
from handlers.game import GameHandler
from handlers.api import (
CategoriesHandler,
NewGameHandler,
GameStateHandler,
GuessHandler,
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
def make_app(session_factory=None):
return tornado.web.Application(
[
(r"/", MainHandler),
(r"/game", GameHandler),
(r"/api/categories", CategoriesHandler),
(r"/api/game/new", NewGameHandler),
(r"/api/game/([^/]+)", GameStateHandler),
(r"/api/game/([^/]+)/guess", GuessHandler),
],
template_path=os.path.join(BASE_DIR, "templates"),
static_path=os.path.join(BASE_DIR, "static"),
session_factory=session_factory,
debug=True,
)
@click.command()
@click.option("--port", default=8888, type=int, help="Port to listen on.")
def main(port):
engine = get_engine()
init_db(engine)
session_factory = get_session_factory(engine)
app = make_app(session_factory=session_factory)
app.listen(port)
print(f"Server started at http://localhost:{port}")
tornado.ioloop.IOLoop.current().start()
if __name__ == "__main__":
main()
- Step 6: Update pyproject.toml packages.find to include new packages
In pyproject.toml, update the [tool.setuptools.packages.find] section:
[tool.setuptools.packages.find]
include = ["handlers*", "models*", "game*"]
- Step 7: Run API tests to verify they pass
uv run pytest tests/e2e/test_api.py -v
Expected: All 5 tests PASS.
- Step 8: Run all tests to verify nothing is broken
uv run pytest -v
Expected: All tests PASS (model tests, factory tests, API tests, existing e2e test).
- Step 9: Commit
git add handlers/api.py app.py tests/conftest.py tests/e2e/test_api.py pyproject.toml
git commit -m "feat: add REST API for categories, game creation, and guessing"
Task 7: Frontend — game page and JavaScript
Files:
-
Modify:
templates/game.html -
Create:
static/js/game.js -
Modify:
static/css/theme.css -
Step 1: Rewrite templates/game.html with game UI structure
Replace templates/game.html with:
{% extends "base.html" %}
{% block title %}Word Search — Play{% end %}
{% block content %}
<!-- Game Setup -->
<div id="game-setup">
<h2 class="mb-4">New Game</h2>
<div class="card">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label for="category-select" class="form-label">Category</label>
<select id="category-select" class="form-select"></select>
</div>
<div class="col-md-4">
<label for="board-size" class="form-label">
Board Size: <span id="size-display">10</span>
</label>
<input type="range" id="board-size" class="form-range"
min="8" max="20" value="10">
</div>
<div class="col-md-4">
<button id="new-game-btn" class="btn btn-accent btn-lg w-100">
Start Game
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Game Board -->
<div id="game-board" class="d-none">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Word Search</h2>
<div class="d-flex align-items-center gap-3">
<span id="timer" class="badge bg-dark fs-6">0:00</span>
<button id="play-again-btn" class="btn btn-outline-light btn-sm d-none">
Play Again
</button>
</div>
</div>
<!-- Completion Banner -->
<div id="completion-banner" class="alert alert-success d-none" role="alert">
<strong>Congratulations!</strong> You found all the words in
<span id="final-time"></span>!
</div>
<div class="row">
<!-- Grid -->
<div class="col-lg-8 mb-3">
<div class="card">
<div class="card-body p-2">
<table id="grid-table" class="game-grid mx-auto"></table>
</div>
</div>
<!-- Input Area -->
<div class="card mt-3" id="input-area">
<div class="card-body">
<div class="row g-2">
<div class="col">
<input type="text" id="word-input"
class="form-control"
placeholder="Type a word..."
autocomplete="off">
</div>
<div class="col-auto">
<button id="guess-btn" class="btn btn-accent">
Submit
</button>
</div>
<div class="col-auto">
<button id="submit-selection-btn"
class="btn btn-outline-light d-none">
Submit Selection
</button>
</div>
<div class="col-auto">
<button id="clear-selection-btn"
class="btn btn-outline-secondary d-none">
Clear
</button>
</div>
</div>
<div id="guess-feedback" class="mt-2"></div>
</div>
</div>
</div>
<!-- Word List -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">Words to Find</div>
<ul id="word-list" class="list-group list-group-flush"></ul>
</div>
</div>
</div>
</div>
<script src="{{ static_url('js/game.js') }}"></script>
{% end %}
- Step 2: Create static/js/game.js
Create static/js/game.js:
(function () {
"use strict";
let gameState = null;
let timerInterval = null;
let selectedCells = [];
// DOM refs
const setupDiv = document.getElementById("game-setup");
const boardDiv = document.getElementById("game-board");
const categorySelect = document.getElementById("category-select");
const boardSizeInput = document.getElementById("board-size");
const sizeDisplay = document.getElementById("size-display");
const newGameBtn = document.getElementById("new-game-btn");
const gridTable = document.getElementById("grid-table");
const wordList = document.getElementById("word-list");
const wordInput = document.getElementById("word-input");
const guessBtn = document.getElementById("guess-btn");
const submitSelectionBtn = document.getElementById("submit-selection-btn");
const clearSelectionBtn = document.getElementById("clear-selection-btn");
const guessFeedback = document.getElementById("guess-feedback");
const timerEl = document.getElementById("timer");
const completionBanner = document.getElementById("completion-banner");
const finalTime = document.getElementById("final-time");
const playAgainBtn = document.getElementById("play-again-btn");
const inputArea = document.getElementById("input-area");
// Load categories
async function loadCategories() {
const resp = await fetch("/api/categories");
const categories = await resp.json();
categorySelect.innerHTML = "";
categories.forEach(function (cat) {
const opt = document.createElement("option");
opt.value = cat.id;
opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1);
categorySelect.appendChild(opt);
});
}
boardSizeInput.addEventListener("input", function () {
sizeDisplay.textContent = this.value;
});
// New game
newGameBtn.addEventListener("click", async function () {
const categoryId = parseInt(categorySelect.value);
const boardSize = parseInt(boardSizeInput.value);
const resp = await fetch("/api/game/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category_id: categoryId, board_size: boardSize }),
});
gameState = await resp.json();
showBoard();
});
function showBoard() {
setupDiv.classList.add("d-none");
boardDiv.classList.remove("d-none");
completionBanner.classList.add("d-none");
playAgainBtn.classList.add("d-none");
inputArea.classList.remove("d-none");
selectedCells = [];
renderGrid();
renderWordList();
startTimer();
}
function renderGrid() {
gridTable.innerHTML = "";
gameState.grid.forEach(function (row, r) {
const tr = document.createElement("tr");
row.forEach(function (letter, c) {
const td = document.createElement("td");
td.textContent = letter;
td.dataset.row = r;
td.dataset.col = c;
td.addEventListener("click", function () {
onCellClick(r, c, td);
});
tr.appendChild(td);
});
gridTable.appendChild(tr);
});
}
function renderWordList() {
wordList.innerHTML = "";
gameState.words.forEach(function (w) {
const li = document.createElement("li");
li.className = "list-group-item";
li.textContent = w.text;
li.dataset.word = w.text;
if (w.found) {
li.classList.add("word-found");
}
wordList.appendChild(li);
});
}
// Timer
function startTimer() {
if (timerInterval) clearInterval(timerInterval);
const startedAt = new Date(gameState.started_at);
function tick() {
const elapsed = Math.floor((Date.now() - startedAt.getTime()) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
timerEl.textContent = mins + ":" + (secs < 10 ? "0" : "") + secs;
}
tick();
timerInterval = setInterval(tick, 1000);
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
// Text guess
guessBtn.addEventListener("click", submitTextGuess);
wordInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") submitTextGuess();
});
async function submitTextGuess() {
const word = wordInput.value.trim();
if (!word) return;
wordInput.value = "";
await sendGuess({ word: word });
}
// Click selection
function onCellClick(row, col, td) {
if (gameState.status === "completed") return;
// Check if already selected — deselect
const idx = selectedCells.findIndex(
function (c) { return c.row === row && c.col === col; }
);
if (idx !== -1) {
selectedCells.splice(idx, 1);
td.classList.remove("cell-selected");
updateSelectionButtons();
return;
}
// Validate straight line
if (selectedCells.length >= 2) {
if (!isValidExtension(row, col)) {
showFeedback("Selection must form a straight line.", "text-warning");
return;
}
} else if (selectedCells.length === 1) {
// Any adjacent-ish cell is fine for the second pick — direction is set
}
selectedCells.push({ row: row, col: col });
td.classList.add("cell-selected");
updateSelectionButtons();
}
function isValidExtension(row, col) {
if (selectedCells.length < 2) return true;
const dr = selectedCells[1].row - selectedCells[0].row;
const dc = selectedCells[1].col - selectedCells[0].col;
// Normalize direction
const len = selectedCells.length;
const expectedRow = selectedCells[0].row + dr * len;
const expectedCol = selectedCells[0].col + dc * len;
return row === expectedRow && col === expectedCol;
}
function updateSelectionButtons() {
if (selectedCells.length > 0) {
submitSelectionBtn.classList.remove("d-none");
clearSelectionBtn.classList.remove("d-none");
} else {
submitSelectionBtn.classList.add("d-none");
clearSelectionBtn.classList.add("d-none");
}
}
submitSelectionBtn.addEventListener("click", async function () {
if (selectedCells.length === 0) return;
await sendGuess({ cells: selectedCells });
clearSelection();
});
clearSelectionBtn.addEventListener("click", clearSelection);
function clearSelection() {
selectedCells = [];
document.querySelectorAll(".cell-selected").forEach(function (td) {
td.classList.remove("cell-selected");
});
updateSelectionButtons();
}
// Send guess to server
async function sendGuess(payload) {
const resp = await fetch("/api/game/" + gameState.id + "/guess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await resp.json();
gameState.words = result.words;
gameState.status = result.status;
if (result.correct) {
highlightFoundCells(result.cells);
renderWordList();
showFeedback("Found: " + result.word + "!", "text-success");
} else {
showFeedback("Not a match.", "text-danger");
}
if (result.status === "completed") {
onGameComplete(result.completed_at);
}
}
function highlightFoundCells(cells) {
cells.forEach(function (c) {
const td = gridTable.querySelector(
'td[data-row="' + c.row + '"][data-col="' + c.col + '"]'
);
if (td) td.classList.add("cell-found");
});
}
function showFeedback(msg, cls) {
guessFeedback.textContent = msg;
guessFeedback.className = "mt-2 " + cls;
setTimeout(function () {
guessFeedback.textContent = "";
guessFeedback.className = "mt-2";
}, 2000);
}
function onGameComplete(completedAt) {
stopTimer();
completionBanner.classList.remove("d-none");
finalTime.textContent = timerEl.textContent;
playAgainBtn.classList.remove("d-none");
inputArea.classList.add("d-none");
}
playAgainBtn.addEventListener("click", function () {
boardDiv.classList.add("d-none");
setupDiv.classList.remove("d-none");
});
// Init
loadCategories();
})();
- Step 3: Add game grid and interaction styles to theme.css
Append to static/css/theme.css:
/* Game grid */
.game-grid {
border-collapse: collapse;
}
.game-grid td {
width: 2.5rem;
height: 2.5rem;
text-align: center;
vertical-align: middle;
font-size: 1.1rem;
font-weight: 600;
font-family: monospace;
border: 1px solid var(--teal-mid);
cursor: pointer;
user-select: none;
transition: background-color 0.15s;
}
.game-grid td:hover {
background-color: var(--teal-mid);
}
.game-grid td.cell-selected {
background-color: #1a6b6b;
outline: 2px solid var(--teal-light);
outline-offset: -2px;
}
.game-grid td.cell-found {
background-color: var(--teal-accent);
color: #ffffff;
}
/* Word list */
.list-group-item {
background-color: var(--teal-dark);
border-color: var(--teal-mid);
color: #e0e0e0;
}
.list-group-item.word-found {
text-decoration: line-through;
color: var(--teal-accent);
}
.card-header {
background-color: var(--teal-mid);
border-color: var(--teal-mid);
color: #ffffff;
font-weight: 600;
}
/* Form controls in dark theme */
.form-select,
.form-control {
background-color: var(--teal-dark);
border-color: var(--teal-mid);
color: #e0e0e0;
}
.form-select:focus,
.form-control:focus {
background-color: var(--teal-dark);
border-color: var(--teal-accent);
color: #e0e0e0;
box-shadow: 0 0 0 0.2rem rgba(0, 179, 179, 0.25);
}
.form-range::-webkit-slider-thumb {
background: var(--teal-accent);
}
.form-range::-moz-range-thumb {
background: var(--teal-accent);
}
- Step 4: Manual verification
Start the server and seed the DB:
uv run python seed.py
uv run python app.py
Open http://localhost:8888/game in a browser. Verify:
- Category dropdown is populated (Animals, Colors, Food)
- Board size slider works (displays value)
- "Start Game" creates a board — grid renders with letters
- Word list sidebar shows target words
- Timer counts up
- Type a word from the list and submit — word highlights on grid, crosses off list
- Click cells in a line and submit selection — same result
- Finding all words shows completion banner and stops timer
- "Play Again" returns to setup
- Step 5: Run all tests
uv run pytest -v
Expected: All tests PASS.
- Step 6: Run formatters and linter
uv run black . && uv run flake8
Expected: Clean.
- Step 7: Commit
git add templates/game.html static/js/game.js static/css/theme.css
git commit -m "feat: add game page frontend with grid, word list, timer, and dual input"
Task 8: Delete placeholder tests
Files:
-
Modify:
tests/unit/test_hello.py -
Modify:
tests/e2e/test_hello.py -
Step 1: Remove placeholder tests
Delete tests/unit/test_hello.py and update tests/e2e/test_hello.py — move its test_homepage_returns_200 into tests/e2e/test_api.py (it already uses the same app fixture pattern).
Add to the end of tests/e2e/test_api.py:
async def test_homepage_returns_200(http_server_client):
response = await http_server_client.fetch("/")
assert response.code == 200
Then delete tests/unit/test_hello.py and tests/e2e/test_hello.py.
- Step 2: Run all tests
uv run pytest -v
Expected: All tests PASS.
- Step 3: Commit
git add -A tests/
git commit -m "chore: remove placeholder tests, consolidate homepage test"