Usage
This page is the full guide to building a Dota 2 addon with ktox-dota: the Gradle plugin that
drives the build, the type libraries (lua-types, panorama-types), the Panorama layout DSL, and
the ktox-dota-lib helper library.
Requirements
- JDK 21
- Gradle 8.x or later (Kotlin DSL recommended)
- Dota 2 with the Workshop Tools DLC installed (the Steam library is auto-detected)
The ktox-dota Gradle plugin
ktox-dota is a Gradle plugin for building Dota 2 addons and gamemodes using Kotlin — covering Lua
(VScript), Panorama JS/Sass, .dota.xml.kts layout scripts, and the addon's scripts/npc KeyValues.
Apply it in the root project of a multi-module addon build:
plugins {
id("com.isycat.ktox-dota") version "<version>"
}
dotaAddon {
projectName = "myaddon" // the dota_addons/<name> directory
}
Tasks
| Task | Description |
|---|---|
buildAddon |
Full build: transpiles Lua + Panorama, generates layouts, KV, and the client entry. |
syncAddon |
buildAddon + deploys everything into the live dota_addons/<name> directories. |
dev |
Live watch mode: initial full deploy, then per-file re-transpile + sync on every save. |
Project Structure
A ktox-dota project typically uses a multi-module structure to separate server-side (Lua) and
client-side (Panorama) code:
my-dota-project/
├── build.gradle.kts # Root build script — applies the ktox-dota plugin
├── settings.gradle.kts # Project and module definitions
├── addoninfo.txt # Addon manifest (map list, player counts)
├── content/ # Static Source 2 content — committed (see below)
│ ├── panorama/images/ # UI images
│ ├── materials/ models/ particles/ maps/ soundevents/
├── scripts/npc/ # Hand-written KeyValues bases (optional)
├── resource/ # Localization, e.g. addon_english.txt (optional)
├── lua/ # Server-side module (VScripts)
│ ├── build.gradle.kts
│ └── src/main/kotlin/ # Your Kotlin VScript sources
└── panorama/ # Client-side module (UI) — sources only
├── build.gradle.kts
├── src/main/kotlin/ # Your Kotlin UI sources
├── src/main/layout/ # XML and .dota.xml.kts layouts
└── src/main/styles/ # CSS, SASS, and SCSS styles
- Root project: applies the
ktox-dotaplugin, orchestrates the subprojects, and owns the addon-wide files —content/(static assets),scripts/npc/(KV bases),resource/(localization), andaddoninfo.txt.content/is at the root, not inside a module, because it holds the whole addon's Source 2 assets (models, materials, particles, maps, soundevents, and Panorama images) — shared across server and client. Its location is configurable viadotaAddon { addonContentDir }. :luamodule: server-side code, where you uselua-types. Compiled to Lua files that deploy into the game'svscriptsfolder.:panoramamodule: client-side UI (Kotlin sources only — nocontent/subdir), where you usepanorama-typesanddota-panorama-layout-dsl. Compiled to JavaScript and XML for the game'spanoramafolder:src/main/kotlin/: Kotlin UI logic.src/main/layout/: UI layout files (.xmland.dota.xml.kts).src/main/styles/: Stylesheets (.css,.sass,.scss).
This structure allows you to share common data models or utility classes between both modules if they are placed in a shared module.
Adding the library dependencies
Add the type libraries (and optionally the layout DSL and helper library) to the matching modules:
repositories {
mavenCentral()
}
dependencies {
// :lua module — Dota 2 VScript (server-side Lua) types
implementation("com.isycat.dota:lua-types:<version>")
// :panorama module — Dota 2 Panorama UI types
implementation("com.isycat.dota:panorama-types:<version>")
// :panorama module — Panorama layout DSL (.dota.xml.kts)
implementation("com.isycat.ktox:dota-panorama-layout-dsl:<version>")
// either module — opinionated helper wrappers
implementation("com.isycat:ktox-dota-lib:<version>")
}
Check the GitHub releases for the latest version.
Content layout: static vs generated
The repo's content/ directory holds ONLY committed static assets (maps, materials, images…) —
no build output, no .gitignore needed. All generated panorama output (transpiled JS, compiled
layouts, compiled styles) stages under build/ktox-dota/content/. Deploy merges the two trees
into the live content/dota_addons/<name>/; a static file at a generated path is ambiguous and
fails the build instead of silently picking one.
Generated scripts/npc KeyValues
Annotate a class in the Lua module with @AbilityKv, @ItemKv, @UnitKv, or @HeroKv and the build
generates the matching entry in npc_abilities_custom.txt / npc_items_custom.txt /
npc_units_custom.txt / npc_heroes_custom.txt (plus herolist.txt for heroes). The class name is
the entity key; a @Dota2Class ability/item gets its BaseClass and ScriptFile (the real transpiled
lua path) filled in automatically. Fields are typed — real Dota enums, numbers, per-level arrays — with
an extra escape hatch for anything unmapped:
@Dota2Class
@AbilityKv(
behavior = [DotaAbilityBehavior.NO_TARGET],
cooldown = [6.0],
manaCost = [75],
)
class WhirlingDeath : AbilityLua { /* … */ }
For KV that is data-driven rather than per-class (unit rosters, generated variants), mark a file
@file:KvSource and expose top-level val lists of AbilityKvSpec / ItemKvSpec / UnitKvSpec /
HeroKvSpec. The file is excluded from transpilation (it is build-time data, not shipped code —
see kotlinToLua { excludeFileAnnotations }) and its lists are evaluated at build time, so one Kotlin
roster can drive both the runtime logic and the KV.
Hand-written base files in scripts/npc (configurable via dotaAddon { npcBaseDir }) are the starting
point: generated entries are merged into the base file's root block, so static and generated KV
coexist; base-only files deploy verbatim.
Precache blocks
Every generated entity carries an engine-native "precache" sub-block: resources referenced by its own
spec values (.vmdl → model, .vpcf → particle, .vsndevts → soundfile) are collected automatically,
so PrecacheUnitByName precaches the entity's assets with it. Assets used at runtime that no KV value
mentions (a cast particle, a custom sound bank) go in the spec's precache list:
@AbilityKv(
// …
precache = ["particles/units/heroes/hero_juggernaut/juggernaut_blade_fury.vpcf"],
)
The kind is inferred from the extension; an unknown extension fails the build (a typo would otherwise silently not precache).
@Dota2Class: engine-bound Lua classes
A class the ENGINE instantiates (an ability, item, or modifier) is marked @Dota2Class and simply
extends the typed engine interface — the annotation only changes the lowering, never the type:
@Dota2Class
class NovaAbility : AbilityLua {
override fun onSpellStart() {
val caster = caster ?: return
// …
}
}
NovaAbility = class({})
function NovaAbility:OnSpellStart()
local caster = self:GetCaster()
-- …
end
The class lowers to the engine's class({}) idiom, overridden lifecycle hooks take their inherited
native names (OnSpellStart, OnCreated, …), and inherited members resolve on self
(caster → self:GetCaster()). A ModifierLua subclass additionally auto-emits its
ktox_link_modifier(...) registration (with the correct motion type), which also feeds the
client-entry generation below. Note: the engine instantiates these classes itself — put setup in the
lifecycle hooks (onCreated / onSpellStart), not in a constructor or init {} block.
Client-side script entry
Networked Lua modifiers must be constructible on clients, which never load addon_game_mode.lua.
The build scans the transpiled output for ktox_link_modifier(...) registrations and generates
addon_game_mode_client.lua requiring every declaring file, so clients can instantiate the modifiers
(no more unknown modifier type spam). A hand-written client entry is never clobbered; the generated
one is removed again when the last modifier disappears.
Dev mode
gradlew dev keeps a warm transpiler alive and mirrors outputs into the live addon on every save
(single-file re-transpile, typically tens of milliseconds). Dev mode is feature-parity with the
full build — the same script injection, client-entry generation, and KV pipeline run in both paths:
- Lua and Panorama sources re-transpile per file and sync immediately.
.dota.xml.ktslayouts and Sass recompile on change.- Editing a KV-participating file (a KV annotation or a
@file:KvSourcespec) recompiles the module and regeneratesscripts/npcin a few seconds. KV is read at addon load, so the change lands on the next addon restart — no full rebuild needed.
Working with the type libraries
lua-types
Full reference: the Lua API Reference tab above.
Accessing global singletons
Common singletons are exposed as top-level val properties:
import com.isycat.dota.types.lua.*
// Game rules access
val gameMode = GameRules.getGameMode()
// Particle manager
val particle = ParticleManager.createParticle("particles/units/heroes/hero_axe/axe_culling_blade_hit.vpcf", ParticleAttachment.ABSORIGIN_FOLLOW, null)
// Custom events
CustomGameEventManager.registerListener("my_event") { _, eventData ->
// handle event
}
Working with entities
import com.isycat.dota.types.lua.*
val hero: BaseNPCHero = /* obtained from game */
val health = hero.health // val Int — native: GetHealth
hero.health = 500 // var — native: SetHealth
val isAlive = hero.isAlive // val Boolean — native: IsAlive
Enums
All Dota 2 flag and constant enums are available as Kotlin enum class with integer values and @NativeName annotations:
import com.isycat.dota.types.lua.*
val damage = DamageTypes.MAGICAL // native: DAMAGE_TYPE_MAGICAL
val unitState = UnitState.STUNNED // native: UNIT_STATE_STUNNED
panorama-types
Full reference: the Panorama API Reference tab above.
Working with panels
import com.isycat.dota.types.panorama.*
fun setupPanel(panel: Panel) {
panel.setPanelEvent("onactivate") {
// handle click
}
panel.addClass("highlighted")
panel.visible = true
}
CSS properties
Use CssProperties constants to avoid typos in CSS property names:
import com.isycat.dota.types.panorama.*
panel.style.setProperty(CssProperties.BACKGROUND_COLOR, "#FF000080")
panel.style.setProperty(CssProperties.OPACITY, "0.5")
Listening to Panorama events
import com.isycat.dota.types.panorama.*
GameEvents.subscribe("DOTAHeroPickCompleted") { event ->
// handle hero pick completed
}
NativeName annotations
Every property and function carries a @NativeName annotation that maps the idiomatic Kotlin name back to the native Dota 2 engine name. This is used by the transpiler to call the correct engine function.
When you access entity.health the transpiler calls entity:GetHealth() / entity:SetHealth(value) in Lua.
ktox-dota-lib
ktox-dota-lib is an opinionated Kotlin library providing enhanced, strongly-typed wrappers and utilities for both Dota 2 VScript (Lua) and Panorama (JS) development.
Unlike com.isycat.dota:lua-types and com.isycat.dota:panorama-types, which provide direct Kotlin mirrors of the raw Dota 2 API shapes, ktox-dota-lib is intentionally opinionated — it offers higher-level abstractions, convenience helpers, and ergonomic patterns that go beyond the raw API surface to make Dota 2 addon development safer and more expressive in Kotlin.
Kotlin sources in this library are annotated with either @KtoxLibrarySource(language = Lua::class) or @KtoxLibrarySource(language = Js::class) and are automatically transpiled by the transpileKotlinToLua / transpileKotlinToJs tasks respectively. Consumer projects that declare ktox-dota-lib as a dependency automatically receive the transpiled Lua and JS files in their output directory — no manual configuration required.
Add the dependency to your addon module:
onGameEvent (Lua/VScript)
A strongly-typed wrapper around the Dota 2 VScripts ListenToGameEvent API. Kotlin infers the concrete event type from the typed EventKey constant, so listener lambdas receive an already-typed event table — no manual casts at the call site:
import com.isycat.ktox.dota.lib.onGameEvent
import com.isycat.dota.EventKey.DEMO_STOP
import com.isycat.dota.DemoStop
onGameEvent(DEMO_STOP) { event: DemoStop ->
println("demo stopped at tick ${event.tick}")
}
The corresponding Lua is emitted automatically — no boilerplate required.
Panel operators (Panorama/JS)
Syntactic sugar for navigating the Dota 2 Panorama UI panel hierarchy — ["id"] to find a child panel by ID, and ("selector") for CSS-like multi-step traversal supporting ID (#id), class (.Class), type (TypeName), and combinator (>) selectors:
import com.isycat.ktox.dota.lib.panorama.get
import com.isycat.ktox.dota.lib.panorama.invoke
// Find a child panel by ID
val scoreboard: Panel? = panorama["Scoreboard"]
val players: Panel? = panorama["Scoreboard"]?.get("Players")
// CSS-like selector traversal
val names: List<Panel> = players(".PlayerName")
val bold: List<Panel> = players(".PlayerName.BoldLabel")
val byId: List<Panel> = players("#HeroLabel")
val labels: List<Panel> = players("Label")
val nested: List<Panel> = players(".Row > Label.Active")
val first: Panel? = players(".PlayerName").firstOrNull()
The corresponding JS is emitted automatically — no boilerplate required.
Why opinionated?
The shapes and patterns in ktox-dota-lib are deliberately different from the raw Dota 2 Lua/Panorama API. They exist to make common addon tasks feel idiomatic in Kotlin: strong types, inference, and safe wrappers replace the stringly-typed, callback-heavy patterns of the vanilla API.