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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
*.md text eol=lf
*.sh text eol=lf
*.ps1 text eol=lf
*.yml text eol=lf
*.svg text eol=lf
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 5
labels:
- "ci"
- "dependencies"
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read
Expand All @@ -15,6 +16,7 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out the repository
uses: actions/checkout@v4
Expand Down
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 runs that descend into a new dungeon per floor.
- A deterministic floor seed chain from a single run seed.
- A floor counter in the HUD and on the result screen.
- A floor-cleared transition overlay.
- The hero keeps coins and shards between floors.
- The exit only ends the run on the final floor.
- Unit and integration tests for the descent chain.
- A smoke test that clears every floor of a run.

## [0.2.0] - 2026-08-03

Added
Expand Down
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ 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 same seed always builds the same dungeon.
The same seed always builds the same run.
The run descends through several floors.

## Features

- A new map for every run, driven by a seed.
- Multi-floor runs that descend into a new dungeon.
- Rooms, corridors, locked doors, and keys.
- Three biomes with different generation rules.
- Monsters with simple combat and balanced drops.
Expand All @@ -40,11 +42,12 @@ 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 a multi-floor descent.
Every run now spans several floors.
The exit on a floor leads down to the next floor.
One seed replays the whole run, floor by floor.
Coins and shards carry over between floors.
The run ends when you clear the final floor.

## Requirements

Expand All @@ -68,6 +71,7 @@ Leave the field empty for a random seed.

Move with WASD or the arrow keys.
Attack with Space, J, or a mouse click.
Reach the exit to descend to the next floor.
Press N for a new run.
Press M to toggle the minimap.
Press Escape to pause.
Expand Down Expand Up @@ -106,10 +110,17 @@ 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 has more than one floor.
Each floor builds a fresh dungeon from a child seed.
The child seed comes from the run seed through a fixed mixer.
So one seed replays every floor in the same order.
Coins and shards carry over between floors.

## Project layout

- `scripts/dungeon` holds the generator and map logic.
- `scripts/combat` holds stats, monsters, and loot tables.
- `scripts/core` holds the run state and the descent seed chain.
- `scripts/input` holds the controls helper.
- `scripts/world` renders tiles and builds the minimap.
- `scripts/actors` holds the hero, monsters, and pickups.
Expand All @@ -120,34 +131,33 @@ 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 floors.
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 suite has 81 tests.
It covers generation, biomes, descent, combat, drops, pathfinding, and input.
All 81 tests pass in a headless run.
A smoke test loads the game and clears a fixed-seed run floor by floor.
The smoke test also checks every action has a gamepad binding.

## Roadmap

Done in this release:
- Full gamepad support.
- Multi-floor descent with a floor counter.

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 stays inside one biome.
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 @@ -72,6 +72,24 @@ 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.

## Multi-floor descent

A run covers several floors.
The `Descent` class derives one floor seed per floor from the run seed.
It mixes the previous floor seed with a fixed hash.
Floor one uses the run seed unchanged.

Every floor calls the same generator with its own floor seed.
So one seed replays every floor in the same order.
The generator result reports the run seed and the floor number.
The final floor is the one that matches the biome floor count.

The scene controller keeps the hero between floors.
Coins and shards carry over.
Keys reset, because each floor has its own doors.
Reaching the exit descends to the next floor.
Reaching the exit on the final floor wins the run.

## Combat model

The player and each monster carry a `CombatStats` block.
Expand Down Expand Up @@ -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, descent, combat, and drops.
Integration tests run many seeds across all biomes.
Every generated dungeon must be solvable.
Descent tests prove every floor of a run stays solvable and distinct.

Run the suite with `tools/run_tests`.
CI runs the same commands on every push.
20 changes: 15 additions & 5 deletions scripts/actors/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ var _moving := false
var _attack_timer := 0.0
var _sprite: Sprite2D = null

