Building a Terminal RPG: ASCII Art, Fog of War, and Why I Can't Stop

I'm building a terminal RPG and I can't stop. It started as "let me try out ratatui" and has evolved into a full tactical combat system with fog of war, procedural world generation, eleven playable races, and a magic system with five schools. Someone please send help.
Actually, don't. I'm having too much fun.
The Addiction Begins
It started innocently enough. I'd been playing around with Rust and wanted to try building something with ratatui—the terminal UI library that makes building TUIs actually pleasant. "I'll just make a simple dungeon crawler," I told myself. "Maybe a weekend project."
Narrator: It was not a weekend project.
Three weeks later, I had implemented a full Forge: Out of Chaos rules engine, complete with characteristics, skills, magic, initiative tracking, and detailed combat mechanics. Because apparently I don't know how to do anything small.
Why Terminal? Why Not Just Use Unity?
Here's the thing about terminal games: they have a particular magic that graphical games can't match. The abstraction forces your imagination to do the heavy lifting. A @ symbol on a map becomes your character in a way that a 3D model never quite does.
Plus, there's something deeply satisfying about building UI with nothing but text and box-drawing characters:
╔═══════════════════════════════════════════════════════════════╗
║ ⚔ TACTICAL COMBAT ⚔ Round 1 ║
╠═══════════════════════════════════════════════════════════════╣
║ ║
║ . . . . . . . . . . . . . . . ║
║ . . . . . . . . . . . . . . . ║
║ . . . . . G . . . . . . . . . ║
║ . . . . . . . . . . . . . . . ║
║ . . . . . . . @ . . . . . . . ║
║ . . . . . . . . . . . . . . . ║
║ . . . . . . . . . G . . . . . ║
║ . . . . . . . . . . . . . . . ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
That's a combat encounter. @ is you, G is a goblin. Beautiful in its simplicity. And it runs on literally any machine with a terminal.
The Fog of War Problem
One of the first features that took me down a rabbit hole was fog of war. I wanted exploration to feel meaningful—you shouldn't see the whole dungeon the moment you enter. You have a torch, you have vision, the rest is mystery.
The naive implementation is simple: track which tiles the player has seen, only render those. But then you get these ugly hard edges where your vision radius ends. And what about tiles you've seen before but can't currently see?
The solution was a three-state system:
enum TileVisibility {
Unexplored, // Never seen - render as solid black
Explored, // Seen before - render dimmed
Visible, // Currently in view - full brightness
}
fn apply_fog_of_war(color: Color, visibility: TileVisibility) -> Color {
match visibility {
TileVisibility::Visible => color,
TileVisibility::Explored => dim_color(color),
TileVisibility::Unexplored => Color::Black,
}
}
fn dim_color(color: Color) -> Color {
// Preserve hue but reduce brightness
match color {
Color::LightRed => Color::Red,
Color::LightGreen => Color::Green,
Color::LightBlue => Color::Blue,
Color::White => Color::Gray,
other => other, // Already dim enough
}
}
The key insight was preserving color in explored areas rather than turning everything grayscale. A grayed-out dungeon feels dead. A dimmed dungeon still feels alive—just mysterious. You remember that room had some interesting stuff in it, but you can't quite see it anymore.
Seamless World Generation
The overworld uses chunk-based procedural generation, but I didn't want players to see the chunks. No ugly loading boundaries, no obvious seams. The world should feel continuous.
The solution was aggressive pre-generation: whenever you enter or approach a zone boundary, the system generates all eight adjacent zones. When you move, adjacent zones are always already loaded.
fn ensure_adjacent_zones(&mut self, current_zone: &ZoneCoord) {
for dx in -1..=1 {
for dy in -1..=1 {
let adjacent = ZoneCoord {
x: current_zone.x + dx,
y: current_zone.y + dy,
};
self.world.get_or_generate_zone(&adjacent);
}
}
}
Combined with consistent noise seeds (so the same coordinates always generate the same terrain), this creates a world that feels truly infinite. Walk in any direction forever and you'll keep discovering new areas. Everything is deterministic but nothing is pre-authored.
Tactical Combat: The Rabbit Hole Within The Rabbit Hole
I wanted combat to feel tactical. Not just "select attack, roll damage, repeat." I wanted positioning to matter, initiative to create interesting decisions, and a proper action economy.
The Forge: Out of Chaos system uses d20-based combat with Attack Value (AV) vs Defense Value (DV). But I layered tactical grid combat on top:
- Initiative tracking: Rolled at combat start, determines turn order
- Movement system: Move up to 5 squares per turn (Manhattan distance)
- Melee range: Must be adjacent to attack
- Defensive stance: Skip your attack for +4 DV until next round
- AI that actually fights back: Moves toward you, attacks when in range
fn execute_ai_turn(&mut self, ai_index: usize) {
let ai = &self.participants[ai_index];
let player_pos = self.get_player_position();
let ai_pos = ai.position;
let distance = manhattan_distance(ai_pos, player_pos);
if distance <= 1 {
// Adjacent - attack!
self.execute_attack(ai_index, 0); // 0 = player index
} else if distance <= 3 {
// Can reach player this turn - move then attack
let new_pos = move_toward(ai_pos, player_pos, 3);
self.move_participant(ai_index, new_pos);
if manhattan_distance(new_pos, player_pos) <= 1 {
self.execute_attack(ai_index, 0);
}
} else {
// Too far - just close distance
let new_pos = move_toward(ai_pos, player_pos, 3);
self.move_participant(ai_index, new_pos);
}
}
Combat feels tense now. You can't just stand still and trade blows—you need to manage distance, choose when to be aggressive vs defensive, and position to avoid getting surrounded.
The UI: Making ASCII Beautiful
Terminal constraints force creativity. You can't throw a drop shadow on something to make it pop. You have characters, colors, and box-drawing symbols. That's it.
But you can do a lot with that:
// Style the initiative panel with fantasy flair
Block::default()
.title("⚡ INITIATIVE ⚡")
.title_style(Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(Color::Cyan))
The little touches matter: lightning bolts around "INITIATIVE", crossed swords (⚔) for combat titles, hearts (♥) for health, and diamonds (◈) for special abilities. Unicode gives you a surprising amount to work with.
I also use different border styles to create visual hierarchy: thick borders for primary panels, double lines for headers, rounded corners for input areas. It's not a graphical masterpiece, but it has personality.
What I Learned
Building Warlords taught me a few things:
Terminal UIs are underrated. With ratatui, you can build responsive, attractive interfaces that work everywhere. The constraints are features, not bugs.
Fog of war is about psychology. The partially-revealed map is more engaging than either full darkness or full visibility. Mystery creates curiosity.
AI needs to cheat (a little). The AI doesn't have true fog of war—it knows where you are. Otherwise combat is trivial. The player accepts this because enemies being aware of you feels fair.
Scope creep is real. I genuinely meant to spend a weekend on this. Now it has a magic system with five schools, eleven playable races, procedural dungeons, a full inventory system, and I'm planning multiplayer support. Help.
What's Next
The roadmap keeps growing:
- SpacetimeDB integration: Multiplayer MUD-style gameplay
- Expanded world events: Seasons, weather affecting gameplay
- More enemy types: Right now it's mostly goblins. Need variety.
- Quests: Actual objectives beyond "don't die"
- ASCII art improvements: The battlefield needs more terrain variety
If you want to try it: github.com/Caryyon/Warlords. Fair warning—it requires a terminal that supports Unicode. Most modern ones do.
And if you're thinking "I could build this in a weekend"—yeah, I thought that too. See you in three weeks.