Any Language

The catalogs and the formulas are TypeScript, and TypeScript does not run in Python, Rust, Go, C# or Java. TheTowerSDK ships a WebAssembly build so it does not have to: one file, loaded into a runtime you already have, running the same code this project ships to JavaScript.

No port of the numbers, and no service to host. Every catalog, all fifteen builders, 914 formulas, the number formatting and the save decoder run inside your own process.

Get The Module

thetowersdk.wasm is attached to each GitHub release. It is not in the npm package: it is about 6.6 MB, and a JavaScript project has no use for it — you already have the library.

curl -LO https://github.com/TmRxJD/TheTowerSDK/releases/latest/download/thetowersdk.wasm

The Protocol

One JSON object in on standard input, one JSON object out on standard output. That is the entire interface, chosen because every language already writes JSON and WASI's stdio is the one thing every runtime exposes identically.

{"op": "calc.run", "id": "thorns.damage", "input": {"baseThorns": 120, "wallThorns": 12}}

{"ok": true, "id": "thorns.damage", "input": {…}, "result": {…}}

Failures come back as {"ok": false, "error": "…"}, never as a trap — a trap reaches a host language as an abort with no message, which in Python or Go is close to unreadable. Ask {"op": "ops"} and the module lists what it carries, so discovery is part of the protocol rather than documentation that can fall out of date.

Python

pip install wasmtime, then instantiate the module and write to its stdin. Compile once and instantiate per call: the guest runs a main and exits, and compilation is the only slow part.

import json, tempfile
from pathlib import Path
from wasmtime import Engine, Linker, Module, Store, WasiConfig

engine = Engine()
module = Module.from_file(engine, "thetowersdk.wasm")
linker = Linker(engine)
linker.define_wasi()

def call(**request):
    with tempfile.TemporaryDirectory() as directory:
        work = Path(directory)
        (work / "in.json").write_text(json.dumps(request), encoding="utf-8")
        (work / "out.json").touch()

        config = WasiConfig()
        config.stdin_file = str(work / "in.json")
        config.stdout_file = str(work / "out.json")

        store = Store(engine)
        store.set_wasi(config)
        linker.instantiate(store, module).exports(store)["_start"](store)
        return json.loads((work / "out.json").read_text(encoding="utf-8"))

print(call(op="version"))
# {'ok': True, 'formulas': 914, 'calculators': 15, 'chartDatasets': 48}

print(call(op="calc.run", id="thorns.damage",
           input={"baseThorns": 120, "wallThorns": 12, "tier": 14})["result"]["atWallThorns"])

A fuller wrapper, with the convenience methods, is in the repository under wasm/examples/python/.

Other Languages

The three steps are the same everywhere: instantiate with WASI, point stdin at your request, read stdout.

LanguageRuntime
Pythonwasmtime (pip)
Rustwasmtime or wasmer
Gowazero — pure Go, no cgo
C# / .NETWasmtime.Dotnet
Java / Kotlinchicory or wasmtime-java
PHPwasm extension
Command linewasmtime thetowersdk.wasm < request.json

What It Can Do

{"op": "ops"}                       # everything below, from the module itself
{"op": "version"}                   # counts, read from the package

{"op": "data.list"}                 # every catalog
{"op": "data.get", "name": "LAB_CATALOG", "offset": 0, "limit": 100}
{"op": "data.find", "name": "LAB_CATALOG", "where": {"category": "Attack"}}

{"op": "calc.list"}                 # the builders
{"op": "calc.describe", "id": "module.cost"}
{"op": "calc.run", "id": "module.cost", "input": {"currentLevel": 1, "targetLevel": 20}}

{"op": "mechanics.list", "match": "thorn"}
{"op": "mechanics.call", "name": "thornDamageOnHit", "args": [{…}]}

{"op": "format", "value": 4770477147914}   # -> "4.77T"
{"op": "format", "value": "4.77T"}          # -> 4770000000000

{"op": "charts.list"}
{"op": "charts.rows", "id": "…"}

{"op": "save.decode", "base64": "…"}       # gunzipped playerInfo.dat bytes
{"op": "contributions"}

Lists are paged, and total is always the real count — so reading 100 rows never looks like reading all of them. calc.run returns the normalised input beside the result, because a value quietly replaced by a default is the difference between an answer to your question and an answer to a different one.

Saves

JSON has no byte type and the module has no zlib, so save.decode takes base64 of the already gunzipped file. Your language has gzip; shipping a second implementation inside the module would buy nothing.

import base64, gzip

with open("playerInfo.dat", "rb") as handle:
    raw = handle.read()

inflated = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw
print(call(op="save.decode", base64=base64.b64encode(inflated).decode()))
# {'ok': True, 'rootKeys': 611, 'runs': 30, 'sample': [...]}

Does It Agree With The Library?

It is tested to. A build that quietly disagrees with the TypeScript would be the exact failure this exists to prevent — the reason to ship a module at all is that re-implementing the numbers in another language guarantees two versions that drift.

The suite compares both builds on the same inputs, and it has caught three real differences. The JavaScript engine inside the module has no ICU, so formatGroupedNumber(4770477147914) returned the digits ungrouped where Node grouped them — both look like numbers. Then very large values printed in exponential notation. And atob is a browser API, so the save decoder was the one operation that could not run at all, while every other answer was correct.

Calculators → · Save Files → · Game Data →