- Project Overview
- Technology Stack
- Repository Structure
- Dependency Graph
- Module Breakdown
- Domain Model
- Battle FSM
- Turn Resolution Pipeline
- Condition System
- Data Pipeline
- Build System
- Persistence & Session Model
- Known Issues & Technical Debt
- Planned Features (Not Implemented)
NullBattle is a turn-based battle simulator modeled after the Generation 3 Pokémon mechanics, written in C++20. It is structured as a CLI application backed by three independently compiled static libraries. The core battle logic is entirely decoupled from data loading and UI concerns.
The project is in active development. The turn resolution, damage calculation, and status condition systems are functional; STAB, type effectiveness, critical hits, and stat growth formulas are not yet applied.
| Component | Technology | Details |
|---|---|---|
| Language | C++20 | set_property(TARGET NullBattle PROPERTY CXX_STANDARD 20) |
| Build system | CMake 3.10+ | Multi-target with FetchContent |
| Build generator | Ninja | Configured via CMakePresets.json |
| Compiler | MSVC (cl.exe) |
Hot Reload via EditAndContinue (Debug) |
| JSON library | nlohmann/json 3.12.0 | Header-only, fetched via FetchContent |
| Data source | PokeAPI v2 | Offline — consumed only by Python scripts |
| IDE | Visual Studio 2022+ | .slnx solution format |
| Planned UI | Dear ImGui | Placeholder directory, not implemented |
| Planned testing | Catch2 | Not implemented |
NullBattle/
├── CMakeLists.txt # Root build — defines exe, FetchContent, subdirs
├── CMakePresets.json # Build presets: x64/x86 × Debug/Release
├── NullBattle.cpp # Entry point: main(), game loop, top-level orchestration
├── NullBattle.h # Root header — re-exports all exe-level includes
│
├── common/ # Cross-cutting utilities (compiled into the exe directly)
│ ├── LogQueue.cpp # Logging queue + file export
│ ├── UuidV4.cpp # RFC 4122 UUID v4 generator
│ ├── pokemon_print_helpers.hpp # Console UI declarations
│ └── pokemon_print_helpers.cpp # Console UI implementations
│
├── core/ # nullbattle_core static library — pure battle logic
│ ├── CMakeLists.txt
│ ├── battle/
│ │ ├── BattleAction.hpp # ActionType enum + BattleAction struct
│ │ ├── BattleState.hpp # BattleState enum (8 states) + BattleStateMap
│ │ ├── BattleStateMachine.hpp # FSM interface
│ │ ├── BattleStateMachine.cpp # FSM implementation
│ │ ├── TurnEngine.hpp # Turn resolution interface
│ │ ├── TurnEngine.cpp # Turn resolution implementation
│ │ ├── ConditionEngine.hpp # Battle condition engine interface
│ │ └── ConditionEngine.cpp # Battle condition engine implementation
│ └── models/
│ ├── Types.hpp # Types enum (19 values) + typeFromString()
│ ├── Move.hpp # MoveCategory enum + Move struct
│ ├── Pokemon.hpp # BattleCondition enum + Pokemon struct
│ ├── Team.hpp # Team struct — party[6] + active slot management
│ ├── Player.hpp # Player struct — id + Team + Bag
│ ├── Item.hpp # ItemType/HealEffect enums + Item struct
│ └── Bag.hpp # Bag struct — vector<Item>
│
├── data/ # nullbattle_data static library — JSON loading
│ ├── CMakeLists.txt # Defines DATA_PATH compile-time macro
│ ├── DataLoader.hpp
│ ├── DataLoader.cpp # Parses moves.json + pokemon.json
│ ├── models/
│ │ ├── PokemonTemplate.hpp # PokemonTemplate struct + build() factory
│ │ └── LearnsetEntry.hpp # LearnsetEntry DTO {move, level}
│ ├── moves.json # 372 moves (Gen 1–3)
│ ├── pokemon.json # 386 Pokémon with base stats and learnsets
│ └── typechart.json # 18×18 type effectiveness matrix (Gen 3)
│
├── ui/ # Placeholder for Dear ImGui (empty)
│ └── CMakeLists.txt
│
└── python/ # Offline data pipeline — not a runtime dependency
├── fetch_pokemon.py # PokeAPI fetcher → generates the project's JSON files
└── data/ # Local copies of generated JSONs (not versioned)
NullBattle.exe
│
├── nullbattle_core (static lib)
│ ├── No external dependencies
│ └── C++ STL only
│
├── nullbattle_data (static lib)
│ ├── nullbattle_core (PUBLIC link — consumers of data also get core)
│ └── nlohmann_json::nlohmann_json (PRIVATE — not propagated)
│
├── nlohmann_json::nlohmann_json (header-only, FetchContent)
│
└── common/ (source files compiled directly into the exe)
├── LogQueue.cpp
├── UuidV4.cpp
└── pokemon_print_helpers.cpp
Dependency direction is strictly one-way: core has zero knowledge of data, ui, or common. The data layer consumes core types and extends them with JSON hydration. common is a utility layer with no domain knowledge.
Files: NullBattle.cpp, NullBattle.h
The executable contains the application entry point and top-level orchestration. It owns the two Player instances and the BattleStateMachine, which holds references to both players (see BattleStateMachine.hpp:47-50 for ownership documentation).
Responsibilities:
main()— bootstraps data loading, session UUID, player init, and runs the game looprunPlayersActions()— CLI prompt loop for both players' input per turninitPlayers()— hardcoded team setup usingPokemonTemplate::build()forceSwitchActivePokemon()— prompts a player to select a replacement after KOsaveMatchResult()— serializes match outcome tomatch_<uuid>.json
The game loop (NullBattle.cpp:153-184) is a while (!battleEnd) driven by a switch over battleEngine.getState(). It handles four states directly: ACTION_TURN, ACTION_EXECUTING_TURN, SWITCH_AFTER_FAINT, and BATTLE_END. The remaining four FSM states (TEAM_SELECT, END_TURN, SWITCH_IN, USE_ITEM) are defined but not handled in the loop.
Compiled as a static library with no external dependencies beyond the C++ STL. This is the domain layer — it holds all battle logic and domain models.
| Type | Kind | Key Fields |
|---|---|---|
Pokemon |
struct | name, level, currentHP, types[2], stats[6], battleCondition, moves[4] |
Move |
struct | pp[2], power (optional), priority, accuracy, category, type, name |
Team |
struct | party[6] (fixed array), activePokemon (private index) |
Player |
struct | id, team: Team, bag: Bag |
Item |
struct | name, itemType, healEffect, healHP (optional) |
Bag |
struct | items: vector<Item> |
Types |
enum | 19 values (NONE through DRAGON); typeFromString() for JSON parsing |
BattleCondition |
enum class | NONE, BURNED, ASLEEP, PARALYZED, POISONED, FREEZED, CONFUSED, ATTRACTED |
MoveCategory |
enum | DEFAULT, PHYSICAL, SPECIAL |
stats[6] layout: [maxHP, Atk, SpAtk, Def, SpDef, Speed]. Accessor methods on Pokemon (attack(), speed(), etc.) index into this array.
moves[4] always has exactly 4 slots. pp is stored as std::array<int, 2> — [currentPP, maxPP].
power uses std::optional<int> — std::nullopt for status moves. Move::isDamageMove() returns power.has_value().
| Class | Responsibility |
|---|---|
BattleStateMachine |
Owns FSM state, stores player actions, delegates to TurnEngine |
TurnEngine |
Determines turn order, executes move/item/switch actions |
ConditionEngine |
Pre-turn action gating, move secondary effects, end-of-turn condition damage |
Compiled as a static library. Depends on nullbattle_core (PUBLIC) and nlohmann_json (PRIVATE).
DATA_PATH is a compile-time string macro defined in data/CMakeLists.txt:
target_compile_definitions(nullbattle_data PRIVATE
DATA_PATH="${CMAKE_SOURCE_DIR}/data/"
)This resolves file paths without hardcoding them in C++ source. Used at DataLoader.cpp:10 and DataLoader.cpp:56.
DataLoader has two public methods:
loadMoves()→std::map<std::string, Move>— key is the move's slug nameloadPokemon()→std::vector<PokemonTemplate>
PokemonTemplate is a factory struct. Its build(level, moveNames[4], movesMap) method (PokemonTemplate.hpp:18) produces a Pokemon instance with stats set directly from baseStats (no level-scaling formula applied).
LearnsetEntry is a plain DTO: {std::string move, int level}.
Compiled directly into the executable (listed in add_executable() at CMakeLists.txt:15).
| File | Responsibility |
|---|---|
LogQueue.cpp |
Logging queue with stdout echo + file export |
UuidV4.cpp |
RFC 4122 UUID v4 generation (std::mt19937_64) |
pokemon_print_helpers.cpp |
Console rendering: battle panel, move panel, HP bars, victory screen |
LogQueue is instantiated as a global at NullBattle.cpp:4 and passed by reference into all engines. It provides:
log(tag, text)— adds to queue and prints to stdoutgenerateLogFile(sessionId)— drains queue tolog_<uuid>.txtclearLog()— clears the queue
clearScreen() in pokemon_print_helpers uses the ANSI escape sequence \033[H\033[2J\033[3J.
Player
id: int
team: Team
party: std::array<Pokemon, 6> ← fixed 6 slots; unused slots are zero-valued
activePokemon: int ← private index into party[]
+ inBattle() → Pokemon&
+ countAlive() → int
+ switchActivePokemon(index)
+ hasAlivePokemon() → bool
bag: Bag
items: std::vector<Item>
Pokemon
name: std::string
level: unsigned int
currentHP: unsigned int
types: std::array<Types, 2> ← types[1] == Types::NONE for mono-type
stats: std::array<unsigned int, 6> ← [maxHP, Atk, SpAtk, Def, SpDef, Speed]
battleCondition: BattleCondition
moves: std::array<Move, 4>
Move
pp: std::array<int, 2> ← [currentPP, maxPP]
power: std::optional<int> ← nullopt for status moves
priority: unsigned int
accuracy: unsigned int ← 0 means always-hit
category: MoveCategory
type: Types
name: std::string
PokemonTemplate (data layer only — not a battle entity)
id: int
name: std::string
types: std::array<Types, 2>
baseStats: std::array<unsigned int, 6>
learnset: std::vector<LearnsetEntry>
+ build(level, moveNames[4], movesMap) → Pokemon
BattleStateMachine manages the currentState: BattleState field. The enum is defined in core/battle/BattleState.hpp.
| State | Description | Implemented |
|---|---|---|
TEAM_SELECT |
Players pick their starting team | No — skipped in init |
ACTION_TURN |
Both players submit their action for the turn | Yes |
ACTION_EXECUTING_TURN |
TurnEngine resolves and applies both actions |
Yes |
END_TURN |
Post-turn cleanup, check for KOs | Partial |
SWITCH_AFTER_FAINT |
Forced switch after a Pokémon faints | Yes |
SWITCH_IN |
Voluntary switch (not a faint) | Defined, not handled in loop |
USE_ITEM |
Item use action | Defined, not handled in loop |
BATTLE_END |
One team has no remaining Pokémon | Yes |
┌──────────────────────────────┐
│ ACTION_TURN │
│ (both players submit action)│
└──────────────┬───────────────┘
│ startExecutingTurn()
▼
┌──────────────────────────────┐
│ ACTION_EXECUTING_TURN │
│ (TurnEngine resolves turn) │
└──────┬───────────────┬───────┘
│ │
KO detected │ │ no KO
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ SWITCH_AFTER_ │ │ (check winner) │
│ FAINT │ └────────┬─────────┘
└────────┬────────┘ │
│ │ all alive
switch │ ▼
done │ ┌─────────────────┐
│ │ ACTION_TURN │
└───────────►│ (next turn) │
└─────────────────┘
(if all Pokémon on one team fainted)
▼
┌──────────────────────────────┐
│ BATTLE_END │
└──────────────────────────────┘
Player actions are stored as std::optional<BattleAction> (BattleStateMachine.hpp:54-55). A BattleAction is a simple struct: {ActionType type, int index}. ActionType values are: USE_MOVE, CHOOSE_POKEMON, USE_ITEM, RENDER.
TurnEngine is instantiated per-turn inside BattleStateMachine::executeTurnActions(), receiving the two stored BattleAction values and a LogQueue reference.
Priority is evaluated in order, stopping at the first differentiator:
1. Switch action always precedes a move action
(p2 switches + p1 moves → p2 goes first, and vice versa)
2. Move priority field
(Quick Attack priority=1 beats Tackle priority=0)
3. Speed stat
(higher speed acts first)
4. Speed tie → coin flip
std::uniform_int_distribution<int>(1, 2) on std::mt19937
Source: TurnEngine.cpp:20-60.
Each player's action is executed in the resolved order. The execution path depends on ActionType:
USE_MOVE (executeMoveAction) — TurnEngine.cpp:62-108:
1. move.canUseMove() → check currentPP > 0
2. conditionAllowsToAction() → pre-turn condition gate (paralysis, sleep, etc.)
3. accuracy roll → generateRandom(100) <= move.accuracy || accuracy == 0
4. damage calculation (if isDamageMove()):
dano = ((2 * level / 5 + 2) * power * attacker.attack() / defender.defense()) / 50 + 2
5. defender.receiveDamage(dano)
6. applyUsedMoveConditionIfApplicable(defender, move) → secondary effects
7. checkPostActionBattleCondition(attacker) → end-of-turn DoT
Damage formula is the Gen 3 base formula. Note: attacker.attack() always uses the physical Attack stat and defender.defense() always uses the physical Defense stat, regardless of MoveCategory. Special moves do not use Sp.Atk / Sp.Def yet.
USE_ITEM (executeItemAction) — TurnEngine.cpp:110-119:
Delegates to Item::healPokemonHP() or Item::healPokemonCondition() based on item.isAHealHpItem() / item.hasAConditionHealEffect().
CHOOSE_POKEMON (switchActivePokemon) — TurnEngine.cpp:121-123:
Delegates to Team::switchActivePokemon(index).
TurnEngine owns a std::mt19937 m_rng seeded with std::random_device{}() at construction. ConditionEngine has its own separate std::mt19937 instance. There is no shared RNG state between them.
ConditionEngine is composed inside TurnEngine (not injected — instantiated at TurnEngine.cpp:10). It exposes three public methods.
Called before executing a move. Returns false to block the action.
| Condition | Blocking logic | Notes |
|---|---|---|
PARALYZED |
(1 / generateRandom(4)) <= 25 |
Integer division bug — see §13 |
FREEZED |
(1 / generateRandom(5)) <= 20 |
Integer division bug |
ASLEEP |
(1 / generateRandom(3)) <= 33.3 |
Integer division bug |
ATTRACTED |
(1 / generateRandom(2)) <= 50 |
Integer division bug |
CONFUSED |
(1 / generateRandom(3)) <= 33.3 |
If blocked: self-damage with power=40, Physical formula |
Source: ConditionEngine.cpp:10-72.
Applied after damage lands (only if the defender survives — the call at TurnEngine.cpp:95 is inside the else branch of the dano > defender.currentHP check).
| Move type | Move name | Effect | Chance |
|---|---|---|---|
| FIRE | ember, fire-punch, flamethrower, fire-blast, fire-fang | BURNED | 10% |
| FIRE | scald, lava-plume, steam-eruption, ice-burn, searing-shot | BURNED | 30% |
| FIRE | sacred-fire | BURNED | 50% |
| FIRE | will-o-wisp | BURNED | 100% |
| ELECTRIC | thunder-shock, thunder-punch, bolt-strike, volt-tackle, thunderbolt | PARALYZED | 10% |
| ELECTRIC | spark, discharge, thunder | PARALYZED | 30% |
| ELECTRIC | zap-cannon | PARALYZED | 50% |
| ELECTRIC | stun-spore, glare | PARALYZED | 75% |
| ELECTRIC | thunder-wave, nuzzle | PARALYZED | 100% |
| ICE | ice-beam, ice-punch, blizzard, powder-snow, freeze-dry, ice-fang | FREEZED | 10% |
| NORMAL | body-slam, lick | PARALYZED | 30% |
| DRAGON | dragon-breath | PARALYZED | 30% |
The chance check uses (1.0 / seed) <= chance where seed is a random integer from generateRandom(100). For chance == 1.0, seed is hardcoded to 1.
Source: ConditionEngine.cpp:101-247.
| Condition | Damage per turn |
|---|---|
POISONED |
round(maxHP * 0.125) |
BURNED |
round(maxHP * 0.0625) |
FREEZED |
round(maxHP * 0.0625) (non-canonical — Gen 3 freeze deals no DoT) |
Source: ConditionEngine.cpp:74-99.
At startup, main() instantiates DataLoader(logQueue) and calls both load methods. The loaded data is passed into initPlayers() and then discarded — no data is kept alive in long-term storage beyond the Player instances.
DataLoader::loadMoves()
→ opens data/moves.json (via DATA_PATH macro)
→ iterates JSON object entries
→ for each entry: extract pp, priority, category (cast to MoveCategory enum),
accuracy, power (optional), type (typeFromString())
→ returns std::map<std::string, Move>
DataLoader::loadPokemon()
→ opens data/pokemon.json (via DATA_PATH macro)
→ iterates JSON array
→ for each entry: extract id, name, types[2], stats[6], learnset[]
→ returns std::vector<PokemonTemplate>
typechart.json is present in the data directory but is not loaded at runtime — it exists as a reference for future type effectiveness implementation.
python/fetch_pokemon.py fetches data from https://pokeapi.co/api/v2 and generates the three JSON files. This script is not part of the CMake build and must be run manually to refresh data.
moves.json — object keyed by move slug:
{
"tackle": { "name": "tackle", "pp": 35, "power": 40, "accuracy": 100,
"priority": 0, "category": 1, "type": "normal" }
}pokemon.json — array ordered by Pokédex ID:
[{
"id": 1, "name": "bulbasaur",
"types": ["grass", "poison"],
"stats": [45, 49, 65, 49, 65, 45],
"learnset": [{"move": "tackle", "level": 1}, ...]
}]typechart.json — 18×18 matrix (not currently consumed at runtime).
| Target | Type | Source files |
|---|---|---|
NullBattle |
Executable | NullBattle.cpp, common/*.cpp |
nullbattle_core |
Static lib | core/battle/*.cpp |
nullbattle_data |
Static lib | data/DataLoader.cpp |
nlohmann_json::nlohmann_json |
Interface (header-only) | FetchContent |
| Preset | Arch | Config | Generator |
|---|---|---|---|
x64-debug |
x64 | Debug | Ninja |
x64-release |
x64 | Release | Ninja |
x86-debug |
x86 | Debug | Ninja |
x86-release |
x86 | Release | Ninja |
# Configure
cmake --preset x64-debug
# Build
cmake --build out/build/x64-debugout/build/x64-debug/
NullBattle.exe
NullBattle.pdb # debug symbols
core/nullbattle_core.lib
data/nullbattle_data.lib
_deps/json-src/ # nlohmann/json unpacked by FetchContent
match_<uuid>.json # generated at runtime
log_<uuid>.txt # generated at runtime
/DWIN32 /D_WINDOWS /W3 /GR /EHsc /MDd /Ob0 /Od /RTC1 -std:c++20 -ZI
Hot Reload is enabled via CMake policy CMP0141 with EditAndContinue format for Debug configs (CMakeLists.txt:7-10).
Each run generates a session UUID via UuidV4::generate() (NullBattle.cpp:136). This UUID names both output files.
Written by saveMatchResult() (NullBattle.cpp:102-127) at BATTLE_END:
{
"sessionId": "62a004ad-2391-4560-ab2b-730fba5aca88",
"totalTurns": 9,
"winnerPlayer": {
"id": 2,
"partySize": 6
}
}Written by logQueue.generateLogFile(session) (NullBattle.cpp:178). Contains every log() call from all engines with their tag prefix:
[CoreEngine] Starting session 62a004ad-...
[DataLoader] Successfully loaded 386 Pokemon!
[BattleStateMachine] ...
[TurnEngine] Determining battle order...
[ConditionEngine] Pikachu is paralyzed. It won't let it use a move!
ConditionEngine.cpp:19-43 uses (1 / generateRandom(N)) <= threshold where the division is integer division. For any N > 1, 1 / N == 0. The comparison 0 <= 25 is always true, meaning every condition check always allows action — the blocking logic never fires. The intended behavior was (1.0 / generateRandom(N)) <= (threshold / 100.0).
LogQueue.cpp and UuidV4.cpp are #include-d in multiple headers (BattleStateMachine.hpp:12, TurnEngine.hpp, ConditionEngine.hpp, DataLoader.hpp) in addition to being listed in add_executable(). The #pragma once guard in LogQueue.cpp prevents ODR violations within a single translation unit, but this pattern is non-standard and fragile. The correct approach is to expose only headers and let the linker handle the symbols.
PokemonTemplate::build() copies baseStats directly into Pokemon::stats without applying the Gen 3 stat growth formula (((2 * base + iv + ev/4) * level / 100) + 5). All Pokémon at level 5 fight with their base stat values.
TurnEngine.cpp:83 always uses attacker.attack() (physical Atk) and defender.defense() (physical Def) regardless of MoveCategory. Special moves like Thunderbolt and Flamethrower do not use Sp.Atk / Sp.Def.
Same-Type Attack Bonus (1.5× multiplier when move type matches the attacker's type) is not implemented in the damage formula.
typechart.json is present but not loaded. The 2×/0.5×/0× effectiveness multipliers are not applied in damage calculation.
The Gen 3 critical hit mechanic (1/16 base chance, 2× damage) is not implemented.
Gen 3 applies a random factor of 85–100 divided by 100 to the final damage. This roll is absent — damage is always deterministic given the same stats and move.
ConditionEngine.cpp:17-65 has no cases for BURNED or POISONED. Burned/poisoned Pokémon are never action-gated (correct), but fall through to the default: allows = true branch, which is harmless.
NullBattle.cpp:155-183 handles only 4 of the 8 BattleState values. TEAM_SELECT, END_TURN, SWITCH_IN, and USE_ITEM have no case branches. If the FSM transitions to any of these states, the loop spins silently.
| Feature | Notes |
|---|---|
| STAB multiplier | 1.5× when move type == attacker type |
| Type effectiveness | Load typechart.json at runtime; apply multiplier in damage formula |
| Critical hits | Gen 3 formula: 1/16 base rate, stage system for high-crit moves |
| Random damage roll | rand * [85, 100] / 100 applied to final damage |
| Stat scaling by level | Gen 3 formula in PokemonTemplate::build() |
| Physical/Special split | Use Sp.Atk / Sp.Def when MoveCategory == SPECIAL |
| TEAM_SELECT state | Interactive team selection before battle |
| Dear ImGui UI | ui/ directory is a placeholder |
| Catch2 unit tests | No test infrastructure exists |
| Volatile conditions duration | Sleep (2–5 turns), confusion (2–5 turns), freeze thaw chance |
| Leech Seed | data/moves.json includes it; no implementation |
| Multi-turn moves | Hyper Beam recharge, Fly/Dig invulnerability frames |
| Ability system | No ability data or engine exists |
| Weather effects | No weather state in FSM |