Skip to content

CustomMonsters Scripts

wezzzyrek1 edited this page Sep 18, 2026 · 1 revision

Monster scripts

Flags and a body colour get you a uniformly tinted model. A script is what gives a monster lit skin, a glowing crystal, beams that fade in as it attacks, and effects of its own.

Data\Custom\Scripts\Game\Monsters\<monsterid>.gsc      production
Data\Custom\Scripts\Game\Monsters\<monsterid>.lua      dev mode only

The example throughout is Monsters\2498.lua, the Hydra.

How a script starts

The chunk runs once when the script is loaded, and hands over closures:

local Enums   = require("Enums")
local Helpers = require("Helpers")
local R       = require("Registry")

Monster.OnCreate(function(o, m) ... end)
Monster.OnRender(function(o, m) ... end)

Every handler receives two arguments:

  • o - the object: position, angle, meshes, alpha. Shared with map scripts, and documented in Objects and the sandbox.
  • m - the monster record: index, attack state, body light, render flags.

Order of execution

Handler When
Monster.OnCreate once, when the monster is set up
Monster.OnRender every frame, before the body is drawn
Monster.OnRenderPost every frame, right after the body is drawn
Monster.OnAttack every frame while an attack is running

OnCreate runs straight after the config is applied, so a script can override it. Two things are not available that early, because the model is not resolved yet: m.bodyLight* and m.renderFlags / m.bright. Set those in OnRender.

Monster.OnCreate

For anything that should hold for the whole life of the instance - meshes, scale, bounding box - instead of rewriting it every frame.

Monster.OnCreate(function(o, m)
    o.boundingBoxMaxX = 100.0
    o.boundingBoxMaxY = 100.0
    o.boundingBoxMaxZ = 150.0

    -- Both of these, or the head beams are permanently on.
    o.blendMesh = 5
    o.blendMeshLight = 0.0

    o.renderShadow = false
    o.enableShadow = false
    m.hideShadow = true
end)

The bounding box decides how far away the monster can be clicked and when it is culled. A custom model without one is awkward to select, which is why it is the first thing Hydra sets.

The three shadow switches matter for any model with a blend mesh: the shadow pass draws that mesh too, so glowing beams end up as black bars on the floor.

Monster.OnRender

Every drawn frame, before the body. This is where per-frame body state goes.

Monster.OnRender(function(o, m)
    -- Ramp the blend mesh brightness with the attack: fades the head beams in when
    -- the attack starts and back out when it ends.
    local action = o.currentAction
    local attacking = action >= Enums.Action.Attack1 and action <= Enums.Action.Attack2
    local light = o.blendMeshLight + (attacking and 0.1 or -0.1)

    if light > 1.0 then light = 1.0 end
    if light < 0.0 then light = 0.0 end

    o.blendMeshLight = light

    -- Two lights on one bone: transform once, spawn twice.
    local x, y, z = o:TransformPosition(63, 0, 0, 20.0)

    -- Our own copies of the client's pictures, from Registry.lua.
    local lightning, shiny = Helpers.Texture(R, "lightning2"), Helpers.Texture(R, "shiny03")

    if x and lightning and shiny then
        Helpers.RenderLightAt(o, lightning, 1.0, x, y, z)
        Helpers.RenderLightAt(o, shiny, 4.0, x, y, z)
    end
end)

o:TransformPosition(bone, dx, dy, dz) turns a model-local offset into a world position through one of the model's bones - how you pin a flame to a torch or a glow to an eye. An out-of-range bone returns nothing, so check before using the result.

Monster.OnRenderPost - the second pass

This is the handler that makes a ported monster look right. The extra pass draws some of the model's meshes again, on top of the finished body, with different flags and often a different texture. A lit skin or a glowing crystal comes from here, and a monster built with flags alone looks flat because it is missing it.

Issued from OnRender the extra pass would land underneath the body, so it has to be this handler.

Monster.OnRenderPost(function(o, m)
    local light = math.sin(Map.WorldTime() * 0.002) * 0.3 + 0.5
    local scroll = math.floor(Map.WorldTime()) % 100 * 0.01

    m:BeginRender(o.alpha)
    m:RenderMesh(0, Enums.RenderFlags.TEXTURE, o.alpha, 0, light,
                 o.blendMeshTexCoordU, o.blendMeshTexCoordV, m:TextureIndex(6))
    m:RenderMesh(3, Enums.RenderFlags.CHROME + Enums.RenderFlags.BRIGHT, o.alpha, 3, light,
                 o.blendMeshTexCoordU, -scroll)
    m:EndRender()
end)
Call Purpose
m:BeginRender(alpha) opens the pass
m:RenderMesh(mesh, flags, alpha, blendMesh, blendMeshLight, u, v, texture) draws one mesh again
m:EndRender() closes it
m:TextureIndex(slot) the model's own texture id for that slot

