# 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** ```bash uv add sqlalchemy alembic ``` - [ ] **Step 2: Verify dependencies installed** ```bash uv run python -c "import sqlalchemy; import alembic; print('ok')" ``` Expected: `ok` - [ ] **Step 3: Commit** ```bash 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`: ```python 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`: ```python 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** ```bash 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`: ```python from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): pass ``` - [ ] **Step 4: Create Category and CategoryWord models** Create `models/category.py`: ```python 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`: ```python 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** ```bash uv run pytest tests/unit/test_models.py -v ``` Expected: All 5 tests PASS. - [ ] **Step 7: Commit** ```bash 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** ```python 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** ```bash uv run python -c "from db import get_engine, get_session_factory, init_db; print('ok')" ``` Expected: `ok` - [ ] **Step 3: Commit** ```bash 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`: ```python 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** ```bash 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): ```python ``` - [ ] **Step 4: Create BoardFactory** Create `game/factory.py`: ```python 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** ```bash uv run pytest tests/unit/test_factory.py -v ``` Expected: All 5 tests PASS. - [ ] **Step 6: Commit** ```bash 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** ```bash uv run alembic init alembic ``` - [ ] **Step 2: Configure alembic.ini** In `alembic.ini`, set the database URL: ```ini 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: ```python 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** ```bash uv run alembic revision --autogenerate -m "initial tables" ``` Expected: Creates a migration file in `alembic/versions/`. - [ ] **Step 5: Apply migration** ```bash uv run alembic upgrade head ``` Expected: Creates `wordsearch.db` with all tables. - [ ] **Step 6: Create seed.py** Create `seed.py`: ```python 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** ```bash 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** ```bash 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** ```bash 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: ```python 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`: ```python 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** ```bash 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`: ```python 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: ```python 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: ```toml [tool.setuptools.packages.find] include = ["handlers*", "models*", "game*"] ``` - [ ] **Step 7: Run API tests to verify they pass** ```bash uv run pytest tests/e2e/test_api.py -v ``` Expected: All 5 tests PASS. - [ ] **Step 8: Run all tests to verify nothing is broken** ```bash uv run pytest -v ``` Expected: All tests PASS (model tests, factory tests, API tests, existing e2e test). - [ ] **Step 9: Commit** ```bash 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: ```html {% extends "base.html" %} {% block title %}Word Search — Play{% end %} {% block content %}