Skip to main content

Python 3.10+ Pydantic v2 PEP 561 typed UV Work in Progress

EmpireCore

A fully typed Python client for Goodgame Empire

Installation • Quick Start • Services • Game State • Map Scanning • Errors • Contributing • Contact



What you get

Typed end to end Pydantic v2 models for every command, and a py.typed marker so your type checker actually sees them
Honest failures Typed exceptions from a single EmpireError base — no leaked pydantic or socket errors, and no empty list that secretly means "the request failed"
Thread-safe state A background thread applies server pushes while your code reads consistent snapshots
High-level services client.alliance, client.attack, client.castle, client.army, client.commanders, client.ranking, client.spy
Map scanning BFS kingdom discovery with cheap, targeted re-scans
Multi-account A pool that leases one logged-in client per account

Installation

uv add empire-core        # or: pip install empire-core

The experimental persistence layer needs an extra:

pip install "empire-core[storage]"
Developing on the library itself
git clone https://github.com/eschnitzler/EmpireCore.git
cd EmpireCore
uv sync --extra dev     # `dev` is an extra, not a default group:
                        # a plain `uv sync` leaves you without pytest/ruff/mypy
uv run pytest

Quick Start

from empire_core import EmpireClient

# The context manager disconnects and shuts the state worker down on any exit.
with EmpireClient(username="your_user", password="your_pass") as client:
    client.login()

    client.alliance.send_chat("Hello alliance!")

    for castle in client.castle.get_all():
        print(f"{castle.castle_name} at ({castle.x}, {castle.y})")

Without the with block, call client.close() yourself — skipping it leaks the receive thread and the state executor for the life of the process.

Services

Services are attached to the client automatically; there is nothing to wire up.

client.alliance

client.alliance.send_chat("Hello!")
client.alliance.help_all()

for entry in client.alliance.get_chat_log():
    print(f"{entry.player_name}: {entry.decoded_text}")

# Typed push subscription (detach again with remove_chat_message_callback)
client.alliance.on_chat_message(lambda msg: print(msg.decoded_text))

client.castle

castles = client.castle.get_all()

details = client.castle.get_details(castle_id=12345)
if details:                       # None when the response omits the castle
    print(f"Wood: {details.wood}, units: {details.units}")

resources = client.castle.get_resources(castle_id=12345)
if resources:
    print(f"Wood: {resources.wood}, Stone: {resources.stone}")

Also available: client.army, client.ranking and client.spy.

client.commanders

for commander in client.commanders.get_commanders():
    print(commander.commander_id, commander.name, commander.wins, commander.defeats)
    for item in commander.equipment():
        print("  ", item.equipment_id, item.slot, item.enchantment_level, item.is_permanent)

# The defensive counterparts come back from the same command.
castellans = client.commanders.get_castellans()

The server calls both kinds "lords" (command gli, field LID); the game UI calls them commanders and castellans, and so does this library.

client.attack

from empire_core import AttackWave, WaveFlank

commanders = client.commanders.get_commanders()

client.attack.send_attack(
    source_x=500,
    source_y=510,
    target_x=700,
    target_y=710,
    waves=[AttackWave(L=WaveFlank(U=[[487, 100]], T=[[301, 5]]))],
    commander_id=commanders[0].commander_id,
)

commander_id is required: every id get_commanders() returns leads an attack, 0 included, so there is no value that means "no commander". The server echoes the chosen one back, so CreateAttackResponse.leader says which commander it actually flew with.

Waves without units are dropped before sending, as the game client does, and passing feathers=True forces the horse field to -1 exactly as the client does. See examples/commanders_and_attack.py for a runnable version that dry-runs by default.

Filling waves

client.load_game_data()          # explicit: the items payload is a large download

commander = client.commanders.get_commanders()[1]
attack = client.attack.fill_attack(
    castle_id,
    target_x=624, target_y=247,  # a target is all it needs
    commander=commander,
)

client.attack.send_attack(
    source_x=castle.x, source_y=castle.y,
    target_x=624, target_y=247,
    waves=attack.waves, yard_wave=attack.yard,
    commander_id=commander.commander_id,
)

Coordinates are enough. From them it reads the target's area type and structures, the defenders each flank holds and the castellan holding it, the area effects that widen your flanks, your general's skills and your own legend and Hall of Legends skills. A camp's level comes from the victory count in its map row; a player's from the owner records beside it. Every one of those can be passed instead, and passing one skips the request that would have found it.

