msw-scripting
Authoring MSW scripts (.mlua) plus integrated playtest and debugging. Covers mlua syntax, annotations (@Component/@Logic/@ExecSpace/@Sync), lifecycle, exec spaces, property sync, event system, file workflow, build-log inspection, error classification, and the test/debug loop. Keywords: script, mlua, lua, Component, Logic, annotation, ExecSpace, Sync, event, play, test, debug, lifecycle.
Other options
Summary
Authoring MSW scripts (.mlua) plus integrated playtest and debugging. Covers mlua syntax, annotations (@Component/@Logic/@ExecSpace/@Sync), lifecycle, exec spaces, property sync, event system, file workflow, build-log inspection, error classification, and the test/debug loop. Keywords: script, mlua, lua, Component, Logic, annotation, ExecSpace, Sync, event, play, test, debug, lifecycle.
Raw SKILL.md
44.2K bytes---
name: msw-scripting
description: "Authoring MSW scripts (.mlua) plus integrated playtest and debugging. Covers mlua syntax, annotations (@Component/@Logic/@ExecSpace/@Sync), lifecycle, exec spaces, property sync, event system, file workflow, build-log inspection, error classification, and the test/debug loop. Keywords: script, mlua, lua, Component, Logic, annotation, ExecSpace, Sync, event, play, test, debug, lifecycle."
---
# 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`, glob/keyword-search under `./RootDesk/MyDesk/` for an existing script with the same purpose — **extending an existing file is always the first choice**. Duplicate implementations raise maintenance cost and conflict risk.
### 1.2 Folder Structure for New Scripts — Never Dump Files Flat
When a new `.mlua` is unavoidable, place it under a feature/category subfolder. **Required path shape**: `./RootDesk/MyDesk/<FeatureFolder>/<ScriptName>.mlua`.
- **Reuse** an existing subfolder if it fits (`Player/`, `UI/`, `Combat/`, `Inventory/`, …); glob `./RootDesk/MyDesk/` first.
- **Otherwise create** one named for the feature (PascalCase). All related scripts of one feature (Component/Logic/Event/Struct) stay together. Even a single-file feature gets its own folder.
- **Forbidden**: catch-all folders like `Scripts/`, `Misc/`, `Common/`, `New/`, `temp/`. A flat root makes rule §1.1 (search before creating) impossible.
Examples: `Inventory/InventoryManager.mlua`, `Combat/MeleeAttackComponent.mlua`, `UI/Popup/RewardPopupLogic.mlua`.
### 1.3 Never Guess APIs — Verify Before Writing
Guessing an MSW API name/param/return type **silently fails at runtime**. Required order: **`.d.mlua` for signature** → **`msw-search` for semantics/examples** if needed → write → LSP diagnose (auto-run).
The engine API lives under `./Environment/NativeScripts/`:
| Folder | Contents | Count |
|------|------|:-:|
| `Component/` | Engine components | 104 |
| `Service/` | System services | 46 |
| `Event/` | Event types | 202 |
| `Logic/` | Built-in logic | 9 |
| `Enum/` | Enumerations | 118 |
| `Misc/` | Utility types (Vector2, …) | 140 |
Known name → `Read ./Environment/NativeScripts/{folder}/{name}.d.mlua`. Unknown name → Grep keywords there.
### 1.4 Lint (LSP diagnostics)
`mlua-diagnose` hook runs LSP `diagnose` automatically after every `.mlua` create/modify. Iterate fix → re-edit until error-severity diagnostics reach zero.
### 1.5 `.codeblock` & Refresh
- `.codeblock` files are generated by Maker Refresh — never create/edit/delete manually.
- After any `.mlua` create/modify/rename/delete, call Maker MCP **`refresh`**. Refresh requires edit mode — `stop` first if playing.
### 1.6 MSW ≠ Unity — Do Not Reason From Intuition
Applying Unity/generic patterns directly **compiles fine but silently fails at runtime**. Common misconceptions:
| Unity intuition | MSW reality / Where it's covered |
|---|---|
| `gameObject` / `transform` from a global manager | `@Logic` has no `self.Entity` — see §3.2 (use property injection / `_EntityService`) |
| `OnMouseDown` / `BoxCollider2D` for clicks | Physics colliders never emit `TouchEvent` — World uses `TouchReceiveComponent` (§10); UI uses `ButtonComponent`/`UITouchReceiveComponent` |
| `OnCollisionEnter` + Rigidbody | Entity↔entity collisions need `TriggerComponent` + `TriggerEnter/Leave/Stay` event |
| UI field names (`interactable`/`text`/`color`) | MSW-specific names — check [`msw-ui-system/references/component-api.md`](../msw-ui-system/references/component-api.md). Common mappings: disable→`Enable`, text→`Text`, text color→`FontColor`, tint→`Color`. `ButtonComponent.Interactable` doesn't exist. |
| Attach multiple Rigidbody/Collider freely | **One Body per map type** — see [`msw-general/references/platform.md`](../msw-general/references/platform.md) §4 |
| Touch UI from server code | **UI is client-only** — server→UI goes via `@ExecSpace("Client")` RPC. Hosting `Server`/`ServerOnly`/`Multicast`/`@Sync` on a UI-attached Component silently no-ops with runtime warning. See [`msw-ui-system/references/runtime-patterns.md`](../msw-ui-system/references/runtime-patterns.md) |
| `Instantiate(prefab)` callable anywhere | `_SpawnService:SpawnByModelId(id, name, pos, parent)` — `parent` required, server-only — see §11 |
| `static` classes / hand-rolled singletons | `@Logic` is itself the singleton — call as `_ScriptName:Method()`, never instantiate — see §3.2 |
**Rule**: when tempted to apply a Unity pattern, stop and verify against `Environment/NativeScripts/*.d.mlua` first.
### 1.7 Builder Protocol Preflight — **MUST**
If this turn touches `.map` / `.model` / `.ui` (directly, or via spawn/entity-placement/UI-binding code in `.mlua`), **[`../msw-general/references/builder-protocol.md`](../msw-general/references/builder-protocol.md) (core) plus the per-builder file for each type touched ([`builder-protocol-map.md`](../msw-general/references/builder-protocol-map.md) / [`builder-protocol-model.md`](../msw-general/references/builder-protocol-model.md) / [`builder-protocol-ui.md`](../msw-general/references/builder-protocol-ui.md)) must be fully in context first** (`Read` the full files only if never loaded this session or lost to compaction — a memorized summary does not count as in context). The core carries the shared write-side contract and cross-flow; each per-builder file carries that builder's API, `typeKey` metadata, auto-lint, child-entity invariants, and `placeModel` mirroring; knowing one builder doesn't cover another.
**Triggers** (broad on purpose): `_SpawnService` / `SpawnByModelId` / `SpawnByEntity`; any `.map`/`.model`/`.ui` change; calling `msw_map_builder.cjs` / `msw_model_builder.cjs` / `msw_ui_builder.cjs`; any "new monster/NPC/popup/map object" request; §11 or §16 work.
### 1.8 Method Documentation Comments — Inside the Body
Every `method` (lifecycle, RPC, event handler, user-defined) **must** have a description comment as the **first line inside the body**, never above the declaration. mlua's parser binds leading comments to the previous declaration, so an "above" comment is unreliable.
```lua
-- ✅ Correct
method void ApplyDamage(Entity target, number amount)
-- Applies damage and triggers hit VFX.
target:TakeDamage(amount)
end
-- ❌ Wrong — comment above the method
-- Applies damage...
method void ApplyDamage(Entity target, number amount)
target:TakeDamage(amount)
end
```
---
## 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` plus existing `./Global/*.model` files in place | 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.
```lua
@Component
script MyScript extends Component
property number Speed = 5.0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- initialization (also: OnUpdate(delta), OnEndPlay)
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.
```lua
@Logic
script GameManager extends Logic
@Sync property integer Score = 0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- global initialization (also: OnUpdate, OnEndPlay)
end
end
```
- One per world (singleton)
- Accessed as `_<ExactScriptName>` — **no suffix stripping**. `TDHUDLogic.mlua` → `_TDHUDLogic` (not `_TDHUD`); `TowerDefenseConfig.mlua` → `_TowerDefenseConfig`. Heuristic stripping silently returns `nil`.
- Supports `@Sync` properties (server→client)
- Logic's `OnUpdate` runs **before** Components'.
> ⚠️ **`@Logic` has no `self.Entity`** — Logic parent only exposes `ConnectEvent`/`DisconnectEvent`/`IsClient`/`IsServer`/`SendEvent`. `self.Entity.xxx` compiles but is a runtime nil-access. To bind a world entity, inject via property (`property Entity x = "uuid"` / `property EntityRef x = ""`) or look it up with `_EntityService:GetEntityByPath(...)` / `:FindEntityByName(...)`. Property injection (UUID literal) is preferred. See §7.
>
> ⚠️ **`OnMapEnter` / `OnMapLeave` never fire on `@Logic`** — they're Component-only (see §5). Declaring them on a Logic is silent dead code.
> **Decision: @Component vs @Logic — by lifetime, not "is it global?"**
>
> | Scope | Pick | Why |
> |---|---|---|
> | World-wide, survives every map transition (account state, world event bus, global UI manager) | **`@Logic`** | Engine singleton; lives for whole world session. |
> | **Map-scoped** — only meaningful inside one map (quest controller, wave spawner, puzzle) | **`@Component` on the map entity** | Cleaned on map unload. Putting this in `@Logic` leaks state/timers across maps. |
> | One actor (monster AI, item pickup, player skill) | **`@Component`** on that entity | |
>
> Ask: *"Still running after the player walks to another map?"* — Yes ⇒ `@Logic`; No (this map) ⇒ `@Component` on map entity; No (this actor) ⇒ `@Component` on actor.
### 3.3 Extend scripts
```lua
@Component
script PlayerAttack extends AttackComponent
-- Override parent methods; call parent via __base:MethodName()
end
```
### 3.4 Other script types
`@Event` (custom event) · `@Item` (inventory) · `@BTNode` (behaviour tree) · `@State` (state machine) · `@Struct` (composite data type).
---
## 4. mlua Language Extensions (vs. plain Lua)
Based on Lua 5.3 with these differences:
**Added syntax**:
- `continue` — skip to next loop iteration.
- Compound assignment: `+=`, `-=`, `*=`, `/=`, `//=`, `%=`, `^=`, `..=` (and bitwise `&=`, `|=`, `<<=`, `>>=`). Multi-assign (`a, b += 1, 2`) and use as a function arg (`print(a += 1)`) are invalid.
- Bitwise operators: `&`, `|`, `<<`, `>>`.
**Restrictions**:
- **No globals** (`global` keyword forbidden) — share values via Properties.
- **No coroutines** (`coroutine.*`).
- Parent call is `__base:MethodName()`, not `super`.
**Built-in utility functions**:
| Function | Purpose |
|------|------|
| `log()` / `log_warning()` / `log_error()` | Logging at each severity |
| `wait(seconds)` | Pause script execution |
| `isvalid(obj) → boolean` | Validity (handles deletion/nil) |
| `enum(t) → table` | Swap keys and values |
| `beginscope(name)` / `endscope()` | Profiling scopes |
---
## 5. Lifecycle
```
OnInitialize → OnBeginPlay → OnUpdate(delta) → OnEndPlay → OnDestroy
↑
OnMapEnter / OnMapLeave (Component only, per transition)
```
| Method | When | Where | Purpose |
|--------|------|------|------|
| `OnInitialize` | After creation | Component + Logic | Init internal vars (rarely used) |
| `OnBeginPlay` | Game start | Component + Logic | **Wire events, start timers, initial setup** |
| `OnUpdate(delta)` | Every frame | Component + Logic (**Logic first**) | Movement, animation, input |
| `OnMapEnter` / `OnMapLeave` | Map transition | **Component only** (silent no-op on Logic) | Per-map init/cleanup |
| `OnEndPlay` | Game end | Component + Logic | **Disconnect events, clear timers (mandatory!)** |
| `OnDestroy` | Removal | Component + Logic | Final cleanup (rarely used) |
**Required pattern**: everything connected in `OnBeginPlay` must be released in `OnEndPlay` (events, timers).
```lua
property any eventHandler = nil -- EventHandlerBase (must be 'any'; not integer)
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
```
---
## 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` |
### Typical server-client pattern
```
[Client] input (ClientOnly) ──Request()──→ [Server] validate (ServerOnly)
├─ state auto-syncs via @Sync
[Client] UI update (ClientOnly) ←──Show()──────┘ (Client RPC)
```
- `ServerOnly`: client call is silently ignored (no error).
- `Server`: client→server RPC (network latency).
- `Client`: server→client RPC; add UserId as the **last call-site arg** to target one client (do NOT add it to the declaration).
### `senderUserId` — verifying the requester
Inside an `@ExecSpace("Server")` body, the local `senderUserId` holds the caller client's UserId (server-assigned, not client-modifiable). Use it for security checks.
```lua
@ExecSpace("Server")
method void RequestBuyItem(integer itemId)
if senderUserId ~= self.Entity.PlayerComponent.UserId then return end
self:ProcessPurchase(itemId)
end
```
### Reserved parameter names — `name is unavailable`
Four parameter names are reserved for the RPC marshaller and cannot be used as your own parameter names on any `@ExecSpace(...)` method. The LSP blocks the script with `'<name>' name is unavailable.`:
| Reserved | What the engine uses it for |
|---|---|
| `self` | Method receiver |
| `senderUserId` | Calling client's UserId on `@ExecSpace("Server")` bodies |
| `targetUserId` | Recipient client's UserId (last call-site arg on `@ExecSpace("Client")` bodies — do NOT declare it; the engine appends it) |
| `messageOwnerEntity` | Originating entity for some service callbacks |
Rename your own parameters when they collide (`targetUserId` → `forUserId`, `senderUserId` → `fromUserId`). `self` is the receiver and cannot be aliased — pick any other name for an unrelated parameter.
### Manual branching — `IsServer()` / `IsClient()` are **methods**, not properties
When a method has no `@ExecSpace` (runs on whichever side called it) and needs different paths per side, branch with `self:IsServer()` / `self:IsClient()`. Both are declared as `method boolean IsServer()` / `method boolean IsClient()` on `Component` and `Logic` — they must be **called**, not read.
```lua
if self:IsServer() then ... end -- ✅ method call → boolean
if self.IsServer then ... end -- ❌ method object itself → always truthy
```
The dot-without-parens form is a silent bug: the LSP doesn't flag it, the script compiles, and the "if" always enters because a method object is truthy — so client-only code runs on the server too (or vice versa). The symptom is "both branches execute on both sides," not a crash. Use colon-call (`self:IsServer()`) every time.
### Cross-boundary parameter types
Allowed across server↔client RPC: `string`, `integer`, `number`, `boolean`, `table`, `Vector2/3/4`, `Color`, `Entity`, `Component`, `EntityRef`, `ComponentRef`. **`any` not allowed.** Engine enums also do not cross — neither typed (the LSP rejects engine enum types as parameters) nor smuggled via `any` (runtime `LEA-3036 InvalidCast`). Standard workaround: encode the choice as a `string` key on the sender, branch on the receiver, and convert back to the enum locally. `SyncTable<k,v>` generics must also be from the allowed list.
---
## 7. Property System
### Basic types
`number` (float/double — integers are separate type `integer`), `string`, `boolean`, `Vector2`/`Vector3`, `Color` (r,g,b,a in 0.0~1.0), `any`.
```lua
property number Speed = 5.0
property integer Count = 0
property Vector2 Direction = Vector2(0, 0)
property Color Tint = Color(1, 1, 1, 1)
```
### Entity / Component reference properties
```lua
property Entity targetEntity = "94a274e4-4111-40f1-924d-c95a3a1f14d5" -- UUID string literal
property ButtonComponent btnOk = "uuid-string" -- typed component ref
```
**AI must inject UUIDs directly** — read `id` from `.map`/`.ui` and hard-code as string literal. Never ask the user to drag-bind in the editor (that's a human-author convenience).
### Entity vs EntityRef
`Entity` / `Component` references are **dropped on map transition**. `EntityRef` / `ComponentRef` **survive** map transitions — prefer for multi-map games.
### Sync annotations
- `@Sync` — server → all clients. One-way; client-side change does NOT propagate back. Has network latency.
- `@TargetUserSync` — server → owning user's client only. Useful for per-player private data (currency, achievements). On a non-PlayerEntity it falls back to plain `@Sync`.
- **Cannot be synced**: `any`, `table` — use `SyncTable` instead.
- Both take no arguments.
```lua
@Sync property number CurrentHp = 100
@TargetUserSync property number PrivateScore = 0
@Sync property SyncTable<number> Scores -- array form, NO default literal
@Sync property SyncTable<string, number> Stats -- dict form, NO default literal
```
#### `SyncTable<...>` property — no default literal
Declare `SyncTable<V>` (array form) or `SyncTable<K, V>` (dict form) **without** an `= ...` initializer. The engine reserves the `=` slot of a SyncTable property for its own type bookkeeping and auto-initializes the property to an **empty collection** at runtime. Any literal you write (`= {}`, `= { key = val }`, `= nil`) is silently dropped — it is misleading noise, not a real default, and a round-trip through the codeblock will erase it.
Populate initial entries in `OnInitialize` / `OnBeginPlay`:
```lua
@Sync property SyncTable<string, number> Stats -- empty at construction
@Sync property SyncTable<number> Scores -- empty at construction
method void OnBeginPlay()
if self:IsServer() then
self.Stats["hp"] = 100
self.Stats["mp"] = 50
self.Scores:Add(0)
end
end
```
Assigning a plain Lua table to a `SyncTable` property at runtime is also rejected — the property accepts only its own proxy. Mutate it field by field (`self.Stats[k] = v`) or call its methods (`self.Scores:Add(v)` / `:Remove(v)` / `:Clear()`).
#### `SyncList<V>` is not a user property type
`SyncList<V>` is exposed only as a **readonly property on native engine Components** (e.g. `TagComponent.Tags`, `PhysicsColliderComponent.PolygonPoints`, `SkeletonRendererComponent.AnimationNames`, the various `JointComponent.Joints`). User scripts can **read** these and call their methods (`:Add(v)`, `:Remove(v)`, `:Clear()`, `.Count`, `:ToTable()`), but cannot declare `property SyncList<...> X` on their own `@Component` / `@Logic` and cannot instantiate `SyncList(...)`.
For synced collections in your own scripts, use `SyncTable<V>` (array form) or `SyncTable<K, V>` (dict form) — see above.
### Temporary properties (`_T`)
`self._T.<name>` is non-synced, declaration-free ad-hoc state. Server and client keep their own values; never shown in inspector. Cannot be `@Sync`'d.
> ⚠️ **`_T` is the ONLY declaration-free field.** Assigning to any other undeclared `self.<name>` is a runtime error — `cannot set <name>, no such field` — that kills the calling method (usually all of `OnBeginPlay`). Every `self.<name>` you set must be a declared `property`, or go through `self._T.<name>`. The build log stays clean except an easily-missed `LIA-1114` Info (see §17.2).
### `OnSyncProperty` callback
Client-side hook fired when a `@Sync` property changes. **Must be `ClientOnly`** (cannot be changed). Available on Component and Logic.
```lua
@ExecSpace("ClientOnly")
method void OnSyncProperty(string name, any value)
if name == "CurrentHp" then self:UpdateHpBar(value) end
end
```
### Property editor attributes
```lua
@DisplayName("...") @Description("...") @MaxLength(20) @HideFromInspector
@MinValue(0) @MaxValue(999) @Delta(5) -- Delta = mobile +/- step
```
---
## 8. Event System / RPC
### Static subscription — `@EventSender` + `handler`
```lua
@EventSender("Self") handler HandleHitEvent(HitEvent event) ... end
@EventSender("Service", "InputService") handler HandleKeyDown(KeyDownEvent event) ... end
```
`@EventSender` 1st arg: `"Self"` / `"LocalPlayer"` (no 2nd arg) · `"Entity"`,id / `"Model"`,id / `"Service"`,typeName / `"Logic"`,typeName.
### Dynamic subscription — `ConnectEvent` / `DisconnectEvent`
```lua
property any clickHandler = nil
self.clickHandler = entity:ConnectEvent(ButtonClickEvent, self.OnClick) -- OnBeginPlay
entity:DisconnectEvent(ButtonClickEvent, self.clickHandler) -- OnEndPlay (mandatory)
```
For per-element captured state (card IDs, slot indexes), use a closure handler; store the returned `EventHandlerBase` in a table and disconnect each in `OnEndPlay`.
```lua
property table clickHandlers = {}
for _, id in ipairs(cardIds) do
local capturedId = id
local h = e:ConnectEvent(ButtonClickEvent, function() self:OnCardClicked(capturedId) end)
table.insert(self.clickHandlers, { entity = e, handler = h })
end
```
> ⚠️ **`ConnectEvent` is on Entity / Logic / Service — NOT Component.** Components only *emit* events; subscribe on the owning **Entity** (or `_InputService` / `_<LogicName>`). `self.Entity.ButtonComponent:ConnectEvent(...)` runtime nils.
>
> ```lua
> property any clickHandler = nil
> property any keyHandler = nil
> self.clickHandler = self.Entity:ConnectEvent(ButtonClickEvent, self.OnClick)
> self.keyHandler = _InputService:ConnectEvent(KeyDownEvent, self.OnKeyDown)
> ```
> ⚠️ **`handler` vs `method void`** — `handler Name(Ev e)` pairs with `@EventSender(...)` and is wired by declaration. `method void Name(Ev e)` is the dynamic callback wired via `ConnectEvent(EvType, self.Name)`. Mixing them compiles but never fires (E-V1-5). If `@EventSender` is present → `handler`; if you'll call `ConnectEvent` → `method void`.
### CustomEvent — typed class style
The **only** way to author one is `@Event` + `extends EventType` with `property` fields. There is no inline factory.
```lua
@Event
script DamageDealtEvent extends EventType
property number amount = 0
end
local dmg = DamageDealtEvent(); dmg.amount = 50
self.Entity:SendEvent(dmg) -- via Entity / Logic / Service
self.Entity:ConnectEvent(DamageDealtEvent, self.OnDamage) -- first arg = event Type
method void OnDamage(DamageDealtEvent event) log(event.amount) end
```
NativeEvent (engine-provided, e.g., `HitEvent.TotalDamage/.AttackerEntity`, `ButtonClickEvent`, `StateChangedEvent.PrevState/.CurState`) — see `Environment/NativeScripts/Event/`.
---
## 9. Validity Checks and Method Override
### Validity checks
Accessing a deleted entity is a runtime error — always `isvalid()` first.
```lua
if isvalid(entity) then ... end
if isvalid(self.Entity.SomeComponent) then ... end
```
### Method override
In an `extends`-ing script, a `method` with the same signature as the parent overrides it. Built-in engine methods marked `---@sealed` cannot be overridden. Call the parent original via `__base:MethodName(args)`.
#### ⚠️ LEA-3014 `SignatureMismatch` — ExecSpace must match the parent
"Same signature" **includes `@ExecSpace`**. The override must be **byte-identical** to the parent's annotation block — including the **absence** of one. Adding `@ExecSpace("ServerOnly")` to "make it server-side" when the parent has none → runtime LEA-3014.
**Common offenders**: AttackComponent / HitComponent damage hooks (`CalcDamage`, `CalcCritical`, `GetCriticalDamageRate`, `GetDisplayHitCount`, `IsAttackTarget`, `IsHitTarget`, `OnAttack`) are all declared **without** `@ExecSpace`. Override with no annotation — they're still safe because the server-side hit pipeline is the only caller.
**Workflow**: read the parent in `.d.mlua` (§1.3) and copy its annotation block verbatim. Fix LEA-3014 by aligning the child's `@ExecSpace` to the parent's, never the reverse.
---
## 10. Input / Click Events — World vs UI (Do Not Confuse)
### World touch — two approaches
| Approach | Event | Connect on | Use |
|------|------|----------|-----|
| **Entity touch** — `TouchReceiveComponent` on entity | `TouchEvent` (+Hold/Release) | `entity:ConnectEvent(...)` | "Which entity was touched" — NPCs, items |
| **Screen touch** — no component | `ScreenTouchEvent` | `_InputService:ConnectEvent(...)` | "Where on the screen" — placement, move target |
Both events carry `TouchId` (int32) + `TouchPoint` (Vector2 screen coord). For world coords, `_UILogic:ScreenToWorldPosition(event.TouchPoint)`. Filter UI clicks with `_InputService:IsPointerOverUI()`. If `TouchEvent` misses, `ScreenTouchEvent` + `ScreenToWorldPosition` is the no-config fallback.
> ⚠️ **Physics colliders do NOT emit `TouchEvent`** — `BoxCollider2D`, `CircleCollider2D`, Rigidbody/Kinematicbody, and `TriggerComponent` all do not deliver touch input. Only **`TouchReceiveComponent`** emits `TouchEvent`/`TouchHoldEvent`/`TouchReleaseEvent`.
>
> **Setup**: `AutoFitToSize = true` (auto-fits TouchArea to the Sprite/Avatar scale) is the simplest path. Manual `TouchArea` should leave 10–20% slack beyond the sprite. `RelayEventToBehind = true` (default) forwards through; set `false` only to block.
>
> **Not firing? Check, in order**: (1) `TouchReceiveComponent` actually attached (in `.map` / `.model`); (2) `TouchArea` non-zero and entity visible on screen; (3) no front entity blocking with `RelayEventToBehind = false`; (4) handler stored in a `property any` (otherwise GC'd).
> **Selection rule**: "Which entity was touched" → `TouchEvent`; "Where on the screen" → `ScreenTouchEvent`.
### PC mouse buttons (left/right/middle) — use `KeyDownEvent`, not `ScreenTouchEvent.TouchId == 2`
`ScreenTouchEvent` fires on PC only for the left button (`TouchId == 1`); `TouchId == 2` is mobile two-finger touch — reading right-click through it works in the Maker simulator but is **silent no-input on real PCs**. For PC mouse buttons, `_InputService:ConnectEvent(KeyDownEvent, ...)` and branch on `event.key == KeyboardKey.Mouse0` / `Mouse1` / `Mouse2` (Left = 323, Right = 324, Middle = 325). To support both mobile multi-touch and PC, connect both `ScreenTouchEvent` and `KeyDownEvent` — they don't double-fire (no mobile right-click; no PC `TouchId == 2`).
### UI clicks
For UI entities (`./ui/*.ui`, `ui` tree), use **`ButtonComponent` + `ButtonClickEvent`**. Putting UI events on a world object (or vice versa) silently does nothing — decide first whether the target is a world object or a UI panel button.
---
## 11. Map Context and Entity Spawning
> **§1.7 trigger** — `Read` [builder-protocol.md](../msw-general/references/builder-protocol.md) + the matching per-builder protocol file before any spawn / `.map` / `.model` work.
### Children traversal
For "all X in the map" / "child named Y" queries, use `Entity`'s lookup toolkit:
| Member | Returns | Use |
|---|---|---|
| `Entity.Children` | `ReadOnlyList<Entity>` (call `:ToTable()` to iterate) | Immediate children |
| `Entity:GetChildByName(name, recursive=false)` | `Entity` | By name |
| `Entity:GetChild(id, recursive=false)` | `Entity` | By UUID |
| `Entity:GetChildComponentsByTypeName(typename, recursive=false)` | `table<Component>` | All matching descendants |
| `Entity:GetFirstChildComponentByTypeName(typename, recursive=false)` | `Component` | First match |
```lua
local map = self.Entity.CurrentMap -- prefer this over service lookup
local units = map:GetChildComponentsByTypeName("script.MyUnit", false)
for _, child in ipairs(self.Entity.Children:ToTable()) do log(child.Name) end
```
The collection is `Children` — `ChildList`/`Childs`/`GetChildren()` are wrong (compile, runtime nil, `LIA-1114` Info). Runtime-spawned entities must be parented under `CurrentMap` to be findable.
### Native vs user component access
| Access | Works on |
|---|---|
| `entity.SomeComponent` (dot) | **Engine-native only** (`TransformComponent`, `ButtonComponent`, …) |
| `entity:GetComponent("script.MyUnit")` | User `@Component` (any) |
| `entity:GetFirstChildComponentByTypeName("script.MyUnit", true)` | User `@Component` on descendant |
User `@Component` typename is **always `"script.<FileBaseName>"`** — `MyUnit.mlua` → `"script.MyUnit"`, regardless of feature-folder nesting. `entity.MyUnit` (dot) returns `nil` with `LIA-1114`. To pass user-component refs between scripts, declare a typed property (`property MyUnit unit = ""`) and inject UUID.
> ⚠ The method is `GetComponent` (overloaded as `GetComponent(Type)` and `GetComponent(string typename)` — see `Environment/NativeScripts/Misc/Entity.d.mlua`). The `*ByTypeName` suffix exists only on the **child** variants (`GetChildComponentsByTypeName` / `GetFirstChildComponentByTypeName`).
`GetComponent(string)` returns the abstract `Component` type, so member access on the result drops to dynamic dispatch and the LSP raises `LIA-1114` Info (or a `type mismatch` Error when the value is passed to a function whose signature expects the concrete user `@Component`). Cast with `---@type` to restore static typing:
```lua
---@type MyUnit
local unit = self.Entity:GetComponent("script.MyUnit")
unit:DoSomething() -- LSP now type-checks against MyUnit
```
### Spawning at runtime
- Use `_SpawnService:SpawnByModelId(id, name, pos, parent)` — **`parent` is required** (no default). Pass `self.Entity.CurrentMap`. `SpawnByEntity` differs — `parent = nil` is allowed.
- A `.model` template must already exist. New-object flow: **author `.model` → spawn or place on map**.
### Body components vs direct Position writes
Entities with a Body (Kinematic/Rigid/Sideview) ignore direct `TransformComponent.WorldPosition` writes — physics overwrites them next frame. Use instead:
- Per-frame: `MovementComponent:MoveToDirection(dir, dt)`
- Teleport (local): `MovementComponent:SetPosition(pos)` or `body:SetPosition(Vector2)`
- Teleport (world): `body:SetWorldPosition(Vector2)` — the standard absolute-place call for Kinematicbody on RectTile maps
- Direct Transform writes are OK only for Body-less entities (decorations, effects).
**Do NOT remove the Body as a workaround** — disables tile collision and enter/leave events (`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 entities (`SpawnByModelId`, `SpawnByEntity`). **There is no `Despawn` method** — remove spawned entities via `Entity:Destroy()` / `Entity:Destroy(delaySeconds)` (both `ControlOnly`). |
| `_TimerService` | Timers (`SetTimer`, `SetTimerRepeat`, `ClearTimer`) |
| `_EntityService` | Entity lookup (`GetEntity`, `GetEntities`, `GetEntitiesByPath`) |
| `_UserService` | Player lookup (`GetUsersByMapComponent(map.MapComponent)` returns all players currently on the given map — canonical "find players on this map" call, used by `Soldier`'s `FindNearestPlayer`). Returns `nil` when no users. |
| `_InputService` | Input state queries; receives `ScreenTouchEvent` |
| `_ResourceService` | Look up resource RUIDs; `LoadAnimationClipAndWait(ruid)` synchronously loads an AnimationClip (block for one frame — cache the result; wrap in `_ResourceService:PreloadAsync({ruid}, function() ... end)` if you want to avoid the block) |
| `_DataStorageService` | Persistent data (player saves) — **⚠️ Credit-billed. Do not call in `OnUpdate` / short timers; use `Batch*` in loops. Details: [references/datastorage.md](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`.
### Built-in globals accessed WITHOUT the `_` prefix
The `_Name` rule above applies to **Services and Logic only**. A few built-ins are exposed as plain globals — accessing them with a leading underscore is a runtime error (`nil` reference).
| Global (correct) | Wrong | Purpose |
|---|---|---|
| `Environment` | ❌ `_Environment` | Execution-environment queries — `Environment:IsMakerPlay()` / `IsMakerEdit()` / `IsPlay()` / `IsPublishedPlay()` / `IsMobilePlatform()` / `IsPCPlatform()`, `WorldId` property. `GetApplicationVersion()` is ClientOnly (nil on server). |
| `CollisionGroups` | ❌ `_CollisionGroups` | Table of `CollisionGroup` objects keyed by group name — `CollisionGroups.HitBox`, `.Monster`, `.Player`, etc. Built-ins: `Default` / `TriggerBox` / `HitBox` / `Interaction` / `Portal` / `Climbable`, plus any project-defined groups. Each entry has a `.Id` (string) and `:GetCollideGroups()`. |
---
## 13. Math, Utilities, Reserved Words, Type Annotations
### Math / utility examples
```lua
_UtilLogic:RandomDouble() -- 0.0~1.0
_UtilLogic:RandomIntegerRange(1, 10) -- inclusive
-- ElapsedSeconds / ServerElapsedSeconds: world-instance lifetime, NOT reset on OnBeginPlay.
-- They keep ticking across repeated play sessions in the Maker editor.
-- For per-session countdowns, see "Per-session timers" below.
_UtilLogic.ElapsedSeconds
_UtilLogic.ServerElapsedSeconds
```
### mlua utility classes
Collections beyond Lua stdlib: `List` / `ReadOnlyList` / `SyncList`, `Dictionary` / `ReadOnlyDictionary` / `SyncDictionary` (Sync* variants auto-sync server↔client). Other utility types: `DateTime`, `TimeSpan`, `Regex`, `Translator`, `Quaternion`, `Vector2Int`, `FastVector2/3` / `FastColor` (in-place ops for perf), `Item` (inventory).
> `.Values` / `.Keys` on `Dictionary` / `ReadOnlyDictionary` / `SyncDictionary` returns a plain Lua `table` — iterate with `ipairs` directly. No `:GetValues()` / `:ToTable()` / `pairs(dict)` wrapper needed. Lists are similar but require `:ToTable()` first (`ReadOnlyList<T>` is not a Lua table).
>
> ```lua
> -- All connected players (server-side fan-out)
> for _, user in ipairs(_UserService.UserEntities.Values) do
> if isvalid(user) then ... end
> end
> ```
> Detailed APIs in `Environment/NativeScripts/` or via `msw-search`.
### Per-session timers — never anchor on `ElapsedSeconds`
Trap: `self.deadline = _UtilLogic.ElapsedSeconds + 15` in `OnBeginPlay`. The world instance survives multiple Maker play sessions, so the saved deadline is in the past on the next play and fires immediately.
For per-session countdowns, decrement a `delta`-driven property in `OnUpdate`:
```lua
property number waveCountdown = 0
method void OnBeginPlay() self.waveCountdown = 15 end
method void OnUpdate(number delta)
if self.waveCountdown > 0 then
self.waveCountdown = self.waveCountdown - delta
if self.waveCountdown <= 0 then self:StartWave() end
end
end
```
For session-relative elapsed time, baseline in `OnBeginPlay` (`self.startTime = _UtilLogic.ElapsedSeconds`) and subtract. Never compare raw `ElapsedSeconds` across sessions.
### Type annotations (code hints)
`---@type T` / `---@param` / `---@return` give editor autocomplete only — **no runtime effect**.
### Reserved words
Forbidden as identifiers: `handler`, `property`, `method`, `script`, `end`, `extends`, `self`, `nil`, `true`, `false`.
Applies to locals, parameters, properties, methods, dot-field names (`rec.handler`), and **bare** table keys (`{ handler = ... }`). Bracket-quoting an external string key (`rec["handler"]`) is fine, but prefer renaming internal keys (e.g., `eventHandler`).
---
## 14. External Tooling
- Maker MCP (`refresh`/`logs`/`play`/`stop`/`screenshot`/…): **`msw-general`** skill.
- API descriptions/examples/guides not in `.d.mlua`: **`msw-search`** skill.
- MCP wiring / `.mcp.json` / API key setup: share https://maplestoryworlds-creators.nexon.com/ko/docs?postId=1368
Debug order: **build logs → play → logs → stop → fix → diagnose → refresh → repeat**.
---
## 15. Script Authoring Workflow
1. **Search** existing scripts (§1.1) — modify if a similar one exists.
2. **Verify spec** (§1.3) — `.d.mlua` first, `msw-search` if insufficient.
3. **Decide path** (§1.2) — feature-folder mandatory; never write to `MyDesk/` root.
4. **Write**.
5. **Validate** — `mlua-diagnose` hook auto-runs; fix until zero errors (§1.4).
6. **Refresh** — Maker MCP `refresh` (§1.5).
7. **(If needed)** `play` → `logs` → `stop` (§17).
Delete/rename also requires `refresh` + cleanup of references in `.model` / `.map`.
---
## 16. Attaching Scripts (Components) to Entities
> **§1.7 trigger** — `Read` [builder-protocol.md](../msw-general/references/builder-protocol.md) + the matching per-builder protocol file first. Never edit `Components` arrays as raw JSON.
- **Attach to `.model` (preferred)**: `ModelBuilder.addComponent()` / `upsertComponent()`. Map instances inherit.
- **Attach to one map instance only**: `MapBuilder.upsertComponent(name, "script.XXX", body)`.
- **Global models** (`./Global/*.model`): existing global templates affect the entire project and are edited in place via `ModelBuilder` + Maker Refresh. Do not create new files under `Global/`; create new custom models under `RootDesk/MyDesk/Models/`.
---
## 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
**Before every `play`, run `logs(kind="build")`**. Build errors make scripts fail to load entirely (the component/logic behaves as if missing), and they often **don't appear in runtime logs** — most "code looks correct but doesn't work" reports trace to a missed build error. Fix → refresh → recheck until errors are zero, then play.
> ⚠️ **Empty build logs ≠ build OK.** Refresh-stage **mlua conversion errors** (Maker popup *"An error occurred during mlua conversion"*) bypass the Build Console entirely: `refresh` still reports ok, `logs(kind="build")` stays at 0, and the error text lands **only in `logs(kind="normal")`**. If build logs are empty but a script still fails to load — or that popup is reported — read `logs(kind="normal")` next instead of looping refresh→build-check. Do **not** `clear_logs` until the cause is captured: clearing wipes the normal-log bucket, i.e. the only copy of the conversion error.
### 17.2 Error Classification
| Class | Signs | Where to look |
|------|-----------|-----------|
| **Script error** | Stack trace with file + line | Exact `.mlua` line; event/timing order |
| **nil reference** | `attempt to index a nil value` | Init order, `isvalid`, 1-frame post-Spawn timing |
| **Component missing** | nil component / `GetComponent` fails | `Components` array in `.model`; name typos |
| **Sync / network** | Only client breaks, values mismatch or converge late | `@Sync`, `ExecSpace`, RPC flow |
| **`Info` LIA 1113/1114/1115** (false positives on read/call sites) | Static-analysis can't resolve user cross-script refs (`_LogicName`, user `@Component` dot/method). Build still passes (errors=0/warnings=0) | Treat as noise; verify with `log()`. **Exception**: `LIA-1114` on an assignment target (`self.<name> = ...` with `<name>` undeclared) is a real runtime-error signal — `cannot set <name>, no such field` at play time; declare the `property` or use `self._T` (§7). Scope next `logs` call to higher severity if they drown real issues. |
| **User type `Symbol not found` / `type not found`** | Usage site authored before the user-type body `.mlua` exists. | Write the body `.mlua` first, then Maker `refresh` to regenerate the `.codeblock`. Build-log cache can hold one stale cycle — judge by the next diagnose. |
If logs are inconclusive, add `log()` in `.mlua` to inspect entity/component/property state.
### 17.3 Test-Result Report
Summarize briefly: **Scenario** (one line) · **Env** (map, refreshed?) · **Steps** (input/Lua) · **Result** (Pass/Fail/Blocked) · **Evidence** (1–2 log lines, screenshot if requested) · **Next action**.
### 17.4 Workflow
One unified loop for every playtest scenario:
```
edit → refresh → logs(kind="build") ──┐
↓ (errors? fix and refresh again)
clear_logs (optional) → play
↓
keyboard_input / mouse_input to reproduce
↓
logs(kind="normal") → classify with §17.2 table
↓ (insufficient? add log() in .mlua, refresh, replay)
stop → fix → loop
```
**Variants** — same loop, different entry conditions:
| Scenario | Notable steps |
|---|---|
| **First playtest** | Start from edit → refresh. |
| **Regression / fix loop** | `clear_logs` before `play` for a clean repro. |
| **mlua conversion error** (popup, or build logs empty yet script never loads) | `logs(kind="normal")` first — conversion errors skip build logs (§17.1). No `clear_logs` until the error is captured. |
| **Error analysis** | After collecting runtime logs, map to §17.2 first; only add `log()` when classification is inconclusive. |
| **Runtime value inspection** | Add `log()` calls; if API is unknown, verify spec (§1.3) before adding the call. |
### 17.5 Final Verification (PASS/FAIL)
**"No errors ≠ Pass."** Before reporting done, gather positive `log()`-based evidence that the intended logic actually executed. Full checklist: [references/verify-checklist.md](references/verify-checklist.md) (Runtime → Code Review → Log Evidence → PASS/FAIL).
### 17.6 Related Skills
`msw-general` — MCP tools, screenshot/logs policy, refresh rules, workspace and hierarchy.

