darkwatch/docs/HANDBOOK.md

15 KiB

Darkwatch Handbook

Last updated: 2026-04-11

A reference for everyone working on or with Darkwatch — whether you're a DM discovering the tool, a developer joining the project, or a collaborator adding features.

Table of Contents


Part 1: Product Overview

What is Darkwatch?

Darkwatch is a real-time digital session companion for Shadowdark RPG. It runs in a web browser — the DM opens a campaign and players join via invite link. Everything stays in sync automatically: HP changes, dice rolls, spell slots, atmosphere effects, and combat all update live for everyone at the table.

It's not a virtual tabletop (no maps or tokens). It's the digital layer that sits alongside your physical table: character sheets, dice, a torch timer, initiative tracking, and the right amount of atmosphere.

Who is it for?

  • Dungeon Masters running Shadowdark campaigns who want a real-time view of the whole party without paper shuffling
  • Players who want a clean digital character sheet that stays synced without everyone shouting HP updates across the table
  • Groups playing in-person, online, or hybrid — it works in any browser

How a session works

  1. The DM creates a campaign and gets an invite link
  2. Players click the link and join with their account (or create one)
  3. The DM sees all characters in a compact card grid; players see their own full character sheet
  4. Dice rolls, HP changes, spell casts, and atmosphere effects all sync in real time
  5. When combat starts, the DM opens the initiative tracker — everyone rolls, the tracker resolves who goes first, and the DM advances turns

Sessions are stateful: pausing mid-combat and resuming later works because combat state is saved to the database.

Features

Characters

Each character has the full Shadowdark stat block: STR, DEX, CON, INT, WIS, CHA, HP, AC, XP, gold, gear slots, and currency. Stats are click-to-edit inline. AC and attacks are auto-calculated from stats and talents.

Character creation wizard walks through four steps: name/class/ancestry → roll 3d6 for each stat → background/alignment/deity → review. HP, starting gold, gear slots, and character title are all derived automatically at the end.

Talents can be added with name and effect (e.g. "+2 STR", "+1 gear slot"). They apply to the relevant derived values immediately.

Avatars use DiceBear with a style picker. Each character also has a color used for their dice and name display.

Dice Rolling

Dice roll in 3D with animation. Results are predetermined on the server (not client-side) so they're trustworthy. Dice match the character's color. Results appear in a roll log that all players can see.

Supported: d4, d6, d8, d10, d12, d20, d100. Advantage and disadvantage are supported (roll two dice, take higher/lower). Long-press the roll button on mobile to trigger advantage or disadvantage.

Spellcasting

34 Tier 1 and Tier 2 spells for Wizard and Priest classes. Casting rolls a d20 against a difficulty; failure causes exhaustion (one spell slot lost). The spell list shows current/max slots and highlights exhausted spells.

Wizard mishaps: On a critical fail, a d12 mishap table is rolled automatically. The effect is applied and can be undone if the DM made an error.

Priest penance: Failed casts trigger the Shadowdark penance mechanic. Rest recovers all spell slots.

Torch Timer

A 60-minute countdown synced across all clients. The DM controls start/stop/reset. When the torch goes out, everyone knows.

Luck Tokens

Each character has a luck token toggle. Players and the DM can both see and toggle luck token state.

Atmosphere Effects

The DM can layer atmosphere effects that all players see in real time:

  • Fog — drifting radial-gradient layers with adjustable intensity; fades in smoothly when enabled
  • Fire — Three.js GLSL shader with a realistic colour ramp (deep red tips → orange → amber → warm yellow core), per-column height variation for a ragged silhouette, and ember glow at the base
  • Rain — translucent blue-white drops that increase in density with intensity; at high intensity the angle tilts slightly, simulating wind-driven rain
  • Snow — gentle white flakes with size variation for depth; drift naturally as they fall; density scales with intensity
  • Embers — small slow-drifting sparks with flickering opacity; more numerous and finer than rain

Each effect has an independent intensity slider. Atmosphere is DM-only to control; all players see the active effects.

Initiative Tracker

Shadowdark uses team-based initiative — the whole party acts together, then all enemies act together. Darkwatch implements this directly:

Rolling phase: Each player clicks "Roll Initiative" for their character. The server takes the highest individual roll as the party's result. The DM rolls once for all enemies. Either side can roll in any order.

