Scripting Overview
How server-side C# scripting works in Vastopia - scripts, modules, schemas, the per-file compile rule, and what the client cannot do.
10 min read
What scripting is in Vastopia#
All custom game logic in Vastopia runs on the game server, written in C#. There is no build step on your side: the server compiles every script file itself when it boots (and again on a hot reload), straight from the .cs files in your project's server/ folder. You write a file, save it, start or reload the server, and the code is live.
Scripts are how you add things the editors do not cover out of the box: a ::heal chat command, what happens when a player clicks an object, a fishing activity that ticks every second, a custom boss swing, the server half of a GUI screen, or an entire game system such as a minigame with its own state and rules.
The game client never runs any of your code. It is a fixed renderer that shows what the server tells it to show, so every piece of gameplay, UI logic and rule enforcement is authoritative on the server. See What is not possible below before you plan a feature around the client.
Where scripts live#
Everything scriptable sits under server/ in your project and ships to the game server inside the server data zip on every build (see Builds and versions). The Script Editor shows these folders as a tree.
server/
├── modules/ # Long-lived game systems (IModule classes), scanned recursively
│ ├── Content/ # Bosses, Buffs, Combat, Crafting, Dialogue, Drops, Economy, Quests, Skills, Social
│ ├── Entity/ # Movement, NPC, NpcInteraction, Player, PlayerInteraction, Trade, Combat
│ └── World/ # WorldModule
├── scripts/ # Single-purpose scripts, one folder per kind
│ ├── commands/ # ICommandScript - player ::commands
│ ├── npcs/ # NpcScript - NPC click options and lifecycle
│ ├── objects/ # ObjectScript - world object clicks, spawning, per-tick logic
│ ├── items/ # ItemScript - inventory item options and use-on
│ ├── equipment/ # EquipmentScript - effects while an item is worn
│ ├── combat/ # NpcCombatScript / ISpecialAttack - custom swings and specials
│ ├── locations/ # LocationScript - enter/exit/tick for a named area
│ ├── buffs/ # IBuffDefinition
│ ├── interfaces/ # IInterfaceScript - the server half of a GUI screen
│ └── tasks/ # TaskScript<T> / WorldTaskScript (scanned recursively)
├── schemas/ # Data tables: <Category>/<name>/<name>.json + <name>_data.json
└── data/ # Exported game data (items, npcs, objects, systems.json, script_bindings.json ...)
The server creates the scripts/ subfolders if they are missing, so an empty folder is normal. Each kind of script is discovered by the folder it is in, then matched to a base class or interface inside the file. A file in scripts/commands/ that does not contain a class implementing ICommandScript registers nothing.
| Kind | Folder | You write | How it is bound |
|---|---|---|---|
| Command | scripts/commands/ | class X : ICommandScript | By its Name (and Aliases); players type ::name |
| NPC script | scripts/npcs/ | class X : NpcScript | NPC definition's script path, recorded in server/data/script_bindings.json |
| Object script | scripts/objects/ | class X : ObjectScript | Object definition's script path (script_bindings.json) |
| Item script | scripts/items/ | class X : ItemScript | Item definition's script path (script_bindings.json) |
| Equipment script | scripts/equipment/ | class X : EquipmentScript | Item definition, separate binding from the item script |
| Combat script | scripts/combat/ | class X : NpcCombatScript or ISpecialAttack | NPC definition's combat_script_path, matched by file name |
| Location script | scripts/locations/ | class X : LocationScript | The location id the class declares |
| Interface script | scripts/interfaces/ | class X : IInterfaceScript | The InterfaceNames array the class declares |
| Task script | scripts/tasks/ | class X : TaskScript<ScriptPlayer> | The task's script_path in schemas/Tasks/tasks/tasks_data.json |
| Module | modules/ (any subfolder) | class X : ModuleBase | Registered by its Id; no binding needed |
Only scripts/tasks/ and modules/ are scanned recursively. Every other script folder is read one level deep, so a command placed in scripts/commands/admin/ is ignored.
Scripts versus modules#
A script is small and reactive: it implements one interface or base class and the server calls it when something happens (a command is typed, an NPC is clicked, a task ticks). Scripts have no lifecycle of their own and are usually stateless.
A module is a long-lived game system. It implements IModule (normally by extending ModuleBase) and the server loads it once, enables it after every other module it depends on, and keeps it alive for the life of the process. Modules own state (a minigame lobby, cached config), subscribe to events, and expose methods for scripts to call. The bundled starter ships about twenty of them; they are the code behind the gameplay systems you toggled when creating the project.
public class FishingModule : ModuleBase<FishingModule>
{
public override string Id => "fishing";
public override string Name => "Fishing";
public override string Version => "1.0.0";
public override string[] Dependencies => Array.Empty<string>();
private EventSubscription? _tickSub;
public override Task OnEnable()
{
_tickSub = Events.Subscribe<GameTickEvent>(OnTick, EventPriority.NORMAL);
Log("Fishing enabled");
return Task.CompletedTask;
}
public override Task OnDisable()
{
if (_tickSub != null) Events.Unsubscribe(_tickSub);
return Task.CompletedTask;
}
public int RollCatch(string username, int spotId, int level) { /* ... */ return 0; }
private void OnTick(GameTickEvent e) { }
}
Key facts about modules:
- Identity.
Idmust be unique.Name,Version,DescriptionandAuthorare informational.Dependencieslists other module ids that must be enabled first. - Lifecycle.
OnLoadruns when the module is registered right after compiling,OnEnableafter every module is loaded and scripts are compiled, in dependency order (register event handlers here),OnDisableon shutdown or reload,OnUnloadwhen the module is discarded. All four returnTask, andModuleBasegives each a no-op default so you override only what you need. - Helpers from
ModuleBase.Eventsis the event bus,Modulesis the loader, andLog,LogWarning,LogErrorwrite prefixed lines to the server console. ModuleBase<TSelf>gives the module a typed singleton: declareclass FishingModule : ModuleBase<FishingModule>and any script can callFishingModule.Instance. The loader sets it when the module registers and clears it on unload, soInstanceisnullbefore load, after unload, or if the module failed to compile.ModuleLoader.Module<T>()is the other way in:var fishing = ModuleLoader.Module<FishingModule>(); fishing?.RollCatch(...). It returnsnullwhen that module is not loaded, so null-check and degrade rather than throw.- Game modules override platform copies. The hosted server ships pre-compiled copies of a few modules (
movement,npc,player,player-interaction,combat,trade,shops,world). A module in your project with the sameIdreplaces the platform copy. Two modules in your project with the sameIdare a conflict: the first wins and the second is skipped with a warning in the log. - Disabled systems are refused.
server/data/systems.jsoncarries adisabled_moduleslist written by Game Config. A module whose id is on that list is not loaded even if its.csfile is still present, and the same gate stops the platform's pre-compiled copy.
The per-file compile rule#
The server compiles each .cs file on its own, as its own assembly. This has consequences that trip up people used to a normal C# project:
- A helper class declared in one script file is not visible from another script file. Put shared logic in a module and call it from scripts.
- Modules compile first, and every module assembly is handed to the script compiler as a reference. A script can therefore use any public type a module declares (the module class itself, DTOs, enums), which is why you should return real objects from a module instead of delimited strings.
- A file that fails to compile is logged and skipped; the server keeps booting without it. Always read the boot log for
Compilation errors in <file>lines, or use the editor's Dev Test validation, described in IDE setup. - Compiled scripts are cached in
server/scripts/.compile_cache/keyed by source content, so an unchanged script does not pay the compile cost on the next boot. The folder is safe to delete.
The auto-included prelude#
You never write using lines for the engine: the server prepends a prelude to every file before compiling. Scripts get System, System.Collections.Generic, System.Linq, System.Threading.Tasks, System.Numerics, every EzRealm.Server.Core.Scripting.* namespace, EzRealm.Server.Core.Events, Core.Modules, Core.Services, Core.Game.Tasks, Core.Game.Skills, Core.Game.World, Core.Data, the entity namespaces, and using static EzRealm.Server.Core.Scripting.GameData. That last one is why a script can call GetItem(123), Vec3(x, z), Distance2D(a, b) or Random(1, 6) with no prefix. Modules receive a similar prelude that additionally includes Core.Game, Core.Game.Entities.Movement, Core.Game.Updating, Core.Networking and Core.Scripting.Crafting. Extra using lines you add yourself are fine; explicit using lines for namespaces already in the prelude are harmless.
The file server/_VastopiaGlobalUsings.g.cs, written by generate_script_project, is this prelude expressed as global usings so your IDE sees the same world.
Schemas: data tables for scripts#
Not everything needs code. server/schemas/ holds data tables: a definition file listing fields and a data file holding rows. Shops, recipes, quests, drop tables, prayers and attack styles all live here, and the starter's modules read them at enable time.
server/schemas/Shops/shops/shops.json # definition: table, description, fields[]
server/schemas/Shops/shops/shops_data.json # rows: a JSON array of objects
The server scans exactly two levels: schemas/<Category>/<name>/. A script reads a table with GetData("shops"), which returns a DataTable with Rows, FindById(int), FindByField(field, value) and Where(predicate); each DataRow has GetInt, GetFloat, GetString, GetBool, Get<T> and Has. GetSchema(name) and DataExists(name) are also available. The Script Editor edits both halves visually, and the Game Tools editors write several of these tables for you.
What is not possible#
- No client-side scripting. The client has no script runtime and cannot load code from your project. Interface scenes can only contain nodes the client already knows how to draw, and every button press is a round trip to the server handled by an
IInterfaceScript. Timers, tweens and animations inside a custom interface are not available; a countdown label costs one packet per second per player. - The client's own screens are fixed. The inventory, equipment and skills tabs, the player right-click menu (Follow, Trade, Examine) and the equipment slot layout are built into the client and cannot be changed from a script.
- No sound from scripts. Audio plays only through a location's enter/exit music and ambient settings.
- No camera control or cutscenes from the server.
- Scripts cannot talk to each other across files except through modules, as described above.
For persistence, use player.SetVar / GetVar (saved with the character) and ScriptWorld.GetStore("name") (a shared, persisted world store) rather than static dictionaries, which are lost on every restart. Both are described in the Script API reference.
From file to running server#
- Write or edit the script in the Script Editor (or any IDE; the editor detects files changed on disk).
- Dev Test (top bar) saves the project, runs a structural and syntax check over every
.csfile underserver/scripts/andserver/modules/, and stops with a Script Validation dialog if it finds errors. Double-click a row to jump to the line; Launch Anyway proceeds regardless. - If a local server is already running, Dev Test asks it to hot-reload instead of restarting: data schemas, regions, modules and scripts are all reloaded and the map's NPCs and objects are respawned. If the reload fails, the editor restarts the server.
- Read the boot output. Dev Test runs the server on your machine and prints to its own console window, so that is where you look for
[ScriptManager] Script loading complete:followed by per-kind counts,[CommandRegistry] Registered command: ::namelines, and anyCompilation errors inblock. The Server Logs view (top bar) shows the same output for the hosted test deployment used by Play Local and Push to Test. - Push to Test uploads
server/data,server/modules,server/scriptsandserver/schemaswith the build, so the hosted test server boots with the same scripts. See Push to Test.
Where to go next#
- Script Editor - the editor UI, autocomplete, schema tables.
- Commands -
::commands, arguments and ranks. - Events and systems - module lifecycle, the event bus and the tick loop.
- NPC, object and interaction scripts - reacting to clicks on things in the world.
- Script API reference - every member of
ScriptPlayer,ScriptWorld,GameDataand friends. - IDE setup - IntelliSense and compile-checking outside the editor.
With AI (MCP)#
The MCP tools author scripts without opening the editor: list_server_scripts (every script with its category, class and base class), read_server_script, write_server_script (with a kind that picks the right base-class template: combat, npc, object, command, task, item, equipment, location, interface or custom), move_server_script, delete_server_script, get_script_api (the real API surface, per class) and generate_script_project (the IDE companion project). See the tools reference.
