Delve

I've been wanting to write a small game for a while. Nothing huge, just something fun to tinker with. I landed on a terminal roguelike because I've always had a soft spot for them, and they're a great excuse to think about procedural generation, turn-based combat, and field of view without having to worry about graphics or a game engine.

The result is Delve, a little dungeon crawler that runs entirely in your terminal.

delve screenshot

The Stack

I wrote it in Go using Bubbletea for the TUI framework and Lipgloss for styling. If you've not used Bubbletea before, it's based on the Elm architecture, you have a model, an Update function that handles input and produces a new model, and a View function that renders it. It's a really clean way to think about terminal apps and I'd used it before for smaller tools so it felt like a natural fit.

Dungeon Generation

The dungeon is procedurally generated using a fairly classic room-placement approach. It tries to place up to 14 rooms of random sizes, rejects any that overlap, and then connects them with L-shaped corridors:

if rng.Intn(2) == 0 {
    m.carveH(prev.cy(), prev.cx(), room.cx())
    m.carveV(room.cx(), prev.cy(), room.cy())
} else {
    m.carveV(prev.cx(), prev.cy(), room.cy())
    m.carveH(room.cy(), prev.cx(), room.cx())
}

It either goes horizontal then vertical or vertical then horizontal, chosen at random. Simple, but it produces surprisingly varied layouts.

The stairs down are placed in the last room, the player starts in the first, and enemies are scattered in between. About one in three rooms also gets a health potion dropped in it.

dungeon layout

Field of View

I wanted a proper FOV system rather than just revealing everything. The player has a sight radius of 8 tiles, and I cast a ray from the player to every tile within that radius using Bresenham's line algorithm. If the ray hits a wall before reaching the target, the tile stays dark.

Tiles have three states: Hidden, Seen (visited but out of sight), and Lit (currently visible). Lit tiles fade to Seen when you move away, so you can still see the shape of rooms you've explored, just not what's lurking in them.

func (m *model) hasLOS(tx, ty int) bool {
    x0, y0 := m.player.x, m.player.y
    x1, y1 := tx, ty
    dx, dy := iabs(x1-x0), iabs(y1-y0)
    sx, sy := isign(x1-x0), isign(y1-y0)
    err := dx - dy
    for {
        if x0 == x1 && y0 == y1 {
            return true
        }
        if m.tiles[x0][y0].kind == TileWall {
            return false
        }
        e2 := 2 * err
        if e2 > -dy {
            err -= dy
            x0 += sx
        }
        if e2 < dx {
            err += dx
            y0 += sy
        }
    }
}

Combat

Combat is bump-based, you walk into an enemy to attack them. Damage is your attack stat plus a small random roll, and enemies counter-attack immediately. Enemies only take their turn when they're in your line of sight, which means you can use doorways and corridors tactically to avoid being ganged up on.

There are three enemy types to start with: Goblins, Orcs, and Trolls. Each floor they get a bit tougher, gaining extra HP and attack as you descend, so there's a real sense of pressure to keep moving.

When you find the stairs and press > to descend, your attack permanently increases by 1, which is a small but satisfying reward.

The Death Screen

Dying gives you a little red box with your final floor number and the option to restart or quit. It's a small thing but I think it's important in a roguelike that death feels intentional rather than just crashing back to a prompt.

death screen

Controls

Move: ↑↓←→ / hjkl
Wait: .
Descend: >
Quit: q

It supports vim-style movement because of course it does.

What's Next

I'd like to add more enemy types, maybe some basic inventory, and possibly a simple scoring system. For now though it's in a state where it's actually fun to play for a few minutes, which was the goal.

You can grab the code on GitHub.