msw-scripting
This skill focuses on authoring, playtesting, and debugging MSW scripts (.mlua), covering mlua syntax, MSW-specific annotations, lifecycle, execution spaces, property synchronization, event systems, and file workflows. It guides developers through the unique mechanisms of MSW scripting, enabling integrated testing and debugging loops to ensure script correctness and efficient operation, thereby accelerating game logic development and iteration.
npx skills add https://github.com/msw-git/msw-ai-coding-plugins-official --skill msw-scriptingBefore / After Comparison
1 组Before this skill, developers had to manually consult extensive documentation and rely on trial-and-error to understand MSW's unique scripting mechanisms and API usage. This resulted in prolonged development cycles, frequent runtime errors, and time-consuming, inefficient debugging processes.
This skill provides comprehensive guidance on mlua syntax, MSW-specific annotations, and file workflows, enabling developers to quickly master scripting best practices. Through integrated testing and debugging loops, this Skill significantly reduces runtime errors and debugging time, boosting script development efficiency and quality.
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
.codeblockfiles are generated by Maker Refresh — never create/edit/delete manually.- After any
.mluacreate/modify/rename/delete, call Maker MCPrefresh. Refresh requires edit mode —stopfirst 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. 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.
1.7 Builder Protocol Preflight — MUST
If this turn touches .map / .model / .ui (directly, or via spawn/entity-placement/UI-binding code in .mlua), Read ../msw-general/references/builder-protocol.md first (full file, every turn — do not skip on prior-turn memory). It consolidates the write-side contracts (componentNames sync, typeKey metadata, auto-lint, child-entity invariants, placeModel mirroring) for all three builders; 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.
-- ✅ 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, ./Global/*.model, etc. | Edit Components when attaching scripts |
| Map instances | ./map/*.map | Edit when attaching scripts to entities that exist only inside a map |
3. Script Types and Declarations
3.1 Component scripts (@Component)
Scripts attached to an Entity. Use self.Entity to access the owning entity.
@Component
script MyScript extends Component
property number Speed = 5.0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- initialization (also: OnUpdate(delta), OnEndPlay)
end
end
Allowed parents:
Component— generic componentAttackComponent— attack system (Shape, AttackFast, OnAttack)HitComponent— hit system (OnHit, HandleHitEvent)
3.2 Logic scripts (@Logic)
Global singletons. Run independently without an Entity. Use for game managers, UI managers, utilities, etc.
@Logic
script GameManager extends Logic
@Sync property integer Score = 0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- global initialization (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 returnsnil. - Supports
@Syncproperties (server→client) - Logic's
OnUpdateruns 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.
3.3 Extend scripts
@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 (
globalkeyword forbidden) — share values via Properties. - No coroutines (
coroutine.*). - Parent call is
__base:MethodName(), notsuper.
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).
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.
@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 `@E
...
User Reviews (0)
Write a Review
No reviews yet
Statistics
User Rating
Rate this Skill