A maintainable client mod changes the smallest useful surface. Prefer an event or a wrapper around one callable over shipping a replacement copy of a game module.
First milestone
By the end of this chapter, you should understand where a mod is placed, when its code runs, how a safe wrapper behaves, and how to confirm the package contains the correct path.
Five-minute loader check
Before adding hooks or UI code, confirm that the game can discover and execute one minimal mod file.
- Create a file named mod_hello.py.
- Place it inside the archive at scripts/client/gui/mods/mod_hello.py.
- Launch the client and wait until the hangar loads.
- Open game.log and search for [Hello Mod] loaded.
def init():
print '[Hello Mod] loaded'
def fini():
print '[Hello Mod] unloaded'
Most common reasons the loader check fails
- The archive contains an extra top-level folder before
res/scripts/.... - The file is named
mod_hello.py.txtbecause file extensions are hidden. - The compiled file or source file is placed in the wrong client-version directory.
- The package was rebuilt but the old archive is still installed.
- A Python exception appears earlier in
game.logand prevents the file from finishing.
What you need
- A Python runtime compatible with the client build you target. World of Tanks client scripts use Python 2.7-era semantics, but do not assume that one historical patch release is universally correct.
- Readable client source or decompiled scripts for the exact game version you are investigating.
- A clean test installation, access to the client logs, and a repeatable packaging process.
Where a mod is loaded
Authored client mods normally live below scripts/client/gui/mods and
use a filename beginning with mod_.
scripts/
└── client/
└── gui/
└── mods/
└── mod_example.py
Inside a packaged .wotmod, the archive includes the resource root:
res/
└── scripts/
└── client/
└── gui/
└── mods/
└── mod_example.pyc
Do not replace an original client script merely to change one method. A copied module freezes an entire old implementation and is much more likely to fail after an update.
Import, init, and fini
Top-level statements run when the module is imported. Keep import-time work limited to definitions and safe constants. Use loader lifecycle functions for owned setup and teardown.
_started = False
def init():
global _started
if _started:
return
_started = True
install_hooks()
subscribe_events()
def fini():
global _started
if not _started:
return
cancel_callbacks()
unsubscribe_events()
restore_owned_hooks()
_started = False
init() should be idempotent. fini() should undo only resources owned by
this mod and should remain safe after partial startup.
Wrap a function instead of replacing a module
Capture the callable currently installed, preserve its contract, and restore it only if your wrapper still owns the slot.
_original_method = None
_wrapper = None
def install_hook():
global _original_method, _wrapper
if _wrapper is not None:
return
_original_method = TargetClass.targetMethod
def wrapped(self, *args, **kwargs):
result = _original_method(self, *args, **kwargs)
after_original_call(self, result)
return result
wrapped.__mod_owner__ = 'example_mod'
_wrapper = wrapped
TargetClass.targetMethod = wrapped
def restore_hook():
global _original_method, _wrapper
if _wrapper is not None and TargetClass.targetMethod is _wrapper:
TargetClass.targetMethod = _original_method
_original_method = None
_wrapper = None
Call before
Use this when your state must exist before the client method runs.
Call after
Use this when you need the original result or fully initialized base state.
Replace deliberately
Skip the original only when suppressing or redefining its behavior is the explicit feature.
Private-looking methods
A leading underscore is a convention, not access control. Names beginning with two underscores
are name-mangled by Python. A method written as __method inside Avatar
is normally stored as _Avatar__method.
Use flexible arguments carefully
*args and **kwargs reduce breakage when optional parameters are added,
and forwarding them preserves the original call contract.
def wrapped(self, *args, **kwargs):
result = original(self, *args, **kwargs)
return result
They do not make a hook immune to updates. A renamed method, changed return type, reordered side effect, new ownership model, or removed call site can still invalidate the mod. Log failures and re-verify after every client update.
Schedule work through BigWorld
BigWorld.callback(delay, callable) schedules a callable on the game thread. Use
functools.partial when arguments must be bound, but keep the returned callback ID
so it can be cancelled.
import BigWorld
from functools import partial
_callback_id = None
_generation = 0
def schedule_refresh(vehicle_id):
global _callback_id
cancel_refresh()
token = _generation
_callback_id = BigWorld.callback(
0.25,
partial(_run_refresh, token, vehicle_id)
)
def _run_refresh(token, vehicle_id):
global _callback_id
_callback_id = None
if token != _generation:
return
refresh_vehicle(vehicle_id)
def cancel_refresh():
global _callback_id
if _callback_id is not None:
BigWorld.cancelCallback(_callback_id)
_callback_id = None
Prefer events when an event already owns the transition
Before patching a method, look for an existing event on the responsible controller, event bus, or
lifecycle object. Some legacy lifecycle events are exposed through modules such as gui.shared.personality,
but the correct source depends on the current client and feature.
def start():
lifecycle_events.onAvatarReady += _on_avatar_ready
def stop():
lifecycle_events.onAvatarReady -= _on_avatar_ready
def _on_avatar_ready(*args, **kwargs):
begin_battle_feature()
Do not rely on a function merely sharing an event name unless you have verified that the loader performs that discovery. Most event systems require an explicit subscription and the exact same handler identity for unsubscription.
Compile and package
Normal client packages commonly contain compiled Python bytecode. Compile with the interpreter expected by the target client, then preserve the directory structure inside the archive.
python -m compileall path/to/mod_example.py
- Remove stale bytecode before rebuilding.
- Confirm the generated filename and package path.
- Open the final archive and inspect its root rather than trusting the build script.
- Test startup, account switching, battle entry, battle exit, and client shutdown.
Beginner checkpoint
- The client launches without a new Python exception.
[Hello Mod] loadedappears once ingame.log.- You know which line runs at import, which runs in
init(), and which runs infini(). - You can remove the archive and return the client to its original state.
- You inspected the final archive and confirmed the exact internal path.
Minimal release checklist
- The mod does not replace a complete original client module.
- Initialization is safe when called twice.
- Every event, callback, view, hook, and worker has a teardown path.
- The original method receives all arguments and its return value is preserved unless replacement is intentional.
- Delayed work rejects stale account, arena, vehicle, or view contexts.
- The package contains the expected
res/scripts/client/gui/modspath. - The current game build has been tested rather than inferred from an old source mirror.
Next: build a complete production mod
Continue with Level 1: Configurable Garage Greeting. The production tutorial series turns this loader check into complete, working mods and grows from configuration and garage events into battle lifecycle, Gameface, persistence, local integration, analytics, and replay-aware telemetry.
Each level includes requirements, source-investigation pointers, fully annotated code, packaging, a test matrix, failure diagnosis, and maintenance guidance. Follow the levels in order if you are new; experienced readers can jump to the system they need.