Skip to content
Closed
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ 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 with five floors per run.
- A floor counter in the HUD.
- Reaching the exit on a lower floor descends to the next floor.
- Per-floor monster scaling for health and damage.
- Deeper floors raise monster density and door counts.
- Deterministic floor seeds derived from the run seed.
- A floors total in the run summary.
- Unit tests for floor rules and multi-floor replay.

## [0.2.0] - 2026-08-03

Added
Expand Down
45 changes: 30 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Dungeonwright

A seeded dungeon crawler built with Godot and GDScript.
Every run builds a new dungeon that you can explore and finish.
Every run builds new dungeons that you explore floor by floor.

```
####.D.############
Expand All @@ -15,14 +15,15 @@ Every run builds a new dungeon that you can explore and finish.

## What it is

Dungeonwright generates a connected dungeon on every run.
Dungeonwright generates a connected dungeon for every floor.
You explore rooms and corridors.
You find keys, open locked doors, and reach the exit.
The same seed always builds the same dungeon.
The same seed always replays the same descent.

## Features

- A new map for every run, driven by a seed.
- Multi-floor descents with five floors per run.
- Rooms, corridors, locked doors, and keys.
- Three biomes with different generation rules.
- Monsters with simple combat and balanced drops.
Expand All @@ -40,11 +41,14 @@ 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.
Each run spans five floors.
Reaching the exit on a lower floor descends to the next one.
Deeper floors grow harder.
Monsters gain health and damage.
They spawn more often and in greater numbers.
The HUD shows the current floor.
The full descent replays from one seed.

## Requirements

Expand Down Expand Up @@ -106,6 +110,13 @@ 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.

Floors use the same pipeline.
Each floor derives its seed from the run seed.
The full descent then replays from one seed.

Monster stats scale with the floor number.
Deeper floors also add more monsters and doors.

## Project layout

- `scripts/dungeon` holds the generator and map logic.
Expand All @@ -121,33 +132,37 @@ The scene controller turns the map into a live game.
## Design guarantees

A seed always produces the same map.
A run seed replays every floor of the descent.
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 77 tests.
It covers generation, biomes, floors, combat, drops, pathfinding, and input.
All 77 tests pass in a headless run.
A smoke test loads the game and spawns a fixed-seed run.
The smoke test also checks every action has a gamepad binding.
The smoke test also descends one floor and re-verifies the world.
It also checks every action has a gamepad binding.

## Roadmap

Done in this release:
- Full gamepad support.
- Multi-floor descent and a depth counter.
- Per-floor monster scaling.

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

See `docs/roadmap.md` for the full plan.

## Limitations

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

Expand Down
21 changes: 20 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@ 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 progression

A run spans five floors.
Each floor is its own generated dungeon.
`FloorRules` derives a floor seed from the run seed.
The whole descent replays from the single run seed.

The hero reaches the exit on a lower floor and descends.
The `Main` scene keeps the hero health and loot.
Keys reset, because every floor has its own locks.
The final floor ends the run with a victory summary.

Deeper floors scale the monster pressure.
`FloorRules.scaled` returns a copy of the biome.
Monster health and damage rise each floor.
Monster density, monster cap, and door counts also rise.
The run summary shows the floor count reached.

## Input handling

A `Controls` class reads all movement input.
Expand All @@ -111,9 +129,10 @@ 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 floors.
Integration tests run many seeds across all biomes.
Every generated dungeon must be solvable.
Every floor of a run must replay from its run seed.

Run the suite with `tools/run_tests`.
CI runs the same commands on every push.
49 changes: 49 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Roadmap

This roadmap tracks the planned releases.
It shows what is done and what remains.

## Released

### 0.1.0 - Foundations

- Seed-driven dungeon generation with three biomes.
- Rooms, corridors, locked doors, and keys.
- Monsters, melee combat, and drop tables.
- Deterministic replay from a seed string.
- Procedural tile art and a minimap.
- A headless test suite and a smoke test.

### 0.2.0 - Gamepad support

- Gamepad bindings for every action.
- Analog stick movement with a deadzone.
- Normalised diagonal movement speed.
- Control hints for the active device.

### 0.3.0 - Multi-floor descent

- Five floors per run with a floor counter.
- Reaching the exit on a lower floor descends.
- Per-floor monster scaling.
- Deterministic floor seeds from the run seed.

## Next up

### Ranged combat

- Ranged monsters and projectiles.

### Audio

- Sound and music.

### Content

- More biomes and items.

## Scope notes

- Every feature must keep generation deterministic.
- The exit must stay reachable on every floor.
- New systems need unit or integration tests.
2 changes: 1 addition & 1 deletion project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ config_version=5
[application]

config/name="Dungeonwright"
config/description="A seeded dungeon crawler that builds a new, solvable map every run."
config/description="A seeded dungeon crawler that builds new, solvable maps for every floor of a run."
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
Expand Down
10 changes: 6 additions & 4 deletions scripts/actors/monster_actor.gd
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ func setup(
p_start: Vector2i,
p_view: DungeonView,
p_occupancy: Dictionary,
p_target: Node2D
p_target: Node2D,
p_health_scale: float = 1.0,
p_damage_scale: float = 1.0
) -> void:
spec = p_spec
stats = CombatStats.make({
"max_health": p_spec.stats.max_health,
"health": p_spec.stats.max_health,
"damage": p_spec.stats.damage,
"max_health": maxi(1, roundi(p_spec.stats.max_health * p_health_scale)),
"health": maxi(1, roundi(p_spec.stats.max_health * p_health_scale)),
"damage": maxi(1, roundi(p_spec.stats.damage * p_damage_scale)),
"speed": p_spec.stats.speed,
"attack_range": p_spec.stats.attack_range,
"attack_cooldown": p_spec.stats.attack_cooldown,
Expand Down
13 changes: 13 additions & 0 deletions scripts/actors/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ func setup(
_add_light()
emit_hud()

## Repositions the hero for a new floor. Keeps health, coins, and
## shards. Keys reset, because every floor has its own locks.
func relocate(p_start: Vector2i, p_view: DungeonView, p_occupancy: Dictionary) -> void:
view = p_view
occupancy = p_occupancy
grid_pos = p_start
position = view.tile_to_world(grid_pos)
keys_held = 0
_moving = false
_progress = 1.0
_attack_timer = 0.0
emit_hud()

func _physics_process(p_delta: float) -> void:
if view == null or stats == null:
return
Expand Down
46 changes: 46 additions & 0 deletions scripts/core/floor_rules.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
class_name FloorRules
extends RefCounted
## Defines the floor progression of a run.
##
## A run spans a fixed number of floors. Every floor derives a fresh seed
## from the run seed, so the whole descent repeats from one seed. Deeper
## floors scale the monster pressure, so the run grows harder as you go.

const FLOOR_COUNT := 5
const SEED_MASK := 0xFFFFFF
const SEED_MIX := 2654435761

## Returns the deterministic seed of the given floor inside a run.
static func seed_for(p_run_seed: int, p_floor: int) -> int:
return (p_run_seed + p_floor * SEED_MIX) & SEED_MASK

## True when the floor is the last one of a run.
static func is_final(p_floor: int) -> bool:
return p_floor >= FLOOR_COUNT

## Returns a copy of a biome scaled for the given floor.
## Monsters grow stronger and denser with every descent.
static func scaled(p_biome: DungeonConfig, p_floor: int) -> DungeonConfig:
var config := DungeonConfig.new()
config.id = p_biome.id
config.display_name = p_biome.display_name
config.description = p_biome.description
config.width = p_biome.width
config.height = p_biome.height
config.room_count_min = p_biome.room_count_min
config.room_count_max = p_biome.room_count_max
config.room_min = p_biome.room_min
config.room_max = p_biome.room_max
config.corridor_style = p_biome.corridor_style
config.loop_chance = p_biome.loop_chance
config.door_count_min = p_biome.door_count_min
config.door_count_max = mini(6, p_biome.door_count_max + (p_floor - 1) / 2)
config.monster_density = minf(0.9, p_biome.monster_density + 0.05 * (p_floor - 1))
config.monster_cap = p_biome.monster_cap + (p_floor - 1) / 2
config.monster_table = p_biome.monster_table
config.starting_health = p_biome.starting_health
config.player_damage = p_biome.player_damage
config.palette = p_biome.palette
config.monster_health_scale = 1.0 + 0.15 * (p_floor - 1)
config.monster_damage_scale = 1.0 + 0.1 * (p_floor - 1)
return config
1 change: 1 addition & 0 deletions scripts/core/floor_rules.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://cbf1g1jby6ocv
2 changes: 2 additions & 0 deletions scripts/core/run_state.gd
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ enum RunStatus { IDLE, ACTIVE, WON, LOST }
static var seed_value: int = 0
static var seed_string: String = ""
static var biome_id: StringName = &""
static var floor: int = 1
static var floor_count: int = FloorRules.FLOOR_COUNT
static var status: RunStatus = RunStatus.IDLE
static var started_at: float = 0.0
static var finished_at: float = 0.0
Expand Down
6 changes: 6 additions & 0 deletions scripts/dungeon/dungeon_config.gd
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ var door_count_max: int = 3
var monster_density: float = 0.5
var monster_cap: int = 10
var monster_table: Array = []
var monster_health_scale: float = 1.0
var monster_damage_scale: float = 1.0
var starting_health: int = 100
var player_damage: int = 12
var palette: Dictionary = {}
Expand All @@ -45,6 +47,10 @@ func validate() -> Array[String]:
problems.append("door count is negative")
if monster_density < 0.0 or monster_density > 1.0:
problems.append("monster density must be between 0 and 1")
if monster_health_scale < 1.0:
problems.append("monster health scale is below 1")
if monster_damage_scale < 1.0:
problems.append("monster damage scale is below 1")
if loop_chance < 0.0 or loop_chance > 1.0:
problems.append("loop chance must be between 0 and 1")
if monster_table.is_empty():
Expand Down
4 changes: 4 additions & 0 deletions scripts/dungeon/dungeon_result.gd
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ extends RefCounted
var seed_value: int = 0
var config: DungeonConfig = null
var map: DungeonMap = null
## One-based floor number inside the run that owns this map.
var floor: int = 1
## Total number of floors in the run.
var floor_count: int = 1
var rooms: Array[Room] = []
var corridors: Array[Corridor] = []
var start_room: int = -1
Expand Down
Loading
Loading