A full-stack character creation and management system for Vampire: The Masquerade (V20) chronicles. Built with Next.js 16, React 19, PostgreSQL 16, and a pure TypeScript rules engine.
VTM Elysium provides:
- Players — Create, edit, and submit characters through a guided multi-phase wizard; spend XP once approved.
- Storytellers — Manage chronicles, approve/reject/archive characters, grant XP, manage their player roster, and revert character state to any historical snapshot.
- Rules Engine — A fully deterministic, seeded character generator built on VTM V20 rules (attributes, abilities, disciplines, backgrounds, virtues, merits & flaws, freebie points, and XP costing strategies).
- Interactive API Docs — Available at
/api-docs(Swagger UI, dark theme, try-it-out enabled).
- Deferred Auto-save: Character records are now created in the database only upon explicit user save, preventing unintended draft fragments (Bug #15).
- Smart Chronicle Pre-fill: New characters automatically inherit the current game name in the Chronicle field, which is set to read-only for consistency (Bug #14).
- Specialty Drawer Refinement: Enhanced the specialty selection with a high-contrast dark theme, autocomplete-only selection (with custom entry support), and specialized logic for Revenants and Animal Ghouls.
- Optimized Shell Layout: Increased sidebar width to 280px for better readability of character names (Bug #10).
- Intelligent Tooltips: Character names in the toolbar now show the full name on hover if truncated (Bug #12).
- Visual Polish: Fixed indentation for Ghoul/Revenant items and corrected selection border alignment in the roster (Bugs #11, #13).
- Unified Dashboard — Merge
/playerand/storytellerinto a single, modular dashboard to reduce code complexity. - Meta-Rules Registry — Add edition/splat filters to game creation to support different rule sets (V20, 3rd Ed).
- Admin Module — Implement a management interface for game data (Clans, Disciplines, Backgrounds, etc).
- Log Configuration — Add a module for system-wide debugging of screens, drawers, and API calls.
- UI Polish — Refine the layout of large buttons on the character creation sheet.
- Context-Aware Rules — Implement the engine for edition-based overrides (Attribute costs, Starting points).
- Data Migration — Move Disciplines and Specialties to dedicated database tables with full descriptions.
- Equipment & Combat — Add Equipment management and an Attack tab to character sheets.
- Creation Engine — Generalize the creation wizard to support modular steps per Splat/Edition.
- Werewolf & Mage — Rage, Gnosis, Forms, Gifts; Arete, Spheres, Paradox.
- Wraith & Changeling — Arcanoi, Passions, Fetters; Arts, Realms, Glamour.
- Hunter & Demon — Edges, Virtues; Lores, Faith, Torment.
- Human Splat — Basic mortal characters.
- Master Data — Detailed descriptions including Rolls and Effects for all supernatural powers.
| Requirement | Version |
|---|---|
| Node.js | ≥ 24.0.0 |
| PostgreSQL | ≥ 16 |
| Docker & Docker Compose | optional |
# 1. Install dependencies
npm install
# 2. Create environment file
cp .env.example .env.local # then edit values
# 3. Run database migrations
psql -U vtm_app -d vtm_chargen -f migrations/init.sql
# 4. Start dev server
npm run dev# Start everything (app + postgres)
docker-compose up --build
# Or detached
docker-compose up -d --buildServices:
| Service | URL |
|---|---|
| Next.js App | http://localhost:3000 |
| PostgreSQL | localhost:5432 |
| API Docs | http://localhost:3000/api-docs |
Entry point for all users. Authenticates via email + password and stores the JWT token in localStorage. Redirects to /player after login.
URL: http://localhost:3000/player
Who: Authenticated players (role = PLAYER)
The main interface for players. Requires authentication — unauthenticated users are redirected to /login.
Layout: three-panel (left sidebar / main area / right info panel)
| Panel | What it shows |
|---|---|
| Top bar | Active character name · Chronicle selector · Profile menu · Logout |
| Left sidebar | Chronicle dropdown + character list with status indicators |
| Main area | Character sheet (read-only) or character creation/edit wizard |
| Right panel | Audit trail or character info (clan weakness, recent changes) |
What players can do:
- Select a chronicle from the top bar dropdown — the character list and sheet update automatically.
- View character sheet — full read-only VTM sheet (Vampire, Human Ghoul, or Animal Ghoul), switching automatically to the correct sheet type.
- Create a new character — click "+ New Character" → wizard opens inline (Phase 1: Starting Points, Phase 2: Freebie Points). Includes type selection (vampire or ghoul).
- Create a ghoul tied to one of their own vampire characters — picks ghoul type (human / animal), inherits domitor clan and generation cap.
- Edit a character in DRAFT or REJECTED status — click the Edit button next to the character in the sidebar.
- Spend XP on APPROVED or XP-status characters — click the "XP" button to open the XP spending drawer. Spends are queued as PENDING and sent to the storyteller for approval.
- Submit for review — click "✓" to submit a draft character to the storyteller.
- View audit trail — filtered log of all changes (Starting Points, Freebie, XP, Specialty, Merit/Flaw) with pagination.
- Edit profile — update name, email, or password from the top bar profile menu.
URL: http://localhost:3000/storyteller
Who: Users with STORYTELLER role in at least one game
The main interface for storytellers (Narrators / Game Masters). Requires authentication and at least one game where the user is the STORYTELLER.
Layout: three-panel (left sidebar / main area / right info panel)
| Panel | What it shows |
|---|---|
| Top bar | Selected chronicle name · Create Chronicle button · Profile menu |
| Left sidebar | Chronicle selector + full character roster (all players' characters) |
| Main area | Character sheet + action buttons (Approve / Reject / Grant XP / Archive) |
| Right panel | Audit trail + pending XP spend requests |
What storytellers can do:
- Create a new chronicle — click "New Chronicle" in the sidebar header → enter name and optional description.
- Create a player account — click "Add Player" → enter name, email, and optional password (if omitted, a one-time generated password is shown and must be saved).
- View all characters in the selected chronicle — full roster regardless of status. Status badge shown on each entry.
- Approve a submitted character — one-click approve button visible on SUBMITTED characters. Status transitions to APPROVED.
- Reject a submitted character — opens a rejection dialog to enter a reason. Status transitions to REJECTED; player can edit and resubmit.
- Grant XP to a single character or bulk-grant to all characters — via the Grant XP modal (amount + optional session note).
- Approve pending XP spends — when a player submits XP spend drafts, the right panel shows the pending items; one click applies them all to the sheet.
- Reject (cancel) XP spends — clears the player's pending spend queue without applying it.
- Archive a character — hides it from active lists (reversible).
- Create a ghoul linked to one of the roster's vampire characters — same workflow as player-side but full storyteller control.
- View audit trail — same paginated, filterable log with access to all characters in the game.
- Edit profile — same profile editor as player view.
URL: http://localhost:3000/api-docs
Who: Developers / anyone
Swagger UI rendering the full OpenAPI 3.0 spec (/api/swagger). Dark VTM theme with blood-red accents, colour-coded HTTP method badges, and Try It Out enabled for live calls against the running server.
Use the Authorize button (top right of the Swagger UI) to enter a Bearer token and test protected endpoints directly in the browser.
Create .env.local (or set via Docker env):
# Required – PostgreSQL connection string
DATABASE_URL=postgres://vtm_app:password@localhost:5432/vtm_chargen
# Required – JWT signing secret (256-bit hex recommended)
JWT_SECRET=your-256-bit-secret-key
# Optional – JWT expiry (default: 365d)
JWT_EXPIRES_IN=365d
# Optional – Public base URL (used in Swagger server list)
NEXT_PUBLIC_APP_URL=http://localhost:3000vtm-elysium-next/
├── app/ # Next.js App Router
│ ├── api/ # REST API routes (35 endpoints)
│ ├── api-docs/ # Interactive Swagger UI (dark VTM theme)
│ ├── login/ # Login page
│ ├── player/ # Player dashboard (inline creation wizard)
│ ├── storyteller/ # Storyteller dashboard (inline creation wizard)
│ └── character/ # Character sheet view
│
├── components/ # React UI components
│ ├── app-shell/ # Layout, navigation, LeftToolbar
│ ├── character-creation/ # Inline creation wizard components
│ │ ├── CreationWizard.tsx # Main wizard orchestrator
│ │ ├── CreateVampire.tsx
│ │ ├── CreateGhoul.tsx
│ │ ├── CreateRevenant.tsx
│ │ └── CreateAnimal.tsx
│ ├── character-sheet/ # Per-type sheet components
│ ├── common/ # Reusable generic components (Dots, Squares)
│ ├── modals/ # GrantXpModal and other dialogs
│ ├── xp-drawer/ # XP spending drawer UI
│ └── power-selection-drawer/ # Discipline/power picker
│
├── src/
│ ├── core/ # Rules engine (pure TS, no DB dependency)
│ │ ├── data/ # Game data access layer
│ │ │ └── raw/ # Static JSON data:
│ │ │ ├── abilities.json, attributes.json
│ │ │ ├── backgrounds.json, clans.json
│ │ │ ├── concepts.json, disciplines.json
│ │ │ ├── disciplines-enhanced.json
│ │ │ ├── merits.json, flaws.json
│ │ │ ├── natures.json, virtues.json
│ │ │ ├── generations.json, tags.json
│ │ │ ├── bestiary/ # Beast templates
│ │ │ ├── disciplines/# Per-discipline data
│ │ │ ├── revenants/ # Revenant family data
│ │ │ └── Names/ # Name generation lists
│ │ ├── services/ # Character generation services:
│ │ │ ├── CharacterGeneratorService.ts # Entry point
│ │ │ ├── AttributeService.ts
│ │ │ ├── AbilityDistributionService.ts
│ │ │ ├── DisciplineDistributionService.ts
│ │ │ ├── BackgroundDistributionService.ts
│ │ │ ├── VirtueDistributionService.ts
│ │ │ ├── FreebieSpendingService.ts
│ │ │ ├── CoreStatsService.ts
│ │ │ ├── LifeCycleService.ts
│ │ │ ├── NameGeneratorService.ts
│ │ │ ├── PersonaService.ts
│ │ │ ├── TraitManagerService.ts
│ │ │ └── XpSpendingService.ts
│ │ ├── strategies/ # Starting point & cost strategies:
│ │ │ ├── NeophiteStartingPointStrategy.ts
│ │ │ ├── AncillaeStartingPointStrategy.ts
│ │ │ ├── ElderStartingPointStrategy.ts
│ │ │ ├── GhoulStartingPointStrategy.ts
│ │ │ ├── RevenantStartingPointStrategy.ts
│ │ │ ├── HumanStartingPointStrategy.ts
│ │ │ ├── FreebiePointCostStrategy.ts
│ │ │ ├── XpPointCostStrategy.ts
│ │ │ └── StartingPointStrategyResolver.ts
│ │ ├── xpStrategies/ # Per-trait XP spending strategies:
│ │ │ ├── XpAttributeStrategy.ts
│ │ │ ├── XpAbilityStrategy.ts
│ │ │ ├── XpDisciplineStrategy.ts
│ │ │ ├── XpVirtueStrategy.ts
│ │ │ ├── XpHumanityStrategy.ts
│ │ │ └── XpWillpowerStrategy.ts
│ │ ├── affinity/ # Affinity processor (clan trait preferences)
│ │ ├── enums/ # TraitType, etc.
│ │ └── utils/ # RNG (mulberry32, seedFromString)
│ ├── i18n/ # Internationalisation helpers
│ └── lib/ # Shared XP ledger, other lib code
│
├── lib/ # Next.js server utilities
│ ├── db.ts # PostgreSQL connection pool (pg)
│ ├── auth.ts # JWT auth (jose) – requireAuth, requireUser
│ ├── roles.ts # requireRoleInGame helper
│ ├── sheet.ts # buildZeroSheet – blank sheet factory
│ └── xp/
│ ├── xpLedger.ts # insertPendingXpSpendLog, getXpTotalsForCharacter
│ └── xpSpendService.ts # spendXpImmediate, getCurrentLevel
│
├── types/ # Shared TypeScript types
│ └── app.ts # CharacterListItem, etc.
│
├── migrations/
│ └── init.sql # Full database schema (single-file)
│
├── __tests__/api/ # Jest unit tests (23 test files)
├── tests/ # Smoke / integration tests
├── contracts/ # API contract definitions
├── docs/
│ ├── api-reference.md # API reference
│ ├── database-schema.md # Database schema docs
│ └── development.md # Development guide
│
├── swagger.config.ts # OpenAPI 3.0 spec (all 35 routes)
├── docker-compose.yml
├── Dockerfile
├── next.config.mjs
└── tsconfig.json
| Type | Description |
|---|---|
| Vampire | Full character with attributes, abilities, disciplines, virtues, humanity/road, willpower, backgrounds |
| Human Ghoul | Human servant; inherits domitor disciplines up to their generation cap |
| Revenant Ghoul | Ghoul from a revenant family; starts with family disciplines (max level 1) |
| Animal Ghoul | Ghoul bonded to an animal template; locked trait caps and limited growth |
DRAFT_PHASE1 ──► DRAFT_PHASE2 ──► SUBMITTED ──► APPROVED ──► XP
▲ ▲ │ │
└─────────────────┴── REJECTED ◄┘ │
ARCHIVED
| ID | Status | Description |
|---|---|---|
| 1 | DRAFT_PHASE1 |
Starting points phase — base attribute/ability allocation |
| 2 | DRAFT_PHASE2 |
Freebie points phase — secondary trait spending |
| 3 | SUBMITTED |
Awaiting storyteller review |
| 4 | APPROVED |
Approved and active |
| 5 | REJECTED |
Returned by storyteller; player can edit and resubmit |
| 6 | ARCHIVED |
Retired character; hidden from active lists |
| 7 | XP |
Active character in XP spending mode |
The src/core/ package implements VTM V20 rules with no database dependency:
- Deterministic generation via seeded
mulberry32RNG — pass the same seed to get the same character. - Affinity system — clan affinities influence trait distribution probabilities.
- Starting point strategies — separate point budgets per vampire age category (Neophyte, Ancillae, Elder, etc.) and ghoul types.
- Freebie spending — weighted priority-based allocation across all trait categories.
- XP cost strategies — VTM-compliant costs (in-clan vs. out-of-clan, current level multipliers) per trait type.
- Character creation wizard (Phase 1 → Phase 2)
- Live character sheet with type-specific UI (Vampire / Human Ghoul / Animal Ghoul)
- Submit for storyteller approval
- Spend XP on attributes, abilities, disciplines, backgrounds, virtues, willpower, and road
- Draft XP spend requests (pending ST approval workflow)
- View XP history (grants + spends unified timeline)
- Full character version history
- Manage multiple chronicles
- Approve / reject submitted characters (with optional rejection reason)
- Archive / unarchive characters
- Grant XP individually (per-session, with notes)
- Approve pending XP spend drafts submitted by players
- Move a character to a different chronicle
- Revert a character to any historical snapshot (by version or history ID)
- Permanently delete characters (soft delete, cascades to all related records)
- View player roster per game
- Add player accounts directly (auto-generated password returned if not supplied)
The full interactive API reference is available at /api-docs (Swagger UI with dark VTM theme, try-it-out enabled).
The OpenAPI 3.0 spec is served at GET /api/swagger (JSON).
All endpoints except /api/login and /api/register require:
Authorization: Bearer <JWT token>| Method | Endpoint | Description |
|---|---|---|
POST |
/api/login |
Authenticate; returns JWT token |
POST |
/api/register |
Create account; returns JWT token |
GET |
/api/me |
Current user profile + storyteller flag |
PATCH |
/api/profile |
Update name, email, or password |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/games |
List all chronicles |
POST |
/api/games |
Create a new chronicle |
POST |
/api/games/{gameId}/characters |
Create blank character in a game |
GET |
/api/games/{gameId}/characters/me |
My characters in a game |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/characters/{id} |
Get character by ID |
PUT |
/api/characters/{id} |
Replace character sheet |
DELETE |
/api/characters/{id} |
Soft-delete character (draft only) |
PATCH |
/api/characters/{id}/sheet-merge |
Partial sheet patch (JSON merge) |
POST |
/api/characters/{id}/submit |
Submit for storyteller review |
GET |
/api/characters/{id}/history |
Paginated version history |
GET |
/api/characters/{id}/audit |
Paginated audit trail |
POST |
/api/characters/{id}/audit |
Add manual audit entry |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/characters/{id}/xp |
XP totals (granted / spent / remaining) |
POST |
/api/characters/{id}/xp/grant |
Grant XP (requires ST/Admin role) |
POST |
/api/characters/{id}/xp/spend |
Immediate XP spend & apply |
GET |
/api/characters/{id}/xp/spend-draft |
Get pending XP spend requests |
POST |
/api/characters/{id}/xp/spend-draft |
Create pending XP spend request |
DELETE |
/api/characters/{id}/xp/spend-draft |
Cancel pending XP spend |
POST |
/api/characters/{id}/xp/approve |
Player approves own pending spend |
GET |
/api/characters/{id}/xp/history |
XP history (grants + spends) |
POST |
/api/characters/{id}/xp/start |
Transition APPROVED → XP status |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/storyteller/characters |
List all characters in a game |
PATCH |
/api/storyteller/characters/{id}/approve |
Approve a SUBMITTED character |
PATCH |
/api/storyteller/characters/{id}/reject |
Reject a SUBMITTED character |
POST |
/api/storyteller/characters/{id}/archive |
Archive a character |
DELETE |
/api/storyteller/characters/{id}/archive |
Unarchive a character |
DELETE |
/api/storyteller/characters/{id}/delete |
Permanently delete a character |
PUT |
/api/storyteller/characters/{id}/move |
Move character to another chronicle |
POST |
/api/storyteller/characters/{id}/revert |
Revert to a history snapshot |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/storyteller/characters/{id}/xp/grant |
Grant XP to character |
POST |
/api/storyteller/characters/{id}/xp/approve |
Approve pending XP spends |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/storyteller/games |
List ST's chronicles |
POST |
/api/storyteller/games |
Create chronicle |
GET |
/api/storyteller/games/{gameId} |
Get game details |
GET |
/api/storyteller/games/{gameId}/characters |
Characters in game (filterable by status) |
GET |
/api/storyteller/games/{gameId}/players |
Player roster |
POST |
/api/storyteller/games/{gameId}/players |
Create player account |
DELETE |
/api/storyteller/games/{gameId}/players/{playerId} |
Remove player from game |
GET |
/api/storyteller/games/{gameId}/settings |
Game settings (XP purchase rules) |
PATCH |
/api/storyteller/games/{gameId}/settings |
Update game settings |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/characters/generate |
Generate a random character (seedable) |
See docs/database-schema.md for full documentation.
| Table | Description |
|---|---|
users |
User accounts (email, password_hash, is_active) |
games |
Chronicles / campaigns |
roles |
Role definitions (PLAYER, STORYTELLER) [lookup] |
user_game_roles |
Player ↔ Game ↔ Role association (uses role_id) |
characters |
Character records with status, XP, and JSON sheet |
character_status |
Status enum lookup (1-7) |
characters_history |
Full character snapshots captured before every update |
xp_grants |
XP grant ledger (amount, date, note) |
xp_spent_status |
XP spend status lookup (APPROVED, REJECTED, PENDING) |
xp_spend_logs |
XP spend request ledger (uses status_id) |
audit_log_types |
Audit action type definitions lookup |
audit_logs |
Free-form audit trail per character |
| Trigger | Purpose |
|---|---|
characters_history_before_update |
Automatically snapshot the character state before any UPDATE on characters. |
trg_characters_set_updated_at_and_version |
Updates updated_at timestamp and increments the record version. |
trg_delete_history_on_archive |
Purges history snapshots for a character when status is set to ARCHIVED (6). |
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| UI | React 19 |
| Language | TypeScript 5.7 |
| Database | PostgreSQL 16 |
| DB Client | pg (raw SQL, no ORM) |
| Auth | JWT via jose + bcryptjs |
| Validation | Zod |
| API Docs | swagger-jsdoc + Swagger UI |
| Testing | Jest 29 + ts-jest |
| Linting | ESLint 9 + typescript-eslint |
| Formatting | Prettier 3 |
| CSS | CSS Modules + PostCSS |
| Container | Docker + Docker Compose |
| CI Hooks | Husky + lint-staged |
| Command | Description |
|---|---|
npm run dev |
Start dev server on port 3000 (all interfaces) |
npm run build |
Type-check → format-check → lint → next build |
npm run start |
Start production server |
npm run lint |
Run ESLint |
npm run lint:fix |
Auto-fix ESLint issues |
npm run format |
Format all files with Prettier |
npm run format:check |
Check formatting (no write) |
npm run fix |
Format + lint:fix in one pass |
npm run type-check |
TypeScript tsc --noEmit |
npm run check |
format:check + lint + type-check |
npm run test |
Run Jest test suite |
npm run docs |
Generate TypeDoc documentation |
npm run css:sort |
Sort CSS declarations (SMACSS order) |
The README version badge is kept in sync with package.json automatically.
Use the standard npm version commands — the badge is updated and staged into the
same commit as the version bump:
# Patch release e.g. 2.2.0 → 2.2.1
npm version patch
# Minor release e.g. 2.2.0 → 2.3.0
npm version minor
# Major release e.g. 2.2.0 → 3.0.0
npm version major
# Specific version
npm version 2.5.0How it works: the
versionlifecycle hook runsscripts/update-readme-version.cjs, which rewrites the badge inREADME.md. Thepostversionhook then stagesREADME.mdso it is included in the automatically created version commit and tag.
The test suite covers 23 API test files in __tests__/api/:
# Run all tests
npm test
# Watch mode
npm test -- --watch
# Single file
npm test -- characters.xp.spend.test.ts
# With coverage
npm test -- --coverageTest files include:
login.test.ts,me.test.tsgames.test.ts,games.gameId.characters.post.test.tscharacters.get.test.ts,characters.put.test.ts,characters.delete.test.tscharacters.submit.post.test.ts,characters.sheet-merge.patch.test.tscharacters.history.get.test.ts,characters.audit.get.test.tscharacters.xp.get.test.ts,characters.xp.spend.test.ts,characters.xp.history.test.tscharacters.generate.post.test.tsstoryteller.characters.approve.patch.test.ts,storyteller.characters.reject.patch.test.tsstoryteller.characters.move.put.test.ts,storyteller.characters.revert.post.test.tsstoryteller.characters.xp.grant.test.tsstoryteller.game.characters.get.test.ts,storyteller.games.get.test.ts
# Full check before committing
npm run check
# Pre-commit hook (husky) automatically runs:
# eslint --fix + prettier --write on staged files- Create a feature branch from
main - Make your changes
- Run
npm run check(format + lint + typecheck) - Run
npm testto ensure all tests pass - Commit with a descriptive message
- Open a pull request
Verify DATABASE_URL format:
postgres://username:password@host:port/dbname
Ensure PostgreSQL is running and the schema has been applied (migrations/init.sql).
Ensure JWT_SECRET is set and identical across all environments.
Tokens expire after JWT_EXPIRES_IN (default 365 days).
# Clear Next.js cache
rm -rf .next
npm run buildEnsure the test database is accessible and migrations have been applied. Tests mock the database layer — check __mocks__/ for mock setup.
Built with:
- Next.js · React · PostgreSQL · TypeScript · jose · Zod · Swagger UI
Private — All rights reserved.
