An infinite, AI-powered text-based MMORPG. Explore a procedurally generated open world, fight enemies, complete quests, trade with vendors, and chat with AI-simulated players — all rendered in a terminal-style browser UI.
- Concept
- The Infinite Loop — Foundational Mechanic
- Tech Stack
- Architecture Overview
- Quick Reference — What Lives Where
- Directory Structure & What Lives Where
- Key Systems — How They Work
- Math & Scaling Reference
- Data Models
- API Reference
- Getting Started
- Environment Variables
- Simulation-Driven Balance Methodology
- Extending the Game
- Design Decisions
- Known Constraints & Gotchas
- License
- Commercial Licensing
The game follows a classic MMO loop — open world zones → dungeons (level 10+) → raids (level 20+) — repeated infinitely with no level cap. Each zone is either drawn from a curated starter template (levels 1–5) or procedurally generated using an AI narrative layer on top of deterministic math scaffolding.
Each zone's topology is hub → path → POI for every spoke. Path locations are safe rest stops between the hub and each point of interest — no combat mobs spawn on paths, and patrol encounters never fire there. Every path location has a harvestable plant and a fishing hole, giving players a low-pressure gold source between fights.
Players are single-player but exist in a world populated by simulated entities (SimulatedPlayers) that move, rest, and respond to the environment — each with a generated fantasy name (Theron, Sylvara, Corvus, etc.) and a stable personality archetype. World chat responses come from these zone-specific players, grounded in the actual location, mobs, and weather the player is experiencing right now.
Every class has a unique auto-firing passive proc that triggers mid-combat without any input — Rogues evade, Warlocks drain life, Paladins self-heal. Combat is intentionally hands-off so players can focus on questing, chatting, and exploring while loot and levels accumulate. Dungeon and raid portals are always visible in the sidebar from level 1 so players always know what they're working toward.
Any input that isn't a recognized game command is passed to the local LLM as a freeform roleplay action. Type jump up and down, bow to the merchant, or examine the strange rune on the wall and the AI responds with an in-world atmospheric description grounded in your current location. This turns the command line into a genuine text adventure — the world reacts to anything you try, not just the commands the engine explicitly handles.
This is the core design this engine is built around. If you use any part of this architecture in your own project, this section is the part you're borrowing.
The central problem in every prestige/idle/ARPG game is: how do you make a loop feel faster each cycle without making it trivial? Most games solve this with a power cliff — resets become meaningless after 20–30 cycles and the game is "solved." This engine solves it with dual-exponential scaling: two growth curves that chase each other forever.
ascension_damage_mult = 1.15 ^ ascension_count ← player grows here
arc_difficulty = 1.10 ^ ascension_count ← world grows here
zone_difficulty_mult = (1.0 + (zone − 1) × 0.2) × arc_difficulty
- Player power grows at
1.15^N— compounds permanently, never resets - World difficulty grows at
1.10^N— each arc's mobs are harder than the last - Net speed gain per ascension:
1.15 / 1.10 ≈ 4.5%
Each run is always faster. Zone 10 is always a real wall. The loop never breaks.
Linear scaling (common approach: +5% difficulty per ascension) fails because exponential player growth eventually laps it — Zone 10 collapses to a one-shot at ascension ~12. The game is solved.
Matching the player's growth rate exactly (1.15^N difficulty) removes all sense of progress — no run ever feels faster.
The solution is two exponentials with different bases, close but not equal. The player always wins — but only barely per cycle. After 100 ascensions, runs are ~85× faster than the first. Zone 10 still requires leveling, gear, and actual engagement. After 1,000 ascensions the numbers hit scientific notation and runs are thousands of times faster — but the loop structure is identical to cycle 1.
Let
p = 1.15(player power base) andr = 1.10(resistance base). Per-cycle speed multiplier after N ascensions:(p/r)^N = 1.0454^NThis series diverges — runs approach but never reach instant. The wall (Zone 10) requires defeating mobs with HP proportional tor^N. A naked level-1 character's base damage cannot one-shot that untilp^N > r^N × zone_difficulty, which requires sustained leveling and gear regardless of N. The loop is provably infinite.
Designed and implemented by the author of this repository (2025). The specific combination of per-arc exponential resistance scaling paired with a compounding prestige multiplier, tuned such that player_base > resistance_base by a fixed ratio, is the original architecture documented here. If you build on this engine or derive a system from this pattern, attribution to this repository is required under the project license (AGPL v3).
| Layer | Technology | Purpose |
|---|---|---|
| Frontend | Next.js 16 (App Router) | React SSR + client state |
| Frontend | TypeScript | Type safety across all UI logic |
| Frontend | Tailwind CSS + globals.css | Dark terminal aesthetic, component styles |
| Frontend | react-markdown | Renders the How to Play guide from Markdown source |
| Backend | FastAPI + Uvicorn | Async Python HTTP API |
| Backend | Pydantic v2 | Schema validation and serialization (model_dump(mode='json')) |
| Persistence | SQLite (stdlib) | Single mud.db file — stores player + zone rows as JSON blobs |
| Persistence | In-memory LRU cache (200 entries) | Wraps SQLite reads — cache is checked first on every get |
| AI | LM Studio (local) | OpenAI-compatible local LLM server at http://localhost:1234/v1 |
| AI | openai Python SDK | Used to talk to LM Studio via its OpenAI-compatible REST endpoint |
Note: This project does NOT use the OpenAI cloud API. All LLM calls go to a locally running LM Studio instance. The
openaipip package is used purely as the HTTP client because LM Studio exposes an OpenAI-compatible interface.
Browser (Next.js)
│
│ HTTP (fetch, POST/GET)
▼
FastAPI ─── main.py (all endpoints, combat logic, loot rolling, quest management)
│
├── app/core/world_generator.py (zone/mob/NPC/vendor generation)
├── app/core/combat_engine.py (hit/miss/damage resolution)
├── app/core/scaling_math.py (HP/damage/XP formulas)
├── app/core/ai_client.py (LM Studio wrapper)
├── app/core/simulation.py (background tick loop)
├── app/core/vector_db.py (SQLite DBManager + LRU cache)
└── app/models/schemas.py (Pydantic models — shared truth for all data)
Request lifecycle (example: player attacks a mob):
POST /action/attack/{player_id}?mob_name=boar→main.py- Rate-limit check (
_attack_timesdict, 1.5s cooldown per player) - Load player from
vec_db(LRU cache → SQLite fallback) - Load zone from
vec_db(LRU cache → SQLite fallback) - Find target mob in current location, verify it's alive (
respawn_at is None) combat_engine.resolve_tick(player, mob)→ hit roll → damage roll- Counter-attack if mob survived
- On mob death: XP + gold + respawn timer + class-biased loot roll → auto-equip if upgrade
vec_db.save_zone(...)— always called, even if mob survived, to persist HP damagevec_db.save_player(...)— persist new HP/XP/inventory/equipment- Return JSON response with all state deltas
State ownership:
- All authoritative game state lives in SQLite + cache on the backend
- The frontend maintains a local mirror of
playerandzonestate for instant UI updates - After every mutating action the frontend syncs from the response (XP, HP, gold, kills)
- The zone ticker (
/zone/{zone_id}polled every 10s) keeps the local zone mirror fresh
| I want to… | Go here |
|---|---|
| Add or change an API endpoint | backend/main.py |
| Change combat hit/damage math | backend/app/core/combat_engine.py |
| Tune HP/XP/damage scaling curves | backend/app/core/scaling_math.py |
| Change loot drop rates or slot weights | backend/app/core/world_generator.py → _roll_loot, _CLASS_SLOT_WEIGHTS |
| Add a dungeon or raid mechanic | backend/app/core/dungeon_engine.py |
| Change zone/mob/NPC/quest generation | backend/app/core/world_generator.py |
| Add a new Pydantic field to any model | backend/app/models/schemas.py |
| Change the LLM provider or prompts | backend/app/core/ai_client.py |
| Change the background simulation tick | backend/app/core/simulation.py |
| Change any UI, command, or frontend state | frontend/app/page.tsx |
| Change any visual style or animation | frontend/app/globals.css |
| Run the full progression sim | scripts/sim_run.py |
| Run the fast smoke test | scripts/smoke_test.py |
| Create a boosted character for frontend testing | scripts/boost_char.py |
| Wipe all game data | scripts/reset_data.py |
SINGLE-PLAYER-AI-MUD/
├── README.md
│
├── scripts/
│ ├── sim_run.py ← Headless full-progression sim. Drives every endpoint the
│ │ browser does. Use to validate balance and catch regressions.
│ ├── smoke_test.py ← Fast (~60s) happy-path integration test. Covers all 17
│ │ systems in order; creates and deletes a throwaway character.
│ ├── boost_char.py ← Dev tool: creates a character and instantly boosts it to
│ │ dungeon-ready (lv10, GS ~94) or raid-ready (lv20, GS ~280).
│ │ Load the character in the browser — no grinding required.
│ └── reset_data.py ← Deletes backend/data/mud.db entirely. Use after schema changes.
│
├── frontend/
│ ├── app/
│ │ ├── page.tsx ← ENTIRE frontend. One large file — all state, UI, commands
│ │ └── globals.css ← All styles. Sections marked with comment headers
│ └── public/assets/ ← Class portraits, UI images
│
└── backend/
├── main.py ← ALL endpoints + constants + loot system + level-up logic
│ If you're adding a new endpoint, this is the file.
│
├── requirements.txt
│
└── app/
├── models/
│ └── schemas.py ← Pydantic data models. Single source of truth for all
│ game objects: Player, Zone, Location, Mob, NPC, Quest,
│ Item, DungeonRun, DungeonMember. Edit here when adding
│ new fields.
│
└── core/
├── ai_client.py ← LM Studio HTTP wrapper. generate_content(), stream_content(),
│ generate_json(). Swap the LLM provider here.
│
├── combat_engine.py ← CombatEngine class. Hit rolls, damage rolls, defense
│ calculation. RuneScape-style accuracy formula.
│ resolve_tick(attacker, target) → (messages, is_dead)
│
├── dungeon_engine.py ← Dungeon & raid lifecycle. generate_run() builds a
│ DungeonRun (party + 3–7 rooms). resolve_round() steps
│ one combat round: player attacks, each NPC acts by role
│ (tank/healer/dps), mobs retaliate, room-clear check.
│ Calls combat_engine.resolve_tick() internally — no
│ duplicated combat math.
│
├── scaling_math.py ← Pure math. get_max_hp(level), get_damage(level),
│ get_xp_required(level). Tune numbers here.
│ Also: CLASS_STATS (hp/dmg multipliers per class),
│ RARITY dict (stat multipliers per rarity tier),
│ apply_levelups(player, messages) — shared level-up
│ loop used by both main.py and dungeon_engine.py.
│
├── simulation.py ← Background asyncio loop (10s tick). Handles:
│ - Mob respawn (respawn_at timer expiry)
│ - SimulatedPlayer movement and status changes
│ (battling status: actually kills a mob at their
│ location and posts a world_message)
│ - Weather shifts (5% chance per tick)
│ - Time-of-day progression
│ - AI-generated zone ambiance messages (10% chance)
│
├── vector_db.py ← SQLite wrapper + in-memory LRU cache (200 entries).
│ save_player / get_player / save_zone / get_zone /
│ delete_player / get_all_players / reset_all.
│ Uses INSERT OR REPLACE — no pandas, no numpy, no
│ pyarrow. Cache checked before every DB read. Zone is
│ ALWAYS written after combat (even on non-fatal hits).
│
└── world_generator.py← Zone factory. Two paths:
1. Starter (level ≤5): picks from 5 curated templates
2. Procedural (level >5): math scaffold + AI narrative layer
Also owns: elite/named mob generation, loot tables,
vendor NPC generation (_make_vendor).
world_generator.py → WorldGenerator.generate_zone(level, is_dungeon, is_raid)
Two-phase approach:
- Phase 1 (Math): Deterministic skeleton — zone ID, hub + 4 POI locations, 3–4 distinct mob names, 5 quest skeletons covering all 4 archetypes, XP values from
ScalingMath - Phase 2 (AI): Calls LM Studio for names, descriptions, NPC dialogue, quest flavor text. Falls back to a hardcoded gritty-fantasy dictionary if AI is unavailable
- Each hub gets 2 quest giver NPCs (quests split between them) + a vendor NPC
- Mobs spawn with a 20% elite chance and 5% named chance per slot (
_make_mobs). Multipliers ramp by level so low-level content stays fair: elites are 1.3× HP / 1.1× dmg at level 1, scaling to the full 2.0× / 1.3× by level 10; named mobs start at 1.6× / 1.15× and reach the full 3.0× / 1.5× by level 10. - Path topology: Between every hub and POI a path location is inserted —
hub → path_0 → poi_0,hub → path_1 → poi_1, etc. Paths are wired bidirectionally. Path locations have no mobs, no NPCs, and aresourcesfield:[plant_name, fish_species]drawn from level-appropriate tables (e.g.["Ironweed", "Silverscale"]). Patrol spawns skip path locations entirely. - Dungeons: the final POI is always a boss chamber (
force_boss=True) — guaranteed named boss + elite guards, location labeled[BOSS] - Starter zones (level ≤5): 5 distinct hand-crafted templates (Whispering Glade, Moonshaded Glade, Saltcliff Reach, The Ashen Fields, Barrowmoor) — picked randomly so players rarely see the same start twice
Quest archetypes (6 quests per zone, all 5 types used):
| Type | Mechanic | Completion |
|---|---|---|
kill |
Slay N of a named mob | Client-side on mob death, synced to backend |
hunt |
Kill the zone's named/boss mob (1 target) | Triggers on target_is_named == true in attack response |
gather |
Collect N items from a mob type | Tracks on kill — strips mob-specific collectible suffix (Tusk, Fang, Pelt, Wing, Tail, Hide, Scale, etc.) to match base mob name. Each mob type has a specific collectible noun — Boars drop Tusks, Spiders drop Fangs, Bats drop Wings, etc. |
explore |
Travel to a specific POI location | Auto-completes server-side in POST /action/move when player reaches target. Not re-offered once visited. |
forage |
Collect N resources using the gather command at a specific location (no combat) |
Backend endpoint /action/gather — requires standing in quest.target_id location. 8 s cooldown per gather. Zone-themed resources: Bog Moss, Wild Herb, Sea Kelp, Ember Root, Glowbloom, etc. |
The intended loop mirrors classic MMO tier structure. Each tier requires you to gear through the previous one:
Open World (level 1–9) → kill quests, gather, hunt, explore, forage
Quests are repeatable — grind until level 10
Dungeon (level 10+) → 5-player instanced, Rare/Epic loot (1.6× stats)
4 rooms: trash → corridor → trash+elite → boss
Gear Score gates the Raid — farm dungeons first
Raid (level 20+, → 10-player instanced, Epic/Legendary loot (2.8× stats)
GS ≥ 100) 7 rooms: trash → corridor → trash+elite → mini-boss
→ corridor → deep trash → final boss (enrage at 30%)
Clearing a raid pushes open-world zone level +3
Zone Travel → Requires GS ≥ 1000 (fixed gate, not level-scaled)
~3 raid clears with Rare/Epic drops at level 20
Cannot travel on open-world drops alone — must do dungeons + raids
"★ ZONE CLEARED!" fires only when travel succeeds (real milestone)
This creates an infinite compounding loop: Open World → Dungeon → Raid → meet GS threshold → travel → harder Open World → harder Dungeon → harder Raid → …
Gear Score — shown live in the HUD stats panel. Calculated as the sum of all equipped item stat values × rarity multiplier (Common 1×, Rare 2.5×, Epic 4×, Legendary 7×). Raid entry is blocked until GS ≥ 100 with a clear message: "Gear score too low (74/100). Farm dungeons first." Once GS ≥ 100 the HUD shows ✓ RAID READY in purple.
Zone travel GS gate — fixed at 1000 GS. Dungeon grinding alone tops out around 500–600 GS — raids are required. ~2-3 raid clears with Epic/Legendary drops at level 20 will hit 1000. The gate is intentionally fixed (not level-scaled) so it can't become an infinite treadmill as the player levels through raids. The scrolling ticker always shows current GS vs required so the player knows exactly what to farm.
Raid tier escalation: Each raid cleared increments player.raids_cleared. The zone travel endpoint adds raids_cleared × 3 to the generated zone level, so open-world content, dungeon mobs, and raid bosses all scale harder with every tier you complete.
Death penalty: 15% of current XP lost on death. No gear durability — the XP sting is enough to create tension without frustrating casual players.
Level-up scaling: On every level-up, max_hp and damage are recalculated from ScalingMath and the class-specific multiplier from CLASS_STATS is re-applied. Class advantages persist through every level, not just at character creation.
| Class | HP Mult | Dmg Mult | Identity |
|---|---|---|---|
| Warrior | 1.20 | 1.10 | Tanky all-rounder |
| Paladin | 1.15 | 0.95 | Sustain tank, proc heals |
| Hunter | 1.00 | 1.10 | Glass cannon, spiky proc |
| Rogue | 0.90 | 1.20 | Highest damage, dodge survivability |
| Priest | 0.85 | 0.85 | Squishiest — relies on heal procs |
| Shaman | 1.10 | 1.05 | Tankier damage dealer, sturdy grinder |
| Mage | 0.80 | 1.30 | Burst glass cannon |
| Warlock | 0.85 | 1.20 | High damage, self-sustain via lifesteal |
| Druid | 1.00 | 1.00 | Generalist, frequent rejuvenation procs |
Out-of-combat HP regen: 2% of max HP per second kicks in after 6 seconds without taking damage. The frontend regen timer syncs the new HP to the backend (POST /action/rest/{player_id}) every ~10 seconds — reconnecting or refreshing restores the regened HP rather than snapping back to the last combat value.
Rested XP — the daily login hook: When you log out cleanly (POST /action/logout/{player_id} via sendBeacon), the server stamps your logout time. On the next login (POST /action/login/{player_id}), rest accumulated at a rate of next_level_xp / 8 per real hour is added to your pool, capped at 1.5× the current level's XP requirement. While you have rested XP, every kill grants 2× XP and drains the pool by the base XP amount — the transition back to 1× is seamless. The XP bar shows a faint teal overlay representing the rested pool, and kill log lines show 💤(+N rested) so the bonus is always visible. The message 💤 You are Rested! greets you on login when the pool is non-zero.
Consumables — closing the gold loop: Every vendor stocks two potions that scale in price with zone level, giving gold a permanent purpose:
| Item | Effect | Cooldown | Price |
|---|---|---|---|
| Healing Potion | Restores 40% max HP instantly | 60 s | ~8 × level gold |
| Elixir of Insight | Next 5 kills grant +75% XP | 5 min | ~22 × level gold |
Potions appear in a dedicated POTIONS panel in the sidebar with USE buttons, cooldown countdowns, and active buff charge tracking. They can also be used via use healing / use elixir in the command line. Auto-use: the frontend automatically drinks a Healing Potion when HP drops to ≤25% of max, if one is available and off cooldown — keeping fragile classes like Mage and Priest alive without interrupting combat flow.
main.py → _apply_class_proc(player, target_mob, messages)
Every class has a unique passive ability that fires automatically between the player's attack and the mob's counter-attack — no button presses required. This keeps combat frictionless (ideal for chatting while grinding) while creating unpredictable dopamine moments. Proc messages appear in gold in the combat log.
| Class | Proc | Chance | Effect |
|---|---|---|---|
| Warrior | ⚔ BATTLE FURY | 20% | 2× bonus damage |
| Paladin | ✦ DIVINE GRACE | 20% | Heal 15% max HP |
| Hunter | ⚡ POWER SHOT | 20% | 2.5× bonus damage |
| Rogue | ☽ EVASION | 25% | Skip mob counter-attack |
| Priest | ✦ HOLY MEND | 25% | Heal 20% max HP |
| Shaman | ⚡ CHAIN LIGHTNING | 20% | 1.8× bonus damage |
| Mage | ✦ ARCANE SURGE | 25% | 1.8× bonus damage |
| Warlock | ✧ SOUL DRAIN | 20% | 1.5× damage + lifesteal (half as healing) |
| Druid | ✦ REJUVENATION | 25% | Heal 15% max HP |
Proc fires after the player's hit resolves. If a proc kills the mob, the mob's counter-attack is skipped. If a dodge/barkskin proc fires, the counter-attack is also skipped regardless.
Proc damage scaling: Damage and drain procs use combat_engine.get_effective_max_hit(player) — the same value used for normal attacks — so proc damage includes all equipped weapon bonuses. Upgrading from a grey dagger to a Legendary Staff increases both your normal hits and your proc hits.
combat_engine.py → CombatEngine
RuneScape-style accuracy formula:
A = attacker.level × 10 (attacker accuracy ceiling)
D = target.level × 8 + armor_stat × 3 (defender defense ceiling)
attacker_roll = random(1, A)
defender_roll = random(1, D)
hit = attacker_roll > defender_roll
damage = random(1, base_damage + weapon_stat_bonus)
See Math & Scaling Reference → Hit Probability for the full derivation and worked examples across key level pairings.
- One tick = player attacks mob → class proc fires → (if mob alive and no dodge) mob counter-attacks → (if mob still alive) check telegraph queue
- Open-world telegraphs: after the mob counter-attacks, named mobs have a 20% chance and elite mobs a 15% chance to queue a telegraph for the next round — see the Telegraph section in Dungeon & Raid System for full details
- Equipment stats are summed via
_equipment_bonus(character, stat) - Minimum 1 damage on any hit (no frustrating 0-damage swings)
- 1.5s server-side rate limit per player enforced via
_attack_timesdict inmain.py
dungeon_engine.py → generate_run(), resolve_round()
Dungeons and raids are instanced — completely separate from the Zone system. Each run is a DungeonRun stored in-memory (_dungeon_runs dict) for the duration of the session. No persistence overhead; server restart abandons any active run, which is acceptable for single-player.
Structure:
| Type | Rooms | Party | Loot tier | Gate |
|---|---|---|---|---|
| Dungeon | 4 (trash → corridor → trash+elite → boss) | Player + 4 AI | dungeon (1.6×) |
Level 10 |
| Raid | 7 (trash → corridor → trash+elite → mini-boss → corridor → deep trash → final boss) | Player + 9 AI | raid (2.8×) |
Level 20 + GS ≥ 100 |
Party composition is role-aware and auto-assigned:
- Tank player (Warrior, Paladin) → 1 Healer + 3 DPS
- Healer player (Priest) → 1 Tank + 3 DPS
- DPS player (everyone else) → 1 Tank + 1 Healer + 2 DPS
- Raid → 2 Tanks + 2 Healers + 6 DPS (player + 9 NPCs)
Round resolution — one POST /dungeon/attack resolves the entire round simultaneously:
- Player attacks the primary mob (
combat_engine.resolve_tick) - Each living AI party member acts based on role:
- Healer: heals the most injured combatant (player or party) if anyone is below 40% HP; else attacks
- Tank: 75% attack, 25% taunt (reduces mob damage 20% for the round)
- DPS: attacks, with 20% proc chance using the same
_CLASS_PROCStable as open-world
- All surviving mobs counter-attack a random living combatant (redirected to tank if taunt is active)
- Room cleared / run cleared / wipe checks
Raid boss phase 2 (enrage): When the final boss drops to ≤30% HP, it enrages once — boss.damage × 1.4, flag stored on the run, pulsing red banner shown in the UI. The enrage persists until the boss dies.
Telegraph (Dodge) Mechanic — named and elite mobs telegraph powerful attacks that the player must actively dodge. This mechanic exists at every content tier, starting in the open world so players learn it before dungeons.
The telegraph fires after a mob's counter-attack: the ⚠ X winds up Y! DODGE! message appears and a DODGE button with a 3-second countdown replaces (or overlays) the normal attack button. The player must click before the timer expires. Missing the window deals the full telegraphed hit automatically on the next attack call.
| Source | Trigger | Damage if missed | UI | Location |
|---|---|---|---|---|
| Named mob (open world) | 20% per round | 2× mob base damage | Yellow DODGE in target frame | _pending_telegraphs dict (in-memory) |
| Elite mob (open world) | 15% per round | 1.5× mob base damage | Yellow DODGE in target frame | _pending_telegraphs dict |
| Named boss (dungeon/raid) | 30% per round | 3× boss base damage | Yellow DODGE replaces ATTACK | DungeonRun.pending_telegraph |
| Elite mob (dungeon) | 20% per round | 2× mob base damage | Yellow DODGE replaces ATTACK | DungeonRun.pending_telegraph |
| Raid final boss (enraged) | 100% every round | Instant kill | Red pulsing DODGE replaces ATTACK | DungeonRun.pending_telegraph |
State per tier:
- Open world — stored in
_pending_telegraphs[player_id](in-memory dict inmain.py). Cleared on mob death and player death./action/attackacceptsdodged=bool; dodge attacks bypass the 1.5s rate limit since they resolve a prior telegraphed hit, not a new offensive action. - Dungeon/raid — stored on
DungeonRun.pending_telegraph. Cleared on room clear./dungeon/attackacceptsdodged=bool.
Teaching progression — players encounter the mechanic first on named mobs in the open world (2× damage, survivable, low pressure), then on dungeon elites and bosses (2–3× damage, higher stakes), then on raid bosses with enrage one-shots. Each tier uses the same 3-second DODGE button with a draining countdown bar.
The sim always dodges optimally at every tier — open world kill_mob tracks pending_telegraph in the attack response and passes dodged=True on the next call; dungeon do_dungeon_run does the same from run.pending_telegraph.
Loot: On run cleared, _roll_loot() is called with zone_tier="dungeon" or "raid". Dungeon: 1–2 drops (Epic base rate 15%, boosted by ×1.6 tier = 24% effective). Raid: 3 guaranteed drops. Loot is auto-equipped on drop if the stat total beats the currently equipped piece — old piece goes to inventory. All items are class-biased toward the player's class using the same slot-weight system as open world.
Combat theater UI: Dungeon/raid content replaces the scrolling chat log with a persistent layout — boss HP bar at top, one row per party member that updates in place each round, 3-line rolling log for dramatic moments only. No scroll, no noise.
main.py → POST /ascend/{player_id} · POST /admin/force_ascend/{player_id}
Ascension is the meta-progression loop. The game is designed around 10-zone arcs — each zone harder than the last, with Zone 10 acting as a deliberate wall that is tuned to be very difficult without accumulated ascension buffs.
Zone difficulty scaling:
arc_difficulty = 1.10 ^ ascension_count (grows with each ascension)
zone_difficulty_mult = (1.0 + (zone_number − 1) × 0.2) × arc_difficulty
Zone 1 at ascension 0 = 1.0× · Zone 10 at ascension 0 = 2.8× mob HP and damage
Zone 10 at ascension 10 = 7.25× · Zone 10 at ascension 20 = 18.8×
Ascension:
- Requires
current_zone_number == 10 - Resets: level, gear, gold, XP, quests, inventory, dungeon/raid progress, zone progress
- Carries forward:
ascension_count + 1andascension_damage_mult × 1.15 - Spawns a new starter zone at level 1
Dual-scaling math — why Zone 10 is a wall forever:
Player power grows at 1.15^N. Mob difficulty grows at 1.10^N. The net speed gain per ascension is 1.15 / 1.10 ≈ 4.5% — each arc is always faster, but never trivially instant. Zone 10 can never be one-shotted from level 1 regardless of ascension count.
| Ascension | Player ×DMG | Zone 10 mob ×HP | Net arc speed |
|---|---|---|---|
| 0 | ×1.00 | ×2.80 | baseline |
| 5 | ×2.01 | ×4.51 | ~1.3× faster |
| 10 | ×4.05 | ×7.25 | ~1.6× faster |
| 20 | ×16.4 | ×18.8 | ~2.5× faster |
| 40 | ×267 | ×127 | ~6× faster |
| 100 | ×1,174,313 | ×13,781 | ~85× faster |
The player multiplier is applied in combat via a temporary copy (combat_player.damage = player.damage × ascension_damage_mult). The arc difficulty is applied at zone generation time to mob HP and damage. Neither value is ever persisted back to the DB — both are stateless and recomputed fresh every use.
Numbers: HP, gold, XP, and damage values use K/M/B/T notation in the frontend at large magnitudes, then scientific notation beyond 10^15. The game is designed for infinite play — notation handles arbitrarily large numbers gracefully.
Testing: python scripts/sim_run.py --skip-to-ascend tests the full ascension endpoint in isolation. --ascensions N uses /admin/force_ascend to verify the ×1.15^N compound math at any stack count.
main.py → _roll_loot(mob_level, loot_table, char_class, zone_tier)
- Rolls against the mob's loot table (chance per rarity tier)
- Loot table order is best-to-worst — the loop checks entries in order and returns the first rarity that passes. Legendary is checked before Epic, Epic before Rare, Rare before Common. This means the zone tier boost raises the probability of better rarities, not just Common. Named bosses are guaranteed Rare minimum (100% fallback), with real 40% Epic and 10% Legendary chances. If Common were checked first at a boosted 100%, it would block all higher rarities entirely.
- Zone tier multiplier multiplies each rarity's chance based on content type:
- Open world: ×1.0 (baseline)
- Dungeon: ×1.6 — meaningfully better quality distribution
- Raid: ×2.8 — best loot in the game
- Slot selection is class-biased using
_CLASS_SLOT_WEIGHTS— Mages get more off-hand/staff drops, Warriors get more armor/melee drops - Weapon names are class-appropriate via
_CLASS_WEAPONS— Mages get Staff/Wand/Tome, Rogues get Dagger/Blade/Shiv - Adjectives are class-themed via
_CLASS_ADJECTIVES— Warlocks get "Cursed/Fel/Void", Paladins get "Holy/Blessed/Sacred" - Auto-itemization: if dropped item's stat total > currently equipped item's stat total in the same slot, it's automatically equipped. Old item goes to inventory. Both
auto_equippedanddisplaced_itemare returned in the attack response. - Bag drop comparison: when an item goes to the bag instead of auto-equipping, the message includes a stat comparison vs what's currently equipped:
+5 damage (Uncommon) (equipped: +8 damage) - Rare drop announcement: named boss kills and Epic+ drops trigger a
★★★ RARE DROP ★★★message in the combat log - Inventory UI: bag slots are clickable — clicking a slot equips the item immediately. The hover tooltip shows the stat delta vs the currently equipped piece (
▲ +3 damage upgrade,▼ -1 downgrade, or▲ Empty slot — instant upgrade). Items glow by rarity: green (Uncommon), blue (Rare), purple (Epic), orange (Legendary).
vector_db.py → DBManager
SQLite (stdlib sqlite3) stores two tables — players and zones — each with id TEXT PRIMARY KEY and data TEXT (JSON blob). Game objects are serialized via Pydantic model_dump(mode='json') before writing. INSERT OR REPLACE handles upserts atomically.
WAL journal mode means reads never block writes and vice versa — important because the simulation loop writes zones concurrently with player requests. On every server startup, DBManager.__init__ runs PRAGMA wal_checkpoint(TRUNCATE) to flush any leftover WAL file from the previous session — no manual cleanup ever needed.
Critical rule: Zone state must be saved after every attack tick, not just on mob death. Without this, each new attack request would reload the mob at full HP from the last saved state — the mob appears to "heal" between hits.
Cache is a simple dict: {id: (data, timestamp)}. LRU eviction kicks in at 200 entries.
Why not LanceDB? LanceDB is a vector database built for semantic similarity search. This game never uses vector search — players are identified by UUID, zones by ID. LanceDB added
lancedb,pandas,pyarrow,numpy, andtantivyas heavy dependencies for zero benefit. SQLite is built into Python, orders of magnitude faster for key-value access, and trivially inspectable with any SQLite browser.
simulation.py → SimulationEngine
Runs as an asyncio.create_task started at FastAPI startup (@app.on_event("startup")). Ticks every 10 seconds over all zones currently in the zone cache:
- Respawns dead mobs whose
respawn_atUnix timestamp has passed - Regens alive mob HP to full when no real player is present — prevents mobs from staying at low HP indefinitely after an incomplete fight
- Moves SimulatedPlayers to adjacent locations (20% chance per tick)
- Shifts weather (5% chance)
- Advances
time_of_day(0.0–1.0, full cycle ~17 minutes) - Has a 10% chance to call AI for an ambient zone atmosphere message — only for zones with a real player currently present (idle cached zones are skipped to avoid wasteful AI calls)
Every 45 seconds the frontend fires POST /action/patrol_check/{player_id} when the player is idle in a non-hub location with no live mobs. The backend has a 25% chance to spawn a wandering mob from the zone's existing mob pool (thematically consistent — no generic enemy types). The mob is added to the live location and the client shows ⚠ A [mob] crosses your path!, then immediately starts auto-attacking — the player cannot ignore a patrol encounter.
Patrol spawns are skipped when:
- The current location is the hub (has NPCs)
- There are already live mobs at the location
- The location is a path location (
loc.resourcesis non-empty) — paths are safe zones
Path locations between the hub and each POI have two passive gold sources available at all times — no quest required.
| Action | Commands | Cooldown | Item | Sell value |
|---|---|---|---|---|
| Harvest | harvest / pick / herb |
8 s | Named plant (slot="material") |
~4 × level gold |
| Fish | fish / angle / cast |
12 s | Named fish (slot="material") |
~4 × level gold |
- Backend checks
loc.resources[0](plant) /loc.resources[1](fish) — no harvest on empty locations - Blocked while any mobs are alive at the location (endpoint returns an error)
- Per-action cooldowns tracked server-side in
_harvest_times/_fish_timesdicts (separate from attack cooldown) - Material items use
slot="material"— not equippable, included in Sell Junk, sellable at any vendor - The action bar shows 🌿 [PlantName] and 🎣 [FishName] buttons only on path locations, showing the actual resource name from the zone
- A gold-border pulse animates the terminal frame during both harvest and fishing (same style as forage gather)
app/core/ai_client.py → LMStudioClient
Wraps the openai SDK pointed at LM Studio's local server. Three methods:
generate_content(prompt, system_prompt, max_tokens)→str— for NPC dialogue, world chat, ambiancestream_content(prompt, system_prompt, max_tokens)→ async generator — for narrative streaming (thought-block stripping built in, 15s timeout)generate_json(prompt, system_prompt, max_tokens)→dict— for zone/mob generation; strips markdown fences before JSON parse
max_tokens budget per call site:
| Call | Limit | Reason |
|---|---|---|
| World chat reply | 45 | Casual 1-liner responses |
| Ambiance message | 40 | Single server notification |
| Location description | 60 | One atmospheric sentence |
| Mob / NPC description | 80 | Two vivid sentences |
| Death scene | 80 | Two dramatic sentences |
| NPC dialogue | 120 | 1–2 sentences + hint |
| Narrative stream | 150 | Short outcome description |
| Zone generation JSON | 700 | Full JSON structure needed |
Ambiance generation only runs for zones with a real player currently present — idle zones in cache are skipped.
All callers wrap in try/except and provide contextual fallbacks so the game works fully offline.
main.py → /narrative/world_chat + _CHAT_PERSONALITIES
World chat responses come from the zone's actual simulated players — not a static pool of names. The frontend sends sim_player_names (comma-separated names from zone.simulated_players) and the backend picks one to respond. Sim players sound like real people at a keyboard playing the same game you are — not fantasy NPCs.
Each sim player has a stable personality derived deterministically from their name hash:
| Personality | Behavior |
|---|---|
| Veteran | Seasoned, tired confidence. Dry humor. Drops useful tips occasionally. Never hyped. |
| Try-hard | Grinding hard, focused, slightly impatient. Talks about kills and progress. |
| Reckless | Dies a lot and finds it funny. Self-deprecating, chaotic, always doing something dumb. |
| Quiet | Few words, chill. Responds briefly when spoken to. Never volunteers information. |
| Complainer | Talks normally but complains about the game — mobs, loot, zone, whatever. Keeps playing anyway. |
| Helper | Laid back, helpful when it comes up naturally. Talks like a friend, not support staff. |
Name addressing — if your message contains a sim player's name, that player responds. Matching supports:
- Full name (
"thornwood") - CamelCase first token (
"iron"→ IronGrog) - Any unique prefix ≥ 3 chars at a word boundary (
"oz"→ Ozric,"mist"→ MistRunner)
Group responses — if you say "you guys", "everyone", "anyone", etc., or address two names in one message, two sim players respond independently with a staggered delay.
Sim players initiate chat unprompted — a frontend interval fires every 30–60 seconds with a 60% fire chance, sending an ambient prompt to the backend. This makes the world feel alive without requiring player input.
Session memory — after every 10 player messages, the backend generates a 1-sentence summary of the conversation and prepends it to the system prompt as context for future replies. Cleared on zone travel.
Inter-player references — 25% of responses include a nudge to reference another sim player by name, creating natural banter.
Ghost player prevention — the endpoint only picks from the sim_player_names list passed by the frontend. If no sim players exist in the zone, no response is generated.
Hallucination guards — the system prompt explicitly constrains the model to: only name creatures that actually exist in the zone, never invent prices/spawn rates/lore/game mechanics, and never speak in-character as a fantasy NPC. Hard rules appear at the top of the system prompt so small local models read them before persona context.
History context keeps the last 10 lines, prioritising the last 3 lines from the responding player for continuity. Fallbacks cover contextual responses by mob / zone — they only fire when the player's message is actually about that topic.
/who output separates players at your current location ([HERE]) from those elsewhere in the zone, so you can see who's nearby at a glance:
- Thornwood (Lvl 4 Elf Rogue) [Sunken Graves] - BATTLING
- Ozric (Lvl 3 Human Mage) [Barrowmoor Hub] - EXPLORING
main.py → /describe/entity + /describe/location
Every encounter and location transition triggers a short AI-generated description — one sentence to two sentences, plain prose, no stats or markdown.
- Mob description: fires the first time you engage a creature in combat. Describes appearance, movement, and threat. Elite and named rank modifiers are passed to the prompt so legendary bosses feel distinct from common mobs. Cached in-memory by name — each creature type is described once per session.
- NPC description: fires when you talk to an NPC. Covers appearance and one personality trait. Also cached by name.
- Death scene: fires when you are killed in combat or die while fleeing. A 2-sentence dramatic account of the fatal moment — always unique, never cached.
- Location description: fires when you move to a new node within a zone, or arrive at the hub location of a new zone. One atmospheric sentence grounded in the location name and static description. Cached by location name.
All four description types fall back silently if the AI is unavailable — no error is shown, the static game text is simply not supplemented.
The top-of-screen ticker scrolls 6 information slots continuously. The last two are dynamically driven by the player's exact loop stage:
| Stage | Progress slot | Next Step slot |
|---|---|---|
| Level 1–9 | GS: 12 — LEVEL 4 / 10 NEEDED FOR DUNGEONS |
GRIND QUESTS → REACH LEVEL 10 → ENTER DUNGEONS |
| Level 10–19 | GS: 85 — LEVEL 14 / 20 NEEDED FOR RAIDS |
RUN DUNGEONS → BUILD GEAR SCORE → UNLOCK RAIDS AT LEVEL 20 |
| Level 20+, GS below threshold | GS: 650 / 1000 REQUIRED TO ADVANCE |
FARM RAIDS FOR EPIC GEAR → HIT 1000 GS → TYPE 'TRAVEL' |
| GS threshold met | ✓ GS: 1009 / 1000 — ZONE COMPLETE |
ZONE CLEARED — TYPE 'TRAVEL' TO ADVANCE |
This means a brand-new player always knows what to do next without reading a guide. The ticker is the tutorial.
The bottom toolbar is a real-time contextual action bar — its buttons and their assigned numbers rebuild every time the world state changes. Button order is always deterministic:
1 → Look
2…N → Exits (one per available direction)
N+1… → Attack buttons (one per unique alive mob type at current location)
… → Turn In (only when quest giver is present + quests completed)
… → Talk (one per quest-giver NPC at location)
… → Shop / Sell (when vendor is present; Sell only if inventory non-empty)
… → Gather (only when an active forage quest targets the current location)
… → 🌿 Harvest (only on path locations — shows actual plant name)
… → 🎣 Fish (only on path locations — shows actual fish species)
… → Quests, Bags, Who
? → Help (always last, always ?)
Number hotkeys work two ways:
| Method | How it works |
|---|---|
| Press digit with empty input | Fires the action immediately — no Enter needed |
| Type digit + Enter | Resolves the same map and executes the command |
Context-awareness examples:
- At a hub with a quest giver and vendor: Talk might be
4, Shop5, Quests6 - In a combat area with two mob types:
attack Boar=3,attack Spider=4, Quests shifts to5 - When a forage quest is active at your location: Gather appears before Quests, shifting everything after it
- While gathering / harvesting / fishing is in progress: the relevant button is disabled — pressing its number does nothing
- Typing a number into the command box mid-sentence is safe — hotkeys only fire when the input is blank
The hotbar action map is maintained in a hotbarActionsRef (a useRef<Map<number, () => void>>) that is rebuilt via useEffect whenever zone, player, combat target, or gathering/harvesting/fishing state changes. This keeps the keydown handler stateless and free from stale closure bugs.
Visual feedback:
- The terminal border pulses red while in combat (
autoAttackTargetis set) - The terminal border pulses gold while gathering, harvesting, or fishing
- Attack buttons show a draining red cooldown overlay during auto-attack
- Gather button shows a draining green overlay during the 8s gather cooldown
- The target frame (top-right) shows a colour-coded progress bar with a live countdown timer during resource actions:
- Forage gather → yellow bar, 8s
- Harvest → green bar, 8s, shows plant name
- Fish → blue bar, 12s, shows fish species
The minimap radar (top of the right panel) shows live entity state for your current location:
| Blip | Meaning |
|---|---|
| Gold center dot | You |
| Blue inner ring | NPCs |
| Red mid ring | Alive mobs |
| Red mid ring (faded) | Dead mobs — respawning |
| Green-tinted outer ring | Sim players currently at your location |
Blips are arranged in deterministic rings so position doesn't change between ticks. Hovering a blip shows a tooltip with name and level. The time-of-day icon (🔆 / 🌙) reflects the current in-game time.
The terminal log renders inline markdown from AI-generated text:
**bold**→ gold/accent bold*italic*→ soft italic`code`→ monospace accent
Applied to all log lines including NPC dialogue, narrative stream output, and combat messages. Implemented in renderLogText → parseMarkdown in page.tsx.
Quests live on the Zone (zone.quests) and are accepted into player.active_quests. Progress tracking varies by type:
- kill / gather: client-side on mob death, synced to backend via
POST /quests/progress/{player_id}.gatherandkillare distinct quest types —gatherrequires a specific mob collectible and tracks via mob-name matching;forageuses thegathercommand and is completely separate. - hunt: completes when any
target_is_named == truekill is recorded (uses the backend flag, not mob name matching — immune to named mob rename variants) - explore: auto-completes server-side in
POST /action/movewhenlocation_id == quest.target_id; backend returnsexplore_completedarray in the move response. Once visited, explore quests for that location are never re-offered. - forage: completed by using the
gathercommand (or clicking GATHER) while standing inquest.target_idlocation. Backend endpoint/action/gatherwith 8 s cooldown. Progress increments once per gather action. Completely separate from mob-kill gather quests — no crossover.
Quests are repeatable. All quest types can be re-accepted after completion — quests are grind content, not one-time story beats. NPCs always re-offer completed quests as long as they aren't currently active.
Turn-in happens at any hub quest giver NPC via POST /quests/complete/{player_id}, which awards XP and optionally an item reward. Zone travel is not unlocked by quest completion alone — it requires GS ≥ 1000. A player who has cleared dungeons and raids to hit that threshold will have naturally engaged with the zone's content. "★ ZONE CLEARED!" fires only when travel actually succeeds.
backend/app/core/scaling_math.py · combat_engine.py · world_generator.py
All game numbers derive from four formulas. Every curve was validated by simulation — the sim confirmed these produce the intended pacing before any player touched the browser.
max_hp(L) = int( 100 × 1.15^(L−1) + L × 10 )
The exponential term gives compound growth; the linear term keeps early levels feeling meaningful. Class multiplier applied after.
| Level | Base HP | Warrior ×1.20 | Mage ×0.80 |
|---|---|---|---|
| 1 | 110 | 132 | 88 |
| 5 | 224 | 268 | 179 |
| 10 | 451 | 541 | 360 |
| 20 | 1,623 | 1,947 | 1,298 |
| 50 | 94,731 | 113,677 | 75,784 |
| 100 | 102,115,213 | 122,538,255 | 81,692,170 |
At level 100 a Warrior has 122 million HP — displayed as 122.5M by the frontend formatter. This is intentional: ascension players at level 100 cycle 100 are dealing billions of damage per hit.
damage(L) = int( 10 × 1.15^(L−1) + L × 2 )
Same exponential base as HP, calibrated so damage/HP ratio stays roughly constant — fights don't get shorter or longer as players level, they stay at the same ~8-hit pace.
| Level | Base DMG | Rogue ×1.20 | Mage ×1.30 | Priest ×0.85 |
|---|---|---|---|---|
| 1 | 12 | 14 | 15 | 10 |
| 5 | 27 | 32 | 35 | 22 |
| 10 | 55 | 66 | 71 | 46 |
| 20 | 182 | 218 | 236 | 154 |
| 50 | 9,523 | 11,427 | 12,379 | 8,094 |
| 100 | 10,211,621 | 12,253,945 | 13,275,107 | 8,679,877 |
xp_required(L) = 100 × L × (L + 1)
xp_per_kill = xp_required(mob.level) // 8
The polynomial curve keeps the per-level XP requirement readable at any level (no scientific notation needed for the XP bar). The // 8 constant on mob XP is the core design constraint: you always need ~8 kills to level up, regardless of whether you're level 1 or level 100. Elites give 2× XP; named mobs give 4×.
| Level | XP Required | XP per Kill | Kills to Level |
|---|---|---|---|
| 1 | 200 | 25 | ~8 |
| 5 | 3,000 | 375 | ~8 |
| 10 | 11,000 | 1,375 | ~8 |
| 20 | 42,000 | 5,250 | ~8 |
| 50 | 255,000 | 31,875 | ~8 |
| 100 | 1,010,000 | 126,250 | ~8 |
The max(player.level, mob.level) rule in the XP calculation means you always earn XP based on the harder of the two levels — grinding low-level mobs never yields diminishing returns. This keeps the open world viable as a catch-up mechanism at any point in the game.
A = attacker.level × 10 (accuracy ceiling)
D = target.level × 8 + armor_stat × 3 (defense ceiling)
attacker_roll = random(1, A)
defender_roll = random(1, D)
hit = attacker_roll > defender_roll
Expected probability (continuous approximation):
⎧ 1 − D / (2A) if A ≥ D
P(hit) = ⎨
⎩ A / (2D) if A < D
| Scenario | A | D | P(hit) |
|---|---|---|---|
| Lv1 vs Lv1 starter mob (no armor) | 10 | 8 | 60% |
| Lv10 vs Lv10 elite (armor stat 8) | 100 | 104 | 48% |
| Lv20 vs Lv20 raid boss (no armor) | 200 | 160 | 60% |
| Lv20 vs Lv25 over-level (armor 12) | 200 | 236 | 42% |
Equal-level fights land at 60% — comfortable but not trivial. Armor matters: each point of armor adds 3 to the defender's ceiling, pushing hit rate down. The formula has no hard floor — a sufficiently armored enemy at a much higher level can make your attacks miss more than half the time, which is the intended "too strong for you" signal.
GS = Σ (over all 7 equipped slots) sum(item.stats.values()) × rarity_mult
Rarity multipliers: Common 1.0 · Uncommon 1.5 · Rare 2.5 · Epic 4.0 · Legendary 7.0
Item stat value = int( mob_level × rarity_stat_mult )
The rarity multiplier appears twice — once when the item's stat value is rolled (higher rarity = higher stat), and again when GS is calculated (higher rarity = multiplied more). A Legendary item from a level 20 boss isn't just 7× better in stats — it compounds to nearly 49× the GS contribution of a Common item from the same mob.
| Source | Rarity | Item stat | GS contribution |
|---|---|---|---|
| Lv1 starter (Common) | Common | 1 | 1 |
| Lv10 dungeon | Uncommon | 15 | 22 |
| Lv10 dungeon | Rare | 25 | 62 |
| Lv10 dungeon | Epic | 40 | 160 |
| Lv20 raid normal | Rare | 50 | 125 |
| Lv20 raid normal | Epic | 80 | 320 |
| Lv20 raid boss | Legendary | 140 | 980 |
What GS 1000 looks like in practice: a mix of level-20 raid drops — roughly 2 Epics + 3 Rares + 2 Uncommons across your 7 slots. One lucky Legendary from the raid boss alone nearly hits the gate (980 GS). The sim confirmed 3–5 raid clears to reach 1000 GS reliably.
effective_chance = min(1.0, base_chance × tier_boost)
Tier boosts: open world 1.0× · dungeon 1.6× · raid 2.8×
Rarities are checked best-to-worst — the first entry that passes its roll is returned. This means the tier boost raises the floor of quality, not just volume. Common is always last so it never blocks higher rarities.
Normal mob drop table (per check, not per kill — nothing drops if all checks fail):
| Rarity | Base | Dungeon (×1.6) | Raid (×2.8) |
|---|---|---|---|
| Epic | 2% | 3.2% | 5.6% |
| Rare | 8% | 12.8% | 22.4% |
| Uncommon | 20% | 32% | 56% |
| Common | 40% | 64% | 100% (capped) |
| No drop | ~40% | ~5% | 0% |
Named boss (guaranteed Rare minimum):
| Rarity | Chance |
|---|---|
| Legendary | 10% |
| Epic | 40% |
| Rare | 100% (fallback — always fires if Legendary and Epic both missed) |
Elite mob:
| Rarity | Chance |
|---|---|
| Epic | 8% |
| Rare | 35% |
| Uncommon | 80% |
arc_difficulty = 1.10 ^ ascension_count (exponential, grows each ascension)
zone_difficulty_mult = (1.0 + (zone_number − 1) × 0.2) × arc_difficulty
ascension_damage_mult = 1.15 ^ ascension_count (compounds permanently on player)
effective_player_damage = player.damage × ascension_damage_mult
effective_mob_hp = base_mob_hp × zone_difficulty_mult
Three scalars interact to produce an infinite loop:
- Zone wall (
zone_difficulty_mult): makes each zone harder within the arc - Arc wall (
arc_difficulty): makes each arc's mobs harder than the last — grows at 1.10^N - Player mult (
ascension_damage_mult): player grows at 1.15^N — always faster than arc difficulty
Net benefit per ascension: 1.15 / 1.10 ≈ 4.5% speed gain. Runs get faster forever, but Zone 10 can never be trivialized — mobs always scale ahead of a naked level-1 character.
| Ascension | Player ×DMG | Zone 10 mob ×HP | Ratio (player advantage) |
|---|---|---|---|
| 0 | ×1.00 | ×2.80 | 0.36 (must grind) |
| 5 | ×2.01 | ×4.51 | 0.45 |
| 10 | ×4.05 | ×7.25 | 0.56 |
| 20 | ×16.4 | ×18.8 | 0.87 |
| 40 | ×267 | ×127 | 2.1 (can clear at lower level) |
| 100 | ×1,174,313 | ×13,781 | 85 (fastest possible arc) |
Applied via temporary combat copies — neither multiplier is ever persisted back to the DB.
backend/app/models/schemas.py — authoritative source, all fields documented below.
| Model | Key Fields | Notes |
|---|---|---|
Player |
level, hp/max_hp, xp/next_level_xp, gold, kills, deaths, inventory: List[Item], equipment: Dict[str, Item], active_quests, current_zone_id, current_location_id, visited_zone_ids, rested_xp, last_logout_time, dungeons_cleared, raids_cleared |
Equipment slots: head chest hands legs feet main_hand off_hand. raids_cleared drives open-world zone tier escalation (+3 levels per raid). active_dungeon_run_id tracks an in-progress run. |
Zone |
id, name, locations: List[Location], quests, simulated_players, time_of_day (0–1), weather, is_dungeon, is_raid |
Zone is the top-level open-world unit. Instanced dungeons use DungeonRun, not Zone. |
Location |
id, name, description, npcs: List[NPC], mobs: List[Mob], exits: Dict[str, str], resources: List[str] |
Exits map direction → location_id. resources is [plant_name, fish_species] for path locations, [] everywhere else. |
Mob |
id, name, level, hp/max_hp, damage, loot_table, respawn_at (Unix ts or None), is_elite, is_named |
respawn_at = None means alive. Reset to max_hp and respawn_at = None when timer fires. |
NPC |
id, name, role (quest_giver/vendor/trainer), dialogue, quests_offered, vendor_items |
Vendors have vendor_items: List[Dict] with price key |
Item |
id, name, description, level, rarity, stats: Dict[str, int], slot |
Equipment stats: armor or damage. Consumables use slot = "consumable" with effect encoded in stats: {"heal_pct": 40} or {"xp_bonus_pct": 75, "xp_charges": 5}. Harvest/fish drops use slot = "material" with stats: {"value": 5} — not equippable, included in Sell Junk. |
Quest |
id, title, objective, quest_type (kill/gather/hunt/explore/forage), target_id, collect_name (gather/forage quests), target_count, current_progress, xp_reward, is_completed |
forage quests use target_id as a location ID (same as explore); progress via /action/gather not mob kills. |
SimulatedPlayer |
id, name, race, char_class, current_location_id, status |
Background actors — not real players. current_location_id resolves to a location name in the /who output. |
DungeonRun |
id, player_id, dungeon_name, dungeon_level, is_raid, room_index, rooms: List[DungeonRoom], party: List[DungeonMember], combat_log, status (active/cleared/wiped), boss_enraged, pending_telegraph |
Stored in-memory only (_dungeon_runs dict). Lost on server restart. pending_telegraph is a PendingTelegraph object (name, damage_mult, is_oneshot, window_ms) when a boss wind-up is active; null otherwise. |
DungeonRoom |
index, name, mobs: List[Mob], cleared |
Rooms 0–3 for dungeons (room 3 = boss), 0–6 for raids (room 6 = final boss). Corridor rooms have 1–2 light mobs. |
DungeonMember |
id, name, char_class, role (tank/healer/dps), hp/max_hp, damage, last_action, is_alive |
AI party member. Stats derived from ScalingMath × role multiplier. Uses same _CLASS_PROCS table as players. |
All endpoints are in backend/main.py. Backend runs on http://localhost:8000.
| Method | Path | Description |
|---|---|---|
GET |
/players |
List all saved characters as summary cards (name, level, race, class, kills, gold, etc.). Used by the load-game screen. Returns {players: [...]} sorted by level desc. |
GET |
/player/{player_id} |
Load a specific character + their current zone. Returns {player_id, player, zone, gear_score}. gear_score is computed fresh each load for the HUD indicator. |
DELETE |
/player/{player_id} |
Delete a single character and all zones in their visited_zone_ids. Clears their attack cooldown. Irreversible. |
POST |
/player/create |
Create character. Params: name, race, char_class, pronouns. Returns {player_id, player, zone} |
| Method | Path | Description |
|---|---|---|
GET |
/zone/{zone_id} |
Fetch full zone state |
POST |
/zone/travel/{player_id} |
Generate + travel to new open-world zone. Params: is_dungeon (deprecated — use /dungeon/enter), is_raid. Zone level = player.level + (raids_cleared × 3) — escalates with each raid tier. Requires: GS ≥ 1000 (fixed gate — not level-scaled). A player with 1000 GS has necessarily cleared dungeons and raids and engaged deeply with the zone. |
| Method | Path | Description |
|---|---|---|
POST |
/action/move/{player_id} |
Move to location. Param: location_id |
POST |
/action/attack/{player_id} |
Attack mob. Params: mob_name, dodged (bool, default false — set true when player dodges a pending open-world telegraph; bypasses rate limit). Returns full combat delta + pending_telegraph (dict or null). |
POST |
/action/flee/{player_id} |
Flee combat. 60% escape chance, counter-hit on failure. Param: mob_name |
POST |
/action/equip/{player_id} |
Equip item from inventory. Param: item_id |
POST |
/action/unequip/{player_id} |
Move equipped item back to bag. Param: slot (head, chest, hands, legs, feet, main_hand, off_hand) |
POST |
/action/talk/{player_id} |
Talk to NPC. Param: npc_name. Returns dialogue, offered_quests, vendor fields |
POST |
/action/use/{player_id} |
Use a consumable from inventory. Param: item_id. Enforces per-type cooldowns (heal 60 s, xp 5 min). Returns player_hp, active_xp_buff, heal_cd, xp_cd. |
POST |
/action/rest/{player_id} |
Persist out-of-combat HP regen. Param: hp (clamped to [1, max_hp] server-side). Called by frontend timer every ~10 s while regenerating. |
POST |
/action/gather/{player_id} |
Progress active forage quests targeting current location. 8 s cooldown. Returns messages, quest_updates. |
POST |
/action/harvest/{player_id} |
Harvest a plant from a path location (loc.resources[0]). 8 s cooldown. Blocked if alive mobs present. Returns item, messages. |
POST |
/action/fish/{player_id} |
Fish at a path location fishing hole (loc.resources[1]). 12 s cooldown. Blocked if alive mobs present. Returns item, messages. |
POST |
/action/patrol_check/{player_id} |
25% chance to spawn a wandering zone-mob in current location (non-hub, no live mobs, non-path only). Returns { patrol, mob_name, mob_level }. |
POST |
/action/login/{player_id} |
Compute and credit rested XP accumulated since last logout. Called on character load. Returns rested_xp, rested_xp_cap. |
POST |
/action/logout/{player_id} |
Stamp logout time for rested XP calculation. Called via sendBeacon on beforeunload. |
| Method | Path | Description |
|---|---|---|
POST |
/quests/accept/{player_id} |
Accept quest. Param: quest_id |
POST |
/quests/progress/{player_id} |
Sync kill/gather progress. Params: quest_id, progress |
POST |
/quests/complete/{player_id} |
Turn in completed quest. Param: quest_id. Returns XP + item reward |
| Method | Path | Description |
|---|---|---|
GET |
/vendor/{player_id} |
Get vendor stock + player gold. Param: npc_name |
POST |
/vendor/buy/{player_id} |
Purchase item. Params: npc_name, item_id |
POST |
/vendor/sell/{player_id} |
Sell inventory item. Price = item.level × stat_total × 2. Param: item_id |
POST |
/vendor/sell_junk/{player_id} |
Sell all Common-rarity non-consumable items at once. Returns gold_gained, sold_count, player_gold. |
| Method | Path | Description |
|---|---|---|
POST |
/dungeon/enter/{player_id} |
Create an instanced dungeon or raid run. Param: is_raid (bool). Gates: lv10/lv20 + GS≥100 for raids. Returns full DungeonRun. |
GET |
/dungeon/run/{run_id} |
Fetch an active run by ID. Used by the frontend to restore dungeon state on page reload. Returns 404 if the run is not in memory (e.g. server restarted). |
POST |
/dungeon/attack/{run_id} |
Resolve one full combat round (player + all AI party members + mob counter-attacks). Params: player_id, dodged (bool, default false — set true when player successfully dodges a telegraph). Returns run, round_log, room_cleared, run_cleared, wiped, xp_gained, gold_gained, loot. |
POST |
/dungeon/advance/{run_id} |
Move to the next room after the current one is cleared. Param: player_id. |
POST |
/dungeon/flee/{run_id} |
Abandon the run. Clears player.active_dungeon_run_id. Param: player_id. |
| Method | Path | Description |
|---|---|---|
POST |
/admin/reset |
Wipe all persisted game data (players + zones). Full clean slate — no server restart needed. Dev/testing only. |
POST |
/admin/boost/{player_id} |
Dev/sim only. Instantly set the player to level with class-appropriate stats and preset gear. Params: level (1–100, default 10), preset (dungeon or raid, default dungeon). dungeon → lv10, ~94 GS (Legendary weapon + Epic/Rare/Uncommon armor). raid → lv20, ~280 GS (Rare weapon + Uncommon armor, mirrors late-dungeon phase). Returns {level, hp, damage, gear_score, gold}. |
| Method | Path | Description |
|---|---|---|
GET |
/narrative/stream/{player_id} |
Streamed AI narrative for any action. Param: action |
POST |
/narrative/world_chat |
AI world chat response. Params: message, player_name, player_bio, zone_name, location_name, weather, mobs_nearby, time_of_day, active_quests, sim_player_names (comma-separated names of zone's sim players — used to pick the responding character) |
GET |
/describe/entity |
AI description for a mob or NPC. Params: name, entity_type (creature/npc/death), is_elite, is_named, zone. Cached by name except death scenes. |
GET |
/describe/location |
AI atmospheric sentence for a location. Params: name, loc_description, zone. Cached by location name. |
All persisted state lives in backend/data/mud.db (a single SQLite file). Multiple characters can be saved simultaneously — each with their own zones, quests, and inventory.
Every character owns their world data via visited_zone_ids: List[str] on the Player model. This field is populated at creation ([initial_zone.id]) and appended each time the player travels to a new zone. It is the source of truth for "which zones belong to this character" and drives per-character cleanup on delete.
Player record ──── visited_zone_ids ────► Zone records (N zones per character)
(starter zone, all traveled zones)
The SQLite tables are:
| Table | Row key | Contents |
|---|---|---|
players |
id (player_id UUID) |
Full player state — stats, inventory, equipment, quests, zone IDs |
zones |
id (zone_id UUID) |
Full zone state — locations, mobs (with respawn timers), NPCs, quests |
When the player presses Enter at the title screen, the frontend calls GET /players. If saved characters exist, it shows a structured card for each one (name, race/class, level, HP, gold, kills, deaths, quests completed) and waits for the player to type a number to continue that character, or new to create a fresh one. Selecting a character calls GET /player/{player_id} which returns the full player + their current zone, and the game resumes exactly where they left off.
In-app (while server is running): A ⚠ Reset button lives below the character biography in the left side panel during gameplay. Clicking it opens a 3-step confirmation flow:
- ⚠ Reset — opens the choice screen
- Choose what to delete:
- Character name — deletes only this character and all their zones (
DELETE /player/{player_id}) - All Characters — wipes everything (
POST /admin/reset)
- Character name — deletes only this character and all their zones (
- Final confirmation — per-choice warning before executing ("Delete [Name] forever?" or "Wipe every character?"), with a cancel option at every step
CLI script (server stopped):
# From the repo root:
python scripts/reset_data.pyscripts/smoke_test.py runs a fast happy-path integration test (under 60 seconds) against a live backend. It covers every major system in order, creates a throwaway character, and deletes it when done.
What it checks (18 sections):
character creation → zone topology (hub/path/POI structure) → movement → harvest & fish (cooldowns + material slot) → combat (attack, cooldown 429, kill, XP) → patrol check → login/logout rested XP → player list/load → NPC talk → quest accept → vendor → sell junk → dungeon gate (blocked at level 1) → zone travel gate (blocked at low GS) → describe endpoints → ascension gate (blocked at zone 1) + /admin/force_ascend mult verification
# Terminal 1 — start the backend
cd backend
.\venv\Scripts\activate
uvicorn main:app --reload --port 8000
# Terminal 2 — run the test
cd backend
.\venv\Scripts\activate
pip install requests # first time only — not in requirements.txt
python ..\scripts\smoke_test.py
# or against a different port:
python ..\scripts\smoke_test.py --base http://localhost:8001Exits 0 on all checks passing, 1 on any failure. Run it after any backend change — if something regresses, the failing section name tells you exactly where to look.
scripts/sim_run.py plays the full progression meta automatically — no browser, no clicking. It follows the same optimal loop a knowledgeable player would: talk to NPCs → accept all quests → harvest/fish every path → kill all mobs at each POI → forage when a quest targets the location → turn in at hub → sell junk → rebuy potions → repeat. It drives through all three content tiers before stopping.
Because the sim calls the exact same backend endpoints as the browser, it is the real game — the backend doesn't know whether the caller is Next.js or a Python script. Combat math, XP gains, loot rolls, quest tracking, dungeon party AI, and level-up logic are all identical. The only thing the sim skips is frontend rendering.
Four-phase meta loop:
| Phase | Goal | Stops when |
|---|---|---|
| Open world | Kill quests, harvest/fish, forage, level up | Level 10 reached |
| Dungeon loop | Dungeon runs back-to-back (no open world sweeps) | GS ≥ 100 AND level 20 |
| Raid loop | Run raids, attempt zone travel after each clear | Zone travel succeeds (GS ≥ 1000) |
| Ascension | Reach Zone 10, call /ascend, verify reset + mult |
Ascension count confirmed, new zone loaded |
Use the sim for:
- Verifying backend changes without touching the browser
- Checking balance — XP curve, kill counts per level-up, loot drop rates, GS progression
- Catching regressions after any change to
main.py,dungeon_engine.py, orscaling_math.py - Watching the loop play out and checking that numbers feel right
# Terminal 1 — start the backend
cd backend
.\venv\Scripts\activate
uvicorn main:app --reload --port 8000
# Terminal 2 — run the sim
cd backend
.\venv\Scripts\activate
pip install requests # first time only — not in requirements.txt (backend uses httpx)
# Full meta run (open world → dungeons → raids → zone travel)
python ..\scripts\sim_run.py
# Quick smoke check — one sweep + one dungeon, then stop
python ..\scripts\sim_run.py --quick
# Skip Phase 1 — boost to lv10 ~94 GS, jump straight to dungeon loop
# Saves ~35-50 min of open-world grind
python ..\scripts\sim_run.py --skip-to-dungeon
# Skip Phases 1+2 — boost to lv20 ~280 GS, jump straight to raid loop
# Saves ~60-90 min — use this to test raids and zone travel directly
python ..\scripts\sim_run.py --skip-to-raid
# Skip to Zone 10 and test the /ascend endpoint — verifies reset + damage mult
python ..\scripts\sim_run.py --skip-to-ascend
# Apply N ascension stacks via /admin/force_ascend — verify damage mult math
# Useful for checking that ×1.15^N is applied correctly at high counts
python ..\scripts\sim_run.py --ascensions 10
# Keep the character after the run for manual inspection in-browser
python ..\scripts\sim_run.py --no-cleanup
# Custom name + different port
python ..\scripts\sim_run.py --name BotWarrior --base http://localhost:8001scripts/boost_char.py solves a specific problem: the game's content tiers (dungeons at level 10, raids at level 20) require real grinding to reach organically — 30–90 minutes of open-world play. That's fine for players, but it makes iterating on dungeon and raid UI or mechanics extremely slow for the developer.
The script creates a fresh character and calls POST /admin/boost/{player_id} to instantly set them to the correct level, stats, and gear score for the target tier. The character is saved to the database and appears immediately in the browser's Load Game screen — no grinding, no sim run, no waiting.
# Dungeon-ready: level 10, GS ~94 — can enter dungeons immediately
python ..\scripts\boost_char.py
# Raid-ready: level 20, GS ~280 — can enter raids immediately
python ..\scripts\boost_char.py --target raid
# Custom character name
python ..\scripts\boost_char.py --target dungeon --name AldricWhy this isn't a cheat concern: The /admin/boost endpoint is backend-only — it has no button, no command, and no mention anywhere in the game UI. Players running the game normally through the browser have no way to discover or trigger it. Anyone technically capable of finding it in the source code could already call any backend endpoint directly, so the endpoint adds no meaningful cheat surface. This is the same pattern used by /admin/force_ascend (used by the sim) and /admin/reset (used by the reset script) — all dev-only tooling that happens to share the same server process.
The boost endpoint is intentionally not rate-limited, not authenticated, and not hidden — it's a development tool, not a security boundary. Single-player local games don't have meaningful cheat protection at the HTTP layer; the gates are game design guardrails, not access control.
Every log line is timestamped with seconds elapsed since sim start. Each section header shows how long the previous section took.
Milestone timeline — the final summary always reprints every phase transition with its timestamp, regardless of how much the terminal scrolled. Example:
══════════════════════════════════════════════════════════════════
Total time: 923.4s (15.4 min)
── Milestone Timeline ──────────────────────────────────
[00:00] SKIPPED TO RAID — entering Phase 3 Lv20 GS 280 D=0 R=0
[02:31] ZONE TRAVEL SUCCESS — Phase 3 Complete Lv22 GS 1084 D=0 R=2
══════════════════════════════════════════════════════════════════
Full-run example (no skip flags):
[00:00] PHASE 1 → 2: Open World Complete Lv10 GS 94 D=0 R=0
[18:44] PHASE 2 → 3: Dungeon Phase Complete Lv20 GS 134 D=9 R=0
[31:07] ZONE TRAVEL SUCCESS — Phase 3 Complete Lv21 GS 1102 D=9 R=3
Columns: [MM:SS] event Lv=player level GS=gear score D=dungeons cleared R=raids cleared
Per-run analytics box — printed after every dungeon and raid clear (or wipe):
┌─ RAID 2 analytics ─────────────────────────────────
│ CLEARED · 34 rounds · ~87 dmg/round · +4200 XP · +340g
│ Telegraphs 3 · Dodges 3 · Party deaths 1
│ Procs: 4×FURY 3×SHOT 2×MEND 1×DRAIN
│ Loot: 1×Epic 2×Rare
└──────────────────────────────────────────────────────
Aggregate analytics section — printed before FINAL CHARACTER STATE, totals across all runs of each type:
── DUNGEON aggregate (9 runs) ────────────────────────────────
Rounds 218 · ~62 dmg/round · +38400 XP · +2870g
Telegraphs 14 · Dodges 14 · Party deaths 6
Procs: 31×FURY 18×SHOT 12×MEND 9×GRACE 6×DRAIN
Loot: 2×Epic 15×Rare 9×Uncommon
── RAID aggregate (3 runs) ────────────────────────────────────
Rounds 97 · ~118 dmg/round · +21600 XP · +1640g
Telegraphs 11 · Dodges 11 · Party deaths 4
Procs: 14×FURY 9×SHOT 7×MEND 4×DRAIN
Loot: 3×Epic 6×Rare
Reading the output — things to watch for:
| Signal | What it means |
|---|---|
Red ✗ lines |
Hard error — endpoint returned unexpected status or request failed |
Party wiped! |
Dungeon party undertuned for zone level, or damage scaling off |
| Level-ups very fast or very slow | XP curve drifted — check ScalingMath.get_xp_required |
Sold 0 junk after harvest+fish |
Material items not reaching inventory, or sell_junk slot filter broken |
Dungeon entry failed unexpectedly |
Level gate in /dungeon/enter too strict, or player level not persisting |
Loot: [] on dungeon/raid clear |
_roll_loot not firing — check dungeon_engine.py |
| No path locations | World generator path insertion broke for this zone type |
Too many empty sweeps |
Mob respawn timer too long or respawn logic broken |
Hit dungeon cap without reaching GS 100 |
Dungeon loot not scaling GS fast enough — check loot tier multipliers |
Hit raid cap without zone travel |
Raid loot not pushing GS over zone travel threshold |
Telegraphs N · Dodges 0 |
Telegraph mechanic broken — sim should always dodge, check pending_telegraph in response |
Party deaths very high |
Mob damage overtuned for party HP pool at that level |
~dmg/round much lower than expected |
Party members not contributing damage — check _member_as_mob or role logic |
If something looks off, paste the full terminal output — the timestamps make it easy to spot where time is being spent unexpectedly.
| Operation | Deletes |
|---|---|
DELETE /player/{id} |
That player's row + all zone rows in their visited_zone_ids |
POST /admin/reset |
All rows in players and zones tables — full wipe |
scripts/reset_data.py |
Deletes backend/data/mud.db entirely — full wipe |
The backend/data/ directory is listed in .gitignore — it is never committed.
- Node.js 18+
- Python 3.10+
- LM Studio with a model loaded and local server started on port 1234
✨ Recommended Model: Qwen3.5 — Qwen3.5 (9B recommended) is the best fit for this game. It handles JSON generation, NPC dialogue, world chat, and narrative summaries well at low token budgets, runs fast on consumer hardware, and follows system prompt constraints reliably. Load it in LM Studio and set:
$env:LM_STUDIO_MODEL="qwen3.5-9b"(use the exact model ID shown in LM Studio).
⚡ LM Studio Performance Tip: Set Thinking Mode → Off in your loaded model's settings before starting the local server. Thinking/reasoning mode causes the model to emit large internal monologue blocks before every response, dramatically increasing latency. With thinking off, responses arrive 3–5× faster. The game already strips thought blocks from streams as a safety net, but disabling it at the model level is the correct fix.
cd backend
python -m venv venv
.\venv\Scripts\activate
pip install -r requirements.txt
uvicorn main:app --reload --port 8000cd frontend
npm install
npm run devOpen http://localhost:3000.
| Variable | Default | Description |
|---|---|---|
LM_STUDIO_MODEL |
local-model |
Model identifier passed to LM Studio API. LM Studio accepts any string and routes to the currently loaded model. Set to the exact model name if using multiple models. |
Set via PowerShell before starting the backend:
$env:LM_STUDIO_MODEL="llama-3.2-3b-instruct"
uvicorn main:app --reloadThe game runs fully without LM Studio — AI calls fail gracefully and fall back to contextual template responses that reference real quest/mob/zone data.
This project uses sim_run.py not just as a test harness but as a balance validation tool — a methodology that applies to any game system, not just this one.
Most games are balanced through playtesting: humans play it, notice when something feels wrong, and adjust. This works but has a ceiling. Human testers have limited time, can't exhaustively cover every level range, and can't hold a spreadsheet in their head while playing. The result is that most indie games ship with progression curves that feel fine in the 10-hour window that was tested, and break apart at hour 30.
The sim solves this by automating the playtesting loop. One --skip-to-raid run completes three full raids, measures exact GS gain per raid, dodge success rates, DPS scaling across level ranges, and telegraph frequency — in under 10 minutes. The same run would take a human 60–90 minutes with worse data quality.
Running the sim against this game revealed specific, quantifiable balance findings:
| Finding | Measurement | Fix |
|---|---|---|
| Zone travel GS gate was a treadmill | Player leveled through raids → gate scaled up → gate never reachable | Replaced level × 50 with flat 1000 GS |
| GS curve from raids | Raid 1: +216 GS · Raid 2: +231 GS · Raid 3: +276 GS | Confirmed 3 raids to zone travel — correct pacing |
| Boss telegraph frequency | Room 7 boss fired 3–4 normal telegraphs + 2–4 ANNIHILATEs per run | Identified potential repetition — cap normal telegraphs before enrage |
| DPS scaling | Raid 1: 648 dmg/round → Raid 2: 944 → Raid 3: 1830 | Level scaling confirmed working (party stats compound with player level) |
| Party deaths | 0 across 3 full raids | Healer output correctly calibrated against mob damage for this level range |
| Dodge mechanics | 100% dodge rate at every tier (open world, dungeon, raid) | Sim always dodges optimally — validates telegraph system end-to-end |
The same methodology applies to any game with quantifiable systems:
- Any MMORPG — run the progression sim 100 times to find what % of players would hit a wall at each tier, before launch, not after
- Card games / tactics — simulate thousands of matches to find which decks/factions dominate, before the community discovers the broken combo
- Idle games — simulate 1000 hours of idle progress in 5 minutes to verify the late-game prestige curve doesn't collapse
- Roguelikes — automated runs per class to verify class power parity without bias from skilled/unskilled testers
The key property that makes it work: the sim calls the same endpoints as the real client. There's no separate "sim mode" in the backend. The sim is just a faster player. This means sim results are guaranteed to reflect what real players will experience — not what a mocked/simplified model predicts.
Based on what this sim revealed as most useful:
| Metric | Why it matters |
|---|---|
| GS per run (or equivalent progression unit) | Tells you how many runs before the next gate — the core time-to-progression number |
| DPS / damage output per round | Catches content that's too fast (boring) or too slow (frustrating) |
| Telegraph → dodge ratio | Any divergence means the mechanic is broken or un-learnable |
| Party deaths per run | Calibrates healer/tank output against mob damage |
| Rounds per room | Rooms with very different counts indicate mob HP outliers |
| Loot by rarity over N runs | Tells you the actual drop distribution, not the intended one |
Simulation isn't just for AAA studios with dedicated tools teams. A 1000-line Python script calling your own API can catch weeks of post-launch balance complaints before a single real player touches the game. The investment is front-loaded but it pays back every time you touch a number — instead of "this feels about right", you have "DPS grew 2.8× from raid 1 to raid 3, which matches the level scaling formula."
- Add the slot key to
Player.equipmentdefault dict inschemas.py - Add the slot name to
_ITEM_NAMESinmain.py - Add weights for it in
_CLASS_SLOT_WEIGHTSinmain.py - Frontend paperdoll renders equipment slots dynamically from
player.equipment— it will appear automatically
- Add to
CLASS_STATSinmain.py(hp_mult, damage_mult) - Add to
_CLASS_SLOT_WEIGHTS,_CLASS_WEAPONS,_CLASS_ADJECTIVESinmain.py - Add flavor text to
CLASS_FLAVORinfrontend/app/page.tsx - Add a portrait image to
frontend/public/assets/portraits/{classname}.png
- Add the role string to
NPC.roletype hint comment inschemas.py - Handle the role in
main.py → talk_to_npc(currently handlesquest_giverandvendor) - Add a button style for the new role in the side panel NPC section in
page.tsx
Edit the templates list in world_generator.py → generate_zone(). Each template needs: name, desc, hub (name, description tuple), pois (3 locations), npc (name, greeting tuple), quests (list of (title, type, mob_or_None, count, collect_name_or_None) tuples).
All hit/damage math is in combat_engine.py. scaling_math.py controls the HP/damage/XP curves per level. These two files are the only places to touch for balance changes.
Replace ai_client.py with any provider that supports the same three method signatures (generate_content, stream_content, generate_json). The openai SDK client can be pointed at any OpenAI-compatible endpoint by changing base_url.
Answers to questions a senior reviewer would ask when reading the codebase.
Every system here is intentionally coupled — loot depends on player class, level-up logic depends on combat result, combat depends on equipment stats. Splitting into separate modules creates import chains between tightly-coupled systems without any real isolation boundary. The benefit of router separation (team onboarding, independent deployment) doesn't apply to a solo project.
dungeon_engine.py was extracted because it's genuinely separate — it owns a full lifecycle (enter → attack → advance → flee) and never needs to reach back into main.py. The loot roller and level-up helpers inside main.py are 30-line functions that only make sense in the context of the endpoint calling them. Moving them to app/core/systems/loot.py saves zero cognitive overhead and costs an import chain.
The rule applied: split when a module boundary creates real isolation, not just file separation.
No concurrent writes from multiple servers, no relational queries — players are fetched by UUID, zones by UUID. SQLite is built into Python, requires no installation, ships as a single inspectable file, and handles the write volume of a single-player game trivially. The LRU cache in front of it means most reads never touch disk. Postgres adds a process, a connection pool, and a migration story for zero gameplay benefit.
Dungeon runs are session-scoped by design. If the server restarts mid-run the player loses progress and starts over — acceptable for single-player. Persisting runs would require a DB write every attack round (to handle crashes mid-run), which adds latency to the tightest loop in the game and complicates the data model. The _dungeon_runs dict is fast, simple, and fits the ephemeral nature of instanced content.
The original gate was player.level × 50. Because players level up by clearing raids (not just open world), the gate kept rising with each clear — a treadmill where the requirement outpaced the reward. Caught and fixed by simulation: the sim validated that a flat 1000 GS gate requires exactly 3–5 raid clears at level 20, is predictable, and can be communicated clearly to players via the HUD ticker. A level-scaled gate is impossible to explain in one line of UI text.
Zero latency variance, zero cost per token, works fully offline, no rate limits, no API key to manage or rotate. The content generated (zone names, mob descriptions, NPC dialogue) doesn't need frontier model capability — a 9B parameter model running locally produces output indistinguishable from GPT-4 for this use case. LM Studio's OpenAI-compatible endpoint means switching to a cloud provider is one base_url change in ai_client.py.
If zone state is only saved on mob death, the next attack request loads the mob from the last saved state — at full HP. Every hit except the kill appears to do nothing, making combat feel broken. This is the single most important rule in the persistence layer and the reason vec_db.save_zone(...) is called unconditionally at the end of every attack handler, not inside the if mob.hp <= 0 branch.
If Common were checked first with a raid-tier multiplier, it would pass at 100% chance on every roll — blocking all higher rarities entirely. By checking Legendary → Epic → Rare → Common in order, the tier multiplier raises the floor of quality rather than just increasing volume. Named bosses with a 100% Common fallback never return Common because Rare is checked first and always passes.
Python's requests library makes Response objects falsy for 4xx/5xx status codes — bool(response) returns False when status_code >= 400. Using if r on a 400 response silently discards the error body and falls through to the else "no response" branch. This was a real bug: try_zone_travel() was logging "Zone travel blocked: no response" instead of the actual backend error message. The fix is always if r is not None when checking for response presence vs. if r when checking for HTTP success.
The sim is a balance tool, not a difficulty test. Dodging every telegraph removes player skill from the equation and isolates the underlying math — party DPS, healer throughput, mob damage, GS curve. A sim that occasionally fails to dodge would add variance that makes balance signals harder to read. The 100% dodge rate across all tiers confirmed the telegraph system is wired correctly end-to-end; difficulty tuning (what happens to real players who miss) is a separate concern validated in the browser.
Raid bosses guarantee 3 drops. With 7 equipment slots and random slot selection, the probability of rolling 3 unique slots in one batch is only ~61% — about 4 in 10 raids would deliver 2 items instead of 3 if a collision just skips. The loot roller retries up to 5× per drop to find an unoccupied slot, which makes the guarantee meaningful. Five retries is enough: the probability of failing all 5 across 7 slots approaches zero.
Zone must be saved after every attack hit, not just on mob death.
The backend loads fresh zone state on each request. If you only save on mob death, every subsequent attack sees the mob at full HP again (the healing bug). This is why vec_db.save_zone(...) is called unconditionally at the end of the attack handler.
model_dump(mode='json') is required for all Pydantic v2 serialization.
Standard dict() or .model_dump() without mode='json' will leave Python Enum objects in the output that SQLite's JSON serializer cannot handle cleanly.
Schema changes require clearing backend/data/mud.db.
If you add a required field to a Pydantic model, existing JSON blobs in the DB won't have it. Pydantic will raise a ValidationError when loading old records. Clear the DB after significant model changes (python scripts/reset_data.py or delete the file).
Frontend state is a local mirror, not the source of truth. The backend DB is authoritative. The frontend applies optimistic updates (local HP/XP changes) from attack response deltas. For full sync, the zone is polled every 10s via the ticker. If you notice desync, check that the backend is saving state and the ticker is running.
Simulation loop only ticks zones in the in-memory cache.
simulation.py iterates vec_db._zone_cache.keys(). A zone is only cached after it's first loaded in a request. Zones that have never been loaded won't be simulated. This is intentional — there's no need to simulate zones no one is in.
Attack cooldown is in-memory only (_attack_times dict).
Restarting the server resets all attack cooldowns. This is fine for a single-player game but would need Redis or similar for multi-player.
Ideas that fit the design philosophy (frictionless, solo-friendly, endlessly progressive) but are large enough to be their own milestone.
Raw fish (from fishing holes) and harvested plants (from path locations) would become ingredients for cooked food. A cook [item] command at any campfire/hub would convert them into consumables: Cooked Silverscale grants a 30-minute out-of-combat HP regen buff (+4% per second instead of +2%). Cooked food would not stack with Healing Potions but would free up potion charges for combat use. This creates a natural gold/resource trade-off — sell materials directly, or spend 10s cooking for a quality-of-life regen buff.
Party members already act intelligently in combat (role-aware healing, taunts, procs). The next layer is making them feel like real companions: contextual one-liners during fights ("Watch the boss's enrage!"), celebrating crits, reacting to rare drops ("Finally, a chest piece upgrade!" or "All yours — can't use that."). This would use a single combined LLM call per round (one line for the most interesting party action) rather than per-member, keeping token usage comparable to the current open-world chat.
Persistent milestone tracking — first boss kill, 100 kills in a zone, first Legendary drop, first raid clear — shown as a pop-up banner and stored on the player record. Achievements give small permanent stat bonuses (e.g. +5 max HP for "First Blood") to reward completionist play without gating progression behind them.
A score-screen shown on zone exit / tab close: kills this session, gold earned, best drop, XP gained, damage dealt. Creates the "just one more run" feeling and gives a natural stopping point. Zero backend changes needed — all data is already tracked in player state.
The headless simulation (sim_run.py) already plays the full meta automatically — open world sweeps, dungeon runs, raids, zone travel — and prints a live colourised feed of everything that happens. It turns out this is a genuinely enjoyable thing to watch while doing something else, like reading or watching YouTube.
The natural extension of this is a standalone terminal idle game: a separate project that takes sim_run.py as its foundation and turns it into the actual product. The player's role shifts from playing to directing and watching — you pick your class, set some preferences (aggressive/cautious, gold-focused/XP-focused), and then watch a rich terminal feed narrate your character's adventure while you idle. World chat would be the primary interaction point — you can message your sim party, react to drops, or just lurk while the game plays out.
What makes it potentially unique: most idle games are number dashboards. This would be a narrative idle game — every kill has a description, every dungeon room tells a story, every rare drop gets called out. The AI layer that makes this MUD feel alive is exactly what would make an idle version feel different from Progress Quest or any clicker.
Since the backend is already completely decoupled from the frontend, this would be a separate repo that reuses the same backend as-is and replaces the Next.js frontend with an enhanced terminal renderer — likely a Python rich or textual UI that displays party HP bars, a scrolling combat log, and a world chat input, all in the terminal.
The sim as it exists today is already ~80% of the way there technically. The gap is just the UI layer and the shift in design intent from "testing tool" to "product."
The intended distribution path is an Electron wrapper on Steam — the game ships as a standalone desktop app with no external server dependency. The AI world chat gimmick requires a local LLM, so the bundle needs to include a model and a way to run it.
Electron shell
├── Next.js frontend (bundled as static files, served by Electron)
├── FastAPI backend (spawned as a child process on app launch)
├── Python runtime (bundled via PyInstaller — no Python install required)
└── LM Studio / llama.cpp (bundled inference engine + Qwen3.5 9B model weights)
The Electron main process becomes the orchestrator: on launch it starts the FastAPI backend subprocess and the inference engine subprocess, waits for both to be healthy (poll http://localhost:8000/docs and http://localhost:1234/v1/models), then opens the game window pointing at the local Next.js build.
Use PyInstaller to produce a single-folder executable from the FastAPI app:
pip install pyinstaller
pyinstaller --onedir backend/main.py --name mud-server \
--add-data "backend/app:app" \
--hidden-import uvicorn.lifespan.onThe resulting dist/mud-server/ folder (or .exe on Windows) gets included in the Electron resources/ directory. Electron spawns it on startup and kills it on quit via app.on('before-quit').
Two options for the inference engine:
| Option | Pros | Cons |
|---|---|---|
| Bundle LM Studio | Familiar, has a GUI for settings, supports many backends | Large binary (~200 MB), not headless-friendly |
| Bundle llama.cpp server | Tiny binary (~10 MB), fully headless, OpenAI-compatible API on port 1234, same interface the game already uses | No GUI — thinking mode must be disabled via a launch flag |
Recommended: llama.cpp server (llama-server binary). It exposes the same OpenAI-compatible REST API at http://localhost:1234/v1 that the game already targets, so zero backend changes needed. Thinking mode is disabled at launch via --no-context-shift or a sampler flag — not a user setting.
Qwen3.5 9B is the target model. At Q4_K_M quantisation it is ~5.5 GB — acceptable for a Steam game download. Include the .gguf file in resources/models/.
Launch command Electron would run:
llama-server \
--model resources/models/qwen3.5-9b-q4_k_m.gguf \
--port 1234 \
--ctx-size 4096 \
--n-predict 256 \
--no-mmap \
--thinking false # disables <think> blocks — Qwen3.5 specific flagOn first launch (detected by absence of mud.db), show an onboarding screen before the title:
- Hardware check — detect VRAM via
nvidia-smior Metal API and recommend quality level:- ≥ 8 GB VRAM → full Q4_K_M (best quality)
- 4–8 GB VRAM → Q3_K_M (slightly lower quality, same feel)
- CPU only → Q2_K or redirect to a smaller model (Qwen3.5 3B)
- Model download — if not bundled, offer to download the
.gguffrom HuggingFace with a progress bar. (Alternatively, bundle it in the Steam depot so it downloads during installation — preferred for a smooth experience.) - Quick test — fire a single
/describe/entitycall with a test prompt. Show the response in the onboarding screen so the player sees AI output before the game starts. If it fails, show a clear fallback message: "AI unavailable — the game works fully without it, but world chat and NPC descriptions will use template responses."
- Ship as a Steam Play title (Windows + Linux via Proton). macOS is a separate build due to Metal/MPS differences with llama.cpp.
- The
backend/data/mud.dbsave file should live in%APPDATA%/SinglePlayerAIMUD/(Windows) or~/.local/share/SinglePlayerAIMUD/(Linux) — not inside the install directory, which Steam may overwrite on update. - Admin endpoints (
/admin/boost,/admin/reset) are localhost-only and not exposed externally — fine for a bundled app. No auth needed. - The Reset button in-game already handles save wipes cleanly (
POST /admin/reset) — no separate uninstaller logic needed for save data.
| File | Purpose |
|---|---|
electron/main.js |
Electron entry — spawns backend + llama-server, opens window |
electron/preload.js |
Context bridge if any native APIs needed |
scripts/build_backend.sh |
PyInstaller build step |
scripts/build_electron.sh |
Full packaging pipeline |
electron-builder.yml |
Electron Builder config — platform targets, Steam appid, resource paths |
GNU Affero General Public License v3.0 (AGPL-3.0)
Copyright © 2026 Ocean Bennett. All rights reserved.
This project is open source under the AGPL v3. You are free to use, study, modify, and distribute this software under the following conditions:
- Visible attribution — Any game, app, or service built on this engine must credit "Ocean Bennett" by name with a link to this repository — in credits, README, or store page. Keeping the copyright notice in source alone is not sufficient.
- Copyleft — Any derivative work must be released under the same AGPL v3 license. You cannot make a closed-source game using this engine.
- Network use — If you run a modified version as a hosted service (e.g., a web game), you must make the complete source publicly available. This closes the SaaS loophole present in GPL.
- Commercial reservation — Ocean Bennett, as copyright holder, may release commercial versions under separate terms. This does not affect open-source rights granted to the community.
The dual-exponential prestige loop described in The Infinite Loop section is the original design of this project. If you derive a game system from that architecture, attribution to this repository is required under the terms above.
Full license text: https://www.gnu.org/licenses/agpl-3.0.en.html
A LICENSE file is included in the root of this repository.
The AGPL v3 requires that any company using this engine in a commercial product release their entire product's source code under the same license. For most studios this is a non-starter.
If your company wants to:
- Ship a closed-source game built on this engine
- Use this engine in a hosted commercial service without open-sourcing your product
- License the dual-exponential prestige loop architecture for use in a proprietary title
- Embed this engine in a Steam, mobile, or console release
...you need a commercial license. As the sole copyright holder, Ocean Bennett issues these on a case-by-case basis.
Contact: github.com/undergroundrap
Commercial licenses include:
- Rights to ship a closed-source derivative without AGPL copyleft obligations
- Rights to modify without publishing source
- A visible credit requirement ("Powered by Ocean Bennett's engine") in lieu of open-source attribution
- A one-time or royalty-based fee depending on scope and scale
The open-source community retains full AGPL rights regardless of any commercial agreements.
Built by Ocean Bennett