Delve — Technical Architecture
Table of Contents
- Architecture Overview
- Technology Stack
- Client Architecture
- Server Architecture
- Database Design
- Game Systems — Server
- Game Systems — Client
- Polling & Notifications
- Chat — Discord Integration
- Mobile Wrapping
- Infrastructure & DevOps
- Security
- Performance Targets
1. Architecture Overview
Delve is a server-authoritative async idle MMO rendered as a web application and wrapped for mobile distribution. Because combat is never real-time (players configure a skill queue, the server resolves it), the architecture optimizes for throughput of simulation ticks and low-frequency but reliable client updates rather than sub-100ms latency.
┌─────────────────────────────────────────────────────┐
│ CLIENTS │
│ Browser (SPA) · iOS (Capacitor) · Android │
└──────────┬──────────────────────────────┬───────────┘
│ HTTPS (REST + polling) │ Push
▼ ▼
┌─────────────────────┐ ┌────────────────────────┐
│ API Gateway / │ │ Push Notification │
│ Load Balancer │ │ Service (FCM / APNs) │
│ (Caddy) │ └────────────────────────┘
└──────────┬──────────┘
│ ┌────────────────────────┐
▼ │ Discord │
┌─────────────────────┐ │ (Chat, community, │
│ REST API Servers │ │ LFG, guild comms) │
└──────────┬──────────┘ └────────────────────────┘
│
┌──────────┴──────────────────────────────────────────┐
│ BullMQ Job Queue (Redis-backed) │
└──────┬────────────┬────────────┬────────────────────┘
▼ ▼ ▼
┌────────────┐ ┌──────────┐ ┌──────────────┐
│ Simulation│ │ Economy │ │ PVP Match │
│ Workers │ │ Workers │ │ Workers │
└────────────┘ └──────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Data Layer │
│ PostgreSQL · Redis · Backblaze B2 │
└─────────────────────────────────────────────────────┘
Design Principles
- Server-authoritative: All game state mutations happen server-side. The client is a view layer.
- Async-first: Most gameplay resolves on the server without a connected client.
- REST + polling, no WebSockets: The game is inherently async — players wait minutes to hours for results. Polling every 30–60s is perfectly adequate and dramatically simplifies the server (no persistent connections, no connection state, no reconnect logic).
- Discord for chat: Community, guild coordination, LFG, and trade chat all happen on Discord. This is where the community already lives, and it eliminates an entire real-time system from the codebase.
- Horizontally scalable: Stateless API servers; sharded workers keyed by character/guild ID.
- Offline-tolerant: Runs, crafting, and gathering proceed whether the player is online or not.
2. Technology Stack
Client
| Layer | Technology | Rationale |
|---|---|---|
| UI Framework | SvelteKit (SPA mode) | Small bundle size (~30KB framework), fast reactivity, excellent mobile perf. SPA mode since all logic is server-side. |
| Rendering | HTML/CSS + PixiJS (optional) | Most of Delve is UI-driven (queues, inventories, logs). PixiJS available for animated run replays and map rendering. |
| State Management | Svelte stores + TanStack Query | Stores for local UI state; TanStack Query for server state caching, deduplication, and background refetching. |
| Styling | Tailwind CSS | Utility-first, tree-shakes to small bundle. Fantasy theme via design tokens. |
| Mobile Wrapper | Capacitor | Web-to-native bridge for iOS/Android. Access to push notifications, haptics, secure storage. |
| Build | Vite | Fast HMR in dev, optimized production builds with code splitting. |
Server
| Layer | Technology | Rationale |
|---|---|---|
| Language | Rust | High performance for the simulation engine. Strong type system catches bugs at compile time. Single binary deploys. Memory safety without GC pauses. |
| API Framework | Axum | Tokio-based, ergonomic extractors, tower middleware ecosystem. The standard choice for Rust web services. |
| Task Scheduling | Custom Redis-backed job queue (via redis crate) | Delayed jobs for run completion, crafting timers, gathering expeditions, raid scheduling, auction expiry. Rust doesn’t have a BullMQ equivalent — a simple custom queue on Redis ZADD/ZPOPMIN with timestamps is sufficient and avoids a heavy dependency. |
| ORM / Query | SQLx | Compile-time checked SQL queries against the real database. No ORM overhead — write SQL directly with type-safe results. Async Postgres driver built-in. |
| Serialization | serde + serde_json | Industry-standard Rust serialization. Used for API request/response bodies, JSONB fields, and game data definitions. |
| Validation | validator crate + custom types | Derive-based validation on request structs. Newtype pattern for domain-specific constraints (e.g., AttributeValue(u8) that enforces 1–99 range). |
| Auth | argon2 crate + custom session middleware | Session-based auth with Argon2id password hashing. Sessions stored in Redis. OAuth2 via oauth2 crate for social logins. |
Data
| Layer | Technology | Rationale |
|---|---|---|
| Primary DB | PostgreSQL 16 | JSONB for flexible item properties, strong indexing, reliable ACID transactions for economy. |
| Cache / Jobs | Redis 7 (Valkey) | Session store, leaderboard sorted sets, rate limiting, job queue backing store. |
| Object Storage | S3-compatible (Backblaze B2) | Run replay logs, seasonal assets, user avatars. Accessed via aws-sdk-s3 crate. |
| Search (future) | Meilisearch | Marketplace full-text search, bestiary/recipe lookup. |
Infrastructure
| Layer | Technology | Rationale |
|---|---|---|
| Containers | Docker + Docker Compose (dev), Kubernetes (prod) | Single binary per service → tiny Docker images (~10–20MB with FROM scratch or Alpine). |
| Reverse Proxy | Caddy | Automatic HTTPS, HTTP/2, simple config. |
| CI/CD | GitHub Actions | Build, test, deploy pipeline. Rust builds cached via sccache or cargo-chef Docker layer. |
| Monitoring | Prometheus + Grafana | Metrics exported via metrics + metrics-exporter-prometheus crates. |
| Logging | tracing + tracing-subscriber → JSON → Loki | Structured logging with spans. The tracing ecosystem is Rust’s standard for observability. |
| Error Tracking | Sentry (sentry-rust crate) | Server-side panic/error capture. Client errors via Sentry JS SDK. |
3. Client Architecture
3.1 Application Structure
src/
├── lib/
│ ├── api/ # API client, polling, query hooks
│ │ ├── client.ts # Hono RPC typed client (end-to-end type safety)
│ │ └── queries/ # TanStack Query definitions per domain (with polling intervals)
│ ├── stores/ # Svelte stores for client-side state
│ │ ├── auth.ts # Current user session
│ │ ├── notifications.ts
│ │ └── ui.ts # Theme, sidebar state, modals
│ ├── components/ # Reusable UI components
│ │ ├── character/ # Character sheet, skill queue builder
│ │ ├── combat/ # Run replay viewer, encounter log
│ │ ├── inventory/ # Gear grid, item tooltips, drag-and-drop
│ │ ├── marketplace/ # Listings, search, buy/sell flows
│ │ ├── social/ # Guild panel, friends list, Discord links
│ │ └── ui/ # Buttons, modals, toasts, progress bars
│ ├── game/ # Client-side game logic
│ │ ├── tooltips.ts # Stat calculation for item/skill tooltips
│ │ ├── timers.ts # Countdown display for active runs/crafts
│ │ └── constants.ts # Shared enums, rarity colors, etc.
│ └── types/ # TypeScript types matching the Rust API contract
│ ├── character.ts # Character, Attributes, Species, Class
│ ├── item.ts # Item, Rarity, ItemLocation, BonusProperty
│ ├── skill.ts # SkillQueueSlot, QueueCondition
│ ├── combat.ts # RunLog, EncounterLog, ActionLog
│ └── api.ts # Request/response types per endpoint
├── routes/
│ ├── (auth)/ # Login, register, password reset
│ ├── (game)/ # Main game layout wrapper
│ │ ├── character/ # Character sheet, progression, skill queue
│ │ ├── quest-board/ # Available quests, active runs
│ │ ├── inventory/ # Gear, backpack, bank
│ │ ├── crafting/ # Profession UIs, recipe browser
│ │ ├── marketplace/ # Auction house
│ │ ├── guild/ # Guild management, lobby scheduling
│ │ ├── pvp/ # Arena queue, leaderboards
│ │ ├── world/ # Map, factions, bestiary
│ │ └── settings/ # Account, notifications, appearance
│ └── +layout.svelte # Root layout with nav, polling init
└── app.html
3.2 Key Client Patterns
Server State vs. Client State: All game data (character stats, inventory, active runs) is server state managed via TanStack Query. The client never computes authoritative game values — it only displays them. Client state is limited to UI concerns (which tab is open, tooltip position, theme preference).
Optimistic Updates: For low-risk actions (equipping gear, reordering skill queue), the client optimistically updates the UI and rolls back on server rejection. For economy actions (marketplace buy, gold transfer), the client waits for server confirmation.
Polling Strategy: TanStack Query handles all server state polling. Different data types poll at different intervals based on how time-sensitive they are:
| Data | Poll Interval | Rationale |
|---|---|---|
| Active runs/crafts/gathering | 60s | Client shows countdown from completesAt — only needs to poll to detect completion |
| Notifications (in-app) | 30s | GET /api/characters/:id/notifications — new completions, mail, PVP results |
| Marketplace listings | 60s | Not urgent — player checks when ready |
| Party/raid lobby status | 15s | More time-sensitive when coordinating group content |
| PVP queue status | 10s | Needs faster feedback when waiting for a match |
| Everything else | On demand | Fetched when the player navigates to that screen |
When a countdown timer reaches zero, the client immediately refetches that resource (rather than waiting for the next poll interval) to show the result as soon as it’s available.
Timer Display: Active runs, crafts, and gathering expeditions show countdown timers. The client calculates display time from startedAt + duration timestamps provided by the server. No client-side simulation of progress — just a countdown to the known completion time. On timer expiry, TanStack Query refetches the resource immediately.
Run Replay Viewer: When a dungeon run completes, the server stores a structured log of every encounter, roll, and outcome. The client renders this as a scrollable timeline with expandable encounter cards. Optional PixiJS layer for animated combat playback.
3.3 Responsive Design
The game targets three breakpoints:
| Breakpoint | Width | Layout |
|---|---|---|
| Mobile | < 640px | Single column, bottom tab navigation, stacked panels |
| Tablet | 640–1024px | Two-column with collapsible sidebar |
| Desktop | > 1024px | Three-column with persistent sidebar and detail panel |
Touch-first interaction design: all drag-and-drop (inventory, skill queue) uses pointer events with touch support. No hover-dependent interactions — tooltips trigger on tap-and-hold on mobile.
4. Server Architecture
4.1 Service Topology
The server is a Rust workspace (Cargo monorepo) deployed as multiple binary targets from a single codebase. This avoids microservice complexity while allowing independent scaling of compute-heavy workers. All binaries share common crates for game logic, database access, and types.
delve-server/
├── Cargo.toml # Workspace root
├── crates/
│ ├── api/ # REST API server binary
│ │ ├── src/
│ │ │ ├── main.rs # Axum server entry point
│ │ │ ├── routes/ # Route handlers organized by domain
│ │ │ │ ├── auth.rs
│ │ │ │ ├── characters.rs
│ │ │ │ ├── quests.rs
│ │ │ │ ├── inventory.rs
│ │ │ │ ├── crafting.rs
│ │ │ │ ├── marketplace.rs
│ │ │ │ ├── guilds.rs
│ │ │ │ ├── pvp.rs
│ │ │ │ ├── factions.rs
│ │ │ │ ├── social.rs
│ │ │ │ ├── notifications.rs
│ │ │ │ └── admin.rs
│ │ │ ├── middleware/ # Auth, rate limiting, validation, tracing
│ │ │ └── extractors.rs # Custom Axum extractors (AuthUser, ValidatedJson, etc.)
│ │ └── Cargo.toml
│ ├── workers/ # Background job processor binary
│ │ ├── src/
│ │ │ ├── main.rs # Worker entry point — registers job handlers, polls Redis queue
│ │ │ ├── simulation/ # Dungeon run resolver
│ │ │ │ ├── engine.rs # Core d100 combat engine
│ │ │ │ ├── encounters.rs
│ │ │ │ ├── skill_queue.rs
│ │ │ │ ├── conditions.rs
│ │ │ │ └── loot.rs
│ │ │ ├── economy.rs # Marketplace matching, auction expiry
│ │ │ ├── crafting.rs # Craft completion, critical craft rolls
│ │ │ ├── gathering.rs # Expedition completion, yield calculation
│ │ │ ├── pvp.rs # Arena match resolution, ELO updates
│ │ │ ├── guild.rs # Guild XP tallying, buff expiry
│ │ │ └── scheduled.rs # Daily reset, weekly rotation, season transitions
│ │ └── Cargo.toml
│ ├── game/ # Core game logic library (shared by api + workers)
│ │ ├── src/
│ │ │ ├── combat.rs # Damage formulas, hit chance, crit calculation
│ │ │ ├── progression.rs # XP curves, level thresholds, feat unlocks
│ │ │ ├── economy.rs # Tax rates, vendor prices, inflation formulas
│ │ │ ├── loot_tables.rs # Drop rates, rarity weights per content tier
│ │ │ ├── time.rs # Duration calculations for runs, crafts, gathering
│ │ │ └── rng.rs # Seeded deterministic RNG (ChaCha8Rng)
│ │ └── Cargo.toml
│ ├── db/ # Database layer (shared)
│ │ ├── src/
│ │ │ ├── lib.rs # Connection pool (sqlx::PgPool), migrations
│ │ │ ├── models/ # Row types (FromRow derives)
│ │ │ ├── queries/ # SQLx query functions per domain
│ │ │ └── migrations/ # SQL migration files (sqlx migrate)
│ │ └── Cargo.toml
│ ├── types/ # Shared types, enums, constants
│ │ ├── src/
│ │ │ ├── character.rs
│ │ │ ├── item.rs
│ │ │ ├── skill.rs
│ │ │ ├── quest.rs
│ │ │ ├── combat.rs
│ │ │ └── ids.rs # Typed ID wrappers (CharacterId, ItemId, etc.)
│ │ └── Cargo.toml
│ └── jobs/ # Job queue abstraction (shared)
│ ├── src/
│ │ ├── lib.rs # Redis-backed delayed job queue
│ │ ├── enqueue.rs # Enqueue jobs with optional delay
│ │ └── process.rs # Poll and process jobs
│ └── Cargo.toml
├── data/ # Static game data (TOML/RON files, compiled into binary)
│ ├── items/ # Item template definitions
│ ├── quests/ # Quest and dungeon definitions
│ ├── creatures/ # Enemy stat blocks
│ ├── skills/ # Skill definitions
│ ├── recipes/ # Crafting recipes
│ └── loot_tables/ # Loot table definitions
└── Dockerfile # Multi-stage: build with rust image, run with scratch/alpine
Why a Cargo Workspace
- Single compilation unit for shared code: The
game,db,types, andjobscrates are compiled once and shared by both theapiandworkersbinaries. - Compile-time guarantees: SQLx checks queries against the real database schema at compile time. Type mismatches between API routes and database models are caught before deployment.
- Two binaries, one repo:
cargo build --bin apiandcargo build --bin workersproduce independent binaries that can be deployed and scaled separately. - Static game data: Item templates, quests, creatures, and loot tables are defined as TOML or RON files in the
data/directory and loaded at startup (or compiled in viainclude_str!). Balance changes are code changes — reviewed in PRs, versioned in git.
4.2 Process Types
| Process | Scaling | Role |
|---|---|---|
delve-api | Horizontal (2+ instances behind load balancer) | REST endpoints, request validation, auth, notification polling. Axum binary. |
delve-workers | Horizontal (scale by queue depth) | Polls Redis job queue, dispatches to simulation/economy/pvp/crafting handlers. Single binary handles all job types — scaling is just running more instances. |
delve-workers --scheduler | Single instance | Same binary with a flag to also run cron-like triggers: daily resets, weekly rotations, season transitions. Only one instance runs scheduled jobs (Redis-based leader election). |
Workers handle all background job types in a single binary. The economy queue is processed serially (single consumer) to prevent race conditions, while simulation/pvp/crafting jobs are processed concurrently across all worker instances.
4.3 Request Flow Example — Start a Dungeon Run
1. Client POST /api/quests/start
Body: { characterId, questId, skillQueue, loadout, supplies }
2. API Server:
a. Validate session → get userId
b. Validate character belongs to user, is not already in a run
c. Validate skill queue (skills owned, correct order, conditionals valid)
d. Validate loadout (gear owned, correct slots)
e. Validate supplies (owned, within slot limits)
f. Calculate run duration based on quest + Patron status
g. Deduct supplies from inventory
h. Create run record in DB (status: "in_progress", completesAt: now + duration)
i. Enqueue BullMQ delayed job: "resolve-run" with delay = duration
j. Return { runId, completesAt } to client
3. [Time passes — player may be offline]
4. BullMQ triggers "resolve-run" job at completesAt:
a. Simulation Worker picks up job
b. Load run record, character snapshot, quest definition
c. For each encounter in quest:
- Roll initiative for all participants
- Execute skill queues round-by-round (d100 rolls, conditional checks)
- Apply damage, healing, conditions
- Check for death/completion
- If rest point: restore resources per rules
- Log every action and roll to run_log JSONB
d. Calculate loot drops from completed encounters
e. Calculate XP and gold earned
f. Write results: run status, loot, XP, gold, run_log
g. Insert notification record: { characterId, type: "run_complete", runId }
h. If character has push notifications enabled and is offline:
- Send push notification via FCM/APNs: "Your dungeon run is complete!"
5. Client discovers result via one of:
- Poll: GET /api/characters/:id/notifications returns the "run_complete" event
- Timer: client countdown hits zero → immediate refetch of run status
- Push: mobile notification tapped → app opens to run result screen
5. Database Design
5.1 PostgreSQL Schema (Key Tables)
Data-driven convention: All game content references (species, class, weapon type, rarity, faction, etc.) are stored as TEXT columns containing data IDs validated by the application against the GameData registry. No Postgres ENUM types are used for game content — this means adding new content never requires a database migration.
-- ==================== ACCOUNTS ====================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
patron_tier SMALLINT DEFAULT 0, -- 0=free, 1=patron
patron_expires TIMESTAMPTZ,
character_slots SMALLINT DEFAULT 2,
bank_slots SMALLINT DEFAULT 50,
marketplace_slots SMALLINT DEFAULT 10,
created_at TIMESTAMPTZ DEFAULT now(),
last_login TIMESTAMPTZ
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
);
-- ==================== CHARACTERS ====================
CREATE TABLE characters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name TEXT UNIQUE NOT NULL,
species TEXT NOT NULL, -- data ID, validated by app against GameData registry
class TEXT NOT NULL, -- data ID, validated by app against GameData registry
subclass TEXT, -- data ID, NULL until level 10
background TEXT NOT NULL,
level SMALLINT DEFAULT 1,
xp INTEGER DEFAULT 0,
paragon_level INTEGER DEFAULT 0, -- post-50 progression
paragon_xp BIGINT DEFAULT 0,
-- Core attributes (base values before gear/buffs)
might SMALLINT NOT NULL,
logic SMALLINT NOT NULL,
speed SMALLINT NOT NULL,
presence SMALLINT NOT NULL,
fortitude SMALLINT NOT NULL,
luck SMALLINT NOT NULL,
-- Non-combat skills (0-100)
skill_athletics SMALLINT DEFAULT 0,
skill_acrobatics SMALLINT DEFAULT 0,
skill_stealth SMALLINT DEFAULT 0,
skill_perception SMALLINT DEFAULT 0,
skill_arcana SMALLINT DEFAULT 0,
skill_nature SMALLINT DEFAULT 0,
skill_religion SMALLINT DEFAULT 0,
skill_persuasion SMALLINT DEFAULT 0,
skill_deception SMALLINT DEFAULT 0,
skill_intimidation SMALLINT DEFAULT 0,
skill_medicine SMALLINT DEFAULT 0,
skill_survival SMALLINT DEFAULT 0,
-- Currency
gold BIGINT DEFAULT 0,
-- Rested bonus (accumulated offline XP multiplier)
rested_bonus REAL DEFAULT 0.0, -- 0.0 to 0.5
last_active TIMESTAMPTZ DEFAULT now(),
-- Chosen feats (JSONB array of feat IDs)
feats JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT valid_attributes CHECK (
might BETWEEN 1 AND 99 AND logic BETWEEN 1 AND 99 AND
speed BETWEEN 1 AND 99 AND presence BETWEEN 1 AND 99 AND
fortitude BETWEEN 1 AND 99 AND luck BETWEEN 1 AND 99
)
);
CREATE INDEX idx_characters_user ON characters(user_id);
CREATE INDEX idx_characters_name ON characters(name);
-- ==================== EQUIPMENT & INVENTORY ====================
CREATE TABLE items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID REFERENCES characters(id) ON DELETE CASCADE,
template_id TEXT NOT NULL, -- data ID, references item template in GameData
rarity TEXT NOT NULL, -- data ID, references rarity tier in GameData
enhancement SMALLINT DEFAULT 0, -- +0 to +5
bonus_properties JSONB DEFAULT '[]', -- random rolled properties for rare+
location TEXT NOT NULL, -- 'equipped:main_hand', 'backpack', 'bank', 'mail'
slot_index SMALLINT, -- position within location
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_items_owner ON items(owner_id);
CREATE INDEX idx_items_owner_location ON items(owner_id, location);
-- Artifact tracking (server-unique items)
CREATE TABLE artifacts (
template_id TEXT PRIMARY KEY,
held_by UUID REFERENCES characters(id),
acquired_at TIMESTAMPTZ
);
-- ==================== SKILL QUEUE ====================
CREATE TABLE skill_queues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
name TEXT DEFAULT 'Default',
is_active BOOLEAN DEFAULT false,
slots JSONB NOT NULL, -- ordered array of { skillId, condition? }
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_skill_queues_char ON skill_queues(character_id);
-- ==================== WEAPON PROFICIENCY ====================
CREATE TABLE weapon_proficiencies (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
weapon_type TEXT NOT NULL, -- sword, axe, bow, staff, etc.
proficiency SMALLINT DEFAULT 0, -- 0-100
PRIMARY KEY (character_id, weapon_type)
);
-- ==================== RUNS & QUESTS ====================
CREATE TABLE runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
quest_id TEXT NOT NULL, -- references static quest/dungeon definition
difficulty TEXT NOT NULL,
status TEXT DEFAULT 'in_progress', -- in_progress, completed, failed, abandoned
skill_queue JSONB NOT NULL, -- snapshot of queue at run start
loadout JSONB NOT NULL, -- snapshot of equipped gear at run start
supplies JSONB NOT NULL, -- snapshot of supplies consumed
party_id UUID, -- NULL for solo, references party for group content
started_at TIMESTAMPTZ DEFAULT now(),
completes_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
run_log JSONB, -- full encounter-by-encounter replay log
rewards JSONB, -- { xp, gold, items[], proficiencyGains }
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_runs_character ON runs(character_id);
CREATE INDEX idx_runs_status ON runs(status) WHERE status = 'in_progress';
CREATE INDEX idx_runs_completes ON runs(completes_at) WHERE status = 'in_progress';
-- ==================== CRAFTING & GATHERING ====================
CREATE TABLE crafting_proficiencies (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
profession TEXT NOT NULL, -- blacksmithing, alchemy, etc.
skill_level SMALLINT DEFAULT 1, -- 1-100
PRIMARY KEY (character_id, profession)
);
CREATE TABLE crafting_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
recipe_id TEXT NOT NULL,
status TEXT DEFAULT 'in_progress',
started_at TIMESTAMPTZ DEFAULT now(),
completes_at TIMESTAMPTZ NOT NULL,
result_item_id UUID, -- set on completion
is_critical BOOLEAN
);
CREATE INDEX idx_crafting_jobs_char ON crafting_jobs(character_id);
CREATE TABLE gathering_expeditions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
profession TEXT NOT NULL, -- mining, herbalism, logging, skinning
zone TEXT NOT NULL,
tier SMALLINT NOT NULL, -- 1-5
status TEXT DEFAULT 'in_progress',
started_at TIMESTAMPTZ DEFAULT now(),
completes_at TIMESTAMPTZ NOT NULL,
yields JSONB -- set on completion: [{ materialId, quantity }]
);
CREATE TABLE known_recipes (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
recipe_id TEXT NOT NULL,
learned_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (character_id, recipe_id)
);
-- ==================== MARKETPLACE ====================
CREATE TABLE marketplace_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id UUID REFERENCES characters(id) ON DELETE CASCADE,
item_id UUID REFERENCES items(id),
price BIGINT NOT NULL,
listing_fee BIGINT NOT NULL, -- 5% deducted at listing time
status TEXT DEFAULT 'active', -- active, sold, expired, cancelled
listed_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL, -- listed_at + 48 hours
sold_to UUID REFERENCES characters(id),
sold_at TIMESTAMPTZ
);
CREATE INDEX idx_marketplace_status ON marketplace_listings(status) WHERE status = 'active';
CREATE INDEX idx_marketplace_expires ON marketplace_listings(expires_at) WHERE status = 'active';
CREATE INDEX idx_marketplace_seller ON marketplace_listings(seller_id);
-- ==================== MAIL ====================
CREATE TABLE mail (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sender_id UUID REFERENCES characters(id),
recipient_id UUID REFERENCES characters(id) ON DELETE CASCADE,
subject TEXT,
body TEXT,
gold_amount BIGINT DEFAULT 0,
item_ids UUID[], -- items attached
is_read BOOLEAN DEFAULT false,
deliverable_at TIMESTAMPTZ NOT NULL, -- sent_at + 1 hour
sent_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ -- auto-delete after 30 days
);
CREATE INDEX idx_mail_recipient ON mail(recipient_id);
-- ==================== GUILDS ====================
CREATE TABLE guilds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT UNIQUE NOT NULL,
tag TEXT UNIQUE NOT NULL, -- 2-4 char tag
leader_id UUID REFERENCES characters(id),
level SMALLINT DEFAULT 1,
xp BIGINT DEFAULT 0,
bank_gold BIGINT DEFAULT 0,
active_buff TEXT, -- current guild buff ID
buff_expires TIMESTAMPTZ,
max_members SMALLINT DEFAULT 50, -- scales with guild level, max 200
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE guild_members (
guild_id UUID REFERENCES guilds(id) ON DELETE CASCADE,
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
rank TEXT DEFAULT 'member', -- leader, officer, member, recruit
joined_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (guild_id, character_id)
);
CREATE INDEX idx_guild_members_char ON guild_members(character_id);
-- ==================== FACTIONS & REPUTATION ====================
CREATE TABLE character_reputation (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
faction_id TEXT NOT NULL, -- iron_compact, shadow_court, etc.
reputation INTEGER DEFAULT 0, -- -3000 to 21000+
PRIMARY KEY (character_id, faction_id)
);
-- ==================== PVP ====================
CREATE TABLE pvp_ratings (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
bracket TEXT NOT NULL, -- '1v1', '3v3'
rating INTEGER DEFAULT 1000, -- ELO
season SMALLINT NOT NULL,
wins INTEGER DEFAULT 0,
losses INTEGER DEFAULT 0,
PRIMARY KEY (character_id, bracket, season)
);
CREATE TABLE pvp_matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bracket TEXT NOT NULL,
season SMALLINT NOT NULL,
team_a UUID[] NOT NULL, -- character IDs
team_b UUID[] NOT NULL,
winner TEXT, -- 'a', 'b', 'draw'
match_log JSONB,
rating_changes JSONB, -- { charId: delta }
resolved_at TIMESTAMPTZ DEFAULT now()
);
-- ==================== SOCIAL ====================
CREATE TABLE friends (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
friend_id UUID REFERENCES characters(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending', -- pending, accepted
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (character_id, friend_id)
);
-- ==================== PARTIES & RAIDS ====================
CREATE TABLE parties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
leader_id UUID REFERENCES characters(id),
type TEXT NOT NULL, -- duo, standard, raid
max_size SMALLINT NOT NULL, -- 2, 4, 8
status TEXT DEFAULT 'forming', -- forming, ready, in_run, completed
quest_id TEXT,
scheduled_at TIMESTAMPTZ, -- for timed lobbies
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE party_members (
party_id UUID REFERENCES parties(id) ON DELETE CASCADE,
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
role TEXT, -- tank, healer, dps, support
ready BOOLEAN DEFAULT false,
PRIMARY KEY (party_id, character_id)
);
-- ==================== ACHIEVEMENTS & COLLECTIONS ====================
CREATE TABLE character_achievements (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
achievement_id TEXT NOT NULL,
earned_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (character_id, achievement_id)
);
CREATE TABLE character_bestiary (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
creature_id TEXT NOT NULL,
kills INTEGER DEFAULT 0,
first_killed TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (character_id, creature_id)
);
-- ==================== DAILY/WEEKLY TRACKING ====================
CREATE TABLE daily_tracking (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
date DATE NOT NULL DEFAULT CURRENT_DATE,
first_run_bonus BOOLEAN DEFAULT false,
bounties_completed SMALLINT DEFAULT 0,
bounty_chest_claimed BOOLEAN DEFAULT false,
faction_quests JSONB DEFAULT '{}', -- { factionId: count }
PRIMARY KEY (character_id, date)
);
CREATE TABLE weekly_tracking (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
week_start DATE NOT NULL, -- Monday of the week
challenge_completed BOOLEAN DEFAULT false,
raid_tokens_earned INTEGER DEFAULT 0,
PRIMARY KEY (character_id, week_start)
);
-- ==================== SEASONAL ====================
CREATE TABLE season_progress (
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
season_id TEXT NOT NULL,
track TEXT NOT NULL, -- 'free' or 'premium'
tier_reached SMALLINT DEFAULT 0,
xp INTEGER DEFAULT 0,
PRIMARY KEY (character_id, season_id)
);
5.2 Static Game Data
Item templates, quest definitions, skill data, creature stats, recipes, and loot tables are not stored in PostgreSQL. They are defined as TOML files in the data/ directory, deserialized into Rust structs at startup via serde, and versioned with the codebase. This keeps game balance changes in source control and avoids DB migrations for tuning.
# data/items/iron_longsword.toml
[iron_longsword]
name = "Iron Longsword"
type = "weapon"
subtype = "sword"
slot = "main_hand"
damage = [8, 14]
speed = 1.0
level_req = 1
rarity = "common"
# data/quests/goblin_warren.toml
[goblin_warren]
name = "Goblin Warren"
type = "bounty"
level_range = [1, 5]
base_duration_secs = 1800 # 30 minutes
loot_table = "goblin_bounty_t1"
[[goblin_warren.encounters]]
type = "combat"
enemies = ["goblin_scout", "goblin_scout"]
# ...
#![allow(unused)]
fn main() {
// crates/game/src/data.rs
use once_cell::sync::Lazy;
use std::collections::HashMap;
pub static ITEMS: Lazy<HashMap<String, ItemTemplate>> = Lazy::new(|| {
load_toml_dir("data/items")
});
pub static QUESTS: Lazy<HashMap<String, QuestDefinition>> = Lazy::new(|| {
load_toml_dir("data/quests")
});
}
5.3 Redis Data Structures
# Session store
session:{sessionId} → JSON { userId, expiresAt } TTL: 30 days
# Leaderboards (sorted sets)
leaderboard:pvp:1v1:season:{n} → ZADD score=rating member=charId
leaderboard:pvp:3v3:season:{n} → ZADD score=rating member=charId
leaderboard:achievements → ZADD score=points member=charId
leaderboard:wealth → ZADD score=gold member=charId
# Rate limiting
ratelimit:{userId}:{endpoint} → counter TTL: window duration
# Active PVP queue
pvp:queue:1v1 → sorted set (score=rating, member=charId)
pvp:queue:3v3 → sorted set (score=rating, member=charId+teamId)
# BullMQ job queues (managed by BullMQ internally)
bull:resolve-run:*
bull:marketplace-buy:*
bull:pvp-resolve:*
bull:resolve-craft:*
bull:resolve-gathering:*
6. Game Systems — Server
6.1 Simulation Engine (Combat Resolution)
The simulation engine is the core of Delve. It runs entirely on the server with no real-time client involvement.
#![allow(unused)]
fn main() {
// crates/workers/src/simulation/engine.rs
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
pub struct SimulationContext {
pub run: Run,
pub party: Vec<CharacterSnapshot>, // character stats + gear + queue at run start
pub quest: QuestDefinition,
pub rng: ChaCha8Rng, // deterministic seeded RNG
pub log: RunLogBuilder,
}
pub fn resolve_run(ctx: &mut SimulationContext) -> RunResult {
for encounter in &ctx.quest.encounters {
let result = resolve_encounter(ctx, encounter);
ctx.log.add_encounter(&result);
if result.outcome == EncounterOutcome::PartyWipe {
return RunResult {
status: RunStatus::Failed,
log: ctx.log.build(),
failed_at: Some(encounter.index),
rewards: None,
};
}
}
let loot = roll_loot(ctx);
let xp = calculate_xp(ctx);
RunResult {
status: RunStatus::Completed,
log: ctx.log.build(),
failed_at: None,
rewards: Some(RunRewards { loot, xp }),
}
}
}
Deterministic RNG: Each run is seeded with a ChaCha8Rng initialized from a seed stored in the run record. This means any run can be replayed identically for debugging or dispute resolution. The seed is derived from blake3::hash(run_id + started_at). ChaCha8 is fast and produces identical output across platforms — critical for deterministic simulation.
Encounter Resolution Loop:
- Sort all participants by initiative (Speed + weapon speed modifier + d100 roll)
- For each round (max 50 rounds per encounter):
- For each participant in initiative order:
- Evaluate skill queue: find first skill whose conditions are met and resources available
- Roll d100 against success chance (derived from attacker stats vs. defender stats)
- Apply effects: damage, healing, conditions, resource cost
- Check for death/incapacitation
- Tick conditions (poison damage, buff/debuff duration)
- Check encounter end conditions
- For each participant in initiative order:
- Log all rolls and outcomes
6.2 Economy Worker
The economy worker handles marketplace transactions with serialized processing to prevent race conditions.
Marketplace Buy Flow:
1. API validates buyer has enough gold
2. API enqueues "marketplace.buy" job (NOT direct DB update)
3. Economy worker (single instance) processes:
a. BEGIN TRANSACTION
b. Verify listing still active (SELECT FOR UPDATE)
c. Verify buyer gold >= price (SELECT FOR UPDATE on buyer character)
d. Transfer gold: buyer -= price, seller += (price - 10% tax)
e. Transfer item: update item.owner_id, item.location = 'mail'
f. Create mail record for seller (gold received notification)
g. Update listing status = 'sold'
h. COMMIT
4. Insert notification records for buyer ("item purchased") and seller ("item sold")
6.3 PVP Match Resolution
Arena matches resolve instantly (no wait timer) using the same simulation engine as PvE, but with stat normalization applied.
PVP Flow:
1. Player enters arena queue → added to Redis sorted set by rating
2. Matchmaker (runs every 5 seconds):
a. Scan queue for viable matches (rating within ±150, expanding over time)
b. Pop matched players from queue
c. Enqueue "pvp.resolve" job (immediate, no delay)
3. PVP Worker resolves match:
a. Snapshot both characters with PVP stat normalization
b. Run simulation engine (same as PvE but PVP-specific encounter rules)
c. Calculate ELO changes
d. Write match result + rating updates
e. Insert notification records for both players
4. Both players discover result via polling (PVP poll interval: 10s) or push notification
6.4 Scheduled Jobs
| Job | Schedule | Description |
|---|---|---|
daily-reset | 00:00 UTC | Reset daily bounties, first-run bonus, faction quest counts |
weekly-reset | Monday 00:00 UTC | Rotate weekly challenge, reset raid token caps |
auction-expiry | Every 5 min | Expire stale marketplace listings, return items via mail |
rested-bonus-tick | Every 1 hour | Increment rested bonus for offline characters |
season-transition | Manual trigger | End current season, archive ratings, distribute rewards |
guild-buff-expiry | Every 1 min | Expire guild buffs past their duration |
mail-cleanup | Daily | Delete read mail older than 30 days |
pvp-matchmaker | Every 5 sec | Scan PVP queue and create matches |
7. Game Systems — Client
7.1 Skill Queue Builder
The skill queue builder is the most complex client-side UI. Players drag-and-drop skills into an ordered list and configure optional conditions.
┌─────────────────────────────────────────────┐
│ Skill Queue: "Boss Rush Build" [Save]│
├─────────────────────────────────────────────┤
│ 1. [🗡️ Power Strike] always │
│ 2. [🛡️ Shield Wall] if HP < 50% │
│ 3. [⚡ Cleave] if enemies > 2 │
│ 4. [❤️ Second Wind] if HP < 30% │
│ 5. [🗡️ Execute] if target HP < 20%│
│ 6. [🗡️ Basic Attack] always (fallback) │
├─────────────────────────────────────────────┤
│ Available Skills: [Drag to add] │
│ [Whirlwind] [Taunt] [Parry] [Charge] ... │
└─────────────────────────────────────────────┘
Condition types (predefined, not free-form):
always— default, no conditionif_hp_below(%)— self HP thresholdif_hp_above(%)if_target_hp_below(%)if_enemy_count_above(n)if_enemy_count_below(n)if_ally_hp_below(%)— any ally below thresholdif_resource_above(type, n)— e.g., “if Fury > 50”if_resource_below(type, n)if_has_condition(condition)— if affected by specific statusif_target_has_condition(condition)
The client validates the queue locally (correct number of slots, skills owned, conditions valid) and sends it to the server for authoritative validation on run start.
7.2 Run Replay Viewer
After a run completes, the client renders the server-generated run_log as a detailed timeline.
┌─────────────────────────────────────────────┐
│ Run Complete: Goblin Warren (Normal) │
│ Result: ✅ Success | Duration: 32 min │
│ XP: +450 | Gold: +120 | Items: 3 │
├─────────────────────────────────────────────┤
│ │
│ ▼ Encounter 1/5: Goblin Scouts (Combat) │
│ Round 1: │
│ You use Power Strike → Hit (rolled 34 │
│ vs 72% chance) → 18 damage to Goblin A │
│ Goblin A attacks → Miss (rolled 88 vs │
│ 45% chance) │
│ Round 2: ... │
│ Result: Victory (2 rounds) │
│ │
│ ▶ Encounter 2/5: Trapped Hallway (Trap) │
│ ▶ Encounter 3/5: Fork in the Road (Choice) │
│ ▶ Encounter 4/5: Goblin Chieftain (Combat) │
│ ▶ Encounter 5/5: Treasure Room (Loot) │
│ │
├─────────────────────────────────────────────┤
│ Loot: [Goblin Blade (Uncommon)] [Health │
│ Potion x3] [12 Rough Leather] │
│ [Collect All]│
└─────────────────────────────────────────────┘
7.3 Activity Dashboard
The main game screen shows all concurrent activities at a glance:
┌─────────────────────────────────────────────┐
│ 🏰 Active Activities │
├─────────────────────────────────────────────┤
│ ⚔️ Dungeon Run: Goblin Warren 02:15:33 │
│ 🔨 Crafting: Iron Longsword 00:12:45│
│ ⛏️ Mining: Copper Vein (Tier 1) 01:45:00│
│ 📦 Marketplace: 3 active listings │
│ 🎯 Arena Queue: Searching... │
├─────────────────────────────────────────────┤
│ 📬 1 new mail | 🔔 2 new notifications │
└─────────────────────────────────────────────┘
8. Polling & Notifications
8.1 Why Polling, Not WebSockets
Delve is an async idle game. The fastest meaningful game event is a PVP match (resolved in seconds), and the slowest is a raid (12 hours). There is no real-time combat, no live player movement, no twitch gameplay. The client needs updates, not a live stream.
WebSockets add significant complexity for negligible benefit in this context:
- Persistent connection state to manage on the server (memory per connection, reconnect logic, heartbeats)
- Dedicated WebSocket server processes to scale independently
- Connection lifecycle management on mobile (background/foreground transitions)
- Missed-event recovery when connections drop
Polling via TanStack Query is simpler, stateless, and works identically whether the player is on desktop, mobile, or just opened the app after 8 hours offline. The server remains a pure REST API with no connection state.
8.2 Notification System
Workers write notification records to the database when events complete. The client polls for them.
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID REFERENCES characters(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- run_complete, craft_complete, item_sold, etc.
title TEXT NOT NULL,
body TEXT,
data JSONB, -- { runId, itemId, etc. } for deep linking
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_notifications_unread
ON notifications(character_id, created_at DESC)
WHERE is_read = false;
GET /api/characters/:id/notifications?since={timestamp}
→ Returns unread notifications since last poll
POST /api/characters/:id/notifications/read
Body: { ids: [...] }
→ Marks notifications as read
8.3 Push Notifications (Mobile)
When a worker creates a notification record, it also checks whether to send a push notification. Push is sent when:
- The player has push enabled for that notification type
- The player’s last API request was > 5 minutes ago (likely offline/backgrounded)
Push notifications are delivered via FCM (Android) and APNs (iOS) through Capacitor’s push plugin.
Notification events (user-configurable):
- Run completed
- Crafting completed
- Gathering expedition completed
- Marketplace item sold
- Raid lobby starting in 15 min / 5 min / now
- Mail received
- Guild war declared
Never sent (per design doc anti-burnout philosophy):
- “Come back!” re-engagement nags
- Daily streak reminders
- “Your friends are playing” social pressure
- Limited-time urgency notifications
8.4 Polling Impact on Server Load
At 10,000 concurrent players polling every 30s, that’s ~333 requests/second to the notification endpoint. This is a trivial load for an API server — it’s a single indexed query (WHERE character_id = $1 AND is_read = false AND created_at > $2). For comparison, a typical Node.js server handles 5,000–10,000 simple requests/second.
At 100,000 concurrent players: ~3,333 req/s. Still manageable with 2-3 API server instances. The notification query hits an index and returns a handful of rows — it’s one of the cheapest possible database operations.
9. Chat — Discord Integration
9.1 Why Discord, Not In-Game Chat
Building a real-time chat system requires WebSockets, persistent connections, message storage, moderation tools, spam filtering, mute/block systems, and mobile notification integration. Discord already does all of this better than we could build, and the Delve community will already be on Discord.
9.2 Integration Approach
Discord is the primary social layer. The game links to it but does not embed it.
In-game integration points:
- Guild creation flow prompts the guild leader to link a Discord server
- Guild detail page shows a “Join Discord” button that opens the linked invite
- Party/raid lobby page shows a “Coordinate on Discord” link
- LFG (looking for group) happens in a dedicated Discord channel, not in-game
- Trade chat happens in a Discord channel
Server-side integration (Discord bot, optional):
- Bot posts to a
#notificationschannel when server events happen (season starts, maintenance, patch notes) - Bot can optionally post run results or achievements to a guild’s Discord channel (if guild links their server and enables it)
- Account linking: players can link their Discord account via OAuth2 for bot features
9.3 What This Eliminates
| Removed Component | Complexity Saved |
|---|---|
| WebSocket server process | No persistent connections, no connection state management |
| Chat message storage | No chat tables, no message history, no Redis pub/sub |
| Chat moderation | No profanity filter, no mute/ban system, no reporting UI |
| Presence system | No online/offline tracking, no heartbeats |
| NATS message bus | Workers write to DB directly, no pub/sub needed |
| Chat UI components | No message input, no channel switching, no emoji picker |
This reduces the server to a pure stateless REST API, which is dramatically simpler to build, deploy, debug, and scale.
10. Mobile Wrapping
10.1 Capacitor Configuration
Capacitor wraps the SvelteKit SPA as a native iOS and Android app.
Native plugins used:
├── @capacitor/push-notifications — FCM/APNs integration
├── @capacitor/haptics — tactile feedback for loot drops, crits
├── @capacitor/app — app state (foreground/background detection)
├── @capacitor/status-bar — immersive game UI
├── @capacitor/splash-screen — branded loading screen
├── @capacitor/browser — external links (terms, support, Discord)
└── @capacitor/preferences — local key-value storage (settings, cached auth)
10.2 Mobile-Specific Adaptations
| Concern | Implementation |
|---|---|
| App lifecycle | Detect foreground → resume polling + refetch stale queries. Background → pause polling (push notifications still arrive). |
| Offline | Show cached data via TanStack Query persistence. Queue-able actions (queue edits) stored locally, synced on reconnect. |
| Deep links | delve://character/{id}, delve://guild/{id}, delve://quest/{id} for sharing |
| App Store compliance | All purchases routed through Stripe web checkout (linked from app). No in-app purchase SDK to avoid 30% platform cut. Patron status synced via server. |
| Performance | Lazy-load routes. Limit PixiJS animations on low-end devices (detect via navigator.deviceMemory). |
| Safe areas | CSS env(safe-area-inset-*) for notch/dynamic island handling |
| Splash + icons | Generated via @capacitor/assets from a single source SVG |
10.3 Build Pipeline
Web Build:
pnpm build → SvelteKit static adapter → dist/
iOS:
npx cap sync ios → Xcode project updated
xcodebuild → .ipa
Distribute via App Store Connect (or TestFlight for beta)
Android:
npx cap sync android → Gradle project updated
./gradlew assembleRelease → .aab
Distribute via Google Play Console (or internal testing track)
CI/CD:
GitHub Actions:
- On push to main: build web, run tests, deploy to staging
- On tag (v*): build web + iOS + Android, deploy to production
- iOS builds via Xcode Cloud or self-hosted Mac runner
- Android builds via standard Linux runner
11. Infrastructure & DevOps
11.1 Deployment Architecture
Production Environment:
┌─────────────────────────────────────────────┐
│ CDN (Cloudflare Pages) │
│ Static SPA assets │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Caddy (Reverse Proxy) │
│ TLS termination, /api → API │
└──────────────────┬──────────────────────────┘
│
┌────────┴────────┐
│ API Servers │
│ (2 replicas) │
└────────┬────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────────┐
│Simulation│ │ Economy │ │ PVP/Crafting │
│Workers(N)│ │Worker(1) │ │ Workers │
└─────────┘ └──────────┘ └──────────────┘
│ │ │
└─────────────┼─────────────┘
│
┌────────┼────────┐
▼ ▼ ▼
PostgreSQL Redis Backblaze B2
11.2 Environment Strategy
| Environment | Purpose | Infrastructure |
|---|---|---|
| Local | Development | Docker Compose (all services) |
| Staging | Pre-production testing | Single node, real DB, seeded test data |
| Production | Live game | Multi-node, replicated DB, Redis cluster |
11.3 Database Operations
- Migrations: Drizzle-kit generates SQL migration files, applied via CI before deployment.
- Backups: PostgreSQL WAL archiving to S3 for point-in-time recovery. Daily full backups retained 30 days.
- Connection pooling: PgBouncer in transaction mode between API/workers and PostgreSQL.
11.4 Monitoring & Alerting
| Metric | Alert Threshold |
|---|---|
| API response time p95 | > 500ms |
| Simulation worker queue depth | > 1000 pending jobs |
| Error rate | > 1% of requests |
| Database connections | > 80% pool utilization |
| Redis memory | > 80% capacity |
| Disk space | > 85% utilization |
| Notification poll latency p95 | > 100ms |
12. Security
12.1 Authentication & Authorization
- Password hashing: Argon2id with recommended parameters
- Sessions: Opaque session tokens stored in Redis, 30-day expiry, refreshed on use
- CSRF: SameSite=Strict cookies + origin header validation
- OAuth2: Optional social login (Google, Discord) via standard OAuth2 flow
- Authorization: Every API call validates that the authenticated user owns the character being acted upon. No cross-user access.
12.2 Anti-Cheat
Because the game is server-authoritative, traditional client-side cheats are not possible. The main attack vectors are:
| Vector | Mitigation |
|---|---|
| API manipulation | All inputs validated server-side. Skill queue validated against owned skills. Gear validated against owned items. Gold amounts verified in transactions. |
| Race conditions | Economy worker serialized. SELECT FOR UPDATE on critical resources. Idempotency keys on purchase/trade endpoints. |
| Automation / botting | Rate limiting on all endpoints. CAPTCHA on account creation. Behavioral analysis on marketplace patterns (future). |
| Multi-accounting | Single email per account. IP-based rate limiting on account creation. Marketplace patterns flagged for review. |
| Time manipulation | All timers are server-side. completesAt timestamps are authoritative. Client countdown is display-only. |
12.3 Data Protection
- Encryption at rest: PostgreSQL with disk encryption. Redis with TLS.
- Encryption in transit: TLS everywhere (HTTPS, internal service communication).
- PII minimization: Only email stored. No real names, addresses, or payment details (payments handled by Stripe).
- GDPR/CCPA: Account deletion endpoint that cascades to all character data. Data export endpoint for portability.
- Input sanitization: All user-provided strings (character names, guild names, mail messages) sanitized against XSS.
13. Performance Targets
13.1 Client
| Metric | Target |
|---|---|
| Initial load (LCP) | < 2s on 4G |
| Bundle size (gzipped) | < 150KB initial, < 500KB total with lazy routes |
| Time to interactive | < 3s on mid-range mobile |
| Frame rate (UI transitions) | 60fps |
| Memory usage | < 100MB on mobile |
13.2 Server
| Metric | Target |
|---|---|
| API response time p50 | < 50ms |
| API response time p95 | < 200ms |
| Notification poll response | < 30ms (indexed query, tiny result set) |
| Simulation throughput | 100+ concurrent run resolutions per worker |
| Marketplace transaction throughput | 1000+ transactions/minute |
| Target concurrent players (initial) | 10,000 |
| Target concurrent players (scaled) | 100,000+ |
13.3 Scalability Path
Phase 1 (Launch): Single PostgreSQL instance, 2 API servers, 2 simulation workers, 1 economy worker. Supports ~10K concurrent players.
Phase 2 (Growth): Read replicas for PostgreSQL, Redis cluster, 4+ simulation workers, horizontal API scaling. Supports ~50K concurrent players.
Phase 3 (Scale): Database sharding by character ID range (if needed), dedicated PVP cluster, CDN for all static game data, regional API deployments for latency. Supports 100K+ concurrent players.
Without WebSocket servers, scaling is much simpler — every API server is stateless and interchangeable. No sticky sessions, no connection affinity, no need to coordinate which server holds which player’s connection.
Appendix A: Data-Driven Design
All game content (species, classes, items, skills, conditions, factions, etc.) is defined in TOML data files and referenced by string ID. See features/data-driven-architecture.md for the full design. Only engine-structural concepts are Rust enums.
Game Data Registry
#![allow(unused)]
fn main() {
// crates/game/src/registry.rs
// Loaded once at startup, shared via Axum state. All game content lives here.
pub struct GameData {
pub species: HashMap<String, SpeciesDef>,
pub classes: HashMap<String, ClassDef>,
pub backgrounds: HashMap<String, BackgroundDef>,
pub weapon_types: HashMap<String, WeaponTypeDef>,
pub equipment_slots: IndexMap<String, EquipmentSlotDef>, // ordered
pub rarities: Vec<RarityDef>, // ordered by tier
pub resources: HashMap<String, ResourceDef>,
pub conditions: HashMap<String, ConditionDef>,
pub skills: HashMap<String, SkillDef>,
pub item_templates: HashMap<String, ItemTemplateDef>,
pub creatures: HashMap<String, CreatureDef>,
pub quests: HashMap<String, QuestDef>,
pub factions: HashMap<String, FactionDef>,
pub reputation_tiers: Vec<ReputationTierDef>,
pub loot_tables: HashMap<String, LootTableDef>,
pub recipes: HashMap<String, RecipeDef>,
pub achievements: HashMap<String, AchievementDef>,
}
impl GameData {
pub fn load(data_dir: &Path) -> Result<Self, GameDataError>;
pub fn validate(&self) -> Result<(), GameDataError>; // cross-reference check
}
}
Data-Driven IDs (game content — stored as TEXT in Postgres)
#![allow(unused)]
fn main() {
// crates/types/src/ids.rs
// Validated newtype wrappers over String. The inner value is a key into GameData.
macro_rules! data_id {
($name:ident) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct $name(String);
impl $name {
pub fn as_str(&self) -> &str { &self.0 }
/// Unchecked construction — only use when loading from DB (already validated)
pub fn from_db(s: String) -> Self { Self(s) }
}
};
}
data_id!(SpeciesId);
data_id!(ClassId);
data_id!(SubclassId);
data_id!(BackgroundId);
data_id!(WeaponTypeId);
data_id!(SlotId);
data_id!(RarityId);
data_id!(ResourceId);
data_id!(ConditionId);
data_id!(SkillId);
data_id!(ItemTemplateId);
data_id!(CreatureId);
data_id!(QuestId);
data_id!(FactionId);
data_id!(RecipeId);
data_id!(AchievementId);
data_id!(LootTableId);
}
Character (uses data IDs, not enums)
#![allow(unused)]
fn main() {
// crates/types/src/character.rs
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Character {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub species: SpeciesId, // TEXT column — e.g., "ironborn"
pub class: ClassId, // TEXT column — e.g., "vanguard"
pub subclass: Option<SubclassId>,
pub background: BackgroundId,
pub level: i16,
pub xp: i32,
pub gold: i64,
pub might: i16,
pub logic: i16,
pub speed: i16,
pub presence: i16,
pub fortitude: i16,
pub luck: i16,
}
}
Engine-Structural Enums (code, not data)
#![allow(unused)]
fn main() {
// These remain as Rust enums — they control code branching, not content.
/// Where an item is stored — each variant has different rules and UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ItemLocation {
Equipped { slot: String }, // slot is a data ID (e.g., "main_hand")
Backpack { index: u16 },
Bank { index: u16 },
Mail { mail_id: Uuid },
Listed { listing_id: Uuid },
}
/// Run state machine — finite set of states with enforced transitions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RunStatus { InProgress, Completed, Failed }
/// Encounter dispatch — each variant calls a different resolver function.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EncounterType { Combat, Trap, Decision, Hazard, Rest, Puzzle }
/// Queue condition evaluation — each variant has different evaluation logic.
/// The parameters (resource ID, condition ID) are data references.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum QueueCondition {
Always,
IfHpBelow { threshold: u8 },
IfHpAbove { threshold: u8 },
IfTargetHpBelow { threshold: u8 },
IfEnemyCountAbove { count: u8 },
IfEnemyCountBelow { count: u8 },
IfAllyHpBelow { threshold: u8 },
IfResourceAbove { resource: String, amount: u16 }, // resource is a data ID
IfResourceBelow { resource: String, amount: u16 },
IfHasCondition { condition: String }, // condition is a data ID
IfTargetHasCondition { condition: String },
}
}
Client/Server Type Sharing
Since the backend is Rust and the frontend is TypeScript, types are not shared at the language level. The API contract is the source of truth:
- OpenAPI spec: The API server exports an OpenAPI schema (via
utoipacrate), and the client generates TS types from it (viaopenapi-typescript). - Or: A
types.tsfile in the client is manually maintained to match the API. - Game data: The client fetches data definitions (species list, class list, rarity colors, etc.) via
GET /api/game-datawhich returns the full registry for UI rendering.
Appendix B: API Route Summary
| Method | Path | Description |
|---|---|---|
| Auth | ||
| POST | /api/auth/register | Create account |
| POST | /api/auth/login | Login, returns session |
| POST | /api/auth/logout | Destroy session |
| Characters | ||
| GET | /api/characters | List user’s characters |
| POST | /api/characters | Create character |
| GET | /api/characters/:id | Get character details |
| DELETE | /api/characters/:id | Delete character |
| PUT | /api/characters/:id/skill-queue | Save skill queue |
| GET | /api/characters/:id/skill-queues | List saved queues |
| Equipment | ||
| GET | /api/characters/:id/inventory | Get backpack + equipped |
| POST | /api/characters/:id/equip | Equip item |
| POST | /api/characters/:id/unequip | Unequip item |
| POST | /api/characters/:id/salvage | Salvage items |
| Quests & Runs | ||
| GET | /api/quest-board | Get available quests for character |
| POST | /api/runs | Start a dungeon run |
| GET | /api/runs/:id | Get run status + results |
| GET | /api/characters/:id/runs | Run history |
| Crafting | ||
| GET | /api/characters/:id/recipes | Known recipes |
| POST | /api/crafting/start | Start crafting job |
| GET | /api/characters/:id/crafting | Active crafting jobs |
| Gathering | ||
| POST | /api/gathering/start | Start expedition |
| GET | /api/characters/:id/gathering | Active expeditions |
| Marketplace | ||
| GET | /api/marketplace | Search listings |
| POST | /api/marketplace/list | Create listing |
| POST | /api/marketplace/buy/:id | Buy listing |
| DELETE | /api/marketplace/:id | Cancel listing |
| GET | /api/characters/:id/mail | Get mailbox |
| POST | /api/mail/send | Send mail |
| POST | /api/mail/:id/collect | Collect attachments |
| Guilds | ||
| POST | /api/guilds | Create guild |
| GET | /api/guilds/:id | Guild details |
| POST | /api/guilds/:id/join | Request to join |
| POST | /api/guilds/:id/invite | Invite player |
| PUT | /api/guilds/:id/settings | Update guild settings |
| POST | /api/guilds/:id/buff | Activate guild buff |
| PVP | ||
| POST | /api/pvp/queue | Enter arena queue |
| DELETE | /api/pvp/queue | Leave arena queue |
| GET | /api/pvp/history | Match history |
| GET | /api/pvp/leaderboard/:bracket | Leaderboard |
| Factions | ||
| GET | /api/characters/:id/reputation | All faction standings |
| GET | /api/factions/:id/vendor | Faction vendor inventory |
| Social | ||
| GET | /api/characters/:id/friends | Friends list |
| POST | /api/friends/add | Send friend request |
| POST | /api/friends/accept/:id | Accept request |
| Parties | ||
| POST | /api/parties | Create party |
| POST | /api/parties/:id/join | Join party |
| POST | /api/parties/:id/ready | Mark ready |
| GET | /api/parties/:id | Party details |
| Seasons & Dailies | ||
| GET | /api/characters/:id/dailies | Daily progress |
| GET | /api/characters/:id/weeklies | Weekly progress |
| GET | /api/seasons/current | Current season info |
| GET | /api/characters/:id/season-progress | Season track progress |
| Notifications | ||
| GET | /api/characters/:id/notifications | Unread notifications (polled) |
| POST | /api/characters/:id/notifications/read | Mark notifications as read |
| Achievements | ||
| GET | /api/characters/:id/achievements | Earned achievements |
| GET | /api/characters/:id/bestiary | Bestiary progress |