Files
games/AGENTS.md
Ryan Hamamura 551190b801
All checks were successful
CI / Deploy / test (pull_request) Successful in 15s
CI / Deploy / lint (pull_request) Successful in 28s
CI / Deploy / deploy (pull_request) Has been skipped
Switch to datastar-pro and stop tracking downloaded libs
Datastar-pro is fetched from a private Gitea repo (ryan/vendor-libs)
using VENDOR_TOKEN for CI/Docker builds, with a local fallback from
../optional/ for development. DaisyUI is pinned to v5.5.19 instead of
tracking latest. Downloaded files are now gitignored and fetched at
build time via 'task download', which is a dependency of both build
and live tasks.
2026-03-11 13:17:50 -10:00

7.9 KiB

AGENTS.md

Instructions for AI coding agents working in this repository.

Quick Reference

# Development
task live              # Hot-reload dev server (templ + tailwind + air)
task build             # Production build to bin/games
task run               # Build and run server
task download          # Download pinned client-side libs (datastar-pro, daisyui)

# Quality
task test              # Run all tests: go test ./...
task lint              # Run linter: golangci-lint run

# Single test
go test -run TestName ./path/to/package
go test -v -run TestHandleLogin_Success ./features/auth

# Code generation
task build:templ       # Compile .templ files (go tool templ generate)
task build:styles      # Build TailwindCSS (go tool gotailwind)

Tools (templ, air, gotailwind, goose, sqlc) are managed via Go 1.25's tool directive in go.mod — no separate installs needed.

Workflow Rules

  • Never merge PRs without explicit user approval. Create the PR, push changes, then wait.
  • Always use PRs via tea CLI — never push directly to main.
  • Write semantic commit messages focusing on "why" not "what".

Project Structure

games/
├── connect4/, snake/       # Game logic packages (pure Go, no HTTP)
├── features/               # Feature modules (handlers, routes, templates)
│   ├── auth/               # Login/register (standard HTTP, not SSE)
│   ├── c4game/             # Connect 4 UI + services
│   ├── snakegame/          # Snake UI + services
│   ├── lobby/              # Game lobby
│   └── common/             # Shared components, layouts
├── chat/                   # Reusable chat room (NATS + optional DB persistence)
├── auth/                   # Password hashing/validation (pure, no HTTP)
├── db/                     # SQLite, migrations, sqlc queries
├── cmd/downloader/         # Build-time tool: fetches datastar-pro + daisyui
├── assets/                 # Static files (embedded in prod, filesystem in dev)
└── config/, logging/, nats/, sessions/, router/, player/, version/

Code Style

Imports

Three groups separated by blank lines: stdlib, third-party, local. Enforced by goimports with local-prefixes: github.com/ryanhamamura/games.

import (
    "fmt"
    "net/http"

    "github.com/go-chi/chi/v5"

    "github.com/ryanhamamura/games/connect4"
)

Error Handling

// Wrap errors with context
return fmt.Errorf("loading game %s: %w", id, err)

// Combine cleanup errors with errors.Join
return nil, errors.Join(fmt.Errorf("pinging database: %w", err), DB.Close())

// Best-effort operations
nc.Publish(subject, nil) //nolint:errcheck // best-effort notification

// HTTP errors
http.Error(w, "game not found", http.StatusNotFound)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

Comments

  • Focus on why, not how. Avoid superfluous comments.
  • Package comments at top of primary file.
  • Function comments for exported functions.

Go Patterns

Dependency Injection via Closures

Handlers receive dependencies and return http.HandlerFunc:

func HandleGamePage(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) { /* ... */ }
}

Cleanup Function Returns

Infrastructure init functions return a cleanup func the caller defers:

cleanupDB, err := db.Init(cfg.DBPath)
defer cleanupDB()

Store/Instance Pattern

Game state uses a two-tier pattern: a thread-safe Store (map + RWMutex) holding Instance wrappers (individual game + own mutex + DB queries). Stores lazy-load from DB on cache miss.

Build Tags

//go:build dev and //go:build !dev switch behavior for static asset serving (filesystem vs embedded hashfs) and config loading.

Templ + Datastar Patterns

Architecture: Everything Is a Stream

The core mental model: the server owns all state and continuously projects it to the browser over SSE. There is no client-side state management. The browser connects to an event stream, and the server pushes full HTML fragments whenever something changes. Datastar morphs these into the DOM — the client is a thin rendering surface.

User actions (clicks, keypresses) trigger short POST/DELETE requests back to the server. The server mutates state, publishes a NATS signal, and every connected SSE stream picks up the change and re-renders. The client never needs to know what changed — it just receives the new truth and morphs to match.

This means: always send whole components down the wire. Don't try to diff or send minimal patches. Render the full templ component, call sse.PatchElementTempl(), and let Datastar's morph handle the rest. The only exception is appending to a list (e.g. chat messages).

Signals follow command-query segregation. Signals are commands — they carry the user's intent to the server (form input values, button clicks). The SSE stream is the query — it continuously projects the server's truth into the DOM. Keep signals thin: form input buffers (chatMsg, nickname), pure UI state the server never needs (activeTab), and request indicators. Don't use signals to hold application state — that arrives from the server via SSE.

SSE Event Loop

Both game event handlers follow the same structure:

  1. Subscribe to NATS channels before creating SSE (avoids missed messages)
  2. Send initial full-state patch
  3. select loop over: context done, game updates (drain channel first), chat messages (append), 1-second heartbeat (full re-render)
// Handler side — long-lived SSE with Brotli compression
sse := datastar.NewSSE(w, r, datastar.WithCompression(
    datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
sse.PatchElementTempl(components.GameBoard(game))

// Template side — disable Datastar's default SSE cancellation on interaction
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }

Client-Server Interactions

// Trigger SSE actions from templates
data-on:click={ datastar.PostSSE("/games/%s/drop?col=%d", g.ID, colIdx) }
data-on:click={ datastar.DeleteSSE("/games/%s", g.ID) }

// Read client signals in handlers
var signals struct { ChatMsg string `json:"chatMsg"` }
datastar.ReadSignals(r, &signals)

// Clear input after submission
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""})

// Redirect via SSE
sse.Redirectf("/games/%s", newGame.ID())

Appending Elements (Chat Messages)

The one exception to whole-component morphing is chat, where messages are appended individually:

sse.PatchElementTempl(
    chatcomponents.ChatMessage(msg, cfg),
    datastar.WithSelectorID("c4-chat-history"),
    datastar.WithModeAppend(),
)

Datastar Template Attributes

  • data-signals — declare reactive state
  • data-bind — two-way input binding
  • data-show — conditional visibility
  • data-class — reactive CSS classes
  • data-morph-ignore — prevent SSE from overwriting an element (e.g. chat input)

Testing

task test                                          # All tests
go test -run TestHandleLogin_Success ./features/auth  # Single test
go test -v ./features/auth                         # Verbose package
  • Use testutil.NewTestDB(t) for tests needing a database
  • Use testutil.NewTestSessionManager(db) for session-aware tests
  • Use config.LoadForTest() to set safe defaults without .env
  • Tests use external test packages (package auth_test)

Tech Stack

Layer Technology
Templates templ (type-safe HTML)
Reactivity Datastar Pro (SSE-driven)
CSS TailwindCSS v4 + daisyUI
Router chi/v5
Sessions scs/v2 (SQLite-backed)
Database SQLite (modernc.org/sqlite)
Migrations goose (embedded SQL)
SQL codegen sqlc
Pub/sub Embedded NATS (nil-payload signals)
Logging zerolog + slog (bridged via slog-zerolog)