npx skills add ...
npx skills add msw-git/msw-ai-coding-plugins-official --skill 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.
npx skills add msw-git/msw-ai-coding-plugins-official --skill msw-scripting
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.
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.
When a new .mlua is unavoidable, place it under a feature/category subfolder. Required path shape: ./RootDesk/MyDesk/<FeatureFolder>/<ScriptName>.mlua.
Player/, UI/, Combat/, Inventory/, …); glob ./RootDesk/MyDesk/ first.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.
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.
mlua-diagnose hook runs LSP diagnose automatically after every .mlua create/modify. Iterate fix → re-edit until error-severity diagnostics reach zero.
.codeblock & Refresh.codeblock files are generated by Maker Refresh — never create/edit/delete manually..mlua create/modify/rename/delete, call Maker MCP refresh. Refresh requires edit mode — stop first if playing.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. 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 §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 |
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.
If this turn touches .map / .model / .ui (directly, or via spawn/entity-placement/UI-binding code in .mlua), ../msw-general/references/builder-protocol.md (core) plus the per-builder file for each type touched (builder-protocol-map.md / builder-protocol-model.md / 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.
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.
| 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 |
@Component)Scripts attached to an Entity. Use self.Entity to access the owning entity.
Allowed parents:
Component — generic componentAttackComponent — attack system (Shape, AttackFast, OnAttack)HitComponent — hit system (OnHit, HandleHitEvent)@Logic)Global singletons. Run independently without an Entity. Use for game managers, UI managers, utilities, etc.
_<ExactScriptName> — no suffix stripping. TDHUDLogic.mlua → _TDHUDLogic (not _TDHUD); TowerDefenseConfig.mlua → _TowerDefenseConfig. Heuristic stripping silently returns nil.@Sync properties (server→client)OnUpdate runs before Components'.⚠️
@Logichas noself.Entity— Logic parent only exposesConnectEvent/DisconnectEvent/IsClient/IsServer/SendEvent.self.Entity.xxxcompiles 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/OnMapLeavenever 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) @LogicEngine singleton; lives for whole world session. Map-scoped — only meaningful inside one map (quest controller, wave spawner, puzzle) @Componenton the map entityCleaned on map unload. Putting this in @Logicleaks state/timers across maps.One actor (monster AI, item pickup, player skill) @Componenton that entityAsk: "Still running after the player walks to another map?" — Yes ⇒
@Logic; No (this map) ⇒@Componenton map entity; No (this actor) ⇒@Componenton actor.
@Event (custom event) · @Item (inventory) · @BTNode (behaviour tree) · @State (state machine) · @Struct (composite data type).
Based on Lua 5.3 with these differences:
Added syntax:
continue — skip to next loop iteration.+=, -=, *=, /=, //=, %=, ^=, ..= (and bitwise &=, |=, <<=, >>=). Multi-assign (a, b += 1, 2) and use as a function arg (print(a += 1)) are invalid.&, |, <<, >>.Restrictions:
global keyword forbidden) — share values via Properties.coroutine.*).__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 |
| 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).
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 |
| 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 |
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 requesterInside an @ExecSpace("Server") body, the local senderUserId holds the caller client's UserId (server-assigned, not client-modifiable). Use it for security checks.
name is unavailableFour 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.
IsServer() / IsClient() are methods, not propertiesWhen 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.
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.
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.
number (float/double — integers are separate type integer), string, boolean, Vector2/Vector3, Color (r,g,b,a in 0.0~1.0), any.
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 / Component references are dropped on map transition. EntityRef / ComponentRef survive map transitions — prefer for multi-map games.
@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.any, table — use SyncTable instead.SyncTable<...> property — no default literalDeclare 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:
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 typeSyncList<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.
_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.
⚠️
_Tis the ONLY declaration-free field. Assigning to any other undeclaredself.<name>is a runtime error —cannot set <name>, no such field— that kills the calling method (usually all ofOnBeginPlay). Everyself.<name>you set must be a declaredproperty, or go throughself._T.<name>. The build log stays clean except an easily-missedLIA-1114Info (see §17.2).
OnSyncProperty callbackClient-side hook fired when a @Sync property changes. Must be ClientOnly (cannot be changed). Available on Component and Logic.
@EventSender + handler@EventSender 1st arg: "Self" / "LocalPlayer" (no 2nd arg) · "Entity",id / "Model",id / "Service",typeName / "Logic",typeName.
ConnectEvent / DisconnectEventFor per-element captured state (card IDs, slot indexes), use a closure handler; store the returned EventHandlerBase in a table and disconnect each in OnEndPlay.
⚠️
ConnectEventis on Entity / Logic / Service — NOT Component. Components only emit events; subscribe on the owning Entity (or_InputService/_<LogicName>).self.Entity.ButtonComponent:ConnectEvent(...)runtime nils.
⚠️
handlervsmethod void—handler Name(Ev e)pairs with@EventSender(...)and is wired by declaration.method void Name(Ev e)is the dynamic callback wired viaConnectEvent(EvType, self.Name). Mixing them compiles but never fires (E-V1-5). If@EventSenderis present →handler; if you'll callConnectEvent→method void.
The only way to author one is @Event + extends EventType with property fields. There is no inline factory.
NativeEvent (engine-provided, e.g., HitEvent.TotalDamage/.AttackerEntity, ButtonClickEvent, StateChangedEvent.PrevState/.CurState) — see Environment/NativeScripts/Event/.
Accessing a deleted entity is a runtime error — always isvalid() first.
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).
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.
| 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, andTriggerComponentall do not deliver touch input. OnlyTouchReceiveComponentemitsTouchEvent/TouchHoldEvent/TouchReleaseEvent.Setup:
AutoFitToSize = true(auto-fits TouchArea to the Sprite/Avatar scale) is the simplest path. ManualTouchAreashould leave 10–20% slack beyond the sprite.RelayEventToBehind = true(default) forwards through; setfalseonly to block.Not firing? Check, in order: (1)
TouchReceiveComponentactually attached (in.map/.model); (2)TouchAreanon-zero and entity visible on screen; (3) no front entity blocking withRelayEventToBehind = false; (4) handler stored in aproperty any(otherwise GC'd).
Selection rule: "Which entity was touched" →
TouchEvent; "Where on the screen" →ScreenTouchEvent.
KeyDownEvent, not ScreenTouchEvent.TouchId == 2ScreenTouchEvent 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).
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.
§1.7 trigger —
Readbuilder-protocol.md + the matching per-builder protocol file before any spawn /.map/.modelwork.
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 |
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.
| 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 asGetComponent(Type)andGetComponent(string typename)— seeEnvironment/NativeScripts/Misc/Entity.d.mlua). The*ByTypeNamesuffix 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:
_SpawnService:SpawnByModelId(id, name, pos, parent) — parent is required (no default). Pass self.Entity.CurrentMap. SpawnByEntity differs — parent = nil is allowed..model template must already exist. New-object flow: author .model → spawn or place on map.Entities with a Body (Kinematic/Rigid/Sideview) ignore direct TransformComponent.WorldPosition writes — physics overwrites them next frame. Use instead:
MovementComponent:MoveToDirection(dir, dt)MovementComponent:SetPosition(pos) or body:SetPosition(Vector2)body:SetWorldPosition(Vector2) — the standard absolute-place call for Kinematicbody on RectTile mapsDo NOT remove the Body as a workaround — disables tile collision and enter/leave events (NativeIssue_MissingComponent).
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 |
_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.
_ prefixThe _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(). |
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/.KeysonDictionary/ReadOnlyDictionary/SyncDictionaryreturns a plain Luatable— iterate withipairsdirectly. No:GetValues()/:ToTable()/pairs(dict)wrapper needed. Lists are similar but require:ToTable()first (ReadOnlyList<T>is not a Lua table).
Detailed APIs in
Environment/NativeScripts/or viamsw-search.
ElapsedSecondsTrap: 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:
For session-relative elapsed time, baseline in OnBeginPlay (self.startTime = _UtilLogic.ElapsedSeconds) and subtract. Never compare raw ElapsedSeconds across sessions.
---@type T / ---@param / ---@return give editor autocomplete only — no runtime effect.
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).
refresh/logs/play/stop/screenshot/…): msw-general skill..d.mlua: msw-search skill..mcp.json / API key setup: share https://maplestoryworlds-creators.nexon.com/ko/docs?postId=1368Debug order: build logs → play → logs → stop → fix → diagnose → refresh → repeat.
.d.mlua first, msw-search if insufficient.MyDesk/ root.mlua-diagnose hook auto-runs; fix until zero errors (§1.4).refresh (§1.5).play → logs → stop (§17).Delete/rename also requires refresh + cleanup of references in .model / .map.
§1.7 trigger —
Readbuilder-protocol.md + the matching per-builder protocol file first. Never editComponentsarrays as raw JSON.
.model (preferred): ModelBuilder.addComponent() / upsertComponent(). Map instances inherit.MapBuilder.upsertComponent(name, "script.XXX", body)../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/.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.
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:
refreshstill reports ok,logs(kind="build")stays at 0, and the error text lands only inlogs(kind="normal"). If build logs are empty but a script still fails to load — or that popup is reported — readlogs(kind="normal")next instead of looping refresh→build-check. Do notclear_logsuntil the cause is captured: clearing wipes the normal-log bucket, i.e. the only copy of the conversion error.
| 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.
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.
One unified loop for every playtest scenario:
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. |
"No errors ≠ Pass." Before reporting done, gather positive log()-based evidence that the intended logic actually executed. Full checklist: references/verify-checklist.md (Runtime → Code Review → Log Evidence → PASS/FAIL).
msw-general — MCP tools, screenshot/logs policy, refresh rules, workspace and hierarchy.**
@Component
script MyScript extends Component
property number Speed = 5.0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- initialization (also: OnUpdate(delta), OnEndPlay)
end
end@Logic
script GameManager extends Logic
@Sync property integer Score = 0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- global initialization (also: OnUpdate, OnEndPlay)
end
end@Component
script PlayerAttack extends AttackComponent
-- Override parent methods; call parent via __base:MethodName()
endOnInitialize → OnBeginPlay → OnUpdate(delta) → OnEndPlay → OnDestroy
↑
OnMapEnter / OnMapLeave (Component only, per transition)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[Client] input (ClientOnly) ──Request()──→ [Server] validate (ServerOnly)
├─ state auto-syncs via @Sync
[Client] UI update (ClientOnly) ←──Show()──────┘ (Client RPC)@ExecSpace("Server")
method void RequestBuyItem(integer itemId)
if senderUserId ~= self.Entity.PlayerComponent.UserId then return end
self:ProcessPurchase(itemId)
endif self:IsServer() then ... end -- ✅ method call → boolean
if self.IsServer then ... end -- ❌ method object itself → always truthyproperty number Speed = 5.0
property integer Count = 0
property Vector2 Direction = Vector2(0, 0)
property Color Tint = Color(1, 1, 1, 1)property Entity targetEntity = "94a274e4-4111-40f1-924d-c95a3a1f14d5" -- UUID string literal
property ButtonComponent btnOk = "uuid-string" -- typed component ref@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@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@ExecSpace("ClientOnly")
method void OnSyncProperty(string name, any value)
if name == "CurrentHp" then self:UpdateHpBar(value) end
end@DisplayName("...") @Description("...") @MaxLength(20) @HideFromInspector
@MinValue(0) @MaxValue(999) @Delta(5) -- Delta = mobile +/- step@EventSender("Self") handler HandleHitEvent(HitEvent event) ... end
@EventSender("Service", "InputService") handler HandleKeyDown(KeyDownEvent event) ... endproperty any clickHandler = nil
self.clickHandler = entity:ConnectEvent(ButtonClickEvent, self.OnClick) -- OnBeginPlay
entity:DisconnectEvent(ButtonClickEvent, self.clickHandler) -- OnEndPlay (mandatory)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