11 KiB
Ashen Realms – First Visible Vertical Slice Design
Date: 2026-08-18
Status: Approved in chat; awaiting written-spec review
Scope: First visible, server-authoritative world and travel slice
Purpose
This slice proves that Ashen Realms can present a premium dark-fantasy browser-RPG world while keeping character, location, connection, travel timing, and travel completion authoritative in the NestJS/PostgreSQL backend.
The completed slice lets a player open /world, see Aric Duskwalker at the Südtor von Graufurt, select the Verbrannte Straße, start a ten-second journey, watch a countdown derived from the server response, and arrive only after the backend completes the travel.
Scope boundaries
Included:
- PostgreSQL and TypeORM foundation with explicit migration
- Reproducible seed for one demo character, two locations, and two directed connections
- Health, demo-character, current-location, travel-start, and current-travel REST endpoints
- Angular application shell and
/worldroute - Signal-based frontend state for the visible slice
- Original location artwork plus code-native fantasy UI
- Backend and frontend tests requested by the implementation prompt
- Local development documentation
Explicitly excluded:
- Authentication, users, JWTs, and account flows
- Hunting, combat, loot, inventory, equipment, quests, merchants, and currencies
- Realtime, WebSockets, chat, guilds, CMS, object storage, and microservices
- Ambush resolution;
ambushChanceis stored but not rolled - Production containerization and NestJS static hosting, which are outside this slice's Definition of Done
Technical approach
The existing npm-workspace structure remains unchanged:
apps/web Angular SPA
apps/api NestJS modular monolith
packages/* reserved shared boundaries
docs/ product and implementation documentation
The existing Angular 21.2 scaffold is upgraded to Angular 22 to match the technical foundation document. NestJS remains version 11. No Nx, Turborepo, GraphQL, CQRS, event bus, or separate service is introduced.
The API uses a global /api prefix. Angular calls only relative /api/... URLs and the development server proxies them to http://localhost:3000.
Persistence model
All primary keys are UUIDs. Entity and table names are explicit and stable. Timestamps use PostgreSQL timestamptz. synchronize is always false.
Character
idnamelevelexperiencebaseHpbaseAttackcurrentHpcurrentLocationIdcreatedAtupdatedAt
baseHp is exposed as maxHp and baseAttack as attack until equipment is implemented.
LocationDefinition
id- unique
key namedescriptionregionKeyminRecommendedLevelmaxRecommendedLeveldangerLevelisSafehuntingEnabledartworkPathcreatedAtupdatedAt
LocationConnection
idfromLocationIdtoLocationIdtravelDurationSecondsambushChanceenabled
The pair (fromLocationId, toLocationId) is unique.
Travel
idcharacterIdoriginLocationIdtargetLocationIdstartedAtarrivesAtstatus(TRAVELLINGorCOMPLETED)createdAt
A PostgreSQL partial unique index permits only one TRAVELLING row per character. Service validation still returns a domain-friendly error before this constraint is reached.
Migration and seed
One reviewed TypeORM migration creates the four tables, foreign keys, enum, indexes, and uniqueness constraints. No runtime schema synchronization is used.
The seed is idempotent and uses stable UUID constants for this deliberately fixed demo slice. It upserts:
- Aric Duskwalker, level 1, 100 HP, 6 base attack, at
south-gate south-gate, safe, level 1, hunting disabledburned-road, unsafe, level 1–2, hunting enabledsouth-gate -> burned-road, ten seconds, 0.05 ambush chanceburned-road -> south-gate, ten seconds, 0.05 ambush chance
Re-running the seed restores definition fields without duplicating rows. It does not reset the demo character's live location or delete travel history; a separate database reset remains an explicit developer action.
Backend modules and API behavior
Health
GET /api/health returns { "status": "ok" } without requiring a database query.
Characters
GET /api/characters/me resolves the stable demo-character ID in the backend and returns character data with the current location summary. No character ID is hardcoded in Angular.
World
GET /api/world/current-location first asks the travel domain to complete any due journey. It then returns the authoritative current location and enabled outgoing connections.
The public connection DTO contains only target-location summary, duration, and a textual danger rating. For this slice, ambushChance <= 0.05 maps to LOW. The raw probability remains internal.
If a journey is still active, the endpoint continues to report the origin as the character's current location; arrival is never inferred by the client.
Travel
POST /api/travel accepts exactly:
{
"targetLocationId": "uuid"
}
It validates the target, current character location, enabled directed connection, and absence of an active journey. It derives startedAt from the server clock and arrivesAt from the connection duration.
GET /api/travel/current runs completion logic in a database transaction. A due travel changes both the travel status and character location atomically. Before arrivesAt, neither value changes. Concurrent completion requests are serialized using row locking and remain idempotent.
When active, the endpoint returns TRAVELLING, origin, target, startedAt, and arrivesAt. When it completes a journey, it returns COMPLETED and the target. With no travel history it returns an explicit idle response rather than pretending a journey completed.
Domain errors use NestJS HTTP exceptions with stable error codes such as INVALID_TRAVEL_TARGET and TRAVEL_ALREADY_ACTIVE.
Angular architecture
Shell
The root renders a reusable AppShellComponent composed from:
TopBarComponentSideNavigationComponent- routed main content
ContextPanelComponentowned by the world featureGameFooterComponent
/ redirects to /world. Karte is active. Jagd, Quests, Inventar, and Charakter are visible but disabled. Shop is not shown.
API and state
A typed API service owns all relative HTTP calls. A focused world store/facade owns these Angular signals:
charactercurrentLocationselectedConnectioncurrentTravelremainingSecondsloadingerror
Components render state and forward player decisions. They do not validate connections, calculate travel completion, or mutate the character location.
On initial load, the store requests character, current location, and current travel. Starting travel posts only targetLocationId. The countdown is recalculated from the backend's ISO arrivesAt against the browser clock for presentation only. At zero, the store polls GET /api/travel/current; only a server COMPLETED response triggers reloading character and world data.
Timers are disposed when the store or page is destroyed. Errors appear in-shell and keep retry actions available; browser alerts are not used.
Visual design
The visible surface follows the reference anatomy without copying the screenshots:
- compact full-width topbar with portrait, name, level, and HP
- narrow persistent left navigation
- dominant illustrated Aschenfelder scene in the center
- two large, readable location nodes connected by a visible travel path
- dense right-side location context panel
- bottom-centered travel panel inside the scene
- restrained persistent footer
An original raster artwork depicts the Südtor opening toward a burned road and distant ash fields. It contains no embedded UI text. Location nodes, connection paths, labels, controls, icons, and panels remain HTML/CSS/SVG so they stay interactive and accessible.
The visual system uses dark stone/metal surfaces, thin bronze borders, warm ash highlights, cool blue selection light, a restrained serif display face with readable system fallbacks, limited radii, and no glassmorphism, neon, white cards, or dashboard grids.
Central SCSS tokens define background, panel, muted panel, borders, text, gold, blue, success, warning, danger, spacing, radius, shadow, and motion values. Component styles consume these tokens rather than duplicating palette values.
Desktop is optimized for 1920×1080, 1440×900, and 1366×768. Tablet retains the core hierarchy with a narrower navigation and context column. Mobile optimization is deliberately deferred.
Accessibility and interaction
- Location nodes are real buttons with selected, current, hover, focus, and disabled states.
- Color never communicates danger or status alone; text labels remain present.
- Focus indicators fit the bronze/blue fantasy system.
- Motion is subtle and disabled or reduced under
prefers-reduced-motion. - Loading and error states remain readable against the artwork.
Testing strategy
Implementation follows test-driven development.
Backend tests cover:
- health response
- world DTO construction from location and connections
- valid connection starts travel
- invalid connection is rejected
arrivesAtis derived from the injected server clock- travel stays active before arrival
- due travel atomically completes and updates character location
Frontend tests cover:
- application shell composition
- world state loads character/location/travel through the API service
- travel start sends only
targetLocationId - countdown is derived from
arrivesAt - zero countdown polls the backend and does not locally mark arrival
Repository verification includes all workspace tests, API build, web build, migration compilation, seed compilation, and a browser walkthrough of selection, travel start, countdown, server completion, and return travel.
Visual verification compares the rendered implementation with docs/references/world-travel-screen.png for layout hierarchy, density, typography, palette, panel treatment, artwork prominence, node legibility, and interaction states. The reference image itself is never shipped as an application asset.
Local operation
The README documents:
npm install- create PostgreSQL database
ashen_realms - copy
.env.exampleto.envand setDATABASE_URL - run migrations
- run the seed
- start API and web in separate terminals
Root scripts provide stable commands for migration, seed, API development, web development, tests, and both builds.
Acceptance criteria
The slice is accepted when a clean database can be migrated and seeded, both applications start, /api/health returns OK, and the browser visibly supports the complete Südtor-to-Verbrannte-Straße travel flow with backend-owned arrival.
No excluded system is introduced, no frontend fake character becomes a data source, no absolute API URL is compiled into Angular, and no schema change depends on synchronize.