msw-scripting
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 asUtil/orShared/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 viamsw-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
.mluafile, themlua-diagnosehook runs LSPdiagnoseautomatically and feeds errors back. - Repeat fix → re-edit until error-severity diagnostics reach zero.
1.5 .codeblock files
.codeblockfiles 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 MCPrefresh. refreshcannot run during play mode — first callstopto 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
methoddeclaration 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 themethoddeclaration. - 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
.mluachange: everymethodyou 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 componentAttackComponent— 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
@Syncproperties — server→client sync behaves the same way
⚠️ Warning —
@Logicdoes NOT haveself.Entity[VERIFIED]The public members of the
Logicparent class (Environment/NativeScripts/Logic/Logic.d.mlua) are onlyConnectEvent/DisconnectEvent/IsClient/IsServer/SendEvent. There is noEntityproperty,GetOwner, orOwner. The engine source (mod/.../MODLogic.cs) confirms this.self.Entityis@Component-exclusive (readonly property Entity EntityinComponent.d.mlua).Code like
self.Entity.xxxinside 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'sOnUpdateruns 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:
- Look up the target entity's
id(UUID) in the.map/.uifile. - Hard-code that UUID as a string literal into the
.mluaproperty default. - Apply the same pattern to multiple-slot references (e.g.,
wp0~wp7array-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/Componentis 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
@Syncvalue on the client does NOT propagate back to the server. - Sync has network latency — not instantaneous.
- Cannot be synced:
any,table(useSyncTableinstead).
@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— onlySyncTableis 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)
⚠️
ConnectEventis 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
Componentparent (Component.d.mlua) are onlyEnable/Entity/IsClient/IsServer. So no Component — includingButtonComponentandTriggerComponent— has its ownConnectEvent. 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)
⚠️
handlervsmethod 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
handleras the callback toConnectEventcompiles but never fires (E-V1-5). Conversely, putting amethod voidunderneath an@EventSenderwon't get statically wired. Rules:
- If
@EventSenderis also present → usehandler.- If you will subscribe with
ConnectEvent(...)→ usemethod 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 amethodwith 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'sTouchArea/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 aTouchAreaor raycast-priority issue. CombiningScreenTouchEvent+ScreenToWorldPosition()is more robust without configuration.
⚠️ Warning —
BoxCollider2D/ physics colliders do NOT emitTouchEvent[VERIFIED]If you reason from Unity's
OnMouseDown/OnPointerClickand just attachBoxCollider2D/Rigidbody/TriggerComponentto an entity, no touch input will arrive. In MSW, world-entity touch reception is owned exclusively byTouchReceiveComponent(Environment/NativeScripts/Component/TouchReceiveComponent.d.mlua—EmitTouchEvent/EmitTouchHoldEvent/EmitTouchReleaseEventexist only on this component).
Component Role TouchEvent BoxCollider2D,CircleCollider2D, Rigidbody/Kinematicbody, etc.Physics collision / raycast ❌ TriggerComponentEntity-entity overlap callbacks ( TriggerEnter/Exit)❌ TouchReceiveComponentTouch-input reception ✅ Required setup (
TouchReceiveComponent):
AutoFitToSize = true— auto-fitsTouchAreato theSpriteRenderer/AvatarRendererscale. 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. Setfalseonly for standalone objects you want to block from passing through.Symptoms → diagnosis order:
- Is
TouchReceiveComponentactually attached to the target entity? (Check.map/.model.)- Is
TouchAreazero, or is the entity outside the rendered area?- Is a front-most
TouchReceiveComponentblocking withRelayEventToBehind = false?- Did you call
entity:ConnectEvent(TouchEvent, handler)inOnBeginPlayand store the handler inproperty 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+ButtonClickEventpattern. - UI lives under
./ui/*.uiand theuitree 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.CurrentMapis 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'sparentis required — there is no default, so you must pass a map entity. Passingnilleaves the entity orphaned (not parented), and the engine logsNativeIssue_NotRecommendedValue.- In contrast,
SpawnByEntitydefaultsparent = niland may be omitted — the two methods have different signatures. - Get the map entity via
self.Entity.CurrentMapor_EntityService:GetEntitiesByPath("/maps/MapName").
Body components and direct Position writes
- On entities with a Body component (Kinematicbody/Rigidbody/Sideviewbody), setting
TransformComponent.WorldPositiondirectly 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'sSetPosition(pos). - Direct
TransformComponent.WorldPositionwrites 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.mluafiles directly:./Environment/NativeScripts/Service/(46 files) and./Environment/NativeScripts/Logic/(9 files). For domain details, search viamsw-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 themsw-searchskill.
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)
- Search: scan
./RootDesk/MyDesk/**/*.mlua→ if a similar script exists, modify it first; do not create new. - Verify spec: Read the
.d.mlua→ if insufficient, usemsw-search. - 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>.mlua— never write directly to./RootDesk/MyDesk/root. - Write: create the file at the path chosen in step 3.
- Validate:
mlua-diagnosehook auto-runs on save — fix until errors hit zero. - Refresh: Maker MCP
refresh(if MCP is not connected, point the user to the button). - (If needed)
play→ reproduce →logs→stop.
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 theComponentsarray. Map instances inherit it automatically (must follow the.modelJSON schema). - Only for a specific map instance: add the component to the matching entity definition inside the
.mapfile. - Global models (e.g.,
DefaultPlayerunder./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.
- Scenario name: one-line description of what was verified.
- Environment: map / mode (if known); whether a
refreshhappened before playing. - Steps: a summary of the execution order — input simulation, Lua runs, etc.
- Result: Pass / Fail / Blocked (e.g., couldn't enter play).
- Evidence: one or two key lines quoted from
logs; whether a screenshot exists (only if the user asked for one). - Next action: candidate files to edit, repro conditions, whether to use
clear_logson the next run.
17.4 Workflow Patterns
1) General playtest
- Prepare scripts / maps / models in edit mode.
- If the workspace was changed, call MCP
refresh. - Check build errors with
logs(category="build")→ if any, fix and refresh until clean. - MCP
play→ enter play mode. - As needed, use
keyboard_input/mouse_inputto reproduce input or UI clicks. - As needed, use
logsto inspect runtime state. - As needed, use
logs(category="runtime")to inspect runtime logs. - MCP
stop→ return to edit mode.
2) Regression test loop (fix loop)
- Edit files based on the previous failure cause.
refresh.- Check build errors with
logs(category="build")→ if any, fix and refresh until clean. - For a clean repro, call
clear_logsthenplay. - Replay the same scenario via input / Lua.
- Use
logs(category="runtime")to confirm regression status. stop, then edit again if needed.
3) Error-analysis workflow
- First check build errors with
logs(category="build")— fix them before any runtime analysis. clear_logs(optional) →play.- Reproduce the issue via
keyboard_input/mouse_input/ in-game manipulation. - Collect
logs(category="runtime")and map them to the error classification table above. - If logs are insufficient, add
log()outputs in the.mluato inspect entity / property state. - After
stop, fix the code /.model/ sync. refresh→ recheck build logs →playto re-verify.
4) Runtime Lua debugging
- Add
log()calls in the.mluafor the values you want to inspect. - If you don't know the API, look it up first: search
.d.mlua→msw-search. refresh→playto enter play mode.- Collect output via
logsand analyze. - 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.
More from msw-git/msw-ai-coding-plugins-official
msw-general
Foundation skill for MSW (MapleStory Worlds). Read this FIRST before anything else in MSW.
25msw-search
MSW search integration — (1) vector search for API docs and implementation guides (msw-guide-mcp or curl against mlua_Document_Retriever / mlua_API_Retriever), (2) REST API search for resources (sprite / animation / sound / resource pack / avatar). Use for 'find details, examples, or related APIs not in .d.mlua', 'need a SpriteRUID', 'monster sprite', 'background image', 'find a sound', 'avatar rendering', etc. Keywords: document search, API details, examples, guide, retriever, resource, sprite, animation, sound, RUID, resource pack, avatar.
25msw-defaultplayer
MSW DefaultPlayer(캐릭터) 관리. DefaultPlayer.model 파일 직접 조회/수정, 컴포넌트 추가/제거, 이동 속도/점프력/HP/카메라 설정, 맵 모드별 이동 컴포넌트. Use for DefaultPlayer model, player components, movement speed, jump force, HP, camera, physics. Keywords: player, DefaultPlayer, speed, jump, HP, camera, gravity, 플레이어, 캐릭터, 이동속도, 점프력, 중력, 카메라, 체력, 부활.
25msw-costume
MSW 아바타 코스튬 관리. 코스튬 조회/적용, CostumeManagerComponent 17슬롯, 아바타 유틸리티. DefaultPlayer뿐 아니라 모든 엔티티(NPC, 몬스터 등)에 적용 가능. Use for avatar costume get/set, CostumeManagerComponent 17 equip slots, avatar utilities. Keywords: costume, avatar, equip, slot, 코스튬, 아바타, 장비, 꾸미기, 모자, 상의, 하의, 무기.
23msw-avatar
MSW 아바타 관리 — 코스튬(CostumeManagerComponent 17슬롯) + 애니메이션 3계층 파이프라인(StateComponent → AvatarStateAnimationComponent → AvatarRendererComponent). State 키(대문자)/AvatarBodyActionStateName(소문자)/MapleAvatarBodyActionState enum/스프라이트 액션 ID(swingO1, shoot1) 4단계 구분, IsLegacy/ActionSheet/StateToAvatarBodyActionSheet 두 시스템, PlayerControllerComponent 자동 상태 전이와 ActionStateChangedEvent 충돌 시 RemoveActionSheet/SetActionSheet/BodyActionStateChangeEvent로 해결. DefaultPlayer뿐 아니라 모든 엔티티(NPC, 몬스터 등)에 적용 가능. Use for avatar costume get/set, 17 equip slots, animation state mapping, action override, weapon-specific attack motion, custom shoot/cast/dance action. Keywords: avatar, costume, animation, state, action, shoot, swing, weapon, ActionStateChangedEvent, BodyActionStateChangeEvent, RemoveActionSheet, SetActionSheet, StateToAvatarBodyActionSheet, AvatarStateAnimationComponent, AvatarRendererComponent, MapleAvatarBodyActionState, PlayerControllerComponent, 아바타, 코스튬, 애니메이션, 상태, 동작, 장비, 무기, 공격, 활, 칼, 사격, 휘두르기, 커스텀 액션, 자동 재생 차단, 매핑 변경.
3msw-combat-system
MSW 전투 시스템 통합 가이드. Attack→Hit 파이프라인, 데미지 모델, i-frame, 넉백, Hit Stop, Camera Shake, Sprite Flash, SFX, 사망/부활, 데미지 스킨, 히트 이펙트, 아바타 전투 모션, 커스텀 이벤트, AI FSM 까지 MSW 네이티브 API 기반 2D 다장르 대응. '공격', '피격', '데미지', '전투', '몬스터', '히트', '이펙트', '크리티컬', '투사체', '데미지 스킨', '넉백', '히트스톱', '콤보', 'HP바' 등.
3