Active phase: Whichever side rolled higher acts first (ties go to the party). The DM clicks "Next Turn" to flip sides. The round counter increments each time the turn passes back to the party.

Enemy management (DM only): Enemies are added at combat start with a name and max HP. HP is tracked inline and visible only to the DM — players see enemy names but no HP values. Enemies can be added or removed mid-combat.

Combat state persists to the database. If the server restarts mid-fight, the tracker reappears when players rejoin.

DM View

The DM sees all characters in a compact three-column card grid showing: HP (current/max), AC, luck token state, torch timer, and all stat modifiers at a glance. The full character detail is one click away.

What's coming next

Features currently planned or in progress:

  • Talent roll UI — roll 2d6 on the class talent table when leveling up; currently talents are added manually
  • Death timer — when a character is dying: 1d4 + CON rounds to live, d20 each turn to rise at 1 HP
  • Conditions / status effects — poisoned, stunned, dying, etc. shown on character cards
  • Per-enemy dice rolls — DM triggers attack/damage rolls from individual enemies in the tracker
  • Party loot / shared inventory — campaign-level shared item pool, DM-managed
  • Creature gallery / NPC system — saved enemy stat blocks, quick-add to initiative tracker
  • Initiative enhancements — turn timer per combatant, re-roll initiative mid-combat, individual initiative mode (each combatant rolls separately, as opposed to team-based; needed for non-Shadowdark systems)
  • Google / social login — OAuth alongside the existing email/password flow
  • Ambient audio — optional background sound; low priority

Longer-term ideas: multi-system support (Cairn, Knave, Cyberpunk RED), profile image uploads, metrics and analytics, and API documentation.

Known limitations

  • Shadowdark only (for now) — rules, stat blocks, and spell lists are Shadowdark-specific. Multi-system support is planned but not started.
  • No map or token support — Darkwatch is a companion tool, not a full VTT.
  • Single combat displayed — the initiative tracker shows one combat at a time. The backend supports multiple simultaneous combats (rare edge case) but the UI only shows the first.
  • No offline mode — everything requires a server connection. There's no local-only or offline fallback.
  • No profile image uploads — avatars use DiceBear. Custom image uploads are not yet supported.

Part 2: Technical Guide

Stack

Layer Technology
Client React 18 + Vite + TypeScript + CSS Modules
Server Express + Socket.IO + TypeScript (tsx runner)
Database MariaDB 11 (Docker container darkwatch-maria, port 3307)
DB client mysql2/promise
Real-time Socket.IO (rooms per campaign: campaign:{id})
Auth JWT in httpOnly cookies
3D dice @react-three/fiber + cannon-es (Ammo.js physics)
Particles tsParticles (rain/embers), Three.js (fire)
Avatars DiceBear

Running locally

Prerequisites: Node 18+, Docker

1. Start the database

docker start darkwatch-maria

If the container doesn't exist yet, it was created with:

docker run -d --name darkwatch-maria \
  -e MYSQL_DATABASE=darkwatch \
  -e MYSQL_USER=darkwatch \
  -e MYSQL_PASSWORD=darkwatch \
  -e MYSQL_ROOT_PASSWORD=root \
  -p 3307:3306 mariadb:11

2. Set up environment

Create server/.env with these variables:

DB_HOST=127.0.0.1
DB_PORT=3307
DB_NAME=darkwatch
DB_USER=darkwatch
DB_PASSWORD=darkwatch
JWT_SECRET=devsecret
CLIENT_URL=http://localhost:5173
PORT=3000
NODE_ENV=development

3. Start the server

cd server
npm install
npm run dev

The server runs migrations automatically on startup, then listens on http://localhost:3000.

4. Start the client

cd client
npm install
npm run dev -- --host

The client runs on http://localhost:5173 (or the next available port). The --host flag exposes it on the local network for device testing.

Dev seed accounts (created automatically on first server startup):

  • DM: dm@darkwatch.test / password
  • Player: player@darkwatch.test / password

Repo structure

