Skip to content

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-dota plugin, orchestrates the subprojects, and owns the addon-wide files — content/ (static assets), scripts/npc/ (KV bases), resource/ (localization), and addoninfo.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 via dotaAddon { addonContentDir }.
  • :lua module: server-side code, where you use lua-types. Compiled to Lua files that deploy into the game's vscripts folder.
  • :panorama module: client-side UI (Kotlin sources only — no content/ subdir), where you use panorama-types and dota-panorama-layout-dsl. Compiled to JavaScript and XML for the game's panorama folder:
    • src/main/kotlin/: Kotlin UI logic.
    • src/main/layout/: UI layout files (.xml and .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 (casterself: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.kts layouts and Sass recompile on change.
  • Editing a KV-participating file (a KV annotation or a @file:KvSource spec) recompiles the module and regenerates scripts/npc in 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.

@get:NativeName("GetHealth")
@set:NativeName("SetHealth")
var health: Int

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:

dependencies {
    implementation("com.isycat:ktox-dota-lib:<version>")
}

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.