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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ jobs:

- name: Run the headless smoke test
run: godot --headless --path . -s tools/smoke.gd

- name: Run static checks
run: godot --headless --path . -s tools/check_scripts.gd
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ 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 four generated floors.
- A floor counter in the HUD and the run summary.
- Stairs tiles that lead from one floor to the next.
- A run plan that derives a deterministic seed for every floor.
- Biomes that cycle in a fixed order across floors.
- Deeper floors with more monsters and tougher stats.
- A small heal on descent between floors.
- Static checks for CI that compile every script and verify registries.
- Tests for the run plan, floor seeds, stairs, and monster scaling.

## [0.2.0] - 2026-08-03

Added
Expand Down
86 changes: 46 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# Dungeonwright

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

```
####.D.############
##.........####..###
Expand All @@ -13,16 +10,21 @@ Every run builds a new dungeon that you can explore and finish.
##########.D..######
```

A seeded dungeon crawler built with Godot and GDScript.
Every run descends four floors.
Each floor builds a new map you can explore and finish.

## What it is

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

## Features

- A new map for every run, driven by a seed.
- Multi-floor descent with a floor counter.
- A new map for every floor, driven by a seed.
- Rooms, corridors, locked doors, and keys.
- Three biomes with different generation rules.
- Monsters with simple combat and balanced drops.
Expand All @@ -31,20 +33,18 @@ The same seed always builds the same dungeon.
- Deterministic generation for replayable runs.
- Full gamepad support with analog movement.

## First release

This release ships a playable demo.
The generator guarantees the exit is always reachable.
You can walk, fight, collect loot, and finish a run.
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 spans four floors.
Each floor uses a biome from a fixed cycle.
Every floor gets its own seed, derived from the run seed.
Deeper floors add more monsters and tougher stats.
The HUD shows the floor and the exit depth.
Stairs lead down to the next floor.
The bottom floor holds the true exit.
Keys reset on descent.
The hero keeps health, coins, and shards.

## Requirements

Expand Down Expand Up @@ -87,13 +87,6 @@ Run `tools/run_tests.sh` on Linux or macOS.
The script installs GUT, imports the project, and runs the suite.
Tests run headless, so no window opens.

## Install the test framework

GUT is a test addon for Godot.
The tool scripts can download and install it.
The version and checksum are pinned in `tools/gut.version.json`.
The tests run from the copy in `addons/gut`.

## How it works

The generator has a fixed pipeline.
Expand All @@ -102,13 +95,17 @@ It picks the farthest room as the exit.
It places doors on corridors and puts each key on the safe side.
A solver then proves the dungeon can be completed.

A run plan decides the floors.
Each floor gets a seed and a biome.
The floor index scales the monster pressure.
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.

## Project layout

- `scripts/dungeon` holds the generator and map logic.
- `scripts/core` holds the run plan and run state.
- `scripts/combat` holds stats, monsters, and loot tables.
- `scripts/input` holds the controls helper.
- `scripts/world` renders tiles and builds the minimap.
Expand All @@ -118,36 +115,45 @@ The scene controller turns the map into a live game.
- `tests` holds the GUT suite.
- `tools` holds the setup, test, and CI scripts.

## Sample output

A headless run prints the smoke test result.

```
[smoke] loading main scene
[smoke] scene added
[smoke] starting run with seed 12345
Smoke test passed: seed 12345 spawned a solvable multi-floor run.
```

## Design guarantees

A seed always produces the same map.
A seed always produces the same run.
Every floor is solvable.
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.
A smoke test loads the game and spawns a fixed-seed run.
The smoke test also checks every action has a gamepad binding.
The suite has 94 tests.
It covers generation, biomes, combat, drops, pathfinding, input,
and the run plan.
All 94 tests pass in a headless run.
A smoke test loads the game, spawns a fixed-seed run, and descends.
A static check compiles every script and verifies the registries.
CI runs the suite, the smoke test, and the static check.

## Roadmap

Done in this release:
- Full gamepad support.

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.
Done: seed-driven maps, three biomes, combat and drops,
gamepad support, and multi-floor descent.
Next up: ranged monsters, sound, and more biomes.

## Limitations

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

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

## Multi-floor runs

A run spans several floors.
A `RunPlan` decides the floor sequence from the run seed.
Each floor gets its own derived seed and biome.
The generator places a `STAIRS` tile as the exit of every floor
except the last one.
The bottom floor uses the true `EXIT` tile.

The floor index drives difficulty.
Deeper floors raise the monster cap and the monster density.
The `MonsterSpecs` class scales health and damage with depth.
The biome still sets the map shape, corridor style, and palette.

The hero keeps health, coins, and shards between floors.
Keys reset on descent because doors are per floor.
Reaching the stairs moves the run down one floor.
Reaching the true exit wins the run.

## Input handling

A `Controls` class reads all movement input.
Expand All @@ -111,9 +130,13 @@ 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 plan.
Integration tests run many seeds across all biomes.
Every generated dungeon must be solvable.
A full-run test proves every planned floor stays solvable.

A static check loads every GDScript file and verifies the registries.
The smoke test loads the main scene and descends to the second floor.

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

