A self-hosted, pencil-and-paper-aesthetic virtual tabletop for D&D. Built for small groups who want the feel of graph paper and hand-drawn maps over a remote session — not a video game, not a Roll20/Foundry replacement.
- Hex or square grid, toggleable per map
- Uploadable background image (scanned graph paper, hand drawings)
- Per-map settings: grid type, cell size, width, height, feet-per-square
- Corner scale legend (1 sq = 5 ft) with a 4-cell scale bar
- Map Library: DM can create, rename, duplicate, activate, delete maps. Tokens, walls, and fog are per-map; duplicating carries them along.
- Random dungeon generation — DM "Generate random map" button produces procedural rooms, corridors, and doors
- Themed furniture — generated rooms are themed (barracks, library, smithy, kitchen, throne room, etc.) and auto-populated with thematic objects (beds, bookshelves, anvils) from the object catalog
- Drag-and-drop, named, HP/AC, color, optional uploaded image with live preview pane and a clear-image button
- Kinds: PC / NPC / monster / object
- Creature & object presets — per-ruleset catalog dropdowns (monsters, NPCs, droppable furniture like chests, beds, weapon racks, barrels, braziers, fountains, thrones, altars). Picking a preset auto-fills name, HP, AC, color, image, size, and move
- Size categories — Tiny / Small / Medium / Large / Huge / Gargantuan; token circle scales accordingly
- Race + movement — per-ruleset race catalog with default per-race move distance; dashed range circle during drag, server-side re-validation, hard cap on single-action distance
- Walls block player movement — Bresenham-path check rejects drags through walls or closed doors (DM bypasses)
- 8-way facing with arrowhead tip; rotate with Q/E when selected
- Stack indicator — fan offset and corner ×N badge when multiple tokens share a cell
- Owner dropdown lists only currently-connected players; tokens auto-reassign to none after a 10-second disconnect grace period
- 1e preset light table: none, candle (2), torch (3), lantern (6), bullseye (12 cone), light spell (4), continual (12), infravision (12). Custom radius override supported.
- Bullseye renders as a ~60° cone in the token's facing direction (default for hooded lantern too).
- Three-state memory fog: unexplored (pitch black), explored-memory (ghost tokens at last-seen positions, dim walls), currently lit (full render). Memory is shared by the whole party.
- Live fog preview during drag — light updates client-side as a token moves, not just on mouseup
- Light source objects — torches, lanterns, candles, braziers, campfires, light orbs keep illuminating after being dropped on the ground. Monsters carrying torches do not illuminate (keeps dungeons scary).
- Memory rules — party tokens are excluded from memory ghosts; stale monster memory clears when a token is re-spotted somewhere else
- Light source approval — optional setting requires DM to approve player light source changes
- Dynamic fog: server-side flood-fill from each party token's light source. Walls and closed doors block light; diagonal propagation requires both orthogonal gaps clear.
- Fog recomputes on any token move, create, update, delete, or map change.
- DM fog brushes (reveal, hide, clear, cover-all) and a Clear party memory button remain for edge cases.
- Edge tool — click a cell edge to toggle a wall segment
- Room (drag) tool — drag to outline rectangular rooms with perimeter walls
- Door tool — cycles a cell edge through none → closed → open → none; replaces walls
- Closed doors block light like walls; open doors pass light
- Players can click a visible door to request open/close; DM approves. Pending doors show a red
?to everyone. Controlled by thedoor_approvalcampaign setting.
- Three independent campaign settings:
approval_mode(moves),door_approval(doors),light_approval(player light source changes) - On drag, the player's token snaps back and a dashed ghost circle with connector line shows on all clients
- DM gets a Pending actions panel (top-right) with Approve / Deny for moves, door requests, and light changes
- Pending state is in memory only; clears on server restart
- Players see opaque fog; DM sees 55% alpha to keep oversight
- Tokens, walls, doors, and light glows in fogged cells are hidden from players
- Players always see their own token, even in darkness
- Drop AOE markers on the map: fireball circle, cone of cold cone, lightning bolt line, and the rest of the standard catalog
- Per-ruleset shape + radius catalog so each effect is correctly sized
- Shared chat panel
- Dice roller: d4, d6, d8, d10, d12, d20, d100, plus custom expressions like
2d6+3 - Rolls post to chat
- Unread indicator — red dot on the right-panel toggle when the panel is collapsed and a new message arrives
- DM
clearbutton wipes chat for everyone
- In-memory stack, 50 entries, Ctrl+Z or toolbar button
- Covers: token move / create / update / delete, map settings, fog overrides, wall toggle / clear / rect, door cycle, full random-map generation
- Stack clears on active-map change
- Does not cover: chat, dice, campaign settings, login flows, approval-mode pending queue, door requests
- Collapsible, resizable left and right sidebars (180–600 px)
- Dark parchment theme by default; crescent-moon button toggles a brighter daylight theme
- Player-seen tokens filter — sidebar token list only shows tokens the party has seen; DM sees all
- DM toolbar exposes map library, walls, doors, fog brushes, approval queue, undo, and the deployment panel
- Player UI hides DM tools and respects fog
- Static
/libassets are servedno-cacheand/app.jsis templated with a version stamp so deploys never serve stale JS
git clone https://github.com/jampick/dungeon-grid.git
cd dungeon-grid
npm install
DM_PASSWORD=your-secret npm startRequires Node 22. Open http://localhost:3000 in two browser windows — use Incognito for the second so they do not share localStorage. Log in as DM with the password set above; join as a player by name in the other window.
Pull a prebuilt multi-arch image and run it — no cloning required. The image auto-builds on every push to main via GitHub Actions and supports linux/amd64 and linux/arm64 (Intel NASes, Raspberry Pi, Synology ARM, Apple Silicon).
# docker-compose.yml
services:
dungeon-grid:
image: ghcr.io/jampick/dungeon-grid:latest
restart: unless-stopped
environment:
DM_PASSWORD: your-secret
PORT: 3000
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
ports:
- "127.0.0.1:3000:3000"
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
tmpfs:
- /tmpdocker compose up -d and visit http://localhost:3000. A ready-to-copy version of this file lives at docker-compose.example.yml.
First-time ghcr.io auth: the image is published as PRIVATE by default until the maintainer flips it to public on https://github.com/jampick/dungeon-grid/pkgs/container/dungeon-grid. If your
docker compose pullgets a 403/unauthorized, either wait for it to be made public ordocker login ghcr.iowith a GitHub PAT that hasread:packages.
A Docker Hub mirror is also published at jampick/dungeon-grid once the maintainer configures the DOCKERHUB_USERNAME / DOCKERHUB_TOKEN repo secrets.
npm testUses Node's built-in node:test runner. 356 tests across ~60 files covering chat, dice, fog (including memory fog), light (including effective radius and rounded falloff), maps (including random generation and object-catalog placement), creature catalog, sizes, movement range, walls, doors, visibility, undo, owner reassignment on disconnect, cache headers, login, theme, multi-tenant session isolation + migration + auth, session routing helpers, and deployment wiring.
The app is designed to run as a single Docker container on a NAS (Synology, but anything with Docker works), reached through a Cloudflare Tunnel gated by Cloudflare Access. This gives two independent auth layers:
- Cloudflare Access gates the perimeter (email allowlist, one-time PIN, GitHub SSO, etc.). No inbound port forwarding or firewall holes.
- Cloudflare Tunnel punches an outbound-only connection from the NAS to Cloudflare. The app never listens on a public interface.
DM_PASSWORDgates the DM role inside the app. Defense in depth — set it to something strong even with Access in front.
You can deploy the NAS container in two ways:
- Pull the published image (new, easier) — use
image: ghcr.io/jampick/dungeon-grid:latestin your compose file anddocker compose pull && docker compose up -d. No source checkout or local build required. See Deploy via Docker above. The image is private on ghcr.io until the maintainer flips it to public; authenticate with a GitHub PAT (read:packages) the first time if needed. - Build from source (existing, below) — clone the repo to the NAS and let
docker compose buildrun theDockerfile. This is what the existingupdate.shpolling loop uses and is still fully supported.
services:
dungeon-grid:
build:
context: ./app
network: host
args:
GIT_SHA: ${GIT_SHA:-unknown}
GIT_SUBJECT: ${GIT_SUBJECT:-unknown}
restart: unless-stopped
environment:
DM_PASSWORD: ${DM_PASSWORD}
PORT: 3000
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
- ./triggers:/app/triggers
ports:
- "127.0.0.1:3030:3000"
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
user: "1026:100"
mem_limit: 512m
tmpfs:
- /tmpNotes:
- Bind port to
127.0.0.1only — the tunnel reaches it from the host. DM_PASSWORDlives in an owner-only.envfile, never in git.build.network: hostworks around Synology's default bridge network being unable to resolve DNS forapk.
- Install
cloudflaredon the NAS — a container in host-network mode is the least painful option. Cloudflare's Zero Trust dashboard provides a one-linedocker runcommand once you create the tunnel. - Zero Trust → Networks → Tunnels → Create a tunnel. Name it
dungeon-grid(or whatever). - In the tunnel's Public Hostnames tab, add a hostname (e.g.
grid.example.dev) pointing tohttp://localhost:3030. - Save. Ingress lives in the dashboard, not a local config file.
- Zero Trust → Access → Applications → Add an application → Self-hosted.
- Domain: the hostname from the tunnel step.
- Policy: Include → Emails (list your players) or Email domain (your org).
- Save. Every request to the hostname now hits a Cloudflare login first.
Socket.IO needs WebSocket upgrades. Cloudflare Tunnel enables WS by default — if clients are falling back to long-polling, check the tunnel configuration.
Iterate on the PC, push to main, the NAS picks it up. Two paths:
- Automatic polling. DSM Task Scheduler runs
update.shon the NAS host every minute. The scriptgit fetches via a containerized git (alpine/git— no git binary on the host), and iforigin/mainhas advanced, pulls, rebuilds, and restarts. Latency is under a minute plus build time. - Manual trigger from the DM UI. The DM sidebar has a Deployment panel showing the running short SHA and commit subject, plus a Check for Updates button. Clicking it writes a marker file into the bind-mounted
triggers/directory; the nextupdate.shtick sees it and forces a rebuild even iforigin/mainhas not moved. Useful for "I just pushed, redeploy now."
update.sh is fail-closed: if docker compose build fails it logs and exits, leaving the old container running. A stale SHA in the DM panel is the signal that something is wrong — check triggers/last.log.
Critical constraint: /var/run/docker.sock is deliberately not mounted into the container. A container RCE must not become NAS root. The trigger-file + host-runner pattern exists specifically to avoid exposing the socket. The updater runs on the host under DSM Task Scheduler.
Environment variables:
DM_PASSWORD— required. The DM role gate inside the app.PORT— optional, defaults to3000.
Persistent state:
data/grid.db— SQLite database (WAL mode). Back this up; losing it loses all campaign state.uploads/— user-uploaded background and token images.
Both directories are gitignored and expected to be bind-mounted in Docker deployments.
SQLite, one file at data/grid.db. Tables:
campaigns— campaign-wide settings (ruleset, approval_mode, door_approval, show_other_hp)maps— per-campaign maps with grid type, size, background, active flagtokens— per-map tokens with HP, AC, light, facing, owner, size, kindwalls— per-map wall and door segments keyed by cell edgefog— per-map revealed-cell JSON blob (DM brush overrides)explored_cells— per-map set of cells the party has ever seen lit (memory fog "ever-explored" layer)cell_memory— per-cell snapshot of what was last seen at each remembered cell (memory fog ghost layer)sessions— top-level tenant rows (id,name,join_password_hash, per-session approval/ruleset settings,last_active_at). Every map/player/catalog row is scoped to a session id.instance_settings— global key/value store (currently holds the DM password hash, shared across sessions; rotate viaPUT /api/instance/dm-password)players— per-session login records with role (dmorplayer)catalog— reusable drag-on object library (table exists; the live object catalog ships fromlib/objects.js)events— audit log placeholder (table exists; not currently written)
The app is multi-tenant as of Phase 1. Visit / to see the landing page with all sessions, create a new one, or deep-link to /s/<id> to join a specific session. Uploaded assets are stored under uploads/<session_id>/. The DM password is global to the instance and gates both DM login and new-session creation.
Express serves static files and upload endpoints; Socket.IO carries the realtime game state. Persistence is better-sqlite3 for synchronous, durable SQLite access. The client is vanilla JavaScript rendering to HTML5 Canvas — no build step, no framework, no bundler. Pure game logic (dice, light presets, fog recompute, undo stack, snapshots) lives in lib/logic.js and lib/maps.js so it can be unit-tested without a server.
- No event log. The
eventstable exists but nothing writes to it. - Hex token placement uses square-cell indexing. Tokens carry
x,ycell coordinates and do not follow true offset-hex math. Visually fine for free-form drag, not suitable for strict hex movement rules. - Infravision is modeled as a regular 60 ft light source. 1e infravision is darkness-only and heat-based; the simplification is intentional.
- Wall occlusion is cell-edge only. No raycasting against arbitrary line segments; walls live on grid lines.
- Approval-mode pending moves are not on the DM undo stack. They use a separate queue.
- A handful of catalog entries are still placeholders. Most creature, object, and spell icons are now CC BY 3.0 icons from game-icons.net (see CREDITS.md); the few entries without a good semantic match still use auto-generated letter-in-circle SVGs.
See docs/roadmap.md for design notes on future work.
This project uses branch-per-feature with test-gated merges.
Creature, object, and spell icons are from game-icons.net under CC BY 3.0. See CREDITS.md for per-icon attribution. The published container image bundles these assets and is therefore CC-BY-3.0-compatible; attribution is preserved in CREDITS.md inside the image.
MIT