Repository navigation
Developer Guide
Requirements: Paper or Spigot 1.21.6 - 26.x, Java 21+ (servers on 26.x run Java 25; the plugin itself is built for Java 21 bytecode, so one jar covers the whole range).
The DSL is the recommended way to spawn effects from plugin code. Instead of assembling EffectOptions
by hand and calling FXEngine.play(...), you get a readable, self-documenting configuration block that
can't be broken by a "wrong" signature.
player.location.playEffect("torus") {
scale(2.0)
color(Color.PURPLE)
duration(120L)
parameter("points", 240)
}VEngine is a multi-module build. Two artifacts matter to plugin developers:
| Artifact | What's inside | When you need it |
|---|---|---|
com.utophii:vengine-api |
Stable contracts only: ParticleEffect, EffectOptions, EffectModifier, EffectHandle, the EffectProvider SPI. Zero engine internals.
|
Implementing effects for the SPI, or passing VEngine types around your API |
com.utophii:vengine-core |
The engine + DSL (playEffect, playParametric) on top of vengine-api
|
Calling the DSL or FXEngine directly |
Server owners install a single shadow jar (VEngine-<version>.jar) that bundles everything -
the module split only concerns compile-time consumers.
Then import the DSL:
import com.utophii.api.playEffectMinimal example - spawn an effect at a player's location:
val handle: EffectHandle? = player.location.playEffect("helix") {
radius(1.0)
height(3.0)
turns(3.0)
duration(80L)
}playEffect is an extension function on org.bukkit.Location.
It returns an EffectHandle or null when no such
effect is registered.
Requirement:
FXEngine.initialize(plugin)must already have run (it happens inVEngine.onEnable()).
All methods configure an effect and its common properties. The full table is below.
| Method | Type | Description | Example |
|---|---|---|---|
particle() |
Particle |
Bukkit particle type. | particle(Particle.FLAME) |
color() |
Color |
Primary color (start color for DUST_COLOR_TRANSITION). |
color(Color.fromRGB(0, 255, 160)) |
toColor() |
Color |
End color for DUST_COLOR_TRANSITION. |
toColor(Color.BLUE) |
dustSize() |
Float |
Dust particle size. | dustSize(1.4f) |
material() |
Material |
Material for BLOCK/FALLING_DUST and ITEM. |
material(Material.AMETHYST_BLOCK) |
count() |
Int |
Number of particles per point (min 1). | count(4) |
speed() |
Double |
"Extra" particle speed/parameter. | speed(0.1) |
offset(x, y, z) |
Double, Double, Double |
Anisotropic spread per axis. | offset(0.1, 0.1, 0.1) |
offset(spread) |
Double |
Isotropic spread on all axes. | offset(0.2) |
| Method | Type | Description | Example |
|---|---|---|---|
scale() |
Double |
Geometric scale. | scale(2.0) |
rotationYaw() |
Double |
Rotation around the vertical (Y) axis, radians. | rotationYaw(PI / 3) |
rotationPitch() |
Double |
Tilt around the X axis - leans the effect forward/backward, radians. | rotationPitch(PI / 4) |
rotationRoll() |
Double |
Tilt around the Z axis - leans the effect sideways, radians. | rotationRoll(0.3) |
orientation() |
yaw, pitch, roll |
Sets all three euler angles at once. | orientation(pitch = PI / 2) |
tiltAxis() |
Vector |
Arbitrary tilt axis (advanced, applied after the euler angles). | tiltAxis(Vector(1.0, 0.0, 0.0)) |
tiltAngle() |
Double |
Tilt angle around tiltAxis, radians. |
tiltAngle(0.4) |
Orientation notes:
-
Euler order is roll → pitch → yaw: the effect tilts in its own local frame first, then spins around the world Y axis. Angles are additive for scripted layers (layer angle + runtime angle).
-
Common radian values:
30° = 0.524,45° = 0.785,90° = PI / 2,180° = PI. In Kotlin you can writeMath.toRadians(45.0). -
All angles are supported in scripted YAML too (
rotationYaw,rotationPitch,rotationRollon a layer) and can be animated over time withanimationtracks targetingrotationYaw/rotationPitch/rotationRoll:layers: - effect: beam particle: END_ROD rotationPitch: 1.5708 # a vertical beam becomes a horizontal beam animation: tracks: - target: rotationRoll # ...which then slowly rolls around keyframes: - {tick: 0, value: 0} - {tick: 100, value: 3.1416}
-
Vanilla particles themselves are camera-facing sprites and cannot be rotated individually - VEngine rotates the arrangement of points that make up the effect, never the sprite.
| Method | Type | Description | Example |
|---|---|---|---|
duration() |
Long |
Duration in ticks (20 t = 1 s). | duration(120L) |
receivers(vararg) |
Player... |
Restrict to an explicit audience. Empty = everyone nearby. | receivers(player, staff) |
receivers(list) |
Iterable<Player> |
Same, from a collection. | receivers(onlinePlayers) |
modifiers { } |
lambda | Nested modifier DSL. | modifiers { vortex() } |
parameter() |
String, Double/Int |
Effect-specific numeric parameter. | parameter("majorRadius", 1.4) |
parameters() |
Map<String, Double> |
Several parameters at once. | parameters(mapOf(...)) |
radius() |
Double |
Shortcut for parameter("radius", value). |
radius(1.5) |
points() |
Int |
Shortcut for parameter("points", value). |
points(240) |
The default duration is 1 tick (EffectOptions.DEFAULT_DURATION_TICKS). Always set it explicitly
if you want a live, animated effect.
VEngine is an engine for creating effects, not a "catalog of ready-made ones". Everything is built on atoms: a small set of building blocks from which you compose effects (YAML layers, formulas, modifiers, DSL). Most atoms are formulas (see Parametric Effects); only three irreducible ones remain as code.
| Name | Parameters | Why code |
|---|---|---|
sphere |
radius (1.5), points (160), angularSpeed (0.04) |
Uniform Fibonacci sphere distribution (not a θ/φ grid). |
rk4_trajectory |
steps, timeStep, initialX/Y/Z, initialVelocityX/Y/Z, gravityX/Y/Z, drag, curlStrength, curlFrequency, curlOctaves, curlEpsilon
|
Numerical ODE integration. |
lorenz |
sigma (10), rho (28), beta (8/3), dt, steps, angularSpeed
|
Numerical attractor integration. |
Atoms defined by formulas. Their parameters are the formula variables (via parameter/parameters).
| Name | Key parameters |
|---|---|
helix |
radius (1.0), height (3.0), turns (3.0) |
torus |
majorRadius (1.4), minorRadius (0.45) |
lissajous |
amplitudeX (1.6), amplitudeZ (1.6), a (3.0), b (2.0), delta (π/2) |
rosette |
majorRadius (1.0), minorRadius (0.25), distance (0.9) |
torus_knot |
majorRadius (1.6), minorRadius (0.6), p (2.0), q (3.0) |
beam |
length (5.0), height (1.2), waveSpeed (0.2), waveAmplitude (0.08) |
spline_beam |
length (6.0), arcHeight (1.4), waveAmplitude (0.12), waveFrequency (3.0), waveSpeed (0.25) |
supershape |
radius (1.6), m1/a1/b1/n11/n12/n13, m2/a2/b2/n21/n22/n23
|
Common to all atoms:
points/samples(density) andangularSpeed(animation) are overridden on the fly and can be set in anyplayEffect/DSL/YAML layer. For example,parameter("points", 96)increases density.
modifiers { } is a nested DSL that collects a chain of particle-position post-processing. The chain
is applied in declaration order.
player.location.playEffect("torus_knot") {
scale(1.5)
duration(200L)
modifiers {
vortex(strength = 0.4) // hydrodynamic vortex
rotation(angularVelocity = 0.08) // rotation around an axis
turbulence(strength = 0.18) // fBm turbulence
color(Color.RED, Color.BLUE) // time-based color pulse
}
}| Method | Positional args | Default | Description |
|---|---|---|---|
turbulence() |
strength, frequency, octaves |
0.12, 0.35, 4 |
Deterministic fBm displacement. |
rotation() |
axis, angularVelocity, initialAngle, pivotOffset |
Y-axis, 0.05, 0.0, 0.0
|
Rotation around an axis through center + pivotOffset. |
vortex() |
axis, coreRadius, strength |
Y-axis, 1.2, 0.15
|
Vortex rotation with Gaussian falloff. |
color() |
from, to, periodTicks |
-, -, 40.0
|
Smooth color shift over time (doesn't affect positions). |
motion() |
mode, path { } |
HOLD |
Moves the whole effect along a waypoint path (see below). |
motionTo() |
x, y, z, ticks, easing, mode |
HOLD, sine easing |
Single-hop glide to a spawn-relative offset. |
add() |
EffectModifier |
- | Add an arbitrary/custom modifier. |
Defaults come from the modifiers' public constants (TurbulenceModifier.DEFAULT_STRENGTH etc.), so
vortex() with no args === VortexModifier().
MotionModifier translates all particles of an effect over time - the shape itself keeps
animating while the whole effect glides through the world. Waypoint offsets are relative to the
spawn point: at time 0 the displacement is (0, 0, 0), so the effect always starts exactly where
you spawned it.
player.location.playEffect("helix") {
radius(0.8)
duration(200L)
modifiers {
motion(mode = MotionModifier.Mode.HOLD) { // how the path ends: HOLD | LOOP | PING_PONG
to(x = 10.0, y = 0.0, z = 0.0, ticks = 80L) // glide +10 blocks east
to(x = 10.0, y = 5.0, z = 0.0, ticks = 40L, easing = EasingType.EASE_OUT_CUBIC)
}
}
}
// single-hop convenience
loc.playEffect("beam") {
duration(60L)
modifiers { motionTo(x = 0.0, y = 8.0, z = 0.0, ticks = 40L) }
}To move an effect to absolute coordinates, use moveTo on EffectConfig - it is resolved
against the spawn location, so the effect's center lands exactly on the target:
val spawn = player.location
spawn.playEffect("torus_knot") {
duration(160L)
moveTo(spawn.clone().add(20.0, 3.0, 0.0), ticks = 100L) // glide to an exact coordinate
moveTo(spawn.clone().add(20.0, 8.0, 0.0), ticks = 40L) // ...then rise another 5 blocks
}The same is available in scripted YAML effects as a layer modifier:
modifiers:
- type: motion
mode: hold # hold | loop | ping_pong
easing: ease_in_out_sine # default for legs without their own easing
path:
- {x: 10, y: 0, z: 0, ticks: 40}
- {x: 10, y: 5, z: 0, ticks: 20, easing: ease_out_cubic}Notes:
- Displacement is recomputed every frame, so view-distance culling follows the moving effect.
-
mode: loopjumps back to the spawn point each cycle (handy for patrol-like paths);ping_pongtravels the path back and forth smoothly. - Because motion is an
EffectModifier, it composes with every primitive, formula atom and scripted layer, and stacks withrotation,vortex, etc.
val myModifier = EffectModifier { loc, _ ->
loc.clone().add(0.0, 1.0, 0.0) // raises the particle by one block
}
player.location.playEffect("torus") {
modifiers {
add(myModifier)
}
}import com.utophii.api.EffectHandle
import com.utophii.api.playEffect
import org.bukkit.Color
import org.bukkit.Material
import org.bukkit.Particle
import org.bukkit.entity.Player
import org.bukkit.util.Vector
fun spawnPartyEffect(player: Player): EffectHandle? =
player.location.playEffect("spline_beam") {
// material and rendering
particle(Particle.DUST_COLOR_TRANSITION)
color(Color.fromRGB(120, 80, 255))
toColor(Color.fromRGB(0, 255, 200))
dustSize(1.2f)
// geometry
scale(1.4)
rotationYaw(player.location.yaw * PI / 180.0)
tiltAxis(Vector(0.0, 0.0, 1.0))
tiltAngle(0.15)
offset(0.05, 0.05, 0.05)
// time and audience
duration(100L)
receivers(player)
// modifiers
modifiers {
vortex(axis = Vector(0.0, 1.0, 0.0), coreRadius = 1.5, strength = 0.3)
rotation(angularVelocity = 0.1)
}
// effect-specific parameters
parameter("length", 8.0)
parameter("arcHeight", 2.0)
points(160)
}| Common property | What it changes |
|---|---|
offset(x, y, z) |
Random spread of particles around a point → effect "thickness". |
scale |
Size of the whole shape. |
rotationYaw / tiltAxis+tiltAngle
|
Shape orientation in space (full 3D rotation). |
count |
Particle density at each position. |
speed |
Bukkit particle speed parameter. |
playEffect returns an EffectHandle? that lets you stop the effect early:
val handle = player.location.playEffect("lorenz") {
duration(600L)
}
// cancel the effect (e.g. on player quit)
handle?.let { it.cancel() }
// check its state
if (handle?.isCancelled == true) { /* ... */ }It's handy to keep a handle in a UUID -> EffectHandle map and cancel on PlayerQuitEvent:
val handles = mutableMapOf<java.util.UUID, com.utophii.api.EffectHandle>()
handles[player.uniqueId] = player.location.playEffect("torus") {
duration(200L)
}
// listener
playerQuitEvent.player.uniqueId?.let { handles.remove(it)?.cancel() }For global control from the plugin, FXEngine methods are available:
FXEngine.stopAll() // stop everything, return the count
FXEngine.stop(handle.id) // stop by id
FXEngine.activeHandles() // list of active effectshandle.id and handle.effectName are available anywhere.
Effect doesn't appear - what to check?
- The effect name is registered:
FXEngine.effectNames()(includes both primitives and.ymlscripts). -
duration > 0- default is1tick. - The player is within the
view-distancefromconfig.yml(default 64 blocks). Otherwise the particle is culled and never sent to the player. -
particlesupports color/material (forDUST/DUST_COLOR_TRANSITION/BLOCK/ITEMthe matching parameter is required).
Why doesn't particle(Particle.DUST) show my color?
For DUST the color comes from color. For DUST_COLOR_TRANSITION from color and toColor. For
BLOCK/FALLING_DUST from material(block).
Radians or degrees?
All angles are in radians. Remember: PI comes from kotlin.math.
How do I run a scripted (YAML) effect? The same way - by its registry name. Scripted effects override primitives with the same name.
Another plugin calls FXEngine.play(...). Does the DSL replace this?
Yes, but the old API remains. playEffect internally just calls
FXEngine.play(name, this, EffectConfig().apply(config).build()).
- Pattern: extension function +
@DslMarkerannotation (@VEngineDsl). -
Location.playEffectcreates anEffectConfig, applies the config block, buildsEffectOptionsviabuild()and hands it toFXEngine.play(...). -
EffectConfigwrapsEffectOptions.builder(); all its methods delegate to the builder. -
ModifierConfigcollects aList<EffectModifier>in call order. -
@VEngineDslforbids referencing an outer DSL receiver from a nested block - preventing accidental "invisible" calls to the parent config's methods.
Module: com.utophii.api.PlayEffectDsl. Entry point: com.utophii.api.playEffect.
The public engine API is not "ready-made effects" but the ability to add your own:
-
engine/- registry, scheduler, lifecycle (FXEngine,EffectScheduler,EffectHandle). -
api/- contracts (ParticleEffect,EffectModifier), DSL (playEffect,playParametric) and SPI (EffectProvider). -
math/- expressions (formulas), modifiers, core config.
Implement EffectProvider and register it in onEnable:
import com.utophii.api.EffectProvider
import com.utophii.api.ParticleEffect
import com.utophii.engine.FXEngine
// any class implementing ParticleEffect (your own effect or your own formula)
class MyEffect : ParticleEffect {
override val name = "my_beam"
override fun play(center: Location, opts: EffectOptions): EffectHandle = // ...
override fun calculate(center: Location, opts: EffectOptions, time: Double): List<Location> = // ...
}
override fun onEnable() {
FXEngine.registerProvider(EffectProvider { listOf(MyEffect()) })
}The registered effect becomes a primitive atom: available via /ve spawn, getEffect(name), the
DSL playEffect("my_beam"), and it can be used as a base layer in YAML scripts.
Key FXEngine methods:
-
registerProvider(provider)- register an external effect provider (SPI). -
registerEffect(effect)- register a single effect manually. -
primitiveEffect(name)/effect(name)- look up an effect/atom. -
stopAll()/stop(id)/activeHandles()- manage running effects.
"Engine vs content": the built-in atoms are examples (in
resources/effects/), and the real product is the ability to create effects (formulas, layers, modifiers, and via SPI). Soeffects/isn't a "catalog" but included building blocks that can be replaced or extended.