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 extends ModuleBase and 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 itemNpcScript / ObjectScript / ItemScript (see NPC, object and interaction scripts)
Add a ::commandICommandScript (see Commands)
React to logins, logouts, chat, deaths, XP, location changes, purchasesA module subscribing to events
Run logic every tick or on a timer with no player involvedA module subscribing to GameTickEvent
Share code or state between several scriptsA 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#

  1. Open the Script editor and click New Script….
  2. 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 .cs file in that folder tree, including subfolders.
  3. Name the class and click Create.

Step 2: Extend ModuleBase#

C#
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:

MemberPurpose
IdUnique 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, AuthorDisplay metadata. Version defaults to "1.0.0", Author to "Unknown".
DependenciesIds 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.
EventsShortcut for EventBus.Instance.
ModulesShortcut for ModuleLoader.Instance.
Log, LogWarning, LogErrorWrite 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:

C#
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>).

PriorityRunsUse for
LOWESTFirstEarly validation and filtering
LOW
NORMALDefaultMost handlers
HIGHReacting 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)
HIGHESTLast before MONITORFinal decisions
MONITORAbsolutely last, even on cancelled eventsLogging, 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 cancelled PlayerLoginEvent rejects the login with evt.KickMessage; a cancelled PlayerChatEvent is not broadcast; a cancelled NpcInteractEvent never reaches task triggers).
  • Once cancelled, later handlers are skipped unless they subscribed with ignoreCancelled: false — except MONITOR handlers, which always run.
  • Events marked IUncancellable (ticks, logouts, deaths, level-ups, location enter/exit, ...) still have Cancel(), 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#

C#
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#

EventFired whenCancellableNotable properties
ServerStartEventThe server finishes bootingNoPort
ServerShutdownEventThe server is shutting down (handlers get 500 ms)NoReason
GameTickEventEvery tick, 4 times per second, before anything else in the tickNoTickCount, DeltaTime (ms since last tick)

Players#

EventFired whenCancellableNotable properties
PlayerLoginEventAfter authentication, before entering the worldYes — rejects the loginPlayer, KickMessage (settable; shown to the rejected client)
PlayerLogoutEventA player disconnects, times out, is kicked, or the server stopsNoPlayer, Reason (Normal, Timeout, Kicked, ServerShutdown)
PlayerChatEventA player sends chat (commands starting with :: are not chat)Yes — blocks the messagePlayer, Message (settable — filter or rewrite), Type (All, Game, Public, Private, Clan, Trade)
PlayerRespawnEventJust before a dead player is teleported backNoRegion, Position (both settable — send them to a graveyard or jail instead)
PlayerExperienceGainEventA skill is about to gain XPYesSkillId, Experience (settable — XP boosts)
PlayerLevelUpEventA skill level increasesNoSkillId, OldLevel, NewLevel
PlayerItemAcquiredEventAn item lands in an inventoryNoItemId, Amount, Source
PlayerPurchaseEventA shop purchase completesNoItemId, Amount, CostCoins, ShopName
PlayerLocationEnterEventA player walks into a named location painted in the Map EditorNoLocationName (as authored), LocationId (lowercased, spaces to underscores), MapName
PlayerLocationExitEventA player leaves a named locationNoSame as above
PlayerInteractEventA player picks a right-click option on another playerYesPlayer, Target, OptionIndex

Clicks and item use#

EventFired whenCancellableNotable properties
NpcInteractEventA player picks an NPC optionYes — skips task triggersNpc, Option
ObjectInteractEventA player clicks an object option and is in range (the player walks there first if needed)Yes; also HandledObject, Option
ItemUseEventA player picks an inventory item optionYes; also HandledItemId, Slot, Option
ItemOnObjectEventA player uses an inventory item on a world objectYes; also HandledItemId, Slot, Object
DialogueAdvanceEventA player clicks to continue or picks a choice in a dialogueYesChoiceIndex (-1 = continue)
DialogueCloseEventA player closes a dialogueYesPlayer
DialogueInputEventA player submits text to an input dialogue nodeYesText

Combat, death and NPCs#

EventFired whenCancellableNotable properties
CombatStartEventAn entity begins attacking anotherYesAttacker, Target, Style
CombatEndEventCombat endsNoAttacker, Target, Reason (TargetDied, AttackerDied, OutOfRange, ManualStop, TargetLeft)
DamageAppliedEventAfter damage lands on a player or NPC (the template's combat XP module listens here)NoAttacker, Target, Damage, Style, RemainingHealth
EntityPreDeathEventAn entity is about to dieYes — set OverrideHealth (default 1) to keep it aliveEntity, Killer
EntityDeathEventDeath is confirmed (players and NPCs)NoEntity, Killer
NpcDeathEventAn NPC's death is processed, after the combat script's OnDeathNoNpc, Killer
EntityRespawnEventA player has respawnedNoEntity
NpcSpawnEventThe NPC system respawns an NPC after death (published after the NPC is already in the world, so cancelling has no effect)Declared, not honoredNpc, Position

Movement#

EventFired whenCancellableNotable properties
EntityMoveRequestEventAn entity asks to moveYes — redirect via TargetPositionEntity, FromPosition, TargetPosition (settable), IsRunning
EntityMovedEventAn entity stepped onto a new tile this tickNoEntity, FromPosition, ToPosition, WasRunning
EntityArrivedEventAn entity reached its destinationNoEntity, Position
EntityFollowStartEventAn entity starts following anotherYesFollower, Target, FollowDistance (settable)
EntityFollowStopEventFollowing stopsNoFollower, PreviousTarget

The tick loop#

The server runs a fixed loop at 4 ticks per second (250 ms). Every tick, in this order:

  1. GameTickEvent is published to all subscribers.
  2. Scheduled tasks run (player.After, player.Every, TaskManager.Submit).
  3. NpcScript.Process(npc) runs for every living NPC that has a script.
  4. ObjectScript.Process(obj) runs for scripted objects.
  5. Per-player script ticks: equipment effects, buffs, then LocationScript.OnTick.
  6. The core game systems update (movement, combat, NPC AI, and so on).
  7. 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.
  • GameTickEvent fires for every module every tick. Gate work with evt.TickCount % N == 0 as the regen example does; do not loop over every player every tick unless you need to.
  • There is no OnTick on ModuleBase. Subscribe to GameTickEvent instead.
  • For one-off or repeating timers tied to a player, prefer player.After(ticks, action), player.Every(ticks, action) and player.EveryStartingNow(ticks, action) — those tasks are bound to the player and are cancelled when they log out, and player.CancelAllTasks() clears them. player.CurrentTick exposes 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:

ScopeAPIWhere it is saved
One player (quest stage, personal score, settings)player.SetVar / GetVar / GetVarInt / GetVarBool / HasVar / RemoveVar / IncrementVarWith 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 / ClearFlushed 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:

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#

SymptomCause and fix
Module never logs anythingCheck 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 scriptThe module failed to compile, is disabled, or the script ran before modules enabled. Null-check and degrade.
Handler runs but the default behavior still happensYou 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.

Spotted a mistake or something missing?Tell us on Discord