8000 GitHub - irmakh/rpg-play · GitHub
[go: up one dir, main page]

Skip to content
irmakhPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

RPG Play — D&D 5e Virtual Tabletop

Node.js Express SQLite Electron JavaScript PWA Android Vitest Docker

A self-hosted web app for running D&D 5e sessions. It hosts any number of campaigns, each a fully separate world with its own DM password 8 and its 10BC8 own data. Every campaign bundles a full character sheet, a shared virtual battle map, a real-time initiative tracker, a monster library, a treasury for loot and shop items, a synced music player, handouts with blind skill checks, a comic-style story builder with image and video panels, and a mobile companion — all kept live across every connected browser with no external cloud required.

Vanilla JS, no build step, no framework. Runs on SQLite by default; nothing to provision.

The virtual table — battle map, tokens with HP bars, fog regions and the dice-mode toolbar


Play it as a desktop app

RPG Play ships with a native Windows desktop client. It is the best way to run a session as the DM: real windows instead of browser tabs, one per screen.

Electron

The desktop client — the table in a native window, with the menu bar, token panel and chat dock

The desktop client is a thin client: it renders the pages this server already serves, so the web app stays unchanged and nothing needs redeploying when the desktop app changes. What it adds over a browser:

  • Multi-monitor windows — the table, DM panel, sheet, monsters, treasury, stories and console each open as a separate native window that remembers its size, position, monitor, fullscreen state and zoom. The table and second screen open on a secondary display by default
  • Global hotkeys (Ctrl+Alt+T/D/C/R) reach a window while another application has focus
  • Tray icon, application menu, real fullscreen, per-window zoom, and native Save dialogs for backups, map exports and character XML
  • First-run setup asks for the server address and validates it; self-signed certificates are trusted per host after an explicit prompt and pinned by fingerprint

Anyone already using the web app on Windows is offered the download from the character sheet's nav menu; the link stays hidden on every other platform, and inside the desktop client itself.

Build instructions and artifacts →  ·  Full desktop docs →


Feature Overview

Campaigns (/) — the front door

The campaign picker — every campaign on the left, the selected one's cover, stats and login on the right

