msw-scripting

Installation
SKILL.md

MSW Scripting (.mlua) — Framework + File Workflow + Playtest & Debugging

mlua is Lua-based, but it has MSW-specific annotations, a lifecycle, and an execution-space model. General Lua knowledge alone will not produce working code. All work is done by editing files in the workspace directly, and code is validated in the order build logs → runtime logs.


1. Core Principles (must follow)

1.1 Existing Script First

  • Before creating a new .mlua, you must search under ./RootDesk/MyDesk/ for an existing script with the same or similar purpose.
  • Use glob/keyword search (file names, symbols, comment keywords) as your discovery method.
  • Duplicate implementations raise maintenance cost and conflict risk. Extending (modifying an existing file) is always the first choice.

1.2 Folder Structure for New Scripts — Never Dump Files Flat

When extending an existing script is not possible and a new .mlua must be created, organize it under a feature/category subfolder. Do not drop scripts directly into ./RootDesk/MyDesk/.

Required path shape: ./RootDesk/MyDesk/<FeatureFolder>/<ScriptName>.mlua (or a deeper nested path when the feature has sub-systems).

  • Reuse an existing subfolder if one already fits (e.g., Player/, UI/, Combat/, Tower/, Inventory/). Run a glob/list on ./RootDesk/MyDesk/ first to see what's already there.
  • If no fitting subfolder exists, create one named for the feature/system (PascalCase, matching the surrounding project's casing). Examples:
    • ./RootDesk/MyDesk/Inventory/InventoryManager.mlua
    • ./RootDesk/MyDesk/Combat/MeleeAttackComponent.mlua
    • ./RootDesk/MyDesk/UI/Popup/RewardPopupLogic.mlua
  • Group related scripts together — Component, Logic, custom Event, and helper Struct for the same feature should sit in the same folder so the system is discoverable as a unit.
  • A single-file feature still gets its own folder. Even one script belongs in <FeatureFolder>/, not at the root, so future additions have a home.
  • Naming: folder = feature noun (TowerDefense/, Quest/); file = role-specific name reusing the feature noun where helpful (TowerSpawnerLogic.mlua, QuestTrackerComponent.mlua).
  • Do not create generic catch-all folders like Scripts/, Misc/, Common/, New/, temp/. Pick a real feature name. If you genuinely have a project-wide utility, place it under a specific utility folder such as Util/ or Shared/ only if that pattern already exists in the project.

Why: a flat MyDesk/ quickly becomes unsearchable and makes the "search before creating" rule (1.1) impossible to follow. A consistent feature-folder layout is what lets future agents (and humans) discover what already exists.

1.3 Never Guess APIs or Syntax — Verify Before Writing Code

If you guess an MSW API name, parameter, or return type, the call will silently fail at runtime. Before writing code, verify the spec using one of the methods below.

Method 1 — Read the .d.mlua definition file directly (exact signatures): The full engine API is defined as .d.mlua files under ./Environment/NativeScripts/:

Folder Contents Files Example
Component/ Engine components 104 TransformComponent.d.mlua
Service/ System services 46 SpawnService.d.mlua
Event/ Event types 202 HitEvent.d.mlua
Logic/ Built-in logic 9 TweenLogic.d.mlua
Enum/ Enumerations 118 BodyType.d.mlua
Misc/ Utility types 135 Vector2.d.mlua
You know the API name → Read ./Environment/NativeScripts/{folder}/{name}.d.mlua
You don't know the name → Grep keywords in the NativeScripts folder

Method 2 — msw-search skill (detailed descriptions, examples, implementation guides): When .d.mlua only contains signatures and lacks explanation, search for parameter semantics, code examples, related APIs, and implementation guides.

Required order: confirm signature in .d.mlua → (if needed) look up details via msw-search → write code → LSP diagnose runs automatically (PostToolUse hook).

1.4 Lint (LSP diagnostics) — required after every script change

  • Whenever you create or modify a .mlua file, the mlua-diagnose hook runs LSP diagnose automatically and feeds errors back.
  • Repeat fix → re-edit until error-severity diagnostics reach zero.

1.5 .codeblock files

  • .codeblock files are generated automatically by Maker Refresh.
  • The agent must never create, edit, or delete them manually.

1.6 Refresh Timing

  • After creating, modifying, renaming, or deleting a .mlua, you must call Maker MCP refresh.
  • refresh cannot run during play mode — first call stop to return to edit mode.

1.7 MSW ≠ Unity — Do Not Reason From Intuition [VERIFIED]

MSW uses Lua + ECS + an MSW-specific execution-space model. Applying Unity or generic game-engine patterns directly will compile fine but silently fail at runtime. Common misconceptions:

Unity / generic intuition MSW reality Correct path Source
MonoBehaviour.gameObject / this.transform to access the owning entity @Logic has no self.Entity (that is @Component-only) Inject via property Entity x = "uuid" on the Logic, or use _EntityService:GetEntityByPath(...) Logic.d.mlua, Component.d.mlua:16, mod/.../MODLogic.cs
OnMouseDown / BoxCollider2D is enough to receive clicks/touches Physics colliders and Rigidbody do not emit TouchEvent World: TouchReceiveComponent / UI: ButtonComponent or UITouchReceiveComponent EmitTouchEvent in TouchReceiveComponent.d.mlua, §10
OnCollisionEnter + Rigidbody for collision callbacks Entity-to-entity collisions use a separate TriggerComponent + TriggerEnter/Leave/Stay Attach TriggerComponent, then ConnectEvent(TriggerEnterEvent, ...) TriggerComponent.d.mlua:56-62
Attach multiple Rigidbody/Collider freely One Body per map type (MapleTile→Rigidbody, RectTile→Kinematicbody, SideViewRectTile→Sideviewbody) Custom models include only the Body that matches the map type msw-general/references/platform.md §4. DefaultPlayer has all three with engine auto-activation.
Reference/modify UI objects from server code UI entities exist only on the client — referencing them from server @ExecSpace returns nil Server→UI must go through an @ExecSpace("Client") RPC msw-general/references/ui.md:445-446
Instantiate(prefab) callable anywhere _SpawnService:SpawnByModelId(id, name, pos, parent)parent is required, server-only Inside ServerOnly/Server RPC, pass CurrentMap as parent SpawnService.d.mlua:22 (no default for parent)
static classes / hand-rolled singletons @Logic is itself an engine singleton Call from other scripts as _ScriptName:Method() (e.g., _TweenLogic, _UtilLogic, _ScreenMessageLogic) — never instantiate §3.2

Rule: any time you catch yourself thinking "Unity does it this way, so MSW probably does too", stop and verify against Environment/NativeScripts/*.d.mlua or the mod/ engine source before writing code.

1.8 Method Documentation Comments — Required, and Inside the Body

  • Every method declaration MUST have a description comment explaining what the function does (purpose, parameters, return value, side effects — as applicable).
  • The comment MUST be written inside the method body, as the first line(s) after method .... Never place the description comment on the line(s) above the method declaration.
  • This rule applies to all methods: lifecycle callbacks (OnBeginPlay, OnUpdate, ...), RPC handlers, event handlers, and user-defined methods.

Why this placement matters: mlua's parser/tooling associates leading comments differently from in-body comments. Placing the description inside the body keeps the comment reliably bound to the method and avoids it being parsed as a trailing comment of the previous declaration.

❌ Wrong — comment placed outside (above) the method:

-- Applies damage to the target entity and triggers the hit VFX.
method void ApplyDamage(Entity target, number amount)
    target:TakeDamage(amount)
end

✅ Correct — comment placed inside the method body, as the first line:

method void ApplyDamage(Entity target, number amount)
    -- Applies damage to the target entity and triggers the hit VFX.
    -- @param target : the entity receiving damage (must be valid)
    -- @param amount : damage amount in HP units
    target:TakeDamage(amount)
end

✅ Correct — also applies to lifecycle / RPC methods:

@ExecSpace("ServerOnly")
method void OnBeginPlay()
    -- Server-side initialization: wires up spawn points and boss triggers.
end

@ExecSpace("Server")
method void RequestBuyItem(string itemId)
    -- Client → Server RPC. Validates ownership/gold, then grants the item.
    -- senderUserId is required to authenticate the requester.
end

Checklist before committing any .mlua change: every method you added or modified has a description comment, and that comment is the first statement inside the method body — not a floating comment above the declaration.


2. Paths and File Roles

Target Path Agent action
User scripts ./RootDesk/MyDesk/**/*.mlua Create / read / modify / delete directly
Auto-generated artifacts *.codeblock Do not touch (Refresh manages them)
Engine API definitions ./Environment/NativeScripts/** Read-only (do not modify)
Models (component lists) ./RootDesk/MyDesk/*.model, ./Global/*.model, etc. Edit Components when attaching scripts
Map instances ./map/*.map Edit when attaching scripts to entities that exist only inside a map

3. Script Types and Declarations

3.1 Component scripts (@Component)

Scripts attached to an Entity. Use self.Entity to access the owning entity.

@Component
script MyScript extends Component

    property number Speed = 5.0

    @ExecSpace("ServerOnly")
    method void OnBeginPlay()
        -- initialization
    end

    @ExecSpace("ServerOnly")
    method void OnUpdate(number delta)
        -- per-frame logic
    end

    @ExecSpace("ServerOnly")
    method void OnEndPlay()
        -- cleanup
    end

end

Allowed parents:

  • Component — generic component
  • AttackComponent — attack system (Shape, AttackFast, OnAttack)
  • HitComponent — hit system (OnHit, HandleHitEvent)

3.2 Logic scripts (@Logic)

Global singletons. Run independently without an Entity. Use for game managers, UI managers, utilities, etc.

@Logic
script GameManager extends Logic

    @Sync property integer Score = 0

    @ExecSpace("ServerOnly")
    method void OnBeginPlay()
        -- global initialization
    end

    @ExecSpace("ServerOnly")
    method void OnUpdate(number delta)
        -- per-frame logic
    end

end
  • One per world (singleton)
  • Accessed from other scripts as _GameManager (underscore + script name)
  • Supports @Sync properties — server→client sync behaves the same way

⚠️ Warning — @Logic does NOT have self.Entity [VERIFIED]

The public members of the Logic parent class (Environment/NativeScripts/Logic/Logic.d.mlua) are only ConnectEvent / DisconnectEvent / IsClient / IsServer / SendEvent. There is no Entity property, GetOwner, or Owner. The engine source (mod/.../MODLogic.cs) confirms this. self.Entity is @Component-exclusive (readonly property Entity Entity in Component.d.mlua).

Code like self.Entity.xxx inside a Logic compiles but produces a nil-access at runtime and silently fails. If a Logic needs to operate on a specific world entity, use one of these:

@Logic
script GameManager extends Logic
    property Entity spawnPoint = "uuid-string"        -- inject UUID directly
    property EntityRef bossEntity = ""                -- survives map transitions

    method void OnBeginPlay()
        local map = _EntityService:GetCurrentMap()    -- current map
        local e   = _EntityService:GetEntityByPath("/maps/Main/Entities/Boss")
    end
end
  • Property injection (recommended): hard-code the UUID of an entity already placed in the map into the property default.
  • Service lookup: _EntityService:GetCurrentMap() / GetEntityByPath(...) / FindEntityByName(...).

Decision: @Component vs @Logic — behavior attached to an entity → @Component; global singleton manager → @Logic. A Logic's OnUpdate runs before Components' OnUpdate.

3.3 Extend scripts (extending an existing component)

@Component
script PlayerAttack extends AttackComponent
    -- Override AttackComponent's methods
    -- Call parent via __base:MethodName()
end

3.4 Other script types

Annotation Purpose Notes
@Event Define a custom event type Declare event parameters
@Item Define an item type Inventory system
@BTNode Behaviour Tree node AI behavior trees
@State Define a state type State machines
@Struct Struct / user type Composite data types

4. mlua Language Extensions (vs. plain Lua)

mlua is based on Lua 5.3 but differs in the following ways.

Added keywords / operators

Feature Syntax Notes
continue continue Skip to next iteration in a loop (not in standard Lua)
Compound assignment +=, -=, *=, /=, //=, %=, ^=, ..= Multi-assign (a, b += 1, 2) is invalid; cannot be used as a function arg (print(a += 1))
Bitwise operators &, |, <<, >> Compound forms also valid: &=, |=, <<=, >>=

Restrictions

Restriction Description
No global variables The global keyword is not allowed. Values shared across scripts must be declared as Properties.
No coroutines Lua's coroutine.create/resume/yield is not available.
__base instead of super Call parent methods with __base:MethodName(), not super.

Built-in utility functions

Function Signature Purpose
log() log(any... args) Info-level log output
log_warning() log_warning(any... args) Warning-level log output
log_error() log_error(any... args) Error-level log output
wait() wait(number seconds) Pause script execution for the given seconds
isvalid() isvalid(any object) → boolean Validity check (handles deletion / nil)
enum() enum(table t) → table Swap a table's keys and values
beginscope() / endscope() beginscope(string name) / endscope() User profiling scopes

5. Lifecycle

Component and Logic share the same lifecycle.

OnInitialize → OnBeginPlay → OnUpdate(delta) → OnEndPlay → OnDestroy

Plus, on map transitions: OnMapEnter / OnMapLeave.

Method When it fires Purpose
OnInitialize Right after creation Initialize internal variables (rarely used)
OnBeginPlay Game start / activation Wire up events, start timers, initial setup
OnUpdate(delta) Every frame Movement, animation, input handling
OnMapEnter Entering a map Per-map initialization
OnMapLeave Leaving a map Per-map cleanup
OnEndPlay Game end / deactivation Disconnect events, clear timers (mandatory!)
OnDestroy On removal Final cleanup (rarely used)

Required pattern: anything connected in OnBeginPlay must be released in OnEndPlay.

property any eventHandler = nil   -- store the EventHandlerBase returned by ConnectEvent
property integer timerId = 0

method void OnBeginPlay()
    self.eventHandler = self.Entity:ConnectEvent(SomeEvent, self.OnSomeEvent)
    self.timerId = _TimerService:SetTimerRepeat(self.Tick, 1/60)
end

method void OnEndPlay()
    if self.eventHandler then
        self.Entity:DisconnectEvent(SomeEvent, self.eventHandler)
    end
    if self.timerId then
        _TimerService:ClearTimer(self.timerId)
    end
end

ConnectEvent return type: an EventHandlerBase object. When storing it as a property, declare it as any. If you declare it as integer, DisconnectEvent will fail to detach due to a type mismatch.


6. Execution Space (ExecSpace)

MSW is a server-client architecture. Every method must declare where it runs.

ExecSpace Runs on Direction Use case
ServerOnly Server Server-internal only Damage calc, state changes, spawning
ClientOnly Client Client-internal only UI updates, effects, sounds
Server Server Client→Server RPC Client requesting the server (attack, item use)
Client Client Server→Client RPC Server notifying a client (result UI, effects)
Multicast All clients Server→all clients Global events (announcements, boss spawn)
(unspecified) Caller side Server→Server, Client→Client Shared functions executed locally on either side

ExecSpace constraints on lifecycle methods

Method Allowed ExecSpace
OnSyncProperty ClientOnly only
OnInitialize, OnBeginPlay, OnUpdate, OnEndPlay, OnDestroy, OnMapEnter, OnMapLeave ServerOnly, ClientOnly, or unspecified
All event handlers ServerOnly, ClientOnly, or unspecified
Custom user methods Any of Server, Client, ServerOnly, ClientOnly, Multicast

Key rules

-- Calling a ServerOnly function from the client → silently ignored (no error!)
@ExecSpace("ServerOnly")
method void TakeDamage(number amount)
    self.Hp = self.Hp - amount  -- runs only on the server
end

-- Server RPC: client calls → runs on the server
@ExecSpace("Server")
method void RequestAttack()
    -- client calls self:RequestAttack()
    -- actual execution happens on the server (network latency applies)
end

-- Client RPC: server calls → runs on that client
@ExecSpace("Client")
method void ShowDamageEffect(number damage)
    -- server calls self:ShowDamageEffect(50)
    -- runs on the targeted user's client
end

Typical server-client pattern

[Client]                    [Server]
  Detect input (ClientOnly)
       └─── RequestAction() ──→ Validate + handle (ServerOnly)
                                     ├─ State auto-syncs via @Sync
       ←── ShowResult() ────────────┘  (Client RPC)
  Update UI (ClientOnly)

senderUserId — verifying the requester on the server

When an @ExecSpace("Server") method is called from the client, the server side can read the caller's UserId from the local senderUserId variable. This is required for security checks.

@ExecSpace("Server")
method void RequestBuyItem(integer itemId)
    -- Verify the requester: confirm the call came from the local client
    if senderUserId ~= self.Entity.PlayerComponent.UserId then
        log_warning("blocked request from a different client")
        return
    end
    self:ProcessPurchase(itemId)
end

Sending a Client RPC to a specific client only

When the server invokes an @ExecSpace("Client") function, adding a UserId as the last argument at the call site routes execution to that user's client only.

@ExecSpace("Client")
method void ShowReward(string itemName)
    log("reward earned: " .. itemName)
end

@ExecSpace("ServerOnly")
method void GiveReward(string playerId, string itemName)
    -- Show UI only on playerId's client
    self:ShowReward(itemName, playerId)
end

Do not add the UserId parameter to the function declaration. Add it only as the final argument at the call site.

Allowed parameter types across exec spaces

When functions are called across server↔client boundaries:

  • Allowed: string, integer, number, boolean, table, Vector2, Vector3, Vector4, Color, Entity, Component, EntityRef, ComponentRef
  • Not allowed: any
  • SyncTable generic parameters (k, v) must also be one of the allowed types above.

7. Property System

Basic property types

property number Speed = 5.0              -- floating point (float/double)
property integer Count = 0               -- integer
property string Name = "Player"          -- string
property boolean IsAlive = true          -- boolean
property Vector2 Direction = Vector2(0, 0)
property Vector3 Position = Vector3(0, 0, 0)
property Color Tint = Color(1, 1, 1, 1)
property any CustomData = nil            -- arbitrary type

Type-name reminder: integers are integer, floats are number.

Entity / Component reference properties

property Entity targetEntity = ""                    -- linked by UUID string
property Entity popup = "94a274e4-4111-40f1-924d-c95a3a1f14d5"
property ButtonComponent btnOk = "uuid-string"       -- specific component reference
property TextComponent txtScore = "uuid-string"

AI automation principle — inject UUID strings directly

The default value of an Entity/Component/EntityRef/ComponentRef property is a UUID string. The AI must NOT push the work onto the user (e.g., "drag it in the Maker editor"). Instead:

  1. Look up the target entity's id (UUID) in the .map / .ui file.
  2. Hard-code that UUID as a string literal into the .mlua property default.
  3. Apply the same pattern to multiple-slot references (e.g., wp0~wp7 array-style references) — inject each as a string.
@Logic
script WaypointPath extends Logic
    property Entity wp0 = "a1b2c3d4-...-000000000000"
    property Entity wp1 = "a1b2c3d4-...-000000000001"
    -- ... no drag required. Inject UUIDs from the map file as strings.
end

Note: drag-binding in the Maker editor is a convenience for human authors only. In an AI automation flow, UUID-string injection is the default path.

Entity vs EntityRef

Type After map transition Use case
Entity Reference is dropped (nil) References within the same map
EntityRef Reference persists When the reference must survive a map transition
Component Reference is dropped References within the same map
ComponentRef Reference persists When the reference must survive a map transition

Multi-map games should prefer EntityRef/ComponentRef. Entity/Component is sufficient for single-map games.

Sync annotations

Annotation Behavior
@Sync Server → all clients
@TargetUserSync Server → only that user's client

Both take no arguments.

@Sync
property number CurrentHp = 100
-- Server changes the value → automatically reflected on all clients

@TargetUserSync
property number PrivateScore = 0
-- Synced to the owning user's client only

Core rules:

  • Server → client, one direction only. Changing a @Sync value on the client does NOT propagate back to the server.
  • Sync has network latency — not instantaneous.
  • Cannot be synced: any, table (use SyncTable instead).

@TargetUserSync caveat: only meaningful on a component attached to a PlayerEntity. If attached to any other entity, it behaves like a regular @Sync. It pays off (saving bandwidth) for information that other users do not need to see, such as personal currency, achievements, or consumable counts.

SyncTable type

A table that can be synced. Supports both array and dictionary forms.

-- Array form: SyncTable<ValueType>
@Sync
property SyncTable<number> Scores = {}

-- Dictionary form: SyncTable<KeyType, ValueType>
@Sync
property SyncTable<string, number> Stats = {}
  • Use together with @Sync.
  • Different from a plain table — only SyncTable is synchronized.

Temporary properties (_T)

self._T exposes non-synced temporary properties created on the fly. No property declaration is required.

-- Use for non-synced, frame-local state
self._T.accumulatedDamage = 0
self._T.lastAttackTime = 0
self._T.isCharging = false
  • Cannot be @Sync'd — server and client each keep their own values.
  • Convenient because no property declaration is needed, but it does NOT show up in the editor inspector.

OnSyncProperty callback

A callback automatically invoked on the client when a @Sync property changes on the server.

@ExecSpace("ClientOnly")
method void OnSyncProperty(string name, any value)
    if name == "CurrentHp" then
        self:UpdateHpBar(value)
    elseif name == "IsDead" and value == true then
        self:PlayDeathEffect()
    end
end

Rules:

  • Fixed to ClientOnly — ExecSpace cannot be changed.
  • name: the changed property's name.
  • value: the new value.
  • Available on both Component and Logic.

Property editor attributes

Control how the property is shown in the Maker editor inspector:

@DisplayName("Display Name")           -- Override the name shown in the editor
property string InternalName = ""

@Description("Used for ~")             -- Inspector tooltip
property number Damage = 10

@MinValue(0)                           -- Min limit (number/integer)
@MaxValue(999)                         -- Max limit (number/integer)
@Delta(5)                              -- Step value for the mobile editor's +/- buttons
property integer Score = 0

@MaxLength(20)                         -- Max string length
property string Nickname = ""

@HideFromInspector                     -- Hide from the inspector
property any InternalState = nil

8. Event System / RPC

Static handler declaration (statically subscribed events)

@EventSender("Self")
handler HandleHitEvent(HitEvent event)
    local damage = event.TotalDamage
    -- Receives events emitted by my own entity
end

@EventSender parameters

1st parameter 2nd parameter Purpose
"Self" none Events from my own entity
"LocalPlayer" none Events from the local player entity
"Entity" entity ID (string) Events from a specific entity
"Model" model ID (string) Events from a specific model
"Service" service type name (e.g., "InputService") Service events
"Logic" logic type name Logic events
@EventSender("Service", "InputService")
handler HandleKeyDown(KeyDownEvent event)
    -- Receives key events emitted by InputService
end

Dynamic event connect / disconnect

-- Connect (in OnBeginPlay) — return type is an EventHandlerBase object
local eventHandler = entity:ConnectEvent(ButtonClickEvent, self.OnClick)
-- Disconnect (mandatory in OnEndPlay)
entity:DisconnectEvent(ButtonClickEvent, eventHandler)

⚠️ ConnectEvent is called on Entity / Logic / Service — NOT on a Component [VERIFIED]

Only three types expose ConnectEvent / DisconnectEvent:

  • Entity (Misc/Entity.d.mlua:96-104)
  • Logic (Logic/Logic.d.mlua:7-15)
  • Service (Service/Service.d.mlua:8-16)

The public members of the Component parent (Component.d.mlua) are only Enable / Entity / IsClient / IsServer. So no Component — including ButtonComponent and TriggerComponent — has its own ConnectEvent. Components only emit events; the entity they are attached to is what receives them.

-- ❌ Wrong — Component has no ConnectEvent. Runtime nil access.
self.Entity.ButtonComponent:ConnectEvent(ButtonClickEvent, self.OnClick)

-- ✅ Correct — subscribe via the entity that owns the Component
self.clickHandler = self.Entity:ConnectEvent(ButtonClickEvent, self.OnClick)

-- ✅ Service events: subscribe on the service
self.keyHandler = _InputService:ConnectEvent(KeyDownEvent, self.OnKeyDown)

⚠️ handler vs method void — do not mix them up [VERIFIED]

Declaration keyword Use Wiring
handler Name(Ev event) Static subscription — paired with the @EventSender(...) annotation. Engine wires it automatically. Wired by declaration alone
method void Name(Ev event) Dynamic callback — wired at runtime via ConnectEvent(EvType, self.Name) self.Entity:ConnectEvent(...) or _InputService:ConnectEvent(...)

Passing a handler as the callback to ConnectEvent compiles but never fires (E-V1-5). Conversely, putting a method void underneath an @EventSender won't get statically wired. Rules:

  • If @EventSender is also present → use handler.
  • If you will subscribe with ConnectEvent(...) → use method void.

Defining a CustomEvent — typed class style

The only way to author a CustomEvent is the typed-class form: @Event + extends EventType with property fields. There is no inline factory like CustomEvent("Name", { ... }) — that signature does not exist in mlua. Always declare an event class.

Define the event:

@Event
script UserLogEvent extends EventType
    property string userId = ""
    property number logTime = 0
end

Dispatch from a Logic via SendEvent:

@Logic
script UserLogService extends Logic
    method void LogIn(string userId)
        local userLog = UserLogEvent()
        userLog.userId = userId
        userLog.logTime = DateTime.UtcNow.Elapsed
        _UserService:SendEvent(userLog)
    end
end

Receive via ConnectEvent on the entity. The first argument is the event Type (the class itself), and the callback must reference an existing method:

@Component
script UserLogComponent extends Component
    method void OnBeginPlay()
        self.Entity:ConnectEvent(UserLogEvent, self.OnUserLogEvent)
    end

    method void OnUserLogEvent(UserLogEvent event)
        log("User Id: " .. event.userId .. ", Login Time: " .. tostring(event.logTime))
    end
end

Sending events

Instantiate the typed event class, set its property fields, and pass the instance to SendEvent:

@Event
script DamageDealtEvent extends EventType
    property number amount = 0
end

-- Send to my own entity
local dmg = DamageDealtEvent()
dmg.amount = 50
self.Entity:SendEvent(dmg)

-- Send to another entity
local heal = HealEvent()
heal.amount = 20
targetEntity:SendEvent(heal)

NativeEvent vs CustomEvent

NativeEvent CustomEvent
Definition Built into the engine (.d.mlua) User-defined via @Event ... extends EventType
Examples HitEvent, ButtonClickEvent, StateChangedEvent UserLogEvent, DamageDealtEvent (any class you declare)
Parameters Fixed (see per-event spec) property fields you declare on the class
Reference Environment/NativeScripts/Event/ User code

Common NativeEvent parameters

-- HitEvent
event.TotalDamage      -- number: total damage
event.AttackerEntity   -- Entity: attacker
event.DamageType       -- DamageType: damage type

-- ButtonClickEvent
-- (no parameters; emitted by the entity)

-- StateChangedEvent
event.PrevState        -- string: previous state
event.CurState         -- string: current state

-- PlayerActionEvent
event.ActionName       -- string: action name

9. Validity Checks and Method Override

Validity checks

-- Entity validity (deletion / inactive check)
if isvalid(entity) then
    -- safe to access
end

-- Confirm a component exists
local comp = self.Entity.SomeComponent
if isvalid(comp) then
    -- only when the component is present
end

Caution: accessing a deleted entity is a runtime error. Always use isvalid().

Method override

  • Inside an extends-ing script, declaring a method with the same signature as the parent overrides it.
  • Built-in engine components allow override only for methods without ---@sealed.
  • Call the parent original via __base:MethodName(args) (optional, position is flexible).

10. Input / Click Events — World vs UI (Do Not Confuse)

World touch — two approaches

Approach Receiver Event (+ Hold/Release variants) Connect on
Entity touch An entity with TouchReceiveComponent TouchEvent entity:ConnectEvent(...)
Screen touch The whole screen (no component required) ScreenTouchEvent _InputService:ConnectEvent(...)
  • Both events carry TouchId (int32) + TouchPoint (Vector2, screen coords).
  • Entity touch: control the touch area via TouchReceiveComponent's TouchArea/Offset/AutoFitToSize. Suited for per-object interactions (NPCs, items).
  • Screen touch: suited for coordinate-based interactions (tower placement, move target). For world coordinates, convert via _UILogic:ScreenToWorldPosition(event.TouchPoint). Filter UI clicks with _InputService:IsPointerOverUI().
  • If a map touch is not picked up by TouchEvent: it may be a TouchArea or raycast-priority issue. Combining ScreenTouchEvent + ScreenToWorldPosition() is more robust without configuration.

⚠️ Warning — BoxCollider2D / physics colliders do NOT emit TouchEvent [VERIFIED]

If you reason from Unity's OnMouseDown / OnPointerClick and just attach BoxCollider2D / Rigidbody / TriggerComponent to an entity, no touch input will arrive. In MSW, world-entity touch reception is owned exclusively by TouchReceiveComponent (Environment/NativeScripts/Component/TouchReceiveComponent.d.mluaEmitTouchEvent / EmitTouchHoldEvent / EmitTouchReleaseEvent exist only on this component).

Component Role TouchEvent
BoxCollider2D, CircleCollider2D, Rigidbody/Kinematicbody, etc. Physics collision / raycast
TriggerComponent Entity-entity overlap callbacks (TriggerEnter/Exit)
TouchReceiveComponent Touch-input reception

Required setup (TouchReceiveComponent):

  • AutoFitToSize = true — auto-fits TouchArea to the SpriteRenderer/AvatarRenderer scale. Skips manual math.
  • TouchArea = Vector2(w, h) — when set manually, leave 10–20% slack beyond the sprite size (e.g., 1×1 sprite → 1.2×1.2). Too small leads to misaligned hit detection.
  • Offset — adjust only when the sprite pivot is not at center.
  • RelayEventToBehind = true (default) — forwards the event to entities behind. Set false only for standalone objects you want to block from passing through.

Symptoms → diagnosis order:

  1. Is TouchReceiveComponent actually attached to the target entity? (Check .map / .model.)
  2. Is TouchArea zero, or is the entity outside the rendered area?
  3. Is a front-most TouchReceiveComponent blocking with RelayEventToBehind = false?
  4. Did you call entity:ConnectEvent(TouchEvent, handler) in OnBeginPlay and store the handler in property any? (If unstored, GC will collect it.)

Selection rule: "Which entity was touched" → TouchEvent; "Where on the screen was touched (coords)" → ScreenTouchEvent.

Clicks in UI

  • For UI entities, use the ButtonComponent + ButtonClickEvent pattern.
  • UI lives under ./ui/*.ui and the ui tree of the hierarchy.

What goes wrong if you mix them up

  • Putting only UI button events on a world object, or only world-touch components on UI, results in nothing happening.
  • Even if the requirement says "button," first decide whether it is a world object or a UI panel button.

11. Map Context and Entity Spawning

Prefer Entity.CurrentMap

  • For map-dependent logic, Entity.CurrentMap is safer and more readable.

Runtime entity spawning needs a model

  • To create an entity at runtime, use _SpawnService (SpawnByModelId / SpawnByEntity, etc.). A model (template) to spawn from must already exist.
  • If a brand-new kind of object is needed, follow this order: design a .model → place it in a map or write spawn code.

parent parameter caveats

  • SpawnByModelId's parent is required — there is no default, so you must pass a map entity. Passing nil leaves the entity orphaned (not parented), and the engine logs NativeIssue_NotRecommendedValue.
  • In contrast, SpawnByEntity defaults parent = nil and may be omitted — the two methods have different signatures.
  • Get the map entity via self.Entity.CurrentMap or _EntityService:GetEntitiesByPath("/maps/MapName").

Body components and direct Position writes

  • On entities with a Body component (Kinematicbody/Rigidbody/Sideviewbody), setting TransformComponent.WorldPosition directly will be overwritten by the physics engine on the next frame. This is a top cause of "movement doesn't work."
  • Per-frame movement: MovementComponent:MoveToDirection(direction, deltaTime).
  • Instant teleport: MovementComponent:SetPosition(pos) or the corresponding Body's SetPosition(pos).
  • Direct TransformComponent.WorldPosition writes are limited to entities without a Body (decorations, effects, etc.).
  • Do NOT remove the Body component as a workaround — tile collision and enter/leave events all become disabled, and the engine logs NativeIssue_MissingComponent.

12. Frequently Used Services / Logic

All services and logic are accessed via _Name (underscore + type name). Only the most common ones are listed.

Service / Logic Purpose
_SpawnService Spawn / despawn entities (SpawnByModelId, SpawnByEntity, Despawn)
_TimerService Timers (SetTimer, SetTimerRepeat, ClearTimer)
_EntityService Entity lookup (GetEntity, GetEntities, GetEntitiesByPath)
_InputService Input state queries; receives ScreenTouchEvent
_ResourceService Look up resource RUIDs
_DataStorageService Persistent data (player saves) — ⚠️ Credit-billed. Do not call in OnUpdate / short timers; use Batch* in loops. Details: references/datastorage.md
_UtilLogic Random, time, string, and math utilities
_TweenLogic Tween animations (MoveTo, ScaleTo, RotateTo)
_UILogic UI coordinate conversions (e.g., ScreenToWorldPosition) — ClientOnly

For the full list, read the .d.mlua files directly: ./Environment/NativeScripts/Service/ (46 files) and ./Environment/NativeScripts/Logic/ (9 files). For domain details, search via msw-search.


13. Math, Utilities, Reserved Words, Type Annotations

Math / utility examples

-- Random
local rand = _UtilLogic:RandomDouble()              -- 0.0 ~ 1.0
local randInt = _UtilLogic:RandomIntegerRange(1, 10) -- 1 ~ 10

-- Time
local elapsed = _UtilLogic.ElapsedSeconds           -- elapsed game time

-- Trig
local radian = math.rad(angle)
local x = math.cos(radian) * distance
local y = math.sin(radian) * distance

-- Distance
local diff = targetPos - myPos
local dist = math.sqrt(diff.x * diff.x + diff.y * diff.y)

mlua utility classes

Collections / utility types beyond the Lua standard library:

Class Purpose Notes
List Dynamic array (1-based index) Add / remove / search / sort
ReadOnlyList Read-only array For data protection
SyncList Network-synced array Auto-sync server↔client
Dictionary Hash table (key-value) Fast lookup by unique key
ReadOnlyDictionary Read-only hash table For data protection
SyncDictionary Network-synced hash table Auto-sync server↔client
DateTime Date/time Format-string support
TimeSpan Time span Days/hours/minutes/seconds/milliseconds
Regex Regular expression Match / search / replace
Translator Localization Current language, translated text lookup
Quaternion 3D rotation Avoids gimbal lock; smooth rotations
Vector2Int Integer 2D vector Useful for grid coords
FastVector2/3, FastColor High-performance vector / color In-place ops without new objects (when perf matters)
Item Inventory item Quantity, icon RUID, data-table linkage

For detailed APIs, browse Environment/NativeScripts/ or query the msw-search skill.

Type annotations (code hints)

---@-style annotations help editors with autocomplete and type inference. They have no runtime effect — editor assist only.

---@type string
local name = GetPlayerName()

---@type table<string, Entity>
local entityMap = {}

---@param target Entity
---@param damage integer
---@return boolean
local function ApplyDamage(target, damage)
    return target ~= nil
end

Reserved words

Using mlua keywords as local variable names produces a parse error.

Forbidden as variable names: handler, property, method, script, end, extends, self, nil, true, false.

-- Wrong
local handler = entity:ConnectEvent(...)    -- 'handler' is a reserved word

-- Correct (ConnectEvent returns: EventHandlerBase object)
local eventHandler = entity:ConnectEvent(...)
local connHandler = entity:ConnectEvent(...)

14. External Tooling

Need Skill
Maker MCP (refresh, logs, play, stop, screenshot, etc.) msw-general
MCP wiring, .mcp.json, API key setup msw-env-setup
Descriptions, examples, and implementation guides not in .d.mlua msw-search

Core debug order: build logs first → play → logs → stop → fix → diagnose → refresh → repeat.


15. Script Authoring Workflow (essentials)

  1. Search: scan ./RootDesk/MyDesk/**/*.mlua → if a similar script exists, modify it first; do not create new.
  2. Verify spec: Read the .d.mlua → if insufficient, use msw-search.
  3. Decide path (see §1.2 — folder structure is mandatory): pick a feature/category subfolder. Reuse one if it already exists; otherwise create a new feature-named folder. Final path must look like ./RootDesk/MyDesk/<FeatureFolder>/<Name>.mluanever write directly to ./RootDesk/MyDesk/ root.
  4. Write: create the file at the path chosen in step 3.
  5. Validate: mlua-diagnose hook auto-runs on save — fix until errors hit zero.
  6. Refresh: Maker MCP refresh (if MCP is not connected, point the user to the button).
  7. (If needed) play → reproduce → logsstop.

Delete / rename: still requires refresh after the file change. Also clean up references in .model / .map.


16. Attaching Scripts (Components) to Entities

  • Attach to a model template (.model) — recommended: add the script-component entry to the Components array. Map instances inherit it automatically (must follow the .model JSON schema).
  • Only for a specific map instance: add the component to the matching entity definition inside the .map file.
  • Global models (e.g., DefaultPlayer under ./Global/): affect the entire project — confirm the blast radius before changing.

In all cases, do NOT edit .model / .map JSON directly — use the personal builder.


17. Playtesting and Debugging

The procedure for verifying behavior in play mode in Maker, then narrowing down bugs with runtime logs, screenshots, and simulated input.

For the MCP tool list, play-mode constraints, and refresh rules, see msw-general.

17.1 Always Check Build Logs First (the first step of every playtest)

Before entering play mode, you MUST inspect the build console (build logs) first. If there are build errors, scripts will not load or will behave unexpectedly, making runtime debugging meaningless.

1. After refresh → call logs(category="build")
2. Are there build errors?
   ├─ YES → from the error messages, identify file and line → edit the .mlua → refresh → recheck build logs (repeat until errors are zero)
   └─ NO → enter play → run runtime tests

If you run play with build errors:

  • Scripts with errors fail to load entirely — it behaves as if the component/logic isn't there.
  • Build errors may not appear in the runtime logs, making the cause extremely hard to find.
  • Most "the code looks correct but it doesn't work" reports come from missed build errors.

This step is mandatory in every workflow pattern (general test, regression test, error analysis).

17.2 Error Classification Table (by log / symptom)

When reading logs (and the script stack), do a first-pass classification with the table below.

Class Common signs Where to look
Script error Stack trace with file name and line number The exact line in the .mlua; event/timing order
nil reference attempt to index a nil value, crash right before a field access Init order, isvalid, 1-frame timing right after Spawn
component missing Component field is nil; GetComponent fails Components array in .model; typos in name/path
network / sync Only client breaks; values mismatch; values converge after a delay @Sync, only-on-server changes, ExecSpace, RPC flow

To narrow down a cause: if logs alone are inconclusive, add log() outputs to the .mlua to inspect the relevant entity / component / property state.

17.3 Test-Result Report Format

When a playtest ends, summarize briefly in this format.

  1. Scenario name: one-line description of what was verified.
  2. Environment: map / mode (if known); whether a refresh happened before playing.
  3. Steps: a summary of the execution order — input simulation, Lua runs, etc.
  4. Result: Pass / Fail / Blocked (e.g., couldn't enter play).
  5. Evidence: one or two key lines quoted from logs; whether a screenshot exists (only if the user asked for one).
  6. Next action: candidate files to edit, repro conditions, whether to use clear_logs on the next run.

17.4 Workflow Patterns

1) General playtest

  1. Prepare scripts / maps / models in edit mode.
  2. If the workspace was changed, call MCP refresh.
  3. Check build errors with logs(category="build") → if any, fix and refresh until clean.
  4. MCP play → enter play mode.
  5. As needed, use keyboard_input / mouse_input to reproduce input or UI clicks.
  6. As needed, use logs to inspect runtime state.
  7. As needed, use logs(category="runtime") to inspect runtime logs.
  8. MCP stop → return to edit mode.

2) Regression test loop (fix loop)

  1. Edit files based on the previous failure cause.
  2. refresh.
  3. Check build errors with logs(category="build") → if any, fix and refresh until clean.
  4. For a clean repro, call clear_logs then play.
  5. Replay the same scenario via input / Lua.
  6. Use logs(category="runtime") to confirm regression status.
  7. stop, then edit again if needed.

