Constraints first, then design. Before proposing any solution, identify the hard constraints (language semantics, type system, inheritance, runtime behavior). If the approach conflicts with a constraint, don't propose it. Zero band-aid attempts — if it doesn't fit cleanly, the design is wrong. Redesign, don't force.
Measure before deducing. When debugging, add one targeted diagnostic and look at the data. Don't build chains of reasoning from assumptions about what the code "should" do. If the first theory doesn't match observations, measure — don't generate more theories from the same unverified premises.
Fix at the right layer. Don't patch symptoms. If a fix requires callers to know implementation details, it's at the wrong layer. If the same pattern needs 3+ special cases, the abstraction is wrong.
C.H.O.M.P. (Credibly Hackable On-chain Monster PvP) is an on-chain turn-based PvP battling game inspired by Pokemon Showdown and M.U.G.E.N. Built on Solidity using the Foundry framework, it features an extensible battle engine where users can create custom moves, monsters ("mons"), effects, abilities, and hooks.
License: AGPL-3.0 Solidity version: 0.8.34
forge install # Install dependencies (forge-std)
forge build # Compile contracts
forge test # Run all tests
forge test -vvv # Run tests with verbose outputchomp/
├── src/ # Solidity source contracts
│ ├── Engine.sol # Core battle engine (main entry point)
│ ├── IEngine.sol # Engine interface
│ ├── Structs.sol # All shared data structures
│ ├── Enums.sol # All shared enums (Type, MoveClass, EffectStep, etc.)
│ ├── Constants.sol # Global constants (move indices, defaults, sentinel values)
│ ├── DefaultValidator.sol # Validates game rules (team sizes, move legality, timeouts)
│ ├── DefaultRuleset.sol # Configures initial global effects for battles
│ ├── IValidator.sol # Validator interface
│ ├── IRuleset.sol # Ruleset interface
│ ├── IEngineHook.sol # Hook interface for battle lifecycle events
│ ├── abilities/ # Ability interface (IAbility.sol)
│ ├── commit-manager/ # Commit-reveal scheme for simultaneous moves
│ │ ├── DefaultCommitManager.sol
│ │ ├── SignedCommitManager.sol # EIP-712 signed commits
│ │ └── ICommitManager.sol
│ ├── cpu/ # AI opponents (CPU players)
│ │ ├── CPU.sol # Base CPU
│ │ ├── BetterCPU.sol # Smarter AI
│ │ ├── OkayCPU.sol, RandomCPU.sol, PlayerCPU.sol
│ │ └── ICPU.sol
│ ├── effects/ # Effect system (status effects, stat boosts, battlefield)
│ │ ├── IEffect.sol # Effect interface with lifecycle hooks
│ │ ├── BasicEffect.sol
│ │ ├── StaminaRegen.sol
│ │ ├── StatBoosts.sol
│ │ ├── status/ # Status effects (Burn, Frostbite, Panic, Sleep, Zap)
│ │ └── battlefield/ # Battlefield effects (Overclock)
│ ├── gacha/ # Gacha system for mon ownership
│ ├── hooks/ # Engine hooks (BattleHistory)
│ ├── lib/ # Utility libraries (ECDSA, EIP712, Ownable, etc.)
│ ├── matchmaker/ # Battle matchmaking
│ │ ├── DefaultMatchmaker.sol # Propose/accept/confirm flow
│ │ └── SignedMatchmaker.sol # EIP-712 signed matchmaking
│ ├── mons/ # Individual mon implementations (one dir per mon)
│ │ ├── <monname>/ # Lowercase dir: 4 move .sol files + 1 ability .sol (+ optional libs)
│ │ ├── aurox/ # e.g. BullRush.sol, GildedRecovery.sol, IronWall.sol, UpOnly.sol, ...
│ │ ├── embursa/ # e.g. HeatBeacon.sol, SetAblaze.sol, Tinderclaws.sol, ...
│ │ └── ... # See drool/mons.csv for full roster
│ ├── moves/ # Move system
│ │ ├── IMoveSet.sol # Move interface
│ │ ├── StandardAttack.sol # Base attack implementation
│ │ ├── StandardAttackFactory.sol
│ │ ├── StandardAttackStructs.sol # ATTACK_PARAMS struct
│ │ └── AttackCalculator.sol # Damage calculation
│ ├── rng/ # Randomness oracle interface
│ ├── teams/ # Team and mon registry
│ │ ├── ITeamRegistry.sol
│ │ ├── DefaultTeamRegistry.sol
│ │ ├── GachaTeamRegistry.sol
│ │ ├── LookupTeamRegistry.sol
│ │ ├── IMonRegistry.sol
│ │ └── DefaultMonRegistry.sol
│ └── types/ # Type effectiveness calculator
├── test/ # Foundry test suite
│ ├── abstract/BattleHelper.sol # Shared test helper (battle setup, commit-reveal)
│ ├── mocks/ # Mock contracts for testing
│ ├── effects/ # Effect-specific tests
│ ├── mons/ # Per-mon integration tests
│ ├── moves/ # Move system tests
│ ├── EngineTest.sol # Core engine tests
│ ├── EngineGasTest.sol # Gas benchmarks
│ └── *.sol # Other test files
├── script/ # Foundry deployment scripts
│ ├── EngineAndPeriphery.s.sol # Deploy engine + periphery contracts
│ ├── SetupMons.s.sol # Deploy all mons (auto-generated by processing/)
│ ├── SetupCPU.s.sol # Deploy CPU players
│ └── Surgery.s.sol # Maintenance/upgrade script
├── processing/ # Python build scripts
│ ├── buildAll.py # Master orchestrator (sprites, validation, codegen)
│ ├── generateSolidity.py # Generate SetupMons.s.sol from CSV data
│ ├── validateMoves.py # Validate move contracts match CSV data
│ ├── deploy.py # Full deployment pipeline orchestrator
│ ├── buildTypeChart.py # Build type effectiveness chart
│ ├── createAddressAndABIs.py # Extract deployed addresses + ABIs
│ ├── generateMonsTypeScript.py # Generate TypeScript mon data
│ ├── createMonSpritesheets.py # Generate mon spritesheets
│ ├── createAttackSpritesheets.py # Generate attack spritesheets
│ ├── inputToEnv.py # Parse forge output to .env
│ └── removeUnusedImports.py # Clean up unused Solidity imports
├── transpiler/ # Solidity-to-TypeScript transpiler (Python)
│ ├── sol2ts.py # Main entry point
│ ├── lexer/ # Tokenizer
│ ├── parser/ # AST construction
│ ├── type_system/ # Type registry
│ ├── codegen/ # TypeScript code generation
│ ├── runtime/ # TypeScript runtime library
│ ├── dependency_resolver/ # Dependency resolution
│ └── test/ # TypeScript integration tests (vitest)
├── drool/ # Game data (CSV) and frontend assets
│ ├── mons.csv # Mon stats (HP, attack, speed, types, etc.)
│ ├── moves.csv # Move definitions (power, stamina, accuracy, etc.)
│ ├── abilities.csv # Ability definitions
│ ├── types.csv # Type chart data
│ ├── imgs/ # Sprites (front/back/mini GIFs, spritesheets)
│ └── *.js, *.css, index.html # Data viewer/analysis web app
├── docs/ # Design docs and notes
├── snapshots/ # Foundry gas snapshots (JSON)
├── lib/ # Git submodules (forge-std)
└── foundry.toml # Foundry configuration
- Matchmaking: Players propose and accept battles via
DefaultMatchmakerorSignedMatchmaker - Battle Start:
Engine.startBattle()initializes battle state, validates teams viaIValidator - Turn Loop (commit-reveal):
- Player 0 commits a hash of their move
- Player 1 reveals their move
- Player 0 reveals their preimage
Engine.execute()resolves the turn
- Turn Resolution:
- Priority determines move order (higher priority goes first; speed breaks ties)
- Each player's move is executed (damage, effects, switches)
- Effects run at their lifecycle hooks (RoundStart, AfterDamage, RoundEnd, etc.)
- KO checks and forced switches
- Battle End: When all mons on one side are KO'd
| Interface | Purpose |
|---|---|
IEngine |
Core battle engine - state mutation, battle management |
IMoveSet |
Move contract - move(), priority(), stamina(), moveType(), moveClass() |
IEffect |
Effect lifecycle - onRoundStart(), onAfterDamage(), onRemove(), etc. |
IAbility |
Mon ability - activateOnSwitch() |
IValidator |
Game rule validation - teams, moves, timeouts |
IRuleset |
Initial battle configuration (global effects) |
ICommitManager |
Commit-reveal move management |
IMatchmaker |
Battle matchmaking validation |
ITeamRegistry |
Team storage and retrieval |
IMonRegistry |
Mon data storage (stats, moves, abilities) |
IEngineHook |
Battle lifecycle hooks (OnBattleStart, OnRoundEnd, etc.) |
ICPU |
AI opponent interface |
IRandomnessOracle |
RNG source |
Moves implement IMoveSet. Most standard attacks extend StandardAttack, which takes ATTACK_PARAMS:
ATTACK_PARAMS({
BASE_POWER: 50,
STAMINA_COST: 2,
ACCURACY: 100,
PRIORITY: DEFAULT_PRIORITY, // DEFAULT_PRIORITY = 3
MOVE_TYPE: Type.Fire,
EFFECT_ACCURACY: 30,
MOVE_CLASS: MoveClass.Physical,
CRIT_RATE: DEFAULT_CRIT_RATE, // 5
VOLATILITY: DEFAULT_VOL, // 10
NAME: "Tinderclaws",
EFFECT: IEffect(address(0))
})Custom moves implement IMoveSet directly for complex behavior.
Effects implement IEffect with a bitmap indicating which lifecycle steps they run at:
OnApply,RoundStart,RoundEnd,OnRemoveOnMonSwitchIn,OnMonSwitchOutAfterDamage,AfterMove,OnUpdateMonState
Effects can be per-mon (local) or global (battlefield-wide). The StaminaRegen effect is a global default that regenerates 1 stamina per turn.
16 types: Yin, Yang, Earth, Liquid, Fire, Metal, Ice, Nature, Lightning, Mythic, Air, Math, Cyber, Wild, Cosmic, None. Type effectiveness is calculated by ITypeCalculator.
BattleDataandBattleConfigare stored per battle key (derived from player addresses)MonStatetracks deltas from base stats (hpDelta, staminaDelta, etc.)- Effects stored in per-mon mappings with stride-based indexing (64 slots per mon)
- Heavy use of bit packing for gas efficiency (KO bitmaps, effect counts, active mon indices)
- Transient storage used for per-transaction state (
battleKeyForWrite,tempRNG)
- AGPL-3.0 license header on all files
- Pragma:
^0.8.0 - Imports: Use named imports (
import {Foo} from "path") -sort_imports = truein formatter - Optimizer: max runs (4294967295) with via-IR enabled
- Constants:
SCREAMING_SNAKE_CASE(though lint excludes this check) - Move indices: 0-3 for regular moves (stored +1 to avoid zero ambiguity), 125 = switch, 126 = no-op
- State sentinel:
CLEARED_MON_STATE_SENTINEL = type(int32).max - 1
Each mon lives in src/mons/<monname>/ (lowercase). A typical directory contains:
- 4 move contracts — one
.solfile per move,PascalCasematching the move name (e.g., "Bull Rush" →BullRush.sol) - 1 ability contract —
PascalCase.solmatching the ability name (e.g.,UpOnly.sol) - Optional library files — shared logic between moves in the same mon (e.g.,
NineNineNineLib.sol,HeatBeaconLib.sol)
Each mon has exactly one test file at test/mons/<MonName>Test.sol (PascalCase + "Test", e.g., AuroxTest.sol). Tests extend BattleHelper.
The CSV files in drool/ are the source of truth for mon stats, move parameters, and ability assignments. The Solidity contracts must match these values — run python processing/validateMoves.py to verify.
- Tests extend
BattleHelper(intest/abstract/) which provides:_startBattle(): Full battle setup with matchmaker propose/accept/confirm_commitRevealExecuteForAliceAndBob(): Execute a turn with commit-revealALICE=address(0x1),BOB=address(0x2)
- Per-mon tests in
test/mons/test specific move interactions - Mock contracts in
test/mocks/for isolated testing - Gas benchmarks in
EngineGasTest.solandInlineEngineGasTest.solwith JSON snapshots
When implementing new features or refactors, follow a test-first approach:
- Write tests that specify the desired behavior
- Run to verify the tests fail (confirming they test new behavior)
- Implement the changes
- Run to verify the tests pass
- Add mon stats to
drool/mons.csv(HP, Attack, Defense, SpAtk, SpDef, Speed, Types) - Add 4 moves to
drool/moves.csv(Name, Mon, Power, Stamina, Accuracy, Priority, Type, Class, etc.) - Add ability to
drool/abilities.csv(Name, Mon, Effect description) - Create directory
src/mons/<monname>/(lowercase, e.g.,src/mons/aurox/) - Implement 4 move contracts as
PascalCase.solfiles (see "Move Implementation Patterns" below) - Implement 1 ability contract as
PascalCase.sol(see "Ability Patterns" below) - Run
python processing/validateMoves.pyto validate contracts match CSV data - Run
python processing/buildAll.py --skip-spritesto regenerateSetupMons.s.sol - Add tests in
test/mons/<MonName>Test.solextendingBattleHelper
Choose the simplest pattern that fits the move's behavior:
1. Pure StandardAttack — constructor-only, no move() override. For straightforward damaging moves or simple effect-applying moves. Pass EFFECT + EFFECT_ACCURACY for probabilistic status application (e.g., 30% chance to burn):
contract Blow is StandardAttack {
constructor(IEngine _ENGINE, ITypeCalculator _TYPE_CALCULATOR)
StandardAttack(msg.sender, _ENGINE, _TYPE_CALCULATOR, ATTACK_PARAMS({
BASE_POWER: 70, STAMINA_COST: 2, ACCURACY: DEFAULT_ACCURACY,
PRIORITY: DEFAULT_PRIORITY, MOVE_TYPE: Type.Air,
EFFECT_ACCURACY: 0, MOVE_CLASS: MoveClass.Physical,
CRIT_RATE: DEFAULT_CRIT_RATE, VOLATILITY: DEFAULT_VOL,
NAME: "Blow", EFFECT: IEffect(address(0))
}))
{}
}2. StandardAttack + move() override — for moves with side effects after damage (recoil, self-switch, self-status, multi-hit). Call _move() which returns (int32 damage, bool crit), then add custom logic:
function move(...) public override {
(int32 damage,) = _move(battleKey, attackerPlayerIndex, attackerMonIndex, defenderMonIndex, rng);
if (damage > 0) {
ENGINE.dealDamage(attackerPlayerIndex, attackerMonIndex, selfDamage); // recoil
}
}Moves that require the player to select a target mon override extraDataType() to return ExtraDataType.SelfTeamIndex or ExtraDataType.OpponentNonKOTeamIndex.
3. Custom IMoveSet — for complex conditional moves (variable power, healing, stat manipulation, reading opponent state). Implement all 7 IMoveSet functions directly. Use AttackCalculator._calculateDamage() for damage. Store dependencies as immutable.
4. IMoveSet + BasicEffect hybrid — for moves that persist as effects across turns (traps, delayed damage, per-turn modifiers). Implement both interfaces in one contract. The move() function calls ENGINE.addEffect(playerIndex, monIndex, IEffect(address(this)), ...).
Shared libraries — when multiple moves in the same mon share logic, extract into a library contract in the same directory (e.g., NineNineNineLib.sol, HeatBeaconLib.sol).
Each mon has exactly one ability, implemented in its own PascalCase.sol file within the mon directory.
Pure IAbility — for one-time switch-in actions (deal damage, apply a stat boost). Implement name() and activateOnSwitch(). No persistent state. (e.g., PreemptiveShock, SaviorComplex)
IAbility + BasicEffect (most common) — for abilities with ongoing lifecycle effects. activateOnSwitch() registers address(this) as an effect on the mon, then hooks into effect lifecycle via getStepsBitmap(). Must override name() with override(IAbility, BasicEffect). Uses an idempotency guard to prevent duplicate registration:
function activateOnSwitch(bytes32 battleKey, uint256 playerIndex, uint256 monIndex) external {
(EffectInstance[] memory effects,) = ENGINE.getEffects(battleKey, playerIndex, monIndex);
for (uint256 i; i < effects.length; i++) {
if (address(effects[i].effect) == address(this)) return;
}
ENGINE.addEffect(playerIndex, monIndex, IEffect(address(this)), bytes32(0));
}For once-per-battle abilities (e.g., RiseFromTheGrave), use a globalKV flag instead of the effect-list check.
Effects fall into several categories depending on scope:
- Status effects (
src/effects/status/): ExtendStatusEffectwhich enforces one-status-per-mon via a KV flag. Shared across mons — deployed once, injected into moves via constructor parameters. (e.g.,BurnStatus,FrostbiteStatus,SleepStatus) - Battlefield effects (
src/effects/battlefield/): ExtendBasicEffect, usetargetIndex=2for global scope. (e.g.,Overclock) - Shared utility effects (
src/effects/): Deployed once, used by many contracts. (e.g.,StatBoostsfor stat modifiers,StaminaRegenfor per-turn recovery) - Mon-local effects (
src/mons/<monname>/): Abilities or move-effect hybrids that only apply to one mon. These live in the mon's directory, not insrc/effects/.
To implement a new effect:
- Extend
BasicEffect(orStatusEffectfor status conditions) - Override the relevant lifecycle hooks (
onRoundEnd,onAfterDamage, etc.) - Return a bitmap from
getStepsBitmap()indicating which hooks to call - Return
(updatedExtraData, removeAfterRun)from hooks — useextraData(bytes32) to carry state between turns (counters, degrees, flags) - Shared effects are injected into moves/abilities via constructor parameters at deploy time — there is no runtime effect registry
# Full build pipeline
python processing/buildAll.py [--skip-sprites] [--skip-validation] [--color]
# Individual scripts
python processing/validateMoves.py # Validate contracts vs CSV
python processing/generateSolidity.py # Generate SetupMons.s.sol
python processing/deploy.py --testnet # Full deployment (forge scripts + codegen)
python processing/deploy.py --mainnet # Production deploymentPython dependencies: numpy, pexpect, pillow (managed via uv, see pyproject.toml)
Converts Solidity contracts to TypeScript for local battle simulation:
# Transpile all contracts
python3 transpiler/sol2ts.py src/ -o transpiler/ts-output -d src --emit-metadata
# Run transpiler tests
cd transpiler && npm install && npx vitest runEngineAndPeriphery.s.sol- Engine, validators, commit managers, matchmakers, registriesSetupMons.s.sol- All mon contracts (moves, abilities)SetupCPU.s.sol- CPU players
GitHub Actions runs on pull requests (.github/workflows/main.yml):
forge buildforge test -vvv
| File | Purpose |
|---|---|
drool/mons.csv |
Mon stats: Id, Name, HP, Attack, Defense, SpAtk, SpDef, Speed, Type1, Type2, Flavor |
drool/moves.csv |
Move data: Name, Mon, Power, Stamina, Accuracy, Priority, Type, Class, DevDescription, UserDescription, InputType |
drool/abilities.csv |
Ability assignments: Name, Mon, Effect |
drool/types.csv |
Type effectiveness chart |
CSV-to-code mapping notes:
- Priority in
moves.csvis a signed offset fromDEFAULT_PRIORITY(3). So0= default,1= faster,-1= slower. - Power can be
?for variable-power custom moves (Tier 3/4 implementations) - InputType maps to
ExtraDataTypeenum:none→None,self-mon→SelfTeamIndex,opponent-mon→OpponentNonKOTeamIndex - Type2 is
"NA"for single-type mons
- If a move forces a switch before the other player acts, the new mon will still try to execute its move (Engine skips if stamina is insufficient)
- If an effect calls
dealDamage()and triggersAfterDamage, it can cause infinite loops - avoid dealing damage inonAfterDamagehooks - RNG reuse:
StandardAttackuses the same RNG for both accuracy and effect chance, making them correlated rather than independent - Malicious p0 can modify mon moves between commit and battle start - mitigate via team registry or adding move indices to integrity hash
MAX_BATTLE_DURATIONis 1 hour;TIMEOUT_DURATIONis configurable per validator
- Storage bit-packing throughout (BattleData, BattleConfig, KO bitmaps, effect counts)
- Batch context structs (
BattleContext,DamageCalcContext,ValidationContext) to reduce external calls / SLOADs - Effect step bitmaps avoid calling effects at steps they don't use
MappingAllocatorfor efficient storage slot management- Transient storage for per-call state to avoid unnecessary SLOADs/SSTOREs
- Optimizer runs set to max (4294967295) with via-IR for aggressive optimization