Game Dev Skill
This skill enables rapid creation of terminal-based games for @hypersocial/cli-games .
Quick Start
-
Get game concept and name from user
-
Create game file at src/games/{name}/index.ts
-
Register in src/games/index.ts
-
Follow patterns below for consistent look and feel
Architecture Overview
All games share these characteristics:
-
GameController interface: stop() method and isRunning getter
-
State machine: gameStarted , gameOver , paused , won flags
-
Shared pause menu: Import from ../shared/menu
-
Shared effects: Import from ../shared/effects
-
Theme awareness: Use getCurrentThemeColor() from ../utils
-
Terminal rendering: ANSI escape codes, alternate buffer mode
File Structure
src/games/ ├── {gamename}/ │ ├── index.ts # Main game file (required) │ └── effects.ts # Complex games: separate effects ├── shared/ │ ├── menu.ts # Shared pause menu system │ ├── effects.ts # Shared particle, popup, shake, flash utilities │ └── index.ts # Re-exports ├── gameTransitions.ts # Quit/switch game helpers ├── utils.ts # Theme colors, utilities └── index.ts # Game registry
Required Imports
import type { Terminal } from '@xterm/xterm'; import { getCurrentThemeColor } from '../utils'; import { dispatchGameQuit, dispatchGameSwitch, dispatchGamesMenu } from '../gameTransitions'; import { PAUSE_MENU_ITEMS, renderSimpleMenu, navigateMenu } from '../shared/menu';
For effects, import from the shared module:
import { type Particle, type ScorePopup, spawnParticles, updateParticles, addScorePopup, updatePopups, triggerShake, applyShake, createShakeState, MAX_PARTICLES, PARTICLE_CHARS, } from '../shared/effects';
GameController Interface
Every game exports a controller with this shape:
export interface {Name}Controller { stop: () => void; isRunning: boolean; }
State Machine Pattern
let running = true; let gameStarted = false; let gameOver = false; let paused = false; let pauseMenuSelection = 0; let won = false;
State transitions:
-
Start: !gameStarted && !paused -> any key -> gameStarted = true
-
Pause: ESC toggles paused , reset pauseMenuSelection = 0
-
Game Over: collision/loss -> gameOver = true , optionally won = true
-
Quit: Q key or menu selection -> controller.stop()
- dispatchGameQuit()
Pause Menu Integration
CRITICAL: Always use the shared menu system:
import { PAUSE_MENU_ITEMS, renderSimpleMenu, navigateMenu } from '../shared/menu';
// In render(): if (paused) { output += renderSimpleMenu(PAUSE_MENU_ITEMS, pauseMenuSelection, { centerX: Math.floor(cols / 2), startY: pauseY + 2, showShortcuts: false, }); }
// In key handler: if (paused) { const { newSelection, confirmed } = navigateMenu( pauseMenuSelection, PAUSE_MENU_ITEMS.length, key, domEvent );
if (newSelection !== pauseMenuSelection) { pauseMenuSelection = newSelection; return; }
if (confirmed) { switch (pauseMenuSelection) { case 0: paused = false; break; // Resume case 1: initGame(); gameStarted = true; paused = false; break; // Restart case 2: controller.stop(); dispatchGameQuit(terminal); break; // Quit case 3: dispatchGamesMenu(terminal); break; // List Games case 4: dispatchGameSwitch(terminal); break; // Next Game } } }
Terminal Size Handling
Always check minimum size and show helpful resize message:
const MIN_COLS = 40; const MIN_ROWS = 20;
// In render():
if (cols < MIN_COLS || rows < MIN_ROWS) {
const msg1 = 'Terminal too small!';
const needWidth = cols < MIN_COLS;
const needHeight = rows < MIN_ROWS;
let hint = needWidth && needHeight ? 'Make pane larger'
: needWidth ? 'Make pane wider ->' : 'Make pane taller';
const msg2 = Need: ${MIN_COLS}x${MIN_ROWS} Have: ${cols}x${rows};
// Center and render messages...
return;
}
Glitch Title Effect
Every game has a glitchy ASCII title:
const title = [ '{TITLE_LINE_1}', '{TITLE_LINE_2}', ];
let glitchFrame = 0;
// In render(): glitchFrame = (glitchFrame + 1) % 60; const glitchOffset = glitchFrame >= 55 ? Math.floor(Math.random() * 3) - 1 : 0; const titleX = Math.floor((cols - title[0].length) / 2) + glitchOffset;
if (glitchFrame >= 55 && glitchFrame < 58) {
output += \x1b[1;${titleX}H\x1b[91m${title[0]}\x1b[0m;
output += \x1b[2;${titleX + 1}H\x1b[96m${title[1]}\x1b[0m;
} else {
output += \x1b[1;${titleX}H${themeColor}\x1b[1m${title[0]}\x1b[0m;
output += \x1b[2;${titleX}H${themeColor}\x1b[1m${title[1]}\x1b[0m;
}
Game Loop Setup
setTimeout(() => { if (!running) return;
// Enter alternate buffer, hide cursor terminal.write('\x1b[?1049h'); terminal.write('\x1b[?25l');
initGame(); gameStarted = false;
const renderInterval = setInterval(() => { if (!running) { clearInterval(renderInterval); return; } render(); }, 50); // 20 FPS
const gameInterval = setInterval(() => { if (!running) { clearInterval(gameInterval); return; } update(); }, 50);
const keyListener = terminal.onKey(({ domEvent }) => { if (!running) { keyListener.dispose(); return; } domEvent.preventDefault(); domEvent.stopPropagation(); // Handle input... });
// Override stop to clean up const originalStop = controller.stop; controller.stop = () => { clearInterval(renderInterval); clearInterval(gameInterval); keyListener.dispose(); originalStop(); }; }, 50);
Registering a Game
In src/games/index.ts , add a direct import and entry:
// 1. Add the import at the top with existing imports: import { run{Name}Game } from './{name}';
// 2. Add to the games array: export const games: GameInfo[] = [ // ... existing games { id: '{name}', name: '{Name}', description: 'Game description', run: run{Name}Game }, ];
// 3. Add to the individual game runner exports: export { // ... existing exports run{Name}Game, };
Effect Patterns
See patterns/effects.md for:
-
Particle systems (burst, trail, firework)
-
Screen shake
-
Score popups
-
Flash effects
-
Kill streaks and combos
Use the shared effects module at src/games/shared/effects.ts for common effect logic.
Input Patterns
See patterns/input-handling.md for:
-
Arrow key navigation
-
Action keys (space, etc.)
-
ESC/Q handling
-
Start screen any-key
Rendering Patterns
See patterns/rendering.md for:
-
ANSI escape codes reference
-
Border drawing
-
Centering content
-
Color cycling
-
Perspective effects
Game Complexity Guide
Simple (~300-500 lines): Snake, Hangman
-
Single file
-
Basic collision
-
Minimal physics
Medium (~500-800 lines): Tetris, Pong
-
Single file with more systems
-
AI or grid mechanics
-
More effect polish
Complex (~1000+ lines): Chopper
-
Multiple files (index.ts, effects.ts)
-
Physics, particles, popups
-
Level progression
Contributing a Game
-
Create your game in src/games/{name}/index.ts
-
Register it in src/games/index.ts
-
Use shared effects from ../shared/effects instead of inlining particle/popup code
-
Use the shared menu from ../shared/menu for pause/game-over menus
-
Run npm run build and npm run typecheck to verify
-
Submit a PR
Template
Use the scaffold template at templates/game-scaffold.ts as a starting point.