A real-time top-down ASCII arena shooter played entirely in the terminal. Survive escalating waves of enemies in short, high-pressure arcade runs — rendered with ANSI escape sequences and driven by a deterministic fixed-tick simulation loop.
| Tool | Version | Install |
|---|---|---|
| Node.js | ≥ 18 | nodejs.org |
| pnpm | 10.x | corepack enable && corepack prepare pnpm@10 --activate |
| Nx | ≥ 20 | Included as a dev dependency |
Note: The
preinstallscript automatically runscorepack enableto pin pnpm to the version declared inpackage.json. No manual setup needed — just runpnpm installand Corepack takes care of the rest.
# 1. Clone the repo
git clone <repo-url> && cd void-gladiator
# 2. Install dependencies (also activates corepack)
pnpm install
# 3. Run the game
pnpm devAll scripts are run from the repository root:
| Command | What it does |
|---|---|
pnpm dev |
Launch the game in dev mode (tsx hot-reload) |
pnpm typecheck |
Type-check every package |
pnpm lint |
Lint all TypeScript files (ESLint + Prettier rules) |
pnpm test |
Run the full test suite (Vitest) |
pnpm graph |
Open the Nx dependency graph in your browser |
# Single file
pnpm exec vitest run tests/integration/movement.test.ts
# By pattern
pnpm exec vitest run -t "pattern"apps/
cli-game/ → App shell: bootstraps engine, wires packages, owns process lifecycle
packages/
game-core/ → Simulation: state, commands, movement, collision, scoring, scenes
engine-loop/ → Generic fixed-tick timer (no game knowledge)
renderer-ansi/ → Converts GameState → ASCII frames via ANSI escape codes
terminal-input/ → Raw TTY capture, key decoding, command emission
content/ → Static game constants (arena size, title, enemies, upgrades)
persistence/ → High-score load/save (filesystem)
protocol/ → GameCommand union type, CommandBatch, CommandEnvelope
shared/ → Math utilities (Point, clamp) — intentionally small
network-lan/ → Placeholder for future LAN multiplayer transport
tests/
integration/ → Cross-package integration tests
The game uses a command-driven architecture designed to support future LAN multiplayer without rewriting game logic.
terminal-input → command buffer → game-core (tick) → GameState → renderer-ansi → stdout
cli-game → game-core, engine-loop, renderer-ansi, terminal-input, persistence, content, protocol
game-core → shared, content, protocol
renderer-ansi → game-core (state types only)
terminal-input → protocol
engine-loop → shared
network-lan → protocol
Hard rules to follow:
game-coremust never import terminal or renderer APIsrenderer-ansimust never contain gameplay logic — it only reads stateterminal-inputmust never mutate game state directly- The simulation consumes normalized commands, never raw key events
- Type-only imports are enforced — use
import type { ... }for types - Discriminated unions for commands and protocol messages
- Immutable state updates via spread (reducer pattern)
- Closure-based modules (e.g.,
createTicker()returns{ start, stop }) - State is plain serializable data — no classes with hidden mutation
- Unused variables must be prefixed with
_ - Target:
ES2022/ Module:NodeNext
- Prettier: single quotes, semicolons, trailing commas (
es5) - Spaces, not tabs
- Each package exposes a single barrel:
src/index.ts - Package names:
@void-gladiator/<name> - Internal deps use
"workspace:*"inpackage.json
- Create
packages/<name>/withpackage.json,project.json,tsconfig.json, andsrc/index.ts - Use
@void-gladiator/<name>as the package name - Add
"workspace:*"deps for internal packages - Follow the dependency rules above — never introduce a circular dependency
Deeper design context lives in the root:
| Document | Purpose |
|---|---|
VOID_GLADIATOR_SPEC.md |
Locked game design spec (enemies, upgrades, controls, MVP) |
TECH_ARCHITECTURE.md |
Technical architecture decisions |
MONOREPO_ARCHITECTURE.md |
Package boundaries and dependency rules |
GAME_DESIGN.md |
Original brainstorm and design exploration |
Rendering Pipeline
terminal-input → command buffer → game-core tick → GameState ↓ renderFrame(state) [renderer-ansi] ↓ scene renderer (gameplay/lobby/title/results) returns raw string ↓ normalizeFrame(raw) [frame-buffer] • split into lines • clamp each to FRAME_WIDTH • diff against prevLines • emit only changed lines with cursorTo() ↓ process.stdout.write() [main.ts]
- Pick an area — check open issues or the design docs for what needs work.
- Create a branch — use a descriptive name like
feat/enemy-aiorfix/collision-edge. - Make your changes — follow the coding conventions above.
- Validate before pushing:
pnpm typecheck && pnpm lint && pnpm test
- Open a PR — describe what changed and why. Link to the relevant design doc or issue.
Private — not yet published.