"One does not simply walk into production without testing disaster recovery."
Mordor's Edge is a Rails + React demo application designed for deployment testing and disaster recovery training. It provides a realistic, full-stack environment where teams can practice firefighting production incidents by intentionally breaking things in a controlled setting.
The application uses Lord of the Rings themed data throughout (characters, locations, quests, artifacts) to make the demo environment engaging and easy to reason about.
- Deployment Testing - Validate CI/CD pipelines, infrastructure provisioning, and deployment workflows
- Disaster Recovery Training - Provide a realistic app that can be intentionally broken to teach incident response
- Integration Testing - Exercise real-world integrations (Redis, SQS, WebSockets, OIDC) in a safe environment
- Team Training - Give engineers hands-on experience with production-like failures
+------------------+
| React (Vite) |
| Frontend SPA |
+--------+---------+
|
v
+------------------+ +------------------+ +------------------+
| Amazon SQS |<-------->| Rails API |<-------->| PostgreSQL |
| (via Shoryuken)| | (Backend) | | (Database) |
+------------------+ +--------+---------+ +------------------+
|
+--------+---------+
| |
+------v------+ +------v------+
| Redis | | OIDC |
| Sidekiq | | Provider |
+-------------+ +-------------+
The React SPA is built by Vite and its output is served by Rails at the root URL (/). The architecture allows decoupling the frontend to its own server in the future.
Goal: Establish the project skeleton, CI pipeline, local dev environment, and basic health checks.
- Initialize Rails 8.1 API-mode application with PostgreSQL
- Ruby 4.0
- Configure RSpec as the test framework
- Add health check endpoint (
GET /api/health) returning{ status: "ok", version: "...", environment: "..." } - Configure CORS for frontend dev server
- Initialize React 19.2 + TypeScript project with Vite 8 (react-swc-ts template)
- Configure Biome.js 2.x for linting and formatting
- Install and configure: Mantine 8.3, Zustand 5, TanStack Router 1.x, Axios, Zod, es-toolkit
- Node.js 24 LTS
- npm for package management
- Add Vitest for testing
- Create root layout with Mantine AppShell with dark/light mode toggle (default: dark)
- Retro pixel gaming aesthetic: pixel font (e.g., Press Start 2P), 8-bit styled borders and UI elements, retro color palette. Mantine components customized via theme overrides to match the pixel RPG look.
- Pixel art character/artifact portraits (use or generate a consistent sprite set for all seed characters and artifacts)
- Wire up Axios client to point at Rails API base URL
- Landing page that calls the health endpoint and displays status
docker-compose.ymlwith services: rails, postgres, redis, frontend- Rails runs with file watching / auto-reload
- Frontend runs Vite dev server with HMR
- Postgres and Redis as backing services
- Volume mounts for live code editing
- Environment variable configuration via
.envfile
- Workflow triggers on push and PR to
main - Backend job: Ruby setup, bundle install, RSpec with PostgreSQL service container
- Frontend job: Node setup, npm ci, Biome check, Vitest
- Both jobs must pass before merge
- Pre-push git hook to run tests locally before pushing
Goal: Build the LOTR-themed data model and REST API with OpenAPI documentation.
Characters (The Fellowship and beyond)
name(string, required) - e.g., "Aragorn", "Gandalf", "Frodo"race(string, required) - e.g., "Human", "Maiar", "Hobbit", "Elf", "Dwarf", "Ent"realm(string) - e.g., "Gondor", "The Shire", "Rivendell"title(string) - e.g., "King of Gondor", "The Grey Pilgrim"ring_bearer(boolean, default: false)level(integer, default: 1)xp(integer, default: 0) - experience points, level up at thresholdsstrength(integer, default: 5) - base combat stat (1-20)wisdom(integer, default: 5) - base magic/strategy stat (1-20)endurance(integer, default: 5) - base resilience stat (1-20)status(enum: idle, on_quest, fallen; default: idle)
Quests
title(string, required) - e.g., "Destroy the One Ring"description(text)status(enum: pending, active, completed, failed)danger_level(integer, 1-10)region(string) - e.g., "Mordor", "Moria", "Helm's Deep"progress(decimal, default: 0.0) - completion percentage (0.0 to 100.0)success_chance(decimal) - calculated from party strength vs danger_levelquest_type(enum: campaign, random; default: campaign)campaign_order(integer, nullable) - sequence number for campaign modeattempts(integer, default: 0) - how many times this quest has been attempted
Quest Memberships (join table)
character_id(references)quest_id(references)role(string) - e.g., "Ring Bearer", "Guide", "Protector"- Constraint: a character can only belong to one active quest at a time
Artifacts
name(string, required) - e.g., "The One Ring", "Anduril", "Sting"artifact_type(string) - e.g., "Ring", "Sword", "Staff"power(text) - description of the artifact's powercorrupted(boolean, default: false)character_id(references, nullable) - current holderstat_bonus(jsonb, default: {}) - e.g.,{ "strength": 3, "wisdom": 1 }
Quest Events (log of what happens during quests)
quest_id(references)event_type(enum: started, progress, completed, failed, restarted)message(text) - narrative description of what happeneddata(jsonb) - structured event data (progress %, XP awarded, etc.)created_at(timestamp)
Simulation Config (singleton settings)
mode(enum: campaign, random; default: campaign)running(boolean, default: false)tick_interval_seconds(integer, default: 60)progress_min(decimal, default: 0.01) - min progress per tick (%)progress_max(decimal, default: 0.1) - max progress per tick (%)campaign_position(integer, default: 0) - current quest in campaign sequence
All endpoints under /api/v1/:
| Method | Path | Description |
|---|---|---|
| GET | /characters |
List all characters (paginated) |
| GET | /characters/:id |
Get character details with quests and artifacts |
| POST | /characters |
Create a character |
| PATCH | /characters/:id |
Update a character |
| DELETE | /characters/:id |
Delete a character |
| GET | /quests |
List all quests (paginated, filterable by status) |
| GET | /quests/:id |
Get quest details with members |
| POST | /quests |
Create a quest |
| PATCH | /quests/:id |
Update a quest |
| DELETE | /quests/:id |
Delete a quest |
| POST | /quests/:id/members |
Add character to quest |
| DELETE | /quests/:id/members/:character_id |
Remove character from quest |
| GET | /artifacts |
List all artifacts |
| GET | /artifacts/:id |
Get artifact details |
| POST | /artifacts |
Create an artifact |
| PATCH | /artifacts/:id |
Update an artifact |
| GET | /simulation/status |
Get simulation state |
| POST | /simulation/start |
Start the simulation |
| POST | /simulation/stop |
Stop the simulation |
| POST | /simulation/mode |
Switch campaign/random mode |
| POST | /simulation/reset |
Reset all progress (requires confirm: true) |
| GET | /quests/:id/events |
Get event log for a quest |
| GET | /events |
Get all events (paginated, filterable) |
| GET | /leaderboard |
Characters ranked by level/XP |
| POST | /palantir/send |
Send a Palantir message (SQS test) |
- Generate OpenAPI 3.1 spec from RSpec request specs (using rswag)
- Expose spec at
/api/docs.json - Mount Scalar UI at
/api/docsfor interactive API exploration - Spec includes request/response schemas, examples with LOTR data
- Seed script populating the Fellowship of the Ring, key quests, and notable artifacts
- At least 25 characters, 10 quests, and 16 artifacts
Goal: Add a GraphQL endpoint alongside REST.
- Install
graphql-ruby - Mount GraphQL endpoint at
/api/graphql - GraphiQL UI available in development
- Query types for Characters, Quests, Artifacts
- Nested queries (e.g., character -> quests -> members)
- Mutations for create/update operations
- Input validation matching REST API rules
Goal: Integrate Sidekiq (Redis) and Shoryuken (SQS) for background processing, and build the live quest simulation engine.
- Configure Sidekiq with Redis
- Add Sidekiq Web UI (mounted at
/admin/sidekiq, protected) - Configure sidekiq-cron for recurring jobs
The core game loop, driven by a recurring Sidekiq worker that ticks once per minute.
Quest Tick Worker (runs every minute when simulation is running):
- Find all quests with
status: active - For each active quest, increment
progressby a random amount betweenprogress_minandprogress_max(default 0.01% to 0.1%) - Broadcast progress update via ActionCable (Phase 5)
- Create a
QuestEventwith a narrative message (e.g., "The Fellowship crosses the Misty Mountains...") - When
progressreaches 100%:- Roll success/failure based on
success_chance - On success: mark quest completed, award full XP to all party members, level up characters that cross XP thresholds, set characters back to
idle - On failure: mark quest failed, award partial XP (25%), set characters back to
idle, reset quest (progress: 0,attempts += 1,status: pending), then immediately re-activate with same party
- Roll success/failure based on
- After resolving completed/failed quests, if there are idle characters:
- Campaign mode: activate the next quest in
campaign_ordersequence, assign its book-accurate party - Random mode: generate a random quest with random difficulty, assign a random subset of idle characters
- Campaign mode: activate the next quest in
- Recalculate
success_chancefor newly activated quests
Success Chance Formula:
party_power = sum of (character.strength + character.wisdom + character.endurance + artifact_bonuses) for each member
* level_multiplier (1 + 0.1 per level)
difficulty = danger_level * 100
success_chance = clamp(party_power / difficulty * 50, 5, 95)
Always at least 5% chance of failure and 5% chance of success.
XP and Leveling:
- XP on success:
danger_level * 100 - XP on failure:
danger_level * 25 - Level thresholds: Level N requires
N * 500total XP (Level 2 = 500 XP, Level 3 = 1000 XP, etc.) - On level up: +1 to a random stat (strength, wisdom, or endurance)
Simulation Modes:
- Campaign mode (default): Replays the LOTR story. Quests activate in
campaign_orderwith book-accurate character assignments. When the campaign completes, automatically switches to random mode. - Random mode: Endless sandbox. Generates random quests from a pool of quest templates with random difficulty and random parties from idle characters.
- Toggle between modes via
POST /api/v1/simulation/mode(switching to campaign resets and starts from the beginning)
Simulation Controls:
POST /api/v1/simulation/start- start the simulationPOST /api/v1/simulation/stop- pause the simulationPOST /api/v1/simulation/mode- switch between campaign/randomGET /api/v1/simulation/status- current state (running, mode, campaign position, tick count)POST /api/v1/simulation/reset- stop simulation, reset all characters to base stats/level 1/idle, reset all quests to pending/0 progress/0 attempts, clear quest events, reset campaign position to 0. Requires confirmation param ({ confirm: true }).
- Configure Shoryuken for SQS-backed job processing
- "Palantir Message" endpoint:
POST /api/v1/palantir/sendaccepts a message, enqueues it to SQS. Shoryuken processes the message, creates aQuestEventrecord, and the result appears in the live event feed. - For local development, use ElasticMQ (SQS-compatible) in Docker
- Provides ability to test SQS integration without AWS in local/CI
- Sidekiq cron job running every minute (separate from quest tick)
- Broadcasts a "Sauron's Gaze" event with a random update (which region Sauron is watching, threat level changes based on active quest locations)
- Delivered to connected frontends via WebSocket/SSE (Phase 5)
Goal: WebSocket/SSE support for live updates.
- Configure ActionCable with Redis adapter
- SSE endpoint as fallback:
GET /api/v1/events/stream - Frontend connects on load and displays live events
- Quest progress updates - each tick broadcasts progress for all active quests
- Quest completion/failure - broadcast when quests resolve with narrative outcome
- Character level ups - broadcast when a character gains a level
- Eye of Sauron - broadcasts from the recurring job (Phase 4.4)
- Palantir messages - Shoryuken processing notifications
- Frontend displays a live event feed with timestamps and event type filtering
Goal: OIDC-based authentication.
- Configure OmniAuth with OIDC strategy
- Support configurable OIDC provider (for different deployment environments)
- Session management with secure cookies
- Protected API endpoints require valid session or bearer token
/api/v1/auth/meendpoint returning current user info
- Login button redirecting to OIDC provider
- Callback handling and session storage
- Protected routes in TanStack Router
- User info display in header
- Logout flow
Goal: Build out the React UI for all backend features.
- Live Quest Dashboard (main page) - Real-time view of all active quests with progress bars, party members, success chance, and a scrolling event log. Simulation controls (start/stop/mode toggle) at the top.
- Characters - List view with level, stats, XP progress bar, current quest status (idle/on quest). Detail view shows quest history, level-up timeline, and equipped artifacts.
- Quests - List view filterable by status (active/completed/failed/pending). Detail view shows party, progress, attempt count, and event log for that quest.
- Artifacts - List/detail views, current holder, stat bonuses
- Palantir - Send messages (SQS test) and view processed events
- Leaderboard - Characters ranked by level/XP, quests completed count
- Quest Log - Chronological history of all quest events across the simulation
- Zustand stores for: characters, quests, artifacts, events, simulation, auth
- Real-time store updates via WebSocket subscriptions (quest progress, events)
- Zod schemas for API response validation
- Axios interceptors for auth token injection and error handling
- Vitest for unit tests
- Component tests for critical UI paths
- API client tests with mocked responses
| Decision | Choice | Rationale |
|---|---|---|
| API mode | Rails API-only + separate Vite build | Allows future frontend decoupling |
| Test framework (BE) | RSpec | Industry standard for Rails |
| Test framework (FE) | Vitest | Native Vite integration, fast |
| SQS local | ElasticMQ | Drop-in SQS replacement for dev/test |
| Real-time | ActionCable + SSE fallback | Built into Rails, SSE for simpler clients |
| OpenAPI | rswag | Test-driven spec generation with Swagger UI |
| Auth | OmniAuth + OIDC | Flexible provider support |
| Name | Race | Realm | Title |
|---|---|---|---|
| Frodo Baggins | Hobbit | The Shire | Ring Bearer |
| Samwise Gamgee | Hobbit | The Shire | Mayor of the Shire |
| Aragorn | Human | Gondor | King Elessar |
| Gandalf | Maiar | Valinor | The White |
| Legolas | Elf | Woodland Realm | Prince of Mirkwood |
| Gimli | Dwarf | Erebor | Lord of the Glittering Caves |
| Boromir | Human | Gondor | Captain of the White Tower |
| Pippin | Hobbit | The Shire | Guard of the Citadel |
| Merry | Hobbit | The Shire | Knight of Rohan |
| Eowyn | Human | Rohan | Shieldmaiden of Rohan |
| Faramir | Human | Gondor | Prince of Ithilien |
| Galadriel | Elf | Lothlorien | Lady of Light |
| Elrond | Elf | Rivendell | Lord of Rivendell |
| Saruman | Maiar | Isengard | The White (fallen) |
| Sauron | Maiar | Mordor | The Dark Lord |
| Tom Bombadil | Unknown | Old Forest | Master of Wood, Water, and Hill |
| Goldberry | Unknown | Old Forest | River-daughter |
| Glorfindel | Elf | Rivendell | Lord of the House of the Golden Flower |
| Beregond | Human | Gondor | Guard of the Citadel |
| Farmer Maggot | Hobbit | The Shire | Farmer of Bamfurlong |
| Ghân-buri-Ghân | Wild Man | Druadan Forest | Chieftain of the Woses |
| Radagast | Maiar | Rhosgobel | The Brown |
| Quickbeam (Bregalad) | Ent | Fangorn | Hastiest of Ents |
| Treebeard (Fangorn) | Ent | Fangorn | Eldest of Ents |
| Shelob | Creature | Cirith Ungol | Last Child of Ungoliant |
| Title | Status | Danger Level | Region |
|---|---|---|---|
| Destroy the One Ring | completed | 10 | Mordor |
| Defend Helm's Deep | completed | 8 | Rohan |
| Retake Moria | failed | 9 | Moria |
| Scouring of the Shire | completed | 5 | The Shire |
| Hunt for Gollum | active | 6 | Wilderness |
| Escape the Old Forest | completed | 4 | Old Forest |
| Assault on the Black Gate | completed | 9 | Mordor |
| March of the Ents | completed | 7 | Isengard |
| Passage of the Paths of the Dead | completed | 8 | White Mountains |
| Rescue from Cirith Ungol | completed | 9 | Mordor |
| Name | Type | Corrupted |
|---|---|---|
| The One Ring | Ring | true |
| Anduril (Flame of the West) | Sword | false |
| Narsil (shards) | Sword | false |
| Sting | Sword | false |
| Glamdring (Foe-hammer) | Sword | false |
| Orcrist (Goblin-cleaver) | Sword | false |
| Nenya (Ring of Adamant) | Ring | false |
| Vilya (Ring of Air) | Ring | false |
| Narya (Ring of Fire) | Ring | false |
| Palantir of Orthanc | Seeing Stone | true |
| Palantir of Minas Tirith | Seeing Stone | false |
| Phial of Galadriel | Light | false |
| Mithril Coat | Armor | false |
| Horn of Gondor | Horn | false |
| Red Book of Westmarch | Book | false |
| Barrow-blade (Merry's) | Dagger | false |