25 KiB
Ashen Realms – Playable Slice 0.3: First Combat
Status: Ready for implementation
Prerequisite: Playable Slice 0.2 – First Hunt
Scope: First persistent, server-authoritative combat
Primary Enemies: Aschenratte, Straßenräuber
Next Slice: Playable Slice 0.4 – First Loot
1. Goal
Playable Slice 0.3 implements the first real combat in Ashen Realms.
The existing flow:
World
→ Travel
→ Hunt
→ Encounter selection
is extended to:
World
→ Travel
→ Hunt
→ Encounter selection
→ Combat
→ Victory or defeat
The slice must prove that:
- combat can only start from a valid
HuntEncounter - combat state is persisted on the server
- the client sends decisions, never results
- combat resolution is deterministic
- player and monster HP are server-authoritative
- combat proceeds round by round
- combat events can drive frontend presentation
- victory and defeat are persisted
- refreshing the browser does not lose the fight
This slice deliberately ends when the combat reaches:
WON
or:
LOST
No rewards are granted yet.
2. Core player flow
Verbrannte Straße
↓
Jagd beginnen
↓
Encounter auswählen
↓
Angreifen
↓
Server validates HuntEncounter
↓
Combat created
↓
/combat/:combatId
↓
Player and monster are displayed
↓
Player chooses Angriff
↓
Server resolves round
↓
CombatEvents returned
↓
Frontend updates HP and combat log
↓
next round
↓
monster reaches 0 HP
↓
Combat = WON
Defeat must also work:
player reaches 0 HP
↓
Combat = LOST
3. Scope
Implement:
Combat entity
CombatEvent entity
CombatEngineService
CombatService
CombatController
combat creation from HuntEncounter
ATTACK player action
basic monster attack
server-side damage calculation
round progression
player HP
monster HP
combat status
combat event persistence
combat page
player presentation
monster presentation
HP bars
Angriff action
round display
combat log
victory state
defeat state
4. Explicit non-goals
Do not implement:
loot
XP granting
silver granting
inventory
equipment management
item drops
quests
bosses
elites
sets
area currencies
random combat rewards
combat animations requiring complex timing
WebSockets
PvP
multiplayer combat
Also do not implement the complete final action system yet.
The first playable combat uses only:
ATTACK
for player input.
Future slices may add:
HEAVY_STRIKE
SHIELD_BASH
DEFEND
POTION
FLEE
The combat architecture must allow these later without implementing them prematurely.
5. Combat design principle
Combat is turn-based.
The player makes exactly one primary decision per round.
For Slice 0.3:
Player chooses ATTACK
↓
player attack resolves
↓
if monster survives:
monster attack resolves
↓
round ends
The next round begins only after the server has resolved the previous round.
Angular must never independently resolve a combat round.
6. Combat start boundary
A combat may only be created from:
HuntEncounter.id
Never from:
MonsterDefinition.id
directly supplied by the frontend.
Required endpoint:
POST /api/hunt-encounters/:encounterId/attack
The backend must validate:
HuntEncounter exists
↓
encounter belongs to current character's Hunt
↓
Hunt belongs to current character
↓
Hunt is valid/current
↓
encounter has not already been consumed
↓
character is not travelling
↓
character does not already have another ACTIVE combat
Only then may combat be created.
7. Encounter consumption
A HuntEncounter must not be usable repeatedly to create unlimited combats.
Introduce an appropriate state or relation.
Possible states:
AVAILABLE
SELECTED
CONSUMED
For this slice it is sufficient if the selected encounter becomes:
CONSUMED
when combat is successfully created.
The exact persistence model may follow existing project conventions.
The important invariant is:
One HuntEncounter may create at most one Combat.
Prefer protecting this through both service validation and a database uniqueness constraint where practical.
8. Combat entity
Create:
apps/api/src/combat/entities/combat.entity.ts
Required conceptual fields:
id: uuid;
characterId: uuid;
huntEncounterId: uuid;
monsterDefinitionId: uuid;
status: CombatStatus;
round: number;
playerMaxHp: number;
playerCurrentHp: number;
monsterMaxHp: number;
monsterCurrentHp: number;
playerState: jsonb;
monsterState: jsonb;
createdAt: timestamptz;
updatedAt: timestamptz;
completedAt?: timestamptz;
Supported statuses:
ACTIVE
WON
LOST
Do not add reward-related states yet.
9. Combat snapshot principle
Combat must snapshot the relevant stats at combat creation.
A running combat must not unexpectedly change because content definitions or character state are edited elsewhere.
The combat snapshot should contain enough information to reproduce its deterministic calculations.
Conceptually:
playerState = {
attack: number,
weaponDamage: number,
armor: number
}
monsterState = {
attack: number,
armor: number
}
The exact JSON structure may be strongly typed in TypeScript.
Avoid unstructured arbitrary JSON access throughout the engine.
10. Player stats for the first combat
The final game uses effective character values derived from:
base character stats
+
equipment
For the first combat slice, reuse CharacterStatsService if it already exists.
If equipment has not yet been implemented, do not implement the entire inventory/equipment system solely for Slice 0.3.
The demo character starts conceptually with:
100 HP
6 base attack
8 weapon damage
6 armor
These correspond to the established starting reference character.
Any temporary mechanism needed to supply the starting weapon/armor values must:
- remain server-side
- be isolated behind the character/combat stats boundary
- not leak into Angular
- be easy to replace when Slice 0.5 introduces real equipment
- not change the public combat API
Do not create fake frontend equipment state.
11. Damage formula
Use the established Ashen Realms damage model.
Raw damage:
Raw Damage =
Weapon Damage + Attack
Armor mitigation:
Damage =
Raw Damage × 60 / (60 + Armor)
Round to the nearest integer.
Minimum damage:
1
Conceptual implementation:
const rawDamage =
attack + weaponDamage;
const mitigatedDamage =
rawDamage * 60 / (60 + targetArmor);
return Math.max(
1,
Math.round(mitigatedDamage),
);
For monsters without explicit weapon damage, their attack may represent their complete offensive base value for this first slice.
Keep this distinction explicit in engine types.
12. No combat randomness
Slice 0.3 combat contains no random damage range.
Do not implement:
critical hits
dodge
block chance
accuracy
hit chance
random damage variance
elemental damage
resistances
Given the same combat state and action:
the same result must be produced.
This is intentional.
13. CombatEngineService
Create a framework-light engine:
apps/api/src/combat/combat-engine.service.ts
The engine should not directly access PostgreSQL.
Required conceptual API:
resolveAction(
state: CombatEngineState,
action: CombatActionInput,
): CombatEngineResult
The engine receives:
current round
player stats/state
monster stats/state
requested player action
and returns:
new combat state
events
status/result
The engine must be unit-testable without Nest repositories.
14. Combat action enum
Introduce:
ATTACK
as a real enum/API value.
Design the enum so future values can be added:
HEAVY_STRIKE
SHIELD_BASH
DEFEND
POTION
FLEE
But do not implement their behavior in Slice 0.3.
If an unsupported action is submitted, return a domain validation error.
15. Round resolution
For:
ATTACK
resolve:
Step 1 – Player attack
Calculate damage against monster armor.
Reduce:
monsterCurrentHp
but never below:
0
Create a combat event.
Step 2 – Victory check
If:
monsterCurrentHp <= 0
then:
status = WON
The monster must not receive another attack.
The round ends.
Step 3 – Monster attack
If the monster survived:
Calculate monster damage against player armor.
Reduce:
playerCurrentHp
but never below:
0
Create a combat event.
Step 4 – Defeat check
If:
playerCurrentHp <= 0
then:
status = LOST
Step 5 – Round progression
If combat remains active:
round += 1
Use one consistent round-number convention throughout API, persistence and UI.
Recommended:
Combat starts at round 1.
After resolving round 1 successfully:
round becomes 2.
16. CombatEvent entity
Create:
apps/api/src/combat/entities/combat-event.entity.ts
Fields should support ordered event history.
Conceptually:
id: uuid;
combatId: uuid;
round: number;
sequence: number;
type: CombatEventType;
source: PLAYER | MONSTER;
target: PLAYER | MONSTER;
amount?: number;
createdAt: timestamptz;
Initial event types should include:
DAMAGE
COMBAT_WON
COMBAT_LOST
Avoid storing presentation sentences as the authoritative event format.
Store structured events.
Angular may transform them into German display text.
17. Example events
Player attacks Aschenratte:
{
"type": "DAMAGE",
"source": "PLAYER",
"target": "MONSTER",
"amount": 14
}
Monster attacks player:
{
"type": "DAMAGE",
"source": "MONSTER",
"target": "PLAYER",
"amount": 5
}
Combat victory:
{
"type": "COMBAT_WON",
"source": "PLAYER",
"target": "MONSTER"
}
18. Combat creation transaction
Creating combat should be atomic.
Conceptual transaction:
lock/validate HuntEncounter
↓
verify no active combat
↓
load monster
↓
calculate/snapshot player stats
↓
create Combat
↓
consume HuntEncounter
↓
commit
Concurrent requests for the same encounter must not create two combats.
19. CombatService
CombatService owns persistence and orchestration.
Responsibilities:
create combat from encounter
load combat
validate combat ownership/state
call CombatEngineService
persist updated HP/state
persist ordered CombatEvents
persist WON/LOST status
return combat DTO
Do not put damage formulas into the controller.
Do not put TypeORM repository access into the pure combat engine.
20. Combat API
Required endpoints:
POST /api/hunt-encounters/:encounterId/attack
GET /api/combats/:combatId
POST /api/combats/:combatId/actions
21. Start combat request
Example:
POST /api/hunt-encounters/abc123/attack
No body containing combat values is required.
The server derives:
character
monster
player stats
monster stats
starting HP
Response should return the newly created combat.
22. Combat action request
Request:
{
"action": "ATTACK"
}
The frontend must never send:
damage
playerHp
monsterHp
armor
attack
weaponDamage
round
combat status
These values are entirely server-owned.
23. Combat response DTO
Conceptually:
{
"id": "combat-uuid",
"status": "ACTIVE",
"round": 2,
"player": {
"name": "Aric Duskwalker",
"maxHp": 100,
"currentHp": 95
},
"monster": {
"key": "ash-rat",
"name": "Aschenratte",
"level": 1,
"maxHp": 45,
"currentHp": 31,
"artworkPath": "/assets/monsters/ash-rat.webp"
},
"events": [
{
"round": 1,
"sequence": 1,
"type": "DAMAGE",
"source": "PLAYER",
"target": "MONSTER",
"amount": 14
},
{
"round": 1,
"sequence": 2,
"type": "DAMAGE",
"source": "MONSTER",
"target": "PLAYER",
"amount": 5
}
]
}
Do not expose unnecessary internal engine state.
24. GET combat
GET /api/combats/:combatId must allow the browser to restore the fight after:
refresh
route reload
browser reconnect
The frontend must not depend on transient local state for current HP or round.
The server response remains authoritative.
25. Finished combat behavior
If combat status is:
WON
or:
LOST
then further calls to:
POST /api/combats/:combatId/actions
must be rejected.
Example error:
{
"statusCode": 409,
"code": "COMBAT_ALREADY_FINISHED",
"message": "This combat has already finished."
}
26. Active combat restriction
A character may have at most one:
ACTIVE
combat.
Starting another encounter while a combat is active must fail.
Example:
COMBAT_ALREADY_ACTIVE
Where practical, protect this invariant using a partial unique database index in addition to service validation.
27. Invalid encounter errors
Use stable domain errors.
Examples:
HUNT_ENCOUNTER_NOT_FOUND
HUNT_ENCOUNTER_ALREADY_CONSUMED
INVALID_HUNT_ENCOUNTER
CHARACTER_TRAVELLING
COMBAT_ALREADY_ACTIVE
Do not expose raw TypeORM/PostgreSQL errors to the client.
28. Database migration
Create a reviewed TypeORM migration for:
combat
combat_event
and any required HuntEncounter state/constraint changes.
The migration must:
- preserve existing world/travel/hunting data
- use UUID primary keys
- create foreign keys
- create useful indexes
- prevent obvious duplicate active-combat states where practical
- not recreate unrelated tables
- keep
synchronizedisabled
29. Angular route
Implement:
/combat/:combatId
The Hunt screen's:
Angreifen
action changes from the Slice 0.2 placeholder to:
POST combat from encounter
↓
receive combat ID
↓
navigate to /combat/:combatId
Remove the temporary combat placeholder from Slice 0.2.
30. Combat screen composition
The combat screen follows the established Ashen Realms layout:
player left
monster right
location background
combat actions below
combat information clearly visible
combat log in contextual area/right side
The fight must visually feel like a confrontation.
Do not render combat as a statistics table.
31. Player presentation
Display:
character artwork
character name
current HP
maximum HP
HP bar
Use existing player artwork.
Do not introduce a new art style.
32. Monster presentation
Display:
monster artwork
monster name
monster level
current HP
maximum HP
HP bar
Use the artwork path supplied by the API/content definition.
The monster should receive similar visual weight to the player.
33. Background
Use the current combat location:
Verbrannte Straße
as the scene context.
Reuse the existing location artwork where appropriate.
The background must support the confrontation rather than compete with HP/action readability.
34. Combat action bar
Slice 0.3 shows one functional action:
Angriff
The action should be implemented using the visual language intended for future combat buttons.
Recommended display:
icon
Angriff
optional hotkey
Do not show fake functional buttons for unimplemented mechanics.
It is acceptable to reserve visual space for future actions, but disabled placeholders should only be used if they improve layout validation.
35. Input locking
When an action request is in progress:
disable combat actions
The player must not accidentally submit the same round multiple times.
The backend must still protect against concurrency.
Frontend locking is only UX protection.
Server validation remains authoritative.
36. Combat log
Display structured events as readable German log entries.
Example:
Runde 1
Aric trifft Aschenratte für 14 Schaden.
Aschenratte trifft Aric für 5 Schaden.
Do not persist these sentences as domain truth.
Build them from structured CombatEventDto objects.
37. Round display
Clearly show:
Runde 1
Runde 2
...
Round state must come from the API.
Angular must never independently increment the authoritative round value.
38. Victory state
When:
status = WON
show a clear victory state.
Example:
Sieg
Die Aschenratte wurde besiegt.
At this point Slice 0.3 ends.
Do not grant or invent rewards.
A small deliberate message may indicate:
Belohnungen werden im nächsten Schritt verarbeitet.
Primary action may be:
Zur Jagd
Do not automatically create another hunt.
39. Defeat state
When:
status = LOST
show a clear defeat state.
Example:
Niederlage
Aric wurde im Kampf besiegt.
For Slice 0.3, do not build the complete long-term defeat/reset system unless it already exists.
The combat must remain persisted as:
LOST
A simple return action to the world/hunt context is sufficient for this slice.
Any final safe-location reset should be defined in a later dedicated slice unless already implemented.
40. Refresh/recovery behavior
Refreshing:
/combat/:combatId
must reload combat from the server.
The UI must restore:
combat status
round
player HP
monster HP
monster
combat history
No combat-critical state may exist only in Angular memory.
41. Frontend state
Recommended state:
combat
loading
actionPending
error
Derived presentation state may include:
player hp percentage
monster hp percentage
formatted combat events
Do not duplicate authoritative gameplay values in mutable frontend state.
42. Combat API service
Add focused methods equivalent to:
startCombat(
encounterId: string,
): Observable<CombatDto>
getCombat(
combatId: string,
): Observable<CombatDto>
performAction(
combatId: string,
action: CombatAction,
): Observable<CombatDto>
Only send the action enum for combat actions.
43. CombatEngine unit tests
At minimum cover:
Damage formula
Example established case:
attack = 12
weaponDamage = 15
target armor = 20
Expected:
20 damage
Minimum damage
Extremely high armor must still result in:
1 damage minimum
Player attack
Verify:
monster HP decreases correctly
DAMAGE event created
Monster retaliation
If the monster survives:
player HP decreases
monster DAMAGE event created
No retaliation after death
If player attack reduces monster to zero:
monster does not attack
combat = WON
Defeat
If monster attack reduces player to zero:
combat = LOST
Determinism
The same state plus:
ATTACK
must produce the same result.
44. CombatService tests
At minimum cover:
Valid encounter starts combat
available HuntEncounter
→ ACTIVE Combat
Arbitrary monster cannot start combat
There must be no public API accepting an arbitrary monster definition as the combat source.
Encounter can only be consumed once
Two attempts using the same encounter must not produce two combats.
Active combat restriction
Character with an ACTIVE combat cannot create another.
Persistence
After an action:
HP changes persisted
round persisted
CombatEvents persisted
Finished combat
Further actions after:
WON
or:
LOST
must fail.
Transaction/concurrency
Where feasible, verify concurrent or repeated requests cannot resolve the same combat round twice.
45. Frontend tests
At minimum cover:
Combat creation
Clicking:
Angreifen
on the Hunt page:
uses HuntEncounter.id
calls combat creation
navigates to /combat/:combatId
Combat load
Opening:
/combat/:combatId
loads the authoritative combat state.
Combat presentation
Verify visibility of:
player
monster
both HP bars
round
Angriff
combat log
Combat action
Click:
Angriff
and verify the frontend sends only:
{
"action": "ATTACK"
}
Action lock
While the action request is unresolved:
Angriff disabled
Event rendering
Structured DAMAGE events are rendered as readable combat-log entries.
Victory
When API returns:
WON
the UI shows victory and disables combat actions.
Defeat
When API returns:
LOST
the UI shows defeat and disables combat actions.
46. Visual requirements
Follow the existing Ashen Realms visual design.
Combat should emphasize:
large character artwork
large monster artwork
dark fantasy location background
clear HP bars
clear action area
strong confrontation
dark metal/stone panels
bronze detail
restrained functional colors
Do not use:
generic cards
SaaS dashboard layout
white panels
Material defaults
Bootstrap defaults
glassmorphism
neon-heavy effects
47. Animation scope
Simple presentation effects are allowed:
small hit reaction
short damage number
subtle impact animation
HP-bar transition
But animations must be driven from server-returned events.
Do not delay or alter authoritative combat resolution for presentation.
Do not introduce a complex combat animation queue unless the existing frontend architecture already supports one cleanly.
Respect:
prefers-reduced-motion
48. Server-authority checklist
Server decides:
whether combat can start
which monster belongs to encounter
combat starting stats
player HP
monster HP
damage
round progression
monster retaliation
victory
defeat
combat status
combat events
Client decides only:
which valid HuntEncounter to attack
when to submit ATTACK during an active combat
49. Security/domain constraints
The client must never be able to submit:
monsterId
damage
target HP
player HP
armor
attack
weapon damage
victory
defeat
reward
round number
as authoritative combat inputs.
Do not trust disabled frontend buttons as domain validation.
Every action must be validated server-side.
50. Definition of Done
Playable Slice 0.3 is complete when this complete browser flow works:
Südtor
↓
travel to Verbrannte Straße
↓
Jagd
↓
Jagd beginnen
↓
select Aschenratte or Straßenräuber
↓
Angreifen
↓
server creates Combat from HuntEncounter
↓
navigate to /combat/:combatId
↓
player and monster appear
↓
both HP bars visible
↓
Runde 1 visible
↓
player clicks Angriff
↓
server resolves complete round
↓
combat events appear
↓
HP changes
↓
continue attacking
↓
combat reaches WON or LOST
↓
finished state displayed
51. Required verification
Before considering the slice complete, run all relevant:
API unit tests
API integration tests
Angular tests
API build
Angular build
migration compilation
migration execution
seed verification
Perform browser walkthroughs for at least:
Aschenratte combat
Straßenräuber combat
victory
defeat or deterministic forced-loss test setup
browser refresh during ACTIVE combat
browser refresh after finished combat
reusing consumed HuntEncounter
attempting second combat while one is ACTIVE
52. Acceptance criteria
The implementation is accepted when:
- Combat starts only from persisted HuntEncounter IDs
- one encounter cannot create multiple combats
- one character cannot have multiple active combats
- combat state is stored in PostgreSQL
- player and monster HP are server-owned
- ATTACK is resolved server-side
- damage follows the defined formula
- minimum damage is 1
- monster attacks only if still alive
- combat progresses round by round
- CombatEvents are persisted
- events are ordered
- combat can reach WON
- combat can reach LOST
- finished combat rejects further actions
- page refresh restores combat state
- Angular never calculates authoritative damage
- Angular displays player and monster visually
- HP bars, round and combat log work
- combat visually matches the existing Ashen Realms UI
- no rewards are granted yet
- all relevant tests pass
- frontend and backend builds succeed
53. Handoff to Playable Slice 0.4
The exact output boundary of this slice is:
Combat.status = WON
with a persisted finished combat.
Playable Slice 0.4 will begin from that state and implement:
WON Combat
↓
reward resolution
↓
XP
↓
silver
↓
loot roll
↓
reward persistence
↓
loot summary
The combat system must therefore finish a fight cleanly without granting rewards itself.
Reward calculation belongs to the next slice.
54. Architectural boundary
The final separation should be:
HuntingService
↓
valid HuntEncounter
CombatService
↓
persistent combat orchestration
CombatEngineService
↓
pure deterministic round resolution
Slice 0.4
↓
reward / loot processing
Do not let these responsibilities collapse into a single service.
The combat engine resolves combat.
It does not know about:
TypeORM
loot tables
inventory
XP progression
shops
quests
Angular
55. Summary
Playable Slice 0.3 proves the second major gameplay pillar of Ashen Realms:
A server-generated encounter becomes a real visual, deterministic, persistent turn-based fight.
The player can now:
travel
→ hunt
→ choose an enemy
→ fight
→ win or lose
The next slice adds the reason to keep doing it:
loot and progression