roblox-vfx について
roblox-vfx は、Roblox のビジュアルエフェクトを設計し、ブラウザーシミュレーターでフレームごとに確認して Studio キットへ書き出すスキルです。別の手順では、所有するパックからエミッター設定を抽出し、VFXKit モジュールにまとめられます。
主な機能
- 01シミュレーター定義としてエフェクトを設計
- 02エフェクト用テクスチャとメッシュを生成
- 03シミュレーターでフレームごとに確認
- 04エフェクトを Studio キットに書き出し
- 05所有パックからエミッターを抽出し VFXKit 化
活用シーン
- 01アニメ風 Roblox エフェクトの試作と調整
- 02各フレームのエフェクトのタイミングと外観を確認
- 03Roblox Studio 向けのエフェクトを準備
- 04所有する無料パックのエミッターレシピを再利用
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ syphodev-yt/ roblox-ai-skills --skill roblox-vfxインストール後、Claude Code で/roblox-vfxを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
LOCAL.template.mdreferences/BEATS.mdCharacterVFX.server.luaDESIGN.mdHitSystem.luauproperty-surface.mdVFXKit.luareferences/corpus/CATEGORIES.mdSCHEMA.mdSTATS.jsonSTATS.mdreferences/corpus/tools/contact.pyevents.pymeasure.pypalette.pystrip.pyvtt2txt.pyreferences/styles/anime_cel.mdrealistic.mdSTYLES.mdstylised_game.mdscripts/assemble_kit.pyaudition.pyclassify_lib.pycompose.pycontact_grid.pycritic_gate.pycurate.pyemit_luau.pyemit_scenes.pyREADME(原文)English
プロジェクトの README よりこの README の日本語訳はまだありません。以下は英語の原文です。日本語の紹介はページ上部をご覧ください。
Roblox VFX, headless
First time on this machine? Read
LOCAL.mdin this folder. If it does not exist, run theroblox-skills-setupskill (or copyLOCAL.template.mdtoLOCAL.mdand fill it in).LOCAL.mdholds your paths and tool locations; it is yours and is never shared.
This skill has two routes:
- Author (the normal case, and the one that looks professional). Design the effect, build it as a definition in the browser sim, judge it frame by frame, then export a Studio kit. Every texture and mesh is generated by the skill, so everything you ship is yours. Sections from "The standard" down to "HIGH-QUALITY VFX, every time".
- Harvest (fast prototypes and placeholders). Read every emitter out of a free pack you own, keep
the recipes in your own library, emit a
VFXKitmodule. From "The harvester route" down.
First run: build the textures and meshes (once per machine)
The texture library and the meshes are GENERATED, so they are not in the download. Build them once (about 250 MB of PNGs, a few minutes):
cd sim/tex && python make_textures.py && python make_magic_tex.py && python make_painted.py
cd lib && python build_library.py # 2,000+ library textures, run it from this FILE, not stdin
cd ../../mesh
blender -b -P make_meshes.py # use the full path to blender.exe from LOCAL.md if it is not on PATH
blender -b -P make_props.py
blender -b -P make_dragon.py
cd ../tex && python make_prop_tex.py # skins for the prop meshes (needs meshes/props.json)
Python needs numpy, scipy and Pillow (pip install numpy scipy pillow). Then python sim/serve.py and open the printed address: the three
worked examples (Crimson Detonation, Eclipse Rend, Riptide Lance) should play.
The standard (a 100-move anime-style showcase, verified in Studio 2026-10-29)
The bar this skill was tuned to: 20 characters and 100 moves, built with this skill, recorded and verified in Studio. Judge every new effect against that level, never below it.
What made it that standard. Do all of these:
- Every element is the real thing (RULE ZERO below). Weapons, creatures and a coffin are meshes, and a slam leaves a real crater with debris.
- Ultimates get the anime set-piece layer: impact frames, an FOV punch, focus lines, letterbox and
camera cuts (sim actions
fov,overlay,bloom, below). - Run the polish pass on every effect, last:
sim/effects/fxpolish.py,polish(d).- An energy dome becomes a ForceField shell in the most saturated colour, around a Neon core at 0.6x size that goes out first.
- Crater rims take the arena's stone colour.
- Aura flames and sparks go on the Part, not an Attachment.
- Coloured afterimages are at least 0.6 transparent.
- Lingering smoke is smaller and see-through.
- Saturated colour, never a pale mix. A pale dome draws as clear glass, and a pale core blooms to white. For a darkness move, make the world pale and let the darkness be the black, with no additive glow on the black.
- The target reacts like a body (next section). A hit on a body that floats upright reads as broken, however good the particles are.
- The camera frames the whole action. Check the first and last frame of every cut, keep end close-ups at 4-5 studs, and never put the lens inside the caster.
The gate before a recording. Every check must pass, judged on numbers, not by eye:
| Check | Pass |
|---|---|
python sim/tools/sa_parent_check.py | 0 SurfaceAppearances off a MeshPart |
lune run sim/tools/lune/build_check.luau <kits/*.luau> (run from a folder whose aftman.toml or rokit.toml lists Lune) | every kit OK |
lune run sim/tools/lune/compile_check.luau <scripts> | every script OK |
node sim/tools/phys_audit.js [prefix] | no gap, sink, hover or STILL DOWN line |
A sim snap of every move at its beats (sim/snap.js), tiled into one sheet per character | looked at once |
| One full warm play of every move in Studio before the real take | without it, fresh MeshPart + SurfaceAppearance pairs show "being generated" |
| The take itself | 0 "being generated" warnings; 0 effect or caster touches on the target; the get-up's lowest point at least -0.05; worst frame about 40 ms |
A target that reacts like a body (measured in Studio)
The proven module is references/HitSystem.luau. It drives a knock with a thrown, tumbling R6 body,
and every rule below fixed a defect seen in Studio:
- Going limp takes three steps:
PlatformStand = true, thenChangeState(Enum.HumanoidStateType.Physics), thenEvaluateStateMachine = false. Disabling the state machine alone leaves the Humanoid balancing the body. That was the "pushed, then stands upright in the air" bug. - A lift VectorForce must include the HumanoidRootPart. It is 2.8 of an R6's 12.6 mass, so without it every throw falls short.
- Use collision groups. Every effect part goes in an FX group: the kit folder, its
_Actorsmodel (hooked with DescendantAdded and workspace.ChildAdded) and the caster. The target goes in a target group, and the two groups do not collide. Kit debris was sapping the launch speed, and the caster's limbs were bumping the body. - A body resting upright is toppled. It counts as resting below 2 studs/s for 0.25 s, or when upright (UpVector.Y > 0.35) below 5 studs/s for 0.4 s. An upright body gets a push plus an AngularVelocity at 6 rad/s for 0.4 s, with a fallback get-up 1.2 s later.
- The get-up is planted on the floor. Raycast the floor (the arena first, then the terrain). Build the body's boxes from RootJoint and every Motor6D (C0 * C1:Inverse(), half Part1.Size). On every frame of the 0.5 s Quad Out rise, put the root where the lowest corner sits on the floor. Place the first frame at once, so there is no one-frame pop.
- A stagger slide never takes the root below floor + 3.
- A ragdoll must be the last hit: a later flinch re-anchors the body.
- Between moves: the director calls
HitSystem.recover(target)and waits forisStanding(2 s cap), so the body gets up on screen, then holds max(0.3, 0.8 - elapsed) s before the next move. - The sim models the same body:
w.foewith modes stand, fly, down and getup.phys_audit.jschecks it.
Measured in Roblox Studio (2026-10-27): the sim is calibrated to these
The sim was compared against real Studio frames for three hard effects (a water-dragon spell, a phoenix dive, Crimson Detonation) and fixed until it predicted Studio. Trust these over any earlier note:
- ParticleEmitter Size = HALF the drawn width (Size 4 draws 8 studs). The sim
draws 2 x Size; builders author the width they want to SEE and
effects/roblox_units.pyhalves Size on write (sizeConvention: "roblox"). A builder that writes JSON itself must callto_roblox_units(). - SurfaceAppearance emissive saturates and never blooms: white-hot comes from Neon or LightEmission particles, never from mesh emissive. Emission colour is surface colour x EmissiveTint. Unlit surfaces go much darker at night, so give night smoke more emissive.
- PointLights light surfaces in Roblox, not in the sim: fade them out, or they paint bands on nearby meshes.
- No solid-core texture on a huge plane (it becomes a solid disc):
glow_falloff. - Preload every template at join, not right before play.
- Mesh upload route: OBJ -> FBX turns meshes 180 deg about Y. Open Cloud rejects
.obj, and the obvious Blender conversion (import -Z forward / Y up, export -Z / Y)
lands OBJ -Z on Roblox +Z, so every mesh came in backwards (the phoenix flew
tail-first; the dragon led horns-first). Use
sim/mesh/obj_to_fbx.py, which adds a half turn before export. Symmetric meshes hide this, so check orientation with an asymmetric one (red block at -Z, blue at +Z). - To verify a change in Studio, export the same JSON as a kit (
sim/studio_kit.js), capture Studio at the same times, and lay the frames side by side: old sim | sim | Studio.
Learned building 100 anime-style moves (2026-10-27 to 10-29)
Silent failures: each looked fine in one place and broke in another.
- A CFrame tween turns the SHORT way round (sim slerp = TweenService), so a
260 deg spin turns -100 deg and a 720 deg spin does not turn at all. Spin with
{"do":"spin", target, axis:"X|Y|Z", rate:deg/s, time}; it layers over a tween or path on the same part.fxmore.circle()uses it now. - Preload SurfaceAppearance maps BY ID.
PreloadAsyncon the instance did not fetch the ColorMap: a crater plane drew as a solid tinted square until the map streamed in. The kit'sM.preloadnow asks for every map and MeshId string too. - A fresh upload can say "Asset content representation is being generated" for 30+ min, and Studio keeps that failure for the session. Generation seems to start on the first request. Save, restart the window, run again.
- Additive white stacks to a blown-out disc on a BIG orb (a 5-stud spinning orb, a 16-stud spirit orb). Size the core glow down, colour the swirls (no white start), and use a non-Neon body under a spinning textured shell.
- A thin textured crescent band vanishes; a wave wants a real crescent-moon mesh
in Neon (
crescent_wallinsim/mesh/make_meshes.py). - Frame strips must preload meshes: a mesh was fetched only when its part first
showed, asynchronously, so a synchronous strip never drew a NEW mesh.
index.htmlnow fetches every MeshId on load.app.ready()returns a Promise: await it. - Fit the camera to the WHOLE action: half the sim fixes were framing (caster cropped at the edge, a blade poking into the lens, a camera inside a domain sphere). Check the first and last frame of every camera cut.
- Luau allows 200 locals per function: a kit with ~200 instances (a part-built shrine, a giant
spirit armour) failed to compile.
studio_kit.jsnow keeps template instances in a table (I[n]). - An emitter under an Attachment ignores its Shape: a Box aura or an Inward sphere of motes all spawn at ONE point (an aura's flames were born inside the caster's torso; a "storm" of cut sprites became starbursts). Shaped emitters go straight under the Part.
- Impact frames (anime white/black flash with silhouettes): Neon dome + floor + Highlights on caster and target. A Highlight on a mesh does NOT fill seen from inside it, and Highlights switch a frame before a part shows. A two-tone silhouette impact frame on an extreme close-up draws one giant blob: on a close-up use solid colour frames. Measured: impact frames in 4 of 4 ultimates and 0 of 9 ordinary moves.
- Sim/kit actions for the set-piece layer:
{"do":"fov", by, i, o}(FOV punch),{"do":"overlay", kind:"lines"|"bars"}(ScreenGui focus lines / letterbox),{"do":"bloom"}; Highlight Adornee "Foe" = the target (h.target); shake + FOV go through one camera driver that removes last frame's offset (an earlier shake drifted). - Anchored ropes (tendrils, whips) are NOT path+lag (that slides like a serpent): pin the root and animate the rope's points as their own paths.
- Two stacked greyscale CCs inverted chroma in the sim (saturation summed past -1); clamped now.
- A camera CUT during a running camera tween is lost in Roblox (the tween keeps writing CFrame until it ends; the sim cancelled it): kits now cancel the running camera tween on every camera event.
- Frame review:
sim/snap.js(eval(await (await fetch('snap.js')).text()); await snap(file, name, times)). {"do":"timescale"}sets every emitter's TimeScale; 0 = time stop, live particles hang, and particles Emit()ted while frozen are born frozen, so a frozen FLARE hangs as a white blob: emit only sparks/bursts in stopped time, orclearthe flare. Also{"do":"target","hide"}; actormodel= another rig or"@target"(a copy of the real target, joints driven, real clothes); keymove(Motor6D.Transform with a translation: limbs float off their joints),scale(Model:ScaleTo per frame, re-read C0/C1/Size after),fade(LocalTransparencyModifier).Enum.EasingStyle["Constant"]errors in Roblox: the kit's easeV treats Constant as a step key and falls back on unknown styles.- Close-ups must allow for the rig's props: a hat or a 6.8-stud cloak fills a 3-stud close-up; keep end shots at 4-5 studs and look away from the sun. Sim-snap every opener: a 2-3 stud close-up at FORM/CHANT/POINT puts the lens inside the caster.
- Linger smoke from a slam at the target drifts over end close-ups: cut
lingeron non-ultimates. - A DARK aura must not glow: an additive glow layer over black flames reads as red candy floss. Give a dark aura LightEmission 0, so the envelope is a non-additive black haze.
- A Neon core inside an energy dome must take the dome's most SATURATED colour. A pale mix blooms to white
on every dome;
"noCore": Truedrops it for a black dome or a self-lit body. - The sim renders ForceField material as additive white glare whatever its colour. Judge ForceField domes in Studio only. In Studio a ForceField dome shows its colour only when the colour is SATURATED: a pale tint draws as clear glass with a white rim, dark crimson reads as a real shell. The polish pass gives the shell the most saturated colour.
- A Ball part's inside is culled. For a sky you stand inside, use a mesh sphere in Neon: self-lit, so the inside faces read even though their normals point away.
- A world gone dark makes a darkness move unreadable. Make the WORLD pale and colourless and let the darkness be the black.
- A split-the-sky shot needs a DENSE cloud deck (~100 puffs, 9-15 studs). Thirty scattered puffs read as nothing torn.
- A Neon mesh carrying a soft-glow SurfaceAppearance reads as a blob. A plain Neon mesh shows its silhouette.
- A
pathstep rebuilds the part's rotation from its travel direction every frame and keeps only itsoffset: a tilt set on the starting CFrame is thrown away. Put the tilt inoffset(a shuriken volley flew edge-on, a dark sliver, until it was). - Streak particles on a SPINNING part come out as vertical "rain" sticks in Studio. Put the streak on a Trail on a separate carrier part that rides the same path without spinning.
- A creature summon in Neon with a big flame emitter on it reads as a yellow cloud. Plain Neon, a small emitter, and a REAL mesh of that creature (a dog mesh standing in for a fox read as a blob; a modelled fox with tall ears read at once).
- A SurfaceAppearance can only be parented to a MeshPart: on a plain Part Studio refuses the whole kit.
Use a MeshPart (a cube mesh) for a textured slab. The Lune build_check catches it (it stubs
CreateMeshPartAsync), and so does
sim/tools/sa_parent_check.py. - A SurfaceAppearance texture can draw untextured (pale) for ~1 s the first time its part shows, even after a warm play. Draw the part from t=0 somewhere hidden (under the floor) so the texture is in before its beat.
Character moves (caster animation) in the sim and studio_kit.js: def.rig keys
drive the real caster's Motor6Ds; def.actors = runtime clones of the caster (shadow
clones); key stretch: {RightArm: k | [k, width]} lengthens/thickens a limb with
the joint end held (exact in Lune); {"do":"ghost", parts, keep} = afterimages of
chosen parts; a Highlight with Adornee "Dummy" wraps the caster; {"do":"face"}
calls h.setFace; {"do":"hit", damage, knock, reaction, stun, time, ultimate} goes
to the game's hit system (knock = a deterministic displacement, so effects can be
placed where the target WILL be).
Learned on a sim-only showcase (disasters on a toy town, MP4 rendered from the sim)
Worth knowing for any "show the effects without Studio" request.
fxpolish.polish()turns every fire-shelllumpy_sphereinto a ForceField shell + Neon core, and the sim draws a big ForceField as a WHITE BLOB. A black hole's shock shell and a meteor's fire dome both rendered as white ovals. For a sim-rendered video write two files:<name>.studio.jsonwith the full polish (right for Studio) and<name>.jsonwith every step exceptdomes()(smoke(ghosts(auras(craters(d))))).TL.flash()writes Brightness back to 0 on the grade it flashes. If the scene darkens with a grade tween (a storm), every flash undoes the darkness: give flashes their owngrade() | {"name": "Flash"}.- A mesh's Size scales its bounding box, so parts modelled in one shared space (a body, its jaw, its leg)
stay aligned only if each is placed with
place(origin, centre * K)and sizedbox_of(mesh, K). - Ragdoll bodies in the sim: three meshes per body (stand, panic, flail), swapped by Transparency. Fly the
flail along a
pathwithspin(deterministic, so the landing spot is known). On landing, lay it flat, emit dizzy stars, then swap back to the stand mesh at the landing point (a get-up). - Check every camera position against the scene's buildings before stripping. Four of six first cuts had the lens inside a house or a roof filling the frame. A wide set (a 232-stud wave) needs the side camera outside its width.
- A ground plane at y=0.03 z-fights the sim's own floor at distance (the grass blinked in chunks). Put it at y=0.2 and decals at 0.3+.
- Video:
app.captureFrame(t, 1920, 1080, q)POSTed to/saveruns about 3 frames/s with the tab fronted, much slower in a background tab. Start the loop un-awaited in the page (progress in a window variable), wait on the frame count from a background shell loop, thenffmpeg -framerate 60 -i <name>_%05d.jpgper effect plus title cards, concatenated with-c copy. A long render can crash the tab (it did at frame 5,343 of 5,808), so wait on a loop that flags a stall, and resume from the last frame. Retry each frame's POST: a single failed fetch killed the first render. Run ffmpeg from Python with-nostdin,stdin=DEVNULLandcapture_output=True: with inherited handles in a background shell it crashed. - A small creature mesh does not scale to a giant boss. Its 1024 texture bake went scabby and blurry at 30x.
Build a jointed block version of the design instead, posed by forward kinematics with CFrame tweens, so
joints can't fight a
swing.
Learned putting 20 disaster effects in a real game (2026-11-02)
Sim sheets first, then a real-game capture of every effect. What only the game showed:
- Build for the camera the game really starts on. The effects were judged at zoom 165 and the game opens at 90: a dragon at 58 studs flew above the frame. Either keep tall things inside the frustum or ease the camera out for the effect, then give the view back.
- A kit source past 200,000 characters will not install. Stepped keyframes and many one-use carriers get there fast: reuse one carrier for every same-look part instead of one per use.
- Cel smoke at the sim's default colour reads as black blobs from above. Keep it above about 0.45 grey, at 0.3 transparency, with low LightInfluence, and cut rates hard. Smoke over a pit or a beam hides the effect.
- Emissive on a textured sheet blows out to a pale wash. A lava sheet wants 0.5-0.8, with thin cracks: a noise band picks far more of the map than it looks like it should.
- Judge the real game with Studio focused and not minimised. Unfocused it drops to a crawl and stills repeat; minimised, screen capture fails. Hide tutorial cards during a capture, and aim drops at the middle of town, not at the crowd.
RULE ZERO: no shortcuts, ever
Every effect is built to the best result possible. If an effect needs a thing, make the thing. A spell that is a creature gets a real creature mesh; a weapon gets a real weapon; a book gets a real book; a new look gets its own texture. Never substitute an existing generic piece (a beam, a blob, a box, a reused texture) because it is quicker. Shortcuts are exactly what make effects boring. This overrides time and token budget.
The failure that set this rule: a water-dragon spell shipped as a layered water
beam with a lumpy sphere for a head, when the spell is an eastern water dragon.
It needed a Blender dragon head, a serpentine segmented body that follows the
head's path, water textures for both, and was rebuilt that way (sim/mesh/make_dragon.py).
The audit that followed found the same stand-in in 29 effects, so check these first: craters painted as decals and rocks as flat sprites (use crater() + debris()), props as boxes (books, swords), a named weapon style with no weapons (a three-sword style needs three swords), a lance that is only a beam, a flower spell with petals but no flowers, a punch barrage whose fists are glow sprites. Sprites are fine as a LAYER (gravel, dust, sparks) on top of the real object, never instead of it.
Before calling any effect done, ask of every visible element: "is this what the effect actually IS, or a stand-in for it?" List each stand-in and replace it (Blender mesh, programmatic texture, new helper, new sim feature) before rendering. If the sim cannot show what the effect needs, extend the sim.
A VFX effect is not an asset. It is about thirty numbers per layer plus a texture reference. That single fact decides this skill's whole architecture: effects are definitions you own, and with the generated textures and meshes, so is everything they draw.
The sim: build and judge effects outside Studio
sim/ is a browser simulator of Roblox's VFX instances with Roblox's own
property names and value types: ParticleEmitter (every property, flipbooks,
all four orientations, shapes, drag, squash, ZOffset, LightEmission blend),
Beam (Bezier from attachment +X/-X, segments, texture modes, scroll), Trail,
Part/MeshPart (Neon, ForceField, TextureID), PointLight/SpotLight, Highlight,
ColorCorrectionEffect, BloomEffect, plus a timeline (emit / set / tween with
Roblox easing / enable / shake). Deterministic: seek(t) replays at 1/60 s, so
frame N always looks the same.
| Path | What |
|---|---|
sim/vfxsim.js | simulation core, no DOM (runs under node), plus toLuau() exporter and fromRecipe() importer |
sim/vfxrender.js | three.js r128 renderer, bloom + colour-correction post, metrics() (luma / sat / >250 fraction) |
sim/index.html | the workbench page (inspector, timeline, JSON, Luau tabs); lists sim/effects/index.json |
sim/effects/*.json | effect definitions (recipe codec values, CFrame = [x,y,z,rxDeg,ryDeg,rzDeg]); yours go here too |
sim/effects/build_*.py | parametric builders, one per effect; three worked examples ship |
sim/tex/make_textures.py | the base textures, generated from maths (white + alpha so Color tints them) |
sim/mesh/make_meshes.py | Blender VFX meshes (U along motion, V across), OBJ shared by sim and Roblox upload |
sim/serve.py | local server; POST /save?name=x.png writes a strip to sim/out/ |
Cheap check loop: python sim/serve.py, open it in a browser Claude can drive,
then one JavaScript call: await app.ready(); const s = await app.strip([3,4,6,8,12].map(f=>f/60)); await fetch('/save?name=s.png', {method:'POST', body:s.png}) and read ONE strip image plus s.metrics.
Judge timing on numbers first (flash frames, luma peak), the image once.
Behaviour marked CALIBRATE in the code is inferred, not measured: Drag
(halvings per second), Squash exponent, Disc ShapePartial meaning, flipbook
frame order, beam texture v side, VelocityPerpendicular in-plane axes, bloom
threshold scale. Calibrate each against Studio once (same JSON through
toLuau, phase-frozen capture, compare) before trusting fine detail.
Router: which file to read for which job
| Job | Read |
|---|---|
| The quality bar, the pre-recording gate, a target that must react to hits | The standard and A target that reacts like a body at the top of this file; sim/effects/fxpolish.py; sim/tools/; references/HitSystem.luau |
| Any new effect with NO reference (the normal case) | references/DESIGN.md (brief -> corpus skeleton -> style -> beat -> build -> critic loop) |
| Which look: anime / realistic / game | references/styles/STYLES.md, then ONE of anime_cel.md, realistic.md, stylised_game.md |
| Timing/structure for a category (slash, beam, impact, explosion, orb, aura, ultimate, elemental, projectile, dash, pickup) | references/corpus/CATEGORIES.md and the measured numbers in references/corpus/STATS.md |
| The memorable moment | references/BEATS.md |
| Checking a built effect | python scripts/critic_gate.py sim/effects/<x>.json [--metrics m.json] + the blind review in DESIGN.md |
| Adding to the corpus (your own teardowns of reference clips) | references/corpus/SCHEMA.md + references/corpus/tools/ |
| Worked examples of a full build | sim/effects/build_explosion.py (a 14.5 s sky-circle detonation), build_eclipse_rend.py (a no-reference slash), build_riptide_lance.py (a no-reference beam, uses prop meshes) |
| Effects WITH character animation | sim/effects/fxlib.py (R6 POSES, key(), blocks); "rig" block in the effect JSON, parts with "Follow": "RightHand" ride the body (emitters auto LockedToPart), {"do": "ghost"} afterimages |
| Magic spells, swords, more fighting moves | sim/effects/fxmore.py (spinning layered circle(), glyphs(), gather(), trail_kids(), sword(), enemy() + knock(), ring_burst, smoke, decal) and fxlib.rig_point() (where a hand WILL be at a pose, for circles that must spin). Before any strip or video, render each effect once at several times and await app.ready(): a mesh or texture first seen mid-strip is missing from that frame |
| Picking a texture (any effect) | the generated LIBRARY, 2,000+ originals (built by First run): sim/textures/lib/ = sprites (14 categories: rings, slashes, sigils, elements, impacts, specks, shards, flares, swirls, stars, cracks, smoke, shapes, masks), flipbooks (13: shockwaves, impacts, fire, smoke, lightning, slashes, water, wind, shards, specks 8x8, magic, ink, cracks; 4x4 = 16 frames unless named 8x8) and beams (16 strip families, most tile in U). Every shape comes in up to four looks with the SAME silhouette per index: cel (hard edge, 3 grey levels), soft, glow (core + halo, LightEmission 1 layers), ink (ragged brush); fire also hot (pre-coloured). Search: python sim/tex/lib/find_tex.py slash glow -> sim:lib/... ids; browse textures/lib/_sheets/*.jpg. Regenerate/extend: add a family to `tex/lib/sprites.py |
| Anything the effect IS an object (weapon, book, creature, chain, rock, ice, flower) | sim/mesh/make_props.py (Blender kit: 5 rocks, crater_rim, rock_spire, rock_fist, ice_cluster, ice_block, katana, swords, spear, axe, water_spear, wave_blade, book parts, flower bloom stages + stem, fist_arm, chain_link, chain_spike, phoenix body/wings/tail, portal_ring; writes meshes/props.json sizes + centres) and sim/mesh/make_dragon.py; skins in sim/tex/make_prop_tex.py; place with sim/effects/fxprops.py (prop() by model origin, held() weapons in a hand by the grip, crater() and debris() = REAL ground destruction, never decals or sprite rocks alone) |
| Moving objects | sim actions: path (Catmull-Rom by arc length; lag for serpents and chains, offset/pivot, roll, spin), hinge (jaws, wings: axis X/Y/Z), toss (unanchor with Roblox gravity, bounce, settle: debris), swing (rotate about a local pivot: book covers, pages, doors). All export to Luau. Hidden-tab renders: wait with a MessageChannel loop, not setTimeout (Chrome throttles timers in a hidden tab to a crawl) |
| Working with a specific reference video | steps 1-6 below |
HIGH-QUALITY VFX, every time (the authoring route)
The bar: an effect at the level of a pro showcase or an anime set-piece, built ENTIRELY from your own assets. Harvested packs are a REFERENCE set to learn from, never shipped or sampled. Blender meshes and maths-made textures fill every role. Follow these steps in order; skipping one is how the first attempts came out "not amazing".
- Tear down a reference first. Download it, measure per-frame luma and
saturation, cut numbered strips at native fps, and write a phase timeline
with numbers (durations, growth curve, colour hexes per phase, layer list
with Roblox technique each). Tools:
references/corpus/tools/(events.py, strip.py, measure.py). Keep the heavy files (downloads, frames, strips) in your research folder fromLOCAL.md, never in the skill. Never author a big effect from imagination. - Textures from the measured recipes.
sim/tex/make_textures.py(on the toolkitsim/tex/vfxtex.py) implements the texture rules: hard ~1 px edges- separate halo, 2-3 cel grey levels with a darker rim, asymmetric shapes,
4-11 % padding, flipbooks whose COVERAGE IS SOLVED to the measured template
(frame 0 small, peak t 0.13-0.33, erode from ~0.4, gone by ~0.85). New
category = new function there. Magic circles:
sim/tex/magic_circles.py(own rune alphabet, 4 separable layers, baked bloom). Cel fire volumes:sim/blender/volume_flipbook.py(Blender, procedural).
- separate halo, 2-3 cel grey levels with a darker rim, asymmetric shapes,
4-11 % padding, flipbooks whose COVERAGE IS SOLVED to the measured template
(frame 0 small, peak t 0.13-0.33, erode from ~0.4, gone by ~0.85). New
category = new function there. Magic circles:
- Meshes in Blender (
sim/mesh/make_meshes.py): U along the motion and tiling, V across, full 0..1 UV. Mesh textures (mesh_*) are laid out in UV space, not as radial sprites. Roblox has NO mesh UV scroll: meshes move by Size / CFrame / Transparency / SurfaceAppearance.Color tweens. - Build the effect as a parametric builder (
sim/effects/build_*.py), every number from step 1, scaled from one size parameter (R). Layer roles: flash, core, body, secondary strokes, ground, residue, screen grade. Big moves get the camera, a 1-frame flash, a held whiteout and a cooling ColorCorrection; ordinary moves do not (measured: 4/4 ultimates, 0/9 moves). Add the effect tosim/effects/index.jsonso the workbench lists it. - Judge in the sim with ONE numbered strip at the phase times plus
metrics()(flash frames by luma, peak timing). Fix, re-strip. Only then: - Studio:
node sim/studio_kit.js effects/<e>.json <out>writes a Studio KIT (<e>.luau+<e>.assets.json), not the plaintoLuau()module: a game script may not write MeshPart.MeshId or SurfaceAppearance.ColorMap, so the kit isbuildTemplate(assets, parent)(EDIT mode, plugin/command bar: meshes via CreateMeshPartAsync, textures set, every property write pcall'd and reported inM.PROP_FAILS) +spawn(origin, template)/play(h)(RUNTIME LocalScript: clones, applies the origin to every part CFrame, moves grades into Lighting and removes them after). Upload textures as Image and meshes as Model (FBX fromsim/mesh/obj_to_fbx.py, never the raw .obj; the Open Cloud key is read from the environment variable named inLOCAL.md), then insert each model once to read its inner MeshId (the kit needs the MESH id). Before Studio, run Lune: compile check + buildTemplate against Roblox reflection:lune run sim/tools/lune/compile_check.luau <kit>andlune run sim/tools/lune/build_check.luau <kit>(run lune from a folder whoseaftman.tomlorrokit.tomllists it). Not exported: the caster rig animation and ghost afterimages.
Self-review before showing anything (from the theory study): peak in the first ~25 % of life; layers die at different times; flash 1-4 frames, hard off; dark copy under every bright stroke; white at the peak, colour at the rims; one dominant hue + a desaturated accent; residue outlives the event; greyscale check still reads; nothing symmetric; no linear fades.
First full proof: effects/explosion_crimson.json (builder
build_explosion.py), a 14.5 s sky-circle detonation on a measured
anime-explosion skeleton, all own assets.
The harvester route
The harvester is a reference and prototype tool, not a pack. It ships tooling and no harvested effects. You build your own library, in this order:
- Take free VFX packs into your own Roblox account, by hand. Find a pack on
the Creator Store (or in Studio's Toolbox) and press its Get / Add to
Inventory button while signed in. This step cannot be automated:
insert_assetandInsertService:LoadAssetboth refuse an asset your account does not own, with "User is not authorized to access Asset", even when the listing says free. Free means free to take, not free to load. (Roblox's own "Particle Effects" pack,228304820, is the one exception measured: it loads without being taken, which makes it the test pack.) - Put the pack in a Studio place (drag it from your inventory into Workspace, in any place you have open).
- Run the harvester (
payload.py harvest+recipe_server.py, below). The recipes land in YOUR library folder, outside the skill folder. The path is inLOCAL.md(ROBLOX_VFX_LIB). - Classify, find, curate, emit from your own library:
classify_lib.pytags it,find_vfx.pysearches it,audition.pycompares candidates,curate.pycopies the ones a game uses,emit_luau.pywrites theVFXKitModuleScript (--compact, and--data-dironce a kit nears Roblox's 200,000-character Source cap; a bigger file installs as NOTHING).
Keep your library private. Using a free pack's assets in your own game is within the Creator Store terms; extracting their values and textures and redistributing them as your own pack is a different act.
Layout. The skill and a library are separate on purpose: a library holds
extracted packs, so it lives OUTSIDE the skill directory and every script takes
--lib (or reads the ROBLOX_VFX_LIB environment variable).
<skill>/
SKILL.md this file
LOCAL.md your machine-specific settings (from LOCAL.template.md)
scripts/ the harvester tooling (below)
scripts/luau/ codec + harvest + build bodies, assembled by payload.py
references/ VFXKit.lua, HitSystem.luau, design + style + corpus notes
sim/ the simulator, texture and mesh generators, gate tools
work/ scratch payloads, also served over localhost
<your library folder>/ path set in LOCAL.md, outside the skill folder
harvested/ raw intake from the harvester. Look at it, then promote.
recipes/*.json the recipes you kept
compositions/ optional: timelines you author over your recipes
index.json search index, built by classify_lib.py
.cache/ texture thumbnails + tag caches, all regenerable
Entry points, all --lib-aware:
| Command | Does |
|---|---|
find_vfx.py --lib DIR <words> --layered | find effects by what they ARE |
payload.py build --recipe F --target PATH --out work/b.lua | assemble a build payload |
recipe_server.py | sink for harvests and serves work/*.lua over localhost |
verify_vfx.py --lib DIR --quiet | defect checks; --self-test proves they can fail |
classify_lib.py --lib DIR | (re)build index.json; --sheet ring audits labels |
payload.py harvest --root PATH --out work/h.lua | harvest a pack out of Studio |
assemble_kit.py, contact_grid.py, fetch_tex_hires.py | build a game's kit from many recipes, see a whole kit on one sheet, pull high-res thumbnails of a flipbook |
Before touching Studio, confirm your Studio bridge is actually live. A
connected plugin and a working one are different things. With Roblox's official
Studio MCP (recommended), run a trivial execute_luau (return 1) and take a
screen_capture; where this file says capture_screenshot, that is the official
server's screen_capture. With the older third-party robloxstudio-mcp server:
curl -s localhost:48741/health
"pluginConnected": true is the only signal that means anything there. If that
looks fine and tools still time out, see "When the MCP is connected but every call
times out" below.
To build and judge effects from a library, go to "Previewing a harvested recipe". To add packs to one, go to "Harvesting a pack".
The one idea the harvester is built on
Do not generate particle parameters. Retrieve them.
(For shipped, high-quality work this is superseded by the authoring route above: the sim makes generated parameters verifiable offline, and the generated textures and meshes make every asset yours. Retrieval stays the fast path for prototypes and placeholders.)
Generating a good emitter from a text prompt is unverifiable offline, because nothing outside Roblox simulates a ParticleEmitter. Retrieving an effect a human artist already tuned is trivially verifiable and immediately correct. This is the same T1/T2 logic that makes Creator Store audio the right answer over generated SFX.
What makes it a skill rather than copy-paste is the layer on top:
free VFX pack in Studio
-> harvest.lua (explicit property walk) -> recipe JSON (yours, portable)
-> tune live in Studio, harvest back -> recipe JSON (updated)
-> emit_luau.py -> VFXKit ModuleScript
-> VFXKit.build(name, part, {scale, tint, lifetime_mul, rate_mul, offset})
Retrieval gives you an explosion. The transform layer gives you "that explosion, 0.6 scale, tinted green, fired at this attachment on this event".
This skill is the harvester, not the harvest. Using free pack assets in your own game is within the Creator Store terms. Extracting their values and redistributing them as your own pack is a different act, and the textures are unambiguously someone else's work. So the skill contains the tooling and each user builds their own library from packs in their own account.
Read this first: fifteen things that will bite
1. The MCP's generic property reader sees 18% of a ParticleEmitter.
get_instance_properties and get_class_info both return the same nine-field
generic view: Size, Rotation, Transparency, Color, Enabled,
Brightness, Name/ClassName/Parent. Missing: Rate, Lifetime, Speed,
SpreadAngle, Drag, Acceleration, Texture, LightEmission,
EmissionDirection, Shape, ZOffset, Squash, FlipbookMode,
LockedToPart and more. Measured: 6 of 31 real properties. Harvest through
those tools and every recipe looks populated and is three quarters empty,
silently. Always harvest with execute_luau and an explicit property walk
(scripts/luau/harvest.lua). verify_vfx.py fails a recipe with a short
property set for exactly this reason.
2. FIVE stock particle textures exist, and one obvious-looking path is a
trap. This said "only three" until a real pack was harvested. The first pass
only probed textures/particles/*.dds and never looked in textures/ itself,
so two live paths were being hard-failed as nonexistent. Measured by rendering
each one:
| Path | Looks like | Good for |
|---|---|---|
textures/particles/sparkles_main.dds | soft 8-point star | embers, motes, magic |
textures/particles/fire_main.dds | noisy orange flame | fire bodies, explosions |
textures/particles/smoke_main.dds | soft round puff | smoke, dust, glow cores, auras |
textures/explosion.png | puffy lit explosion cloud | blasts, impacts, cartoon fire |
textures/sparkle.png | hard 8-point star, dark rim | sharp sparks, hit flashes |
rbxasset://textures/smoke.png renders NOTHING — and Roblox's own
"Particle Effects" pack (228304820) ships a Smoke layer pointing straight at
it. A first-party pack contains a permanently invisible emitter. So do
textures/fire.png and textures/particle.png. Everything under
textures/particles/ other than the three above (explosion01_*,
light_diffuse, smoke_dust, burst, slash, glow, star,
sparkles_bright) is also dead.
A wrong texture path is an invisible emitter, not an exception. verify_vfx.py
names the three measured-dead paths explicitly rather than reporting them as
generic unknowns, because "not in the stock set" reads like a false positive
when the path came out of a Roblox pack.
3. An Attachment under a Model or Folder has no world position. Build targets
must be a BasePart. Parent an Attachment anywhere else and the emitters build
silently at the world origin: the effect "works", it is just nowhere near the
thing it belongs to. VFXKit.build asserts on this.
4. A Sphere-shaped emitter on an Attachment spawns from a near-unit volume. There is no emitter-size property. On a character that means every particle is born inside the torso and is occluded by it. To surround a character the particles must either be big enough to overlap the silhouette or be given enough outward speed to escape it. Use both for an aura.
5. capture_screenshot needs the Studio window FOREGROUNDED, not merely
un-minimised. A minimised window fails with "Ensure the Studio viewport is
visible... Known Roblox bug: capture may fail if viewport renders a solid
color." The message blames the viewport colour. It cost three wrong hypotheses
(backdrop size, TimeScale, an over-bright PointLight) before checking
IsIconic.
IsIconic == false is not sufficient — a restored-but-background window
still times out. What reliably works is maximise plus foreground:
[W]::ShowWindow($h, 3) # SW_MAXIMIZE
[W]::SetForegroundWindow($h)
The solid-colour failure is real too, so keep some sky or ground in frame rather than filling it with one flat backdrop.
6. execute_luau silently drops EVERY print if the script yields at all.
Not just prints after a trailing wait: a script whose first statement is a
yielding call (InsertService:LoadAsset, GetKeyframeSequenceAsync) returns
success: true with an EMPTY output array, losing prints that ran long before
the return. "Keep the waits short" does not help when the yield is the work.
The fix is to stop using the return channel for anything that yields: do the
work in task.spawn, write progress into a StringValue, and read it back in a
second execute_luau call.
local sv = Instance.new("StringValue"); sv.Name = "__probe"; sv.Parent = game.ReplicatedStorage
task.spawn(function()
... -- yields freely
sv.Value = "DONE :: " .. result
end)
return "spawned" -- returns immediately, output intact
Because it is written on every iteration it also survives a script that dies half way, which a print does not.
7. A burst layer with Rate > 0 bursts AND streams forever. The commonest
authoring slip, and it reads as "the effect never stops". VFXKit.build forces
Rate = 0 on any layer whose emit mode is burst, and the verifier fails it.
8. Dark smoke on a dark backdrop is invisible, and that is a staging artifact, not a bug in the effect. Judge every effect over BOTH a light and a dark surface before changing a value. A smoke layer was nearly deleted as "not working" when it was reading perfectly well against the ground.
9. Real packs parent emitters straight to a Model, where they render
nothing. Measured on Roblox's own 228304820: Model 'Particles' with three
ParticleEmitters as direct children — no Attachment, no BasePart anywhere. A
ParticleEmitter under a Model has no world position and does not draw at all.
The pack is a bag of parts meant to be dragged onto something, not a working
effect. So an inserted pack looking dead in the viewport tells you nothing
about the recipes inside it; harvest first and judge after the rebuild.
harvest.lua records parent_class and renders_in_place: false, and the
verifier warns rather than fails, because the values are perfectly good.
10. Setting CurrentCamera.CFrame in edit mode does not stick. Studio's own
camera controller overwrites it between execute_luau calls, so a rig staged at
a fixed world position is often out of frame by the time you capture. Do the
opposite: read CurrentCamera.CFrame, then place the rig at
cf.Position + cf.LookVector * d and spread it along cf.RightVector. Screen
order is then left-to-right in the order you place, which is what makes a
labelled contact sheet trustworthy.
11. GetFullName() IS NOT UNIQUE, and using it as a grouping key loses
recipes silently. Ten sibling Parts all called Part under one Folder return
the same string. Keying groups on it merges unrelated effects into one giant
recipe AND makes them overwrite each other on write. Measured on a real place:
366 of 583 recipes vanished, and what survived included 40- and 55-layer
"effects". Key groups on the instance itself, then disambiguate the recorded
path with a #n suffix. The sink refuses to overwrite a file whose
source_path differs, as a second line of defence.
12. PostAsync refuses a large body, and the fallback print is invisible. A
1447-recipe pack encodes to ~2MB and the POST throws. The pcall catches it,
the code falls back to chunked printing — and if the harvest is running inside
task.spawn (which it must be, see trap 6) those prints are dropped too. The
pack reported "harvested 1447" and wrote nothing. Post in batches of ~40,
and return the count that reached disk, never the count that was scanned. A
harvester that reports what it walked rather than what it saved is lying by
construction.
13. Most pack containers are named Part, Model or a number. 61% of
emitters in a real 3100-emitter place. Naming recipes after the nearest
container yields hundreds of files called Part.json — complete, verified, and
impossible to search, which is the same as not having harvested it. Climb past
generic names to the nearest ancestor a human actually named, but never as far
as the harvest root or the whole pack collapses into one recipe.
14. Half of what is sold as a "VFX pack" is a TEXTURE LIBRARY. Shape:
Part '78' -> Attachment -> ParticleEmitter 'Images/Lightning', one emitter
each, hundreds of them. Single-layer recipes are correct here, not a grouping
bug — and the only human-meaningful name is on the layer, not the container.
So when the container name is junk and the recipe has one layer, name it from the
layer (Images/Lightning -> Lightning); when the layer is called Texture
too, fall back to the texture asset id, which is at least unique and matches back
to the image. That turned 1660 unfindable files into 1124 human-named ones.
15. A harvested effect is tuned for the stage it was harvested against, and
LightEmission = 1 is the trap. It makes a ParticleEmitter quad ADDITIVE: it
adds to what is behind it instead of covering it. Most packs are shot against a
dark stage, where that reads as glow. Put the same effect over a pale
environment - white stone, snow, an overcast sky - and every quad saturates to
flat white across its whole area the moment it is drawn, because the background
already sits near the top of the range.
Measured in a pale arena map: a finisher hit put 44% of the frame at full saturation for a fifth of a second, blanking both fighters at the exact instant the player needed to see the hit. Two obvious fixes did nothing, and both were the wrong shape of fix:
| change | frame fully saturated |
|---|---|
| as harvested | 44.1% |
| flash + core quads cut 2.7x | 44.1% |
| spark count 44 -> 24, sizes cut | ~40% |
| pulse lights 9/14 -> 3.0/4.5, ranges halved | 32.5% |
LightEmission 1 -> 0.35-0.55 | 15.4% |
Shrinking the quads could not help, because the saturated area is set by coverage, not by any single quad's size - and coverage barely changed.
Two rules fall out of this:
- Judge a wash by what it is washing. A floor going evenly white with its
texture gone is a light or a blend mode. A burst of quads is not even: it has
edges and spikes. Look at the peak frame at full resolution before changing a
number, and measure saturation (
fraction of pixels > 250) rather than eyeballing brightness - a pale arena reads as 4% at rest and 44% at peak, and the difference between those two is the whole diagnosis. - Check the pulse lights against the constant ones in the same file. One build shipped pulse peaks of 9 and 14 alongside constant glows of 2.2 and 3.0. That gap was not a decision, it came straight from the harvest. Anything above about 4 will wash a pale scene on its own.
16. A harvested LAYER OFFSET can be structure, and the engine scales it.
Every layer records where its emitter's attachment sat in the pack. For most
packs that is zero or a small tuning nudge; for a BEAM pack it is the far end of
the beam. Lava-01 layer #1 (FireSpecs1) sits 6.25 studs out, and
Lighting-01's Lightning layer hangs 9.9 up. The engine multiplies layer
offsets by the element's scale, so at depth-4 scales the embers of every fire
stream landed 15-27 studs from their own effect - a detached clump of specks
floating over the caster, which reads as a separate bug and is not.
Two ways out, both used: select a zero-offset twin where the pack has one
(Lava-01 #6 is #1 with offset 0, byte for byte), or set centred: true
on the recipe element, which builds every layer on its anchor and ignores the
harvested positions. It is opt-in, so existing compositions are unchanged. Worth
writing a small checker that fails on any selected layer more than 0.05 studs off
its anchor, for any composition set that selects layers by hand.
17. A ring costs segments Neon parts, so size its segments by RADIUS. Rings
are built as a loop of parts, not one mesh. Setting segments by
depth gave a 1.6-stud heal ring the same 66 parts as a 15-stud
shockwave; a regeneration heal that spawned a fresh ring set every beat built
2473 instances for one cast. 24 + 2.4 * radius (capped at 64) stays smooth at
every size, and a repeating ring should be RE-CUED (show / radius / fade on the
same id) rather than rebuilt - the timeline drops finished tracks, so a later
cue takes over cleanly as long as two uses of one ring do not overlap.
18. A composition that draws its effect with Parts is not using this skill. The engine CAN build Neon rings, part groups and part-segment bolts, and it is tempting to build the "structure" of an effect (shock rings, pillars, sigils, bolts, walls) that way and only sprinkle harvested emitters on top. That is generating, not retrieving. One large effect set did it: 12 of 1813 library recipes used, 1,696 part-built elements, and all of it was rejected on review. The rebuild found a harvested effect for every one of those roles once someone actually looked (flat shockwave textures, magic circles, tornado loops, frost shells, aura rigs, beam ribbons, toon bolt sprites). Rules that fell out:
- Survey before you build: list the library's effects by pack and name (index.json is keyed by FILENAME - joining on the display name drops whole packs), audition candidates per role side by side, normalised to one size (packs are authored from dot-sized to 35 studs), and write the choice down as a role map in data.
- Audit a candidate at SEVERAL phases. Realistic-Explosion-01 is a white flash for three frames and an orange fireball after; judged at 0.3s it went into every element's release, and put fireballs over water auras.
- A harvested floor wave is a FILLED disc, where a ring was an outline: the same radius covers several times the screen. Size them ~1.3r, not 2r.
- Count, and gate: harvested elements vs parts per effect; fail the build on any visible part that is not an invisible carrier or a copy of the character.
20. A tint cannot recolour a texture that carries its own colour. Tint
multiplies the Color property; the texture multiplies after it. WaterBurst's
ToonLiquidColor sheet is baked blue, so "gold honey" rendered murky green and
"pink cake" stayed blue, while its grey ToonLiquidAlpha twin tinted perfectly.
A layer whose harvested Color is BLACK (Portal-01's Portal) cannot be tinted
at all. Measure each kept layer's texture (the .cache/tex PNG) before choosing
a tint, and keep only the grey layers of anything you recolour.
19. A Roblox script Source must be under 200,000 characters. Assigning a
longer string throws Unable to assign property Source. A shipped effect set
with many harvested recipes crosses that fast (54 recipes: 356K). Split data into
child ModuleScripts holding JSON strings and decode them lazily.
Element kinds (engine.lua)
| kind / field | What |
|---|---|
beam | one harvested Beam layer (recipe, layer) wired between from/to anchors (+offsets) or moving parts (parent/to_parent); width_mul, tint. Before this no harvested Beam could be used at all. |
| Beam layers inside a recipe | wired automatically from the endpoints offsets the harvester recorded (aura rigs: floor emitter + rising beams). |
chain | one harvested recipe strung at points hosts between two anchors, jitter - a line drawn by the harvest's own effects (lightning bolts). |
centred | recipe field: build every layer ON its anchor, ignoring harvested layer offsets (trap 16). |
rate_mul, rate_cap | stream a harvested effect at a multiple of its OWN pack rate (harvested_rate survives a burst harvest), capped. |
Compositions (optional, you author them)
A recipe is one harvested effect on one part. A composition is a timeline over several of YOUR recipes plus simple geometry, for effects like "orb forms in the hand, swells, pulses, releases". The skill ships the engine, not any compositions: you write the JSON yourself, against recipes from your own library.
{
"name": "my_effect",
"title": "My Effect", // on-screen button label (emit_scenes.py)
"duration": 3.0,
"elements": [
{"id": "core", "kind": "recipe", "recipe": "MyRecipe", // a file name in your library
"anchor": "A_Hand", "scale": 0.8, "layers": ["Core", "#3"]},
{"id": "ring", "kind": "ring", "anchor": "A_Ground", "radius": 2, "segments": 32}
],
"timeline": [
{"t": 0.0, "id": "core", "act": "enable", "on": true},
{"t": 1.2, "id": "ring", "act": "radius", "from": 1, "to": 12, "time": 0.6}
],
"camera": {"look": "A_Chest", "offset": [-12, 6, -18], "fov": 50} // edit mode only
}
- Element kinds:
recipe,beam,chain,ring,part,light,group,bolt,trail,clone/model(see the kinds table below andengine.lua). - Cue acts:
emit,enable,show,hide,destroy,regen,warp,scale,radius,fade,light,spin,fly,spiral,burst,stagger,move. Cues taket,id,act, andtime/from/to/style(an easing name) where they animate. - Anchors are a name (
A_Ground,A_Feet,A_Waist,A_Chest,A_Head,A_Air,A_Sky,A_Hand,A_Far) or a literal[x, y, z]. In play mode a literal is character space: x right, y up, -z forward, from the floor point under the character. The edit stage (stage.lua) builds a dummy and the same anchors so what you judge in edit mode is what the runtime builds. - Edit-mode loop:
compose.py my_effect.json --lib <lib> --freeze 0.45 --out work/s.lua, run the payload, screenshot, then repeat at other phases.stage.luamoves the Baseplate and spawn, so run it only in a scratch place.compose.py my_effect.json --self-testproves the composition checks can fail. - Shipping:
emit_scenes.py --lib <lib> --comps <your compositions folder> --out-dir workwrites aVFXScenesModuleScript (compositions plus their de-duplicated recipes) and aVFXDemoLocalScript with one key and button per composition (the first eight get keys 1-8). - Beware trap 18: a composition that draws its effect with Parts is generating, not retrieving. Use the engine's part kinds for glue, and harvested recipes for the visible effect.
Scale: harvest wide, ship narrow
A real harvest is bigger than you expect. One afternoon over 13 free packs produced 1813 recipes / 2238 layers, all verified, with one genuine failure.
Do not emit the whole library. emit_luau.py inlines every recipe into one
ModuleScript; at this size that is a projected 4.7MB of Luau, which is not a
thing you put in a game. The library is an archive to search, and VFXKit is
the subset you chose. Curate to the handful an effect actually needs, then
emit.
Expect the shape of a big harvest to be: a long tail of single-layer texture entries, a small band of genuinely layered effects (153 of 1813 here), and a handful of Beams. The layered ones are where the craft is; the tail is a lookup table.
The verify loop
Nothing offline simulates particles, so the gate is Studio itself, in edit mode (no Play required, confirmed working).
Phase-frozen capture is the technique that makes this practical. A burst
lasts under a second, so a screenshot is a coin flip. Instead: emit, task.wait
the exact age you want to inspect, then set TimeScale = 0 on every emitter.
The simulation freezes and stays frozen until you set it back to 1, so the
capture lands on the phase you asked for, every time.
for _, e in ipairs(handle.emitters) do e.TimeScale = 1; e:Clear() end
handle.emit(1.0)
task.wait(0.22) -- the phase you want to judge
for _, e in ipairs(handle.emitters) do e.TimeScale = 0 end
-- now capture_screenshot
Sample at least an early phase (is the flash reading), a mid phase (is the shape right) and a late phase (does it have a tail, or does it just stop).
The contact sheet ranks candidates. Your eye decides. No metric in
verify_vfx.py judges whether an effect looks good, and none can. What it
catches is the class of defect a still cannot show you: truncated property sets,
emitters transparent for their whole life, burst layers that stream, texture
paths that render as nothing, sequences that do not span t=0..1, portability
flags that contradict the texture refs.
On harvested recipes it also raises two warnings that are a work queue, not defects: layers whose emit mode was inferred rather than recorded, and layers the source pack had parented somewhere they could not render. Both are things only a human can settle.
python scripts/verify_vfx.py --lib DIR --quiet # your harvested library
python scripts/verify_vfx.py work/harvested/SomePack.json # one file
python scripts/verify_vfx.py --self-test # prove the checks can fail (10/10, no library needed)
Files
| Path | What it does |
|---|---|
scripts/luau/codec.lua | Typed round-trip between Roblox values and JSON. One definition, prepended to every payload. |
scripts/luau/harvest.lua | Explicit property walk over a container. The only correct way to read an emitter. |
scripts/luau/build.lua | Recipe to live instances in Studio, with transforms. |
scripts/payload.py | Assembles codec + body + substitutions into one payload. --sink sets the POST target. |
scripts/recipe_server.py | Localhost sink AND payload server. Harvests POST here; GET /payload/<f>.lua serves work/ for loadstring. |
<library>/harvested/ | Raw intake (or work/harvested/ if no library path is set). Harvests land here, never straight in recipes/. Promote by hand after looking. |
scripts/emit_luau.py | Recipe JSON (--lib DIR) to the shippable VFXKit ModuleScript. |
scripts/verify_vfx.py | The checks, plus the sabotage self-test. --lib to point at your own library, --quiet for failures only. |
scripts/classify_lib.py | Tags a harvested library so it can be searched: fetches textures, reads motion from values and shape from pixels, writes index.json. --sheet ring to audit. |
scripts/find_vfx.py | Search the index by what an effect IS. --layered, --portable, --tag, --json. |
references/VFXKit.lua | GENERATED by emit_luau.py. The copy in the skill is an empty kit; yours holds the recipes you emit. Drop in ReplicatedStorage. |
references/CharacterVFX.server.lua | Worked example: aura on spawn, impact burst on landing. |
scripts/compose.py, scripts/emit_scenes.py, scripts/luau/engine.lua, scene.lua, runtime.lua, stage.lua, demo_controller.lua | The optional composition engine: timelines over recipes you author yourself (see Compositions below). |
scripts/audition.py | Candidates for ONE role side by side in ANY place (--at x,y,z, empty Workspace.__VFXStage), normalised to one size, slot bars counted L->R. Never runs stage.lua. |
scripts/curate.py | Copy the recipes a game uses into the game by role: `NAME=STEM |
LOCAL.md | Your machine-specific paths. Never shared. |
Harvesting a pack
Status: the harvester has been run end to end on a real third-party pack and
the round trip is lossless. Measured on Roblox's own "Particle
Effects" (228304820): harvest -> JSON -> rebuild, then a property-by-property
diff of the rebuilt emitters against the originals — 84 of 84 values
identical across all 31 properties x 3 layers, plus a side-by-side
phase-frozen capture that is indistinguishable. The remaining unknown is pack
shape, not fidelity.
Getting the pack in front of you
search_assets is a dead end. Do not spend time on it. Through an MCP it
typically returns
{"errors":[{"code":0,"message":"Scope not authorized."}]} # HTTP 403
against /toolbox-service/v2/assets:search, and renaming the API-key variable
buys nothing. A new Open Cloud key with Creator Store scope would be needed.
You do not need one. The endpoint Studio's own toolbox uses takes no auth at all:
curl "https://apis.roblox.com/toolbox-service/v1/marketplace/10?limit=20&keyword=vfx%20pack&sortType=3"
curl "https://apis.roblox.com/toolbox-service/v1/items/details?assetIds=ID1,ID2"
marketplace/10 returns asset IDs (10 = Model) with a nextPageCursor;
items/details returns name, creator, createdUtc, categoryPath,
fiatProduct.isFree and capabilities.shouldSandbox. That is everything
search_assets would have given you.
The real blocker is insertion, not discovery. insert_asset and
InsertService:LoadAsset both fail on an unowned Creator Store asset with
"User is not authorized to access Asset" — and this is true even when
isFree: true and purchasable: true. Free does not mean loadable. The asset
has to be taken into your account first, through the Creator Store or the
Studio toolbox, by you, by hand. No script can do that step for you.
Measured across 33 free VFX packs: exactly one loaded without being acquired —
228304820, the Roblox-owned one, the only result in the whole sweep with
shouldSandbox: false. Sort candidates by shouldSandbox and expect
everything true to need acquiring. So the honest first step of a real
harvest is: take the packs into your account, then harvest.
The run
-
Start the sink. It is the real output path — chunked printing works for three recipes and falls over for two hundred, and a truncated recipe looks exactly like a valid one.
python scripts/recipe_server.py # lands in <ROBLOX_VFX_LIB>/harvestedIt lands harvests in
harvested/, not your curatedrecipes/folder, on purpose. A sink pointed atrecipes/makes thirty unlooked-at recipes indistinguishable from the ones you chose. Promote by hand. -
Assemble the payload.
python scripts/payload.py harvest --root Workspace.SomePack --out work/h.lua -
Run it via
loadstring, not by pasting.loadstringis available in plugin context (measured), and the sink serveswork/*.luaover localhost, so payload size stops being a constraint entirely:local src = game:GetService("HttpService"):GetAsync("http://127.0.0.1:48752/payload/h.lua") local fn, err = loadstring(src) if not fn then print("COMPILE_ERROR " .. tostring(err)) return end return fn()Expect
HARVEST_POSTED recipes=N len=M. If the sink is down it falls back to chunked printing and says so, so a forgotten sink is a slow path, not a silent truncation. -
Verify, then curate, then promote.
python scripts/verify_vfx.py --lib "<your library>/harvested" --quiet -
Move the keepers from
harvested/intorecipes/, runclassify_lib.py --lib <your library>, thenemit_luau.py --lib <your library>/recipes(orcurate.pyfirst, for a game-sized subset).
Harvest at the container level, never the individual emitter. A real effect is three or four layers (a fast bright core, a slower body, high-drag sparks, a brief light). Index single emitters and you get a bag of parts with no memory of what they belonged to.
What the harvester infers, and why you must look
A pack records no emit metadata. It ships emitters Enabled = false with a
live Rate, which means "turn me on for a moment" — a one-shot in everything
but name, with the window nowhere in the data. So harvest.lua guesses:
count = clamp(round(Rate * 0.1), 1, 250), and marks the layer
"inferred": true with burst_window and harvested_rate both kept so nothing
is silently lost. The verifier warns on every inferred layer.
Treat that warning as the curation queue. On 228304820, Rate = 1000 became
count = 100; whether the artist meant 40 or 400 is not recoverable from the
asset, only from looking at it.
Pack shapes that break grouping
containerOf walks to the nearest Model or BasePart. Measured and predicted
failures:
| Pack shape | What goes wrong | What to do |
|---|---|---|
Emitters parented straight to a Model (MEASURED on 228304820) | grouping is right, offsets all read [0,0,0], nothing renders in place | correct, not a bug. parent_class + renders_in_place are recorded; the rebuild puts them on a BasePart and they come back exactly |
| Ten unrelated effects under one Model | all collapse into one giant recipe | group by the nearest ancestor that has a sibling, or split on Attachment when a Part carries several unrelated ones |
| Emitters parented straight to a Part | offsets all read [0,0,0] | correct: a Part-parented emitter has no attachment offset |
| Nested Models (effect inside a prop inside a kit) | recipe named after the wrong ancestor | name from the outermost Model whose descendants are ALL vfx classes |
Only the first row has been seen for real. Do not theorise about which one you
hit. Dump the tree and look (get_descendants with a classFilter, or a Luau
walk printing ClassName/Name/Parent.ClassName). The input is fully visible.
Beam and Trail need structure, not values. Attachment0/Attachment1 are
instance refs, recorded as {"Ref": "<full name>"}. Rebuilding means creating
two attachments on the target and pointing the Beam at them, so a Beam recipe
needs an endpoints block naming the two offsets. That block does not exist yet
because no Beam has been harvested. Add it when the first one arrives, do not
design it in advance.
Decide texture provenance at ingest, and expect most packs to fail it. Good
packs ship their own uploaded textures, so most harvested recipes will come back
texture_portable: false. That is fine for use in your own game and it is the
constraint on giving the library away. Sort by it early so the portable ones are
findable. Note that texture_portable: true means only "every ref is an
rbxasset:// path" — it does not mean the paths resolve. 228304820
harvested as portable and one of its two textures is dead.
Curation is the part that is NOT mechanical. Harvesting thirty recipes yields thirty recipes, not thirty good ones. Nothing here judges taste. Capture a preview per recipe, look at them, and delete the dross before indexing.
If free-pack quality does not clear the bar, the skill does not die. It degrades to authoring and applying recipes by hand in the recipe format below. Harvesting is the accelerator, not the foundation.
When the MCP is connected but every call times out
Costs an hour if you have not seen it. /health says pluginConnected: true
and every tool call returns "Studio plugin connection timeout".
Applies to the third-party robloxstudio-mcp server: two copies are running. The second one does not enter
proxy mode as intended: listenWithRetry(app, host, 48741, 5) succeeds on
48742, so it believes it is primary and never promotes. Meanwhile the Studio
plugin only ever polls 48741. Your tools talk to one server; the plugin
talks to the other, and neither reports anything wrong.
curl -s localhost:48741/health; curl -s localhost:48742/health
Two "status":"ok" replies with only one showing pluginConnected: true is the
signature. Then:
-
Kill the server holding the port the plugin is on — the one your tools are NOT attached to. Killing yours ends the session's MCP.
-
A raw TCP forwarder from 48741 to your port is enough, because the plugin reconnects to whatever answers on 48741.
-
The plugin will still show as disconnected.
GET /polldoes not register an instance — onlyPOST /readydoes, and the plugin sends that once at startup. Register it yourself, using theinstanceIdits polls carry:curl -X POST http://localhost:<yourport>/ready \ -H "Content-Type: application/json" \ -d '{"instanceId":"<from the /poll query string>","role":"edit"}'
An ESTABLISHED TCP connection and a 200 on /poll both look healthy while the
bridge is doing nothing. pluginConnected in /health is the only signal that
means anything.
Previewing a harvested recipe
The loop for judging one recipe out of a library. Everything here is measured; the traps referenced are the numbered ones above.
-
Sink up, payload assembled. The sink also serves payloads over localhost.
python scripts/recipe_server.py --out "<lib>/harvested"python scripts/payload.py build --recipe "<lib>/recipes/X.json" --target "Workspace.__VFXTarget" --out work/b.lua -
Stage a target RELATIVE TO THE CAMERA. Setting
CurrentCamera.CFramein edit mode does not stick (trap 14). Read the camera, then place the part atcf.Position + cf.LookVector * d. The target must be aBasePart(trap 3). Put a mid-grey backdrop behind it but keep some sky or ground in frame, or the capture fails on the solid-colour bug (trap 5). -
Run it via
loadstring, not by pasting:local fn = loadstring(game:GetService("HttpService"):GetAsync("http://127.0.0.1:48752/payload/b.lua")) return fn() -
Emit, then phase-freeze, then capture. Use the emit count the recipe carries, not a number you invented:
for _, e in ipairs(emitters) do e.TimeScale = 1; e:Clear() end for _, e in ipairs(emitters) do e:Emit(e:GetAttribute("EmitCount") or 20) end task.wait(0.22) -- the phase you want for _, e in ipairs(emitters) do e.TimeScale = 0 endSample early / mid / late. Maximise and foreground the Studio window first (trap 5) —
IsIconic == falseis not enough.
What you are looking for, beyond "does it look nice":
- Is the emit count sane? Almost every harvested recipe is
emit-inferred: the pack recorded no burst window, so the count is a guess fromRate * 0.1withharvested_ratekept beside it. A faithful recipe with an invented count can be five times too dense and still pass every check. This is the single most likely reason a harvested effect looks wrong, and only your eye finds it. - Does it read against BOTH a light and a dark surface? (trap 8)
- Does it stop? A burst layer with
Rate > 0streams forever (trap 7).
Fix by editing the recipe JSON and rebuilding — the JSON is the source of truth,
and emit_luau.py reads the same files, so what you tuned and what ships cannot
disagree.
Finding anything again: classify, then search
A harvested library is unusable without this, and that is not a small caveat.
Names come from whatever the pack author typed. Measured on a real 1813-recipe
library, only 29% of names say anything at all — the rest are Part, 78, or
a texture id. The harvest was complete, verified and impossible to search, which
is the same as not having it.
python scripts/classify_lib.py --lib "~/Roblox-VFX-Library" # -> index.json
python scripts/find_vfx.py --lib "~/Roblox-VFX-Library" ice ring --layered
Three signals, because no one of them is enough:
| Signal | Coverage | What only it can answer |
|---|---|---|
| Values — motion and timing from the harvested props | 100% | one-shot vs continuous, expanding, high-drag, glowing, colour |
| Sprite — shape from the texture's pixels | 97% | ring / streak / star / soft — the ONLY signal for texture swatches |
| Name — what the artist called it | 29% | RasenShuriken. Not derivable from pixels or numbers, and it outranks both |
Together: 98%.
Why values alone fail. A texture-swatch entry is deliberately neutral — white, speed 0, spread 0, size 1 — so the pack can show off the image. 1552 of 1813 recipes classified identically on values. Everything that distinguishes them is in the pixels.
Why the sprite pass needs the flipbook layout. Most particle textures are
sprite sheets, not sprites — 1481 of 2180 layers here are Grid4x4. Measure
the whole image and you measure the geometry of the tiling: a 4x4 grid of rings
reads as dense, round and not hollow no matter what the frames hold. Crop to one
frame using the layout the recipe declares, and pick the frame with the most ink,
because frame 0 of a OneShot flipbook is usually near-empty.
Audit the labels before you trust them.
python scripts/classify_lib.py --lib DIR --sheet ring
That writes a contact sheet with each sprite's assigned tags printed under it. Look at it. The first version of this classifier scored well and was reading tiling geometry; the sheet is what showed it. A classifier nobody eyeballed is a classifier that says yes.
Tags are honest about their own confidence: is:explosion came from a name,
maybe:shockwave is an inference from sprite shape, and emit-inferred means the
burst count was a guess. Search weights them in that order.
Using it in a game
local VFXKit = require(game.ReplicatedStorage.VFXKit)
-- "MyAura" and "MyImpact" are recipes in YOUR emitted kit (names you chose in curate.py)
local aura = VFXKit.build("MyAura", character.HumanoidRootPart, {
tint = Color3.fromRGB(255, 120, 40), -- keeps the luminance ramp, swaps hue
})
aura.setEnabled(true)
local impact = VFXKit.build("MyImpact", character.HumanoidRootPart, {
offset = Vector3.new(0, -(humanoid.HipHeight + root.Size.Y * 0.5), 0),
})
impact.emit(1.3) -- multiplier scales particle counts and light peak
opts: scale, tint, lifetime_mul, rate_mul, offset, suffix.
handle: emit(mult), setEnabled(bool), destroy().
Build twice with the same name and it replaces rather than duplicates. Use
suffix for a deliberately independent second instance.
Tint keeps the luminance ramp and replaces only the hue. A flat colour swap destroys the bright-core-to-dark-tail arc, which is the thing that reads as fire.
Recipe format
{
"name": "MyImpact",
"duration": 1.9, // longest tail, for previews
"texture_portable": true, // decided at ingest, verified against refs
"texture_refs": ["rbxasset://textures/particles/fire_main.dds"],
"layers": [{
"name": "Fire",
"class": "ParticleEmitter", // or Beam, Trail, PointLight
"offset": [0, 0, 0], // attachment offset from the container origin
"emit": {"mode": "burst", "count": 16},
"props": { /* all 31, typed */ }
}]
}
A harvested layer carries three extra fields recording where the values came from. They are provenance, not settings, and hand-authored recipes omit them:
"parent_class": "Model", // what the emitter hung off in the source pack
"renders_in_place": false, // ...and whether it could draw there at all
"emit": {
"mode": "burst", "count": 100,
"inferred": true, // NOT recorded by the pack. A guess. Look at it.
"burst_window": 0.1, // the guess that produced count
"harvested_rate": 1000 // the original Rate, which burst mode zeroes
}
Typed values, same convention as Rojo .model.json so nothing has to be guessed
from a bare array: {"NumberSequence": [[t, value, envelope], ...]},
{"ColorSequence": [[t, [r, g, b]], ...]}, {"NumberRange": [min, max]},
{"Vector3": [x, y, z]}, {"Enum": "NormalId.Top"}.
emit.mode is one of burst (one-shot :Emit(count)), continuous
(streams at Rate), pulse (a light that decays from from over seconds),
constant (a light that stays on).
Beam and Trail Attachment0/Attachment1 are instance references, not values.
They are recorded as {"Ref": "<full name>"} and are structure to be rebuilt,
never faked as data.
Performance
Rateis per second and continuous emitters cost every frame. Prefer one-shot:Emit(n)for anything event-driven.- Set
LightInfluence = 0on anything meant to glow, or the world's lighting drags it toward grey. LightEmissionis free brightness. A realPointLightis not. A typical impact recipe uses exactly one, pulsed for about 0.3s.- Long-lived large soft particles are the expensive case: heavy overdraw. Keep aura lifetimes under about a second for this reason.