City of Brass is a tabletop RPG world-building and campaign-management platform. Each user signs in and creates a Resident — their identity in the world — and from there builds and runs games: design worlds, author character and creature sheets, write adventures, manage campaigns, and collaborate with other residents. Content is game-system aware, so sheets and rules adapt to the system you're playing (D&D 5e, Pathfinder, Fate Core, and more).
It is a Rails modular monolith made up of ~10 mounted Rails engines, one per feature domain.
City of Brass is organized into feature engines, each mounted under its own path:
- 🗺️ World Builder (
/wb) — build districts (worlds) with pages, sections, features, menus, and collaborating contributors. - 🧙 Entity Builder (
/eb) — game-system-aware character, NPC, and creature sheets: ability scores, skills, defenses, attacks, movements, and trackable resources. - 📖 Story Builder (
/sb) — author adventures and scenarios with pages, sections, and notable elements. - ⚔️ Campaign Manager (
/cm) — run campaigns: campaign pages, players, and features that tie the other tools together. - 📜 Rule Builder (
/rb) — a shared catalogue of rules, spells, items, abilities, and feats, scoped per game system. - 🎲 Active Play (
/ap) — live-play features and virtual tables. - 🖼️ Gallery (
/gallery) — image management and attachment to any content (Paperclip). - 💳 Billing (
/billing) — Stripe-backed subscriptions, plans, and billing events. - 🆘 Support (
/support) — FAQ and help content, including system-specific FAQs. - 📊 Report (
/report) — reporting and analytics.
Cross-cutting capabilities in the core app:
- 🔐 Authentication via Devise (email confirmation, account lockout) plus a custom
single sign-on layer (
lib/single_sign_on.rb). - 🤝 Collaboration — residents send/accept affiliations to co-edit shared content and campaigns.
- 💬 Messaging — resident-to-resident inbox/sent messaging.
- 🛠️ Admin panel — user administration and status management.
- 🎚️ Account tiers & quotas — user statuses (
free,trial,active,alpha,beta,vip,locked,canceled) gate features and enforce content quotas (lib/quota.rb). - 🔒 Privacy controls — content visibility of Public / Residents / Private.
Game systems are defined in config/core_rules/:
- 🐉 3.5 Edition
- 🐉 4th Edition
- 🐉 5th Edition
- 🔫 d20 Modern
- 🚀 d20 Future
- ⚔️ Pathfinder 1e
- ⚔️ Pathfinder 2e
- 🎴 Fate Core
- 🛡️ Draw Steel
- 🌌 W.O.I.N
- 🎲 Generic
- Language / framework: Ruby, Rails 8
- Web server: Puma
- Database: SQLite (with a custom UUID function; UUID primary keys)
- Background jobs: Solid Queue (stored in its own SQLite database)
- Auth: Devise + OmniAuth
- Payments: Stripe
- File uploads: Paperclip (disk-backed, optional AWS S3)
- Frontend: server-rendered ERB, Turbo, jQuery, Foundation, Sprockets asset pipeline; CKEditor for rich text
- Testing: Minitest, Capybara + Cuprite (headless Chrome), RuboCop
- Deployment: Docker image, deployed with Kamal
- Production / Docker / Kamal: Linux on amd64 (the image is built for
x86_64-linux). - Development: macOS and Linux. Requires the Ruby version pinned in
.ruby-versionand a POSIX filesystem for thestorage/directory (SQLite database and uploaded files live there). - Windows is not supported.
All paths below assume the application root. Copy the example config first in every case:
cp config/application.example.yml config/application.yml
# edit config/application.yml and fill in the values you needSee Configuration for the full list of variables.
- Install Ruby (the version pinned in
.ruby-version). - Install dependencies and prepare the database:
Without
bin/setup --skip-server # install gems and JavaScript packages, prepare the database--skip-server,bin/setupfinishes by starting the development server and never returns, so the remaining steps would not run. - Export the required production environment variables:
export RAILS_ENV=production export SECRET_KEY_BASE="$(bin/rails secret)" export DEFAULT_BASE_URL=your.domain export SMTP_URL=smtp://user:pass@host:587 export RAILS_SERVE_STATIC_FILES=1 export RAILS_LOG_TO_STDOUT=1
- Precompile assets and prepare the production database:
bin/rails assets:precompile bin/rails db:prepare
- Start the processes defined in the
Procfile:bundle exec puma -C config/puma.rb # web bin/jobs # worker
The SQLite production database is created at storage/db/production.sqlite3, and
uploads are stored under storage/. Back up the whole storage/ directory.
The Dockerfile builds a self-contained production image (Puma on port 3000).
The container entrypoint creates storage/db on boot; run database migrations
yourself after the first start.
- Build the image:
docker build -t cityofbrass . - Run the app with a volume for
storage/:docker run -d --name cityofbrass \ -p 80:3000 \ -v cob-storage:/rails/storage \ -e SECRET_KEY_BASE="$(openssl rand -hex 64)" \ -e SOLID_QUEUE_IN_PUMA=1 \ -e DEFAULT_BASE_URL=your.domain \ -e SMTP_URL=smtp://user:pass@host:587 \ -e RAILS_SERVE_STATIC_FILES=1 \ -e RAILS_LOG_TO_STDOUT=1 \ cityofbrass - Prepare the database (first run only):
docker exec cityofbrass bin/rails db:prepare
SOLID_QUEUE_IN_PUMA runs background jobs inside the web container. To give jobs a
container of their own instead, leave that variable out and start a second container
from the same image, sharing the volume so both see the same queue:
docker run -d --name cob-worker \
-v cob-storage:/rails/storage \
-e SECRET_KEY_BASE=... \
cityofbrass bin/jobsProduction is deployed with Kamal; see config/deploy.yml.
It builds the image on a remote builder, pushes to a private registry, and runs a
single web role (Puma), which runs background jobs in the same process. The
storage/ directory is mounted from a persistent host volume and the proxy
health-checks /up.
Secrets are pulled at deploy time from .kamal/secrets (backed by 1Password). Provide:
KAMAL_REGISTRY_PASSWORDSECRET_KEY_BASESMTP_URLACTIVEPLAY_SECRET
Common commands:
kamal setup # first-time provisioning + deploy
kamal deploy # build, push, and roll out a new release
kamal app logs # tail application logs
# aliases defined in config/deploy.yml
kamal console # bin/rails console on the server
kamal shell # bash shell in the running container
kamal dbconsole # bin/rails dbconsole
kamal migrate # bin/rails db:migrateConfiguration is read from environment variables (and config/application.yml in
development — see config/application.example.yml).
| Variable | Purpose |
|---|---|
SECRET_KEY_BASE |
Rails secret; required in production |
RAILS_ENV |
Environment (production) |
DEFAULT_BASE_URL |
Public host used for URL generation and mailers |
SOLID_QUEUE_IN_PUMA |
Run background jobs inside the web server (1) |
SMTP_URL |
SMTP connection string for outbound mail |
DEFAULT_FROM_EMAIL |
Default "from" address |
RAILS_SERVE_STATIC_FILES |
Serve precompiled assets from the app (1) |
RAILS_LOG_TO_STDOUT |
Log to STDOUT (1) |
WEB_CONCURRENCY / MAX_THREADS |
Puma workers / threads |
ACTIVEPLAY_URL / ACTIVEPLAY_SECRET |
Active Play integration |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION |
S3 image storage (optional) |
STRIPE_SECRET_KEY / STRIPE_PUBLISHABLE_KEY / STRIPE_WEBHOOK_TOKEN |
Stripe billing (optional) |
DEVISE_SECRET / DEVISE_PEPPER |
Devise auth secrets |
bin/setup # install gems and JavaScript packages, prepare the database, start the appbin/setup ends by starting the app on http://localhost:3000. To stop before that
and run the pieces yourself:
bin/setup --skip-server # install gems and JavaScript packages, prepare the database
bin/rails db:setup # load schema and seed data
bin/dev # http://localhost:3000bin/setup --reset throws the development database away and rebuilds it from
scratch.
The seed data creates a login you can use immediately:
- Email:
user@example.com - Password:
password1
After signing in, set up your resident. To unlock paid features, promote the user to
vip: sign in to the admin panel at /admins/login with the same credentials, then
Admin menu → User Admin → cog icon for the user → set status to vip.
Running checks:
bin/rails test # unit, integration, and system tests (Capybara/Cuprite)
bundle exec rubocop # linting
bin/pre_push_checks # the full pre-push gateSystem tests use Cuprite (headless Chrome), so a local Chrome/Chromium is required.
City of Brass is a Rails modular monolith. The core app/ directory handles
users, residents, authentication, messaging, and affiliations, while each major feature
domain lives in its own namespaced Rails engine mounted under a sub-path in
config/routes.rb (e.g. /wb, /eb, /cm). This keeps domains isolated — each engine
owns its own models, controllers, views, and migrations — while sharing one deployable
app and database.
The frontend is server-rendered ERB with Turbo for fast page transitions,
jQuery for interactivity, and Foundation for styling, served through the Sprockets
asset pipeline (no SPA). Solid Queue handles background work (asynchronous mail,
Paperclip image processing), keeping queued jobs in a SQLite database of its own so no
separate queue server is needed. Caching needs no server either: development caches in
memory and production to disk. Authentication is built on Devise with a custom
single-sign-on layer, and authorization is enforced through a
status-tier and quota system (lib/quota.rb) that gates features per account level.
Records use UUID primary keys. Persistence is SQLite (database and Paperclip
uploads both stored under storage/), payments run through Stripe, and the whole
thing ships as a single Docker image deployed with Kamal.