This document defines the technical plan for implementing Void Gladiator.
It is intended to be the primary engineering reference for:
- project scaffolding
- monorepo workspace structure
- folder structure
- code organization
- runtime design
- game-state architecture
- rendering strategy
- persistence
- future LAN multiplayer expansion
This document should be used alongside VOID_GLADIATOR_SPEC.md.
The architecture should optimize for these goals:
- fast local iteration during development
- responsive terminal rendering for arcade gameplay
- deterministic and testable simulation logic
- clean separation between engine, game rules, and terminal concerns
- minimal friction for adding LAN host/client support later
- simple enough structure to stay productive as a solo project
- Node.js
- TypeScript
pnpmworkspacesNxfor task orchestration, project graph, caching, and affected runs
- custom ANSI terminal renderer
- raw stdout writes for frame output
- terminal input via stdin in raw mode
- TypeScript compiler for builds
tsxfor local development runs- ESLint for code quality
- Prettier for formatting
- Vitest for unit tests on simulation and helpers
The goal is to keep runtime dependencies minimal.
Recommended dependencies:
kleurorpicocolorsfor light terminal color utilities if neededsignal-exitonly if terminal cleanup needs a stable cross-platform helper
Recommended dev dependencies:
nxtypescripttsxvitesteslint@typescript-eslint/eslint-plugin@typescript-eslint/parserprettiereslint-config-prettier@types/node
Void Gladiator is a real-time action game, not a form-driven terminal app.
A custom renderer is the right choice because it gives direct control over:
- frame timing
- redraw behavior
- buffer diffing
- HUD composition
- effects such as flashes and shake
- future spectator or network state rendering
The project should be built as a monorepo from the start.
Use:
pnpmfor workspace and dependency managementNxfor task running, caching, project graph visibility, and incremental builds/tests
- separate packages can evolve without forcing a rewrite later
- networking can be added as new packages instead of being tangled into the first app
- simulation, protocol, renderer, and input can be tested independently
Nxgives useful structure once the repo grows past a few packages
Plain pnpm workspaces would work, but Nx is worth it here because the project is intended to become modular over time and will likely benefit from:
- affected commands
- dependency graph awareness
- cached builds and tests
- explicit project boundaries
This project does not need Bazel-level complexity. pnpm plus Nx is enough structure without becoming a maintenance project.
During early development, optimize for local execution using:
- a monorepo with one runnable app and several internal packages
tsxfor local development of the CLI game app- package-local or Nx-managed TypeScript builds to
dist/
Production build can remain standard Node output initially:
- compile the runnable app and dependent packages to JavaScript
- run the built CLI app via Node on the target machine
Later, if distribution becomes important, this can be extended to:
pkgnexe- platform-specific packaging scripts
That is not needed for the first implementation phase.
The detailed package layout lives in MONOREPO_ARCHITECTURE.md.
At a high level, the workspace should look like this:
test1/
docs/
GAME_DESIGN.md
VOID_GLADIATOR_SPEC.md
TECH_ARCHITECTURE.md
apps/
cli-game/
packages/
game-core/
engine-loop/
renderer-ansi/
terminal-input/
content/
persistence/
shared/
protocol/
network-lan/
tests/
integration/
package.json
pnpm-workspace.yaml
nx.json
tsconfig.json
eslint.config.js
.prettierrc
The current repo stores design docs at root. Once scaffolding starts, those files should move under docs/ to keep the workspace clean.
Owns application startup and orchestration.
Responsibilities:
- bootstrapping dependencies
- starting the terminal session
- selecting the initial scene
- running the main loop
- shutting down cleanly
This is the outer shell of the program.
Contains reusable runtime primitives.
Responsibilities:
- fixed update loop
- frame timing
- terminal access
- input capture
- rendering buffer and diff flush
This layer should know nothing about Void Gladiator enemies, weapons, or score.
Contains all game-specific rules and content.
Responsibilities:
- entity logic
- combat systems
- wave progression
- scene transitions
- content definitions
- score logic
This is the main gameplay layer.
Handles filesystem-backed data.
Responsibilities:
- high score storage
- future settings storage
- platform-safe save paths
These packages should exist early even if network-lan starts nearly empty.
Responsibilities:
- protocol contracts
- interfaces for transport and command sources
- future host/client synchronization seam
The presence of these packages early is intentional. It prevents the project from hardwiring local-only assumptions into game logic.
Contains the terminal rendering implementation.
Responsibilities:
- ANSI screen buffer management
- diffing and frame flush
- color and glyph rendering helpers
- HUD and scene projection helpers where appropriate
This package may understand public game-state shapes, but gameplay rules must not live here.
Contains terminal input capture and normalization.
Responsibilities:
- raw terminal key capture
- input event decoding
- mapping keypresses into normalized commands
Contains typed content definitions.
Responsibilities:
- enemies
- upgrades
- waves
- boss definitions
Holds generic utilities that are not specific to engine or gameplay.
Responsibilities:
- math helpers
- simple data structures
- tiny reusable utilities
This should stay small. Do not turn it into a dumping ground.
The program should be treated as a stack of layers with strict flow direction.
- terminal and process shell
- engine loop and IO primitives
- game simulation and scenes
- renderer output projection
- persistence and future network adapters
Dependencies should generally flow inward and downward like this:
apps/cli-gamedepends ongame-core,engine-loop,renderer-ansi,terminal-input,persistence,contentgame-coredepends onshared,content, andprotocoltypes where neededengine-loopdepends onsharedrenderer-ansidepends onsharedand public game-state contracts onlyterminal-inputdepends onsharedand command contractspersistencedepends onsharednetwork-landepends onprotocolandsharedprotocoldepends onsharedonly when absolutely necessary
The game-core package should not import terminal-specific code.
The engine-loop package should not import game content.
The renderer can understand game state shape, but simulation must not depend on renderer details.
Void Gladiator should use a fixed-tick simulation model.
- simulation tick: 30 updates per second
- render target: 30 frames per second initially
This can later become:
- fixed simulation at 30 Hz
- render as fast as practical with interpolation if needed
For the MVP, matching update and render cadence is simpler and sufficient.
Each loop cycle should:
- collect terminal input events
- normalize them into game commands
- enqueue commands for the next simulation step
- update the active scene and simulation
- build a frame buffer from current state
- diff against previous frame if implemented
- flush ANSI output
- more consistent collisions
- easier gameplay tuning
- more deterministic tests
- easier future host-authoritative networking
The game should use scene-driven application flow.
- title scene
- run scene
- upgrade scene
- game over scene
Each scene should own:
- input handling rules for that scene
- update logic for that scene
- render projection for that scene
- transition conditions to other scenes
Without scene boundaries, pause, upgrade selection, and game-over handling tend to leak logic into the main simulation loop.
Scenes keep state transitions explicit and manageable.
The simulation should be command-driven and state-first.
At each tick:
- read queued commands
- transform simulation state
- emit transient events for feedback and UI
The runtime game state should be split into a few explicit domains:
- current scene
- paused flag
- elapsed run time
- current wave
- arena bounds
- active entities
- projectiles
- hazards
- pickups if added later
- position
- facing
- health
- cooldowns
- special charge
- active upgrades
- score multiplier state
- announcement banners
- warning messages
- upgrade selection options
- special ready pulses
- hit flashes
- shake intensity
- brief freeze-frame timers
- score
- highest wave this run
- end-of-run summary
Prefer plain serializable data structures over classes with hidden mutable behavior.
That makes the simulation easier to:
- test
- snapshot
- debug
- replicate across a future host/client boundary
The game should normalize input into a command stream before simulation consumes it.
- move up start
- move up stop
- move down start
- move left start
- move right start
- fire press
- fire release
- dash press
- special press
- pause press
- confirm press
- cancel press
This model creates a clean seam between:
- local keyboard input now
- remote network input later
- replay recording later if desired
The simulation should never ask whether a key is physically down. It should only consume normalized command state.
Within the run scene, the simulation step should follow a stable order.
- resolve input into player intent
- update player movement and facing
- process dash and weapon triggers
- update enemy AI intentions
- move projectiles and enemies
- resolve collisions
- apply damage and death
- process scoring and streaks
- update effects and UI events
- check wave completion and transitions
Stable system ordering keeps combat behavior predictable and easier to tune.
A lightweight structured entity model is the right starting point.
Do not introduce a full ECS framework for the MVP.
Use a pragmatic typed model such as:
- player object
- arrays for enemies and projectiles
- typed discriminated unions for entity kinds
- system functions that operate on state slices
- more setup cost
- less clarity for a small action game
- no strong payoff at current scope
If complexity grows significantly later, the system-oriented code organization will still make refactoring possible.
The renderer should convert current scene state into a frame buffer and flush it efficiently.
- clear or reuse buffer
- draw border and arena
- draw entities in priority order
- draw projectiles and hazards
- draw HUD
- apply lightweight effects
- flush to terminal
Use a 2D screen buffer that stores per-cell:
- glyph
- foreground color
- background color
- style flags if needed later
Suggested draw order:
- background and border
- hazards
- pickups if any
- enemies
- player
- projectiles
- overlays and UI
This may be adjusted for readability after playtesting.
The renderer should be designed with optional frame diffing.
That means:
- keep previous frame buffer
- compare changed cells only
- write only changed runs where worthwhile
For the earliest prototype, a full redraw may be acceptable. But the renderer API should not block later optimization.
The terminal session should manage:
- entering alternate screen if desired
- hiding the cursor
- enabling raw input mode
- restoring terminal state on exit or crash path
The code should explicitly handle terminal limitations.
If the terminal is too small:
- pause gameplay startup
- show a clear resize message
- resume only when dimensions are acceptable
The renderer should be able to respond to terminal resize events.
For MVP, the safe policy is:
- pause simulation
- redraw layout
- continue when size is valid
Primary target is macOS and Linux terminals first.
Windows support can come later unless it arrives naturally from Node terminal behavior.
The input layer should convert raw terminal bytes or key events into a normalized input state and command buffer.
- decode keypresses
- track held movement directions
- detect edge-triggered actions such as dash and special
- emit commands to the active scene or simulation
Movement should be stateful.
Examples:
- holding
Wkeeps moving up - pressing
Kemits a dash command once - pressing
Jemits a special command once
Define an InputSource interface early.
Potential implementations later:
- local keyboard source
- replay source
- remote network source
- bot input source for tests
Persistence should be minimal and isolated.
- highest score
- highest wave reached
- maybe a small recent run summary later
Use JSON for simplicity.
Use a stable app-specific path under the user home directory rather than writing into the project folder at runtime.
Example strategy:
- macOS and Linux: a hidden folder or app data folder under the user home directory
The path helper should centralize this logic so it can change later without touching game code.
Enemy, upgrade, and wave configuration should live as structured data definitions, not hardcoded inside systems.
- ids
- stats
- cooldowns
- projectile properties
- spawn weights or scripted wave lists
- display metadata such as glyphs and colors
- easier balancing
- easier testing
- easier iteration without changing simulation plumbing
- future possibility of seeded content generation
Because this is a terminal application, shutdown behavior matters.
The app should always try to restore:
- cursor visibility
- raw mode changes
- alternate screen state if used
If a fatal runtime error occurs:
- restore terminal state first
- print error after cleanup
- exit non-zero
This prevents the terminal from being left in a broken state during development.
The simulation core should be designed for testability from day one.
- collision detection
- movement clamping
- dash cooldown logic
- projectile lifetime logic
- enemy behavior helpers
- wave progression rules
- score and streak logic
- one simulation tick with a command batch
- elite wave transitions
- run start and run end state transitions
Keep renderer tests focused and cheap:
- buffer composition
- diff generation
- HUD rendering snapshots where useful
Do not overinvest in renderer golden tests until the rendering API settles.
LAN multiplayer is not part of the MVP, but the architecture should preserve the right seams.
The host machine will later run the authoritative simulation.
Responsibilities of host later:
- receive commands from both players
- validate command timing
- advance simulation
- resolve collisions and damage
- distribute snapshots or state deltas
Responsibilities of client later:
- capture local input
- send commands to host
- render synchronized state
- command-driven simulation
- serializable state
- clear scene boundaries
- no renderer-owned gameplay state
- no keyboard-specific assumptions inside systems
Start with TCP sockets on a local network using a compact JSON protocol for simplicity.
Potential later protocol shape:
joinstart_matchinput_batchstate_snapshotstate_deltamatch_end
Binary encoding can wait until there is proof JSON becomes a bottleneck.
These do not need to be fully implemented immediately, but the code should lean toward them.
TerminalSessionRendererInputSourceTicker
SceneSimulationStepCommandBufferRunStateWorldState
CommandSourceSnapshotPublisherTransport
This is enough structure to guide implementation without overdesigning the project.
This project should favor:
- small pure functions in simulation code
- explicit data flow
- minimal inheritance
- descriptive type names
- content defined as typed objects
Avoid:
- giant god objects
- hidden mutable singleton state
- renderer logic mixed into gameplay rules
- deeply coupled scene and system code
When implementation begins, the first scaffolding pass should create:
- pnpm workspace and Nx configuration
apps/andpackages/layout with the core package boundaries- terminal session abstraction and engine loop package
- screen buffer and simple ANSI renderer package
- local keyboard input package
- game-core package with scene and state contracts
- one runnable CLI app with an empty arena render
That will establish the full technical spine before game content grows.
- project scaffold
- main loop
- terminal session
- buffer renderer
- player movement in arena
- primary fire
- projectiles
- collision
- enemy spawn plumbing
- waves
- scoring
- game over
- title scene
- dash
- special
- upgrades
- elite fights
- boss
- feedback refinement
- persistence polish
- performance cleanup
The architecture should be disciplined but not ceremonial.
The right approach is:
- isolate engine concerns
- keep simulation data-oriented
- render from state only
- treat commands as the seam between input and gameplay
- leave clear space for a future LAN host/client model
If this structure is followed, the project can stay simple in the single-player phase without becoming disposable when multiplayer work begins later.