Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ All notable changes to this project are listed here.
The format follows Keep a Changelog.
This project uses semantic versioning.

## [0.3.0] - 2026-08-03

Added

- Multi-floor descent across three floors.
- A floor counter in the HUD.
- Difficulty scaling for monsters on deeper floors.
- A heal between floors that keeps hero health.
- Loot that carries across floors.
- Run rules with deterministic per-floor seeds.
- Integration tests for the descent flow.

Changed

- Reaching the exit now descends until the final floor.
- Run summaries show the floor reached.
- Coins reset at the start of a new run.

## [0.2.0] - 2026-08-03

Added
Expand Down
43 changes: 33 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

A seeded dungeon crawler built with Godot and GDScript.
Every run builds a new dungeon that you can explore and finish.
Descend three floors. Every floor grows harder.

```
####.D.############
Expand All @@ -18,14 +19,18 @@ Every run builds a new dungeon that you can explore and finish.
Dungeonwright generates a connected dungeon on every run.
You explore rooms and corridors.
You find keys, open locked doors, and reach the exit.
The exit leads to the next floor.
After three floors, the run ends in victory.
The same seed always builds the same dungeon.

## Features

- A new map for every run, driven by a seed.
- Three floors per run with a depth counter.
- Rooms, corridors, locked doors, and keys.
- Three biomes with different generation rules.
- Monsters with simple combat and balanced drops.
- Monsters grow stronger on deeper floors.
- Procedural pixel art with no bundled image files.
- A minimap, a health bar, and run summary overlays.
- Deterministic generation for replayable runs.
Expand All @@ -40,11 +45,15 @@ You can replay any run from its seed.

## This release

This release adds full gamepad support.
Every action has a gamepad binding.
Movement uses the left stick or the d-pad.
Analog input gets a deadzone and a diagonal speed cap.
Menus show the controls for the active device.
This release adds multi-floor descent.
A run now spans three floors.
Each floor is a new solvable dungeon.
Monsters grow stronger with depth.
The hero keeps health and loot between floors.
Keys reset when the hero descends.
The hero heals a little on each descent.
The HUD shows the current floor.
The run ends when the hero clears the final floor.

## Requirements

Expand Down Expand Up @@ -106,10 +115,20 @@ The generation code is pure data.
It has no scene nodes, so tests run fast and deterministic.
The scene controller turns the map into a live game.

A run descends through three floors.
`RunRules` sets the floor count and the difficulty curve.
Each floor uses a seed derived from the run seed.
Floor one uses the run seed itself.
Deeper floors mix the run seed with the floor number.
The hero keeps health and loot between floors.
Monsters use scaled stats on deeper floors.
The run ends on the final floor.

## Project layout

- `scripts/dungeon` holds the generator and map logic.
- `scripts/combat` holds stats, monsters, and loot tables.
- `scripts/core` holds run rules and shared run state.
- `scripts/input` holds the controls helper.
- `scripts/world` renders tiles and builds the minimap.
- `scripts/actors` holds the hero, monsters, and pickups.
Expand All @@ -121,33 +140,37 @@ The scene controller turns the map into a live game.
## Design guarantees

A seed always produces the same map.
A seed always produces the same floor sequence.
Doors never block the exit permanently.
Every key sits on the reachable side of its door.
Monsters never cross a locked door.

## Evaluation evidence

The suite has 68 tests.
It covers generation, biomes, combat, drops, pathfinding, and input.
All 68 tests pass in a headless run.
The suite has 95 tests.
It covers generation, biomes, combat, drops, pathfinding, input, and floors.
All 95 tests pass in a headless run.
A smoke test loads the game and spawns a fixed-seed run.
The smoke test checks the hero descends after reaching the exit.
The smoke test also checks every action has a gamepad binding.

## Roadmap

Done in this release:
- Multi-floor descent with a depth counter.

Done in an earlier release:
- Full gamepad support.

Next up:
- Multi-floor descent and a depth counter.
- Ranged monsters and projectiles.
- Sound and music.
- More biomes and items.

## Limitations

The demo has three biomes.
Each run is a single floor.
Each run spans three floors.
All monsters use melee attacks.
The game has no audio yet.

Expand Down
30 changes: 29 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ The class wraps the mulberry32 algorithm.
The output depends only on the seed, never on the platform.
Seeds display as six-character base-36 strings.

A run uses one run seed.
Each floor derives its dungeon seed from that run seed.
`RunRules.floor_seed` mixes the run seed with the floor number.
Floor one uses the run seed unchanged.

## Combat model

The player and each monster carry a `CombatStats` block.
Expand All @@ -95,6 +100,27 @@ The world renders from a tile map.
A `TileArt` class draws every sprite from pixel patterns.
The biome palette recolors the tiles at run time.

## Floor descent

A run spans three floors.
`RunRules` in `scripts/core` defines the run.
It holds the floor count, the difficulty curve, and the heal rate.

Each floor uses a derived seed.
Floor one uses the run seed unchanged.
Deeper floors mix the run seed with the floor number.
This keeps every run replayable from one seed.

The hero keeps health and loot between floors.
Keys reset, because each floor has its own doors.
The hero heals a fraction of missing health on descent.
Monsters use scaled stats on deeper floors.

The scene controller descends when the hero reaches the exit.
The run ends when the hero clears the final floor.
`MonsterSpec.scaled` copies a spec with stronger health and damage.
The generator and the rest of combat stay unchanged.

## Input handling

A `Controls` class reads all movement input.
Expand All @@ -111,9 +137,11 @@ The hints update when a gamepad connects or disconnects.
## Testing

The suite runs headless with GUT.
Unit tests cover the RNG, generator, biomes, combat, and drops.
Unit tests cover the RNG, generator, biomes, combat, drops, and run rules.
Integration tests run many seeds across all biomes.
Integration tests also drive the floor descent flow.
Every generated dungeon must be solvable.
Each floor must be a fresh solvable dungeon.

Run the suite with `tools/run_tests`.
CI runs the same commands on every push.
25 changes: 20 additions & 5 deletions scripts/actors/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ func setup(
view = p_view
occupancy = p_occupancy
position = view.tile_to_world(grid_pos)
_sprite = Sprite2D.new()
_sprite.texture = TileArt.entity_texture(&"player")
_sprite.centered = true
add_child(_sprite)
_add_light()
if _sprite == null:
_sprite = Sprite2D.new()
_sprite.texture = TileArt.entity_texture(&"player")
_sprite.centered = true
add_child(_sprite)
_add_light()
emit_hud()

func _physics_process(p_delta: float) -> void:
Expand Down Expand Up @@ -148,6 +149,20 @@ func spend_key() -> void:
func add_key() -> void:
apply_pickup(&"key", 1)

## Resets run-only loot when a new run starts. Health is set by setup.
func start_run() -> void:
coins = 0
shards = 0
keys_held = 0
if stats != null:
emit_hud()

## Drops any keys carried from the previous floor.
func reset_keys() -> void:
if keys_held > 0:
keys_held = 0
keys_changed.emit(keys_held)

func emit_hud() -> void:
hp_changed.emit(stats.health, stats.max_health)
coins_changed.emit(coins)
Expand Down
23 changes: 23 additions & 0 deletions scripts/combat/monster_spec.gd
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,26 @@ var drop_table: DropTable = null
## True when this spec is valid for spawning.
func is_valid() -> bool:
return id != &"" and stats != null and drop_table != null

## Returns a copy of this spec with scaled health and damage.
## Deeper floors use the result so monsters grow stronger.
## A scale at or below 1.0 returns this spec unchanged.
func scaled(p_scale: float) -> MonsterSpec:
if p_scale <= 1.0:
return self
var copy := MonsterSpec.new()
copy.id = id
copy.display_name = display_name
copy.ai = ai
copy.sprite_key = sprite_key
copy.aggro_range = aggro_range
copy.stats = CombatStats.make({
"max_health": maxi(1, roundi(stats.max_health * p_scale)),
"health": maxi(1, roundi(stats.max_health * p_scale)),
"damage": maxi(1, roundi(stats.damage * p_scale)),
"speed": stats.speed,
"attack_range": stats.attack_range,
"attack_cooldown": stats.attack_cooldown,
})
copy.drop_table = drop_table
return copy
38 changes: 38 additions & 0 deletions scripts/core/run_rules.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
class_name RunRules
extends RefCounted
## Rules that shape a complete dungeon run.
##
## A run descends through a fixed number of floors. Every floor is a new
## solvable dungeon of the same biome. The hero keeps health and loot
## between floors. Monsters grow stronger on each descent.

const DEFAULT_FLOORS := 3
const FLOOR_MIX := 0x9E3779B9

var floor_count: int = DEFAULT_FLOORS
## Fraction of missing health restored when the hero descends.
var heal_between_floors: float = 0.5
## Extra monster health and damage per floor, as a fraction of the base.
var difficulty_per_floor: float = 0.25

## The dungeon seed for a floor. Floor 0 uses the run seed unchanged.
static func floor_seed(p_run_seed: int, p_floor: int) -> int:
if p_floor <= 0:
return p_run_seed & SeededRng.SEED_MASK
return (p_run_seed ^ (p_floor * FLOOR_MIX)) & SeededRng.SEED_MASK

## True when reaching the exit on this floor wins the run.
func is_final_floor(p_floor: int) -> bool:
return p_floor >= floor_count - 1

## The monster strength multiplier for a floor. Floor 0 is 1.0.
func monster_scale(p_floor: int) -> float:
return 1.0 + float(p_floor) * difficulty_per_floor

## Restores a fraction of the hero's missing health on a descent.
## Returns the health actually restored.
func heal_between(p_stats: CombatStats) -> int:
var missing := p_stats.max_health - p_stats.health
if missing <= 0:
return 0
return p_stats.heal(maxi(1, roundi(missing * heal_between_floors)))
1 change: 1 addition & 0 deletions scripts/core/run_rules.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://cehmkkkft3kk0
2 changes: 2 additions & 0 deletions scripts/core/run_state.gd
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ static var biome_id: StringName = &""
static var status: RunStatus = RunStatus.IDLE
static var started_at: float = 0.0
static var finished_at: float = 0.0
static var floor_index: int = 0
static var floor_count: int = 0

## Elapsed play time in seconds for the current run.
static func elapsed() -> float:
Expand Down
Loading
Loading