How to prompt AI to write 3ds Max scripts that actually work

AI assistants can write MaxScript quickly, but a script that compiles is not the same as a script that changes your scene. In our own work with 3ds Max and Corona, nearly every failed script came down to one of three things: the wrong material class, the wrong property name, or a loop that never reached the material. This guide covers how we now prompt for scripts so those three problems are dealt with before the first line runs.

It is based on our notes from 3ds Max 2025 and 2026 with Corona 12, and later Corona 14. Where something was only confirmed on Corona 12, we say so.

Dump the material before you ask for a change

Never let the AI guess a property name from memory, and do not trust one from memory yourself. Run a read only dump on the material you want to change, then paste the result into the chat. The dump prints the material's class and every property it actually has.

m = selection[1].material
format "% [%]\n" m.name (classof m)
for p in (getPropNames m) do (
    try ( format "  % = %\n" p (getProperty m p) ) catch ( format "  %\n" p )
)
m = selection[1].material
format "% [%]\n" m.name (classof m)
for p in (getPropNames m) do (
    try ( format "  % = %\n" p (getProperty m p) ) catch ( format "  %\n" p )
)
m = selection[1].material
format "% [%]\n" m.name (classof m)
for p in (getPropNames m) do (
    try ( format "  % = %\n" p (getProperty m p) ) catch ( format "  %\n" p )
)

Use getPropNames rather than showProperties. On some Max 2025 builds showProperties prints to the editor window and the Listener just says OK, so you get nothing useful to paste back.

The class matters because the same slot has a different scripting name on each material class. Diffuse colour, for example, is baseColor on CoronaPhysicalMtl, diffuseColor on CoronaLegacyMtl and CoronaMtl, and base_color on Max's own PhysicalMaterial. Reflection is baseRoughness on Physical, where 0 is sharp and 1 is matte, but reflectionGlossiness on Legacy, where 1 is sharp and 0 is matte. A script written for one class will quietly do nothing on another. The Physical names in our reference were confirmed with getPropNames on Corona 12 with Max 2025, so recheck them on Corona 14 by running the dump above.

Give the AI the 12 rules

These are the rules every script we use now follows. Paste them into the chat, or keep them in a saved instruction file your assistant reads at the start, so you do not have to repeat them.

  1. Diagnose first, change second. Run a read only dump (classof plus getPropNames) before any bulk change. Never trust a property name from memory.

  2. Check the class. CoronaPhysicalMtl, CoronaLegacyMtl/CoronaMtl and Max's own PhysicalMaterial all use different property names.

  3. Loop objects, not just sceneMaterials. Materials assigned to objects but not sitting in the editor are invisible to sceneMaterials.

  4. Go deep. Use getNumSubMtls/getSubMtl and getNumSubTexmaps/getSubTexmap with a visited array. materialList alone misses CoronaSelect, Blend and scanned materials.

  5. Default to selection. Only touch objects when you explicitly mean the whole scene.

  6. Wrap every change in an undo block: undo "Name" on ( ... ) so one Ctrl+Z rolls it back.

  7. Count before you destroy. Deleting maps or renaming files? Run a list only pass, check the count, then run the real one.

  8. No local at top level. Only inside fn, rollout, struct or loops.

  9. Counters a function changes are global and never named fixed, which is reserved.

  10. if ... do has no else. Use if ... then (...) else (...) for any branch.

  11. Wrap risky property sets in try ( ) catch ( ) so one odd material does not kill the run.

  12. Deliver as a .ms file with a header saying what it does, what it targets and how to undo.

Rules 8 to 10 exist because each one caused a compile or runtime error once: "no local declarations at top level", "cannot assign to read only variable" from a counter named fixed, and "unexpected else" from if ... do ( ) else ( ).

What the rules look like in our own scripts

These snippets are lifted unchanged from scripts we use, so you can see the rules in working code.

Selection only, with the whole change inside one undo block (rules 5 and 6), from a script that adds Chaos Scatter edge trimming to materials:

local selectedObjects = selection as array

