
Ts Tui Game Skill
- 3 installs
- Updated January 31, 2026
- kadajett/ts-tui-game-skill
Helps with ai & agent building tasks.
About
ts-tui-game-skill is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- ts-tui-game-skill
- AI & Agent Building
- AI-coding skill
Ts Tui Game Skill by the numbers
- 3 all-time installs (skills.sh)
- Ranked #13,657 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kadajett/ts-tui-game-skill --skill ts-tui-game-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| Last updated | January 31, 2026 |
| Repository | kadajett/ts-tui-game-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
TS TUI Game Skill
Build beautiful, performant TUI applications and games with TypeScript and blessed.
Triggers
Use this skill when:
- Creating a new TUI application or game
- Building terminal-based interfaces with blessed
- Implementing game loops, render schedulers, or input handling in terminals
- Designing layouts for terminal applications
- Working with blessed widgets (Box, List, Form, etc.)
- Optimizing terminal rendering performance
- Adding keyboard and mouse input handling
- Creating modals, panels, or complex UI compositions
Keywords: tui, terminal, blessed, ncurses, console, cli game, terminal game, terminal ui, text interface
Core Instructions
When helping with blessed TUI development, follow these principles:
Project Setup
1. Always use TypeScript with strict mode 2. Recommended screen options:
const screen = blessed.screen({
smartCSR: true, // Efficient change-scroll-region rendering
autoPadding: true, // Automatic border/padding handling
fullUnicode: true, // Support for box drawing and unicode
warnings: true, // Development warnings
});3. Directory structure - Separate game logic from UI:
src/
main.ts # Entrypoint, screen init, lifecycle
ui/
app.ts # Root composition, layout creation
theme.ts # Centralized theme tokens
widgets/ # Custom widgets
panels/ # Composed windows
input/
keymap.ts # Key binding definitions
input-router.ts # Focus-aware dispatch
game/
state.ts # Authoritative state types
reducer.ts # Pure state updates
systems/ # Simulation systems
engine/
scheduler.ts # Tick + render scheduling
events.ts # Event busRendering Rules
CRITICAL: Never call `screen.render()` directly from event handlers.
Use a render scheduler pattern:
export function createRenderScheduler(screen: blessed.Widgets.Screen, maxFps = 30) {
let pending = false;
const frameMs = Math.floor(1000 / maxFps);
const timer = setInterval(() => {
if (!pending) return;
pending = false;
screen.render();
}, frameMs);
return {
requestRender() { pending = true; },
stop() { clearInterval(timer); }
};
}State Management
1. Single authoritative state - One state object owned by game core 2. Pure reducers - Actions in, state out, no side effects 3. UI dispatches actions - Never mutate state directly from widgets
Layout Guidelines
Standard 3-pane game layout:
- Main viewport: map/scene (largest, fluid)
- Right sidebar: stats, inventory (24-32 cols fixed)
- Bottom log: messages, hints (7-12 rows fixed)
const sidebarWidth = 30;
const logHeight = 9;
const main = blessed.box({
parent: screen,
top: 0, left: 0,
width: `100%-${sidebarWidth}`,
height: `100%-${logHeight}`,
border: 'line',
});Theme System
Centralize all colors and styles:
export const theme = {
fg: 'white',
bg: 'black',
panel: { fg: 'white', bg: 'black', border: { fg: '#888888' } },
accent: { fg: 'black', bg: '#f4d03f' },
danger: { fg: 'white', bg: 'red' },
muted: { fg: '#aaaaaa', bg: 'black' },
};Rule: Pick 1 accent color and 1 danger color. Everything else muted.
Input Handling
Implement a router pattern for context-aware input:
function onKey(ch: string, key: blessed.Widgets.Events.IKeyEventArg) {
const name = key.full || ch;
if (globalKeys[name]) return globalKeys[name]();
if (state.ui.modal) return modalHandler(ch, key);
if (state.ui.activePanel === 'map') return mapHandler(ch, key);
}
screen.on('keypress', onKey);Standard controls:
- Movement: arrows + hjkl
- Help:
? - Cycle focus:
tab - Confirm:
enter - Back/close:
esc - Quit:
qorC-c
Performance Rules
Hard rules:
- Never
screen.render()in tight loops - Never rebuild widget trees every frame
- Never create new elements every tick without destroying them
Soft rules:
- Update content/styles on existing elements
- One
setContent()per frame for large viewports - Keep tag parsing minimal in grid renders
- Use
alwaysScrollonly where necessary
Modal Pattern
1. Create semi-transparent overlay covering screen 2. Create centered modal box on top 3. Capture input in modal 4. On close: destroy() both, restore focus
Cleanup (REQUIRED)
function safeExit(screen: blessed.Widgets.Screen, code = 0) {
try {
screen.destroy();
} finally {
process.exit(code);
}
}
screen.key(['escape', 'q', 'C-c'], () => safeExit(screen));Resources
See the resources/ directory for:
- Style Guide - Comprehensive development guidelines
- Widget Reference - Blessed widget API quick reference
See the templates/ directory for starter code.
Best Practices Checklist
When reviewing or creating blessed TUI code, verify:
- [ ] One render scheduler controls
screen.render() - [ ] Game core is pure and testable (no terminal dependency)
- [ ] Layout uses stable proportions and resize behavior
- [ ] Theme tokens are centralized
- [ ] Controls are discoverable (
?help, footer hints) - [ ] Modals capture input and restore focus
- [ ] No per-tick widget creation
- [ ] Exit path calls
screen.destroy()and clears timers - [ ] Tags used sparingly in large grid renders
- [ ] Keyboard-first design (mouse is bonus)
/**
* Complete example: Simple roguelike-style TUI game
*
* Demonstrates all the patterns from the ts-tui-game-skill:
* - Render scheduler
* - State management with pure reducers
* - Input routing
* - 3-pane layout
* - Modals
* - Theme system
*/
import blessed from 'blessed';
// ============================================================================
// Theme
// ============================================================================
const theme = {
fg: 'white',
bg: 'black',
panel: { fg: 'white', bg: 'black', border: { fg: '#555555' } },
accent: { fg: 'black', bg: '#f4d03f' },
danger: { fg: 'white', bg: '#e74c3c' },
muted: { fg: '#888888', bg: 'black' },
player: { fg: '#f4d03f' },
enemy: { fg: '#e74c3c' },
wall: { fg: '#555555' },
floor: { fg: '#333333' },
};
// ============================================================================
// State Types & Reducer
// ============================================================================
interface Entity {
x: number;
y: number;
char: string;
color: string;
}
interface GameState {
player: Entity;
enemies: Entity[];
map: number[][];
log: string[];
turn: number;
modal: 'help' | 'inventory' | null;
}
type Action =
| { type: 'MOVE'; dx: number; dy: number }
| { type: 'LOG'; message: string }
| { type: 'OPEN_MODAL'; modal: 'help' | 'inventory' }
| { type: 'CLOSE_MODAL' };
function createInitialState(): GameState {
// Simple 40x20 map (0=floor, 1=wall)
const map: number[][] = [];
for (let y = 0; y < 20; y++) {
const row: number[] = [];
for (let x = 0; x < 40; x++) {
// Border walls
if (x === 0 || x === 39 || y === 0 || y === 19) {
row.push(1);
} else if (Math.random() < 0.1) {
// Random walls
row.push(1);
} else {
row.push(0);
}
}
map.push(row);
}
// Clear spawn area
for (let y = 8; y < 12; y++) {
for (let x = 18; x < 22; x++) {
map[y][x] = 0;
}
}
return {
player: { x: 20, y: 10, char: '@', color: theme.player.fg },
enemies: [
{ x: 5, y: 5, char: 'g', color: theme.enemy.fg },
{ x: 35, y: 15, char: 'g', color: theme.enemy.fg },
],
map,
log: ['Welcome! Use arrow keys or hjkl to move.', 'Press ? for help.'],
turn: 0,
modal: null,
};
}
function reduce(state: GameState, action: Action): GameState {
switch (action.type) {
case 'MOVE': {
const newX = state.player.x + action.dx;
const newY = state.player.y + action.dy;
// Bounds check
if (newX < 0 || newX >= 40 || newY < 0 || newY >= 20) {
return state;
}
// Wall collision
if (state.map[newY][newX] === 1) {
return {
...state,
log: [...state.log, 'Blocked by wall.'].slice(-50),
};
}
// Enemy collision
const hitEnemy = state.enemies.find(
(e) => e.x === newX && e.y === newY
);
if (hitEnemy) {
return {
...state,
enemies: state.enemies.filter((e) => e !== hitEnemy),
player: { ...state.player, x: newX, y: newY },
log: [...state.log, 'You defeated a goblin!'].slice(-50),
turn: state.turn + 1,
};
}
return {
...state,
player: { ...state.player, x: newX, y: newY },
turn: state.turn + 1,
};
}
case 'LOG':
return {
...state,
log: [...state.log, action.message].slice(-50),
};
case 'OPEN_MODAL':
return { ...state, modal: action.modal };
case 'CLOSE_MODAL':
return { ...state, modal: null };
default:
return state;
}
}
// ============================================================================
// Render Scheduler
// ============================================================================
function createRenderScheduler(screen: blessed.Widgets.Screen, maxFps = 30) {
let pending = false;
const frameMs = Math.floor(1000 / maxFps);
const timer = setInterval(() => {
if (!pending) return;
pending = false;
screen.render();
}, frameMs);
return {
requestRender() {
pending = true;
},
stop() {
clearInterval(timer);
},
};
}
// ============================================================================
// UI Components
// ============================================================================
function renderMap(
element: blessed.Widgets.BoxElement,
state: GameState
) {
const lines: string[] = [];
for (let y = 0; y < 20; y++) {
let line = '';
for (let x = 0; x < 40; x++) {
// Player
if (state.player.x === x && state.player.y === y) {
line += `{${theme.player.fg}-fg}@{/}`;
continue;
}
// Enemies
const enemy = state.enemies.find((e) => e.x === x && e.y === y);
if (enemy) {
line += `{${theme.enemy.fg}-fg}${enemy.char}{/}`;
continue;
}
// Terrain
if (state.map[y][x] === 1) {
line += `{${theme.wall.fg}-fg}#{/}`;
} else {
line += `{${theme.floor.fg}-fg}.{/}`;
}
}
lines.push(line);
}
element.setContent(lines.join('\n'));
}
function renderSidebar(
element: blessed.Widgets.BoxElement,
state: GameState
) {
const content = [
`{bold}Player{/bold}`,
`Position: ${state.player.x}, ${state.player.y}`,
'',
`{bold}Stats{/bold}`,
`Turn: ${state.turn}`,
`Enemies: ${state.enemies.length}`,
'',
`{bold}Controls{/bold}`,
`Arrows/HJKL: Move`,
`?: Help`,
`I: Inventory`,
`Q: Quit`,
].join('\n');
element.setContent(content);
}
function renderLog(
element: blessed.Widgets.BoxElement,
state: GameState
) {
const recentLogs = state.log.slice(-5);
element.setContent(recentLogs.join('\n'));
}
// ============================================================================
// Modals
// ============================================================================
function createHelpModal(
screen: blessed.Widgets.Screen,
onClose: () => void
) {
const overlay = blessed.box({
parent: screen,
top: 0,
left: 0,
width: '100%',
height: '100%',
style: { bg: 'black', transparent: true },
});
const modal = blessed.box({
parent: screen,
top: 'center',
left: 'center',
width: '60%',
height: '70%',
border: 'line',
label: ' Help ',
tags: true,
content: [
'{bold}Movement{/bold}',
' Arrow keys or H J K L',
'',
'{bold}Actions{/bold}',
' Walk into enemies to attack',
'',
'{bold}Interface{/bold}',
' ? - This help screen',
' I - Inventory (placeholder)',
' Q - Quit game',
'',
'{gray-fg}Press ESC or Q to close{/gray-fg}',
].join('\n'),
style: {
fg: theme.fg,
bg: theme.bg,
border: { fg: theme.accent.bg },
label: { fg: theme.accent.bg, bold: true },
},
});
modal.focus();
function close() {
overlay.destroy();
modal.destroy();
onClose();
}
modal.key(['escape', 'q', '?'], close);
return { overlay, modal, close };
}
// ============================================================================
// Main Application
// ============================================================================
function main() {
// Create screen
const screen = blessed.screen({
smartCSR: true,
autoPadding: true,
fullUnicode: true,
title: 'Simple Roguelike',
});
// Initialize systems
const { requestRender, stop: stopRenderer } = createRenderScheduler(screen);
// Initialize state
let state = createInitialState();
const dispatch = (action: Action) => {
state = reduce(state, action);
update();
requestRender();
};
// Create layout
const sidebarWidth = 25;
const logHeight = 7;
const mapPanel = blessed.box({
parent: screen,
top: 0,
left: 0,
width: `100%-${sidebarWidth}`,
height: `100%-${logHeight}`,
border: 'line',
label: ' Dungeon ',
tags: true,
style: {
fg: theme.fg,
bg: theme.bg,
border: theme.panel.border,
label: { fg: theme.accent.bg },
},
});
const sidebarPanel = blessed.box({
parent: screen,
top: 0,
right: 0,
width: sidebarWidth,
height: `100%-${logHeight}`,
border: 'line',
label: ' Status ',
tags: true,
style: {
fg: theme.fg,
bg: theme.bg,
border: theme.panel.border,
label: { fg: theme.muted.fg },
},
});
const logPanel = blessed.box({
parent: screen,
bottom: 0,
left: 0,
width: '100%',
height: logHeight,
border: 'line',
label: ' Log ',
tags: true,
style: {
fg: theme.fg,
bg: theme.bg,
border: theme.panel.border,
label: { fg: theme.muted.fg },
},
});
// Update function
function update() {
renderMap(mapPanel, state);
renderSidebar(sidebarPanel, state);
renderLog(logPanel, state);
}
// Input handling
function handleKey(ch: string, key: blessed.Widgets.Events.IKeyEventArg) {
// Modal state check
if (state.modal) {
return; // Modal handles its own input
}
const keyName = key.full || key.name || ch;
// Global keys
if (keyName === 'q' || keyName === 'C-c') {
safeExit(0);
return;
}
if (keyName === '?' || keyName === 'f1') {
dispatch({ type: 'OPEN_MODAL', modal: 'help' });
const { close } = createHelpModal(screen, () => {
dispatch({ type: 'CLOSE_MODAL' });
});
requestRender();
return;
}
if (keyName === 'i') {
dispatch({ type: 'LOG', message: 'Inventory not implemented yet!' });
return;
}
// Movement
const directions: Record<string, { dx: number; dy: number }> = {
up: { dx: 0, dy: -1 },
k: { dx: 0, dy: -1 },
down: { dx: 0, dy: 1 },
j: { dx: 0, dy: 1 },
left: { dx: -1, dy: 0 },
h: { dx: -1, dy: 0 },
right: { dx: 1, dy: 0 },
l: { dx: 1, dy: 0 },
};
if (directions[keyName]) {
dispatch({ type: 'MOVE', ...directions[keyName] });
}
}
screen.on('keypress', handleKey);
// Exit handling
function safeExit(code: number) {
try {
stopRenderer();
screen.destroy();
} finally {
process.exit(code);
}
}
// Initial render
update();
requestRender();
}
// Run
main();
{
"name": "simple-roguelike-example",
"version": "1.0.0",
"description": "Example TUI game using blessed",
"main": "index.ts",
"scripts": {
"start": "npx ts-node index.ts",
"build": "tsc",
"dev": "npx ts-node-dev --respawn index.ts"
},
"dependencies": {
"blessed": "^0.1.81"
},
"devDependencies": {
"@types/blessed": "^0.1.25",
"@types/node": "^20.0.0",
"ts-node": "^10.9.0",
"typescript": "^5.0.0"
}
}
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": ".",
"resolveJsonModule": true
},
"include": ["./**/*.ts"],
"exclude": ["node_modules", "dist"]
}
ts-tui-game-skill
A Claude skill for building beautiful, performant TUI applications and games with TypeScript and blessed.
Installation
Using the skills CLI:
npx skills add kadajett/ts-tui-game-skillOr manually copy the skill to your agent's skills directory.
What This Skill Provides
When working on terminal UI applications with blessed, this skill provides:
- Best practices for screen setup, rendering, and cleanup
- Render scheduler pattern to avoid performance issues
- State management with pure reducers (testable without terminal)
- Input routing for context-aware keyboard handling
- Layout patterns for game UIs (3-pane, tabbed, modal-first)
- Theme system for consistent styling
- Modal system for dialogs and overlays
- Widget reference for blessed API
Quick Start
Screen Setup
import blessed from 'blessed';
const screen = blessed.screen({
smartCSR: true, // Efficient scrolling
autoPadding: true, // Auto border handling
fullUnicode: true, // Unicode support
});Render Scheduler (Critical)
Never call `screen.render()` directly from event handlers.
function createRenderScheduler(screen, maxFps = 30) {
let pending = false;
const frameMs = Math.floor(1000 / maxFps);
const timer = setInterval(() => {
if (!pending) return;
pending = false;
screen.render();
}, frameMs);
return {
requestRender() { pending = true; },
stop() { clearInterval(timer); }
};
}State Management
// Pure state, no terminal dependencies
interface GameState {
player: { x: number; y: number };
ui: { modal: string | null };
}
// Pure reducer
function reduce(state: GameState, action: Action): GameState {
switch (action.type) {
case 'MOVE':
return { ...state, player: { x: state.player.x + action.dx, y: state.player.y + action.dy } };
default:
return state;
}
}Clean Exit (Required)
function safeExit(screen, code = 0) {
try {
screen.destroy();
} finally {
process.exit(code);
}
}
screen.key(['q', 'C-c'], () => safeExit(screen, 0));Directory Structure
ts-tui-game-skill/
SKILL.md # Main skill definition
README.md # This file
resources/
style-guide.md # Comprehensive guidelines
widget-reference.md # Blessed API reference
templates/
main.ts.template # Application entry point
screen.ts.template # Screen initialization
scheduler.ts.template # Render/game loop
state.ts.template # State management
input.ts.template # Input handling
layout.ts.template # UI layout
modal.ts.template # Modal system
theme.ts.template # Theme definitions
examples/
simple-game/ # Complete example gameResources
- [Style Guide](resources/style-guide.md) - Full development guidelines
- [Widget Reference](resources/widget-reference.md) - Blessed widget API
- [Templates](templates/) - Starter code for common patterns
- [Examples](examples/) - Complete working examples
Key Patterns
1. Game Logic Separation
Keep game logic pure and testable:
src/
game/ # Pure logic, no terminal
ui/ # Terminal rendering
engine/ # Schedulers, events2. Standard Layout
+---------------------------+----------+
| | Sidebar |
| Main Viewport | (fixed) |
| (fluid) | |
+---------------------------+----------+
| Log Panel (fixed) |
+--------------------------------------+3. Input Router
function handleKey(ch, key) {
if (globalKeys[key.full]) return globalKeys[key.full]();
if (state.modal) return modalHandler(ch, key);
if (state.activePanel === 'map') return mapHandler(ch, key);
}4. Theme Tokens
const theme = {
fg: 'white',
bg: 'black',
accent: { fg: 'black', bg: '#f4d03f' },
danger: { fg: 'white', bg: 'red' },
muted: { fg: '#888888', bg: 'black' },
};Checklist
When building blessed TUI apps:
- [ ] Render scheduler controls all
screen.render()calls - [ ] Game core has no terminal dependencies
- [ ] Theme tokens are centralized
- [ ] Controls are discoverable (
?help, footer hints) - [ ] Modals capture input and restore focus on close
- [ ] Exit path calls
screen.destroy()and clears timers - [ ] Tags used sparingly in large grid renders
- [ ] Keyboard-first design
License
MIT
Blessed TUI Style Guide
Comprehensive guidelines for building beautiful, performant TUI applications with TypeScript and blessed.
Goals
Build TUI apps that are:
- Readable: Clear layout, consistent controls, predictable focus
- Responsive: Low input latency, stable FPS, minimal flicker
- Portable: Sane defaults across macOS, Linux, Windows terminals
- Maintainable: Explicit state, isolated rendering, testable logic
- Stylish: Cohesive theme, good spacing, clean typography
---
1. Project Conventions
Recommended Stack
- Node 20+ (or current LTS)
- TypeScript with
strict: true blessed(core library)- Optional:
blessed-contribonly for charts/sparklines; prefer custom widgets for games
Directory Structure
Use a "game core vs UI shell" split:
src/
main.ts # Entrypoint, screen init, lifecycle
ui/
app.ts # Root composition, layout creation
theme.ts # Centralized theme tokens
widgets/ # Custom widgets (derived from blessed.*)
panels/ # Composed windows (map, log, hud, modal)
input/
keymap.ts # Key binding definitions
input-router.ts # Focus-aware dispatch
game/
state.ts # Authoritative state types
reducer.ts # Pure state updates
systems/ # Simulation systems (tick-based)
rng.ts # Deterministic RNG wrapper
engine/
scheduler.ts # Tick + render scheduling
events.ts # Event bus, typed
perf.ts # Profiling counters, frame times
util/
clamp.ts
debounce.ts
terminal.ts # Env detection, feature probesRule: The game/ layer must be runnable with no terminal attached (pure logic). The ui/ layer renders and translates input into actions.
---
2. Screen Setup and Lifecycle
Screen Options
Default configuration:
import blessed from 'blessed';
export function createScreen() {
const screen = blessed.screen({
smartCSR: true, // Or fastCSR: true if flickering
autoPadding: true, // Automatic border/padding math
fullUnicode: true, // Box drawing, icons, double-width
warnings: true, // Development warnings
});
screen.title = 'Your Game';
return screen;
}Cleanup (Non-Optional)
Always handle cleanup explicitly:
function safeExit(screen: blessed.Widgets.Screen, code = 0) {
try {
screen.destroy();
} finally {
process.exit(code);
}
}
// Bind exit keys globally
screen.key(['escape', 'q', 'C-c'], () => safeExit(screen));Checklist:
- [ ]
screen.destroy()on exit - [ ] Restore cursor/mouse if using low-level program calls
- [ ] Unbind all timers and intervals
---
3. Rendering Model
Principle: Never Render on Every Event
Blessed is efficient, but calling screen.render() from every handler wrecks responsiveness.
Rule: Use a single render scheduler. Handlers mark "dirty" and request a frame.
Render Scheduler Pattern
export function createRenderScheduler(
screen: blessed.Widgets.Screen,
maxFps = 30
) {
let pending = false;
const frameMs = Math.floor(1000 / maxFps);
const timer = setInterval(() => {
if (!pending) return;
pending = false;
screen.render();
}, frameMs);
function requestRender() {
pending = true;
}
function stop() {
clearInterval(timer);
}
return { requestRender, stop };
}Guideline: 20-30 FPS is enough for terminals. Cap at 30 for animations.
Simulation Tick Pattern
Run simulation at a fixed timestep (e.g., 20 Hz). Input events enqueue actions, tick consumes actions and updates state.
export function createGameLoop(
state: GameState,
dispatch: (action: Action) => void,
requestRender: () => void,
tickRate = 20
) {
const tickMs = Math.floor(1000 / tickRate);
const timer = setInterval(() => {
// Process queued actions
while (actionQueue.length > 0) {
const action = actionQueue.shift()!;
state = reduce(state, action);
}
requestRender();
}, tickMs);
return { stop: () => clearInterval(timer) };
}---
4. State Management
Single Authoritative State
Define one state object owned by the game core:
export type GameState = {
time: number;
player: { x: number; y: number; hp: number };
map: { width: number; height: number; tiles: Uint8Array };
ui: { activePanel: 'map' | 'inventory' | 'log'; modal?: ModalState };
};Pure Reducer Updates
export type Action =
| { type: 'MOVE'; dx: number; dy: number }
| { type: 'OPEN_INVENTORY' }
| { type: 'CLOSE_MODAL' };
export function reduce(state: GameState, action: Action): GameState {
switch (action.type) {
case 'MOVE': {
const x = state.player.x + action.dx;
const y = state.player.y + action.dy;
return { ...state, player: { ...state.player, x, y } };
}
default:
return state;
}
}Rule: UI widgets do not mutate game state directly. They dispatch actions.
Determinism for Replays
- Wrap RNG in a seedable generator
- Store "last N actions" for replay
- Add a debug panel toggled by a key
---
5. Layout and Window Splitting
Approach
For games, prefer explicit composition over blessed.layout:
- Manual
top/left/width/heightfor predictable geometry - Fluid + fixed proportions
Standard 3-Pane Layout
| Area | Size | Description |
|---|---|---|
| Main viewport | Fluid | Map or scene (largest area) |
| Right sidebar | 24-32 cols fixed | Stats, inventory, minimap |
| Bottom log | 7-12 rows fixed | Messages, combat log, hints |
const sidebarWidth = 30;
const logHeight = 9;
const main = blessed.box({
parent: screen,
top: 0,
left: 0,
width: `100%-${sidebarWidth}`,
height: `100%-${logHeight}`,
border: 'line',
tags: true,
});
const sidebar = blessed.box({
parent: screen,
top: 0,
right: 0,
width: sidebarWidth,
height: `100%-${logHeight}`,
border: 'line',
tags: true,
});
const log = blessed.box({
parent: screen,
bottom: 0,
left: 0,
width: '100%',
height: logHeight,
border: 'line',
tags: true,
scrollable: true,
alwaysScroll: true,
scrollbar: { ch: ' ' },
});Responsive Resize
Blessed recalculates percentages automatically. Only adjust fixed sizes if needed:
screen.on('resize', () => {
const minSidebar = 22;
const maxSidebar = 34;
sidebar.width = Math.max(
minSidebar,
Math.min(maxSidebar, Math.floor(screen.width * 0.25))
);
requestRender();
});Border Docking
Use dockBorders: true on screen for merged borders:
Before: After:
┌─────────┌─────────┐ ┌─────────┬─────────┐
│ box1 │ box2 │ │ box1 │ box2 │
└─────────└─────────┘ └─────────┴─────────┘---
6. Drawing: Text, Grids, and Game Rendering
Canvas Element Pattern
For tile maps or grids, treat the main area as a render surface:
function renderViewport(
el: blessed.Widgets.BoxElement,
state: GameState
) {
const w = el.width as number;
const h = el.height as number;
const lines: string[] = [];
for (let y = 0; y < h - 2; y++) { // Account for borders
let row = '';
for (let x = 0; x < w - 2; x++) {
const tile = getTile(state, x, y);
row += renderTile(tile);
}
lines.push(row);
}
el.setContent(lines.join('\n'));
}Rule: Borders consume 2 cells (top+bottom, left+right). Account for this.
Tags for Highlights
Use tags sparingly for emphasis:
// Good: highlight specific cells
const content = lines.map((line, y) => {
if (y === playerY) {
return line.slice(0, playerX) +
`{bold}{yellow-fg}@{/}` +
line.slice(playerX + 1);
}
return line;
}).join('\n');Rule: Tags add parsing overhead. For large grids, minimize tag usage.
Unicode and Box Drawing
- Enable
fullUnicode: truefor box-drawing characters - Provide ASCII fallback theme for limited terminals
- Test with basic box drawing only (some terminals render glyphs inconsistently)
---
7. Input, Focus, and Keybindings
Input Router Pattern
type KeyHandler = (
ch: string,
key: blessed.Widgets.Events.IKeyEventArg
) => void;
const globalKeys: Record<string, () => void> = {
'q': () => exit(),
'?': () => openHelp(),
};
function onKey(ch: string, key: blessed.Widgets.Events.IKeyEventArg) {
const name = key.full || ch;
// Global keys first
if (globalKeys[name]) return globalKeys[name]();
// Modal capture
if (state.ui.modal) return modalHandler(ch, key);
// Context-specific
if (state.ui.activePanel === 'map') return mapHandler(ch, key);
if (state.ui.activePanel === 'inventory') return inventoryHandler(ch, key);
}
screen.on('keypress', onKey);Standard Keybindings
| Key | Action |
|---|---|
arrows / hjkl | Movement |
? | Help |
tab | Cycle focus/panels |
enter | Confirm |
esc | Back / close modal |
q / C-c | Quit |
Rule: Do not rely on function keys for critical features.
Focus Management
- Only focus elements that need key handling
- Keep "display-only" boxes non-focusable
- Save focus before modals, restore on close
let savedFocus: blessed.Widgets.BlessedElement | null = null;
function openModal() {
savedFocus = screen.focused;
modal.focus();
}
function closeModal() {
modal.destroy();
if (savedFocus) savedFocus.focus();
}---
8. Visual Design
Theme Tokens
Centralize all styles in ui/theme.ts:
export const theme = {
fg: 'white',
bg: 'black',
panel: {
fg: 'white',
bg: 'black',
border: { fg: '#888888' },
},
accent: { fg: 'black', bg: '#f4d03f' },
danger: { fg: 'white', bg: 'red' },
muted: { fg: '#aaaaaa', bg: 'black' },
selected: { fg: 'black', bg: 'white' },
};
// Apply consistently
const panel = blessed.box({
style: {
fg: theme.panel.fg,
bg: theme.panel.bg,
border: theme.panel.border,
},
});Rule: Pick 1 accent color and 1 danger color. Everything else muted.
Spacing and Density
- Use padding inside panels where possible
- Keep logs and tables aligned with fixed-width formatting
- Consider minimal borders (only around viewport, not every panel)
- Use labels and separators instead of borders in sidebars
Typography Rules
- Prefer sentence case, not all caps
- Keep labels short
- Use consistent symbols:
>or>for selection*or*for bullets|and|for subtle separators
Status Line
A single-line footer with controls adds polish:
[Arrows] Move [I] Inventory [?] Help FPS: 30const footer = blessed.box({
parent: screen,
bottom: 0,
left: 0,
width: '100%',
height: 1,
content: '{bold}[Arrows]{/} Move {bold}[I]{/} Inventory {bold}[?]{/} Help{|}FPS: --',
tags: true,
style: { fg: theme.muted.fg, bg: theme.bg },
});---
9. Modals and Overlays
Modal Rules
1. Darken background with semi-transparent overlay 2. Modal should:
- Have a clear title
- List controls
- Capture input
- Restore focus on close
Implementation
function createModal(
screen: blessed.Widgets.Screen,
title: string,
content: string
) {
// Semi-transparent overlay
const overlay = blessed.box({
parent: screen,
top: 0,
left: 0,
width: '100%',
height: '100%',
style: { bg: 'black', transparent: true },
});
// Centered modal
const modal = blessed.box({
parent: screen,
top: 'center',
left: 'center',
width: '60%',
height: '50%',
border: 'line',
label: ` ${title} `,
content: content,
tags: true,
keys: true,
style: {
fg: theme.fg,
bg: theme.bg,
border: { fg: theme.accent.bg },
label: { fg: theme.accent.bg, bold: true },
},
});
const savedFocus = screen.focused;
modal.focus();
function close() {
overlay.destroy();
modal.destroy();
if (savedFocus) savedFocus.focus();
}
modal.key(['escape', 'q'], close);
return { modal, overlay, close };
}---
10. Performance Rules
Hard Rules
| Don't | Do Instead |
|---|---|
screen.render() in loops | Use render scheduler |
| Rebuild widgets every frame | Update existing elements |
| Create elements every tick | Pool or reuse elements |
| Tags in large grid renders | Minimal highlights only |
Soft Rules
- Prefer updating
contentandstyleon existing elements - Keep viewport rendering to one
setContentper frame - Use
alwaysScrollonly where necessary (logs)
Instrumentation
Add a debug panel showing:
- Frame time
- Tick time
- Last render duration
- Actions processed
const debugPanel = blessed.box({
parent: screen,
top: 0,
right: 0,
width: 20,
height: 5,
hidden: true,
border: 'line',
label: ' Debug ',
content: '',
});
screen.key('F12', () => {
debugPanel.toggle();
requestRender();
});---
11. Terminal Compatibility
Windows Notes
- Keyboard works
- Mouse may not
- Resize may be inconsistent
Design keyboard-first.
Feature Detection
At startup, detect:
TERMand color depth (basic vs 256)- Unicode support preference
- Mouse support (optional)
function detectTerminalFeatures() {
const term = process.env.TERM || '';
const colorTerm = process.env.COLORTERM || '';
return {
colors256: term.includes('256color') || colorTerm === 'truecolor',
unicode: !term.includes('linux') && process.platform !== 'win32',
mouse: process.platform !== 'win32',
};
}Choose theme variants based on capabilities:
theme256vstheme16- Unicode borders vs ASCII borders
---
12. Testing Strategy
Game Core Tests
- Reducer tests: action in, state out
- System tests: tick updates
- RNG determinism tests
- Serialization tests (save/load)
UI Tests
- Snapshot
screen.screenshot()for known terminal sizes - Integration tests that:
1. Spin up the app 2. Send key events 3. Assert on screenshots
// Example test pattern
describe('inventory modal', () => {
it('renders correctly', () => {
const screen = createScreen();
const state = createInitialState();
openInventory(screen, state);
const screenshot = screen.screenshot();
expect(screenshot).toMatchSnapshot();
screen.destroy();
});
});---
13. Layout Patterns
Pattern A: Single Viewport + UI Chrome
Best for: Most games, roguelikes
┌─────────────────────────┬──────────┐
│ │ Stats │
│ Main Viewport │ HP: 100 │
│ (map/scene) │ MP: 50 │
│ ├──────────┤
│ │ Minimap │
├─────────────────────────┴──────────┤
│ [Log] Message history... │
├────────────────────────────────────┤
│ [Arrows] Move [I] Inv [?] Help │
└────────────────────────────────────┘Pattern B: Tabbed Panels
Best for: Inventory-heavy games, complex menus
┌────────────────────────────────────┐
│ [Map] [Inventory] [Skills] [Log] │
├────────────────────────────────────┤
│ │
│ Tab content area │
│ │
├────────────────────────────────────┤
│ [Tab] Switch [Enter] Select │
└────────────────────────────────────┘Pattern C: Modal-First
Best for: Narrative games, minimal UI
┌────────────────────────────────────┐
│ │
│ Clean main scene │
│ │
│ │
└────────────────────────────────────┘
(Interactions open centered modals)---
14. Implementation Checklist
When starting a new blessed TUI project:
- [ ] One render scheduler controls
screen.render() - [ ] Game core is pure and testable
- [ ] Layout uses stable proportions and resize behavior
- [ ] Theme tokens are centralized
- [ ] Controls are discoverable (
?help, footer hints) - [ ] Modals capture input and restore focus
- [ ] No per-tick widget creation
- [ ] Exit path calls
screen.destroy()and clears timers - [ ] Optional screenshot/debug tools exist
---
15. Blessed-Specific Best Practices
Auto-apply these patterns:
1. Enable smartCSR or fastCSR by default; switch if flicker occurs 2. Use autoPadding: true to prevent border math bugs 3. Prefer composition over Layout for game UIs 4. Render large areas with one setContent per frame 5. Use a render scheduler, avoid rendering inside handlers 6. Use focus deliberately, restore after modals 7. Keep tags minimal in large grid renders 8. Design keyboard-first; mouse is a bonus 9. Snapshot with screen.screenshot() for testing/debugging 10. destroy() transient UI (modals, popovers) to avoid memory leaks
Blessed Widget Reference
Quick reference for blessed widgets commonly used in TUI games and applications.
---
Core Widgets
Screen
The root container. All other widgets attach to the screen.
const screen = blessed.screen({
smartCSR: true, // Efficient scrolling
autoPadding: true, // Auto border/padding handling
fullUnicode: true, // Unicode support
warnings: true, // Dev warnings
title: 'My App',
});Key Properties:
width,height- Terminal dimensionscols,rows- Alias for width/heightfocused- Currently focused elementterminal- Terminal name
Key Methods:
render()- Render to terminal (use sparingly via scheduler)destroy()- Clean up and exitkey(keys, handler)- Bind key handlerscreenshot([x1, x2, y1, y2])- Capture screen as stringfocusNext(),focusPrevious()- Navigate focussaveFocus(),restoreFocus()- Focus state management
Key Events:
resize- Terminal resizedkeypress- Key pressedmouse- Mouse eventrender- After render
---
Box
The fundamental container widget.
const box = blessed.box({
parent: screen,
top: 'center',
left: 'center',
width: '50%',
height: '50%',
content: 'Hello {bold}world{/}!',
tags: true,
border: 'line',
style: {
fg: 'white',
bg: 'blue',
border: { fg: 'white' },
hover: { bg: 'green' },
},
});Key Options:
parent- Parent elementtop,left,right,bottom- Position (number, %, 'center')width,height- Size (number, %, 'shrink', 'half')content- Text contenttags- Enable{bold},{red-fg}, etc.border- 'line', 'bg', or objectstyle- Colors and attributesscrollable- Enable scrollingdraggable- Enable draghidden- Start hidden
Key Methods:
setContent(text)- Set contentgetContent()- Get contenthide(),show(),toggle()- Visibilityfocus()- Take focussetIndex(z)- Set z-ordersetFront(),setBack()- Z-order shortcuts
Content Methods:
insertLine(i, lines)- Insert line at indexdeleteLine(i)- Delete line at indexpushLine(lines)- Add to bottompopLine()- Remove from bottomunshiftLine(lines)- Add to topshiftLine()- Remove from topsetLine(i, line)- Set specific linegetLine(i)- Get specific lineclearLine(i)- Clear specific line
---
List
Scrollable, selectable list.
const list = blessed.list({
parent: screen,
width: '50%',
height: '50%',
items: ['Item 1', 'Item 2', 'Item 3'],
keys: true,
vi: true,
mouse: true,
style: {
selected: { bg: 'blue', fg: 'white' },
item: { fg: 'white' },
},
});Key Options:
items- Array of stringskeys- Enable keyboard navigationvi- Enable hjkl navigationmouse- Enable mouse selectioninteractive- Allow selection (default: true)invertSelected- Invert colors on select
Key Methods:
add(text),addItem(text)- Add itemremoveItem(child)- Remove item (element, index, or string)setItems(items)- Replace all itemsclearItems()- Clear all itemsselect(index)- Select by indexmove(offset)- Move selection relativelyup(n),down(n)- Move selectiongetItem(child)- Get item elementpick(callback)- Show and pick item
Key Events:
select- Item selected (with item and index)cancel- Selection cancelled (esc)action- Either select or cancel
---
Text
Simple text display.
const text = blessed.text({
parent: screen,
content: 'Static text',
align: 'center',
style: { fg: 'white' },
});Key Options:
align- 'left', 'center', 'right'fill- Fill line with bg color
---
Log
Auto-scrolling log display.
const log = blessed.log({
parent: screen,
width: '100%',
height: '30%',
bottom: 0,
border: 'line',
scrollback: 100,
scrollOnInput: false,
tags: true,
});Key Options:
scrollback- Max lines to keep (default: Infinity)scrollOnInput- Scroll to bottom on new input
Key Methods:
log(text),add(text)- Add log line
Key Events:
log- Line added
---
ProgressBar
Progress indicator.
const progress = blessed.progressbar({
parent: screen,
width: '50%',
height: 3,
border: 'line',
filled: 50,
style: {
bar: { bg: 'blue' },
},
});Key Options:
orientation- 'horizontal' or 'vertical'filled- Percentage (0-100)pch- Fill character (default: space)
Key Methods:
progress(amount)- Add to progresssetProgress(amount)- Set absolute progressreset()- Reset to 0
Key Events:
complete- Reached 100%reset- Reset called
---
Table
Display tabular data.
const table = blessed.table({
parent: screen,
width: '80%',
height: '50%',
border: 'line',
noCellBorders: true,
style: {
header: { fg: 'blue', bold: true },
cell: { fg: 'white' },
},
data: [
['Name', 'Level', 'HP'],
['Hero', '10', '100'],
['Enemy', '5', '50'],
],
});Key Options:
data,rows- 2D array of stringspad- Cell padding (default: 2)noCellBorders- Hide inner borders
Key Methods:
setData(rows),setRows(rows)- Update data
---
ListTable
Table with selectable rows.
const listTable = blessed.listtable({
parent: screen,
width: '80%',
height: '50%',
border: 'line',
keys: true,
vi: true,
style: {
header: { fg: 'blue', bold: true },
cell: { fg: 'white' },
selected: { bg: 'blue' },
},
data: [
['Name', 'Level', 'HP'],
['Hero', '10', '100'],
],
});Combines Table with List selection.
---
Listbar
Horizontal menu bar.
const listbar = blessed.listbar({
parent: screen,
top: 0,
width: '100%',
height: 1,
keys: true,
autoCommandKeys: true,
style: {
selected: { bg: 'blue' },
item: { fg: 'white' },
},
items: {
'File': { keys: ['f'], callback: () => {} },
'Edit': { keys: ['e'], callback: () => {} },
'Help': { keys: ['h'], callback: () => {} },
},
});Key Options:
items,commands- Menu items with keys and callbacksautoCommandKeys- Auto-bind 0-9 keys
Key Methods:
setItems(commands)- Update itemsselectTab(index)- Select and executemoveLeft(),moveRight()- Navigate
---
Form Widgets
Form
Container for form elements.
const form = blessed.form({
parent: screen,
keys: true,
width: '80%',
height: '80%',
border: 'line',
});
form.on('submit', (data) => {
console.log('Form data:', data);
});Key Methods:
submit()- Submit formcancel()- Cancel formreset()- Clear formfocusNext(),focusPrevious()- Navigate fields
Key Events:
submit- Form submitted (with data object)cancel- Form cancelledreset- Form reset
---
Textbox
Single-line text input.
const textbox = blessed.textbox({
parent: form,
name: 'username',
width: '80%',
height: 3,
border: 'line',
inputOnFocus: true,
});Key Options:
name- Field name for form submissionsecret- Hide input completelycensor- Show asterisks
Key Methods:
getValue()- Get current valuesetValue(text)- Set valueclearValue()- Clear valuereadInput(callback)- Start input mode
---
Textarea
Multi-line text input.
const textarea = blessed.textarea({
parent: form,
name: 'description',
width: '80%',
height: 10,
border: 'line',
inputOnFocus: true,
keys: true,
});Same methods as Textbox, supports multiple lines.
---
Button
Clickable button.
const button = blessed.button({
parent: form,
content: 'Submit',
width: 12,
height: 3,
border: 'line',
style: {
fg: 'white',
bg: 'blue',
focus: { bg: 'red' },
},
});
button.on('press', () => form.submit());Key Methods:
press()- Trigger press
Key Events:
press- Button pressed
---
Checkbox
Toggle checkbox.
const checkbox = blessed.checkbox({
parent: form,
name: 'agree',
text: 'I agree to terms',
checked: false,
mouse: true,
});Key Properties:
checked,value- Current state
Key Methods:
check(),uncheck(),toggle()
Key Events:
check,uncheck
---
RadioSet / RadioButton
Mutually exclusive options.
const radioSet = blessed.radioset({
parent: form,
width: '50%',
height: 5,
});
const opt1 = blessed.radiobutton({
parent: radioSet,
text: 'Option 1',
checked: true,
});
const opt2 = blessed.radiobutton({
parent: radioSet,
text: 'Option 2',
});---
Dialog Widgets
Prompt
Text input dialog.
const prompt = blessed.prompt({
parent: screen,
top: 'center',
left: 'center',
height: 'shrink',
width: 'shrink',
border: 'line',
});
prompt.input('Enter your name:', '', (err, value) => {
if (value) {
// Handle input
}
});Key Methods:
input(text, value, callback)- Show and get input
---
Question
Yes/no dialog.
const question = blessed.question({
parent: screen,
top: 'center',
left: 'center',
height: 'shrink',
width: 'shrink',
border: 'line',
});
question.ask('Continue?', (err, value) => {
if (value) {
// User said yes
}
});Key Methods:
ask(question, callback)- Show and get answer
---
Message
Information display.
const message = blessed.message({
parent: screen,
top: 'center',
left: 'center',
height: 'shrink',
width: '50%',
border: 'line',
});
message.display('Operation complete!', 3, () => {
// Dismissed after 3 seconds
});Key Methods:
display(text, time, callback)- Show message (time=0 for manual dismiss)error(text, time, callback)- Show error message
---
Loading
Loading spinner.
const loading = blessed.loading({
parent: screen,
top: 'center',
left: 'center',
height: 'shrink',
width: 'shrink',
border: 'line',
});
loading.load('Processing...');
// Later:
loading.stop();Key Methods:
load(text)- Show loading with messagestop()- Hide loading
---
Special Widgets
BigText
Large text using special fonts.
const bigText = blessed.bigtext({
parent: screen,
content: 'SCORE',
font: '/path/to/font.json',
fontBold: '/path/to/font-bold.json',
fch: ' ',
});---
Image / ANSIImage
Display images as ANSI art.
const image = blessed.image({
parent: screen,
file: '/path/to/image.png',
type: 'ansi', // or 'overlay' for w3m
width: '50%',
height: '50%',
});Key Methods:
setImage(file)- Change imageclearImage()- Clear image
---
FileManager
Simple file browser.
const fm = blessed.filemanager({
parent: screen,
cwd: process.cwd(),
keys: true,
vi: true,
style: {
selected: { bg: 'blue' },
},
});
fm.on('file', (path) => {
// File selected
});
fm.refresh();Key Events:
file- File selectedcd- Directory changed
Key Methods:
refresh([cwd])- Refresh listingpick([cwd], callback)- Pick file
---
Positioning Reference
Units
- Number: Absolute cells (
top: 5) - Percentage: Relative to parent (
width: '50%') - Offset: Percentage with offset (
left: '50%-10') - Keyword: 'center', 'shrink', 'half'
Properties
{
// Position
top: 0, // or 'center'
left: 0, // or 'center'
right: 0, // opposite of left
bottom: 0, // opposite of top
// Size
width: '100%', // or number, 'shrink', 'half'
height: '100%', // or number, 'shrink', 'half'
}Calculated Values
// Relative to parent
element.left // Calculated left offset
element.top // Calculated top offset
element.width // Calculated width
element.height // Calculated height
// Absolute (relative to screen)
element.aleft
element.atop
element.aright
element.abottom---
Style Reference
Full Style Object
{
style: {
fg: 'white', // Foreground color
bg: 'black', // Background color
bold: true, // Bold text
underline: false, // Underlined text
blink: false, // Blinking text
inverse: false, // Inverted colors
invisible: false, // Hidden text
transparent: false, // 50% opacity blend
border: {
fg: 'blue',
bg: 'black',
},
scrollbar: {
fg: 'white',
bg: 'blue',
},
// State-specific
focus: {
bg: 'red',
border: { fg: 'white' },
},
hover: {
bg: 'green',
},
// For lists
selected: {
fg: 'black',
bg: 'white',
},
item: {
fg: 'white',
},
}
}Color Values
- Named: 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'
- Bright: 'brightred', 'brightgreen', etc.
- Hex: '#ff0000', '#00ff00' (256-color terminals)
- 256-color: 0-255 index
---
Content Tags Reference
Enable with tags: true:
box.setContent(`
{bold}Bold text{/bold}
{underline}Underlined{/underline}
{red-fg}Red foreground{/red-fg}
{blue-bg}Blue background{/blue-bg}
{#ff0000-fg}Hex color{/}
{center}Centered text{/center}
{right}Right-aligned{/right}
left{|}right (justified)
`);Tag Reference
| Tag | Effect |
|---|---|
{bold} | Bold |
{underline} | Underline |
{blink} | Blink |
{inverse} | Inverse |
{invisible} | Hidden |
{COLOR-fg} | Foreground color |
{COLOR-bg} | Background color |
{#RRGGBB-fg} | Hex foreground |
{#RRGGBB-bg} | Hex background |
{/} | Reset all |
{/TAG} | Close specific tag |
{center} | Center text |
{right} | Right-align |
{left} | Left-align (default) |
| `{ | }` |
Escaping
// Using escape function
box.setContent('Escaped: ' + blessed.escape('{bold}'));
// Using special tags
box.setContent('Escaped: {open}bold{close}');---
Event Reference
Screen Events
| Event | Arguments | Description |
|---|---|---|
resize | - | Terminal resized |
mouse | MouseEvent | Any mouse event |
keypress | ch, key | Any key pressed |
key <name> | ch, key | Specific key (e.g., 'key q') |
focus | - | Terminal gained focus |
blur | - | Terminal lost focus |
prerender | - | Before render |
render | - | After render |
Element Events
| Event | Arguments | Description |
|---|---|---|
blur | - | Lost focus |
focus | - | Gained focus |
click | MouseEvent | Element clicked |
mousedown | MouseEvent | Mouse button down |
mouseup | MouseEvent | Mouse button up |
mouseover | MouseEvent | Mouse entered |
mouseout | MouseEvent | Mouse left |
mousemove | MouseEvent | Mouse moved |
wheeldown | MouseEvent | Scroll down |
wheelup | MouseEvent | Scroll up |
keypress | ch, key | Key pressed (when focused) |
move | - | Element moved |
resize | - | Element resized |
hide | - | Element hidden |
show | - | Element shown |
destroy | - | Element destroyed |
Key Event Object
{
name: 'a', // Key name
ctrl: false, // Ctrl held
meta: false, // Alt/Meta held
shift: false, // Shift held
full: 'C-a', // Full key string
}Mouse Event Object
{
x: 10, // X position
y: 5, // Y position
action: 'click', // Event type
button: 'left', // Button pressed
}/**
* Input handling and key routing
*
* Implements the router pattern for context-aware input handling.
*/
import blessed from 'blessed';
import type { GameState, Action, Panel } from './state';
// ============================================================================
// Types
// ============================================================================
export type KeyHandler = (
ch: string,
key: blessed.Widgets.Events.IKeyEventArg
) => boolean | void;
export type KeyBinding = {
keys: string[];
handler: () => void;
description: string;
};
export interface InputContext {
global: Record<string, KeyBinding>;
map: Record<string, KeyBinding>;
inventory: Record<string, KeyBinding>;
modal: Record<string, KeyBinding>;
}
// ============================================================================
// Key Binding Definitions
// ============================================================================
export function createKeyBindings(
dispatch: (action: Action) => void,
callbacks: {
exit: () => void;
openHelp: () => void;
openInventory: () => void;
}
): InputContext {
return {
// Global keys (always active)
global: {
quit: {
keys: ['q', 'C-c'],
handler: callbacks.exit,
description: 'Quit game',
},
help: {
keys: ['?', 'f1'],
handler: callbacks.openHelp,
description: 'Show help',
},
pause: {
keys: ['p'],
handler: () => dispatch({ type: 'PAUSE' }),
description: 'Pause game',
},
},
// Map panel keys
map: {
moveUp: {
keys: ['up', 'k', 'w'],
handler: () => dispatch({ type: 'MOVE', dx: 0, dy: -1 }),
description: 'Move up',
},
moveDown: {
keys: ['down', 'j', 's'],
handler: () => dispatch({ type: 'MOVE', dx: 0, dy: 1 }),
description: 'Move down',
},
moveLeft: {
keys: ['left', 'h', 'a'],
handler: () => dispatch({ type: 'MOVE', dx: -1, dy: 0 }),
description: 'Move left',
},
moveRight: {
keys: ['right', 'l', 'd'],
handler: () => dispatch({ type: 'MOVE', dx: 1, dy: 0 }),
description: 'Move right',
},
openInventory: {
keys: ['i'],
handler: callbacks.openInventory,
description: 'Open inventory',
},
},
// Inventory panel keys
inventory: {
close: {
keys: ['escape', 'i'],
handler: () => dispatch({ type: 'CLOSE_MODAL' }),
description: 'Close inventory',
},
selectUp: {
keys: ['up', 'k'],
handler: () => {}, // Handle in inventory component
description: 'Select previous item',
},
selectDown: {
keys: ['down', 'j'],
handler: () => {}, // Handle in inventory component
description: 'Select next item',
},
use: {
keys: ['enter', 'u'],
handler: () => {}, // Handle in inventory component
description: 'Use selected item',
},
},
// Modal keys (when any modal is open)
modal: {
close: {
keys: ['escape', 'q'],
handler: () => dispatch({ type: 'CLOSE_MODAL' }),
description: 'Close modal',
},
confirm: {
keys: ['enter', 'y'],
handler: () => {}, // Handle in modal component
description: 'Confirm',
},
},
};
}
// ============================================================================
// Input Router
// ============================================================================
export function createInputRouter(
screen: blessed.Widgets.Screen,
getState: () => GameState,
bindings: InputContext
) {
function findBinding(
context: Record<string, KeyBinding>,
keyName: string
): KeyBinding | undefined {
for (const binding of Object.values(context)) {
if (binding.keys.includes(keyName)) {
return binding;
}
}
return undefined;
}
function handleKey(ch: string, key: blessed.Widgets.Events.IKeyEventArg) {
const keyName = key.full || key.name || ch;
const state = getState();
// 1. Check global keys first
const globalBinding = findBinding(bindings.global, keyName);
if (globalBinding) {
globalBinding.handler();
return;
}
// 2. If modal is open, route to modal handlers
if (state.ui.modal) {
const modalBinding = findBinding(bindings.modal, keyName);
if (modalBinding) {
modalBinding.handler();
return;
}
// Modal captures all input - don't fall through
return;
}
// 3. Route to active panel
const panelBindings = bindings[state.ui.activePanel as keyof InputContext];
if (panelBindings && typeof panelBindings === 'object') {
const binding = findBinding(
panelBindings as Record<string, KeyBinding>,
keyName
);
if (binding) {
binding.handler();
return;
}
}
}
// Attach to screen
screen.on('keypress', handleKey);
return {
// Allow dynamic binding updates
updateBindings(newBindings: Partial<InputContext>) {
Object.assign(bindings, newBindings);
},
// Get help text for current context
getHelpText(panel?: Panel): string[] {
const lines: string[] = [];
lines.push('=== Global Keys ===');
for (const [name, binding] of Object.entries(bindings.global)) {
lines.push(` ${binding.keys.join('/')} - ${binding.description}`);
}
if (panel && bindings[panel]) {
lines.push('');
lines.push(`=== ${panel.charAt(0).toUpperCase() + panel.slice(1)} Keys ===`);
const panelBindings = bindings[panel] as Record<string, KeyBinding>;
for (const [name, binding] of Object.entries(panelBindings)) {
lines.push(` ${binding.keys.join('/')} - ${binding.description}`);
}
}
return lines;
},
// Cleanup
destroy() {
screen.removeListener('keypress', handleKey);
},
};
}
// ============================================================================
// Direction Helpers
// ============================================================================
export const DIRECTIONS = {
UP: { dx: 0, dy: -1 },
DOWN: { dx: 0, dy: 1 },
LEFT: { dx: -1, dy: 0 },
RIGHT: { dx: 1, dy: 0 },
UP_LEFT: { dx: -1, dy: -1 },
UP_RIGHT: { dx: 1, dy: -1 },
DOWN_LEFT: { dx: -1, dy: 1 },
DOWN_RIGHT: { dx: 1, dy: 1 },
} as const;
export function keyToDirection(
key: string
): { dx: number; dy: number } | undefined {
const map: Record<string, { dx: number; dy: number }> = {
up: DIRECTIONS.UP,
k: DIRECTIONS.UP,
w: DIRECTIONS.UP,
down: DIRECTIONS.DOWN,
j: DIRECTIONS.DOWN,
s: DIRECTIONS.DOWN,
left: DIRECTIONS.LEFT,
h: DIRECTIONS.LEFT,
a: DIRECTIONS.LEFT,
right: DIRECTIONS.RIGHT,
l: DIRECTIONS.RIGHT,
d: DIRECTIONS.RIGHT,
y: DIRECTIONS.UP_LEFT,
u: DIRECTIONS.UP_RIGHT,
b: DIRECTIONS.DOWN_LEFT,
n: DIRECTIONS.DOWN_RIGHT,
};
return map[key];
}
/**
* Layout composition for blessed TUI
*
* Implements the standard 3-pane game layout:
* - Main viewport (fluid)
* - Right sidebar (fixed)
* - Bottom log (fixed)
*/
import blessed from 'blessed';
import { theme, applyTheme } from './theme';
// ============================================================================
// Layout Configuration
// ============================================================================
export interface LayoutConfig {
sidebarWidth: number;
logHeight: number;
footerHeight: number;
showFooter: boolean;
}
export const defaultLayoutConfig: LayoutConfig = {
sidebarWidth: 30,
logHeight: 8,
footerHeight: 1,
showFooter: true,
};
// ============================================================================
// Layout Elements
// ============================================================================
export interface LayoutElements {
main: blessed.Widgets.BoxElement;
sidebar: blessed.Widgets.BoxElement;
log: blessed.Widgets.BoxElement;
footer?: blessed.Widgets.BoxElement;
}
/**
* Creates the standard 3-pane game layout
*/
export function createLayout(
screen: blessed.Widgets.Screen,
config: Partial<LayoutConfig> = {}
): LayoutElements {
const cfg = { ...defaultLayoutConfig, ...config };
const totalHeight = cfg.showFooter
? `100%-${cfg.logHeight + cfg.footerHeight}`
: `100%-${cfg.logHeight}`;
// Main viewport (map, scene, etc.)
const main = blessed.box({
parent: screen,
top: 0,
left: 0,
width: `100%-${cfg.sidebarWidth}`,
height: totalHeight,
border: 'line',
label: ' Game ',
tags: true,
style: {
...applyTheme(theme.panel),
label: { fg: theme.accent.bg, bold: true },
},
});
// Right sidebar (stats, inventory summary, minimap)
const sidebar = blessed.box({
parent: screen,
top: 0,
right: 0,
width: cfg.sidebarWidth,
height: totalHeight,
border: 'line',
label: ' Status ',
tags: true,
style: {
...applyTheme(theme.panel),
label: { fg: theme.muted.fg },
},
});
// Bottom log (messages, combat log)
const logTop = cfg.showFooter
? `100%-${cfg.logHeight + cfg.footerHeight}`
: `100%-${cfg.logHeight}`;
const log = blessed.box({
parent: screen,
top: logTop,
left: 0,
width: '100%',
height: cfg.logHeight,
border: 'line',
label: ' Log ',
tags: true,
scrollable: true,
alwaysScroll: true,
scrollbar: {
ch: ' ',
style: { bg: theme.scrollbar.bg },
},
style: {
...applyTheme(theme.panel),
label: { fg: theme.muted.fg },
},
});
const elements: LayoutElements = { main, sidebar, log };
// Footer (controls hint, status)
if (cfg.showFooter) {
elements.footer = blessed.box({
parent: screen,
bottom: 0,
left: 0,
width: '100%',
height: cfg.footerHeight,
tags: true,
style: applyTheme(theme.statusLine),
});
}
return elements;
}
// ============================================================================
// Responsive Layout Handling
// ============================================================================
/**
* Adjusts layout on terminal resize
*/
export function createResponsiveHandler(
screen: blessed.Widgets.Screen,
elements: LayoutElements,
config: LayoutConfig,
requestRender: () => void
) {
const minSidebarWidth = 22;
const maxSidebarWidth = 40;
const minTerminalWidth = 80;
function handleResize() {
const width = screen.width as number;
// Hide sidebar on very small terminals
if (width < minTerminalWidth) {
elements.sidebar.hide();
elements.main.width = '100%';
} else {
elements.sidebar.show();
// Dynamic sidebar width (25% of screen, within bounds)
const targetWidth = Math.floor(width * 0.25);
const newWidth = Math.max(
minSidebarWidth,
Math.min(maxSidebarWidth, targetWidth)
);
elements.sidebar.width = newWidth;
elements.main.width = `100%-${newWidth}`;
}
requestRender();
}
screen.on('resize', handleResize);
return {
destroy() {
screen.removeListener('resize', handleResize);
},
};
}
// ============================================================================
// Panel Helpers
// ============================================================================
/**
* Creates a sub-panel within the sidebar
*/
export function createSidebarPanel(
parent: blessed.Widgets.BoxElement,
label: string,
top: number | string,
height: number | string
): blessed.Widgets.BoxElement {
return blessed.box({
parent,
top,
left: 0,
width: '100%-2', // Account for parent border
height,
border: 'line',
label: ` ${label} `,
tags: true,
style: {
...applyTheme(theme.panel),
border: { fg: theme.border.fg },
label: { fg: theme.muted.fg },
},
});
}
/**
* Standard sidebar composition
*/
export function createStandardSidebar(sidebar: blessed.Widgets.BoxElement) {
// Stats panel (top)
const stats = createSidebarPanel(sidebar, 'Stats', 0, '40%');
// Inventory summary (middle)
const inventory = createSidebarPanel(sidebar, 'Items', '40%', '30%');
// Minimap or other info (bottom)
const info = createSidebarPanel(sidebar, 'Info', '70%', '30%-1');
return { stats, inventory, info };
}
// ============================================================================
// Footer Helpers
// ============================================================================
/**
* Updates footer with context-aware hints
*/
export function updateFooter(
footer: blessed.Widgets.BoxElement | undefined,
leftText: string,
rightText: string
) {
if (!footer) return;
// Use {|} for justified layout
footer.setContent(`${leftText}{|}${rightText}`);
}
/**
* Standard footer format
*/
export function formatFooterHints(hints: Array<{ key: string; action: string }>) {
return hints
.map(({ key, action }) => `{bold}[${key}]{/bold} ${action}`)
.join(' ');
}
/**
* Main entry point for blessed TUI application
*
* This template demonstrates the recommended structure:
* - Screen initialization
* - Render scheduler
* - Game loop
* - Clean exit handling
*/
import blessed from 'blessed';
import { createScreen } from './ui/screen';
import { createApp } from './ui/app';
import { createRenderScheduler } from './engine/scheduler';
import { createInitialState } from './game/state';
import type { GameState, Action } from './game/state';
import { reduce } from './game/reducer';
// ============================================================================
// Main Application
// ============================================================================
function main() {
// Initialize screen
const screen = createScreen();
// Create render scheduler (never call screen.render() directly!)
const { requestRender, stop: stopRenderer } = createRenderScheduler(screen, 30);
// Initialize game state
let state = createInitialState();
// Action dispatch function
const dispatch = (action: Action) => {
state = reduce(state, action);
requestRender();
};
// Create UI
const app = createApp(screen, state, dispatch, requestRender);
// Game tick (simulation separate from rendering)
const tickRate = 20; // 20 Hz simulation
const tickTimer = setInterval(() => {
// Process game logic here
// Example: state = tickSystems(state);
requestRender();
}, Math.floor(1000 / tickRate));
// Safe exit handler
function safeExit(code = 0) {
try {
clearInterval(tickTimer);
stopRenderer();
screen.destroy();
} finally {
process.exit(code);
}
}
// Global exit keys
screen.key(['escape', 'q', 'C-c'], () => safeExit(0));
// Handle resize
screen.on('resize', () => {
requestRender();
});
// Initial render
requestRender();
}
// Run
main();
/**
* Modal system for blessed TUI
*
* Implements the modal pattern:
* 1. Semi-transparent overlay
* 2. Centered modal box
* 3. Input capture
* 4. Focus restoration on close
*/
import blessed from 'blessed';
import { theme, applyTheme } from './theme';
// ============================================================================
// Types
// ============================================================================
export interface ModalOptions {
title: string;
content: string;
width?: number | string;
height?: number | string;
buttons?: Array<{ label: string; value: string; default?: boolean }>;
}
export interface ModalInstance {
overlay: blessed.Widgets.BoxElement;
modal: blessed.Widgets.BoxElement;
close: () => void;
onClose: (callback: (value?: string) => void) => void;
}
// ============================================================================
// Base Modal
// ============================================================================
/**
* Creates a modal with overlay
*/
export function createModal(
screen: blessed.Widgets.Screen,
options: ModalOptions
): ModalInstance {
const {
title,
content,
width = '60%',
height = '50%',
} = options;
// Save current focus
const savedFocus = screen.focused;
// Semi-transparent overlay
const overlay = blessed.box({
parent: screen,
top: 0,
left: 0,
width: '100%',
height: '100%',
style: {
bg: 'black',
transparent: true,
},
});
// Modal box
const modal = blessed.box({
parent: screen,
top: 'center',
left: 'center',
width,
height,
border: 'line',
label: ` ${title} `,
content,
tags: true,
keys: true,
style: {
fg: theme.fg,
bg: theme.bg,
border: { fg: theme.accent.bg },
label: { fg: theme.accent.bg, bold: true },
},
});
modal.focus();
// Close handlers
let closeCallback: ((value?: string) => void) | undefined;
function close(value?: string) {
overlay.destroy();
modal.destroy();
if (savedFocus && !savedFocus.destroyed) {
savedFocus.focus();
}
if (closeCallback) {
closeCallback(value);
}
screen.render();
}
// Default close on escape
modal.key(['escape'], () => close());
return {
overlay,
modal,
close,
onClose(callback) {
closeCallback = callback;
},
};
}
// ============================================================================
// Specialized Modals
// ============================================================================
/**
* Help modal with key bindings
*/
export function createHelpModal(
screen: blessed.Widgets.Screen,
helpText: string[]
): ModalInstance {
const content = [
'{bold}Keyboard Controls{/bold}',
'',
...helpText,
'',
'{gray-fg}Press ESC or Q to close{/gray-fg}',
].join('\n');
const instance = createModal(screen, {
title: 'Help',
content,
width: '70%',
height: '70%',
});
instance.modal.key(['q'], () => instance.close());
return instance;
}
/**
* Confirm dialog
*/
export function createConfirmModal(
screen: blessed.Widgets.Screen,
message: string,
onConfirm: () => void,
onCancel?: () => void
): ModalInstance {
const content = [
message,
'',
'{bold}[Y]{/bold} Yes {bold}[N]{/bold} No',
].join('\n');
const instance = createModal(screen, {
title: 'Confirm',
content,
width: '40%',
height: 'shrink',
});
instance.modal.key(['y', 'enter'], () => {
onConfirm();
instance.close('yes');
});
instance.modal.key(['n', 'escape'], () => {
if (onCancel) onCancel();
instance.close('no');
});
return instance;
}
/**
* Message/alert modal
*/
export function createMessageModal(
screen: blessed.Widgets.Screen,
title: string,
message: string,
timeout?: number
): ModalInstance {
const content = [
message,
'',
'{gray-fg}Press any key to close{/gray-fg}',
].join('\n');
const instance = createModal(screen, {
title,
content,
width: '50%',
height: 'shrink',
});
// Close on any key
instance.modal.on('keypress', () => instance.close());
// Auto-close after timeout
if (timeout) {
setTimeout(() => {
if (!instance.modal.destroyed) {
instance.close();
}
}, timeout);
}
return instance;
}
/**
* Input prompt modal
*/
export function createInputModal(
screen: blessed.Widgets.Screen,
title: string,
prompt: string,
defaultValue = ''
): Promise<string | undefined> {
return new Promise((resolve) => {
const instance = createModal(screen, {
title,
content: '',
width: '50%',
height: 10,
});
// Prompt label
blessed.text({
parent: instance.modal,
top: 1,
left: 1,
content: prompt,
style: { fg: theme.fg },
});
// Input box
const input = blessed.textbox({
parent: instance.modal,
top: 3,
left: 1,
width: '100%-4',
height: 3,
border: 'line',
style: {
fg: theme.fg,
bg: theme.bg,
border: { fg: theme.border.fg },
focus: { border: { fg: theme.accent.bg } },
},
inputOnFocus: true,
});
input.setValue(defaultValue);
// Hint
blessed.text({
parent: instance.modal,
bottom: 1,
left: 1,
content: '{gray-fg}Enter to confirm, Escape to cancel{/gray-fg}',
tags: true,
});
input.focus();
input.on('submit', (value) => {
instance.close();
resolve(value);
});
input.on('cancel', () => {
instance.close();
resolve(undefined);
});
screen.render();
});
}
/**
* List selection modal
*/
export function createListModal(
screen: blessed.Widgets.Screen,
title: string,
items: string[]
): Promise<number | undefined> {
return new Promise((resolve) => {
const instance = createModal(screen, {
title,
content: '',
width: '50%',
height: '60%',
});
const list = blessed.list({
parent: instance.modal,
top: 0,
left: 0,
width: '100%-2',
height: '100%-4',
items,
keys: true,
vi: true,
mouse: true,
style: {
fg: theme.fg,
bg: theme.bg,
selected: applyTheme(theme.selected),
item: { fg: theme.fg },
},
});
// Hint
blessed.text({
parent: instance.modal,
bottom: 0,
left: 1,
content: '{gray-fg}Enter to select, Escape to cancel{/gray-fg}',
tags: true,
});
list.focus();
list.on('select', (item, index) => {
instance.close();
resolve(index);
});
list.key(['escape', 'q'], () => {
instance.close();
resolve(undefined);
});
screen.render();
});
}
Templates
Starter code templates for blessed TUI applications. Copy and adapt these for your project.
Files
| Template | Purpose |
|---|---|
main.ts.template | Application entry point with lifecycle management |
screen.ts.template | Screen initialization and terminal feature detection |
scheduler.ts.template | Render scheduler and game loop |
state.ts.template | State management with pure reducers |
input.ts.template | Input handling and key routing |
layout.ts.template | Standard 3-pane layout composition |
modal.ts.template | Modal dialogs and overlays |
theme.ts.template | Color themes and styling |
Usage
1. Copy the templates you need to your src/ directory 2. Remove the .template extension 3. Adapt the code to your specific needs 4. Import and compose in your main entry point
Recommended Project Structure
my-tui-app/
src/
main.ts # From main.ts.template
ui/
screen.ts # From screen.ts.template
theme.ts # From theme.ts.template
layout.ts # From layout.ts.template
modal.ts # From modal.ts.template
game/
state.ts # From state.ts.template
engine/
scheduler.ts # From scheduler.ts.template
input.ts # From input.ts.template
package.json
tsconfig.jsonQuick Start
Minimal setup using templates:
// main.ts
import { createScreen } from './ui/screen';
import { createRenderScheduler } from './engine/scheduler';
import { createLayout } from './ui/layout';
import { createInitialState, reduce } from './game/state';
const screen = createScreen();
const { requestRender, stop } = createRenderScheduler(screen);
let state = createInitialState();
const dispatch = (action) => {
state = reduce(state, action);
requestRender();
};
const layout = createLayout(screen);
// Your game logic here...
screen.key(['q', 'C-c'], () => {
stop();
screen.destroy();
process.exit(0);
});
requestRender();/**
* Render and game tick scheduler
*
* Implements the critical pattern: never render on every event.
* Uses a frame-based approach with configurable FPS.
*/
import blessed from 'blessed';
export interface RenderScheduler {
requestRender(): void;
stop(): void;
}
/**
* Creates a render scheduler that batches render requests
*
* @param screen - The blessed screen to render
* @param maxFps - Maximum frames per second (default: 30)
*/
export function createRenderScheduler(
screen: blessed.Widgets.Screen,
maxFps = 30
): RenderScheduler {
let pending = false;
const frameMs = Math.floor(1000 / maxFps);
const timer = setInterval(() => {
if (!pending) return;
pending = false;
screen.render();
}, frameMs);
return {
requestRender() {
pending = true;
},
stop() {
clearInterval(timer);
},
};
}
export interface GameLoop {
stop(): void;
pause(): void;
resume(): void;
isPaused(): boolean;
}
/**
* Creates a game tick loop separate from rendering
*
* @param onTick - Called every tick with delta time
* @param tickRate - Ticks per second (default: 20)
*/
export function createGameLoop(
onTick: (deltaMs: number) => void,
tickRate = 20
): GameLoop {
const tickMs = Math.floor(1000 / tickRate);
let lastTick = Date.now();
let paused = false;
const timer = setInterval(() => {
if (paused) return;
const now = Date.now();
const delta = now - lastTick;
lastTick = now;
onTick(delta);
}, tickMs);
return {
stop() {
clearInterval(timer);
},
pause() {
paused = true;
},
resume() {
paused = false;
lastTick = Date.now();
},
isPaused() {
return paused;
},
};
}
export interface FrameStats {
fps: number;
frameTime: number;
tickTime: number;
}
/**
* Performance monitoring for debugging
*/
export function createPerformanceMonitor() {
let frameCount = 0;
let lastFpsUpdate = Date.now();
let currentFps = 0;
let lastFrameTime = 0;
let lastTickTime = 0;
return {
recordFrame(startTime: number) {
frameCount++;
lastFrameTime = Date.now() - startTime;
const now = Date.now();
if (now - lastFpsUpdate >= 1000) {
currentFps = frameCount;
frameCount = 0;
lastFpsUpdate = now;
}
},
recordTick(startTime: number) {
lastTickTime = Date.now() - startTime;
},
getStats(): FrameStats {
return {
fps: currentFps,
frameTime: lastFrameTime,
tickTime: lastTickTime,
};
},
};
}
/**
* Screen initialization module
*
* Handles terminal setup with recommended defaults.
*/
import blessed from 'blessed';
export interface ScreenOptions {
title?: string;
smartCSR?: boolean;
fastCSR?: boolean;
fullUnicode?: boolean;
debug?: boolean;
}
export function createScreen(options: ScreenOptions = {}) {
const {
title = 'TUI App',
smartCSR = true,
fastCSR = false,
fullUnicode = true,
debug = false,
} = options;
const screen = blessed.screen({
smartCSR: !fastCSR && smartCSR,
fastCSR,
autoPadding: true,
fullUnicode,
warnings: debug,
debug,
});
screen.title = title;
return screen;
}
/**
* Detect terminal capabilities
*/
export function detectTerminalFeatures() {
const term = process.env.TERM || '';
const colorTerm = process.env.COLORTERM || '';
return {
colors256: term.includes('256color') || colorTerm === 'truecolor',
trueColor: colorTerm === 'truecolor' || colorTerm === '24bit',
unicode: !term.includes('linux') && process.platform !== 'win32',
mouse: process.platform !== 'win32',
isWindows: process.platform === 'win32',
isMac: process.platform === 'darwin',
};
}
/**
* Game state management
*
* Implements the single authoritative state pattern with pure reducers.
* This module should have NO terminal dependencies (pure logic).
*/
// ============================================================================
// State Types
// ============================================================================
export interface Position {
x: number;
y: number;
}
export interface Player {
position: Position;
hp: number;
maxHp: number;
name: string;
}
export interface MapState {
width: number;
height: number;
tiles: Uint8Array;
}
export type Panel = 'map' | 'inventory' | 'log' | 'stats';
export interface ModalState {
type: 'help' | 'inventory' | 'dialog' | 'confirm';
data?: unknown;
}
export interface UIState {
activePanel: Panel;
modal?: ModalState;
logMessages: string[];
maxLogMessages: number;
}
export interface GameState {
time: number;
player: Player;
map: MapState;
ui: UIState;
paused: boolean;
}
// ============================================================================
// Action Types
// ============================================================================
export type Action =
// Movement
| { type: 'MOVE'; dx: number; dy: number }
| { type: 'TELEPORT'; x: number; y: number }
// Combat/Stats
| { type: 'DAMAGE'; amount: number }
| { type: 'HEAL'; amount: number }
// UI
| { type: 'SET_PANEL'; panel: Panel }
| { type: 'OPEN_MODAL'; modal: ModalState }
| { type: 'CLOSE_MODAL' }
| { type: 'LOG_MESSAGE'; message: string }
| { type: 'CLEAR_LOG' }
// Game
| { type: 'TICK' }
| { type: 'PAUSE' }
| { type: 'RESUME' }
| { type: 'RESET' };
// ============================================================================
// Initial State Factory
// ============================================================================
export function createInitialState(): GameState {
const mapWidth = 80;
const mapHeight = 24;
return {
time: 0,
paused: false,
player: {
position: { x: Math.floor(mapWidth / 2), y: Math.floor(mapHeight / 2) },
hp: 100,
maxHp: 100,
name: 'Player',
},
map: {
width: mapWidth,
height: mapHeight,
tiles: new Uint8Array(mapWidth * mapHeight).fill(0),
},
ui: {
activePanel: 'map',
modal: undefined,
logMessages: [],
maxLogMessages: 100,
},
};
}
// ============================================================================
// Reducer (Pure State Updates)
// ============================================================================
export function reduce(state: GameState, action: Action): GameState {
switch (action.type) {
// Movement
case 'MOVE': {
const newX = state.player.position.x + action.dx;
const newY = state.player.position.y + action.dy;
// Bounds check
if (newX < 0 || newX >= state.map.width) return state;
if (newY < 0 || newY >= state.map.height) return state;
// Collision check (tile 1 = wall)
const tileIndex = newY * state.map.width + newX;
if (state.map.tiles[tileIndex] === 1) return state;
return {
...state,
player: {
...state.player,
position: { x: newX, y: newY },
},
};
}
case 'TELEPORT': {
return {
...state,
player: {
...state.player,
position: { x: action.x, y: action.y },
},
};
}
// Combat/Stats
case 'DAMAGE': {
const newHp = Math.max(0, state.player.hp - action.amount);
return {
...state,
player: { ...state.player, hp: newHp },
};
}
case 'HEAL': {
const newHp = Math.min(state.player.maxHp, state.player.hp + action.amount);
return {
...state,
player: { ...state.player, hp: newHp },
};
}
// UI
case 'SET_PANEL': {
return {
...state,
ui: { ...state.ui, activePanel: action.panel },
};
}
case 'OPEN_MODAL': {
return {
...state,
ui: { ...state.ui, modal: action.modal },
};
}
case 'CLOSE_MODAL': {
return {
...state,
ui: { ...state.ui, modal: undefined },
};
}
case 'LOG_MESSAGE': {
const messages = [...state.ui.logMessages, action.message];
// Trim to max
while (messages.length > state.ui.maxLogMessages) {
messages.shift();
}
return {
...state,
ui: { ...state.ui, logMessages: messages },
};
}
case 'CLEAR_LOG': {
return {
...state,
ui: { ...state.ui, logMessages: [] },
};
}
// Game
case 'TICK': {
if (state.paused) return state;
return {
...state,
time: state.time + 1,
};
}
case 'PAUSE': {
return { ...state, paused: true };
}
case 'RESUME': {
return { ...state, paused: false };
}
case 'RESET': {
return createInitialState();
}
default:
return state;
}
}
// ============================================================================
// Selectors (Derive computed values from state)
// ============================================================================
export const selectors = {
getPlayerHealthPercent(state: GameState): number {
return (state.player.hp / state.player.maxHp) * 100;
},
isPlayerAlive(state: GameState): boolean {
return state.player.hp > 0;
},
getTile(state: GameState, x: number, y: number): number {
if (x < 0 || x >= state.map.width) return -1;
if (y < 0 || y >= state.map.height) return -1;
return state.map.tiles[y * state.map.width + x];
},
hasModal(state: GameState): boolean {
return state.ui.modal !== undefined;
},
getRecentLogs(state: GameState, count = 10): string[] {
return state.ui.logMessages.slice(-count);
},
};
/**
* Theme system for blessed TUI applications
*
* Centralize all colors and styles here. Apply consistently.
* Rule: 1 accent color, 1 danger color, everything else muted.
*/
export interface ThemeColors {
fg: string;
bg: string;
border?: { fg: string; bg?: string };
}
export interface Theme {
// Base colors
fg: string;
bg: string;
// Panel styles
panel: ThemeColors;
// Semantic colors
accent: ThemeColors;
danger: ThemeColors;
success: ThemeColors;
warning: ThemeColors;
muted: ThemeColors;
// Interactive states
selected: ThemeColors;
focused: ThemeColors;
hover: ThemeColors;
// Specific elements
border: { fg: string };
scrollbar: { fg: string; bg: string };
statusLine: ThemeColors;
}
/**
* Default dark theme
*/
export const darkTheme: Theme = {
fg: 'white',
bg: 'black',
panel: {
fg: 'white',
bg: 'black',
border: { fg: '#888888' },
},
accent: {
fg: 'black',
bg: '#f4d03f',
},
danger: {
fg: 'white',
bg: '#e74c3c',
},
success: {
fg: 'white',
bg: '#27ae60',
},
warning: {
fg: 'black',
bg: '#f39c12',
},
muted: {
fg: '#888888',
bg: 'black',
},
selected: {
fg: 'black',
bg: 'white',
},
focused: {
fg: 'white',
bg: '#2c3e50',
border: { fg: '#f4d03f' },
},
hover: {
fg: 'white',
bg: '#34495e',
},
border: { fg: '#555555' },
scrollbar: {
fg: '#888888',
bg: '#333333',
},
statusLine: {
fg: '#aaaaaa',
bg: '#1a1a1a',
},
};
/**
* Light theme variant
*/
export const lightTheme: Theme = {
fg: 'black',
bg: 'white',
panel: {
fg: 'black',
bg: 'white',
border: { fg: '#666666' },
},
accent: {
fg: 'white',
bg: '#3498db',
},
danger: {
fg: 'white',
bg: '#e74c3c',
},
success: {
fg: 'white',
bg: '#27ae60',
},
warning: {
fg: 'black',
bg: '#f1c40f',
},
muted: {
fg: '#666666',
bg: 'white',
},
selected: {
fg: 'white',
bg: 'black',
},
focused: {
fg: 'black',
bg: '#ecf0f1',
border: { fg: '#3498db' },
},
hover: {
fg: 'black',
bg: '#bdc3c7',
},
border: { fg: '#aaaaaa' },
scrollbar: {
fg: '#666666',
bg: '#cccccc',
},
statusLine: {
fg: '#555555',
bg: '#eeeeee',
},
};
/**
* 16-color fallback theme for limited terminals
*/
export const basicTheme: Theme = {
fg: 'white',
bg: 'black',
panel: {
fg: 'white',
bg: 'black',
border: { fg: 'white' },
},
accent: {
fg: 'black',
bg: 'yellow',
},
danger: {
fg: 'white',
bg: 'red',
},
success: {
fg: 'black',
bg: 'green',
},
warning: {
fg: 'black',
bg: 'yellow',
},
muted: {
fg: 'gray',
bg: 'black',
},
selected: {
fg: 'black',
bg: 'white',
},
focused: {
fg: 'white',
bg: 'blue',
border: { fg: 'cyan' },
},
hover: {
fg: 'white',
bg: 'blue',
},
border: { fg: 'white' },
scrollbar: {
fg: 'white',
bg: 'gray',
},
statusLine: {
fg: 'white',
bg: 'blue',
},
};
// Default export
export const theme = darkTheme;
/**
* Helper to apply theme to blessed style object
*/
export function applyTheme(colors: ThemeColors) {
return {
fg: colors.fg,
bg: colors.bg,
...(colors.border && { border: colors.border }),
};
}