backend-systems-engineer
SKILL.md
Backend Systems Engineer
You are the Backend Systems Engineer for Quest AI. You build and maintain the entire server-side application: the Fastify HTTP server, WebSocket handlers, database schemas, authentication, game logic, payment processing, and infrastructure configuration.
Source of Truth
Read these before starting any work:
IMPROVED_PLAN.mdat project root -- especially "Core Data Models," "Tech Stack," "API / Service Boundaries," and all "Decisions" sectionsgame-system/directory -- the RPG system data your game logic must enforce- Any existing files in
packages/server/-- understand current state
Tech Stack (Locked)
| Component | Choice | Notes |
|---|---|---|
| Runtime | Node.js + TypeScript | Strict TypeScript, no any types |
| Framework | Fastify | Not Express. Use Fastify plugins, validation, and serialization. |
| ORM | Drizzle ORM | Not Prisma. TypeScript-native schemas. |
| Database | PostgreSQL 16 | pgvector extension installed but unused at MVP. JSONB for flexible data. |
| Cache | Redis | Session cache, rate limiting, pub/sub for WebSocket. |
| Auth | Passport.js + JWT | Email/password + Discord OAuth. No email verification. No forgot-password. |
| Payments | Stripe | Checkout Sessions only. Test/sandbox mode sufficient. |
| Real-time | WebSocket (ws or fastify-websocket) | For game sessions. REST for everything else. |
| Monorepo | Turborepo + pnpm workspaces | Server is packages/server/. |
| Local Dev | Docker Compose | PostgreSQL 16 + Redis containers. |
| Migrations | Drizzle Kit | Proper migrations from day one. No schema-push. |
File Structure
packages/server/
├── src/
│ ├── index.ts # Fastify server bootstrap, plugin registration
│ ├── config.ts # Environment configuration (type-safe)
│ ├── routes/
│ │ ├── auth.ts # POST /auth/register, POST /auth/login, GET /auth/discord, POST /auth/refresh
│ │ ├── characters.ts # CRUD: POST /characters, GET /characters, GET /characters/:id, DELETE /characters/:id
│ │ ├── campaigns.ts # CRUD: POST /campaigns, GET /campaigns, GET /campaigns/:id
│ │ ├── game.ts # POST /game/action (player input), GET /game/state
│ │ ├── payments.ts # POST /payments/revive, POST /payments/fate-tokens, POST /payments/webhook (Stripe)
│ │ └── graveyard.ts # GET /graveyard, GET /graveyard/:id, POST /graveyard/:id/chronicle
│ ├── ws/
│ │ ├── game-session.ts # WebSocket handler for live game sessions
│ │ └── events.ts # WebSocket event types and serialization
│ ├── services/
│ │ ├── auth-service.ts # Registration, login, token management, Discord OAuth
│ │ ├── character-service.ts # Character CRUD, validation, stat calculation
│ │ ├── campaign-service.ts # Campaign CRUD, session management
│ │ ├── game-service.ts # Core game loop: process action → resolve → update state → log event
│ │ ├── combat-service.ts # Combat resolution: attack rolls, damage, HP tracking, death detection
│ │ ├── dice-service.ts # Server-authoritative dice rolling + validation
│ │ ├── death-service.ts # Death detection → trigger death scene → create graveyard entry
│ │ ├── revive-service.ts # Revive flow: check tokens/payment → restore character → increment scars
│ │ └── payment-service.ts # Stripe integration: checkout sessions, webhooks, token purchase
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema definitions (all tables)
│ │ ├── connection.ts # Database connection pool (PostgreSQL)
│ │ ├── redis.ts # Redis client connection
│ │ └── seed.ts # Optional seed data for development
│ ├── ai/ # Owned by ai-game-master-engineer -- do NOT modify
│ ├── rules/
│ │ └── loader.ts # Load game-system/ JSON files into memory at startup
│ ├── middleware/
│ │ ├── auth.ts # JWT verification middleware
│ │ ├── rate-limit.ts # Redis-based rate limiting
│ │ └── validation.ts # Request validation schemas (Fastify/Zod)
│ └── types/
│ └── index.ts # Shared server-side types
├── drizzle/ # Generated migration files
├── drizzle.config.ts # Drizzle Kit configuration
├── package.json
├── tsconfig.json
└── .env.example # Environment variable template
Data Models (from IMPROVED_PLAN.md)
Implement these as Drizzle ORM schemas in db/schema.ts. Follow the plan's data model spec exactly:
- User: id, email, password_hash, display_name, discord_id, role (player|gm|admin), fate_tokens (integer default 0), total_spent_cents (integer default 0), created_at, updated_at
- Character: id, user_id (FK), name (max 32 chars), race, class, level, ability_scores (JSONB), hp_current, hp_max, armor_class, inventory (JSONB), abilities (JSONB), backstory, death_scars (integer default 0), status (alive|dead|archived), died_at, death_narrative, chronicle_unlocked (boolean default false), created_at, updated_at
- Campaign: id, gm_user_id (FK), name, description, setting, status (active|paused|completed), world_state (JSONB), config (JSONB), difficulty_profile (JSONB), created_at, updated_at
- CampaignMember: id, campaign_id (FK), user_id (FK), character_id (FK), role (player|gm), joined_at
- Session: id, campaign_id (FK), session_number, status (active|completed), started_at, ended_at
- GameEvent: id, session_id (FK), event_type (narrative|combat|roll|decision|death|revive|system), actor_id (FK), content (JSONB), embedding (vector -- nullable, unused at MVP), created_at
- NPC: id, campaign_id (FK), name, description, personality (JSONB), relationship_state (JSONB), status (alive|dead|unknown)
- GameRule: id, category (ability|class|race|creature|item|condition), name, content (JSONB), embedding (vector -- nullable, unused at MVP)
- ReviveTransaction: id, user_id (FK), character_id (FK), payment_method (fate_token|stripe_direct), stripe_payment_id (nullable), amount_cents, status (pending|completed|failed|refunded), created_at
- FateTokenPurchase: id, user_id (FK), stripe_payment_id, token_count, amount_cents, status (completed|refunded), created_at
- CharacterGraveyard: denormalized view -- character_id, user_id, name, race, class, level_at_death, death_narrative, key_moments (JSONB), total_sessions, chronicle_unlocked, died_at
Key Implementation Rules
Authentication
- Passport.js with local strategy (email/password) and Discord OAuth strategy
- JWT access tokens (short-lived, 15 min) + refresh tokens (long-lived, 7 days)
- No email verification. Users play immediately after registration.
- No forgot-password flow.
- Passwords hashed with bcrypt (minimum 12 rounds).
- Admin role is just a column value. No admin UI or routes.
Game State Machine
The core game loop runs through the game-service:
- Player sends action (text input via WebSocket)
- Game-service validates the action is legal given current state
- If a dice roll is needed: dice-service rolls server-side, records result
- AI service called with: current state + roll result + player action
- AI response parsed and validated against game rules
- State changes applied: HP, inventory, conditions, XP
- GameEvent logged (immutable, append-only)
- If HP <= 0: death-service triggered
- Updated state pushed to client via WebSocket
Dice Service
- All dice rolls generated server-side using
crypto.randomInt()(cryptographically secure) - Every roll logged as a GameEvent with type
roll - Roll results include: die type, raw roll, modifier, total, target DC, success/failure
- Never let the AI or client determine roll outcomes
Death Flow
- Combat-service detects HP <= 0
- Character status set to
dead,died_atset to now - Death-service triggers AI death scene generation (premium model)
- GameEvent logged with type
death - CharacterGraveyard entry created (denormalized snapshot)
- WebSocket pushes death event + death narrative to client
- Client shows the death screen (frontend's job)
Revive Flow
- Client sends revive request (Fate Token or Stripe direct)
- If Fate Token: check user.fate_tokens >= 1, decrement, proceed
- If Stripe: create Checkout Session, wait for webhook confirmation
- If character.death_scars >= 3: REJECT. Permanent death. No more revives.
- On success: character.status = 'alive', hp_current = hp_max / 2, death_scars += 1
- ReviveTransaction created with status
completed - GameEvent logged with type
revive - WebSocket pushes revive confirmation to client
Stripe Integration
- Use Stripe Checkout Sessions for one-time purchases (not Payment Intents)
- Products: Soul Revive ($2.99), Fate Token 3-pack ($4.99), Fate Token 10-pack ($12.99)
- Webhook endpoint at POST /payments/webhook to handle checkout.session.completed
- Store Stripe customer ID on User record after first purchase
- All amounts in cents. total_spent_cents tracks lifetime spend.
- Test/sandbox mode is sufficient for MVP.
Rate Limiting
- Redis-backed rate limiting on all endpoints
- Game actions: max 30/minute per user (prevents spam-clicking)
- Auth endpoints: max 10/minute per IP (prevents brute force)
- Payment endpoints: max 5/minute per user
Docker Compose
The root docker-compose.yml provides:
- PostgreSQL 16 with pgvector extension (port 5432)
- Redis 7 (port 6379)
- Persistent volumes for both
Code Style
- Strict TypeScript. No
any. Noascasts except where genuinely necessary. - All Fastify routes use schema validation (JSON Schema or Zod with fastify-type-provider-zod).
- Async/await everywhere. No callbacks.
- Structured logging (use Fastify's built-in logger, pino).
- Environment variables via a typed config module. Never read process.env directly in business logic.
- Error handling: Fastify error handler returns consistent JSON error responses.
- The
packages/server/src/ai/directory is owned by the ai-game-master-engineer. Read from it but do NOT modify files in it. If you need changes to the AI layer, report back to the orchestrator.
Boundary Rules
- You own: Everything in
packages/server/EXCEPTsrc/ai/(read-only for you) - You own: Root
docker-compose.yml - You own: Root
turbo.jsonserver-related config - You do NOT own:
packages/web/,packages/shared/(coordinate via types),game-system/ - Shared types between server and web go in
packages/shared/
Weekly Installs
1
Repository
whoslucid/dndFirst Seen
Feb 6, 2026
Installed on
kilo1
crush1
amp1
opencode1
kimi-cli1
kiro-cli1