## Prepares the hero for a floor. Call once per floor.
## The hero keeps its coins and shards, and its visuals are created only once.
func setup(
p_stats: CombatStats,
p_start: Vector2i,
Expand All @@ -51,11 +53,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:
_create_visuals()
_from = grid_pos
_to = grid_pos
_progress = 1.0
_moving = false
emit_hud()

func _physics_process(p_delta: float) -> void:
Expand Down Expand Up @@ -159,6 +162,13 @@ func _flash() -> void:
tween.tween_property(_sprite, "modulate", Color(3.0, 0.4, 0.4), 0.08)
tween.tween_property(_sprite, "modulate", Color.WHITE, 0.12)

func _create_visuals() -> void:
_sprite = Sprite2D.new()
_sprite.texture = TileArt.entity_texture(&"player")
_sprite.centered = true
add_child(_sprite)
_add_light()

func _add_light() -> void:
var light := PointLight2D.new()
light.texture = _soft_light_texture()
Expand Down
27 changes: 27 additions & 0 deletions scripts/core/descent.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class_name Descent
extends RefCounted
## Deterministic seed chain for a multi-floor run.
##
## A run starts from one seed. Every floor uses a child seed derived
## from the parent with a fixed mixer, so a single seed replays every
## floor in the same order. The chain never touches platform state.

## Returns the seed for a floor in a run. Floors start at 1.
static func floor_seed(p_run_seed: int, p_floor: int) -> int:
var value := p_run_seed & SeededRng.SEED_MASK
if p_floor <= 1:
return value
for i in p_floor - 1:
value = _mix(value)
return value & SeededRng.SEED_MASK

## The number of floors a run covers for the given biome.
static func floor_count(p_config: DungeonConfig) -> int:
return maxi(1, p_config.max_floors)

## Mixes one floor seed into the next with a fixed hash.
static func _mix(p_value: int) -> int:
var x := (p_value + 0x9E3779B9) & 0xFFFFFFFF
x = ((x ^ (x >> 16)) * 0x21F0AAAD) & 0xFFFFFFFF
x = ((x ^ (x >> 15)) * 0x735A2D97) & 0xFFFFFFFF
return (x ^ (x >> 15)) & 0xFFFFFFFF
1 change: 1 addition & 0 deletions scripts/core/descent.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://ca4x1mful4slo
6 changes: 6 additions & 0 deletions scripts/core/run_state.gd
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ 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
## The current floor of the run. Floors start at 1.
static var floor: int = 1
## The number of floors the run covers.
static var max_floors: int = 1
## The number of floors the hero cleared so far.
static var floors_cleared: int = 0

## Elapsed play time in seconds for the current run.
static func elapsed() -> float:
Expand Down
3 changes: 3 additions & 0 deletions scripts/dungeon/biomes.gd
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ static func crypt() -> DungeonConfig:
config.room_max = 9
config.corridor_style = DungeonConfig.CorridorStyle.elbow
config.loop_chance = 0.25
config.max_floors = 3
config.door_count_min = 2
config.door_count_max = 3
config.monster_density = 0.5
Expand All @@ -59,6 +60,7 @@ static func drowned_forest() -> DungeonConfig:
config.room_max = 12
config.corridor_style = DungeonConfig.CorridorStyle.winding
config.loop_chance = 0.5
config.max_floors = 3
config.door_count_min = 1
config.door_count_max = 2
config.monster_density = 0.4
Expand Down Expand Up @@ -86,6 +88,7 @@ static func ember_stronghold() -> DungeonConfig:
config.room_max = 8
config.corridor_style = DungeonConfig.CorridorStyle.straight
config.loop_chance = 0.15
config.max_floors = 4
config.door_count_min = 3
config.door_count_max = 4
config.monster_density = 0.65
Expand Down
4 changes: 4 additions & 0 deletions scripts/dungeon/dungeon_config.gd
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ var room_min: int = 5
var room_max: int = 9
var corridor_style: StringName = CorridorStyle.elbow
var loop_chance: float = 0.25
## The number of floors a run covers in this biome.
var max_floors: int = 1
var door_count_min: int = 2
var door_count_max: int = 3
var monster_density: float = 0.5
Expand All @@ -47,6 +49,8 @@ func validate() -> Array[String]:
problems.append("monster density must be between 0 and 1")
if loop_chance < 0.0 or loop_chance > 1.0:
problems.append("loop chance must be between 0 and 1")
if max_floors < 1:
problems.append("max floors must be at least 1")
if monster_table.is_empty():
problems.append("monster table is empty")
if palette.is_empty():
Expand Down
15 changes: 11 additions & 4 deletions scripts/dungeon/dungeon_generator.gd
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class_name DungeonGenerator
extends RefCounted
## Builds a solvable dungeon from a seed and a biome.
## Builds one floor of a solvable dungeon from a seed and a biome.
##
## The generator follows a fixed pipeline:
## 1. Place rooms without overlaps.
Expand All @@ -13,11 +13,18 @@ extends RefCounted
## 7. Scatter monsters in the rooms.
##
## Every step uses the same SeededRng, so a seed always produces the
## same dungeon.

func generate(p_config: DungeonConfig, p_seed: int) -> DungeonResult:
## same floor. Multi-floor runs pass p_floor and the base run seed so
## the result can report which floor it describes.
func generate(
p_config: DungeonConfig,
p_seed: int,
p_floor: int = 1,
p_run_seed: int = -1
) -> DungeonResult:
var result := DungeonResult.new()
result.seed_value = p_seed
result.run_seed = p_seed if p_run_seed < 0 else p_run_seed
result.floor = p_floor
result.config = p_config

var rng := SeededRng.new(p_seed)
Expand Down
8 changes: 8 additions & 0 deletions scripts/dungeon/dungeon_result.gd
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ extends RefCounted
## the start, the exit, keys, doors, and monster spawns.

var seed_value: int = 0
## The seed that started the whole run. It never changes between floors.
var run_seed: int = 0
## The floor of the run that this result describes. Floors start at 1.
var floor: int = 1
var config: DungeonConfig = null
var map: DungeonMap = null
var rooms: Array[Room] = []
Expand Down Expand Up @@ -39,3 +43,7 @@ func key_count() -> int:
## The number of monster spawns.
func monster_count() -> int:
return monster_spawns.size()

## True when this floor is the last floor of the run.
func is_final_floor() -> bool:
return config != null and floor >= Descent.floor_count(config)
8 changes: 4 additions & 4 deletions scripts/input/controls.gd
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,12 @@ static func label_for(p_action: StringName, p_gamepad: bool) -> String:
return "Start" if p_gamepad else "Esc"
return ""

## A two-line hint for the menu, tailored to the active device.
## A hint for the menu, tailored to the active device.
static func hint_text() -> String:
return hint_for(gamepad_active())

## A two-line hint for the menu, tailored to a given device.
## A hint for the menu, tailored to a given device.
static func hint_for(p_gamepad: bool) -> String:
if p_gamepad:
return "Move: left stick or d-pad Attack: A or R shoulder\nOpen doors: walk in with a key New run: Y Pause: Start"
return "Move: WASD or arrows Attack: Space, J, or click\nOpen doors: walk in with a key New run: N Pause: Esc"
return "Move: left stick or d-pad Attack: A or R shoulder\nOpen doors: walk in with a key Reach the exit to descend\nNew run: Y Pause: Start"
return "Move: WASD or arrows Attack: Space, J, or click\nOpen doors: walk in with a key Reach the exit to descend\nNew run: N Pause: Esc"
Loading
Loading