references/datastorage.md
# DataStorage — Cost, Limits, and Safe Usage Guide
> **⚠️ IMPORTANT — This document is directly tied to billing.**
> DataStorage calls consume **Credit**, and worlds that exceed their Credit budget may have future requests **blocked**.
> Even today, exceeding the threshold is recorded in the **critical report** (for published worlds). When generating code,
> AI **must** follow the rules below to prevent excessive storage usage.
---
## 0. 90-Second Summary — 5 Rules You Must Follow
1. **Never call DataStorage functions inside `OnUpdate`, every frame, or short-interval timers (<1s).** Saves and reads must be **event-driven** only.
2. **Compare against the cache before writing.** If the value has not changed, do not call `SetAsync`/`SetAndWait`.
3. **For multiple keys, always use `Batch*`.** Running `SetAsync` inside a `for` loop consumes Credit linearly.
4. **Design for value strings ≤ 4,000 bytes.** Going over 4,000 bytes consumes **proportionally more Credit**.
5. **Use `Transact*` only when atomicity is truly required.** It costs **2× the Credit** of Batch.
> For player-data systems with multiple domains (basic info / inventory / quest / …), follow the reference architecture in **§8 Multi-Component Persistence Protocol** — one BatchGet on login, one BatchSet on flush, per-domain dirty flags. Do not invent ad-hoc save logic per Component.
---
## 1. Hard Limits (immediate error if exceeded)
| Field | Limit (UTF-8 bytes) |
|------|--------------------|
| DataStorage name | 1 ~ 64 |
| Key | 1 ~ 100 |
| Tag | 0 ~ 64 |
| Version | 0 ~ 64 |
| `Update*` value | 0 ~ 50,000 |
| `Set*` value | 0 ~ 300,000 |
> Actually using the per-Set maximum (300KB) burns **75+ Credits** in a single call. Do not store anywhere near the limit.
---
## 2. Credit Model — What AI Must Understand
Credit accumulates and is consumed **per FunctionGroup**, summed across **all instances** of the world.
| FunctionGroup | Granted/min | Max accumulation | Cost per request |
|---|---|---|---|
| **Set** / **Get** | `100 + (concurrent_users × 10)` | grant × 2 | **1 per 4,000 bytes** |
| **Delete** | `50 + (concurrent_users × 2)` | grant × 2 | **1 per 4,000 bytes** |
| **List** / **List DataStorage** / **Delete DataStorage** | `10 + (concurrent_users × 2)` | grant × 2 | 1 |
| **List Sorted** | `50 + (concurrent_users × 2)` | grant × 2 | 1 |
| **None** (local handles such as `GetGlobalDataStorage`) | — | — | 0 |
### Credit by byte size (Set/Get/Delete)
```
0 ~ 4,000 bytes → 1 credit
4,001 ~ 8,000 → 2 credit
8,001 ~ 12,000 → 3 credit
... (rounded up in 4,000-byte chunks)
```
Key/Tag/Version sizes have **no effect** on Credit. Only the value size is counted.
### Storage Layout Conventions
When data is keyed by player, pick one of two layouts:
| Layout | Pattern | When to use |
|---|---|---|
| **Per-user container** | `GetUserDataStorage(profileCode)` + storage key = field name (e.g. `"PlayerData"`) | Player-owned data with multiple independent keys per user (basic info, inventory, quests). Each key is independently batched and versioned. |
| **Global with profileCode key** | `GetGlobalDataStorage("YourFeature")` + storage key = `profileCode` | Cross-user data accessed by *another* user (ban list, friendship, social graph). One slot per user inside one global container. |
**The first-party PlayerData reference passes ProfileCode to `GetUserDataStorage(...)`.** The API signature names its parameter `userId`, but the published MSW PlayerData reference consistently passes `self.Entity.PlayerComponent.ProfileCode`. The behavioral difference between passing UserId vs ProfileCode has not been independently verified here, so when implementing cross-session player saves, mirror the first-party pattern (ProfileCode) until your own playtest confirms otherwise.
**Centralize storage names on an owning `@Logic` singleton.** Hard-coded string literals scattered across files are a silent data-loss bug waiting to happen (one typo → new empty container).
```lua
@Logic
script GMPlayerDataToolLogic extends Logic
property string StorageName = "PlayerData"
end
```
Other scripts then read it via `_GMPlayerDataToolLogic.StorageName`.
### Special Rules
- **Reading a non-existent key still consumes Credit** → do not blindly query when existence is unknown.
- **Batch family**: the first 25 entries are charged immediately at call time; the rest are charged when `MoveToNextPageAndWait()` is invoked.
- **Transact family**: **2× the Credit** of Batch. Use only when atomicity is required. Up to 20 keys per call.
- **`MoveToNextPageAndWait()` after `LoadNextPageAndWait()`**: pages already loaded do not consume additional Credit.
---
## 3. Anti-Patterns — Do Not Generate
### ❌ Saving every frame / on a short repeating timer
```lua
-- Forbidden: OnUpdate runs every frame (typically 30~60Hz). Credit is exhausted instantly.
method void OnUpdate(number dt)
self.storage:SetAsync("Hp", tostring(self.Hp), nil)
end
-- Forbidden: short-interval repeating timers are equivalent
_TimerService:SetTimerRepeat(function()
self.storage:SetAsync("Pos", tostring(self.Entity.TransformComponent.WorldPosition), nil)
end, 0.1)
```
### ❌ Per-element Set/Get inside a loop
```lua
-- Forbidden: 10 saves = 10 Credits + 10 network requests
for i, item in ipairs(items) do
self.storage:SetAsync(item.Key, item.Value, nil)
end
```
→ **Replace with**: `BatchSetAndWait` / `BatchSetAsync` (handled in a single request, Credit per request scales with bytes).
### ❌ Saving an unchanged value every time
```lua
-- Forbidden: identical values still consume Credit.
method void OnHit()
self.storage:SetAsync("LastHit", os.time(), nil)
end
```
→ **Replace with**: save only when the value changes, or **batch up changes on a periodic flush (debounce)**.
### ❌ Storing an entire table as one giant string
```lua
-- Caution: a 50KB result from TableToString costs 13 Credits in a single Set.
local bigStr = _UtilLogic:TableToString(self.EntireInventory) -- assume 50KB
self.storage:SetAsync("Inventory", bigStr, nil)
```
→ **Replace with**: **split rarely-changing data and frequently-changing data into separate keys**, or save only the diff.
### ❌ Calling DataStorage from the client
`_DataStorageService:Get*DataStorage` is **Server Only**. Calls from client space will not execute.
→ Use **only inside methods marked `@ExecSpace("ServerOnly")`**.
---
## 4. Recommended Patterns
### 4.1 Read once, then serve from in-memory cache
```lua
property any storage = nil
property table cache = {}
@ExecSpace("ServerOnly")
method void OnBeginPlay()
self.storage = _DataStorageService:GetGlobalDataStorage("PlayerStats")
local errorCode, raw = self.storage:GetAndWait(self.UserId)
self.cache = (errorCode == 0 and raw) and _UtilLogic:StringToTable(raw) or {}
end
@ExecSpace("ServerOnly")
method void GetStat(string key)
return self.cache[key] -- no DB roundtrip
end
```
### 4.2 Writes use a dirty flag + debounce
```lua
-- Mark dirty only on actual change, and flush on a fixed cadence
property boolean dirty = false
property any flushTimer = nil
@ExecSpace("ServerOnly")
method void SetStat(string key, any value)
if self.cache[key] == value then return end -- no change → no save
self.cache[key] = value
self.dirty = true
end
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- Flush example: every 30 seconds, or only on logout / important events
self.flushTimer = _TimerService:SetTimerRepeat(function()
if not self.dirty then return end
self.dirty = false
self.storage:SetAsync(self.UserId, _UtilLogic:TableToString(self.cache), nil)
end, 30.0)
end
@ExecSpace("ServerOnly")
method void OnEndPlay()
if self.flushTimer then _TimerService:ClearTimer(self.flushTimer) end
if self.dirty then
self.storage:SetAndWait(self.UserId, _UtilLogic:TableToString(self.cache))
end
end
```
### 4.3 Use Batch for multiple keys
```lua
@ExecSpace("ServerOnly")
method void SaveAll()
local kv = {}
for k, v in pairs(self.cache) do kv[k] = tostring(v) end
local errorCode, successKeys = self.storage:BatchSetAndWait(kv)
if errorCode ~= 0 then
log_warning("BatchSet partial failure, success key count: " .. tostring(#successKeys))
end
end
```
### 4.4 Use Increase for SortableDataStorage counters
```lua
-- Forbidden pattern: Get → +1 → Set (2× Credit + race condition). Use Increase for atomic update.
local errorCode, newScore = self.ranking:IncreaseAndWait(userId, delta)
```
### 4.5 Pick the right storage for the job
| Storage | Scope | Type | Use Case |
|---|---|---|---|
| `GlobalDataStorage` | World | string | World-wide settings/state |
| `UserDataStorage` | User | string | Inventory, progression |
| `CreatorDataStorage` | Creator (shared across worlds) | string | Creator-wide values |
| `SortableDataStorage` | World | int | Rankings, cumulative scores |
**Rule**: User data must live in `UserDataStorage`. Do not dump `user_<id>_xxx` keys into Global.
### 4.6 BatchGet returns paged results — drain every page
`BatchGetAndWait(keys)` returns `(errorCode, DataStorageItemPages)`. The pages object is **not** a plain list — you must loop until `IsLastPage` is true. Skipping the loop silently drops keys past the first page.
```lua
@ExecSpace("ServerOnly")
method boolean BatchGetAndWait(string profileCode, table loadKeys, table outLoadedData)
local ds = _DataStorageService:GetUserDataStorage(profileCode)
local code, itemPages = ds:BatchGetAndWait(loadKeys)
if code ~= 0 then
log_error(string.format("BatchGetAndWait failed. ErrorCode: %d", code))
return false
end
while true do
local datas = itemPages:GetCurrentPageDatas()
if datas == nil then break end
for i = 1, #datas do
outLoadedData[datas[i].KeyInfo.Key] = datas[i] -- DataStorageItem
end
if itemPages.IsLastPage then break end
-- Load next page first and check its error code; only then move the cursor.
-- Skipping LoadNextPageAndWait swallows network/storage errors silently.
local loadErr = itemPages:LoadNextPageAndWait()
if loadErr ~= 0 then
log_error(string.format("LoadNextPageAndWait failed. ErrorCode: %d", loadErr))
return false
end
itemPages:MoveToNextPageAndWait()
end
return true
end
```
Each entry is a `DataStorageItem` exposing `.KeyInfo.Key` and `.Value` (the stored string). Decode `.Value` per key as needed (`_HttpService:JSONDecode(item.Value)`).
> If a requested key has never been written, BatchGet **omits** it from the pages instead of returning a NotFound row. Always nil-check `loadedData[key]` before reading `.Value`.
### 4.7 AndWait on user-leave, Async on periodic flush
Two save windows in a typical player session — pick the matching variant for each:
| Window | Variant | Why |
|---|---|---|
| User leaving (`UserLeaveEvent`) | `~AndWait` | Last chance to persist. Block until storage confirms before the session tears down. |
| Periodic auto-save during play | `~Async` + callback | Frame-budget critical. Async never blocks the game loop. Published reference cadence: 5 minutes. |
```lua
@Component
script PlayerDBManager extends Component
property integer TimerId = 0
@ExecSpace("ServerOnly")
method void StartAutoSave()
local period = 300 -- seconds
self.TimerId = _TimerService:SetTimerRepeat(function()
self:SaveToDB(false) -- false = playing, use Async
end, period, period)
end
@ExecSpace("ServerOnly")
method void SaveToDB(boolean isLeaving)
local saveData = {}
self.Entity.PlayerData:SaveToDB(saveData)
-- (more components contribute their keys here)
-- Skip the round-trip entirely when no component had anything to save.
-- Otherwise empty BatchSet wastes a service call and contradicts the
-- "unchanged domains cost zero Credit" guarantee.
if next(saveData) == nil then return end
local profileCode = self.Entity.PlayerComponent.ProfileCode
local ds = _DataStorageService:GetUserDataStorage(profileCode)
if isLeaving then
-- Block until durable, AND check the result — this is the player's last-chance save.
local errorCode, successKeys = ds:BatchSetAndWait(saveData)
if errorCode ~= 0 then
log_error(string.format(
"Logout save failed. ErrorCode: %d, succeeded keys: %d",
errorCode, #successKeys))
-- See §4.8 for the failed-keys resolution pattern.
end
else
ds:BatchSetAsync(saveData, function(errorCode, successKeys)
if errorCode ~= 0 then
log_warning("Periodic save partial failure, success count: " .. tostring(#successKeys))
end
end)
end
end
@ExecSpace("ServerOnly")
@EventSender("Service", "UserService")
handler HandleUserLeaveEvent(UserLeaveEvent event)
if event.UserId ~= self.Entity.PlayerComponent.UserId then return end
_TimerService:ClearTimer(self.TimerId)
self:SaveToDB(true) -- true = leaving, use AndWait
end
end
```
### 4.8 BatchSet partial failure — `successKeys` is the SUCCEEDED list
`BatchSetAndWait(keyValues) → (errorCode, List<string>)`. When `errorCode ~= 0` (typically `1000006 PartialFailure`), the second return is the **succeeded** keys, not the failed ones. Compute `failed = inputKeys − successKeys` to retry.
```lua
local errorCode, successKeys = ds:BatchSetAndWait(keyValues)
if errorCode ~= 0 then
local failed = {}
for k, _ in pairs(keyValues) do failed[k] = true end
for i = 1, #successKeys do failed[successKeys[i]] = nil end
-- `failed` now holds keys that need retry / alerting
for k, _ in pairs(failed) do
log_warning("BatchSet failed key: " .. k)
end
end
```
The same shape applies to `BatchSetAsync` (second callback arg is succeeded keys) and `BatchDeleteAndWait` (second return is deleted keys).
### 4.9 Serialize through a `@Struct` mirror, decode with default fallbacks
Don't serialize a Component directly. Mirror its persisted fields into a `@Struct` and route Component ↔ JSON through that struct. This isolates the storage schema from the runtime Component shape, lets you add/rename fields without breaking save files, and keeps serialization unit-testable.
```lua
@Struct
script PlayerBasicInfo
property integer Level = 0
property integer Dia = 0
property integer Meso = 0
property string Extra = ""
method void Init()
self.Level = 1 -- new-account defaults go here, not in Deserialize
end
method boolean Serialize(table out)
local t = {}
out["Basic"] = t -- nested key namespacing keeps room for future struct versions
t["Level"] = self.Level
t["Dia"] = self.Dia
t["Meso"] = self.Meso
t["Extra"] = self.Extra
return true
end
method boolean Deserialize(table src)
local t = src["Basic"]
if t == nil then return true end -- legacy save before this struct existed → keep Init() defaults
self.Level = t["Level"] or 1 -- `x or default` covers missing-field forward compat
self.Dia = t["Dia"] or 0
self.Meso = t["Meso"] or 0
self.Extra = t["Extra"] or ""
return true
end
method void ToComponent(PlayerData comp)
comp.Level = self.Level
comp.Dia = self.Dia
comp.Meso = self.Meso
comp.Extra = self.Extra
end
method void FromComponent(PlayerData comp)
self.Level = comp.Level
self.Dia = comp.Dia
self.Meso = comp.Meso
self.Extra = comp.Extra
end
end
```
The owning Component then calls `_HttpService:JSONEncode(t)` on the table produced by `Serialize`, and `JSONDecode` + `Deserialize` on the way back.
**Why JSON over `_UtilLogic:TableToString`**: JSON has a documented shape, survives external inspection (admin tools, log forensics), preserves **nested** tables, and the `or default` fallback pattern composes naturally with optional fields. `TableToString` is acceptable only for ephemeral / opaque payloads with no schema evolution.
> [!WARNING]
> **`TableToString` / `StringToTable` round-trips only a flat table** whose values are `string` / `number` / `boolean`. If a value is itself a table, it serializes to an opaque reference and is **silently dropped** on `StringToTable` — that field comes back `nil`, with no error. Flatten before storing, or use `_HttpService:JSONEncode` / `JSONDecode` (which preserves nesting).
>
> ```lua
> -- ❌ nested value silently lost on round-trip
> _UtilLogic:TableToString({ profile = { gold = 100 } }) -- `profile` returns nil after StringToTable
> -- ✅ flat, or use JSON for structure
> _UtilLogic:TableToString({ gold = 100, ["o_magic_claw"] = 5 })
> ```
---
## 5. AndWait vs Async — Which to Choose
| Suffix | Behavior | When to Use |
|---|---|---|
| `~AndWait` | Synchronous, blocks the script until completion | Initial load (OnBeginPlay), logout save — moments where **blocking is acceptable** |
| `~Async` + callback | Asynchronous, result handled in callback | In-game live saves. Prevents frame drops |
> Credit cost is **the same**. Only the **performance characteristics** differ.
---
## 6. Error Code Handling (Do Not Ignore)
Every DataStorage call returns `errorCode` as its first value. **Always check it.**
| Code | Name | Action |
|---|---|---|
| 0 | Ok | Normal |
| 1000004 | TimedOut | Retry with backoff or fold into the next flush |
| 1000005 | **ResourceExhausted** | **Credit exceeded.** Reduce call frequency immediately. Log an alert. |
| 1000006 | PartialFailure | Batch had partial failure — retry only the failed keys |
| 1000002 | NotFound | First-time access (assign default value) |
| Other | InternalError/Unknown | Log and retry, or give up |
If `ResourceExhausted` ever appears, treat the offending function as a **cost bug** and redesign its call path.
### NotFound (1000002) — first-time access is not an error
For a brand-new user, the very first `GetAndWait` returns `1000002 NotFound`. Treat it as a normal "initialize defaults" signal — do not return early as an error, and do not blindly call `SetAndWait` first ("write empty just to make it exist" wastes Credit every login).
The example below is for the **§2 layout 2** pattern (Global container, profileCode as the key inside it):
```lua
-- Container = global named "PlayerBan"; key inside = the user's profileCode.
local ds = _DataStorageService:GetGlobalDataStorage("PlayerBan")
local errorCode, raw = ds:GetAndWait(profileCode)
if errorCode == 1000002 then -- NotFound: this user has no ban record yet
return SuccessCode -- proceed with default (not banned)
end
if errorCode ~= 0 then
log_error("Get failed: " .. tostring(errorCode))
return errorCode
end
-- raw is valid string → deserialize
```
For **§2 layout 1** (per-user container, key = field name), the call shape is `ds:GetAndWait("PlayerData")` — the storage key is the field name, not the profileCode again:
```lua
local ds = _DataStorageService:GetUserDataStorage(profileCode) -- container scoped to this user
local errorCode, raw = ds:GetAndWait("PlayerData") -- key inside that container
if errorCode == 1000002 then return SuccessCode end -- first-time user for this field
```
The same NotFound-vs-absent distinction applies inside `BatchGetAndWait`: a never-written key is simply **absent** from the returned pages (no NotFound row). Nil-check `loadedData[key]` before reading `.Value`.
### PartialFailure (1000006) — `successKeys` lists what got through
See §4.8 for the resolution pattern: `failed = inputKeys − successKeys`.
---
## 7. Pre-Generation Checklist (Answer Before Writing Code)
Before adding a DataStorage call to a script, you **must be able to answer all of the following**:
- [ ] Is the method holding this call marked `@ExecSpace("ServerOnly")`?
- [ ] Does this call avoid frames and short timers? (Is it event-driven?)
- [ ] Is the same call repeated inside a loop? If so, can it become a `Batch*`?
- [ ] What is the maximum byte size of the value? If over 4KB, can it be split?
- [ ] Does it save only when the value actually changed? (dirty check)
- [ ] Is `errorCode` branched on? Especially `ResourceExhausted`.
- [ ] Is user data being placed in Global by mistake? (Verify UserDataStorage is used.)
- [ ] If `Transact*` is used, is atomicity actually required? (Otherwise, use Batch.)
---
## 8. Multi-Component Persistence Protocol
When a player carries multiple independent data domains (basic info, inventory, quests, achievements…), don't scatter `Set*` calls across components. Run them all through one `PlayerDBManager` that aggregates into a single `BatchGetAndWait` on login and a single `BatchSet*` on flush. This is the reference architecture used by MSW first-party feature packages.
### The 5-method contract
Every persistent data Component implements five `ServerOnly` methods that the Manager calls in order:
| Method | Called by Manager | Component's job |
|---|---|---|
| `LoadFromDB(table loadKeys) → boolean` | Before BatchGet | Push every key this Component owns into `loadKeys`. Do **not** touch storage here. |
| `OnLoadedDataFromDB(table loadedData) → boolean` | After BatchGet | Look up your keys in `loadedData[key] = DataStorageItem`. Decode `.Value`, populate properties. Return `false` to abort the whole load. |
| `PostOnLoadedDataFromDB() → boolean` | After ALL components finished `OnLoadedDataFromDB` | Cross-component finalization — anything that needs *other* components already populated (apply daily reset, recompute derived fields). |
| `SaveToDB(table saveData, table savedGenerations)` | Before BatchSet | Serialize current state into `saveData[key] = encodedString`. Use a dirty flag to skip if unchanged. **Do not clear the dirty flag here** — the save may still fail. Also write `savedGenerations[key] = self.SaveGeneration` so the round-trip carries the generation that was actually serialized (required for the concurrent-saves race; see below). |
| `OnSavedToDB(table successKeys, table savedGenerations)` | After BatchSet returns | Inspect `successKeys` for keys this Component owns. Compare `savedGenerations[key]` (the generation that was serialized for this specific save) against the current `self.SaveGeneration`. Clear the dirty flag only if both the key appears in `successKeys` **and** the generations match. Otherwise leave the flag dirty so the next save cycle retries. |
### Why three load phases (`Load → OnLoaded → PostOnLoaded`)
`OnLoadedDataFromDB` runs in arbitrary Component order — `PlayerData` cannot assume `QuestData` is already populated. If `PlayerData` needs `QuestData` ready (e.g. to apply pending quest rewards on login), put that logic in `PostOnLoadedDataFromDB`. It is guaranteed to run after every Component has finished its `OnLoadedDataFromDB`.
### Why `SaveToDB` + `OnSavedToDB` are split (and why every save carries its own generation snapshot)
A naive `SaveToDB` that clears its dirty flag immediately after serializing into `saveData[key]` (the pattern in some reference samples) is buggy in three distinct ways:
1. **PartialFailure data loss**: if the subsequent `BatchSet*` returns `PartialFailure` (1000006) and the key was not in `successKeys`, the in-memory change has been "forgotten" — the next save sees a clean dirty flag and skips it, losing the change permanently.
2. **Async-window data loss**: even on full success, periodic saves go through `BatchSetAsync`. Any setter that fires between `SaveToDB` and the async callback re-marks dirty, but if `OnSavedToDB` then clears the dirty flag unconditionally on success, the **new** change is also discarded — dirty is now false and the next cycle skips it. This races whenever a player mutates state during the storage round-trip.
3. **Concurrent-saves data loss**: two saves can be in flight at the same time (logout `AndWait` overlapping with a still-pending periodic `Async`, or — pathologically — two consecutive periodic ticks if the network is slow). If the Component stores a single `LastSerializedGeneration` property, the second save overwrites it and the first save's callback then compares against the wrong generation, declaring a stale write "current" and clearing dirty for changes that were never actually persisted.
The fix is a **per-save generation snapshot threaded through the protocol**, treated as a required part of every persistent Component (not an optional optimization):
- Every setter bumps `self.SaveGeneration` (and sets `IsSaveDB = true`).
- `SaveToDB(saveData, savedGenerations)` stamps `savedGenerations[key] = self.SaveGeneration` — the generation that was actually serialized into `saveData[key]`. The snapshot travels with the save through the round-trip.
- `OnSavedToDB(successKeys, savedGenerations)` clears `IsSaveDB` **only if** the key appears in `successKeys` **and** `savedGenerations[key] == self.SaveGeneration` — i.e. (a) storage confirmed the write and (b) no setter has fired since that particular save's serialization. Anything else leaves dirty true.
Because `savedGenerations` is a local table created fresh in each `SaveToDB` call and captured by the corresponding callback's closure, concurrent saves do not interfere — each carries its own snapshot, and only the save whose snapshot still matches the current generation gets credit for the clear.
This pattern is mandatory whenever `BatchSetAsync` is used (almost always, for periodic saves) and harmless for the blocking `BatchSetAndWait` (logout path), so the same Component logic covers both paths.
### Manager skeleton
```lua
@Component
script PlayerDBManager extends Component
@TargetUserSync property boolean IsLoadSuccess = false
property integer TimerId = 0
@ExecSpace("ServerOnly")
method boolean LoadFromDB()
local profileCode = self.Entity.PlayerComponent.ProfileCode
-- 1. Collect keys from every data component
local loadKeys = {}
if not self.Entity.PlayerData:LoadFromDB(loadKeys) then return false end
-- if not self.Entity.InventoryData:LoadFromDB(loadKeys) then return false end
-- if not self.Entity.QuestData:LoadFromDB(loadKeys) then return false end
-- 2. One BatchGet for everything (drains all pages via the helper below)
local loadedData = {}
if not self:BatchGetAndWait(profileCode, loadKeys, loadedData) then return false end
-- 3. Each component deserializes its own keys
if not self.Entity.PlayerData:OnLoadedDataFromDB(loadedData) then return false end
-- ...other components...
-- 4. Cross-component finalization
if not self.Entity.PlayerData:PostOnLoadedDataFromDB() then return false end
-- ...other components...
-- 5. CRITICAL: flip the gate ONLY after every component reports success.
-- SaveToDB checks this; if it stays false, periodic saves silently skip forever.
self.IsLoadSuccess = true
return true
end
-- Inlined §4.6 helper. Drains every page; surfaces LoadNext errors instead of swallowing them.
@ExecSpace("ServerOnly")
method boolean BatchGetAndWait(string profileCode, table loadKeys, table outLoadedData)
local ds = _DataStorageService:GetUserDataStorage(profileCode)
local code, itemPages = ds:BatchGetAndWait(loadKeys)
if code ~= 0 then
log_error(string.format("BatchGetAndWait failed. ErrorCode: %d", code))
return false
end
while true do
local datas = itemPages:GetCurrentPageDatas()
if datas == nil then break end
for i = 1, #datas do
outLoadedData[datas[i].KeyInfo.Key] = datas[i] -- DataStorageItem
end
if itemPages.IsLastPage then break end
local loadErr = itemPages:LoadNextPageAndWait()
if loadErr ~= 0 then
log_error(string.format("LoadNextPageAndWait failed. ErrorCode: %d", loadErr))
return false
end
itemPages:MoveToNextPageAndWait()
end
return true
end
@ExecSpace("ServerOnly")
method void SaveToDB(boolean isLeaving)
if not self.IsLoadSuccess then return end -- never overwrite with empty before load completed
-- saveData: payload to BatchSet. savedGenerations: per-key generation snapshot
-- that travels alongside the payload through the round-trip. Both are LOCALS,
-- so a concurrent SaveToDB call (logout overlapping a pending periodic, etc.)
-- gets its own fresh pair — no cross-contamination.
local saveData = {}
local savedGenerations = {}
self.Entity.PlayerData:SaveToDB(saveData, savedGenerations)
-- self.Entity.InventoryData:SaveToDB(saveData, savedGenerations)
-- self.Entity.QuestData:SaveToDB(saveData, savedGenerations)
-- All components clean → no keys to write. Bail out before touching the storage
-- service so unchanged domains truly cost zero Credit (per §8 "Why this scales").
if next(saveData) == nil then return end
local profileCode = self.Entity.PlayerComponent.ProfileCode
local ds = _DataStorageService:GetUserDataStorage(profileCode)
if isLeaving then
-- §6 mandates checking errorCode on every DataStorage call.
local errorCode, successKeys = ds:BatchSetAndWait(saveData)
-- Dispatch confirmed-saved keys + the per-save generation snapshot back to every
-- data component. Inlined per-component fan-out (not a helper) because the
-- helper's `successKeys` parameter cannot be annotated to match BatchSet*'s
-- return type (`List<string>`). Component-side params keep plain `table`.
self.Entity.PlayerData:OnSavedToDB(successKeys, savedGenerations)
-- self.Entity.InventoryData:OnSavedToDB(successKeys, savedGenerations)
-- self.Entity.QuestData:OnSavedToDB(successKeys, savedGenerations)
if errorCode ~= 0 then
log_error(string.format(
"Logout BatchSetAndWait failed. ErrorCode: %d, succeeded keys: %d",
errorCode, #successKeys))
-- See §4.8 for the failed-keys resolution pattern.
end
else
ds:BatchSetAsync(saveData, function(errorCode, successKeys)
-- The closure captures THIS call's `savedGenerations`. Even if another
-- SaveToDB starts and finishes before this callback runs, it brings its
-- own snapshot — no overwrite.
self.Entity.PlayerData:OnSavedToDB(successKeys, savedGenerations)
-- self.Entity.InventoryData:OnSavedToDB(successKeys, savedGenerations)
-- self.Entity.QuestData:OnSavedToDB(successKeys, savedGenerations)
if errorCode ~= 0 then
log_warning(string.format(
"Periodic BatchSetAsync failed. ErrorCode: %d, succeeded keys: %d",
errorCode, #successKeys))
end
end)
end
end
end
```
### Component skeleton (per-domain dirty flag)
```lua
@Component
script PlayerData extends Component
@TargetUserSync property integer Level = 0
@TargetUserSync property integer Meso = 0
property boolean IsSaveDB = false -- per-component dirty flag
property integer SaveGeneration = 0 -- bumped on every mutation
@ExecSpace("ServerOnly")
method boolean LoadFromDB(table loadKeys)
table.insert(loadKeys, _GMPlayerDataToolLogic.StorageName) -- "PlayerData"
return true
end
@ExecSpace("ServerOnly")
method boolean OnLoadedDataFromDB(table loadedData)
local item = loadedData[_GMPlayerDataToolLogic.StorageName] -- DataStorageItem | nil
local basicData = PlayerBasicInfo()
basicData:Init() -- defaults (§4.9)
if item ~= nil and not _UtilLogic:IsNilorEmptyString(item.Value) then
local t = _HttpService:JSONDecode(item.Value)
if not basicData:Deserialize(t) then return false end
else
self.IsSaveDB = true -- first-time user → schedule an initial save
self.SaveGeneration += 1
end
basicData:ToComponent(self)
return true
end
@ExecSpace("ServerOnly")
method boolean PostOnLoadedDataFromDB()
return true
end
@ExecSpace("ServerOnly")
method void SaveToDB(table saveData, table savedGenerations)
if not self.IsSaveDB then return end -- nothing changed → skip → save Credit
local basicData = PlayerBasicInfo()
basicData:FromComponent(self)
local t = {}
basicData:Serialize(t)
local myKey = _GMPlayerDataToolLogic.StorageName
saveData[myKey] = _HttpService:JSONEncode(t)
-- Stamp the generation we just serialized into the per-save snapshot. The matching
-- OnSavedToDB call (same closure) will compare this against self.SaveGeneration to
-- detect setters that fired during the BatchSet round-trip.
savedGenerations[myKey] = self.SaveGeneration
end
@ExecSpace("ServerOnly")
method void OnSavedToDB(table successKeys, table savedGenerations)
local myKey = _GMPlayerDataToolLogic.StorageName
for i = 1, #successKeys do
if successKeys[i] == myKey then
local snapshot = savedGenerations[myKey]
if snapshot ~= nil and self.SaveGeneration == snapshot then
self.IsSaveDB = false -- this exact save matches current state → safe to clear
end
-- If SaveGeneration moved forward since serialization (setter raced),
-- or the snapshot is nil (this Component didn't contribute to this save),
-- leave IsSaveDB = true so the next cycle re-serializes the newer state.
return
end
end
-- Key absent from successKeys → BatchSet did not confirm; leave IsSaveDB = true to retry.
end
@ExecSpace("ServerOnly")
method void SetLevel(integer level)
self.Level = level
self.IsSaveDB = true -- mark dirty on every setter
self.SaveGeneration += 1 -- and bump generation
end
end
```
### Why this scales
- One BatchGet per login. Adding a new data domain = a new Component that implements the 5 methods + one line in the Manager → automatically batched.
- One BatchSet per flush. Periodic auto-save (§4.7) and logout save share the same aggregator.
- Per-domain dirty flag means unchanged domains contribute zero bytes to `saveData` → Credit goes only where data actually changed.
- `IsLoadSuccess` gate prevents overwriting saved data with empty defaults if a timer fires before `LoadFromDB` finished (critical safety against data loss on early errors).
### Anti-patterns specific to this architecture
- ❌ Calling `ds:SetAsync` directly from inside a data Component — defeats the batching, multiplies Credit per save.
- ❌ Skipping the dirty flag check in `SaveToDB`. Every periodic flush then re-saves unchanged data → linear Credit burn with online time.
- ❌ **Clearing the dirty flag inside `SaveToDB` (before BatchSet returns).** On `PartialFailure` the in-memory change is lost permanently — the next cycle sees a clean flag and skips. Move the clear into `OnSavedToDB(successKeys)` so it only fires for confirmed-saved keys.
- ❌ **Clearing the dirty flag in `OnSavedToDB` without comparing the per-save generation snapshot.** During `BatchSetAsync`'s round-trip, a setter can mutate state; if `OnSavedToDB` clears `IsSaveDB` unconditionally on success, the newer change is silently lost. Compare `savedGenerations[key]` (carried by the closure / passed by Manager) against the current `self.SaveGeneration` and clear only when they match.
- ❌ **Storing the serialized generation as a Component property** (e.g. `LastSerializedGeneration`). Two saves in flight at the same time (logout `AndWait` overlapping a still-pending periodic `Async`, or two periodic ticks on a slow network) clobber each other's snapshot — the late-arriving callback then sees a generation written by a different save and clears dirty for changes that were never actually persisted. Keep the snapshot as a per-call local table threaded through `SaveToDB(saveData, savedGenerations)` / `OnSavedToDB(successKeys, savedGenerations)`.
- ❌ Building defaults inside `Deserialize`. Put new-account defaults in the `@Struct`'s `Init()`; put missing-field forward compat as `x or default` inside `Deserialize` (§4.9).
- ❌ Reading `loadedData[key].Value` without a nil check on `loadedData[key]`. If the key was never written, BatchGet simply omits it.
- ❌ Periodic flush firing before `IsLoadSuccess` is true. Always gate `SaveToDB` on the load-complete flag.
---
## 9. References
- API signatures: `./Environment/NativeScripts/Service/DataStorageService.d.mlua`, `./Environment/NativeScripts/Misc/UserDataStorage.d.mlua`, `./Environment/NativeScripts/Misc/GlobalDataStorage.d.mlua`, `./Environment/NativeScripts/Misc/SortableDataStorage.d.mlua`, `./Environment/NativeScripts/Misc/DataStorageItem.d.mlua`, `./Environment/NativeScripts/Misc/DataStorageItemPages.d.mlua`.
SKILL.md
---
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.