This document tracks the direction of Dungeonwright.
Completed items are marked done.
Open items show the next planned work.

## Done

- Seed-driven dungeon generation. (0.1.0)
- Rooms, corridors, locked doors, and keys. (0.1.0)
- Three biomes with different generation rules. (0.1.0)
- Monsters, melee combat, and drop tables. (0.1.0)
- Deterministic replay from a seed string. (0.1.0)
- Full gamepad support for every action. (0.2.0)
- Multi-floor descent with a floor counter. (0.3.0)
- Depth scaling for monsters and spawn pressure. (0.3.0)

## Next up

- Ranged monsters and projectiles.
- Sound and music.
- More biomes and items.
- A bestiary or monster log.
- More floor variety in the run plan.
- An export build for desktop platforms.

## Principles

Every planned feature must keep the run deterministic.
A seed must always replay the same run.
Every generated floor must stay solvable.
New behaviour should ship with tests that run headless.
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. Each run descends four floors of generated, solvable maps."
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
Expand Down
18 changes: 18 additions & 0 deletions scripts/actors/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func setup(
p_view: DungeonView,
p_occupancy: Dictionary
) -> void:
_clear_children()
stats = p_stats
grid_pos = p_start
view = p_view
Expand All @@ -58,6 +59,11 @@ func setup(
_add_light()
emit_hud()

## Removes the previous sprite and light before a new setup.
func _clear_children() -> void:
for child in get_children():
child.free()

func _physics_process(p_delta: float) -> void:
if view == null or stats == null:
return
Expand Down Expand Up @@ -145,6 +151,18 @@ func spend_key() -> void:
keys_held -= 1
keys_changed.emit(keys_held)

## Drops every held key. Doors do not carry across floors.
func reset_keys() -> void:
keys_held = 0
keys_changed.emit(keys_held)

## Restores health without consuming a potion.
func heal(p_amount: int) -> void:
if stats == null:
return
stats.heal(p_amount)
hp_changed.emit(stats.health, stats.max_health)

func add_key() -> void:
apply_pickup(&"key", 1)

Expand Down
24 changes: 24 additions & 0 deletions scripts/combat/monster_specs.gd
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,27 @@ static func _entries(p_weights: Array) -> Array:
## Rolls a monster's drop table with a deterministic RNG.
static func roll_drops(p_spec: MonsterSpec, p_rng: SeededRng, p_times: int = 1) -> Array[Drop]:
return p_spec.drop_table.roll_many(p_times, p_rng)

## Returns a copy of the spec with stats scaled for a floor index.
## Deeper floors grow health and damage. The first floor keeps the base
## spec, and the source spec is never mutated.
static func scaled(p_spec: MonsterSpec, p_floor_index: int) -> MonsterSpec:
if p_floor_index <= 0:
return p_spec
var factor := 1.0 + p_floor_index * 0.15
var copy := MonsterSpec.new()
copy.id = p_spec.id
copy.display_name = p_spec.display_name
copy.ai = p_spec.ai
copy.sprite_key = p_spec.sprite_key
copy.aggro_range = p_spec.aggro_range
copy.stats = CombatStats.make({
"max_health": roundi(p_spec.stats.max_health * factor),
"health": roundi(p_spec.stats.max_health * factor),
"damage": roundi(p_spec.stats.damage * factor),
"speed": p_spec.stats.speed,
"attack_range": p_spec.stats.attack_range,
"attack_cooldown": p_spec.stats.attack_cooldown,
})
copy.drop_table = p_spec.drop_table
return copy
33 changes: 33 additions & 0 deletions scripts/core/run_plan.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
class_name RunPlan
extends RefCounted
## The floor sequence of a single run.
##
## A run has a fixed number of floors. Every floor gets its own seed,
## derived from the run seed, and a biome chosen in a stable cycle.
## Deeper floors scale the biome, so each descent is harder than the
## last. The plan is pure data, so tests can assert determinism.

const DEFAULT_FLOOR_COUNT := 4

var floor_count: int = DEFAULT_FLOOR_COUNT

func _init(p_floor_count: int = DEFAULT_FLOOR_COUNT) -> void:
floor_count = maxi(1, p_floor_count)

## Returns one entry per floor: { index, seed, biome }.
func floors(p_run_seed: int) -> Array[Dictionary]:
var biomes := Biomes.all()
var plan: Array[Dictionary] = []
for index in floor_count:
var floor_seed := p_run_seed if index == 0 else SeededRng.derive(p_run_seed, index)
var biome := Biomes.scaled(biomes[index % biomes.size()], index)
plan.append({
"index": index,
"seed": floor_seed & SeededRng.SEED_MASK,
"biome": biome,
})
return plan

## True when the floor index is the last floor of the run.
func is_final(p_floor_index: int) -> bool:
return p_floor_index >= floor_count - 1
1 change: 1 addition & 0 deletions scripts/core/run_plan.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://1mnfl5xl8l7s
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_index: int = 0
static var floor_count: int = 1
static var status: RunStatus = RunStatus.IDLE
static var started_at: float = 0.0
static var finished_at: float = 0.0
Expand Down
Loading
Loading