--- 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//.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 `_` — **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 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` 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 Scores -- array form, NO default literal @Sync property SyncTable Stats -- dict form, NO default literal ``` #### `SyncTable<...>` property — no default literal Declare `SyncTable` (array form) or `SyncTable` (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 Stats -- empty at construction @Sync property SyncTable 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` is not a user property type `SyncList` 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` (array form) or `SyncTable` (dict form) — see above. ### Temporary properties (`_T`) `self._T.` 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.` is a runtime error — `cannot set , no such field` — that kills the calling method (usually all of `OnBeginPlay`). Every `self.` you set must be a declared `property`, or go through `self._T.`. 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` / `_`). `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` (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` | 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."`** — `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` 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. = ...` with `` undeclared) is a real runtime-error signal — `cannot set , 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.