Events and Systems
Subscribe to server events (login, chat, deaths, ticks) from a module, build long-lived game systems, and understand the 4 Hz tick loop.
13 min read
What modules and events are#
Most of your scripts react to one thing: a player clicks an NPC, types a command, uses an item. Those are covered by the per-entity script classes on NPC, object and interaction scripts and Commands. When you need logic that runs across the whole game — a minigame lobby, health regeneration, a quest tracker that watches every kill, a once-a-minute world event — you write a module and subscribe it to events.
- An event is a C# object the server publishes when something happens (
PlayerLoginEvent,NpcDeathEvent,GameTickEvent, ...). Handlers run in priority order and some events can be cancelled to stop the default behavior. - A module is a class under
server/modules/that extendsModuleBaseand has a lifecycle (OnLoad,OnEnable,OnDisable,OnUnload). Modules are where subscriptions, shared state and shared helper code live.
Every gameplay system that ships with a template (dialogue, drops, shops, quests, social, combat, movement...) is itself a module in server/modules/, so the files already in your project are working examples of everything on this page.
When to use a module instead of a script#
| You want to... | Use |
|---|---|
| React to a click on one NPC, object or item | NpcScript / ObjectScript / ItemScript (see NPC, object and interaction scripts) |
Add a ::command | ICommandScript (see Commands) |
| React to logins, logouts, chat, deaths, XP, location changes, purchases | A module subscribing to events |
| Run logic every tick or on a timer with no player involved | A module subscribing to GameTickEvent |
| Share code or state between several scripts | A module — each script file compiles alone and cannot see helpers declared in another script, but every script can call a module |
| Replace a built-in behavior (for example what "attack" does on an NPC) | A module that subscribes at LOWEST or LOW and cancels the event, so the default handler at NORMAL never runs |
Write a module#
Step 1: Create the file#
- Open the Script editor and click New Script….
- Pick the Module kind. The dialog shows Creates: server/modules/Systems/<Name>.cs. You can also create the file anywhere under
server/modules/— the server compiles every.csfile in that folder tree, including subfolders. - Name the class and click Create.
Step 2: Extend ModuleBase#
public class RegenModule : ModuleBase<RegenModule>
{
public override string Id => "regen";
public override string Name => "Health Regen";
public override string Version => "1.0.0";
public override string Description => "Heals players a little every 10 seconds.";
public override string Author => "You";
public override string[] Dependencies => new[] { "player" };
private EventSubscription? _tickSub;
public override Task OnEnable()
{
_tickSub = Events.Subscribe<GameTickEvent>(OnTick, EventPriority.NORMAL);
Log("Regen enabled");
return Task.CompletedTask;
}
public override Task OnDisable()
{
if (_tickSub != null) Events.Unsubscribe(_tickSub);
return Task.CompletedTask;
}
private void OnTick(GameTickEvent evt)
{
if (evt.TickCount % 40 != 0) return; // 40 ticks = 10 seconds
foreach (var p in WorldManager.Instance.GetAllPlayers())
{
if (p.Health < p.MaxHealth) new ScriptPlayer(p).Heal(1f);
}
}
}
What each member does:
| Member | Purpose |
|---|---|
Id | Unique module id. Used for Dependencies, for ModuleLoader.Instance.GetModule("id"), and by systems.json to disable the module. Two game modules with the same id conflict; the first one loaded wins and the second is skipped. |
Name, Version, Description, Author | Display metadata. Version defaults to "1.0.0", Author to "Unknown". |
Dependencies | Ids of modules that must be loaded and enabled before this one. Disabling a module that others depend on is refused. |
OnLoad() | One-time initialization after the module is compiled (load configs, allocate state). |
OnEnable() | Subscribe to events here. Runs after every module has loaded, in dependency order. |
OnDisable() | Unsubscribe and clear temporary state. |
OnUnload() | Release everything; also called on a builtin module when your game module overrides it. |
Events | Shortcut for EventBus.Instance. |
Modules | Shortcut for ModuleLoader.Instance. |
Log, LogWarning, LogError | Write to the server console prefixed with the module name. |
Modules need no using lines for engine namespaces: the server prepends a prelude covering System, System.Linq, System.Numerics, System.Threading.Tasks, the events, modules, services, entities, world, data store, scripting and networking namespaces before compiling. Add your own using lines only for things outside that list (the template's DialogueModule adds System.IO and System.Text.Json, for example).
Step 3: Expose it to scripts#
Declare the module as ModuleBase<TSelf> (as above) and it gets a typed singleton. From any script:
var regen = RegenModule.Instance; // null if the module failed to compile or is disabled
regen?.Log("hello from a command");
// or, without the generic base:
var mod = ModuleLoader.Module<RegenModule>();
Both return null when the module is not loaded, so null-check and degrade rather than crash.
Step 4: Verify it loaded#
Start a Dev Test and read the server console (see IDE setup for reading logs). You should see [ModuleLoader] Compiling: RegenModule.cs followed by your own Log line. A compile error is printed and the file is skipped — the server keeps running without it, so always check the log after adding a module.
Subscribe to events#
Events.Subscribe<T>(handler, priority, ignoreCancelled) returns an EventSubscription; keep it in a field and pass it to Events.Unsubscribe(...) in OnDisable. Handlers can be synchronous (Action<T>) or asynchronous (Func<T, Task>).
| Priority | Runs | Use for |
|---|---|---|
LOWEST | First | Early validation and filtering |
LOW | ||
NORMAL | Default | Most handlers |
HIGH | Reacting after the NORMAL handlers have run (the template's DialogueModule claims "talk" clicks here, cancelling the event so the npc_click task triggers do not also fire) | |
HIGHEST | Last before MONITOR | Final decisions |
MONITOR | Absolutely last, even on cancelled events | Logging, analytics, quest progress. Never modify or cancel here. |
Cancellation rules, verified in the event bus:
evt.Cancel()marks the event cancelled. The publisher then skips its default behavior (a cancelledPlayerLoginEventrejects the login withevt.KickMessage; a cancelledPlayerChatEventis not broadcast; a cancelledNpcInteractEventnever reaches task triggers).- Once cancelled, later handlers are skipped unless they subscribed with
ignoreCancelled: false— exceptMONITORhandlers, which always run. - Events marked
IUncancellable(ticks, logouts, deaths, level-ups, location enter/exit, ...) still haveCancel(), but nothing honors it. - An exception inside a handler is logged and the remaining handlers still run.
Some events also carry a Handled flag (ObjectInteractEvent, ItemOnObjectEvent, ItemUseEvent). Setting evt.Handled = true tells the server the click was consumed so it does not fall through to scripts and task triggers, without cancelling the event for other listeners.
Example: greet on login and track logouts#
public class WelcomeModule : ModuleBase
{
public override string Id => "welcome";
public override string Name => "Welcome";
private EventSubscription? _login, _logout;
public override Task OnEnable()
{
_login = Events.Subscribe<PlayerLoginEvent>(evt =>
{
var p = new ScriptPlayer(evt.Player);
long visits = p.IncrementVar("welcome.visits"); // persistent, saves with the character
_ = p.SendGameMessage($"Welcome back! Visit #{visits}.");
});
_logout = Events.Subscribe<PlayerLogoutEvent>(evt =>
Log($"{evt.Player.Username} left ({evt.Reason})"), EventPriority.MONITOR);
return Task.CompletedTask;
}
public override Task OnDisable()
{
if (_login != null) Events.Unsubscribe(_login);
if (_logout != null) Events.Unsubscribe(_logout);
return Task.CompletedTask;
}
}
Event handlers receive raw engine entities (Player, NPC, WorldObject). Wrap a Player in new ScriptPlayer(player) to get the friendly API (SendGameMessage, GiveItem, Teleport, SetVar, After, ...) documented in the Script API reference.
Events the server fires#
Every class below is published by the engine or by a template module in the current server, so a subscription will actually receive it. Properties marked settable can be changed by a handler to alter the outcome.
Server and tick#
| Event | Fired when | Cancellable | Notable properties |
|---|---|---|---|
ServerStartEvent | The server finishes booting | No | Port |
ServerShutdownEvent | The server is shutting down (handlers get 500 ms) | No | Reason |
GameTickEvent | Every tick, 4 times per second, before anything else in the tick | No | TickCount, DeltaTime (ms since last tick) |
Players#
| Event | Fired when | Cancellable | Notable properties |
|---|---|---|---|
PlayerLoginEvent | After authentication, before entering the world | Yes — rejects the login | Player, KickMessage (settable; shown to the rejected client) |
PlayerLogoutEvent | A player disconnects, times out, is kicked, or the server stops | No | Player, Reason (Normal, Timeout, Kicked, ServerShutdown) |
PlayerChatEvent | A player sends chat (commands starting with :: are not chat) | Yes — blocks the message | Player, Message (settable — filter or rewrite), Type (All, Game, Public, Private, Clan, Trade) |
PlayerRespawnEvent | Just before a dead player is teleported back | No | Region, Position (both settable — send them to a graveyard or jail instead) |
PlayerExperienceGainEvent | A skill is about to gain XP | Yes | SkillId, Experience (settable — XP boosts) |
PlayerLevelUpEvent | A skill level increases | No | SkillId, OldLevel, NewLevel |
PlayerItemAcquiredEvent | An item lands in an inventory | No | ItemId, Amount, Source |
PlayerPurchaseEvent | A shop purchase completes | No | ItemId, Amount, CostCoins, ShopName |
PlayerLocationEnterEvent | A player walks into a named location painted in the Map Editor | No | LocationName (as authored), LocationId (lowercased, spaces to underscores), MapName |
PlayerLocationExitEvent | A player leaves a named location | No | Same as above |
PlayerInteractEvent | A player picks a right-click option on another player | Yes | Player, Target, OptionIndex |
Clicks and item use#
| Event | Fired when | Cancellable | Notable properties |
|---|---|---|---|
NpcInteractEvent | A player picks an NPC option | Yes — skips task triggers | Npc, Option |
ObjectInteractEvent | A player clicks an object option and is in range (the player walks there first if needed) | Yes; also Handled | Object, Option |
ItemUseEvent | A player picks an inventory item option | Yes; also Handled | ItemId, Slot, Option |
ItemOnObjectEvent | A player uses an inventory item on a world object | Yes; also Handled | ItemId, Slot, Object |
DialogueAdvanceEvent | A player clicks to continue or picks a choice in a dialogue | Yes | ChoiceIndex (-1 = continue) |
DialogueCloseEvent | A player closes a dialogue | Yes | Player |
DialogueInputEvent | A player submits text to an input dialogue node | Yes | Text |
Combat, death and NPCs#
| Event | Fired when | Cancellable | Notable properties |
|---|---|---|---|
CombatStartEvent | An entity begins attacking another | Yes | Attacker, Target, Style |
CombatEndEvent | Combat ends | No | Attacker, Target, Reason (TargetDied, AttackerDied, OutOfRange, ManualStop, TargetLeft) |
DamageAppliedEvent | After damage lands on a player or NPC (the template's combat XP module listens here) | No | Attacker, Target, Damage, Style, RemainingHealth |
EntityPreDeathEvent | An entity is about to die | Yes — set OverrideHealth (default 1) to keep it alive | Entity, Killer |
EntityDeathEvent | Death is confirmed (players and NPCs) | No | Entity, Killer |
NpcDeathEvent | An NPC's death is processed, after the combat script's OnDeath | No | Npc, Killer |
EntityRespawnEvent | A player has respawned | No | Entity |
NpcSpawnEvent | The NPC system respawns an NPC after death (published after the NPC is already in the world, so cancelling has no effect) | Declared, not honored | Npc, Position |
Movement#
| Event | Fired when | Cancellable | Notable properties |
|---|---|---|---|
EntityMoveRequestEvent | An entity asks to move | Yes — redirect via TargetPosition | Entity, FromPosition, TargetPosition (settable), IsRunning |
EntityMovedEvent | An entity stepped onto a new tile this tick | No | Entity, FromPosition, ToPosition, WasRunning |
EntityArrivedEvent | An entity reached its destination | No | Entity, Position |
EntityFollowStartEvent | An entity starts following another | Yes | Follower, Target, FollowDistance (settable) |
EntityFollowStopEvent | Following stops | No | Follower, PreviousTarget |
The tick loop#
The server runs a fixed loop at 4 ticks per second (250 ms). Every tick, in this order:
GameTickEventis published to all subscribers.- Scheduled tasks run (
player.After,player.Every,TaskManager.Submit). NpcScript.Process(npc)runs for every living NPC that has a script.ObjectScript.Process(obj)runs for scripted objects.- Per-player script ticks: equipment effects, buffs, then
LocationScript.OnTick. - The core game systems update (movement, combat, NPC AI, and so on).
- Every 40 ticks the world stores are flushed to disk.
Practical rules that follow from this:
- Convert seconds to ticks by multiplying by 4: 10 seconds = 40 ticks, 1 minute = 240 ticks.
GameTickEventfires for every module every tick. Gate work withevt.TickCount % N == 0as the regen example does; do not loop over every player every tick unless you need to.- There is no
OnTickonModuleBase. Subscribe toGameTickEventinstead. - For one-off or repeating timers tied to a player, prefer
player.After(ticks, action),player.Every(ticks, action)andplayer.EveryStartingNow(ticks, action)— those tasks are bound to the player and are cancelled when they log out, andplayer.CancelAllTasks()clears them.player.CurrentTickexposes the tick counter. - For delayed world changes use the
ScriptWorld.SetObjectState(obj, newId, respawnTicks, respawnToId)overload, which schedules the swap-back for you.
Persistent state#
Modules are recreated on every server start and on a hot reload, so fields on a module are runtime-only. For anything that must survive:
| Scope | API | Where it is saved |
|---|---|---|
| One player (quest stage, personal score, settings) | player.SetVar / GetVar / GetVarInt / GetVarBool / HasVar / RemoveVar / IncrementVar | With the character, on autosave and logout |
| Shared world data (leaderboards, minigame points, world event state) | ScriptWorld.GetStore("name") then Set / Get / GetInt / Increment / Keys / Remove / Clear | Flushed every 40 ticks when dirty and on shutdown |
Keys are free-form strings; the template uses dotted names such as social.friends and quest.stage. The template's SocialModule keeps friends and ignore lists entirely in player vars, which is a good reference for the pattern.
Gameplay systems and systems.json#
Template modules are grouped into gameplay systems (Dialogue, Quests, Shops, Social, Combat, Drops, ...). Turning a system off in the New Project wizard or in Game Config → Gameplay Systems deletes its module files and records the decision in server/data/systems.json:
{
"version": 1,
"enabled_systems": ["dialogue", "quests", "shops"],
"disabled_systems": ["trading"],
"disabled_modules": ["trade"]
}
Only disabled_modules is enforced: the loader refuses to register any module whose Id is listed there, even if its .cs file is still present. A missing or unreadable file means "load everything". See Templates and gameplay systems for the list of systems and the files each one owns.
Troubleshooting#
| Symptom | Cause and fix |
|---|---|
| Module never logs anything | Check the console for Compilation errors / CS#### lines — a file that fails to compile is skipped, not fatal. Building the companion project from IDE setup catches these before launch. |
Skipping user module 'x' — another game module already registered this Id. | Two files declare modules with the same Id. Rename one. |
Game module 'x' is disabled by data/systems.json — not loading. | Remove the id from disabled_modules, or re-enable the system in Game Config. |
FooModule.Instance is null in a script | The module failed to compile, is disabled, or the script ran before modules enabled. Null-check and degrade. |
| Handler runs but the default behavior still happens | You subscribed at MONITOR (cancels are ignored there), or the event is IUncancellable, or you need evt.Handled = true rather than Cancel() for click events. |
| A helper from another script "does not exist in the current context" | Script files compile separately. Move the helper into a module. |
With AI (MCP)#
write_server_script with root: "modules" and kind: "custom" writes a module file under server/modules/<category>/; read_server_script, list_server_scripts, move_server_script and delete_server_script manage existing files. generate_script_project creates the companion project that compile-checks modules and scripts together, and read_server_logs shows the load output after a Dev Test.