if selectedObjects.count > 0 then
(
    undo "Apply Edge Trimmers (Independent)" on
    (
local selectedObjects = selection as array

if selectedObjects.count > 0 then
(
    undo "Apply Edge Trimmers (Independent)" on
    (
local selectedObjects = selection as array

if selectedObjects.count > 0 then
(
    undo "Apply Edge Trimmers (Independent)" on
    (

Count before you destroy (rule 7), from a Revit clean up panel. It lists the small objects first, tells you how many it found and only deletes after you say yes:

if toDelete.count > 0 then ( if queryBox ("Delete " + (toDelete.count as string) + " objects?") then ( undo "Cull Tiny" on (delete toDelete); lbl_info.text = ("Status: Culled " + (toDelete.count as string) + " objects.") ) )
if toDelete.count > 0 then ( if queryBox ("Delete " + (toDelete.count as string) + " objects?") then ( undo "Cull Tiny" on (delete toDelete); lbl_info.text = ("Status: Culled " + (toDelete.count as string) + " objects.") ) )
if toDelete.count > 0 then ( if queryBox ("Delete " + (toDelete.count as string) + " objects?") then ( undo "Cull Tiny" on (delete toDelete); lbl_info.text = ("Status: Culled " + (toDelete.count as string) + " objects.") ) )

A visited array, so each material is handled once, and try ( ) catch ( ) around each property so one odd material does not stop the run (rules 4 and 11), from a script that switches off accidental self illumination:

fn killIllum m = (
    if m == undefined then return false
    if (findItem done m) != 0 then return false
    append done m
    if (classof m) == CoronaPhysicalMtl do (
        emits = false
        try ( if m.selfIllumLevel > 0.0 then emits = true ) catch()
        try ( if m.selfIllumTexmapOn and m.selfIllumTexmap != undefined then emits = true ) catch()
fn killIllum m = (
    if m == undefined then return false
    if (findItem done m) != 0 then return false
    append done m
    if (classof m) == CoronaPhysicalMtl do (
        emits = false
        try ( if m.selfIllumLevel > 0.0 then emits = true ) catch()
        try ( if m.selfIllumTexmapOn and m.selfIllumTexmap != undefined then emits = true ) catch()
fn killIllum m = (
    if m == undefined then return false
    if (findItem done m) != 0 then return false
    append done m
    if (classof m) == CoronaPhysicalMtl do (
        emits = false
        try ( if m.selfIllumLevel > 0.0 then emits = true ) catch()
        try ( if m.selfIllumTexmapOn and m.selfIllumTexmap != undefined then emits = true ) catch()

When the script says "Done" and nothing changes

This is the most common failure, and it is rarely a syntax problem. The script runs to the end, prints its message and leaves the scene untouched. In our notes it came from these causes:

  • Wrong class. The script targeted CoronaMtl when the materials were CoronaPhysicalMtl or Max PhysicalMaterial. Fix: print classof for each material first.

  • sceneMaterials found nothing. The materials were only assigned to objects, not loaded in the editor. Fix: loop selection or objects instead.

  • Nested materials were skipped. On one furniture colourway job, a collector found only 2 of 13 target materials because the rest sat inside Multi/Sub materials. Fix: a recursive walk through every sub material.

  • The counter stayed at 0. The counter was changed inside a function but not declared global at top level, so the report was wrong even when work was done.

  • Self illumination still glowing at 0. The level was zeroed but a map was still plugged into the slot. Fix: set the map to undefined and the colour to black as well.

  • Legacy names on a Physical material. selfIllumMultiplier is a Legacy property; on Physical the level is selfIllumLevel. Check with getPropNames.

  • Wrong CoronaSelect property. .selectedMap failed; the property is .selected. It is 0 based and matches the Subtex number shown in the UI, so set it directly with no +1.

Two of these are visible in one copy of our own colourway batch renderer. The CSV reader splits each row with filterString, which silently drops empty cells, so padding the row afterwards cannot put the columns back in the right place:

fn parseCSVLine line =
(
    local fields = filterString line ","
    while fields.count < 7 do append fields ""
    fields
)
fn parseCSVLine line =
(
    local fields = filterString line ","
    while fields.count < 7 do append fields ""
    fields
)
fn parseCSVLine line =
(
    local fields = filterString line ","
    while fields.count < 7 do append fields ""
    fields
)

And the colour switch uses .selectedMap, the property our notes record as failing, inside a try ( ) catch ( ). The catch keeps the batch running, but it also hides the error, so the paint does not change and nothing tells you:

(try(paintSelect.selectedMap = paintIdx)catch())
(try(paintSelect.selectedMap = paintIdx)catch())
(try(paintSelect.selectedMap = paintIdx)catch())

This is why rule 11 needs the companion habit of printing what failed, as the template below does. The fixes in our notes were a character by character CSV parser and .selected.

If you see "Done" and no change, do not ask the AI to try again blind. Paste the classof output and the Listener text back, and ask it to explain which of these it hit.

Paste the Listener output back, every time

One habit paid off again and again: pasting the MaxScript Listener output back into the chat after every diagnostic run. Each time we did, a guess turned into a fix. The AI cannot see your scene; the Listener output is the only evidence it has of what class a material is, what properties exist and how many objects a loop actually reached.

It also helps to build scenes a script can drive: one master material per object that changes, one named CoronaSelect per switch, and clean object names. A screenshot of the Slate node tree is the fastest way to map a deep material chain before asking for a script that has to reach into it.

Ask for a dry run flag

Any script that changes a lot of materials, or any batch render script, should have a dry run mode. With the flag on, the script lists what it would change and how many items it found. You check the list, then switch the flag off and run it for real. For batch rendering, this lets you check the plan before committing render hours. It is also how rule 7 works in practice: count first, then destroy.

This is how the dry run looks in our colourway batch renderer. The same loop runs either way; with the flag on it prints the file it would save instead of rendering:

local pfix = if dryRun then "[DRY] " else ""
local pfix = if dryRun then "[DRY] " else ""
local pfix = if dryRun then "[DRY] " else ""
if dryRun then
    format "  -> Would save: %\n" (filenameFromPath fName)
else
(
    format "  -> Rendering: %\n" (filenameFromPath fName)
    render camera:cam outputfile:fName vfb:true quiet:true
)
if dryRun then
    format "  -> Would save: %\n" (filenameFromPath fName)
else
(
    format "  -> Rendering: %\n" (filenameFromPath fName)
    render camera:cam outputfile:fName vfb:true quiet:true
)
if dryRun then
    format "  -> Would save: %\n" (filenameFromPath fName)
else
(
    format "  -> Rendering: %\n" (filenameFromPath fName)
    render camera:cam outputfile:fName vfb:true quiet:true
)

Start every script from a template

Rather than letting the AI invent structure each time, give it this template and ask it to fill in only the two apply functions. It already follows all 12 rules: a header, selection only, a deep walk with a visited array, a class check, try/catch around each change, an undo block and a dry run flag. Set DRY_RUN = true to list what would change, then false to apply.

/*
    VAARS  <Script name>
    What:    <one line on what it changes>
    Targets: CoronaPhysicalMtl + CoronaLegacyMtl/CoronaMtl, deep, SELECTION only
    Undo:    one Ctrl+Z
    Corona 14 / 3ds Max 2025-26
*/
clearListener()

global DRY_RUN = true
global seen = #()
global changedCount = 0

fn applyPhys m = (
    -- e.g. m.baseRoughness = 0.9
)
fn applyLegacy m = (
    -- e.g. m.reflectionGlossiness = 0.1
)

fn walkMtl node = (
    if node == undefined then return false
    if (findItem seen node) != 0 then return false
    append seen node
    c = classof node
    if c == CoronaPhysicalMtl then (
        format "  [PHYS]   %\n" node.name
        if not DRY_RUN do ( try ( applyPhys node ) catch ( format "    failed: %\n" (getCurrentException()) ) )
        changedCount += 1
    )
    else if c == CoronaLegacyMtl or c == CoronaMtl then (
        format "  [LEGACY] %\n" node.name
        if not DRY_RUN do ( try ( applyLegacy node ) catch ( format "    failed: %\n" (getCurrentException()) ) )
        changedCount += 1
    )
    try ( for i = 1 to (getNumSubMtls node) do walkMtl (getSubMtl node i) ) catch()
    false
)

if selection.count == 0 then (
    format "Nothing selected.\n"
) else (
    undo "VAARS script" on (
        for o in selection do ( if o.material != undefined do walkMtl o.material )
    )
    format "\n% materials % . Ctrl+Z to undo.\n" changedCount (if DRY_RUN then "found (dry run)" else "changed")
)
/*
    VAARS  <Script name>
    What:    <one line on what it changes>
    Targets: CoronaPhysicalMtl + CoronaLegacyMtl/CoronaMtl, deep, SELECTION only
    Undo:    one Ctrl+Z
    Corona 14 / 3ds Max 2025-26
*/
clearListener()

global DRY_RUN = true
global seen = #()
global changedCount = 0

fn applyPhys m = (
    -- e.g. m.baseRoughness = 0.9
)
fn applyLegacy m = (
    -- e.g. m.reflectionGlossiness = 0.1
)

fn walkMtl node = (
    if node == undefined then return false
    if (findItem seen node) != 0 then return false
    append seen node
    c = classof node
    if c == CoronaPhysicalMtl then (
        format "  [PHYS]   %\n" node.name
        if not DRY_RUN do ( try ( applyPhys node ) catch ( format "    failed: %\n" (getCurrentException()) ) )
        changedCount += 1
    )
    else if c == CoronaLegacyMtl or c == CoronaMtl then (
        format "  [LEGACY] %\n" node.name
        if not DRY_RUN do ( try ( applyLegacy node ) catch ( format "    failed: %\n" (getCurrentException()) ) )
        changedCount += 1
    )
    try ( for i = 1 to (getNumSubMtls node) do walkMtl (getSubMtl node i) ) catch()
    false
)

if selection.count == 0 then (
    format "Nothing selected.\n"
) else (
    undo "VAARS script" on (
        for o in selection do ( if o.material != undefined do walkMtl o.material )
    )
    format "\n% materials % . Ctrl+Z to undo.\n" changedCount (if DRY_RUN then "found (dry run)" else "changed")
)
/*
    VAARS  <Script name>
    What:    <one line on what it changes>
    Targets: CoronaPhysicalMtl + CoronaLegacyMtl/CoronaMtl, deep, SELECTION only
    Undo:    one Ctrl+Z
    Corona 14 / 3ds Max 2025-26
*/
clearListener()

global DRY_RUN = true
global seen = #()
global changedCount = 0

fn applyPhys m = (
    -- e.g. m.baseRoughness = 0.9
)
fn applyLegacy m = (
    -- e.g. m.reflectionGlossiness = 0.1
)

fn walkMtl node = (
    if node == undefined then return false
    if (findItem seen node) != 0 then return false
    append seen node
    c = classof node
    if c == CoronaPhysicalMtl then (
        format "  [PHYS]   %\n" node.name
        if not DRY_RUN do ( try ( applyPhys node ) catch ( format "    failed: %\n" (getCurrentException()) ) )
        changedCount += 1
    )
    else if c == CoronaLegacyMtl or c == CoronaMtl then (
        format "  [LEGACY] %\n" node.name
        if not DRY_RUN do ( try ( applyLegacy node ) catch ( format "    failed: %\n" (getCurrentException()) ) )
        changedCount += 1
    )
    try ( for i = 1 to (getNumSubMtls node) do walkMtl (getSubMtl node i) ) catch()
    false
)

if selection.count == 0 then (
    format "Nothing selected.\n"
) else (
    undo "VAARS script" on (
        for o in selection do ( if o.material != undefined do walkMtl o.material )
    )
    format "\n% materials % . Ctrl+Z to undo.\n" changedCount (if DRY_RUN then "found (dry run)" else "changed")
)

The header says Corona 14, but the property names behind the example lines were confirmed on Corona 12. Run the dump on Corona 14 before relying on them.

The prompt we reuse

With the rules and template saved, the prompt itself can be short. This is the one we reuse when asking for a new script:

Use [your saved scripting rules]. Corona 14, Max 2025. Selection only, deep walk, undo block, dry run flag. Give me a read only dump first. Here is the classof output: ...

Each part closes off a known failure. Naming the versions stops the AI mixing up builds. "Selection only" and "undo block" limit the damage if something goes wrong. "Deep walk" makes sure nested materials are reached. "Read only dump first" means the next script is written against real property names, and the pasted classof output tells it which class to target.

Ask for an update to your rules after a long debug

When a long debugging session finally works, ask the AI to update your saved rules or instruction file with what you learned. Otherwise the lesson stays in that one chat and you meet the same problem again in the next one.

One caution

It is easy to end up with a lot of tooling. Our own note to ourselves is to build new scripts only when a live job needs them, and to keep the ones that work in a single library folder with a header on each.

Need visuals built on a careful pipeline?

We use these habits on our own architectural and interior visualisation work. See our recent work, or get in touch to talk through your next project.

Let’s talk about your project.

| Architecture & Visualisation

© 2026

Set in Satoshi & IBM Plex Mono

VAARS STUDIO LLC-FZ | Visual craftsmanship | London & UAE

| Architecture & Visualisation

© 2026

Set in Satoshi & IBM Plex Mono

VAARS STUDIO LLC-FZ

Visual craftsmanship

London & UAE