Among Demons is a server-authoritative demon-collection game built with Node.js/Express and MySQL. The frontend is static HTML/CSS/vanilla JavaScript served from public/app (the world map renders with Pixi.js); public marketing/catalog pages are server-rendered for SEO. All gameplay outcomes — combat, RNG, rewards, XP, Souls, movement, and collection writes — are calculated on the server. The browser only displays state and stages player choices.
The game has two connected play spaces plus permanent progression:
- Dungeon runs (
/dungeon) — roguelite runs through unlimited floors. Draft two starting demons, auto-battle server-simulated fights, recruit defeated enemies, seal run-long Demonic Pacts, and extract with XP/Souls and one saved demon — or lose everything on defeat. - World map (
/world) — a 101×101 tile overworld with roads, unpassable terrain, demon encounters, Forsaken Shrines, and Darkness Portals. Travel tile-by-tile (roads are far safer from ambushes), fight fixed encounter teams, leave a team passively hunting for Souls, and challenge other hunters standing on your tile to PvP. - Permanent progression — a demon collection with Soul-based training (
/collection), an account skill tree fed by level-up stat points (/skill-tree), daily quests and a daily reward (/camp), public hunter profiles (/hunter/:username), and leaderboards (/rankings).
| Area | Technology |
|---|---|
| Runtime | Node.js |
| Server | Express 4, compression |
| Database | MySQL via mysql2/promise |
| Config | dotenv |
| Frontend | Static HTML, vanilla JavaScript |
| World rendering | Pixi.js 8 (served from /vendor/pixi) |
| Styling | Custom CSS (split per page), Lucide icon subset |
| Tests | Node built-in test runner (node --test) |
| Asset tooling | sharp (dev dependency, WebP generation) |
Install dependencies:
npm installCreate .env in the project root:
DB_HOST=your_mysql_host
DB_PORT=3306
DB_NAME=your_database
DB_USER=your_user
DB_PASSWORD=your_password
PORT=3000Optional OAuth sign-in (Google, Discord) is enabled by adding credentials:
OAUTH_REDIRECT_ORIGIN=https://amongdemons.com
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secretThe callback URLs registered with each provider should be https://your-domain.com/api/auth/oauth/google/callback and https://your-domain.com/api/auth/oauth/discord/callback (use http://localhost:3000 for local development). Set OAUTH_REDIRECT_ORIGIN when the public callback origin differs from the request host. Provider review screens can use https://amongdemons.com/privacy and https://amongdemons.com/terms as policy URLs.
Start the server and open http://localhost:3000:
npm run dev # with nodemon restarts
npm start # plain nodeThe database schema is created automatically on first API use — public/api/lib/schema.js creates missing tables and performs additive schema checks against older local databases.
| Script | Command | Purpose |
|---|---|---|
npm start |
node server.js |
Start the Express server |
npm run dev |
nodemon server.js |
Start with automatic restarts |
npm test |
node --test test/*.test.js |
Run the unit test suite |
amongdemons.com/
|-- server.js # Express app: SEO pages, app pages, static serving, caching
|-- lib/
| `-- seo-pages.js # Server-rendered home, demon catalog, sitemap, robots.txt
|-- public/
| |-- api/ # Express API (mounted at /api)
| | |-- account/ # Profile, progression, skill-tree points, daily quests
| | |-- auth/ # Register, login, OAuth, session profile
| | |-- demons/ # Permanent collection list/show/train
| | |-- runs/ # Dungeon run lifecycle endpoints
| | |-- game/ # Public static game-data endpoints
| | |-- world.js # World map, movement, shrines, portals, hunting, PvP
| | |-- hunters.js # Public hunter profile endpoint
| | |-- leaderboard.js # Rankings endpoint
| | |-- data/ # Source game data JSON (types, demons, pacts, world map)
| | `-- lib/ # Shared backend modules (combat, rules, db, schema, ...)
| |-- app/ # Static frontend (HTML pages, css/, js/, images/)
| |-- android/ # Capacitor Android wrapper (separate git repo, ignored)
| `-- steam/ # Electron Steam wrapper (separate git repo, ignored)
|-- scripts/ # Asset/data generators (world map, icons, WebP, CSS split)
`-- test/ # node:test unit testspublic/android and public/steam are separate wrapper repositories (Capacitor and Electron respectively) and are excluded from this repo via .gitignore; each has its own README.
| Route | Description |
|---|---|
/ |
Server-rendered public landing page |
/demons, /demons/:slug |
Server-rendered public demon catalog and per-demon guides |
/camp |
Authenticated hub: progression, current run briefing, daily quests, quick actions |
/world |
World map exploration (Pixi.js) |
/dungeon |
Dungeon run UI |
/collection |
Collection browser with filters, sorting, missing slots, and Soul training |
/skill-tree |
Account stat-point skill tree |
/settings |
Account settings (username, profile demon) |
/hunter/:username |
Public hunter profile |
/login, /register |
Auth pages |
/rankings, /rankings/:sort |
Leaderboards; sorts: floor, level, souls, pvp (/rank and /hunter redirect here) |
/privacy, /terms |
Policy pages |
/robots.txt, /sitemap.xml |
Generated SEO endpoints |
server.js also enforces the canonical host/HTTPS with 301 redirects and sets X-Robots-Tag: noindex on API routes and authenticated app pages.
Most gameplay endpoints require an authenticated player. Send either header:
Authorization: Bearer <token>
x-player-token: <token>POST /api/auth/register creates an account; POST /api/auth/login logs in (and, for prototype convenience, creates the account when the username does not exist). Passwords use PBKDF2-SHA512 with per-user salts; session tokens live in player_sessions.
Google and Discord sign-in flow through /api/auth/oauth/:provider. Provider identities are stored in player_oauth_accounts; a verified provider email matching an existing password account links to it, otherwise a new player is created with a generated username.
The frontend stores the token and cleaned player object in localStorage under the key amongdemons-session.
All routes are mounted under /api.
| Method | Route | Description |
|---|---|---|
POST |
/auth/register |
Create a player account and session |
POST |
/auth/login |
Log in, or create a prototype account if missing |
GET |
/auth/oauth/providers |
OAuth provider availability for the login/register UI |
GET |
/auth/oauth/:provider |
Start Google or Discord OAuth sign-in |
GET/POST |
/auth/oauth/:provider/callback |
Complete OAuth sign-in and create a session |
GET |
/auth/me |
Return the authenticated player |
| Method | Route | Description |
|---|---|---|
PATCH |
/account/profile |
Update username and/or profile demon |
GET |
/account/progression |
Level, XP, Souls, and unlocks |
GET |
/account/stat-points |
Skill-tree summary: points earned, allocations, node states |
POST |
/account/stat-points |
Allocate a stat point to a skill-tree node |
POST |
/account/stat-points/reset |
Refund all allocations for Souls (10 per spent point) |
GET |
/account/quests |
Daily quest and daily reward state |
POST |
/account/quests/:questId/claim |
Claim a completed daily quest |
POST |
/account/daily-reward/claim |
Claim the daily Souls reward |
| Method | Route | Description |
|---|---|---|
GET |
/demons |
List owned permanent demons |
GET |
/demons/:id |
Return one owned permanent demon |
POST |
/demons/:id/train |
Spend Souls to train one demon server-side |
| Method | Route | Description |
|---|---|---|
GET |
/runs/start-options |
Six draft starters, a signed short-lived draft token, and the collection |
POST |
/runs/start |
Start a run from two draft or collection demons |
GET |
/runs/current |
The player's active or defeated-pending run |
GET |
/runs/:id |
One run owned by the player |
POST |
/runs/:id/formation |
Update front/back positions before battle |
POST |
/runs/:id/battle |
Simulate the next battle server-side |
POST |
/runs/:id/buff |
Choose one pending Demonic Pact |
POST |
/runs/:id/buff/reroll |
Recast the pact choices for 10 Souls |
POST |
/runs/:id/reward |
Mark a reward as claimed |
POST |
/runs/:id/recruit |
Stage or commit recruitment and advance to the next floor |
POST |
/runs/:id/cashout |
Extract: save one eligible demon and claim earned XP/Souls |
POST |
/runs/:id/end |
Finalize a defeated run with zero payout |
| Method | Route | Description |
|---|---|---|
GET |
/world/map |
Static map layout (immutable-cached, keyed by content hash) |
GET |
/world/state |
Per-player world state: position, team, shrine, hunt, players nearby |
POST |
/world/move |
Move along a validated path; returns travel events and ambushes |
GET/POST |
/world/team |
Read or save the active world team |
GET |
/world/shrine |
Current bound Forsaken Shrine and whether standing on one |
POST |
/world/shrine/bind |
Bind to the Forsaken Shrine at the current position |
POST |
/world/portal/summon |
Spend Souls to teleport to a Darkness Portal (cost scales with distance) |
POST |
/world/hunt/try |
Fight the encounter on the current tile |
POST |
/world/hunting/start |
Start passive hunting on a defeated encounter |
POST |
/world/hunting/stop |
Stop hunting and bank the accumulated Souls |
GET |
/world/hunting/status |
Current passive hunt progress |
POST |
/world/ambush-defeat |
Return to the Anchored Shrine (or spawn) after an ambush loss |
GET |
/world/players-at |
Other hunters standing on a tile |
POST |
/world/challenge |
Challenge another hunter on your tile to PvP (30 s cooldown) |
| Method | Route | Description |
|---|---|---|
GET |
/game/demon-types |
Demon type, role, stat, targeting, and ability data |
GET |
/game/demons |
Demon asset mappings |
GET |
/hunters/:username |
Public hunter profile (level, floor, PvP record, position) |
GET |
/leaderboard?sort=floor|level|xp|souls|pvp |
Up to 100 ranked players |
- Runs start with exactly 2 demons chosen from six HMAC-signed draft starters (token expires after 15 minutes) and/or the permanent collection. Starting a new run closes any open run.
- The team limit is
min(6, floor + 1): 2 demons on floor 1, capped at 6 from floor 5 onward. Enemy teams keep scaling deeper. - After clearing floor 10, the player may call in one collection demon as a one-time reinforcement.
- After every win, defeated enemies become recruit options; between fights the player can recruit, swap, skip, or extract.
- From floor 4 onward, enemy generation applies spawn pressure biasing deeper floors toward higher type IDs and rarities. A permanent enemy scaling layer ("Terror") ramps enemy stats starting at floor 18.
- Extraction grants accumulated XP/Souls and saves one eligible demon. Losing grants 0 XP, 0 Souls, 0 demons — regardless of staged rewards.
- Account levels use total-XP thresholds of
250 * (level - 1)^1.65; payouts never reduce a stored level.
Run-long modifiers defined in public/api/data/combat-buffs.json and managed by public/api/lib/combat-buffs.js:
- Every third cleared floor offers three rarity-weighted pact choices; a pending offer blocks battle, recruitment, and extraction until resolved.
- Recasting an offer costs 10 Souls and excludes the current choices (
409if no alternates exist). - The same pact can appear in later offers and duplicates stack through the same effect pipeline.
- Effects cover run stats, direct/AOE damage, retaliation, poison, healing, shields, death triggers, and temporary team size. Original run base stats are preserved so recruited/saved demons never keep temporary scaling.
The world is a generated 101×101 grid (coordinates −50..50) defined in public/api/data/map.json: roads, unpassable blocks, fixed demon-team encounters, and event objects (shrines, portals). The layout is identical for every player, so /world/map is served immutable-cached and keyed by a content hash; /world/state carries only per-player data.
- Movement is validated server-side step by step (up to 256 steps per request). Off-road steps on empty tiles risk ambushes (~1 in 7); roads are much safer (~1 in 34).
- World Terror rises one level per map ring beyond the safe center (starting ~10 tiles out, capped at level 40), scaling encounter difficulty with distance.
- Forsaken Shrines — bind your soul at a shrine to respawn there after ambush defeats instead of at world spawn.
- Darkness Portals — paid teleports; the Soul cost scales with distance.
- Passive hunting — after defeating a tile's encounter you may leave your world team hunting there. Souls accumulate while away but are capped by your Soul Vessel (base 50, expanded through skill-tree nodes), so AFK income is bounded by investment, not time.
- PvP — hunters on the same tile can challenge each other (server-simulated battle, 30-second cooldown); wins and losses feed the
pvpleaderboard and hunter profiles.
Players earn one stat point per account level (level − 1 total). Nodes are defined in public/api/lib/account-stat-points.js:
- Branches for Health, Healing, Thorns, Speed, Attack, AOE, Poison, and Soul Vessel: a flat node (cap 5) unlocks a percent node (cap 5), which unlocks an uncapped "Endless" mastery node.
- Allocations translate into pre-battle combat buffs applied server-side (
player-combat-buffs.js). - A full reset refunds all points for 10 Souls per spent point.
Defined in public/api/lib/daily-quests.js with a UTC daily reset: win 3 dungeon fights, extract a demon, and "Trial of the Few" (win at floor 3+ with an open team slot), plus a claimable daily Souls reward. Rewards scale with account level.
- The permanent collection has one slot per demon type and rarity — 11 types × 6 rarities = 66 slots. Saving a duplicate type/rarity replaces that slot.
- Training (
POST /api/demons/:id/train) is transactional and server-authoritative: it locks the player and demon rows, checks cost, spends Souls, and raises one stat by +1, picked with weighted randomness from stats below their caps. - Stat caps come from the matching type's
baseStatsmaxima indemon-types.json. Cost starts at 2 Souls and grows with overall progress toward the caps, multiplied by rarity.
Combat is automatic and simulated in public/api/lib/combat.js (all battle types — dungeon, world encounter, ambush, PvP — share the same simulator):
- Living demons gain
attackMeter += speedper tick and act when the meter reaches 100; battles end when one side is wiped or after a 1000-tick safety limit. - Front-row targeting prefers living front-row enemies, falling back to any living enemy.
- Ability kinds include
basic_attack,heavy_attack,ranged_execute,fast_execute,aoe_attack,slow_crushing_attack(with a rare knockback that shoves the target one row back or swaps slots), stackingpoison,heal(most-missing-HP ally),retaliate(thorns), andchaotic_attack. - Team state is cloned for simulation and persisted from the result; the API returns a combat log plus before/after snapshots for UI replay.
Files in public/api/data are source game data — API code reads them at runtime and must not mutate them.
| File | Contents |
|---|---|
demon-types.json |
11 demon types: roles, base stat ranges, targeting, abilities, spawn weights |
demons.json |
66 demon asset/species mappings |
combat-buffs.json |
20 Demonic Pact definitions |
map.json |
Generated world map: bounds, spawn, roads, blocks, encounters, events |
Demon art lives in public/app/images/demons (full PNGs for battle cards, generated WebP variants for map tokens, thumbnails for lists). Rarity tiers are common, uncommon, rare, epic, legendary, mythic.
Tables are created on first API use by public/api/lib/schema.js:
| Table | Purpose |
|---|---|
players |
Credentials, level, XP, Souls, PvP record, profile demon, unlocks |
player_sessions |
Bearer/session tokens with expiration |
player_oauth_accounts |
Linked Google/Discord identities |
oauth_states |
Short-lived OAuth CSRF state |
player_stat_points |
Skill-tree allocations |
player_demons |
Permanent owned demon collection |
player_world_positions |
Server-side world coordinates |
player_bound_world_shrines |
Anchored Forsaken Shrine return points |
player_hunt_unlocks |
Encounters defeated at least once (hunting eligibility) |
player_active_hunts |
Active passive-hunt snapshots |
player_world_teams |
Saved world battle teams |
player_daily_quests |
Per-day quest progress and claims |
runs |
Dungeon state, rewards, combat history, status (state stored as JSON text) |
| File | Purpose |
|---|---|
public/app/js/session.js |
Session storage and authenticated API helper |
public/app/js/api-config.js |
API base configuration |
public/app/js/auth-ui.js |
Login and register forms (password + OAuth) |
public/app/js/camp-ui.js |
Camp hub: progression, run briefing, daily quests, quick actions |
public/app/js/world-ui.js |
Pixi.js world map: movement, shrines, portals, hunting, PvP |
public/app/js/dungeon.js + public/app/js/dungeon/ |
Dungeon UI modules: battle replay, drag/drop, recruitment, pacts, rewards |
public/app/js/collection-ui.js |
Collection filters, sorting, missing slots, training modal |
public/app/js/skill-tree-ui.js |
Skill-tree allocation UI |
public/app/js/settings-ui.js |
Username and profile demon settings |
public/app/js/hunter-ui.js |
Public hunter profile page |
public/app/js/rankings-ui.js |
Leaderboard UI |
public/app/js/demon-cards.js |
Shared demon card rendering |
public/app/js/navigation.js |
Shared navigation |
public/app/js/lucide-subset.js |
Generated icon subset (see scripts) |
CSS is split per page — base.css loads everywhere; battle.css, camp.css, collection.css, skill-tree.css, and world.css load only where needed.
| File | Purpose |
|---|---|
public/api/lib/auth.js |
Password hashing, tokens, auth middleware |
public/api/lib/oauth.js |
Google/Discord OAuth flows |
public/api/lib/usernames.js |
Username validation/normalization and OAuth-safe candidates |
public/api/lib/progression.js |
Account level/XP curve |
public/api/lib/account-stat-points.js |
Skill-tree nodes, caps, Soul Vessel capacity, resets |
public/api/lib/daily-quests.js |
Daily quest definitions, progress, rewards |
public/api/lib/combat.js |
Server-side combat simulator |
public/api/lib/combat-buffs.js |
Demonic Pact loading, stacking, rerolls, effect pipeline |
public/api/lib/player-combat-buffs.js |
Skill-tree allocations → pre-battle combat buffs |
public/api/lib/demon-factory.js |
Demon generation, rarity selection, stat rolls |
public/api/lib/demon-training.js |
Training caps, costs, stat rolls |
public/api/lib/dungeon-enemies.js |
Enemy pools, floor sizing, spawn pressure, scaling |
public/api/lib/dungeon-rules.js |
Team-size and reinforcement constants |
public/api/lib/world-combat.js |
World encounters, terror, hunts, ambushes, PvP simulation |
public/api/lib/world-shrines.js |
Forsaken Shrine lookup, binding, ambush returns |
public/api/lib/collection-demons.js |
Collection save helpers and stat normalization |
public/api/lib/run-demons.js / run-rewards.js / run-serialization.js / runs.js |
Run normalization, payouts, response shaping, persistence |
public/api/lib/game-data.js |
JSON game-data readers |
public/api/lib/rng.js |
Deterministic seeded RNG |
public/api/lib/db.js |
MySQL pool and .env loading |
public/api/lib/schema.js |
Table creation and additive schema checks |
public/api/lib/async-errors.js |
Express async error forwarding |
lib/seo-pages.js |
Server-rendered home/catalog pages, sitemap, robots.txt, canonical host |
| Script | Purpose |
|---|---|
scripts/generate-world-map.js |
Regenerates public/api/data/map.json (roads, blocks, encounters, events) |
scripts/generate-lucide-subset.js |
Rebuilds lucide-subset.js from icons actually referenced in HTML/JS |
scripts/generate-demon-map-variants.js |
Generates small WebP demon variants for map tokens and avatars |
scripts/split-main-css.js |
Re-runnable splitter that produced the per-page CSS files |
In production (NODE_ENV=production), static assets are served with one-year immutable caching; HTML always revalidates. Because of this, every JS/CSS reference in HTML must carry a ?v= stamp, and the stamp must be bumped whenever the file changes. The world map payload and demon catalog images are also immutable-cached, keyed by content. Caching is disabled outside production so local iteration never fights the cache.
Run the unit tests (combat knockback, run rewards, stat points, username validation, world ambush rules, nav UI):
npm testSyntax-check the server and all backend/frontend scripts (PowerShell):
node --check server.js
Get-ChildItem -Recurse -Filter *.js public\api | ForEach-Object { node --check $_.FullName }
Get-ChildItem -Recurse -Filter *.js public\app\js | ForEach-Object { node --check $_.FullName }Initialize or verify the database schema from the command line:
node -e "require('./public/api/lib/schema').initializeSchema().then(() => { console.log('schema ready'); process.exit(0); }).catch((error) => { console.error(error); process.exit(1); })"MIT. See LICENSE.