An emoji-glyph dungeon crawler in C# / .NET 10. Procedural rooms-and-corridors maps, persistent multi-level dungeons, fog of war, bump-to-attack melee combat, ranged magic via wands, a boss requiring magic or enchanted steel, and endless descent for high scores. No external dependencies — just System.Console.
dotnet runRequires a real terminal (≥ 80×23 cells). Console.ReadKey needs a TTY, so the game cannot be driven from piped stdin.
| Key | Action | Consumes a turn? |
|---|---|---|
↑ ↓ ← → |
Move one tile; bump into a monster to melee-attack | yes |
g / G |
Pick up the item under you | yes (if item) |
i / I |
Open the inventory menu | no |
z / Z |
Zap your readied wand (opens direction prompt) | yes (if fired) |
> |
Descend stairs (must be standing on a >> tile) |
yes |
< |
Ascend stairs (must be standing on a << tile) |
yes |
q / Q |
Quit immediately (no save, no high-score entry) | n/a |
| any other key | ignored | no |
Letter commands (g, i, z, q) are case-insensitive. The stair commands > and < are read by character, not key, so any keyboard layout that produces those characters works.
| Key | Action |
|---|---|
a … t |
Use/equip the item with that letter |
| any other key | Cancel and return to the map |
Effects by item kind:
- Healing potion — drunk, removed from inventory, heals up to its amount (capped at MaxHp).
- Weapon — equipped to the weapon slot (replaces previous equipped weapon, which stays in inventory).
- Wand — readied in the wand slot (replaces previously readied wand).
| Key | Action |
|---|---|
↑ ↓ ← → |
Fire fireball in that direction (consumes 1 charge) |
| any other key | Cancel — no turn, no charge spent |
Requires a readied wand with at least one charge; otherwise pressing z just logs a message and consumes nothing.
| Key | Action |
|---|---|
| any key | Acknowledge death (then any key again to exit) |
The map is 30 tiles wide × 18 tall, with each tile rendered as 2 terminal cells wide to accommodate emoji.
| Glyph | Meaning | Visible color | Dim (explored) color |
|---|---|---|---|
██ |
Wall | DarkCyan | DarkBlue |
·· |
Floor | DarkYellow | DarkGray |
>> |
Stairs down | Yellow | DarkYellow |
<< |
Stairs up | White | Gray |
| ⛲ | Fountain | Cyan | DarkCyan |
| Glyph | Name |
|---|---|
| 🧙 | Player (you) |
| 🐀 | Rat |
| 👺 | Goblin |
| 👹 | Orc |
| 🧌 | Troll |
| 👻 | Wraith |
| 💀 | Dread knight (boss) |
| 🧪 | Healing potion |
| 🗡️ | Weapon (any kind) |
| 🪄 | Wand of fireball |
| 🔥 | Fireball projectile (animation only) |
Visible tiles render in full color; explored-but-not-visible tiles render dim (fog of war). Unexplored tiles render as blank space.
| Concern | Value |
|---|---|
| Language | C# (modern, top-level statements in Program.cs) |
| Framework | .NET 10 (net10.0), ImplicitUsings + Nullable enabled |
| External libraries | None |
| Output encoding | UTF-8 (Console.OutputEncoding = Encoding.UTF8) |
| Terminal size | At least 80 cols × 23 rows |
| Input | Console.ReadKey(true) — real TTY only |
| Persistence | highscores.txt next to the binary (top 10 runs) |
Each iteration of the main loop:
- Compute FOV — recursive 360-ray raycast from player position, radius 8 tiles. Sets each tile's
Visibleflag;Exploredis sticky once set. - Render — draw map + entities + sidebar + log via a double-buffered renderer that only updates changed cells.
- Block on
Console.ReadKey(true)for player input. - Dispatch input. Action returns
tookTurn: bool. Movement, attack, pickup, zap, descend/ascend each consume a turn; opening the inventory or pressing an unbound key does not. - If turn was taken, run monster AI.
- Loop until player dies (
Hp <= 0) or quits (q).
If the player dies, the final frame is rendered with the death message in the log, then the Game Over screen is shown with the final score and an updated high-score table.
- Per-floor procedural generation in
MapGenerator.Generate(width, height, depth, allowBossSpawn). - Up to 30 placement attempts for rectangular rooms (size 4–9 on each axis).
- Each new room is rejected if it intersects an existing one.
- After placement, each new room is connected to the previously placed room via an L-shaped corridor (random order: horizontal-then-vertical or vertical-then-horizontal).
- Spawn: first room's center is the player spawn / stairs-up location.
- Stairs down: last room's center, tile becomes
TileType.StairsDown. - Stairs up: depth > 1 only — first room's center, tile becomes
TileType.StairsUp. Tracked vialevel.HasStairsUp. - Fountains: per non-spawn room, 20% chance to place a
Fountaintile at a random interior point (not on stairs). - Monsters: per non-spawn room,
rng.Next(0, 2 + depth / 2)monsters spawn (so depth scales count). - Items: per non-spawn room, 60% chance for a primary item drop and an additional 20% chance for a bonus drop.
- Boss: at depth > 3, if no boss has been spawned in this run yet, 15% chance to place a dread knight in a random non-spawn room. Tracked via
Game._bossSpawned; persists in the level cache.
Game maintains a Dictionary<int, DungeonLevel> keyed by depth.
- First visit to a depth: generate, cache.
- Revisit (up or down): pull from cache. Killed monsters stay dead, dropped items stay where they were, explored tiles stay explored.
- This prevents farming the upper floors (monsters do not respawn) and also makes the boss permanent on its floor until killed.
- The cache lives only in memory; quitting or dying discards it.
- 4-directional movement for player and monsters (no diagonals).
- Bump-to-attack: walking into a monster's tile triggers melee. Damage =
attacker.Attack + rng[-1, 1], minimum 1. - Player attack stat:
BaseAttack (4) + equipped weapon's AttackBonus. Enchantment is a flag on the weapon (separate from the bonus). - Monster behavior:
- Only acts when in the player's FOV; otherwise idle (preserves state for level cache).
- If orthogonally adjacent (
|dx| + |dy| == 1), attacks formonster.Attack ± 1. - Otherwise steps toward the player on the axis with the greater distance, falling back to the other axis if blocked.
- Cannot step onto another monster or onto the player without attacking.
- Death: monster removed from the level. Player death ends the game.
Fov.Compute(level, px, py, radius):
- Resets
Visibleon every tile. - Casts 360 rays at evenly spaced angles around the player.
- Each ray marches up to
radius(default 8) tiles, marking eachVisibleandExplored. - A ray terminates after marking the first wall it hits (so wall faces are seen).
>>(descend) and<<(ascend) requirekey == '>'or'<'respectively AND the player standing on the matching tile.- Descending to a new max depth grants +5 HP (clamped to MaxHp). Re-descending to an already-visited floor does not heal.
- Ascending from depth 1 is refused with a message ("You cannot leave the dungeon yet.").
- When entering a level via descent, the player lands on the destination's stairs-up if one exists, otherwise on the room-0 spawn.
- When entering via ascent, the player lands on the destination's stairs-down.
- Stepping onto a fountain attempts to enchant your equipped weapon.
- If no weapon equipped: log message, fountain unchanged.
- If weapon already enchanted: log message, fountain unchanged.
- Otherwise: weapon's
IsEnchantedflag is set; player glyph color shifts to Yellow viaPlayer.UpdateAppearance(); message logged. - Fountains are single-use per weapon — but a fountain itself is not consumed, so re-stepping with a different weapon would still enchant. (Currently the only way to "lose" an enchantment is to drink a potion, which calls
UpdateAppearance(). Equipped enchanted weapons are stable.)
- All items live in
Items.Itemwith aKinddiscriminator (HealingPotion,Weapon,Wand). - An
ItemEntitywraps anItemwith map coordinates for items lying on the floor.
| Item kind | Probability |
|---|---|
| Wand of fireball | 15% at depth ≥ 2; 0% otherwise |
| Healing potion | 55% of the remaining roll |
| Weapon | 45% of the remaining roll |
- Heals
10 + rng[0, 5]HP, capped atMaxHp. - Consumed on use (removed from inventory).
- 5 tiered names:
dagger,shortsword,longsword,battle axe,warhammer. AttackBonus = 1 + depth/2 + rng[0, 1], clamped to choose the name from the tier table.- Equipped to
Inventory.EquippedWeaponslot. Replacing equipment is silent — old weapon stays in inventory. - Can be enchanted by a fountain. Enchanted weapons display as
enchanted <name>and can damage the boss.
WandDamage = 8 + depth/3WandRange = 6tilesCharges = 3 + rng[0, 2]- Equipped to a separate
Inventory.EquippedWandslot. - Zap mechanic (
z):- Refuse if no wand readied or charges == 0 (no turn consumed).
- Prompt "Zap which direction?" in the log, render to update display.
- Read next key; only arrow keys count. Non-arrow cancels (no turn, no charge consumed).
- Project a fireball in the chosen direction:
- Steps tile-by-tile up to
WandRange. - Animation: writes
🔥at each step with a 40 ms pause (60 ms on wall impact). - On hitting a wall: log "bursts against the wall", spend a charge, end turn.
- On hitting a monster: deals
WandDamage + rng[-1, 1]damage. Kills incrementPlayer.Kills. End turn. - If it travels the full range without hitting anything: "fizzles in the air", spend a charge, end turn.
- Steps tile-by-tile up to
- Boss rule bypass: fireballs are magic and damage the dread knight regardless of weapon enchantment.
- Capacity: 20 items.
- Pick-up (
g) succeeds if there's an item on your tile and capacity remaining. - Inventory screen (
i):- Renders directly to the console (full-screen overlay).
- Lists items as
a),b), ... in their natural color, with details (heal amount, atk bonus, dmg/range/charges) and an(equipped)marker. - Pressing a matching letter triggers
UseItem(consume potion, equip weapon, or ready wand). - Any other key cancels.
- After closing,
_renderer.Reset()forces a full redraw next frame.
- Dread knight (
💀): Hp 45, Attack 11,IsBoss = true. - Spawns once per run at depth > 3 (15% per generated floor until placed).
- Spawn announcement: "An ominous presence stalks this floor..." (DarkRed).
- Damage immunity: melee attacks bounce off unless the player's equipped weapon is enchanted. Log: "Your attack glances off the dread knight. Only enchanted steel can harm it!" — no turn cost.
- Fireballs always damage the boss.
Score = MaxDepth × 100 + Kills × 10
MaxDepthis the deepest floor ever reached this run; ascending does not reduce it.- High scores persist in
highscores.txtasscore|depth|kills|iso-dateper line. - The Game Over screen loads, appends the new run, sorts descending, truncates to 10, and rewrites the file. Malformed lines are silently skipped on load.
- Logical map: 30 × 18 tiles. Physical render: each tile = 2 columns wide. Sidebar adds 20 cols. Total = 80 × 23 cells.
- Buffer:
string[80, 23]for glyphs +ConsoleColor[80, 23]for colors. Trailing half-cells of 2-wide glyphs are stored as""and skipped on flush. - Double-buffer diff: only cells whose glyph or color changed since the last frame are redrawn. Eliminates flicker.
- Sidebar layout (column 60 onward, rows 0–17):
- Row 0:
-- STATUS -- - Row 1:
Depth: X (max Y) - Row 2:
HP: X/Y(red ≤⅓, yellow ≤⅔, green otherwise) - Row 3:
ATK: X - Row 4:
Kills: X - Row 5:
Score: X - Row 7:
-- KEYS -- - Rows 8–14: 7 key hints (arrows, g, i, z, >, <, q)
- Row 15:
Weapon: - Row 16: equipped weapon name (or
(fists)) - Row 17:
Wand: <name (charges)>or(none)
- Row 0:
- Log: rows 18–22, top row is a divider of
-chars. Log holds up to 4 messages; oldest scroll off. - Transient overlay (
DrawTransient): used for the fireball animation. Writes directly to the terminal at a tile coordinate and updates the prev-buffer so the next render correctly restores the underlying tile.
graph TD
Program["Program.cs<br/><i>entry point</i>"] --> Game
Game["Game<br/><i>turn loop + input + cache</i>"]
subgraph Map["Map layer"]
MapGenerator["MapGenerator<br/><i>rooms + corridors</i>"]
DungeonLevel["DungeonLevel<br/><i>Tile[,] + actors</i>"]
Tile["Tile<br/><i>type + visible/explored</i>"]
Fov["Fov<br/><i>raycast visibility</i>"]
end
subgraph Entities
Entity["Entity<br/><i>base: x,y,glyph,color</i>"]
Player["Player<br/><i>HP, depth, maxDepth, score</i>"]
Monster["Monster<br/><i>HP, attack, IsBoss</i>"]
MonsterFactory["MonsterFactory<br/><i>depth-weighted + boss</i>"]
end
subgraph Items
Item["Item<br/><i>potion / weapon / wand</i>"]
ItemEntity["ItemEntity<br/><i>item on the floor</i>"]
Inventory["Inventory<br/><i>weapon + wand slots</i>"]
ItemFactory["ItemFactory"]
end
subgraph UI
Renderer["Renderer<br/><i>double-width emoji draw</i>"]
MessageLog["MessageLog<br/><i>rolling log</i>"]
end
Game --> Player
Game --> DungeonLevel
Game --> MapGenerator
Game --> Fov
Game --> Renderer
Game --> MessageLog
MapGenerator -->|builds| DungeonLevel
MapGenerator --> MonsterFactory
MapGenerator --> ItemFactory
DungeonLevel --> Tile
DungeonLevel -.contains.-> Monster
DungeonLevel -.contains.-> ItemEntity
Player --> Inventory
Inventory --> Item
ItemEntity --> Item
MonsterFactory -->|creates| Monster
ItemFactory -->|creates| ItemEntity
Fov -.flags.-> Tile
Player -.extends.-> Entity
Monster -.extends.-> Entity
Renderer -.reads.-> DungeonLevel
Renderer -.reads.-> Player
Renderer -.reads.-> MessageLog
classDef core fill:#2d3142,stroke:#bfc0c0,color:#fff
classDef factory fill:#4f5d75,stroke:#bfc0c0,color:#fff
class Game,Program core
class MapGenerator,MonsterFactory,ItemFactory factory
flowchart LR
A[Compute FOV] --> B[Render frame]
B --> C[Read key]
C --> D[Dispatch input]
D --> E{Action took<br/>a turn?}
E -->|no| F{Alive &<br/>not quit?}
E -->|yes| G[Monsters act]
G --> F
F -->|yes| A
F -->|dead| H[Game Over +<br/>high scores]
F -->|quit| I([Exit])
flowchart TD
A[Player on stairs] --> B{Direction}
B -->|descend| C{Depth+1<br/>cached?}
B -->|ascend| D{Depth-1<br/>cached?}
C -->|no| E[Generate +<br/>cache new level]
C -->|yes| F[Restore from<br/>cache]
D -->|yes| F
E --> G[Place player on<br/>StairsUp]
F -->|came down| G
F -->|came up| H[Place player on<br/>StairsDown]
G --> I[Update<br/>MaxDepth + heal]
H --> I
I --> J[Resume turn loop]
Program.cs entry point — sets UTF-8 output and runs Game
Game.cs turn loop, input dispatch, combat, inventory UI,
level cache, ascend/descend, zap, game over
Map/
Tile.cs TileType (Wall, Floor, StairsDown, StairsUp, Fountain)
+ Visible / Explored flags
DungeonLevel.cs 2D Tile grid + Monsters list + Items list +
StairsUp/StairsDown/PlayerSpawn coords
MapGenerator.cs procedural rooms + L-shaped corridors,
monster/item/fountain/boss placement
Fov.cs 360-ray raycast field-of-view
Entities/
Entity.cs abstract base (X, Y, Glyph, Color, Name)
Player.cs HP, attack, depth, maxDepth, kills, score,
inventory, appearance update on enchantment
Monster.cs HP, attack, IsBoss flag
MonsterFactory.cs depth-weighted spawn pool + CreateBoss
Items/
Item.cs Item + ItemEntity + ItemFactory (probabilities)
Inventory.cs items list + equipped weapon + equipped wand
UI/
Renderer.cs double-buffered, double-width emoji-aware draw
MessageLog.cs rolling buffer of recent messages
Console.ReadKey(true)requires a real TTY. The game cannot be smoke-tested from a script or piped stdin — only by running it in an interactive terminal.- Terminal must be ≥ 80 × 23. Smaller windows cause
Console.SetCursorPositionto throwArgumentOutOfRangeException. - Emoji width depends on the terminal. All glyphs assume each emoji renders 2 cells wide (iTerm2, macOS Terminal, Windows Terminal, modern Linux terminals). On terminals that render emoji as 1 cell wide, the layout will misalign.
- No save/load, only high scores. Quit (
q) and death both end the run. - No diagonal movement for any actor. Diagonally adjacent monsters must step before attacking.
- High-score file is plain text (
score|depth|kills|iso-date). Easy to edit by hand; malformed lines are skipped silently on load. - Fountains aren't consumed. Stepping on one with an unenchanted weapon enchants it; with an enchanted weapon nothing happens. This means a second pass with a different weapon would still enchant.
- The boss check is per-run, not per-cache. Once a boss has been placed on any floor in the run, no other floor will spawn another, even if you never visited the boss's floor — this is intentional but worth noting.
