
RetroDungeon
Classic dungeon crawler
Rust with Slint
No engine
My Role
RetroDungeon is a tech demo built on top of my custom ECS system. Here are some highlights:
Programmer
Team Size
Solo project
-
ECS architecture
-
Powerful interaction system
-
Data driven
-
Procedural Generation
-
Pathfinding
-
Line of Sight
Year
2024
Architecture
Storage
I use three main data structures in my architecture:
-
An ECS-style table storage for interactive game elements
-
An array of map tile ids for non interactive game elements
-
A resource manager which maps typed ids to actual data
My table storage is a dynamic array of dynamic arrays containing struct types. This is not ideal, but allows for dynamically adding new component types which has been handy during development.
The resource managers keeps track of ids. These are wrapped in a struct to ensure solid typing, such as SpellDefinitionId(id) or TileId(id). All data stored in the resource manager is specified in yaml format, to allow for easy customization.
(Note: serialized spell definitions are not done yet.)
Components
My components are simple "bag of data" structs. Any struct will do, I have no component trait or similar. They're all stored as optional heap allocated values.
​
This works because the columns in my table storage are defined using generics over Vec<Option<Box<T>>>. While this entails a performance hit versus a more optimized approach, a classic roguelike doesn't need that performance. The flexibility during development has greatly outweighed the cost.
In the future, I may add a marker trait to ensure better compile-time type safety.
Systems
Systems are implemented as rust functions with a specific signature. Specifically, they receive mutable access to the entity storage, the map, and immutable access to the resource manager.
​
This "everything and the kitchen sink" approach works because I have very few systems, with most of the performance demands happening in the "monster take action" during pathfinding.
Frontend
My frontend is made in slint, which uses a custom css-inspired markup language. This enforces a separation between my frontend and backend.
Communication from slint to the game happens through a single callback that issues high level commands, such as player input or system signals like start/restart, and quit.
​
My backend updates the model that slint reads from after each command has been fully processed, and slint decides what to redraw.
Events and Interactions
Overview
All interactions between entities are handled with events. This system is inspired by the stim and response system of the old Thief games.
​
In this context, and event is a strongly typed signal that can be sent to and entity and executed by that entity synchronously.
This happens in four steps:
-
Event is generated
-
Event is sent to an entity
-
Event is dispatched to an appropriate event response
-
Event response calls the event payload
Sending an event
Events have source id, which is the entity that sent the event.
They also belong to a specific type, which gives them their payload function.
​
There are many event types:
-
Combat events, like Attack/Shoot
-
Interaction events, like Interact/Pickup
-
Death events, called by a system when HP reaches 0
​
Once created, an event is send to a specific entity.
Responding to an event
When an event is sent to an entity it is dispatched to a matching event response if that entity has one.
​
Responses inject arguments into the event payload function, and then calls it.
​
This structure gives the event responses the ability to selectively ignore, re-target, or alter the events sent to them. Responses may also perform other operations before or after calling the event payload.
Example
The player attacks a wizard with a ranged attack. That wizard has a reflect arrow spell up, so the attack is going to be reflected.
​
-
A shoot event is generated, with the player's entity as source
-
That event is broadcasted to the correct entity
-
A matching ShootResponse is found on that entity
-
The ShootResponse handles the event:
-
It alters how the event is going to be logged to the player through injecting arguments
-
It calls the event with the source id for both source and target.
-
-
Player sees they have taken damage instead of the wizard, and is told what happened
Optimizations
Since the performance demands of a classic roguelike are so low, very little in terms of optimization has been required.
I did, however, run into a few issues with pathfinding. Originally, I was using a naive Djikstra based approach where each monster would do a full search each turn. This was very slow, even using priority queues and other low hanging fruit.
​
My solution was to run the pathfinding algorithm in reverse, starting with the player's position and decorating each tile with the direction of the fastest path to the player. This meant I only had to run it once per turn, and only processes areas with a path to the player.
​
In the future, I would want to extend this to allow for multiple different destinations, not just the player, using some lazy loading scheme that resets each turn.
I also store indices of entities in a binary space partitioning. My level generation method already produces one as a side product, so I'm using it to speed up finding entities by location.
This is very helpful when I need to check if a tile is obscuring sight, or blocking a path.
Level generation
Rooms
I use a binary space portioning strategy for laying out my rooms. My version boils down into these steps:
​
-
Split the playable area into regions through binary partitioning
-
Transform the split areas from the binary tree into rooms
-
Prune away unwanted rooms
-
Connect rooms locally
-
Prune down the number of connections

Regions without further processing.
Regions are represented as bounding boxes, same with rooms. Rooms are just randomly shrunk versions of the regions.
Rooms are connected to each other by placing hit boxes in cardinal directions that check for overlaps with other rooms. These are also, bounding boxes.
​
Pruning connections is done on rooms with high numbers of edges, ensuring that no room becomes unreachable before committing an edge to be pruned.

Fully generated rooms
Spawning
Once rooms are generated, I do a floodfill starting from the top left.
-
If this is the first room visited: spawn the player only
-
If this is the last room visited, add a staircase
-
Aply a matching room template
Room templates are collections of spawn tables that have requirements on them. Before assigning a room template, we first check that the room passes all requirements.
​
Typically requirements are:
-
Larger/smaller than a certain size
-
Early/Late in the flood fill process
-
Dungeon level constraints
After the rooms have templates, the game goes through them one by one and spawns their respective entities. Spawn entries can also have requirements on them such as:
-
Chests of gold usually require a place near a wall
-
Most enemies are not allowed to spawn by a door
-
Some enemies prefer to spawn in the open