A modular Python framework for building AI agents that compete in the Kaggle PTCG AI Battle Challenge Simulation, powered by the cabt engine.
Platform note: the cabt engine's native library (
libcg.so) is Linux-only. All testing must be done inside a Kaggle notebook. See the Testing section.
PokemonBattle/
├── submission.py # Arena entry point — imports from ptcg/
├── CLAUDE.md # AI assistant context and development guidelines
├── notebooks/
│ └── test_agents.ipynb # Kaggle notebook for testing agents
├── scripts/
│ └── build_submission.py # Generates self-contained submission.py
├── setup.py
├── requirements.txt
├── docs/
│ ├── cabt_api_reference.md # Engine API: observation structure, data classes, enums, functions
│ └── ptcg_ruleset.md # TCG rules + known simulator deviations from official rules
└── src/ptcg/
├── agent_base.py # Abstract base class all agents inherit from
├── observation.py # Stateless helpers for reading the observation dict
├── game.py # Wrappers around the kaggle_environments / cabt API
├── card_db.py # Loads card and attack metadata from the C library
├── agents/
│ ├── random_agent.py # Baseline: picks actions uniformly at random
│ ├── rule_based_agent.py # Priority rule-list agent
│ └── inspector_agent.py # Dev tool: records options for schema inspection
└── rules/
├── rule.py # Abstract Rule base class
├── schema.py # OptionType / CardType enums and type-check helpers
├── damage.py # Effective damage formula (weakness × 2, resistance − 30)
├── basic_rules.py # Rule implementations
└── fallback.py # RandomFallback — always matches, picks randomly
| Doc | Purpose |
|---|---|
docs/cabt_api_reference.md |
Full engine API: observation dict shape, all data classes and enums, game/api/sim functions, deck and submission formats. |
docs/ptcg_ruleset.md |
Pokemon TCG rules (turn structure, combat, status conditions, win conditions) plus a Simulator Differences section covering known deviations from official rules. |
pip install -r requirements.txt
pip install -e .The cabt engine calls your agent in two phases each game:
- Deck selection (
obs["select"]isNone) — return your 60-card deck aslist[int].BaseAgent.__call__handles this automatically viaget_deck(). - Every turn (
obs["select"]is set) — return action indices fromobs["select"]["option"].
observation dict
├── "logs" — list of past game events
├── "current" — full board state (None during deck-selection phase)
│ └── "players" — list of two PlayerState dicts
│ ├── "active" — active Pokémon (list of 0-1, may be None)
│ ├── "bench" — benched Pokémon (up to 5)
│ ├── "hand" — cards in hand (visible only to owner)
│ ├── "handCount" — number of cards in hand
│ ├── "prize" — prize cards (face-down shown as None)
│ ├── "deckCount" — remaining cards in deck
│ ├── "discard" — discard pile
│ ├── "poisoned", "burned", "asleep", "paralyzed", "confused" — bools
└── "select" — available choices (None during deck-selection phase)
├── "option" — list of available actions
└── "maxCount" — how many indices the agent must return
Abstract base class every agent must subclass.
| Method | When called | Must return |
|---|---|---|
get_deck() |
Deck-selection phase | list[int] — 60 card IDs |
select_action(obs) |
Every turn | list[int] — action indices |
on_game_start() |
Before first turn | Nothing |
on_game_end(result) |
After last turn | Nothing |
from ptcg import observation as obs_utils
obs_utils.get_options(obs) # available choices
obs_utils.get_max_count(obs) # how many to pick
obs_utils.get_active_pokemon(obs, 0) # your active Pokémon
obs_utils.get_bench(obs, 0) # your bench
obs_utils.get_hand(obs, 0) # your hand
obs_utils.get_prize_cards(obs, 0) # your prize cards
obs_utils.get_deck_count(obs, 0) # remaining deck size
obs_utils.get_status_conditions(obs, 0) # {"poisoned": bool, ...}File: src/ptcg/agents/random_agent.py
Every turn, picks maxCount actions uniformly at random from the available
options. No game knowledge is used. Useful as a baseline to measure how much
better a smarter agent performs.
File: src/ptcg/agents/rule_based_agent.py
Evaluates a fixed list of rules in priority order every turn. The first rule
whose matches() condition is satisfied fires and its select() result is
returned. RandomFallback is always last, so the agent never fails to act.
Rules require the card and attack database (card_db) to perform card-aware
decisions. The database is loaded from the C library on on_game_start().
On macOS (where the library is unavailable), rules that depend on it silently
skip and fall through to RandomFallback.
| Priority | Rule | Fires when | Selects |
|---|---|---|---|
| 1 | SelectBestAttack |
Attack options are available and card DB is loaded | Best attack by effective damage — see ranking below |
| 2 | EvolveIfBeneficial |
An evolution raises HP or cures a status, and its best attack is not weaker | The evolution with the highest HP |
| 3 | SearchForEnergy |
Active has no energy attached and an energy card is playable from hand | The energy card |
| 4 | AttachEnergyToActive |
Attach option exists, energy not yet attached this turn, active not almost-dead without a KO opportunity (unless Stage 2) | The first attach option |
| 5 | PlayPokemonToBench |
Bench has space and a basic Pokémon is in hand | The basic Pokémon with the highest HP |
| 6 | RetreatIfLowHP |
Active HP ≤ 30 and retreat option exists | The first retreat option |
| 7 | RandomFallback |
Always — catches anything above misses | A random valid action |
Every legal attack is scored on a six-element tuple; higher tuple = better choice.
| Rank | Criterion | How |
|---|---|---|
| 1 | Wins the game | KO + exactly 1 prize card remaining |
| 2 | Knocks Out | Effective damage ≥ opponent remaining HP |
| 3 | Hits weakness, in range next turn | Hits weakness AND remaining HP after hit ≤ best effective damage we can deal |
| 4 | Highest effective damage | effective = base × 2 if weakness, effective − 30 if resistance |
| 5 | Prefer non-resisted | Tiebreaker when effective damage is equal |
| 6 | Lower energy cost | Fewest energies in the attack's cost list |
Weakness and resistance are read from all_card_data() card records for the specific attacker and defender. No hard-coded type chart is used.
Blocked when all three conditions hold:
- Active HP ≤ 30
- No attack KOs the opponent this turn
- Active is not a Stage 2 Pokémon
Blocked if the evolution's best attack has lower damage or higher energy cost than the current form's best attack.
File: src/ptcg/agents/inspector_agent.py
Plays randomly while recording the raw observation at every turn. Use it to
discover the actual structure of obs["select"]["option"] on Kaggle before
implementing or tuning rules.
After a battle, call inspector.print_options(max_turns=5) to print the
options seen in the first N turns, including the chosen indices and the full
option dict for each available action.
- Create
src/ptcg/agents/my_agent.py. - Subclass
BaseAgentand implement all four methods. - Add
"MyAgent"toAGENTSin the notebook to test it.
from ptcg.agent_base import BaseAgent
from ptcg import observation as obs_utils
_DECK: list[int] = [...] # 60 card IDs
class MyAgent(BaseAgent):
def get_deck(self) -> list[int]:
return _DECK
def on_game_start(self) -> None:
pass
def on_game_end(self, result: dict) -> None:
pass
def select_action(self, obs: dict) -> list[int]:
options = obs_utils.get_options(obs)
max_count = obs_utils.get_max_count(obs)
return ... # your strategy hereThe file name must be the snake_case of the class name:
MyAgent → my_agent.py, RandomAgent → random_agent.py.
A deck is a list of exactly 60 integers (card IDs). The default deck is
already set in _DECK inside each agent file using the engine's bundled
example. To build your own, check the card IDs printed in the notebook's
"Available deck" cell and the competition's starter notebooks on Kaggle.
Once you have your 60 IDs, update _DECK in your agent file.
All testing must be done inside a Kaggle notebook — cabt is pre-installed there.
Open notebooks/test_agents.ipynb, set your GitHub URL, edit AGENTS, and run all cells.
The notebook will:
- Clone this repo and install the package
- Print the available card IDs from the default deck
- Run every agent in
AGENTSagainst every other agent - Print a results summary
pip install kaggleGo to https://www.kaggle.com/settings → API → Create New Token, then:
mkdir -p ~/.kaggle
mv ~/Downloads/kaggle.json ~/.kaggle/kaggle.json
chmod 600 ~/.kaggle/kaggle.jsonVisit the competition page and click Join Competition.
Open scripts/build_submission.py and set AGENT_CLASS and AGENT_MODULE
to the agent you want to submit:
AGENT_CLASS = "RandomAgent"
AGENT_MODULE = "ptcg.agents.random_agent"python scripts/build_submission.pyThis reads your agent class and generates a self-contained submission.py
with no local imports — exactly what the competition arena expects.
kaggle competitions submit pokemon-tcg-ai-battle \
-f submission.py \
-m "your description"kaggle competitions submissions pokemon-tcg-ai-battle
kaggle competitions leaderboard pokemon-tcg-ai-battle- Competition: https://www.kaggle.com/competitions/pokemon-tcg-ai-battle/overview
- cabt engine API: https://matsuoinstitute.github.io/cabt/
- Kaggle CLI docs: https://github.com/Kaggle/kaggle-cli/blob/main/docs/competitions.md