Cast a fireball and the ground it lands on is the ground it damages. Cast a cone and the fire burns a cone, with the embers scattered through the wedge and the rune written into it rather than a neat circle laid over the top. Cast the same spell as a dwarf and as a lizardman and the colour, the runes and the character of the particles are all different.
None of that was true in July. The complaint was one sentence: the spells were recolours of each other. Six hundred of them, and the difference between a paladin's consecration and a warlock's plague cloud was a shift in hue.

The engine under the old effects was the other half of the problem, and the distinction it turns on is the one to understand before anything else here. A particle system draws an effect as a swarm of small sprites: embers, sparks, smoke, motes of light. The question is who moves them. In a CPU particle system each one is an object the processor updates every frame, which is flexible and puts a hard ceiling on how many you can afford. In a GPU particle system the processor never touches them: each particle's whole life is computed from its index and the clock inside a shader, so the entire swarm is one instruction to the graphics card and the cost of ten thousand is barely the cost of ten.
The old effects were the first kind. The replacement draws a whole emitter in one call, with every particle's motion computed on the GPU. Measured on the same cast, drawn into four side-by-side viewports with the renderer's counters reset between them, the difference is 22 draw calls and 61,000 triangles at the high quality setting against 26 draw calls and 1,100 triangles at low. Roughly sixty times the geometry, for fewer draws.
That code is not mine. It is a port, and the first thing this post owes is the attribution.
Where the code came from, and under what licence
The effects are adapted from LinearAbiltyCastingThreeJS, at
https://github.com/achrefelouafi/LinearAbiltyCastingThreeJS, by
mohamedachrefelouafi, used under the MIT licence. The full terms and the
copyright line are reproduced in this project's third-party register, the one
document that carries every borrowed licence in full. A NOTICE file or a
licences/ directory serves the same purpose.
What came across: the procedural noise and shading shaders, the GPU particle system, the vertex-shader lightning bolt, the beam, snare, ice, glacier and meteor materials, the procedural geometry builders, and the pooled burst, decal and light services. Twenty files carry a one-line header naming the repository, the licence and the register, which is the convention here for anything adapted and is worth adopting whatever your register looks like. A search for the repository name has to find every file that owes it something, or the next time I refactor one of them I will not know I am editing borrowed code.
A second repository by the same author, AvatarCastingAbilitiesThreeJS, was
surveyed and nothing was ported from it. It is recorded anyway, with the
reasoning, so I do not clone it again hoping for a cast windup that is not
there.
Fifteen of the sixteen files it shares with the newer project are byte-identical
to that project's own archive, and the one genuinely new file looks up poses by
Mixamo bone names, while these rigs come from a different tool, so it would find
no bones and mark itself invalid.
Three systematic changes, baked in at port time
Three differences from upstream are recorded, and any future work on the ported effects has to keep them.
The Z-up remap. Upstream is Y-up, the three.js default, with the ground on the XZ plane, gravity pulling along negative Y, and every shader that bows or lofts a shape doing so on the Y component. This scene is Z-up, with height on Z. The remap is written into the ported source rather than wrapped at runtime, so there is no adapter and no per-frame cost. The substitution table is short enough to reproduce whole:
| Upstream, Y-up | Ported, Z-up |
|---|---|
axis.y += sag | axis.z += sag |
vec3(0.0, 1.0, 0.0) | vec3(0.0, 0.0, 1.0) |
uGravity = (0, -4.5, 0) | (0, 0, -4.5) |
rotate about Y: x*c - z*s, y, x*s + z*c | rotate about Z: x*c - y*s, x*s + y*c, z |
origin.set(x, 0, z), XZ ground | origin.set(x, y, 0), XY ground |
When a ported effect comes out lying on its side or firing into the ground, that table is the first thing to read.
Ink banding. Upstream's shaders end in a smooth four-stop gradient, which reads as glossy and volumetric. These spells have to read as comic ink: flat regions of colour with a dark contour kept, and silhouettes eaten away by noise rather than feathered. So every ported shader swaps its final gradient for a five-band quantiser, which lives in one shared piece of shader code that all of them include rather than each carrying a copy. Transparency is left alone. Colour steps hard, intensity stays smooth, and that combination is what lets a glowing effect still read as a drawing.
The five bands run ash, ink, edge, body, core, with hard cuts at 0.12, 0.29, 0.52 and 0.78. They are deliberately hard steps rather than smooth ones, because the flat regions are the look. Those cut points are the same ones the existing outline shader uses on units, and a test asserts the two still agree. If they drift, a nova and the particles thrown by that same nova band at different brightnesses and the effect visibly comes apart.
Units. Upstream works in metres. This scene is measured in world pixels at 32 per tile, so one tile is the natural stand-in for one upstream metre, and every upstream tuning number is multiplied by that as it is lifted across. It is why a wall of fire authored to cross ground at 3 m/s still crosses ground at a sane rate.
A checklist for porting a three.js effect into different conventions
Those three are not specific to spells, to this game or to that repository. They are the three ways any borrowed 3D effect can be wrong in a new scene, and they fail in three distinguishable ways.
One. Decide wrap or rewrite, once, before touching anything. A rotation around the whole effect is one line, and costs a matrix multiply every frame forever plus a permanent translation layer between what the upstream author wrote and what runs. Rewriting the axes at port time costs a morning and leaves source you can still read against upstream. Rewriting is right when the effect is fully procedural, because there is no art to re-author. Wrapping is right when you expect to keep pulling upstream changes. Either way, get the size of the job first by counting the places that mention the up axis, the up vector, rotations about it and gravity. That count is the estimate.
Two. Units are a separate remap, applied as each number is lifted. One constant, one helper, and every borrowed number passes through it, so nothing downstream ever has to remember which system a value is in. The conversion at the boundary between game coordinates and scene coordinates wants to exist exactly once for the same reason:
// Upstream works in metres. This scene is measured in world pixels at 32 per
// tile, so one tile stands in for one metre.
const UNITS_PER_METRE = 32;
const metres = (v: number): number => v * UNITS_PER_METRE;
const FRONT_SPEED = metres(3); // upstream: 3 m/s
const BURST_RADIUS = metres(1.5); // upstream: 1.5 m
// Game world (+Y down the screen) to scene coordinates (Z is up). Every
// emitter, decal and light goes through this one function. The bug it prevents
// is two call sites disagreeing about the sign of Y, which looks like an effect
// that is correct on one side of the caster and mirrored on the other.
const worldToScene = (x: number, y: number, z: number, out: THREE.Vector3) =>
out.set(x, -y, z);Three. The look is a third remap, and it is the one most often skipped. Borrowed effects carry their source project's grading, usually a smooth gradient at the end of the shader. If your project has a different treatment, swap that one call and leave the transparency alone, because transparency is the shape and colour is the style. Put the swap in a shared include so every effect bands identically, then pin it with a test.
Four. Pin every seam where two things have to agree. Anything the port shares with code that was already there is a place they can drift silently. Here it is the band cut points.
The diagnosis is the useful part of the list, because the three failures look nothing alike. Lying on its side, firing into the ground, or bowing along the wrong axis is the axis remap. Correct in shape but far too fast, too slow, too big or invisibly small is the units remap. Reads like it came from a different game is the look. Only the first is obvious in a screenshot, which is why the second and third are the ones that ship.

