
Msw Combat System
- 4.3k installs
- 33 repo stars
- Updated July 29, 2026
- msw-git/msw-ai-coding-plugins-official
msw-combat-system is an agent skill for MSW native combat covering attack resolution, damage hooks, knockback, game feel, AI FSM, and avatar combat motion.
About
msw-combat-system is an agent skill documenting the full MapleStory Worlds combat pipeline using native APIs for two-dimensional multi-genre games. Coverage spans attack resolution with AttackComponent and HitComponent Box Circle Polygon shapes, damage model hooks including CalcDamage CalcCritical GetCriticalDamageRate and HitEvent Extra, hit reaction with per-body knockback and IsHitTarget i-frames, and six native game-feel systems including Hit Stop camera shake zoom flash VFX and SFX. Combat state uses StateComponent with DeadEvent and ReviveEvent plus PlayerComponent HP, while event bus covers HitEvent AttackEvent StateChangeEvent and custom events. AI guidance combines StateComponent FSM with AIComponent behaviour trees and chase or wander components. Additional layers document DamageSkin components, HitEffectSpawnerComponent, and AvatarStateAnimationComponent combat motion mapping. Movement rules require OnUpdate delta-based MovementComponent MoveToDirection for bodied entities and Transform Translate for projectiles, forbidding timer-based teleport movement. References point to monster model assembly, HP gauge, projectile, and AI BT implementation files for full code patter.
- Coverage matrix for attack, damage, hit reaction, game feel, combat state, events, and AI layers.
- AttackComponent Box Circle Polygon shapes with Attack AttackFast and attackInfo tagging.
- Six native game-feel systems: Hit Stop, Shake, Zoom, Flash, VFX, and SFX.
- OnUpdate delta movement via MoveToDirection or Translate; forbids timer teleport patterns.
- References for monster models, HP gauge, projectile, and AI behaviour tree implementations.
Msw Combat System by the numbers
- 4,282 all-time installs (skills.sh)
- +541 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #8 of 247 Game Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
msw-combat-system capabilities & compatibility
- Capabilities
- attack and hit shape resolution · damage and critical hook overrides · knockback and i frame hit filtering · native game feel effect integration · ai fsm and behaviour tree guidance
- Use cases
- frontend · api development
npx skills add https://github.com/msw-git/msw-ai-coding-plugins-official --skill msw-combat-systemAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4.3k |
|---|---|
| repo stars | ★ 33 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 29, 2026 |
| Repository | msw-git/msw-ai-coding-plugins-official ↗ |
How do I implement MSW melee, ranged, damage, knockback, and monster AI using native AttackComponent and HitComponent APIs?
Integrate MSW native combat pipeline covering AttackComponent, HitComponent, damage hooks, knockback, game feel, AI FSM, and avatar combat motion.
Who is it for?
MSW developers building combat-capable monsters, projectiles, damage skins, and AI-driven attack behavior with native APIs.
Skip if: Skip when the task is avatar costume editing only or platform movement without combat hit resolution.
When should I use this skill?
User implements MSW attack hit damage combat, monster AI, projectiles, knockback, hit stop, or damage skin systems.
What you get
Correct MSW combat pipeline integration with native hit shapes, damage overrides, game-feel effects, and delta-based movement patterns.
- combat hit pipeline
- damage and knockback behaviors
Files
msw-combat-system
The full MSW combat pipeline. Covers only items in the common 2D combat layer that have MSW native API support, regardless of genre. Excludes formulas/theory. API signatures are based on Environment/NativeScripts/**/*.d.mlua.
---
0. Coverage matrix
| # | Layer | Native | Custom required |
|---|---|---|---|
| 1 | Attack Resolution | AttackComponent + HitComponent (Box/Circle/Polygon) | Capsule/Cone/Ray, pierce count |
| 2 | Damage Model | CalcDamage/CalcCritical/GetCriticalDamageRate/GetDisplayHitCount hooks + HitEvent.Extra:any | Element affinity, composite formulas |
| 3 | Hit Reaction | Per-Body knockback API, IsHitTarget-based i-frame | Stagger level, status effects |
| 4 | Game Feel | All 6 native (Hit Stop, Shake, Zoom, Flash, VFX, SFX) | — |
| 5 | Combat State | StateComponent + DeadEvent/ReviveEvent, PlayerComponent HP/revive | MP/Stamina/Rage, aggro |
| 6 | Event Bus | HitEvent/AttackEvent/StateChangeEvent/PlayerActionEvent + custom @Event | OnKill/OnBlocked |
| 7 | AI | StateComponent (FSM) + AIComponent (BT, 4 Composite types native) + AIChaseComponent/AIWanderComponent, _UserService.UserEntities | Decorator/Memory(Blackboard), Threat Table |
| + | Damage Skin | 3 DamageSkin* components + DamageSkinService | — |
| + | Hit Effect | HitEffectSpawnerComponent (auto) | — |
| + | Avatar Motion | AvatarStateAnimationComponent (State→MapleAvatarBodyActionState) | — |
---
0.5 References — where to go
This SKILL.md covers only the system flow and native API surface. Actual model JSON, full script code, and variation patterns are in the references/* files below — Read them directly.
| File | Scope | When to read |
|---|---|---|
| `../msw-general/references/monster.md` | Monster .model component assembly + ActionSheet + AI choice + canonical Pattern A scripts (Soldier-style) + HP/Respawn + spawn + verification | When building a combat-capable monster |
| `references/hp-gauge.md` | Full implementation of an overhead HP bar based on PixelRendererComponent | When attaching an overhead HP bar |
| `references/projectile.md` | Projectile (Body-less entity + OnUpdate Translate) + homing/pierce/splash variants | When building ranged attacks like arrows, bullets, magic bolts |
| `references/ai-bt.md` | BehaviourTree — AIComponent + 4 Composite types + @BTNode + custom Decorator/Memory/Threat | When you need BT-based monster/boss AI and multi-layer decision making |
Priority: *this SKILL.md (concepts + API tables) → the relevant references/ (full implementation)**.
---
1. Attack Resolution
1-1. Shape & Attack trigger
HitComponent.ColliderType supports only Box / Circle / Polygon. Other shapes must be approximated by composition.
AttackComponent:
Attack(Vector2 size, Vector2 offset, string attackInfo, CollisionGroup? cg) → table<Component>
Attack(Shape shape, string attackInfo, CollisionGroup? cg) → table<Component>
AttackFast(Shape shape, string attackInfo, CollisionGroup? cg) → void (for mass resolution, bullet hell)
AttackFrom(Vector2 size, Vector2 position, string attackInfo, CollisionGroup? cg) → table<Component>
emitter EmitAttackEvent(AttackEvent)- Shapes:
CircleShape(position, radius)/BoxShape(position, size, angle)/PolygonShape(position, points, angle). For an axis-aligned rectangle, useBoxShape(center, size, 0)— there is noRectangleShapetype. Passangle = 0toBoxShape/PolygonShapewhen no rotation is needed. - Polygon hit surface:
HitComponent.PolygonPoints: SyncList<Vector2> AttackFastdoes not build a hit table → better performance for bullet hell / mass resolution
1-2. Target filter
| Side | Override | Purpose |
|---|---|---|
| Attacker | AttackComponent:IsAttackTarget(defender, attackInfo) → boolean | Faction / distance / state |
| Defender | HitComponent:IsHitTarget(attackInfo) → boolean | Invincibility / immunity |
If either returns false → the hit is excluded. The super call is `__base:IsAttackTarget(...)` (mlua-specific).
⚠ Do not add `@ExecSpace` when overriding — bothIsAttackTargetandIsHitTargethave an unspecified ExecSpace (=All) on the parent. Adding an annotation like@ExecSpace("ServerOnly")in the child triggers LEA-3014 `SignatureMismatch` at runtime. Even without the annotation, the call path runs through the server-side hit pipeline, so actual execution happens on the server. Details: `msw-scripting/SKILL.md` §9 "Method override".
HitComponent.CollisionGroupdefaults toCollisionGroups.HitBox. The last argument ofAttack(..., cg)specifies the target group.- Duplicate-hit prevention / pierce / max hits: not native. Manage in script via the table returned by
Attack+ atable<Entity, boolean>cache.
1-3. attackInfo tagging
A string extension point that propagates into CalcDamage/IsHitTarget/GetDisplayHitCount. Value conventions are up to the project. A namespace style such as "melee.light", "dot.poison" is recommended.
1-4. ⚠️ IsLegacy
ColliderType/ColliderOffset/PolygonPoints are only valid when HitComponent.IsLegacy = false. BoxOffset/ColliderName are deprecated.
1-5. Shape mapping per attack form
| Form | Shape construction |
|---|---|
| Frontal melee Box | BoxShape(pos + LookDirectionX*offset, size, 0) — see DefaultPlayer PlayerAttack |
| Circular AoE | CircleShape(self.WorldPos, radius) |
| Projectile | Spawn a Body-less model (Sprite+Transform only) + in OnUpdate(delta) call TransformComponent:Translate(speed*delta, 0) + distance-based hit check + _EntityService:Destroy. Movement rules in §1-6, full implementation → [`references/projectile.md`](references/projectile.md) |
There is no MSW-specific projectile system — implemented as an entity + AttackComponent combo.
1-6. Continuous movement — common rules for projectiles, monsters, and AI
Continuous movement (chase, flight, auto-move) is per-frame `OnUpdate(delta)`-based. Moving via a timer (SetTimerRepeat(0.1~0.15s)) produces 6~10Hz teleportation that looks choppy.
Recommended API per target
| Target | Body? | Movement API | Rationale |
|---|---|---|---|
| Monster / NPC / AI | Yes (map-type Body + MovementComponent) | MovementComponent:MoveToDirection(dir, 0) + MovementComponent.InputSpeed | MovementComponent.d.mlua:1 — controls all three of Rigid/Kinematic/Sideview. InputSpeed belongs to MovementComponent (.d.mlua:7), so it is not Player-only. The second arg 0 — deltaTime is applied only on ladders (.d.mlua:32). Official BT examples ActionFollow/ActionMoveRandom also use 0. |
| Projectile / gem / drop item / effect | No (Sprite+Transform+Trigger) | self.Entity.TransformComponent:Translate(speed*delta, 0) every frame | Direct Transform manipulation is safe without a Body. The pattern from the official "Create a Long-Range Projectile" tutorial. |
| Direct Rigidbody control (advanced) | Yes | body:AddForce(...) — sustained acceleration / impulse | RigidbodyComponent.d.mlua:71 — MoveVelocity is "mainly controlled by MovementComponent", so prefer routing through MovementComponent instead of writing it directly. |
For the actual velocity conversion of MovementComponent.InputSpeed per map type, see `msw-general/references/platform.md` §10 (MapleTile=×1, RectTile=÷1.2, SideView=×1.5).Forbidden patterns
| ❌ | Reason |
|---|---|
_TimerService:SetTimerRepeat(move, 0.1~0.15) for movement | 6~10Hz teleport, no frame interpolation → jerky |
body:SetPosition(...) / MovementComponent:SetPosition(...) inside OnUpdate | Both are teleport methods (MovementComponent.d.mlua:37, SetPosition on each Body's .d.mlua). Using them for continuous movement is choppy. Use only for one-shot spawn/respawn/snap. |
self.Entity.TransformComponent.Position = newPos (entity with active Body) | The physics engine overwrites it next frame and network sync is blocked. |
Constant-step move without delta, e.g. Translate(0.009, 0) | Frame-rate dependent. Speed differs between 60FPS and 30FPS. |
⚠ Be cautious with the official msw-search antipattern
mlua_Document_Retriever returns a high-scoring "Entity Movement Control Using MovementComponent" document, but the body is an antipattern that moves every frame inside OnUpdate with MovementComponent:SetPosition(...) — ignore that document and use MoveToDirection / Translate from the table above. FlappyFish Remake, Stopping the Taxi, and Making a Moving Foothold also show missing-delta or direct-Position assignment, so be careful when referring to them.
MovementComponent attachment — monsters / NPCs
Not included in the monster model by default. The .model must contain all of the following components.
| Component | Notes |
|---|---|
MOD.Core.TransformComponent | Default |
MOD.Core.SpriteRendererComponent | Renderer |
| Body (map type) | RigidbodyComponent(MapleTile) / KinematicbodyComponent(RectTile) / SideviewbodyComponent(SideViewRectTile) |
MOD.Core.MovementComponent | E.g. InputSpeed = 2.0 — required for movement APIs |
Cross references
- Knockback (1-shot impulse) is not continuous movement, so use §3-1 directly.
- Body selection per map type / InputSpeed conversion: `msw-general/references/platform.md` §4·§10
---
2. Damage Model
AttackComponent:
method integer CalcDamage(attacker, defender, attackInfo) -- default 1 (ExecSpace=All)
method boolean CalcCritical(attacker, defender, attackInfo) -- default false (ExecSpace=All)
method float GetCriticalDamageRate() -- default 2.0 (ExecSpace=All)
method int32 GetDisplayHitCount(attackInfo) -- default 1 (ExecSpace=All)
method void OnAttack(defender) -- (ExecSpace=All)
HitComponent:
method void OnHit(Entity attacker, integer damage, boolean isCritical, string attackInfo, int32 hitCount)
emitter EmitHitEvent(HitEvent)⚠ All hooks above have an unspecified ExecSpace (=All) on the parent. Adding@ExecSpace("ServerOnly")etc. when overriding triggers LEA-3014 `SignatureMismatch`. Drop the annotation and declare justmethod .... Details: `msw-scripting/SKILL.md` §9 "Method override".
2-1. HitEvent payload
AttackCenter: Vector2
AttackerEntity: Entity (nilable)
Damages: List<integer> -- multi-hit split
Extra: any -- ★ extension slot (knockback/stun/element/tags)
IsCritical: boolean
TotalDamage: integer
FeedbackAction: HitFeedbackAction -- ⚠ entire enum deprecatedCarry auxiliary info (knockback vector, stagger time, element) on the Extra table.
2-2. AttackEvent payload
A single field, DefenderEntity: Entity. The attacker is the handler's self.
2-3. ⚠️ Antipattern: direct HP subtraction — do not bypass HitEvent
Subtracting the defender's HP directly, such as monster.Hp -= damage / target.MonsterAI.HP -= damage, does not emit `HitEvent` — damage skin, hit effect, IsHitTarget immunity, and OnHit overrides all silently skip.
For player-side custom damage (channel / aura / DoT), the bypass also breaks avatar animation: AvatarStateAnimationComponent only reacts to StateChangeEvent, so the player avatar stays in idle even though the HP bar drops. If you must apply damage without HitEvent, also manually call StateComponent:ChangeState("HIT") (UPPERCASE key — see §10) and, for death/revive, PlayerComponent:ProcessDead() / ProcessRevive(). Otherwise hit/dead motions silently miss with no error.
---
3. Hit Reaction
3-1. Knockback — API per Body
| Body (map type) | Implementation |
|---|---|
| Rigidbody (MapleTile) | body:AddForce(Vector2(dir*5, 3)) ★recommended · SetForce · JustJump(Vector2(0, 4)) (vertical) |
| Kinematicbody (RectTile / top-down) | body.MoveVelocity = Vector2(dir*5, 0) — no AddForce |
| Sideviewbody (SideViewRectTile) | body.MoveVelocity + body.JumpSpeed |
- Rigidbody is auto-damped by the engine. Kinematic/Sideview must be damped manually inside
OnUpdate(MoveVelocity *= 0.9). - Wall bounce: subscribe to
FootholdCollisionEventand flip the velocity. - Knockback is a 1-shot impulse — do not confuse it with continuous movement (chase, flight). For continuous movement see §1-6.
- ⚠ Forbidden: assigning
TransformComponent.Positiondirectly on an entity with an active Body → network sync is blocked.body:SetPosition(...)is a teleport method, so do not call it inside anOnUpdateloop (§1-6).
3-2. i-frame
Standard pattern: deadline check based on _UtilLogic.ElapsedSeconds + returning false from HitComponent:IsHitTarget. The DefaultPlayer default PlayerHit.mlua provides this pattern as-is (§9-4).
Alternative: while invincible, swap HitComponent.CollisionGroup to a separate group → the resolution itself is excluded. This is better for frame-accurate precision.
3-3. Status effects (Buff/Debuff)
No native support. Implement a @Component BuffComponent directly + tick with _TimerService:SetTimerRepeat + broadcast custom StatusAppliedEvent/StatusExpiredEvent.
For a single simple stun, StateComponent:ChangeState("STUN") + input/AI block flags is enough.
---
4. Game Feel — all native
| Element | API | ExecSpace |
|---|---|---|
| Hit Stop (global) | _UtilLogic:SetClientTimeScale(float) — 0~100 | ClientOnly |
| Hit Stop (individual) | renderer.PlayRate = 0 (Sprite/Skeleton/Avatar) | @Sync |
| Slow Motion | _UtilLogic:SetClientTimeScale(0.3) + timer to restore | ClientOnly |
| Camera Shake | cameraComp:ShakeCamera(intensity, duration, targetUserId?) | Client |
| Camera Zoom | cameraComp:SetZoomTo(percent, duration, targetUserId?) · requires IsAllowZoomInOut=true first | Client |
| Hit Flash | spriteRenderer.Color = Color(r,g,b,a) → timer to restore | @Sync |
| Color HDR overbright | Color.HSVToRGB(h, s, v, hdr=true) — values > 1.0 allowed | — |
| VFX fixed | _EffectService:PlayEffect(clipRUID, instigator, pos, zRot, scale, isLoop?, options?) → serial | — |
| VFX attached | _EffectService:PlayEffectAttached(clipRUID, parent, localPos, localZRot, localScale, isLoop?, options?) | — |
| VFX remove | _EffectService:RemoveEffect(serial) | — |
| SFX 2D | _SoundService:PlaySound(id, volume, targetUserId?) | Client |
| SFX 3D | _SoundService:PlaySoundAtPos(id, pos, listener, volume) | Client |
| SFX loop | PlayLoopSound / PlayLoopSoundAtPos | Client |
| SFX attached | SoundComponent:Play() · pitch randomization via Pitch 0~3 | Client |
| BGM | _SoundService:PlayBGM(id, volume) / StopBGM(immediately) | Client |
| Preload | _SoundService:LoadSound(id) | ClientOnly |
PlayEffect options keys: FlipX, FlipY, SortingLayer, OrderInLayer, Alpha, StartFrameIndex, EndFrameIndex, PlayRate, SyncFlip, Color, MaterialID, IgnoreMapLayerCheck, LitMode
Get the current camera: _CameraService:GetCurrentCameraComponent().
ParticleService — built-in particles
General-purpose particle effects driven by enum values only, no RUID. 3 categories:
-- BasicParticle: general-purpose presets (no RUID needed)
integer _ParticleService:PlayBasicParticle(BasicParticleType, Entity instigator, Vector3 pos, number zRot, Vector3 scale, boolean isLoop, Dictionary options)
integer _ParticleService:PlayBasicParticleAttached(BasicParticleType, Entity parent, Vector3 localPos, number localZRot, Vector3 localScale, boolean isLoop, Dictionary options)
-- SpriteParticle: custom sprite as a particle (spriteRUID required)
integer _ParticleService:PlaySpriteParticle(SpriteParticleType, string spriteRUID, Entity instigator, Vector3 pos, number zRot, Vector3 scale, boolean isLoop, Dictionary options)
integer _ParticleService:PlaySpriteParticleAttached(SpriteParticleType, string spriteRUID, Entity parent, Vector3 localPos, number localZRot, Vector3 localScale, boolean isLoop, Dictionary options)
-- AreaParticle: environmental particles over a wide area (areaSize added)
integer _ParticleService:PlayAreaParticle(AreaParticleType, Vector2 areaSize, Entity instigator, Vector3 pos, number zRot, Vector3 scale, boolean isLoop, Dictionary options)
void _ParticleService:RemoveParticle(integer serial)options keys: Color, SortingLayer, OrderInLayer, ParticleSize, ParticleCount
Looping particles (isLoop=true) must be cleaned up via RemoveParticle(serial). Store the serial in self._T so it can be removed later.
Full BasicParticleType list
| Family | Name | Description |
|---|---|---|
| Explosion/impact | SparkExplosion | Sparks (one-shot) — general-purpose hit |
SparkLoop | Continuous sparks | |
SparkRadialExplosion | Sparks scattering radially | |
SmallExplosion | Small explosion + smoke | |
BigExplosion | Big explosion + smoke | |
TinyExplosion | Very small explosion (Color option ignored) | |
DustExplosion | Circular shockwave + smoke (Color option ignored) | |
EnergyExplosion | Circular shockwave then center convergence | |
CircleBurst | Circular light burst | |
PillarBurst | Circular light burst + directional light | |
| Fire/flame | FireField | Cartoon flames |
FireFieldIntense | Intense cartoon flames | |
FireBall | Flame at a single point | |
FlameThrower | Flamethrower stream | |
LargeFlames | Large flames from the floor | |
MediumFlames | Medium flames from the floor | |
TinyFlames | Tiny flames from the floor | |
WildFire | Giant pillar of flame (Color option ignored) | |
| Lightning/electric | LightningOrbSharp | Spherical electric particles |
LightningStrikeSharp | Lightning bolt | |
LightningStrikeSharpTall | Tall lightning bolt | |
LightningOrbSoft | Electric wave emission | |
LightningBlast | Periodic electric waves | |
LightningStrike | Periodic lightning | |
LightningStrikeTall | Periodic tall lightning | |
| Buff/magic | Aura | Aurora light from the floor |
Buff | Strong light rising from the floor | |
Charge | Large particles converging on one point | |
ChargeOrb | Particles converging on one point | |
Enchant | Large light with light/particles around it | |
SpinField | Particles around a rotating circle | |
StarVortex | Starlight converging to the center | |
Nova | Wide circular wave | |
UpperCylinder | Rising pillar from the floor | |
| Misc | Firework | Fireworks |
FireworkCluster | Multiple fireworks at once | |
FireFlies | Fireflies | |
GoopSpray | Liquid spray to the side | |
GoopSprayEffect | Liquid spray downwards | |
DustStorm | Wide dust storm | |
RisingSteam | Rising white mist from the floor | |
BigSplash | Large water splash | |
Shower | Water poured on one spot |
Full SpriteParticleType list (8)
| Name | Description |
|---|---|
BurstBig | Sprite emerges in a radial pattern |
SpawnField | Particles + sprite emerge in a circular area |
BurstNova | Particles + sprite burst in a circular pattern |
SimpleSpawn | Simple particle + sprite appearance |
Burst | Particles + sprite scatter |
Stream | Generated while moving in a specific direction |
StreamSharp | Thin line moving in a specific direction |
AdditiveColor | Color effect applied to the sprite |
Full AreaParticleType list (12)
| Name | Description |
|---|---|
Rain | Rain |
Snow | Snow |
FogCalm | Fog |
FogHeavy | Heavy descending fog |
FogLively | Rising fog |
CalmStarField | Rising star cluster |
StarFieldSimple | Twinkling star cluster |
StarFog | Star + nebula particles (stationary) |
StarFogFlow | Star + nebula particles (rising) |
Windlines | Thin lines |
WindlinesBig | Thin lines + thick lines |
WindlinesSpeedy | Fast straight lines |
Choosing between EffectService and ParticleService
| Situation | Recommended |
|---|---|
| MapleStory skill / hit animations (specific imagery) | EffectService (specify RUID) |
| General hit/explosion (fast to implement) | ParticleService.BasicParticle |
| Scatter a custom image as particles | ParticleService.SpriteParticle |
| Environmental ambience like rain/snow/fog | ParticleService.AreaParticle |
| Sustained effects like buff auras | Either with isLoop=true |
| Rich, layered effects | Combine EffectService and ParticleService |
Standard pattern for server event → client effect:@Syncproperty change → detected inOnSyncProperty(ClientOnly)→ call EffectService/ParticleService.
---
5. Death / Revive
| Event | Emission condition | Payload |
|---|---|---|
DeadEvent | Auto on StateComponent:ChangeState("DEAD") | none |
ReviveEvent | Auto on PlayerComponent:Respawn() (players only) | none |
StateChangeEvent | Auto on every state transition | CurrentStateName, PrevStateName |
Tracking the killer: DeadEvent has no payload → cache self.LastAttacker = event.AttackerEntity in HandleHitEvent and use it in HandleDeadEvent.
For player-specific death/revive, prefer §9-1 PlayerComponent.Respawn/ProcessDead/ProcessRevive.
---
6. Event Bus
| Logical event | MSW implementation |
|---|---|
| OnAttackStart | OnAttack hook or custom AttackStartEvent |
| OnAttackHit / OnDamageTaken | Native HitEvent |
| OnAttackMiss | Custom — SendEvent when IsAttackTarget returns false |
| OnCriticalHit | Covered by the HitEvent.IsCritical flag |
| OnDeath / OnRevive | Native DeadEvent/ReviveEvent |
| OnStateChange | Native StateChangeEvent |
| OnKill / OnBlocked / OnParry / OnStatusApplied | Custom @Event |
6-1. Custom event rules
- Definition:
@Event script XxxEvent extends EventType+propertydeclarations - Receiving: the
handlerkeyword (not method),@EventSender("Self" | "Service","XxxService" | "Logic","XxxLogic") - Connect/disconnect:
entity:ConnectEvent(XxxEvent, self.Handler)/ call `DisconnectEvent` in `OnEndPlay` (the engine does not auto-disconnect) - Global:
@Logic CombatEventBusLogicsingleton +@EventSender("Logic","CombatEventBusLogic")
---
7. AI — FSM(StateComponent) + BT(AIComponent) + custom-script (Pattern A), all native-compatible
| Pattern | Fit | Reference |
|---|---|---|
FSM (StateComponent + @State) | Simple enemies (3~5 states), player IDLE/HIT/DEAD, boss phases, animation sync (AvatarStateAnimationComponent auto mapping §10). Requires StateComponent.IsLegacy=false if you want StateAnimationComponent to auto-swap clips. | [`../msw-general/references/animation-state.md`](../msw-general/references/animation-state.md) (state-machine + animation pipeline unified) |
BT (AIComponent + 4 Composite types + @BTNode) | Patrol + chase + attack combos, varied boss patterns, Composite/Decorator reuse, probability-weighted actions. Requires StateComponent.IsLegacy=false. | [`references/ai-bt.md`](references/ai-bt.md) |
Custom script with self-state (@Component holding CurrentAIState plus direct SpriteRUID assignment — Soldier-style pattern) | Behaviors that don't fit AIChase/AIWander (roam ↔ stand ↔ say ↔ attack, range-gated attacks, talking idle). No `AIChaseComponent`/`AIWanderComponent`, no `IsLegacy=false` needed — the script bypasses the ActionSheet pipeline. Reserve StateComponent for IDLE ↔ DEAD only. | `../msw-general/references/monster.md` §7 "Canonical Pattern A Scripts (Soldier)" |
7-1. FSM — StateComponent (summary)
StateComponent + @State script XxxStateType extends StateType (lifecycle OnEnter/OnUpdate/OnExit/OnConditionCheck). The only auto-registered states are IDLE/DEAD (+ HIT if a HitComponent exists, MOVE if an AIChase/AIWander exists) — ATTACK/PATROL/STUN/PHASE2 etc. must all be pre-registered via AddState("name", XxxStateType) in OnBeginPlay. Auto transitions use AddCondition(from, to) + per-frame OnConditionCheck().
⚠ State names must be UPPERCASE; unregistered names immediately produce[LEA-3005] InvalidArgument : 'stateName'. Registering a key inAvatarStateAnimationComponent.StateToAvatarBodyActionSheetdoes not auto-register it inStateComponent— the two are separate.
Full implementation → [`../msw-general/references/animation-state.md`](../msw-general/references/animation-state.md) (FSM authoring, ChangeState failure matrix, standard PATROL/CHASE/ATTACK/HIT/DEAD monster pattern, and the state→animation pipeline live together — the two are the same underlying system viewed from two angles)
7-2. BT — AIComponent (summary)
AIComponent + SequenceNode/SelectorNode/RandomSelectorNode/ParallelNode + @BTNode Action Nodes + native AIChaseComponent/AIWanderComponent. All 4 Composite types are native; Decorator/Memory(Blackboard)/Threat Table must be implemented by hand.
⚠ When using custom BT, removeAIChaseComponent/AIWanderComponentfrom the.model.
Full implementation → [`references/ai-bt.md`](references/ai-bt.md)
Full monster entity composition → `../msw-general/references/monster.md`
>
This SKILL.md only covers combat-specific aspects (ATTACK/HIT/DEAD + DeadEvent/ReviveEvent + BT entry point). For general mlua state machine / scripting patterns see `msw-scripting`.
---
8. UI natives
| UI | API |
|---|---|
| HP bar (screen-fixed) | SliderComponent (MinValue/MaxValue/Value/FillRectColor/FillRectImageRUID/Direction/UseHandle) + SliderValueChangedEvent. ⚠ UI entities only |
| Damage numbers | 3 DamageSkin* components + DamageSkinService — §11 |
| Crosshair | SpriteGUIRendererComponent in .ui |
| Combo counter / buff icons | TextComponent + SpriteGUIRendererComponent |
Worldspace HP bar (overhead): no native support. Two implementation options:
| Option | Approach | Fit |
|---|---|---|
| Lightweight | Adjust LocalScale.x = hp/maxHp on a child entity's SpriteRendererComponent or use TiledSize.x (with SpriteDrawMode.Tiled) | Quick prototype, simple gauge |
| Full | Based on PixelRendererComponent — full implementation [`references/hp-gauge.md`](references/hp-gauge.md) | Production-grade, many monsters shown at once |
---
9. DefaultPlayer combat natives
The player entity has HP, revive, and input natively. Do not create custom `Hp`/`MaxHp` properties — use PlayerComponent.
The full property/method tables forPlayerComponent/PlayerControllerComponentare in `msw-defaultplayer/SKILL.md`. Only combat essentials here.
9-1. Core combat APIs
| Item | Usage |
|---|---|
| HP decrement | self.Entity.PlayerComponent.Hp -= event.TotalDamage |
| Death check | PlayerComponent:IsDead() |
| Revive | PlayerComponent:Respawn() — RespawnPosition → SpawnLocation → map entry point. DeadEvent/ReviveEvent auto-emitted |
| Client-only death processing | @ExecSpace("Client") ProcessDead(targetUserId?) / ProcessRevive(targetUserId?) |
| Direction check ★ | PlayerControllerComponent.LookDirectionX (+1 right, -1 left). Do not use TransformComponent.Scale.x |
| Action hook override | ActionAttack / ActionJump / ActionInteraction(key, isKeyDown) etc. |
| Action event reception | EmitPlayerActionEvent(PlayerActionEvent) → §9-3 |
9-3. PlayerActionEvent
property string ActionName -- "Attack" / "Jump" / "Crouch" / ...
property Entity PlayerEntityThe default pattern is PlayerAttack extends AttackComponent that receives @EventSender("Self") handler HandlePlayerActionEvent(...) and branches on event.ActionName == "Attack".
9-4. Default templates (RootDesk/MyDesk/)
Copy-paste without modification. Override as needed:
| File | Role | Key points |
|---|---|---|
PlayerAttack.mlua | Frontal Box attack | LookDirectionX for direction, AttackFast + CollisionGroups.Monster, CalcDamage=50, 30% crit |
PlayerHit.mlua | i-frame | ImmuneCooldown property, _UtilLogic.ElapsedSeconds deadline, IsHitTarget override |
Monster.mlua | Monster HP | Custom @Sync Hp (no PlayerComponent), HandleHitEvent → Dead/Respawn |
MonsterAttack.mlua | Sprite-size-based melee | isvalid(defender.PlayerComponent) + __base:IsAttackTarget(...) super in IsAttackTarget |
9-5. Time reference
`_UtilLogic.ElapsedSeconds` is recommended (world clock, consistent across pause/restore). Do not use os.clock().
9-6. Standard CollisionGroups
| Constant | Purpose |
|---|---|
CollisionGroups.Player | Monster → Player attack |
CollisionGroups.Monster | Player → Monster attack |
CollisionGroups.HitBox | Default for HitComponent.CollisionGroup |
---
10. Avatar motion — AvatarStateAnimationComponent
Auto-links StateComponent transitions to avatar animations.
@Sync property SyncDictionary<string, AvatarBodyActionElement> StateToAvatarBodyActionSheet -- IsLegacy=false
@Sync property SyncDictionary<string, string> ActionSheet -- IsLegacy=true (deprecated)
method void SetActionSheet(string key, string animationClipRuid)
method void RemoveActionSheet(string key)
method string StateStringToAnimationKey(string stateName)
emitter EmitBodyActionStateChangeEvent(BodyActionStateChangeEvent)ChangeState("HIT")→ the mappedMapleAvatarBodyActionState.Hitplays automatically- Combat-relevant state values:
Attack=3,Hit=14,Dead=10,Alert=4,Heal=13 IsLegacy=falsefixed; use onlyStateToAvatarBodyActionSheet
The full avatar component coverage (AvatarRendererComponent etc.) is in `msw-defaultplayer`. This section covers only combat motion mapping.---
11. Damage skin (number display)
Default RUIDs
| Purpose | RUID | Used on |
|---|---|---|
| Hit | 3271c3e79bf04ecba9a107d55495970d | Default for attacker's DamageSkinSettingComponent.DamageSkinId |
| Taken hit | 02c22d93421b4038b3c413b3e40b57ec | Defender-side display — call _DamageSkinService:Play manually |
| Heal | d58b67cf0f3a4eaf9fe1ad87c0ffac8a | Heal/potion — call _DamageSkinService:Play manually |
11-1. Auto mode (component-based)
On Attack/AttackFast, if all 3 components below are present, damage numbers are displayed automatically:
| Side | Component | Role |
|---|---|---|
| Attacker | DamageSkinSettingComponent | Which skin/style to display |
| Defender | DamageSkinSpawnerComponent | Display position offset |
| Defender | DamageSkinComponent | Damage number body (over the entity) |
Include all 3 in the .model and damage numbers appear with zero script code.
DamageSkinSettingComponent (attacker)
| Property | Type | Default | Description |
|---|---|---|---|
DamageSkinId | DataRef | hit RUID (table above) | Damage number skin RUID |
DamageSkinScale | Vector2 | (1, 1) | Number size |
Alpha | float | 1 | Opacity |
PlayRate | float | 1 | Playback speed |
DelayPerAttack | float | 0.05 | Delay between multi-hits (seconds) |
TweenType | DamageSkinTweenType | Default | Animation style |
LitMode | LitMode | Default | Lighting influence |
DamageSkinTweenType: Default (popup) / Volcano (fan) / Blade (overlap) / each *Mini (75% scale)
DamageSkinSpawnerComponent (defender)
| Property | Type | Default |
|---|---|---|
DamageSkinOffset | Vector2 | (0,0) |
11-2. Manual mode — DamageSkinService
Cases not caught by auto mode (heal, Miss/Guard, non-standard damage sources) call _DamageSkinService directly.
_DamageSkinService:Play(targetEntity, skinRuid, delay, damages:List<int>, tweenType, isCritical, offset, scale, playRate, alpha, litMode)
_DamageSkinService:PlayTextDamage(targetEntity, skinRuid, textType, tweenType)
_DamageSkinService:PreloadAsync(skinRuid, callback(success)) -- ClientOnlyDamageSkinTextType: Miss / Guard / Resist / Shot / Counter
⚠_DamageSkinService:Playis in theClientspace — to call it from server logic (HP subtraction, etc.) wrap it in an@ExecSpace("Client")method or change a@Syncproperty and trigger fromOnSyncProperty.
⚠ `Play()` has 6 required parameters. Passing only some of the 5 optional ones triggers LEA-3005 `InvalidArgument`.
11-3. Recipes
(a) Critical emphasis — auto mode + dynamic scale
Auto mode renders red font automatically when IsCritical=true. To emphasize further, temporarily increase the attacker-side scale:
-- ⚠ AttackComponent hooks (CalcDamage/CalcCritical/GetCriticalDamageRate/GetDisplayHitCount/
-- IsAttackTarget/IsHitTarget/OnAttack) have an unspecified ExecSpace (=All) on the parent.
-- Adding @ExecSpace when overriding triggers LEA-3014 SignatureMismatch.
-- Details: msw-scripting/SKILL.md §9 "Method override → LEA-3014"
method integer CalcDamage(Entity attacker, Entity defender, string attackInfo)
return 100
end
method boolean CalcCritical(Entity attacker, Entity defender, string attackInfo)
return math.random() < 0.3
end
method float GetCriticalDamageRate()
return 2.5 -- 100 → 250
endDifferentiate criticals visually with DamageSkinSettingComponent.TweenType = Volcano (fan scatter) or Blade (overlap).
(b) Heal / recovery — manual call
local HEAL_RUID = "d58b67cf0f3a4eaf9fe1ad87c0ffac8a"
@ExecSpace("Client")
method void ShowHeal(Entity target, integer amount)
_DamageSkinService:Play(
target, HEAL_RUID, 0,
{ amount }, -- damages
DamageSkinTweenType.Default,
false, -- isCritical
Vector2(0, 0.5), -- offset (above head)
Vector2(1, 1), 1.0, 1.0, LitMode.Default
)
end(c) Miss / Guard / Resist text
local HIT_RUID = "02c22d93421b4038b3c413b3e40b57ec"
@ExecSpace("Client")
method void ShowMiss(Entity target)
_DamageSkinService:PlayTextDamage(
target, HIT_RUID, DamageSkinTextType.Miss, DamageSkinTweenType.Default
)
endCall this when AttackComponent:IsAttackTarget returned false → "miss animation + damage 0".
(d) Multi-hit — split into N with a single call
If you pass a List as the damages argument of _DamageSkinService:Play, the numbers are shown sequentially at DelayPerAttack (attacker component value) intervals:
_DamageSkinService:Play(target, ATTACK_RUID, 0, { 12, 8, 14, 11, 9 },
DamageSkinTweenType.Default, false, Vector2(0,0), Vector2(1,1), 1, 1, LitMode.Default)Auto mode behaves identically with HitEvent.Damages (List) — override GetDisplayHitCount(attackInfo) to control the split count.
(e) Preload — prevent first-display stutter
The first use of a skin RUID may have texture loading lag. Preload on map entry:
@ExecSpace("ClientOnly")
method void OnBeginPlay()
_DamageSkinService:PreloadAsync("3271c3e79bf04ecba9a107d55495970d", function(ok) end)
_DamageSkinService:PreloadAsync("02c22d93421b4038b3c413b3e40b57ec", function(ok) end)
_DamageSkinService:PreloadAsync("d58b67cf0f3a4eaf9fe1ad87c0ffac8a", function(ok) end)
end(f) TweenType use cases
| TweenType | Recommended situation |
|---|---|
Default | Normal hits |
Volcano | Critical / area hits (upward scatter) |
Blade | Continuous slashes / combos (overlapping numbers) |
*Mini | Small damage like DoT (poison/burn) — less screen clutter |
(g) Faction-specific skins
To use different skin RUIDs per side (player vs enemy, PvP factions, etc.), swap DamageSkinSettingComponent.DamageSkinId at runtime:
self.Entity.DamageSkinSettingComponent.DamageSkinId = MY_TEAM_SKIN_RUID---
12. Hit effect — HitEffectSpawnerComponent
Attach to the defender and a hit effect plays automatically on HitEvent. No properties — just add the component to the .model.
---
13. Full combat checklist
- [ ] Attacker model: an
AttackComponent-derived script (+ optional:DamageSkinSettingComponent) - [ ] Defender model:
HitComponent+HitEffectSpawnerComponent+ (optional:DamageSkinSpawnerComponent+DamageSkinComponent) - [ ] HitComponent:
IsLegacy=false, setColliderType/BoxSize/CircleRadius, setCollisionGroup - [ ] State motions: register
ATTACK/HIT/DEADinStateComponent+AvatarStateAnimationComponent.StateToAvatarBodyActionSheet - [ ] HP handling: player uses
PlayerComponent.Hp; monster uses custom@Sync Hp - [ ] Direction check:
LookDirectionX(no Scale.x) - [ ] Time reference:
_UtilLogic.ElapsedSeconds(no os.clock) - [ ] Event cleanup: explicit
DisconnectEventinOnEndPlay - [ ] Body rule: do not assign
TransformComponent.Positiondirectly on an entity with an active Body
---
14. Custom implementation is required
Buff/Debuff · BT Decorator/Memory(Blackboard) · Aggro/Threat Table · projectile pooling · pierce/max-hits · stagger-level system · resources (MP/Stamina/Rage) · combo/cancel windows · guard/parry · world→screen coordinate conversion · worldspace HP bar
---
Out of scope
- General player topics (HP/movement/camera/costume aside): `msw-defaultplayer`
- General mlua grammar/lifecycle: `msw-scripting`
.modelauthoring rules/templates: `msw-general`
AI BehaviourTree — AIComponent + Composite/Action Node
0. When to use BT
| Pattern | Fit |
|---|---|
FSM (StateComponent) | Simple enemies (3~5 states), player IDLE/HIT/DEAD, boss phases, animation sync (AvatarStateAnimationComponent auto mapping, SKILL §10) |
BT (AIComponent) | Patrol + chase + attack combos, varied boss patterns, Composite/Decorator reuse, probability-weighted actions |
MSW supports both paradigms natively. Use BT for multi-layer decision making and reusable action modules.
Code-based BT vs data-based BT — pick one per `AIComponent`. This reference covers the code-based path:@BTNodemlua scripts assembled at runtime viaAIComponent:CreateNode/SetRootNode. The data-based path is a separate.behaviourtreeJSON file edited in the Maker editor withdefinitionIdwiring — that pipeline has its own authoring rules and lives in the `msw-behaviourtree` sibling skill. The two pipelines do not mix on the sameAIComponent; if a project already has.behaviourtreeassets, usemsw-behaviourtreeinstead of this reference.
---
1. AIComponent API
@Component AIComponent
property boolean IsLegacy = false -- fixed false (legacy deprecated)
property boolean LogEnabled = false -- BT execution log in Maker mode
property UpdateAuthorityType UpdateAuthority = UpdateAuthorityType.Server
method BTNode CreateLeafNode(string nodeName, func(float) -> BehaviourTreeStatus onBehave)
method BTNode CreateNode(string nodeType, string nodeName = nil, func(float) -> BehaviourTreeStatus = nil)
method void SetRootNode(BTNode node)BehaviourTreeStatus:
| Value | Meaning |
|---|---|
Success = 0 | Proceed to the next sibling node |
Running = 1 | Re-run the same node next frame (parent restarts from this child) |
Failure = 2 | Sequence terminates immediately / Selector tries the next sibling |
---
2. Four Composite Nodes (native)
| Node | Child flow | Termination |
|---|---|---|
SequenceNode(name) | Sequential | Returns Failure immediately on any child Failure; Success if all succeed |
SelectorNode(name) | Sequential | Returns Success immediately on any child Success; Failure if all fail |
RandomSelectorNode(name) | Picks one child by weighted probability and runs it | Returns the chosen child's result as-is. While Running, sticks to the same child |
ParallelNode(name) | All children run in parallel | Success when all succeed; Failure if any fails |
Common methods (all Composites, 1-indexed)
method void AttachChild(BTNode node)
method void AttachChildAt(BTNode node, int32 index)
method boolean DetachChild(BTNode node | string nodeName)
method void DetachChildAt(int32 index)Extra methods on RandomSelectorNode
method void AttachChild(BTNode node, number probability) -- 0~1
method boolean SetChildNodeProbability(BTNode node, number probability)---
3. Action Node — user-defined via @BTNode
The source annotation and the Maker UI menu name differ. The only annotation written in the mlua source is@BTNode. Confusing it with the "Create BTNodeType" menu name in the Maker UI and writing@BTNodeTypepasses the build (info-level) but does not generate the `.codeblock` →CreateNode("ActionWait", ...)returnsnil→[LEA-2007] AttemptToIndex.
>
`extends BTNode` is required. Inheritance is what activates theBehave/ParentAI/Namemembers.
Lifecycle
| Method | When called |
|---|---|
OnInit() | Right before OnBehave(). Not called if the previous frame returned Running (state-preserving semantics) |
OnBehave(number delta) | Every frame the node executes. Must return a BehaviourTreeStatus |
Two ways to create
(a) `CreateNode` — based on a `@BTNode` script (reusable)
@BTNode
script ActionWait extends BTNode
property number Time = 2
property number ElapsedTime = 0
method void OnInit()
self.ElapsedTime = 0
end
method any OnBehave(number delta)
self.ElapsedTime += delta
if self.ElapsedTime < self.Time then
return BehaviourTreeStatus.Running
end
return BehaviourTreeStatus.Success
end
endlocal waitNode = self.Entity.AIComponent:CreateNode("ActionWait", "wait1")(b) `CreateLeafNode` — inline function (one-off)
local logNode = self.Entity.AIComponent:CreateLeafNode("printLog", function(delta)
log("Action!")
return BehaviourTreeStatus.Success
end)⚠ Engine enums cannot cross execution spaces via `any`. Boss/monster Action Nodes commonly broadcast a particle / effect from server logic via an@ExecSpace("Client")helper. Passing an engine enum (BasicParticleType.SparkRadialExplosion, etc.) through ananyparameter triggers[LEA-3036] InvalidCaston the first call, and declaring the parameter with the enum type itself is rejected by mlua diagnostics. Encode the enum as astringkey and branch on the receiver:
>
```lua
@ExecSpace("Client")
method void BroadcastParticle(string particleKey, Vector3 pos)
if particleKey == "spark_radial" then
_ParticleService:PlayBasicParticle(BasicParticleType.SparkRadialExplosion, self.Entity, pos, 0, Vector3(1,1,1), false, nil)
elseif particleKey == "charge" then
_ParticleService:PlayBasicParticle(BasicParticleType.Charge, self.Entity, pos, 0, Vector3(1,1,1), false, nil)
end
end
```
>
Same rule for@ExecSpace("Server" | "Multicast")parameters that carry enum values — declare them asstringand decode in the receiver.
---
4. Decorator Node — custom (not native)
Conditional child execution, result inversion, repetition, etc. Standard pattern:
@BTNode
script DecoInverter extends BTNode
property any Child = nil
method any OnBehave(number delta)
if self.Child == nil then return BehaviourTreeStatus.Failure end
local r = self.Child:Behave(delta)
if r == BehaviourTreeStatus.Success then return BehaviourTreeStatus.Failure end
if r == BehaviourTreeStatus.Failure then return BehaviourTreeStatus.Success end
return BehaviourTreeStatus.Running
end
end---
5. Memory (Blackboard) — custom (not native)
A shared state channel between Action Nodes. `BTNode.ParentAI` references the AIComponent that owns this tree → the access path to memory.
@Component
script AIPatrolComponent extends AIComponent
property table Memory = {}
@ExecSpace("ServerOnly")
method void SetMemory(string key, any value)
self.Memory[key] = value
end
@ExecSpace("ServerOnly")
method any GetMemory(string key)
return self.Memory[key]
end
end-- Inside an Action Node:
method any OnBehave(number delta)
local parentAI = self.ParentAI -- BTNode's ParentAI property
local target = parentAI:GetMemory("PlayerInRange")
if target == nil then return BehaviourTreeStatus.Failure end
-- target chase logic
return BehaviourTreeStatus.Running
end---
6. Tree assembly — standard pattern
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- 1. Create Composites
local root = SelectorNode("root")
local chaseSeq = SequenceNode("chase")
local idleSeq = SequenceNode("idle")
-- 2. Create Actions
local hasTarget = self:CreateNode("DecoHasTarget", "hasTarget")
local follow = self:CreateNode("ActionFollow", "follow")
local noTarget = self:CreateNode("DecoHasNoTarget", "noTarget")
local wander = self:CreateNode("ActionMoveRandom","wander")
local wait = self:CreateNode("ActionWait", "wait")
-- 3. Wire (left→right = high→low priority)
chaseSeq:AttachChild(hasTarget)
chaseSeq:AttachChild(follow)
idleSeq:AttachChild(noTarget)
idleSeq:AttachChild(wander)
idleSeq:AttachChild(wait)
root:AttachChild(chaseSeq)
root:AttachChild(idleSeq)
-- 4. Register root → runs automatically every frame
self:SetRootNode(root)
endEach frame traverses from the root left→right, top→bottom. Children that return Running are re-run by the parent next frame, starting from that child.
---
7. Running semantics — essentials
- Child returns Running → the parent re-runs from that child next frame (if the first child of a Sequence returns Running, the second is not called)
Parallelexception: every child runs every frame; children that already returned Success/Failure are skippedOnInit()is not called the next frame after Running → accumulated time/state is preserved
---
8. Native BT components — AIChaseComponent / AIWanderComponent
Usable immediately without writing your own BT. Both have CreateLeafNode/CreateNode/SetRootNode → native chase/wander can be mixed with custom BT nodes.
| Component | Behavior | Extra API |
|---|---|---|
AIChaseComponent | Auto-chases the nearest player within DetectionRange (default 5) | IsChaseNearPlayer, TargetEntityRef, GetCurrentTarget(), SetTarget(Entity) |
AIWanderComponent | Random direction wandering | — |
⚠ Velocity conflict:AIChaseComponent/AIWanderComponentcallMovementComponent.MoveToDirection→Body.SetVelocityevery frame. When used alongside a custom chase script, they overwrite the velocity. Remove both from the `.model` when using custom AI. The two cannot coexist.
---
9. Threat / Aggro Table — custom (Memory pattern extension)
@ExecSpace("ServerOnly")
method void AddThreat(Entity attacker, number amount)
local t = self:GetMemory("ThreatTable") or {}
t[attacker] = (t[attacker] or 0) + amount
self:SetMemory("ThreatTable", t)
end
method Entity GetTopThreat()
local t = self:GetMemory("ThreatTable") or {}
local best, max = nil, 0
for ent, v in pairs(t) do
if v > max then best, max = ent, v end
end
return best
endIn HandleHitEvent, call AddThreat(event.AttackerEntity, event.TotalDamage) → the BT chase node uses GetTopThreat() to pick priorities.
---
10. Checklist
- [ ] Custom Action/Decorator scripts have both `@BTNode` and `extends BTNode` (
@BTNodeTypebuilds but does not generate the.codeblock) - [ ] After
refresh, a.codeblockwith the same name exists next to the custom BTNode.mlua— if missing, the annotation or extends is missing - [ ] If
CreateNode("XXX", ...)returnsnil,[LEA-2007] AttemptToIndexfollows → check annotation and extends - [ ]
AIComponent.IsLegacy = false(legacy deprecated) - [ ] Keep
UpdateAuthority = Server(same space as a custom chase script) - [ ] Every Action Node
OnBehavereturns aBehaviourTreeStatus - [ ] Be aware of Running semantics — when a Sequence's first child Runs, the second is not called
- [ ] When using custom BT, remove
AIChaseComponent/AIWanderComponentfrom the.model - [ ] Access the Memory table only through
@ExecSpace("ServerOnly")methods (to prevent RPC leakage) - [ ]
DisconnectEventexternal event handlers inOnEndPlay
HP gauge — PixelRendererComponent + Lazy Init
AllPixelRendererComponentmethods are@ExecSpace("ClientOnly")— the buffer init and per-pixel writes must run on the client.
---
Why PixelRendererComponent
Alternative ways to attach a health bar to a dynamically spawned entity, and their failure cases:
| Approach | Issue |
|---|---|
Add a child entity to the .model's Children JSON | File-format errors possible; load fails when a PixelRenderer is included |
Spawn a separate child via SpawnByModelId | Only the first spawn succeeds; subsequent failure cases exist |
SpriteRendererComponent + a solid-color sprite | A solid-color rectangle RUID is not provided by default — requires a separate upload |
| Include `PixelRendererComponent` directly in the model's Components | Most stable — as a native component, it can be included in .model |
PixelRendererComponent is a native component — it can be included in .model directly, without the missing-.codeblock silent-drop risk that custom scripts have.
---
Flow
Server: HP/MaxHP change (@Sync)
└→ Client: OnSyncProperty("HP"/"MaxHP") detected
└→ UpdateHealthBar() — paint via SetPixel
Client OnUpdate: check PixelRenderer readiness → InitHealthBar() (lazy init)---
Step 1: Add PixelRendererComponent to the model
Add the following component to the .model:
| Component | Key value |
|---|---|
MOD.Core.PixelRendererComponent | If SortingLayer is the default ("Default"), map layers are not reflected → adjust to "MapLayer0" in the Maker editor or set at runtime in script (below) |
Setting SortingLayer at runtime:
-- Inside InitHealthBar
pixel.SortingLayer = "MapLayer0"
pixel.OrderInLayer = 10 -- Higher than the monster SpriteRenderer (OrderInLayer=2)---
Step 2: Script — @Sync HP/MaxHP + full HP gauge template
@Component
script MonsterAI extends Component
@Sync
property number HP = 100
@Sync
property number MaxHP = 100
-- Client: initialize the pixel buffer (Lazy Init)
-- Why Lazy Init: for dynamically spawned entities the PixelRendererComponent
-- may not be ready on the client at OnBeginPlay time
@ExecSpace("ClientOnly")
method void InitHealthBar()
local pixel = self.Entity.PixelRendererComponent
if pixel == nil then return end
-- 16×3: 16 px wide (HP steps), 3 px tall (thickness)
-- SetPixel coordinate system: bottom-left (1,1) origin
pixel:ResetWithColor(16, 3, Color(0, 1, 0, 1))
pixel.SortingLayer = "MapLayer0"
pixel.OrderInLayer = 10
self._T.hpBarPixel = pixel
self._T.hpBarInited = true
self:UpdateHealthBar()
end
-- Client: paint pixels based on HP ratio
@ExecSpace("ClientOnly")
method void UpdateHealthBar()
if not self._T.hpBarInited then return end
local pixel = self._T.hpBarPixel
if pixel == nil then return end
local ratio = 0
if self.MaxHP > 0 then
ratio = math.max(0, self.HP / self.MaxHP)
end
local fillWidth = math.max(0, math.floor(ratio * 16))
-- Color thresholds: >60% → green, 31~60% → yellow, 0~30% → red
local barColor
if ratio <= 0.3 then
barColor = Color(1, 0, 0, 1)
elseif ratio <= 0.6 then
barColor = Color(1, 1, 0, 1)
else
barColor = Color(0, 1, 0, 1)
end
for x = 1, 16 do
for y = 1, 3 do
if x <= fillWidth then
pixel:SetPixel(x, y, barColor)
else
pixel:SetPixel(x, y, Color(0.2, 0.2, 0.2, 0.8))
end
end
end
end
-- @Sync property change detection → refresh the bar
@ExecSpace("ClientOnly")
method void OnSyncProperty(string name, any value)
if name == "HP" or name == "MaxHP" then
self:UpdateHealthBar()
end
end
-- OnUpdate: no @ExecSpace → runs on both server and client
-- Branch via IsClient()/IsServer() (health bar on client, combat logic on server)
method void OnUpdate(number delta)
if self:IsClient() then
if self._T.hpBarInited == nil then self._T.hpBarInited = false end
if not self._T.hpBarInited then
self:InitHealthBar()
end
end
if not self:IsServer() then return end
-- Server-only logic (AI, death handling, etc.)
end
@EventSender("Self")
@ExecSpace("ServerOnly")
handler HandleHitEvent(HitEvent event)
self.HP -= event.TotalDamage
if self.HP <= 0 then
self.HP = 0
self.Entity.StateComponent:ChangeState("DEAD")
-- Death handling is in a separate DeadEvent handler
end
end
method void OnEndPlay()
self.Entity:DisconnectEvent(HitEvent, self.HandleHitEvent)
end
end---
PixelRendererComponent API summary
-- Buffer init
@ExecSpace("ClientOnly") ResetWithColor(int32 width, int32 height, Color color)
@ExecSpace("ClientOnly") ResetWithColors(int32 width, int32 height, table<Color> pixels)
-- Pixel read/write coordinates: bottom-left (1,1)
@ExecSpace("ClientOnly") SetPixel(int32 x, int32 y, Color color)
@ExecSpace("ClientOnly") GetPixel(int32 x, int32 y) → Color
@ExecSpace("ClientOnly") SetPixels(table<Color> pixels) -- Width*Height in size
@ExecSpace("ClientOnly") GetPixels() → table<Color>
-- Fill all
@ExecSpace("ClientOnly") FillColor(Color color)
@ExecSpace("ClientOnly") SetAlpha(float alpha)
-- Properties
property int32 OrderInLayer = 0
property string SortingLayer = "Default" -- For map entities, change to "MapLayer0"
property int32 Width = 16 (read-only: set via ResetWithColor)
property int32 Height = 16Recommended size: 16×N (N=2~4). Performance degrades above 16×16.
Width / Height are logical pixels of the texture grid, not screen pixels — the on-screen size scales with the entity's TransformComponent.Scale. Keep the logical grid small (16×3) and grow it visually through scale (e.g. Scale = (4, 4, 1)) instead of enlarging the grid.
---
Setting MaxHP on spawn
Include MonsterAI in the .model in advance (after Maker Refresh) → after spawn, only initialize HP/MaxHP via the handle:
@ExecSpace("ServerOnly")
method void SpawnMonster(Vector3 pos)
local parent = self.Entity.CurrentMap
local entity = _SpawnService:SpawnByModelId(
"monster_1_model", "monster_1", pos, parent -- 1st arg: the .model's EntryKey, case-insensitive
)
local ai = entity.MonsterAI
if ai ~= nil then
ai.HP = 100
ai.MaxHP = 100
end
end---
Constraints
| Item | Rule |
|---|---|
| PixelRenderer methods | ClientOnly — error if called from the server |
| Lazy Init | Use repeated check in OnUpdate instead of OnBeginPlay — guarantees the client readiness timing of dynamically spawned entities |
@Sync required | If HP/MaxHP are not set, the client health bar will not update |
OnUpdate @ExecSpace | Unspecified — handle via IsClient()/IsServer() branches internally |
| Render position | Drawn overlapping the entity's TransformComponent position. Cannot offset to display above the monster separately (child entities are unstable) |
| Recommended resolution | 16×16 or less. The health bar is 16×3 |
Projectile system — ProjectileComponent + SpawnByModelId
Attack Resolution basics and per-Body knockback live in `msw-combat-system/SKILL.md` §1·§3.
---
Core architecture
Why a separate entity
A projectile has an independent lifecycle (spawn → move → hit → destroy), so separating it from the firing unit into its own entity is the standard MSW pattern.
Projectile model composition (no Body)
| Component | Role |
|---|---|
TransformComponent | Position / scale |
SpriteRendererComponent | Projectile image render |
ProjectileComponent (custom) | Movement / hit handling. After writing the .mlua and running Maker Refresh once, the generated .codeblock lets you include it as a component directly in .model |
Hit detection comparison
| Approach | Pros | Cons | Fit |
|---|---|---|---|
| Distance-based (OnUpdate) | Simple, target specification is precise | Pierce/area needs extra logic | Homing projectiles, tower defense |
| TriggerComponent | Reacts to unknown targets, pierce is natural | Requires Collider + collision group setup | Bullet hell, straight fire with no target |
When the target is already known → distance-based is the simplest.
Server / client responsibility split
| Task | Execution location |
|---|---|
| Spawn projectile | @ExecSpace("ServerOnly") |
| Movement / hit / damage | @ExecSpace("ServerOnly") |
| Hit effect playback | @ExecSpace("Client") — when called from server, dispatched to clients |
@ExecSpace("ClientOnly"): ignored when called from the server. Always use "Client".
---
Step 1: Create the ProjectileComponent script
Create the .mlua under RootDesk/MyDesk/. (The .codeblock is auto-generated after Maker Refresh.)
@Component
script ProjectileComponent extends Component
property number Speed = 10 -- movement speed
property number Damage = 10 -- damage on hit
property number MaxLifetime = 3 -- auto-destroy time (seconds)
property string HitEffectRUID = "" -- hit effect RUID
property number HitRadius = 0.5 -- hit distance threshold
property string EnemyModelId = "" -- enemy scan model id (straight projectile)
method void OnBeginPlay()
self._T.fired = false
self._T.lifetime = 0
self._T.dirX = 0
self._T.dirY = 0
end
-- Fire: compute target direction, set fired=true
@ExecSpace("ServerOnly")
method void Fire(Entity target)
self._T.target = target
if target ~= nil and isvalid(target) then
local myPos = self.Entity.TransformComponent.Position
local tPos = target.TransformComponent.Position
local dx = tPos.x - myPos.x
local dy = tPos.y - myPos.y
local dist = math.sqrt(dx*dx + dy*dy)
if dist > 0 then
self._T.dirX = dx / dist
self._T.dirY = dy / dist
else
self._T.dirX = 1
self._T.dirY = 0
end
end
self._T.fired = true
end
-- Movement + homing + hit detection
@ExecSpace("ServerOnly")
method void OnUpdate(number delta)
if not self._T.fired then return end
self._T.lifetime = self._T.lifetime + delta
if self._T.lifetime >= self.MaxLifetime then
_EntityService:Destroy(self.Entity)
return
end
if self._T.target ~= nil and isvalid(self._T.target) then
local myPos = self.Entity.TransformComponent.Position
local tPos = self._T.target.TransformComponent.Position
local dx = tPos.x - myPos.x
local dy = tPos.y - myPos.y
local dist = math.sqrt(dx*dx + dy*dy)
-- Hit
if dist <= self.HitRadius then
self:OnHit()
return
end
-- Homing: recompute direction each frame (remove this block for straight projectiles)
if dist > 0 then
self._T.dirX = dx / dist
self._T.dirY = dy / dist
end
end
-- Move — for a Body-less entity, use Translate (§1-6)
self.Entity.TransformComponent:Translate(
self._T.dirX * self.Speed * delta,
self._T.dirY * self.Speed * delta
)
end
-- Hit handling (server)
-- ⚠ Do not subtract HP directly — bypassing HitEvent skips damage skin / hit effect / IsHitTarget immunity entirely.
-- Details: msw-combat-system/SKILL.md §2-3
-- Recommended: add an AttackComponent-derived script to the projectile model → fire an ad-hoc hitbox via AttackFrom
@ExecSpace("ServerOnly")
method void OnHit()
local pos = self.Entity.TransformComponent.Position -- Vector3
local ac = self.Entity.AttackComponent
if ac ~= nil then
ac:AttackFrom(
Vector2(self.HitRadius * 2, self.HitRadius * 2), -- hitbox size (Vector2)
pos:ToVector2(), -- hit position (Vector2 — AttackFrom takes Vector2, not Vector3)
"projectile", -- attackInfo
CollisionGroups.Monster
)
end
-- Preserve coordinates before destruction and call the effect (so it persists after entity destruction)
self:ShowHitEffect(pos.x, pos.y, pos.z)
_EntityService:Destroy(self.Entity)
end
-- Hit effect (client)
-- Use PlayEffect (fixed coordinates): the effect keeps playing even after the entity is destroyed
-- PlayEffectAttached (attached to the entity) disappears when the entity is destroyed
@ExecSpace("Client")
method void ShowHitEffect(number px, number py, number pz)
if self.HitEffectRUID == "" then return end
local mapEntity = self.Entity.CurrentMap
if mapEntity == nil then return end
_EffectService:PlayEffect(
self.HitEffectRUID, mapEntity,
Vector3(px, py, pz), 0, Vector3(1, 1, 1), false
)
end
method void OnEndPlay()
-- _T is cleaned up by the engine, no manual release needed
end
end---
Step 2: Create the projectile model
After writing the .mlua, run Maker Refresh once → ProjectileComponent.codeblock is auto-generated. Then include the following in the .model:
| Component | Key values |
|---|---|
MOD.Core.TransformComponent | default (reduce Scale if needed) |
MOD.Core.SpriteRendererComponent | SpriteRUID = <projectile sprite RUID>, OrderInLayer = 5(above units), SortingLayer = "MapLayer0" |
ProjectileAttackComponent (custom, AttackComponent-derived) | Calls AttackFrom from OnHit → normal HitEvent pipeline. Override CalcDamage/IsAttackTarget for damage/target policy. ⚠ Do not add `@ExecSpace` — the parent has an unspecified ExecSpace (=All), so adding @ExecSpace("ServerOnly") etc. in the child triggers LEA-3014. See `msw-scripting/SKILL.md` §9 |
ProjectileComponent | Set property defaults (Speed/Damage etc.) in .model so spawns don't need to reconfigure |
ProjectileAttackComponent(or an equivalent script) must be included for damage skin / hit effect / i-frame to work. Bypassing with simpleai.HP -= damageis wrong — see `msw-combat-system/SKILL.md` §2-3.
Projectile sprite search:
msw-search → resource search: "energy ball", "arrow", "fireball" / type: sprite / category: skill---
Step 3: Spawn + fire
Since ProjectileComponent is already included in the .model, use the component handle directly after SpawnByModelId.
-- SpawnService.d.mlua
_SpawnService:SpawnByModelId(string id, string name, Vector3 spawnPosition, Entity parent) → Entity@ExecSpace("ServerOnly")
method void FireProjectile(Entity target)
local parent = self.Entity.CurrentMap
if parent == nil then return end
-- Unique name (collision prevention)
if self._T.projCount == nil then self._T.projCount = 0 end
self._T.projCount = self._T.projCount + 1
local name = "proj_" .. tostring(self._T.projCount)
local myPos = self.Entity.TransformComponent.Position
local proj = _SpawnService:SpawnByModelId(
self._T.projectileModelId, -- the .model's EntryKey (case-insensitive), not a URL
name,
Vector3(myPos.x, myPos.y, myPos.z),
parent
)
if proj == nil then return end
-- ProjectileComponent is already in the .model → just receive the handle and configure
local pc = proj.ProjectileComponent
if pc == nil then return end
pc.Speed = 10
pc.Damage = self.Damage
pc.HitRadius = 0.5
pc.HitEffectRUID = "<hit effect RUID>"
pc:Fire(target)
endObtaining the parent entity
self.Entity.CurrentMap -- current map entity (recommended)
_EntityService:GetEntityByPath("/maps/MapName") -- explicit path---
Step 4: Wire into the firing unit (melee ↔ ranged branch)
@ExecSpace("ServerOnly")
method void TryAttack()
if self._T.target == nil or not isvalid(self._T.target) then return end
if self._T.atkTm < self.AttackCooldown then return end
self._T.atkTm = 0
if self.UseProjectile then
self:FireProjectile(self._T.target)
else
-- Direct melee hit
local ac = self.Entity.AttackComponent
if ac ~= nil then
ac:Attack(Vector2(1.5, 1.0), Vector2(0, 0), "melee", CollisionGroups.Monster)
end
end
end---
Variants
Straight projectile (no homing)
Remove the direction-recompute block in OnUpdate. Instead scan for enemies ahead via EnemyModelId:
-- Replace the homing block in OnUpdate with:
local enemies = _EntityService:GetEntitiesSpawnedByModelId(self.EnemyModelId)
if enemies ~= nil then
for _, enemy in pairs(enemies) do
if isvalid(enemy) then
local ep = enemy.TransformComponent.Position
local pos = self.Entity.TransformComponent.Position
local dx = pos.x - ep.x
local dy = pos.y - ep.y
if math.sqrt(dx*dx + dy*dy) <= self.HitRadius then
self._T.target = enemy
self:OnHit()
return
end
end
end
end
self.Entity.TransformComponent:Translate(
self._T.dirX * self.Speed * delta,
self._T.dirY * self.Speed * delta
)Pierce projectile
Remove Destroy in OnHit, prevent duplicates with hitList. Damage goes through AttackFrom + check the hitList in an attacker-side IsAttackTarget override:
@ExecSpace("ServerOnly")
method void OnHit()
if self._T.hitList == nil then self._T.hitList = {} end
local pos = self.Entity.TransformComponent.Position -- Vector3
local ac = self.Entity.AttackComponent
if ac ~= nil then
ac:AttackFrom(
Vector2(self.HitRadius * 2, self.HitRadius * 2),
pos:ToVector2(), "projectile.pierce", CollisionGroups.Monster -- AttackFrom takes Vector2
)
end
self:ShowHitEffect(pos.x, pos.y, pos.z)
-- Do not call Destroy → pierce
endBlock duplicate hits in ProjectileAttackComponent:
-- ⚠ AttackComponent.IsAttackTarget has an unspecified ExecSpace (=All) on the parent.
-- Adding @ExecSpace to the override triggers LEA-3014 SignatureMismatch.
-- Details: msw-scripting/SKILL.md §9 "Method override"
method boolean IsAttackTarget(Entity defender, string attackInfo)
local proj = self.Entity.ProjectileComponent
if proj == nil then return __base:IsAttackTarget(defender, attackInfo) end
if proj._T.hitList == nil then proj._T.hitList = {} end
local id = defender.Name
if proj._T.hitList[id] then return false end
proj._T.hitList[id] = true
return __base:IsAttackTarget(defender, attackInfo)
endArea explosion (AOE)
@ExecSpace("ServerOnly")
method void OnHit()
local pos = self.Entity.TransformComponent.Position -- Vector3
local blastRadius = 2.0
local ac = self.Entity.AttackComponent
if ac ~= nil then
-- Circular AoE in one call — Shape-based Attack (CircleShape takes Vector2)
ac:Attack(CircleShape(pos:ToVector2(), blastRadius), "projectile.aoe", CollisionGroups.Monster)
end
self:ShowHitEffect(pos.x, pos.y, pos.z)
_EntityService:Destroy(self.Entity)
end---
Constraints
| Item | Rule |
|---|---|
| Spawn | SpawnByModelId can only be called on the server |
| Model id format | The .model file's EntryKey (case-insensitive bare string, e.g. "Fireball"). The engine lowercases it and prepends model:// internally; do not pass the URL form ("model://..." or "model:///UUID") yourself. |
| Name uniqueness | The name parameter must be unique within the map → use a counter (_T.projCount) |
| MaxLifetime | Always set it — so the projectile does not linger forever when the target is destroyed |
| Effect | Use PlayEffect (fixed coordinates) — PlayEffectAttached disappears when the entity is destroyed |
| Movement | Body-less projectile → use TransformComponent:Translate (per §1-6) |
| Custom script | Write the .mlua → Maker Refresh once → include in .model as a component. Placing it in .model while the .codeblock is missing causes silent exclusion during deserialization. |
Related skills
How it compares
Pick msw-combat-system for MSW-native combat APIs rather than porting generic game engine combat patterns.
FAQ
Which hit shapes does HitComponent support natively?
Box, Circle, and Polygon only; other shapes must be approximated by composition.
How should projectiles move continuously?
Use TransformComponent Translate with speed times delta each frame in OnUpdate, not timer-based SetPosition jumps.
What game-feel effects are native in MSW combat?
All six: Hit Stop, Camera Shake, Zoom, Sprite Flash, VFX, and SFX.
Is Msw Combat System safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.