Write your Dota 2 VScript logic in C#, compile it to Lua using CSharp.lua, and run it inside the Dota 2 VScript sandbox.
| Tool | Version | Link |
|---|---|---|
| .NET SDK | 7.0+ | https://dotnet.microsoft.com/download/dotnet/7.0 |
| CSharp.lua | 2.0+ | https://github.com/yanghuan/CSharp.lua/releases |
| Dota 2 Workshop Tools | latest | Steam → Dota 2 → Workshop Tools |
my-addon/
├── src/ # C# source files
│ ├── EntryPoint.cs # addon entry point
│ ├── GameMode.cs
│ ├── abilities/
│ │ └── MedusaSplitShot.cs
│ ├── modifiers/
│ │ └── ModifierMedusaSplitShot.cs
│ └── utils/
│ └── DotaConst.cs # Dota 2 API constants & extern bindings
├── output/ # compiled Lua output (git-ignored)
├── compile.bat # build script
└── README.md
Dota 2 addon layout:
game/dota_addons/<addonName>/scripts/vscripts/
├── addon_game_mode.lua # minimal Lua bootstrap (required by Dota 2)
├── out.lua # compiled output — copy here after build
└── lib/
└── timers.lua # optional third-party Lua libs
The CSharp.lua CoreSystem library uses os.* functions that are not
available in the Dota 2 VScript sandbox. Apply the following patches to
your local copy of CoreSystem before compiling.
-- lines 34–37: guard against nil os
local os = os
local ostime = os and os.time
local osdifftime = os and os.difftime
local osdate = os and os.date-- lines 185–192: guard getTimeZone() called at module load time
local function getTimeZone()
if not osdate then return 0, 0 end -- sandbox fallback: UTC+0
local date = osdate("*t")
local dst = date.isdst
local now = ostime(date)
return osdifftime(now, ostime(osdate("!*t", now))) * 10000000,
dst and 3600 * 10000000 or 0
end-- line 195: fallback when ostime is nil
local time = System.config.time or ostime or function() return 0 end-- lines 28–29
local os = os
local clock = os and os.clock-- line 34
define("System.Environment", {
Exit = os and os.exit,
...-- line 128: remove assert to allow script_reload without crash
-- assert(rawget(scope, name) == nil, className)
rawset(scope, name, cls)Why: Dota 2's
requiredoes not cache modules acrossscript_reload. Without this patch, reloading the script crashes withattempt to call assert … MyAddon.ClassName.
@echo off
setlocal
set COMPILER=..\CSharp.lua-2.0\CSharp.lua\CSharp.Lua.Launcher.dll
set CORESYSTEM=..\CSharp.lua-2.0\CoreSystem.lua
set ADDON_PATH=C:\...\dota 2 beta\game\dota_addons\<addonName>\scripts\vscripts
echo [1/2] Compiling C# to Lua...
dotnet %COMPILER% -s src -d output -c -p -include %CORESYSTEM%
if %errorlevel% neq 0 ( echo FAILED & pause & exit /b 1 )
echo [2/2] Copying to addon...
copy /Y "output\out.lua" "%ADDON_PATH%\out.lua" >nul
echo Done. Use script_reload in Dota 2 console.
pauseCompiler flags:
| Flag | Purpose |
|---|---|
-c |
Lua 5.1 compatibility (Dota 2 uses Lua 5.1) |
-p |
Disable debug.setmetatable (unavailable in VScript) |
-include <path> |
Bundle CoreSystem + your code into a single out.lua |
-s src |
Recursively compiles all .cs files in src/ |
require("out") -- load CoreSystem + compiled C# code
function Precache(context) end
function Activate()
MyAddon.EntryPoint.Setup()
endusing System;
namespace MyAddon {
public static class EntryPoint {
// Register Dota 2 globals from C# — no extra Lua needed
/// @CSharpLua.Template = "Activate = {0}"
private static extern void SetActivate(Action fn);
/// @CSharpLua.Template = "Precache = {0}"
private static extern void SetPrecache(Action<object> fn);
/// @CSharpLua.Get = "GameRules.Addon"
private static extern object GameRulesAddon { get; }
/// @CSharpLua.Template = "{0}:Reload()"
private static extern void CallReload(object addon);
public static void Setup() {
SetActivate(GameMode.OnActivate);
SetPrecache(GameMode.OnPrecache);
// Handle script_reload
var addon = GameRulesAddon;
if (addon != null) CallReload(addon);
Console.WriteLine("[MyAddon] EntryPoint.Setup complete");
}
}
}Use @CSharpLua.Template annotations in a shared base class so that
ability/modifier subclasses contain zero Lua annotations:
// src/utils/BaseModifier.cs
namespace MyAddon.Base {
public abstract class BaseModifier {
// Lifecycle — override in subclasses
public virtual bool IsHidden() => false;
public virtual bool IsPurgable() => true;
// Dota 2 API — all annotations live here
/// @CSharpLua.Template = "{this}:GetAbility():GetSpecialValueFor({0})"
protected extern float GetSpecialValueFor(string key);
/// @CSharpLua.Template = "{this}:GetParent()"
protected extern object GetParent();
/// @CSharpLua.Template = "IsServer()"
protected static extern bool IsServer();
/// @CSharpLua.Template = "FindUnitsInRadius({0},{1},{2},{3},{4},{5},{6},{7},{8})"
protected static extern object[] FindUnitsInRadius(
int team, object origin, object cache, float radius,
int teamFilter, int typeFilter, int flagFilter,
int orderFilter, bool canGrow);
}
}Subclasses only contain game logic:
// src/modifiers/ModifierMedusaSplitShot.cs
using MyAddon.Base;
namespace MyAddon.Modifiers {
public class ModifierMedusaSplitShot : BaseModifier {
public override bool IsHidden() => true;
public override bool IsPurgable() => false;
public void OnCreated(object kv) {
float reduction = GetSpecialValueFor("damage_modifier");
// ...
}
}
}Register the class as a Dota 2 global in EntryPoint.Setup():
/// @CSharpLua.Template = "modifier_medusa_split_shot_lua = {0}"
private static extern void RegisterModifier(object cls);
// in Setup():
RegisterModifier(typeof(ModifierMedusaSplitShot));KV file (npc_abilities_custom.txt):
"ScriptFile" "out"
No wrapper .lua files needed — the class is already in global scope
after Activate() runs.
| Module | Status | Notes |
|---|---|---|
Core, Exception, Boolean, Char |
Full | — |
String, Number, Math, Enum |
Full | — |
Array, Collections/* |
Full | List<T>, Dictionary<K,V>, LINQ |
Delegate, Interfaces, TimeSpan, Type |
Full | — |
Console.WriteLine |
Full | maps to print() |
Threading/* |
Partial | needs Dota 2 Timers integration |
Convert |
Partial | BitConverter throws without struct lib |
Random |
Partial | always pass explicit seed: new Random(42) |
DateTime |
Patched | returns UTC+0 stub; see patches above |
Utilities (Environment/Stopwatch) |
Patched | os.* guarded; see patches above |
IO/File |
No-op | io unavailable; module loads but does nothing |
- Edit
.csfiles insrc/ - Run
compile.bat - In Dota 2 Workshop Tools console:
script_reload - Check output in vConsole2 (
~key)
This project uses CSharp.lua under the Apache 2.0 License.