Each wave is sized the way the game sizes it, which is by the target owner's level rather than the attacker's: a level 13 castle holds far fewer troops than a level 70 one, whatever the attacker's level. Some targets defend at a level of their own - a monument is built for level 70 however low its owner is. On top come the commander's own equipment, its general's unit-limit skills, the Hall of Legends skills, and the legend skills when both sides are at the level cap.

Each flank takes tools first and then units, because a placed tool reduces the defense the units are then chosen against. Units are picked to counter whichever of the target's defenses is proportionally weaker; tools are picked to cancel the target's wall, gate, moat and defender bonuses in as few units as possible, and are skipped entirely where the commander's own reductions already erase them. A flank that ends up with tools but no units gives the tools back.

Fortification is per flank, not per castle: a defending tool raises only the flank it stands on, and only the middle flank meets the gate at all. Tools are also filtered by the target - many may only be carried against particular kingdoms and area types, or not against camps.

See examples/fill_waves.py for the whole path.

Alongside the waves comes the courtyard wave, the final assault that rides in the same request. It holds units only, is sized from both levels rather than the target's alone, and is filled against the defenders of the keep.

Game State

A background thread applies server pushes to client.state while your code reads it. Read through the accessors rather than touching the containers: each one takes the state lock and returns a snapshot, so nothing changes underneath you mid-iteration.

player = client.state.get_local_player()      # None until login completes
castles = client.state.get_castles()
attacks = client.state.get_incoming_attacks()
inventory = client.state.get_inventory()

Knowing whether state is fresh

Not every field is refreshed by every packet. Castle resources and units are often populated once at login and never again unless you ask — so state can be stale without being wrong. Check before trusting it:

if client.state.get_castle_last_updated(castle_id) is None:
    # Never refreshed: resources and units are defaults, not measurements.
    client.castle.get_details(castle_id)

Reacting to movements

def on_attack(movement):
    print(f"{movement.troop_count} troops from {movement.source_player_name}, "
          f"{movement.time_remaining}s out")

def on_arrived(movement_id, movement):
    print(f"{movement_id} arrived: {movement}")

client.state.on_incoming_attack(on_attack)
client.state.on_movement_arrived(on_arrived)

Arrival and recall callbacks also accept a single-argument (movement_id) form, but the movement is removed from state before they run, so the id alone can no longer be resolved — prefer the two-argument form above.

Map Scanning

Scan a kingdom for castles, outposts and capitals. A full scan uses BFS discovery from your castle's position and can take a few minutes:

from empire_core import Kingdom, MapItemType

result = client.scan_kingdom(Kingdom.GREEN, item_types=[MapItemType.CASTLE])
print(f"{len(result.items)} items, {len(result.failed_chunks)} failed chunks")

chunk_delay (default 0.2s) paces the requests — the server drops connections that sustain a high request rate, so don't lower it for long-running scans unless you know the server tolerates it.

Re-scanning cheaply. result.content_chunks lists the chunks that held items. Feed it back into scan_chunks() to re-scan a known region without paying for BFS discovery again (roughly a third fewer requests), and run a full scan_kingdom() periodically to pick up content in previously-empty chunks:

discovery = client.scan_kingdom(Kingdom.GREEN, item_types=[MapItemType.CASTLE])

fresh = client.scan_chunks(
    Kingdom.GREEN, list(discovery.content_chunks), item_types=[MapItemType.CASTLE]
)

For very frequent scans, split content_chunks across several logged-in accounts (interleaved slices chunks[i::n]) and run the scan_chunks() calls concurrently — per-account request rate is what the server limits.

Multiple Accounts

AccountPool hands out one logged-in client per account and refuses to lease the same account twice. Prefer leased(): it releases the account and closes the client even if your code raises.

from empire_core import AccountPool, PoolExhaustedError

pool = AccountPool()
try:
    with pool.leased(tag="scanning") as client:
        result = client.scan_kingdom()
except PoolExhaustedError:
    ...   # no candidate account was free

Accounts come from accounts.json plus every EMPIRE_ACCOUNT_* environment variable. A .env file is read only if you opt in with accounts.load(load_env_file=True) — importing the library never mutates your environment. See examples/account_pool.py.

Protocol Models

For lower-level access, use the protocol models directly:

from empire_core.protocol.models import (
    AllianceChatMessageRequest,
    GetCastlesRequest,
)