The server hosts any number of campaigns, and each one is a fully separate tenant: its own characters, table, initiative, monsters, treasury, calendar, weather, maps, sounds, chat and stories — and its own DM password.

  • / opens the campaign picker: a list of every campaign on the left, the selected campaign's cover, description and stats on the right
  • Selecting a campaign shows its detail and its login (Character tab / DM tab). Logging in scopes the whole app to that campaign
  • Each campaign has its own DM password, stored hashed in campaigns.db. A DM password grants nothing in any other campaign
  • The server-wide MASTER_PASSWORD env var is the super-admin key: it unlocks any campaign, is required to create or delete campaigns, and is the recovery path when a campaign's DM password is lost
  • ⚙ Campaign settings (needs that campaign's DM password): rename, edit the description, upload a cover image, change the DM password
  • Deleting a campaign needs the admin password and the campaign name typed exactly; the last remaining campaign cannot be deleted
  • Every page header carries a campaign badge; clicking it — or logging out — returns to the picker

How isolation works. Each campaign owns a directory of SQLite files:

Application/
├── campaigns.db                      # registry: names, covers, hashed DM passwords
└── data/campaigns/<campaignId>/
    ├── localdb.db                    # characters, table, treasury, monsters, calendar…
    ├── media.db                      # chat images + the table map blob
    ├── stories.db                    # the comic/story builder
    └── aiDM.db                       # only if the campaign used the retired AI DM — no longer opened, still in raw backups

Nothing filters by a campaign_id column, so there is no WHERE clause to forget: a request simply never holds a database handle that reaches another campaign. Real-time events are filtered the same way — a token move in one campaign is never delivered to another campaign's table.

Uploaded images and audio are split the same way: new uploads land in public/uploads/<campaignId>/<kind>/. Files written before the split are still in the flat public/uploads/<kind>/ and still resolve, because every stored reference is a full path — they were deliberately not migrated, since a shared file may belong to more than one campaign. Deleting a campaign removes its databases but leaves its uploads on disk: media is the part a DM cannot regenerate, and an unwanted folder is trivially removed by hand.

Character Sheet (/index.html)

The character sheet — persistent vitals bar with damage/heal, tabbed sections, auto-calculated ability scores and combat block

  • Full D&D 5e sheet: ability scores, skills, saving throws, HP, AC, speed, initiative — all auto-calculated
  • Proficiency bonus auto-derived from level; spell slot tracking for spell levels 1–9 with per-level counters and prepared-spell count
  • Actions tab — aggregates weapon attacks, action-flagged spells, and freeform custom actions into one combat panel. Custom actions carry a category (action / bonus / reaction / other), description, dice, and limited-use tracking with short/long-rest recharge
  • Weapon attacks table with custom dice rolls; weapon notes shown in the damage chat message
  • Equipment / magic items panel: equipped items feed AC, initiative, and speed auto-calc; spell-bonus items supported
  • Spell table with clickable column sort — click any header (Prep / Lvl / Name / Time / Range / Conc / Ritual / School / Dur) to sort with a ▲/▼ indicator; spells carry an action-category and duration field
  • Portrait upload plus media attachments (images, video) with a fullscreen lightbox; videos autoplay with controls
  • Inventory, features & traits, background, and notes tabs
  • Dice rolling with full 3D animation, broadcast live to the virtual table; advantage / disadvantage dual-die rolls; per-character roll history
  • Critical hits (5e rules) — a natural 20 on an attack makes the next damage roll a crit: the damage dice are rolled twice and the modifiers added once (1d8+3 → 2d8+3), marked (Crit) in chat and history
  • Import / export character as XML (D&D Beyond–compatible) — round-trips custom actions and spell action/duration fields, and prompts update-vs-new-copy on a same-name import
  • Per-character password protection (set / change / remove); multiple characters selectable from a list
  • Player-facing tabs: Treasury (one tab with a Free Loot / Shop switch — claim dropped items for free or buy with in-character currency; item images, and unidentified items shown as such), Initiative (slide-out tracker with roll submission), Calendar (Calendar of Harptos — DM events plus your own journals with media)
  • Real-time chat: free text or /r NdS+M [label] dice rolls (e.g. /r 2d6+3, /r d20 Stealth), typed damage with /dmg 1d6 piercing, 2d8 fire, and critical damage with /crit 1d8+3 slashing
  • Private messages — every chat box (sheet, table, DM panel, both console screens) has a recipient picker. Everyone is the default; pick the DM or a character and only the sender, the recipient and the DM can read it. The picker returns to Everyone after each private message
  • Display templates and UI size (every page): the default navy-and-gold look plus 20 colour templates, and a UI size from 50% to 135% that scales text and layout together - both from the palette button in the page header. The choice lasts for the browser tab (on the console screens, for the device); quick-nav buttons to the Table and Stories

The character sheet at phone width — the same vitals bar, with the tab strip scrolled horizontally

The character sheet at phone width.

Virtual Table (/table.html)

The table with the chat dock open — dice rolls posted from the sheet arrive here live

  • Shared battle map with a configurable grid overlay; upload any image as the background
  • Token system for PC / NPC / monster tokens, each with a portrait (thumbnails auto-generated on upload)
  • Open token movement — anyone can drag any token (no ownership, monster, or turn gating), with a 100 ms hold delay to avoid accidental drags and a single-level Undo button to snap the last move back
  • Movement distance is measured Euclidean (matching the ruler), reset per turn; dragging a token shows the distance as you go, in and out of combat
  • Getting around — the mouse wheel zooms where the pointer is, Space + drag (or the middle button) pans from any tool, pinch zooms on touch screens, and the map glides to the acting token when the turn changes
  • Keyboard shortcuts — a key per tool (V H R P D M), N / B for next and previous turn (DM), arrow keys and Ctrl + Z for token moves, ? for the full list
  • Token right-click menu — damage/heal, conditions, hide/show, roll initiative, remove; or select a token and just type a number (7 damages, +7 heals). Hovering a token shows a summary card
  • The table remembers your zoom, tool and open panels between visits
  • Token HP panel: damage, heal, temp HP, death saves, and live AC display; per-token HP bar overlay (green → yellow → red)
  • Move tool also selects — clicking a token selects it (a dedicated Select tool is no longer needed)
  • Monster names hidden from players — shown as an identifier (e.g. "Goblin #2"), editable by the DM from the side panel; per-monster visibility toggle in real time
  • Token quick-roll side panel — click a token for full dice rolling: saves, skills, ability checks, damage, initiative. A persistent dice-mode setting (Normal / Adv / Dis / Ask, default Ask) behind the button beside the dice roller applies to every roll; the button shows the mode in force
  • Right-panel sections use the same section headers as the character sheet (Conditions, HP, Temp HP, etc.), with the Bulk Edit panel mirroring the same controls for multi-token edits
  • Multi-select (DM) — shift+click tokens, or drag a box with the Multi tool (shift+drag adds to it), to build a selection; the token already selected before the first shift+click is kept in it. Bulk HP, conditions, show/hide, group initiative and group checks/saves act on every selected token
  • Equipment wear/unwear in the right panel — toggling items recomputes AC, initiative, speed, and spell DC and syncs to the token
  • Monster actions panel — each action has a Use button that posts the action text and a damage roll to chat; players' custom actions have the same Use button
  • Click a weapon row to roll the attack, then a Miss / Roll Damage / Crit prompt; click a spell name to post its full description to chat with a 5e.tools link
  • Critical damage — on a natural 20 the prompt turns red and leads with Crit: every damage die is rolled twice, modifiers are added once (5e rules), and typed parts all double (1d6 piercing, 2d8 fire → 2d6 piercing, 4d8 fire). A monster's nat-20 attack carries the crit to its next damage roll; the Next damage is a crit switch in the dice settings covers anything else (Champion 19–20, a paralysed target)
  • 3D dice — animated icosahedron (d20) and pentagonal-trapezohedron (d10), shown for every roll including those broadcast from the character sheet; a switch in the dice settings toggles the animation on/off
  • Group rolls (DM) — select multiple tokens and trigger a single ability check or saving throw; each token rolls by its own bonus and the results post as one combined chat message from the DM, headed with the roll count ("DEX Save — 3 rolls"). Results of tokens hidden from players go in a separate DM-only message; group initiative is DM-only, like single-token initiative
  • Hidden tokens stay hidden — any roll the DM makes for a token hidden from players (attack, damage, save, check, a monster's Use card, its 3D dice) goes to the DM only, as do hidden tokens' results in a group roll
  • Notifications — the bell (every page) keeps rolls, chat, loot, handouts and turn alerts. A burst of rolls from one person folds into one row ("Aliyr rolled 12 times") that lists each roll, newest first; a group roll is the same kind of row, one line per token ("DM rolled 3 times": "Goblin 15 (DEX Save)", …). Rolls and chat lines also pop up at the top centre of the screen, one per roll, in the same pop-up the table uses for its own messages: pop-ups stack, newest on top, and stay 30 seconds or until closed (a switch in the bell's settings turns them off)
  • Pop-out panels (View menu) — pop the initiative/chat sidebar, the right token panel, or the chat bar into a separate browser window; the real DOM node moves across windows and keeps updating over SSE. The popped-out character sheet uses a dedicated responsive "magazine" layout
  • Fog-of-war regions and hidden map items (traps, chests, doors, notes): DM reveals or re-hides per region/item; hovering a panel entry highlights its spot on the canvas
  • Drawing tools (freehand, line, arrow, rectangle, circle with colour/weight), ruler, ping marker, zoom
  • Prepared-map selector loads a preset from Map Prep
  • DM shared-media reveal — sending an image opens a draggable reveal card on every client (drag is local-only)
  • Permanent left sidebar (initiative + chat) with a collapse toggle
  • Waiting screens — the DM parks the table on a full-bleed image between scenes (⏸ in the toolbar). Players get the image over the map and the left sidebar and keep their character panel, so they can still roll; the DM keeps the map and carries on arranging it. See Waiting Screens
  • My sheet — a player can open their own character sheet in the right panel at any time, whether or not their character has a token on the map
  • DM unlock via master password; the DM can also log in as any character using the master password

DM Dashboard (/dm.html)

The DM dashboard — tools grouped by At the table / Prepare / Manage, over the initiative tracker and monster table

  • Initiative tracker with full CRUD: add PCs and NPCs, set/reorder initiatives, edit, delete
  • Start / stop combat, next / previous turn, skip; Clean Orphans removes stale entries without disrupting a running encounter
  • Monster library table: search, filter, add to initiative in one click (identifier shown in chat rolls)
  • Monster stat-block popup with correct multi-line trait/action rendering
  • Media sharing: drag-and-drop image / video → shared instantly to the table
  • DM chat: free text or /r rolls (broadcasts the 3D animation to all screens); per-message delete removes it from every client live
  • Data backup / restore: per-section JSON export (characters, monsters, treasury, maps), non-destructive merge import; treasury backups carry item images, and older shop / loot backup files still restore
  • Raw database backup: one-click download of all SQLite databases (localdb, media, stories, plus any old aiDM file) as-is, bundled into a single streamed .tar.gz for full off-site backup (uploaded media under uploads/ is stored separately and not yet included)
  • Display templates and UI size, as on every page

DM Calendar (/events.html) — DM only

The Calendar of Harptos — three tendays per month with rolled weather on the grid, and the day's events beside it

  • Full Calendar of Harptos (Forgotten Realms): 12 months × 30 days as three tendays × 10 columns
  • Festival rows between months: Midwinter, Greengrass, Midsummer, Shieldmeet (leap years), Highharvestide, The Feast of the Moon
  • Dale Reckoning year names for years 1–1600 (e.g. 1492 DR — Year of Three Ships Sailing)
  • Campaign date control: advance a day at a time or jump to any date/festival; current date highlighted in gold
  • Events: title, description, type (session / combat / travel / milestone / rest / note), public or DM-only; public events broadcast live to player calendars over SSE
  • Media attachments: attach images, video, or audio to any event (one per upload, 25 MB cap; images get auto-generated thumbnails)
  • Daily weather roller: roll temperature, wind and precipitation for any date — each gets its own d20 against configurable thresholds, with temperature swinging from a "session normal" baseline and precipitation falling as snow below freezing. Set a day manually instead if you prefer. Results are logged per day and shown as icons on the calendar grid with a hover breakdown

Player Calendar (tab on /index.html)

  • Calendar showing DM-published public events, the player's own journals, and the current campaign date (live via SSE)
  • Player journals — players author their own dated entries from the calendar (+Journal button). A journal defaults to shared (visible to everyone) or can be kept private (visible only to the DM and its author); each entry can carry media attachments. Players can edit and delete only their own journals
  • Visibility rule: a viewer sees an event if they are the DM, the event is public, or they authored it
  • Click a day to filter its events; Go to Today jumps back to the campaign date; ← / → month navigation
  • Weather icons on each day the DM has rolled, with the same hover breakdown; the virtual table also carries a toolbar widget showing the current day's weather

Handouts (/handouts.html) — DM only

A handout — one prompt, a blind skill check, and two bodies: what they read on a success and what they read instead on a failure

  • Author a handout with a title, an optional prompt shown before any check, and two bodies: one for a successful skill check, one for a failure. Each body takes its own image.
  • Hand it to selected characters (or the whole party); every recipient is tracked independently.
  • Optionally require a skill check. The check is blind: the player presses a neutral Examine button and never learns which skill was tested, what the DC was, or what they rolled. The roll happens on the server using the character's own modifier, so it cannot be forged.
  • You decide the outcome. The DM screen lists every recipient with their total and, if you set a DC, a suggested result — but nothing reaches a player until you press Success or Fail. There is an Apply all suggested shortcut, and you can overrule any roll.
  • Rolls are logged to chat as DM-only, so players never see each other's results.
  • Re-tag an outcome at any time, send a recipient back to pending to grant a re-roll, or recall the handout entirely.
  • A handout with no skill check is simply readable the moment you hand it out.
  • Players see it pop up on the table screen and keep it in a 📜 Handouts tab on their character sheet, with an unread badge. A handout pops once — reloading the page does not replay it. It surfaces again only when there is genuinely something new: you re-send it, or you change its outcome.
  • One dialog for the whole handout. The 📜 button in the table toolbar opens it: pick a handout, tick any mix of characters (with All / None) and give it to all of them at once — then watch their checks arrive live and resolve each one with Success / Fail right there. Every character appears once: a tick box if they do not hold it yet, or their roll, the DC’s suggestion and the verdict buttons if they do. Apply suggested clears the whole queue at once. Nothing needs a token selected.
  • Run it all from the map. The right-hand character panel on the table gains a Handouts tab beside Items. Rows collapse to a single title line to fit the narrow panel — anything awaiting attention opens itself, and ⌄ / ⌃ expand or collapse the lot. Select a player's token as DM and you get their handouts with the roll, the DC's suggestion, and Success / Fail / re-roll / recall — plus a Hand Out list to give them a new one, without leaving the map. A player selecting their own token sees the same tab with their history and the Examine button. The tab shows a dot and a count when something needs attention.

Treasury (/treasury.html) — DM only

The treasury — items grouped by tag on the left, and the selected item's distribution, description, image and weapon properties on the right One catalogue for everything you hand out, replacing the separate Merchant and Loot managers. Every item carries the full D&D data set — type, price (PP/GP/EP/SP/CP), stock, AC / initiative / speed / spell bonuses, attunement, weapon damage and properties, description — plus a distribution mode you flip in place:

Mode Players see it Cost
Hidden no —
Free Loot yes free, claimed once per character
Shop while the shop is open and its tag is on sale in-character currency
  • Master–detail workspace: searchable, tag-grouped sidebar with mode filter chips; the selected item is edited inline (no modal)
  • Claiming free loot creates a real inventory item, exactly like a purchase — a claimed weapon arrives with its attack and damage worked out against the character's own stats. Stock governs how many can take it: 1 disappears after the first claim, a higher number counts down, -1 is an open offer to the whole party
  • Item images: drop or pick a picture per item (JPEG/PNG/GIF/WebP, 25 MB); players see a thumbnail in the list, the full size in the detail view, and can open it in the lightbox — and it follows the item into their inventory
  • Unidentified items: leave description visible off and players see the item's name marked (unidentified) along with its kind, price and stock — the description, magic bonus, damage dice, properties and bonuses stay hidden, even from anyone who claims or buys it. The redaction happens server-side, so nothing identifying is sent to the browser at all. Turn it on and everything is revealed at once, including on copies players already hold
  • Multi-tag shop: open the shop for any combination of tags (or all of it); a tag picker shows how many items each tag has for sale
  • Bulk text import, bulk tag / mode assignment, bulk delete, multi-select
  • Ledger: claims and purchases interleaved in one history — who took what, when, and for how much
  • Legacy /merchant.html and /loot.html redirect here

Maintenance (/maintenance.html) — DM only, unlisted

  • Hidden diagnostics page (no link anywhere) gated by the master password on every request
  • Lists every connected real-time client: IP, identity (DM or character name), login time, current page, transport (WebSocket / SSE), user agent
  • Shows the frontend version each client has loaded against the server's, highlighting anyone running stale assets
  • Shows the display template and UI size each client's page is showing (v295), updated live when they change it
  • Logs in by itself while the super admin session is still open (v300); Active sessions marks the Android app's remembered logins and names the app as Console app · Android
  • Force reload — push a refresh to every client, or only to the outdated ones, so a release reaches phones without chasing people
  • Blocked addresses — every address currently locked out of logging in, described in words ("Aliyr in Icewind Dale", "DM of Icewind Dale", "Whole address"), with a live countdown and Unblock / Unblock address buttons so a player who mistyped five times does not have to sit out the timer
  • Login activity — the rolling 30-day audit of every login, failure, lockout and unblock, paged on the server (25 / 50 / 100 per page, remembered between visits; auto-refresh only on page 1)
  • Active sessions — everyone signed in: who, which campaign, when they signed in and were last seen, address and browser. End session signs one browser out; End all sessions does it to everyone but you. Every ending is written to the login activity log
  • Since v237 a connection's identity comes from its session token, not from what the page says about itself, so the connected-clients list shows who is really there
  • Installs (v243) — the servers and desktop clients that sent an install report (see Install reporting). Shown only on the licensor's server, which collects the reports

Map Prep (/prepare-map.html) — DM only

Map prep — fog regions, tokens and hidden items placed on a map before the session, ready to load to the table

  • Upload a map and set grid size; draw fog regions; place tokens (with portrait and visible/hidden state) and hidden items on the prep canvas
  • Save named presets and load any to the live table instantly; export / import a map as .map.json; delete saved maps; load warning before overwriting the live map
  • Prepared maps — the list and their images — are readable by the DM only; players see a map once it is loaded onto the table

Waiting Screens (/waiting-screens.html) — DM only

Waiting screens — named full-bleed images with an optional caption, parked over the players' map between scenes

Park the table on a full-bleed image between scenes — a title card while everyone gets a drink, while the DM lays out the next encounter unseen.

  • Define screens ahead of time: a name, an image, and an optional caption shown to players ("Back in 10")
  • Show or close one from this page or from ⏸ in the table toolbar; deleting the screen that is showing brings the players back automatically
  • Players see the image over the map and the left sidebar, and keep their character panel on the right — they can still roll, act and read their sheet. Music keeps playing, and the Now Playing bar with its seek and volume stays reachable
  • The DM sees the map exactly as normal and keeps arranging tokens, fog and prepared maps. A banner shows which screen the players are on, with a Bring them back button
  • The state lives in table_state, so it survives a reload and a server restart — a player who refreshes mid-break stays on the image
  • The console's map screen (/console/table-console.html) is covered too; the console's second screen is the info/character panel and is deliberately left visible

The map is withheld on the server, not just covered in the browser. While a screen is showing, a player's GET /api/table returns no map, no fog, no hidden items and only their own token (their token still travels — the character panel is built from it). The map image is refused on both /api/table/map and its static /uploads/maps/ URL, and the DM's own client fetches it with the DM password and renders it from a blob, which an <img src> cannot do. Token movement is not broadcast to players while parked.

The live connection needs a login too (v237): the server knows who each connection is from its session token and delivers each event only to those meant to see it, so hidden tokens and DM-only rolls never reach a player's socket.

Monster Library (/monsters.html) — DM only

The monster library — searchable list by CR, with the full stat block, traits and actions beside it

  • Full stat blocks: abilities, skills, saves, senses, CR, HP, AC, speed, traits, actions, legendary actions (multi-line text renders correctly)
  • Portrait upload; per-token portrait override; realtime updates of monster edits
  • Import monsters from XML (D&D Beyond / 5e tools) — bulk, non-destructive merge
  • Single-monster JSON export (portrait embedded) and import (auto-detected via type: "monster"), non-destructive
  • Add any monster straight to initiative; search and filter by name; extra fields for vulnerabilities and initiative

Music & Sound Player (/playlists.html) — DM only

The music library — playlists and the sound library on the left, the selected playlist's tracks on the right

  • Upload audio and organise into named playlists; reorder, rename, delete tracks
  • DM playback on the table: play / stop / seek (seek affects all listeners); loop modes (none / track / playlist auto-advance)
  • All clients hear audio in real time; now-playing bar with track name, state, and duration; clients joining mid-track start from the current position
  • Pop-out music popup; loading / playing / paused notifications broadcast to all clients

AI Dungeon Master — retired

The AI DM was removed from the app in v236 (September 2026): its button and pages are gone. Its code is kept, unchanged, in retired/aiDM/ at the top of the repository, outside the deployed application — see that folder's README before thinking of reviving it. Existing aiDM.db files are not deleted and are still included in the raw database backup.

Stories (/stories.html) — password protected

The story dashboard — a card per story, cover taken from its first panel, filterable by character

The story viewer — comic panels in a grid with the cast strip above, switchable to a single vertical strip

  • Comic-book story system for session recaps and campaign moments
  • Dashboard — card grid (cover = first panel, title, cast, panel count, date) with character filter
  • Builder (/story-builder.html) — title/description with debounced auto-save, character cast multiselect by portrait, per-panel image upload and caption, reorder ▲/▼, delete with confirm; images stored under /story-images/{storyId}/{seqId}.ext
  • Video panels — a panel can hold an MP4 or WebM video of up to 500 MB instead of an image. The upload streams to disk with a progress bar, and each file is checked to be the format it claims. Videos play in place in the viewer (grid and strip) and can be a story's cover, where they show their first frame
  • Viewer (/story-viewer.html) — grid or vertical strip layout, fullscreen lightbox, and a cast strip showing portrait / name / species / class
  • Password gate on all three screens accepts the DM password or any character password (POST /api/auth/verify-any), which starts a real session just like the normal login; bypassed if already logged in

Console — two-screen handheld mode (/console/)

The console's map screen — a status line with whose turn it is, the tool and zoom, the shared map with tokens, and a command line with the move arrows

The info panel's Turn tab — the initiative order with each combatant's health, and the turn controls along the bottom   The Token tab's Health part — damage, healing, temporary hit points and conditions for the selected token   The Token tab's Combat part — attacks and damage rolls for a character, with the Normal, Advantage and Disadvantage switch

The map on the top screen; the info panel on the bottom one, as the DM sees it: Turn, then the Token tab's Health and Combat parts.

The console runs the table on a two-screen handheld (built for the AYN Thor): the map on the top screen and an info panel on the bottom one. On the handheld it runs in the Android app (one icon opens both screens); the two pages also work as two browser windows on a PC. It has its own look, separate from the rest of the app: true black for AMOLED screens, rows instead of cards, one condensed typeface, and four colours that each mean one thing (blue = focus, amber = whose turn, red = hostile or damage, green = health). The display templates and UI size from the rest of the app work here too (the palette button on the launcher, the map's status line and the info panel's More tab); each template recolours the console while keeping those four meanings, and the map and the info panel follow one choice.

  • Launcher and login (/console/) — one login form for all console screens, in the design of the main login page, with a campaign picker when the server hosts several. Two console windows on the same device share one login.
  • Map screen (/console/table-console.html) — the shared table map inside a thin frame: a status line (whose turn, today's weather, notification count, tool, zoom, panel link), a Tools drawer, and a command line with move arrows and Undo move. Its dialogs (add token, dice roller, monster info) and a picture the DM shows everyone appear in the console's design.
  • Info panel (/console/table-secondary.html) — six tabs and a notification bell:
    • Turn — the initiative order with health, a line about your own character, and the turn controls. A player gets Roll my initiative, and End my turn on their own turn.
    • Token — the selected token: Health (damage, healing, conditions), and for a player character the full sheet as Skills, Combat (attacks, actions, limited uses, short and long rest), Spells (slots and prepared spells) and Items (wear / take off). For the DM a monster gets Rolls and its Stat block, and every token Manage (initiative, hide, assign to a player, delete).
    • Chat — the shared chat with private messages and dice.
    • Handouts — players examine and read; the DM hands out and judges checks.
    • Table (DM) — initiative, prepared maps, every token's health, fog and hidden items.
    • More — music (what is playing, a per-device volume, and for the DM playlist, tracks, transport and the volume everyone hears), the gamepad buttons, and Log out.
  • Gamepad — the handheld's buttons drive both screens, whichever one has them: D-pad moves a highlight on the panel, A presses, B goes back, L1/R1 change tab, X (twice) runs the turn action, Y opens dice; the left stick moves the selected token, the right stick slides the map, L2/R2 zoom, Start opens Tools, Select undoes a move. A keyboard works too ([ ] tabs, WASD move, IJKL slide, - = zoom, T, U, X, Y, Esc). Any button can be re-assigned on the More tab, for a device that numbers them differently.
  • Device check (/console/probe.html) — shows the screen size, pixel ratio and gamepad buttons exactly as the browser reports them. No login needed.
  • Safe-area padding for notched screens; pages and fonts are cached for offline start-up. The pages can still be installed as web apps, but Chrome refuses the second one while another app from the same site is installed - the Android app avoids that.

Android App (Android/) — two-screen handhelds and phones

A small native app for Android 11+, built for the AYN Thor. Download it from /downloads/rpg-console.apk on your server and install it (Android asks to allow installs from the browser the first time).

  • One icon, both screens. On a two-screen device it opens the console map on the top screen and the info panel on the bottom one. On a one-screen device (a phone) it opens the whole RPG Play app instead - campaigns, sheet, table and the rest.
  • One login, remembered. With two screens only the top one shows the login form; the bottom one follows. A login in the app lasts until you log out, the super admin ends it, the password changes, or it goes 90 days unused - closing the app does not log you out.
  • The handheld's controller is read by the app and handed to the map, so the console's buttons (and their remapping) work whichever screen was touched last.
  • Back opens the app menu: Reload, Server address (the default is set at build time; it can point at any server, a local one too), Open info panel, Switch campaign, Maintenance, Check for updates, Exit.
  • Super admin: Maintenance opens the maintenance page in its own window and Back to campaign returns without closing it, so both stay logged in. With a live super admin session, choosing a campaign offers Enter as super admin - no DM password.
  • Updates itself. On start it reads /downloads/rpg-console.json and offers a newer version; one tap on Android's install screen updates it (Back → Check for updates checks on demand).
  • Closing any screen closes the app - the other screens too - so nothing keeps running.
  • Screens stay on, system bars are hidden, file pickers work, and links to other sites open in the browser.

Build, signing and publishing are below - see Android/README.md for the full picture.

Desktop App (Desktop/) — Windows

Covered in full at the top of this file — Play it as a desktop app ↑.

  • Electron thin client: renders the pages this server already serves, so the web app is unchanged and nothing needs redeploying when the desktop app changes
  • Multi-monitor windows — open the table, DM panel, sheet, monsters, treasury, stories, console and more as separate native windows; each remembers its size, position, monitor, fullscreen state and zoom. Table and Second Screen open on a secondary display by default
  • Global hotkeys (Ctrl+Alt+T/D/C/R) reach a window while another application has focus; press again to minimise it
  • Tray icon, application menu, real fullscreen, per-window zoom, and native Save dialogs for backups, map exports and character XML
  • First run asks for the server address and validates it against /api/config; self-hosted certificates are trusted per host after an explicit prompt, pinned by fingerprint
  • Build instructions below — see Desktop/README.md for the 10BC8 full picture

Authentication & Security

Who can log in as what

  • Login is always scoped to a campaign. The campaign picker at / is the front door; choosing a campaign sets a campaign cookie, and every later request is answered from that campaign only
  • Character tab: name + password (first-login setup for passwordless characters). DM tab: that campaign’s own DM password
  • Two levels of DM authority: a campaign DM password (hashed in campaigns.db, valid only in its own campaign) and the server-wide MASTER_PASSWORD super-admin key (unlocks any campaign, required to create or delete campaigns, and the recovery path for a lost DM password)
  • The DM can also log in as any character by using a DM password in the character tab
  • The super admin can enter any campaign as its DM without that campaign's DM password: with a live super admin session (logged in on the maintenance page), the campaign picker and the console login offer Enter as super admin. Logging out of that campaign leaves the super admin session alive
  • /login.html still works and reads the campaign already selected; it redirects to the picker when there is none
  • An API call made with no campaign selected answers 409 NO_CAMPAIGN, and the frontend bounces to the picker
  • Stories use a separate gate (/api/auth/verify-any) accepting the DM or any character password; it starts the same kind of session as the normal login, and is bypassed when already logged in
  • Creating a character is the DM's job; the player sets their own password on first login
  • Token movement on the table is intentionally open to all players (DM retains full control); DM-only controls stay hidden until the master password is entered

What needs a login

A page can still be opened without logging in, but it cannot change anything or follow the table live:

  • Live updates (the WebSocket / event stream) need a session. Each event goes only to the people meant to see it — hidden tokens, DM-only rolls, notifications and private messages are filtered on the server
  • Anyone logged into the campaign (the DM or any of its characters) may post to chat, share a chat image, roll the 3D dice, draw on the map or clear drawings, ping, move to the next / previous turn, add to a roll history and read the quick-roll stats the table shows for a token
  • DM only: prepared maps (list and images), and everything on the DM pages
  • Characters with no password stay open to view, as before
  • Everything a player writes on their sheet, and everything in an imported stat block, is shown as plain text on every other screen — never as markup

Session tokens — the password is checked once

Passwords used to travel as X-Master-Password / X-Character-Password headers on every API call, and sat in sessionStorage for as long as the tab was open. They no longer do. A password is now verified in exactly one place, POST /api/auth/login, which hands back an opaque session token. That token now travels in the same headers the password used to, so no call site changed — and a token is all the server will accept.

  • Tokens are rpgs_ + 32 random bytes. Only their SHA-256 is stored, in a sessions table in campaigns.db — a stolen database yields no usable tokens
  • 24 hours idle (sliding) and 7 days absolute, whichever comes first
  • Still kept in sessionStorage, so closing the tab still logs out; HTML pages keep their auth guards. An expired token answers 401 SESSION_EXPIRED and the page returns to login
  • Remembered sessions (the Android app only). The app's login forms ask for remember: true: no fixed end, 90 days idle. The app keeps the token on the device until logout. Never granted to a super admin session. The maintenance page lists them as remembered, and End session ends one like any other
  • The super admin's maintenance login is kept for the browser tab (and on the device in the app), so the maintenance page logs in by itself while that session is still alive (24 hours idle)
  • MASTER_PASSWORD has no fallback. The old hardcoded default is gone: if the env var is unset, the super-admin is simply disabled

A maths problem on every login

Every password form in the app is fronted by an in-house captcha — the campaign picker, both login tabs, campaign create / settings / delete, the mobile console's login, the stories gate, and the DM gates on /dm.html, /treasury.html, /monsters.html, /events.html, /prepare-map.html, /playlists.html and /maintenance.html. It is a small arithmetic problem drawn with hand-coded stroke glyphs, jittered and noised, rasterised to a PNG by sharp; single-use, and it expires after 5 minutes. No third-party service, no external request, no new dependency.

Lockout, hashing and headers

  • Failed-login lockout (lib/login-guard.js) — 5 failures for the same address + account within 15 minutes locks that pair for 60 seconds, doubling on each further round to a 30-minute ceiling; 25 failures from one address locks the whole address. A correct password is still refused while locked (429 with Retry-After), and the DM gets one notification per window
  • Async scrypt (lib/passwords.js) — password hashing is deliberately slow (~50 ms), so it no longer runs on the synchronous path and blocks the event loop for everyone else
  • Security headers on every response (lib/security-middleware.js), and X-Powered-By is off
  • Body limits by caller — 1 MB for an anonymous request, the full 200 MB only once a session is proven, so an unauthenticated client cannot post a 200 MB body. An oversized body answers 413
  • The campaign cookie is Secure when served over HTTPS
  • Login audit — every login, failure, lockout and unblock is recorded for 30 days and read back on the maintenance page

Upgrading from before this? Set MASTER_PASSWORD in .env before restarting — there is no default any more — and expect every player to log in once, as pre-token sessions are dropped. Leave TRUST_PROXY unset unless a real reverse proxy sits in front of the server: with it on, a client can forge X-Forwarded-For and walk around the per-address lockout.


Pages at a Glance

Page URL Who
Campaign Picker / All users
Login /login.html All users
Character Sheet /index.html Players
Virtual Table /table.html Players + DM
DM Dashboard /dm.html DM
DM Calendar /events.html DM
Monster Library /monsters.html DM
Map Prep /prepare-map.html DM
Waiting Screens /waiting-screens.html DM
Treasury (loot + shop) /treasury.html DM
Handouts /handouts.html DM
Music & Sounds /playlists.html DM
Maintenance (unlisted) /maintenance.html Super-admin
Stories /stories.html Any (password gated)
Story Builder /story-builder.html Any (password gated)
Story Viewer /story-viewer.html Any (password gated)
Console (two-screen mode) /console/ All users
Console device check /console/probe.html Anyone, no login
Android app download /downloads/rpg-console.apk (+ rpg-console.json) Anyone

Storage

Local SQLite (better-sqlite3) with real-time over WebSocket. There is nothing to configure and no cloud credentials to supply.

A hosted InstantDB backend used to sit behind a DB_PROVIDER switch. It was retired in September 2026: it had drifted years behind — handouts, notifications, weather, calendar events, map drawings and prepared maps were never implemented there — and nothing ran it. DB_PROVIDER is now ignored with a warning if it is still set. Backup files written under the old backend still restore.


Deployment

Option A — Docker (recommended for local / LAN play)

Prerequisites: Docker Desktop

git clone https://github.com/irmakh/rpg-play.git
cd rpg-play

cp .env.docker .env
# Edit .env — at minimum set MASTER_PASSWORD
./docker-start.sh
# or: docker-compose build && docker-compose up -d

Open http://localhost:3000 (or whatever HOST_PORT you set).

Docker commands:

./docker-stop.sh            # Stop
docker-compose logs -f      # Live logs
docker-compose restart      # Restart
docker-compose build --no-cache && docker-compose up -d   # Rebuild

Note: native addons (sharp, better-sqlite3) require a full rebuild when first added — docker-compose build --no-cache and docker-compose down -v && docker-compose up -d. Skipping down -v leaves the stale anonymous node_modules volume in place and the package stays missing.


Option B — PM2 (production / HTTPS server)

Prerequisites: Node.js 18+, PM2 (npm install -g pm2)

git clone https://github.com/irmakh/rpg-play.git
cd rpg-play/Application

npm install

cp .env.example .env
# Edit .env with your values
pm2 start server.js --name dnd
pm2 save
pm2 startup   # auto-start on reboot

HTTPS / SSL (Let's Encrypt)

The server supports HTTPS natively — no reverse proxy required. It listens on 443 (HTTPS) and 80 (HTTP → HTTPS redirect).

1. Obtain a certificate:

sudo certbot certonly --standalone -d your-domain.com

2. Set environment variables:

SSL_KEY=/etc/letsencrypt/live/your-domain.com/privkey.pem
SSL_CERT=/etc/letsencrypt/live/your-domain.com/fullchain.pem
PORT=443

3. Auto-renew: renew-cert.sh (project root) stops the app to free port 80, runs certbot, and restarts. Add it to cron:

# /etc/cron.d/cert-renewal
0 3 1 * * root /path/to/rpg-play/renew-cert.sh >> /var/log/cert-renewal.log 2>&1

Desktop Client (Windows)

The desktop app is a client, not a second server — it connects to a running RPG Play server. Build it separately from Desktop/.

cd Desktop
npm install
npm run build

If npm install finishes without a node_modules/electron/dist/ folder, run the binary download directly: node node_modules/electron/install.js.

Artifacts land in Desktop/dist/:

File What it is
RPG Table Setup <version>.exe Installer — choose the folder, desktop + Start Menu shortcuts, uninstaller
RPG-Table-<version>-portable.exe Single self-contained file, no install
win-unpacked/RPG Table.exe Raw unpacked build, for a quick test without packaging

Both installers are x64 and self-contained: the target machine needs no Node and no Electron. Other commands:

npm start        # run from source
npm run dev      # run from source with DevTools open
npm run pack     # win-unpacked/ only — much faster than a full build

Version and icon: bump version in Desktop/package.json (it appears in the filenames, the installer and Help → About); replace Desktop/assets/icon.png to change the icon — electron-builder converts it to a Windows .ico, so keep it at least 256×256.

Code signing: builds are unsigned, so Windows SmartScreen warns on first run of the installer (More info → Run anyway). Removing that needs a code-signing certificate configured via win.certificateFile in the build config.

Building from a VS Code terminal: prefix with env -u ELECTRON_RUN_AS_NODE. VS Code exports ELECTRON_RUN_AS_NODE=1, which makes any Electron binary boot as plain Node and crash. A normal PowerShell window is unaffected.


Android App

The Android app is a client too: it opens pages of a running RPG Play server. Build it from Android/; everything builds inside Docker, so nothing is installed on the machine.

cd Android
./make-keystore.sh   # once: the signing key (keystore/ + keystore.properties, outside git)
echo 'defaultServer=https://your.server' > server.properties   # once: the default server, outside git
./build.sh           # -> dist/rpg-console.apk + dist/rpg-console.json

Without server.properties the app has no default and asks for the server address on first start.

The first build makes the rpg-console-android:1 tools image (JDK 17, Android SDK 34, Gradle 8.7; about 2 GB).

Publishing a new version: raise versionCode (and versionName) in Android/app/build.gradle.kts, run ./build.sh, and upload both files from Android/dist/ to public/downloads/ on the server. No restart is needed: installed apps find the update on their next start.

Keep the signing key. Android only installs an update signed with the same key as the installed app. Copy Android/keystore/, Android/keystore.properties and Android/server.properties to every machine that builds the app, and keep a backup.

Environment Variables

Variable Required Default Description
MASTER_PASSWORD Yes — Super-admin password: unlocks any campaign, required to create/delete campaigns, seeds the first campaign’s DM password on migration. Per-campaign DM passwords live in campaigns.db
PORT No 3000 Port the server listens on (use 443 for HTTPS)
SSL_KEY No — Path to TLS private key (enables HTTPS)
SSL_CERT No — Path to TLS certificate chain (enables HTTPS)
HOST_PORT Docker only 3000 Port exposed on the host machine
WS_URL No auto Override WebSocket URL (e.g. wss://your-domain.com/ws)
CAMPAIGNS_DB No Application/campaigns.db Override the campaign registry file
CAMPAIGN_DATA_DIR No Application/data/campaigns Override where per-campaign databases live
TRUST_PROXY No off Read the client IP from X-Forwarded-For. Only enable behind a real reverse proxy — with it on and no proxy, a client can forge the header and walk around the login lockout
TELEMETRY No on off stops the daily install report — see Install reporting
TELEMETRY_URL No https://dnd.kimse.me Where install reports go

Install reporting

RPG Table is licensed under the Business Source License (see LICENSE): free for non-commercial use, while commercial or hosted-for-others use needs a separate licence. To know where it runs, each install sends a small anonymous report to https://dnd.kimse.me. The server announces this in its startup log.

A server reports a minute after it starts and then once a day:

Field What it is
installId A random id made on first start and kept in campaigns.db
version The release number (FRONTEND_VERSION)
node, platform Node.js version, operating system and CPU architecture
campaigns, characters How many exist — counts only
activeUsers7d How many accounts logged in during the last 7 days — a count only
hosts Up to five host names this server was reached by (e.g. rpg.example.com)

The desktop client reports at launch, at most once a day: a random install id, the app version, operating system and the host name of the server it is set up for.

Nothing else is sent: no names, passwords, chat, characters' contents or files. The receiving server also sees the address the report came from.

To turn it off: set TELEMETRY=off in Application/.env and restart. In the desktop client, untick Send an anonymous install report in Settings.


Tech Stack

  • Backend: Node.js (ES modules), Express — split into 18 semantic route modules under server/routes/, with a lean server.js entry point
  • Database: SQLite (better-sqlite3). One cross-tenant registry (campaigns.db) plus four SQLite files per campaign under data/campaigns/<id>/
  • Real-time: WebSocket (ws), with a Server-Sent Events endpoint kept alongside it
  • Frontend: Vanilla JS, HTML, CSS — no build step, no framework, no bundler. The character sheet is 16 modules under js/index/, the table is 19 under js/table/, with shared helpers in js/lib/
  • Dice: 3D CSS dice (icosahedron d20, pentagonal-trapezohedron d10) driven by a shared dice-engine.js
  • Image processing: sharp — each upload generates _thumb.webp (80×80 crop) and _medium.webp (max 500 px); maps excluded
  • PWA: Service Worker (sw.js) — network-first for HTML, cache-first for versioned static assets
  • Android app: Kotlin, plain Android Activity + WebView (one dependency: androidx.webkit), minSdk 30 / target 34; built in Docker with Gradle 8.7, self-updating through Android's PackageInstaller
  • Tests: 1705 Vitest tests across 64 files (unit + API) covering the sheet, table, dice fairness, login security, per-route login rules, escaping of user text in the page renderers, and routes
  • Security: in-house, no new dependencies — maths captcha (lib/captcha.js, rendered by sharp), opaque session tokens stored as SHA-256 (lib/sessions.js), failed-login lockout (lib/login-guard.js), async scrypt (lib/passwords.js), response headers and caller-dependent body limits (lib/security-middleware.js)
  • SSL: Node.js native https with Let's Encrypt certificates

Frontend cache-busting

Release history lives in CHANGELOG.md, numbered by the same FRONTEND_VERSION described here.

Static JS/CSS is served immutable and cached by URL forever; the server injects ?v=N into every src/href at request time. On any frontend change, bump both FRONTEND_VERSION in server.js and the CACHE version in public/sw.js together so the URLs change and clients fetch the new files. HTML is served no-store, so it always carries the current version.


Project Structure

High-level layout — see structure.md for the complete, annotated file tree.

char_sheet/
├── Application/            # The web app
│   ├── server.js           #   Express entry point — loads route modules + shared context
│   ├── server/routes/      #   18 Express route modules
│   ├── lib/                #   Campaign scoping, auth, sessions, captcha, lockout, hardening, upload paths, live-event audience
│   ├── db/                 #   SQLite layers (campaignsdb, campaign-store, localdb, mediadb, storiesdb)
│   ├── tests/              #   64 Vitest unit + API suites (1705 tests)
│   └── public/             #   Served frontend
│       ├── *.html          #     Page entry points (campaigns, index, table, dm, treasury, events, …)
│       ├── js/index/       #     16 character-sheet modules
│       ├── js/table/       #     19 virtual-table modules
│       ├── js/lib/         #     Shared utilities (dice engine, chat render, calendar, …)
│       ├── console/        #     Two-screen console: launcher, map screen, info panel
│       ├── js/console/     #     Console scripts (login, gamepad, info panel modules)
│       ├── css/  img/      #     Styles and static images
│       ├── sw.js           #     Service worker (PWA cache)
│       └── uploads/  story-images/   #   Runtime user uploads
├── Android/               # Android console app: both console screens from one icon (Kotlin, WebView)
│   ├── app/src/main/       #   Activities, controller, updater, manifest
│   ├── docker/  build.sh   #   Builds the APK in Docker
│   └── make-keystore.sh    #   The signing key, once
├── Desktop/               # Electron desktop client (thin client over the web app)
│   ├── src/main/           #   Main process — windows, menu, tray, hotkeys, certs, downloads
│   ├── src/preload/        #   Preloads — cross-window session mirror + settings bridge
│   ├── src/renderer/       #   Its own pages — first-run setup, settings, offline screen
│   └── assets/             #   App and tray icons
├── retired/aiDM/          # The retired AI DM, kept unchanged and not deployed
├── goals/ tools/ context/ args/ hardprompts/   # GOTCHA framework layers (see CLAUDE.md)
├── memory/  data/          # Persistent cross-session memory (Application/data/ holds campaign DBs)
├── docker-compose.yml  Dockerfile.dev  docker-*.sh   # Docker deployment
├── renew-cert.sh          # Let's Encrypt renewal (PM2-aware)
└── CLAUDE.md  README.md  FEATURES.md  DOCKER.md  structure.md

📁 Full file listing: structure.md


Troubleshooting

Port already in use (Docker)

HOST_PORT=3001   # change in .env
docker-compose up -d

Container won't start

docker-compose logs
docker-compose down -v
docker-compose build --no-cache && docker-compose up -d

Reset all data (start fresh)

docker-compose down
rm Application/*.db
docker-compose up -d

Frontend won't update on mobile Bump FRONTEND_VERSION (server.js) and the sw.js CACHE together, redeploy, then on the device clear site data / reinstall the PWA once to drop the old service worker.

Windows — Docker file sharing issues Docker Desktop → Settings → Resources → File Sharing → enable your drive.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0