Files
Mathew Sir Guest the bestandClaude Opus 4.6 36c3c712fe feat: add scoreboard with player names, per-category leaderboards, and themed tables
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>
2026-05-07 10:38:19 -06:00

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 (via alembic 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:

  1. Category dropdown is populated (Animals, Colors, Food)
  2. Board size slider works (displays value)
  3. "Start Game" creates a board — grid renders with letters
  4. Word list sidebar shows target words
  5. Timer counts up
  6. Type a word from the list and submit — word highlights on grid, crosses off list
  7. Click cells in a line and submit selection — same result
  8. Finding all words shows completion banner and stops timer
  9. "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"