request = AllianceChatMessageRequest.create("Hello 100%!")
packet = request.to_packet()
# -> "%xt%EmpireEx_21%acm%1%{"M": "Hello 100%!"}%"

client.send(request)                        # fire and forget
response = client.send(GetCastlesRequest(), wait=True)   # or await the reply

Error Handling

Calls that wait for a response raise typed exceptions instead of returning None, so a timeout, a dropped connection and a server-side rejection are distinguishable. All inherit from EmpireError.

from empire_core import CommandError, ConnectionClosedError, EmpireTimeoutError

try:
    castles = client.castle.get_all()
except CommandError as e:
    print(f"rejected: {e.command} code {e.code}")   # non-zero server error code
except EmpireTimeoutError:
    ...                                             # no response in time
except ConnectionClosedError:
    ...                                             # dropped while waiting

EmpireTimeoutError also subclasses the builtin TimeoutError. Action helpers (e.g. client.castle.select()) return bool — False means the server rejected the action, while transport failures still raise.

Two more you will meet: NetworkError from connect() and the CDN-backed helpers, and PacketError when a response cannot be parsed. Catching EmpireError covers every one of them — the library does not leak pydantic.ValidationError or raw socket exceptions past its own API.

An empty collection therefore always means "nothing there", never "the lookup failed": get_active_events() and get_troop_ids() raise on a CDN outage rather than return empty. Where an exact answer depends on data that may be missing, ask first:

from empire_core import troop_data_available

if not troop_data_available():
    ...   # troop counts would include equipment; treat them as approximate

Contributing

See CONTRIBUTING.md for adding protocol commands and services, model conventions, and testing guidelines.

Contact

Bugs and feature requests are best filed as issues.

Architecture

empire_core/
├── client/          # EmpireClient — main entry point, map scanner
├── network/         # WebSocket connection, receive loop, redaction
├── protocol/
│   ├── models/      # Pydantic request/response models per command
│   └── packet.py    # Low-level frame parsing
├── services/        # High-level APIs attached to the client
├── state/           # Thread-safe game state and world models
├── storage/         # Experimental persistence (optional extra)
└── utils/           # Enums, CDN-backed event and troop data

Design notes live in docs/design/.


For educational purposes only. Use responsibly.

Release files for empire-core 0.35.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for empire-core 0.35.2
File Size Uploaded
empire_core-0.35.2.tar.gz 484.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for empire-core 0.35.2
File Interpreter ABI Platform
empire_core-0.35.2-py3-none-any.whl Python 3 none any Details

Total release size: 691.5 kB

Release files / empire_core-0.35.2.tar.gz

Download URL empire_core-0.35.2.tar.gz
Size 484.0 kB
Tags Source
SHA-256 checksum
How to use checksums
ee815b1ed94c80f983c4729cd915c39ace336c10cd68c5a7da3e8efa34d39076
BLAKE2b-256 checksum
How to use checksums
cfaaf05078b9c22b64a09563d5aba6acae906569690004d6cd540296285f9b12
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / empire_core-0.35.2-py3-none-any.whl

Download URL empire_core-0.35.2-py3-none-any.whl
Size 207.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cdd73a55000b79c6b690d8ac5c52d8a688eb7fbfc62e67bb5aa59c3ff9a35ea6
BLAKE2b-256 checksum
How to use checksums
721374ff5e141733f0f10db89bb5baf782131218ab21d6692453207c2ba5a044
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

0.36.0

2 release files

0.35.3

2 release files

This release

0.35.2 This release

2 release files

0.35.1

2 release files

0.35.0

2 release files

0.33.0

2 release files

0.32.1

2 release files

0.32.0

2 release files

0.31.0

2 release files

0.30.3

2 release files

0.30.2

2 release files

0.30.1

2 release files

0.30.0

2 release files

0.29.0

2 release files

0.27.0

2 release files

0.26.0

2 release files

0.25.3

2 release files

0.25.2

2 release files

0.25.0

2 release files

0.24.2

2 release files

0.24.1

2 release files

0.24.0

2 release files

0.23.1

2 release files

0.22.1

2 release files

0.20.0

2 release files

0.19.2

2 release files

0.19.1

2 release files

0.19.0

2 release files

0.18.0

2 release files

0.17.1

2 release files

0.17.0

2 release files

0.16.0

2 release files

0.15.0

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.12.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.3

2 release files

0.7.2

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.6

2 release files

0.6.5

2 release files

0.6.4

2 release files

0.6.3

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.5

2 release files

0.4.4

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page