shadowdark/
├── client/                   # React + Vite frontend
│   ├── src/
│   │   ├── components/       # Reusable UI components
│   │   ├── pages/            # Route-level pages (CampaignView, Login, etc.)
│   │   ├── context/          # React context (AuthContext)
│   │   ├── lib/              # Utility libraries
│   │   ├── types.ts          # Shared TypeScript types
│   │   └── socket.ts         # Socket.IO singleton
│   └── public/               # Static assets (dice themes, etc.)
├── site/                     # Static marketing brochure (no build step)
│   ├── index.html            # Single-page brochure
│   ├── style.css             # All brochure styles
│   ├── main.js               # Smooth scroll + hero parallax
│   ├── screenshots.js        # Playwright script — regenerates screenshots
│   └── assets/screenshots/   # Committed PNGs used by the brochure
├── server/
│   ├── src/
│   │   ├── routes/           # Express REST routes + socket handler files
│   │   ├── index.ts          # Express app setup
│   │   ├── socket.ts         # Socket.IO setup, connection handler
│   │   ├── db.ts             # MariaDB pool
│   │   ├── dice.ts           # Server-side dice rolling
│   │   ├── auth.ts           # JWT helpers
│   │   └── migrate.ts        # Migration runner
│   └── migrations/           # Numbered SQL migration files (001_, 002_, ...)
├── docs/
│   ├── ROADMAP.md            # Feature backlog and ideas
│   ├── HANDBOOK.md           # This file
│   └── superpowers/
│       ├── specs/            # Design specs (brainstorming output)
│       └── plans/            # Implementation plans
├── .claude/
│   └── skills/               # Project-local Claude skills
├── CHANGELOG.md              # Version history
└── CLAUDE.md                 # Claude Code instructions for this project

Key patterns

Socket rooms

Every campaign has a Socket.IO room named campaign:{id}. Clients join on page load:

socket.emit("join-campaign", String(campaignId));

Server handlers broadcast to the room:

io.to(`campaign:${campaignId}`).emit("event-name", data);

The initiative tracker uses a role-aware pattern: socket.to(room) sends to all except the emitting socket; socket.emit() sends back to the emitter. This lets the DM receive full data while players receive HP-stripped data in one operation.

CSS Modules and variables

All components use CSS Modules (.module.css files co-located with the component). Global CSS variables are defined in client/src/theme.css:

Key variables: --gold, --gold-rgb, --bg-modal, --bg-input, --text-primary, --text-secondary, --text-tertiary, --btn-active-text, --danger, --danger-rgb, --font-display.

Always use CSS variables for colors — never hardcode hex values in component CSS.

Database migrations

Migrations live in server/migrations/ as numbered SQL files: 001_initial_schema.sql, 002_spells.sql, 003_combat_state.sql, etc. The migration runner (server/src/migrate.ts) tracks applied migrations in a _migrations table and runs new ones on server startup.

To add a migration: create the next numbered file. It runs automatically on the next server restart.

Auth and roles

Auth uses JWT stored in httpOnly cookies. The token contains { userId }. On socket connection, the auth middleware reads the cookie and populates socket.data.user.

Campaign roles (DM or player) are stored in the campaign_members table. Role enforcement happens:

  • REST routes: middleware checks campaign_members before handling requests
  • Socket handlers: checkDM() queries campaign_members for DM-only events
  • Frontend: role === "dm" gates DM-only UI elements — role comes from AuthContext and is passed as a prop to campaign view components

All imports use .js extensions

The project uses ES modules. Even when importing .tsx or .ts files, use .js in the import path:

import InitiativeTracker from "../components/InitiativeTracker.js";
import type { CombatState } from "../types.js";

Vite and tsx resolve .js to the TypeScript source at build/dev time.

Development workflow

We use a structured approach: brainstorm → spec → plan → implement.

  1. Brainstorm (/brainstorm skill) — collaborative design session; produces a spec doc

Skills are Claude Code slash commands defined in .claude/skills/ — invoke them inside a Claude Code session.

  1. Spec saved to docs/superpowers/specs/YYYY-MM-DD-feature-design.md
  2. Plan (/writing-plans skill) — detailed implementation plan with code in every step
  3. Plan saved to docs/superpowers/plans/YYYY-MM-DD-feature.md
  4. Implement — subagent-driven development, one task at a time with spec + quality reviews

All specs and plans are committed to the repo. Check docs/superpowers/ to understand why things were built the way they were.

Project-local Claude skills live in .claude/skills/. See CLAUDE.md for the list of available skills and how to invoke them.

Technical backlog (DB migration tooling, containerization, Kubernetes, metrics infrastructure) is tracked in docs/ROADMAP.md.