Everything past mesh and flags is optional: alpha 1, blendMesh -1, blendMeshLight 1, u/v 0, texture -1 (keep the mesh's own). An out-of-range mesh or texture slot is dropped rather than passed on.

m.renderFlags and m.bright do not exist here - the body is already drawn.

Monster.OnAttack

Runs every frame while the attack lasts, not once. That is deliberate: it lets an effect animate across the attack, but it means a one-shot effect has to be gated.

m.attackTime counts up from 1, so == 1 is the first frame of the attack. An attack can end long before it counts far - every one of the Pharaoh's, chasing its target, ended after its first frame - so for a one-shot effect == 1 is the only frame that always comes.

Monster.OnAttack(function(o, m)
    -- Every fifth tick: a laser from the head bone.
    if m.attackTime % 5 == 1 then
        local x, y, z = o:TransformPosition(63, 0, 0, 0)

        if x then
            Laser(x, y, z, o.angleX, o.angleY, o.angleZ, LASER_ORANGE)
        end
    end

    -- Once, on the first tick of a boss skill: a nine-way fan.
    if m.skill == Enums.Skill.Boss and m.attackTime == 1 then
        local px, py, pz = o:GetPosition()
        local az = o.angleZ + 20.0

        for i = 1, 9 do
            az = az + 40.0
            Laser(px, py, pz + 50.0, o.angleX, o.angleY, az, LASER_BLUE)
        end
    end
end)

Laser is the Hydra script's own: it only notes the laser, and OnRender draws every live one for 20 game steps as a row of Effect\Spark03.jpg sprites. The client's Hydra laser is nothing more than that picture, so the script draws it from its own copy - see 2498.lua.

m.skill is the skill id being used, so effects can branch on it.

Borrowing another monster's effect

Returning a monster index from OnAttack runs that monster's built-in attack effect instead, skill checks and all:

Monster.OnAttack(function(o, m)
    if m.skill == 42 then
        return 89     -- use what the game does for monster 89
    end
end)

Returning nothing leaves your own id in place, which means no built-in effect at all

  • exactly what you want when the script draws its own.

Skills

A player skill on a monster - how it gets one, which half the client draws and which SkillEffects.lua adds, m:SpawnSkillEffect, m:TargetPosition and m:ShootArrow - has a page of its own: Monster skills.

The monster record

Everything on m:

Property Access Meaning Example
index read the monster's id, as in CustomMonster.xml and MonsterList.xml 2498
attackTime read 0 outside an attack; during one it counts up from 1, to 15 at most if m.attackTime == 1 then
skill read the skill number of the current attack - MonsterList AttackType 100 + skill; 0 on a plain attack 2023 - Raining Arrow
boneCount read bones in the model; bones are 0..boneCount - 1 if bone < m.boneCount then
meshCount read meshes in the model; meshes are 0..meshCount - 1 for mesh = 0, m.meshCount - 1 do
actionCount read animations in the model; actions are 0..actionCount - 1 if m.actionCount > Enums.Action.Attack3 then
nameHeight read/write how high the name plate and the HP bar sit over the model, in world units - the Ypose of its npcName.txt row m.nameHeight = 400
nameMark read/write the MarkValue of its npcName.txt row, 0 or 1 - only the Chaos Goblins use 1 m.nameMark = 0
hideShadow read/write true draws no shadow under the monster m.hideShadow = true
bodyLightR/G/B read/write body colour, 0..1 each - shared by every instance of the model, see below m.bodyLightR = 1.0
renderFlags read/write the body pass mask from Enums.RenderFlags, OnRender only m.renderFlags = Enums.RenderFlags.METAL + Enums.RenderFlags.BRIGHT
bright read/write brightness of the body pass - what <Glow Power> sets; OnRender only m.bright = 1.0
Method What it does Defaults
m:SetBodyLight(r, g, b) sets bodyLightR/G/B at once a missing value is 0
m:BeginRender(alpha), m:RenderMesh(...), m:EndRender() the second pass - see Monster.OnRenderPost above alpha 1
m:TextureIndex(slot) the model's own texture id of mesh slot, 0..meshCount - 1; nothing outside that -
m:SpawnSkillEffect(type, subType, scale, x, y, z) an effect owned by the monster, the way a skill's is - see Monster skills subType 0, scale 1, position the monster's
m:TargetPosition() x, y, z of the monster's current target, nothing while it has none -
m:ShootArrow(model, skill, x, y, z, r, g, b, angleX, angleY, angleZ) one arrow shot from a spot; model is what falls, skill decides what the client draws where it lands, and the arrow flies at whoever the server said this cast hit - see Monster skills r, g, b the monster's own light; angles 90, a scatter of -10..10, 180 - the drop. angleX 270 sends it climbing instead, never landing
m:SetWeapon(slot, cat, index) puts an item in this monster's hand, as <Weapon> does for every monster of the id: slot 0 right hand, 1 left; cat and index as in ItemList.xml. Without cat the hand is emptied -

bodyLight* belongs to the model, so every instance of that model shares it - the original value is restored after the render, so setting it per frame is correct and setting it once is not.

renderFlags and bright exist only during OnRender. The flags and how the mask is read are on Render flags.

Monster.WorldTime() is the same clock as Map.WorldTime().

Logging

Log.Add(text) and Log.AddC(color, text) print to the Lua console, dev mode only, and are safe to leave in.

See Also

Clone this wiki locally