The number that was lying about itself
The most interesting rule in the whole port is a rule about what a number is allowed to mean.
Every spell carries an authored size, which is presentation weight: how big and heavy the cast should feel. Every area spell also carries the radius of ground it actually damages. The obvious implementation drives the visual footprint from the authored size, since it is right there and it is called size.
Those two numbers correlate at 0.24. The method is two columns and one correlation: pull both values for every spell that has them, and correlate. That is worth running against any pair where one number is authored for feel and the other decides an outcome, because the answer is usually not the one the naming implies. The 0.24 was measured across the 176 area spells that existed when the rule was written. The game now ships 618 spells, 221 of which damage an area, and re-running the same two columns in August gave 0.19. It has not improved, and there is no reason it would, because the two numbers were never answering the same question.
So the drawn radius comes from the damaged radius and nothing else, and every part of the footprint scales off that: the scorch on the ground, the rune ring, the scatter of particles, the shell. The authored size is still read, but only as weight, so a hero's ultimate detonates brighter and heavier than the small spell that set it up without either of them lying about which tiles took the damage.
The same discipline runs through shapes. Given a cone, a line or an arc, every part of the effect reaches into it: particles scatter through the shape, the round parts move to its centre of area rather than sitting on the point under the caster's feet, circular scorch marks give way to a front that follows the real outline, and the runes write themselves into it. Passing the shape to only one of those is how a spell ends up burning a correct wedge with a perfect circle of embers over the top, which is two effects arguing about what the spell is.
What was deliberately left behind
Upstream's settings module, its editor interface, and its world, input, animation, loader and archive code were not ported, because equivalents already existed here. No upstream art was used at all: no character model, no environment map, no floor textures. The effects are fully procedural, which is the property that made the port viable in the first place.
The bug the port introduced
Whether the new effects run at all is decided per cast, because automatic quality control moves the quality setting while you play. The old effects were wired to the same events and gated on nothing. So one cast fired the old glyph burst and the full new cast, and one impact fired both versions of the nova, at the same point on the same frame.
It never looked like a duplicate. Two effects landing together read as one effect that is too bright and too busy, which is exactly the note a spell gets when it needs tuning, so the obvious response is to turn the effect down rather than to go looking for a second one. It survived a day, and in that window every judgement made about a spell at the high or ultra setting was a judgement of two overlaid effects. The guard is now checked per event rather than cached, for the same reason the decision is made per cast.
Where you can look at it
The spell lab boots the real game with a real caster and an invincible dummy rather than a mock, which is covered properly in testing spells without playing a match. The tier bench casts one spell at all four quality settings at once with identical framing and identical random seeds, and measures draw calls per pane. Every ported effect takes an explicit seed, and two casts with the same spell and seed produce identical output, which is the property that lets the bench attribute a visible difference to the quality setting rather than to the dice.
The draw call numbers at the top of this post came off that bench, and they make the same argument as measuring what actually costs frame time: the high setting draws sixty times the geometry for fewer submissions, and it is the submissions that hurt. The banding this port has to match is the subject of giving every unit a readable outline.
The two lower quality settings reproduce their pre-port behaviour exactly, frozen by a snapshot test, because they are what the game falls back to under load, and a spell that changes appearance when the frame rate dips is worse than a plain one. Closing that gap, so the cheap settings carry the shape and the faction identity without the particle count, is the next piece of work here.