3) Error-analysis workflow

  1. First check build errors with logs(category="build") — fix them before any runtime analysis.
  2. clear_logs (optional) → play.
  3. Reproduce the issue via keyboard_input / mouse_input / in-game manipulation.
  4. Collect logs(category="runtime") and map them to the error classification table above.
  5. If logs are insufficient, add log() outputs in the .mlua to inspect entity / property state.
  6. After stop, fix the code / .model / sync.
  7. refreshrecheck build logsplay to re-verify.

4) Runtime Lua debugging

  1. Add log() calls in the .mlua for the values you want to inspect.
  2. If you don't know the API, look it up first: search .d.mluamsw-search.
  3. refreshplay to enter play mode.
  4. Collect output via logs and analyze.
  5. After analysis, stop → edit → repeat.

17.5 Final Verification Before Completion (PASS/FAIL)

Before reporting "done" to the user, you must pass the following checklist:

Principle: "No errors ≠ Pass." You need positive log()-based evidence that the intended logic actually executed.

Full checklist: references/verify-checklist.md (Step 1 Runtime Execution → Step 2 Code Review → Step 3 Log Evidence → Step 4 PASS/FAIL Verdict)

17.6 Related Skills

  • msw-general: MCP tools, screenshot/logs policy, refresh rules, workspace and hierarchy.
Related skills

More from msw-git/msw-ai-coding-plugins-official

Installs
25
First Seen
10 days ago