Skip to content

Java API

The public API for other mods interacting with vfxweaver. Backward compatibility matters, so signatures are only broken for a good reason and with a version bump.

dev.vfxweaver.api.VFXAPI

A stateless class (all methods static), the entry point for other mods.

Server → client (over the network)

// Resolves the datapack/built-in effectId, merges its default constant params with overrides,
// takes the duration/easing from the definition (if not given explicitly), and sends a
// VFXTriggerPayload to the player. Returns false if effectId is unknown.
boolean sendEffect(ServerPlayer player, Identifier effectId, Map<String, Float> overrides, @Nullable EasingType easing);

// Same, but with an explicit world position: the client immediately re-anchors spatial
// bindings (screen_x/screen_y/proximity) to that point and uses it for the effect's positions —
// no pos_x/pos_y/pos_z hack.
boolean sendEffect(ServerPlayer player, Identifier effectId, Vec3 worldPos, Map<String, Float> overrides, @Nullable EasingType easing);

// Same with an explicit instance id (0 = the client assigns one). Lets you later stop exactly
// this instance via sendStop(player, effectId, instanceId) instead of every instance of the effect.
// The id is a unique handle: playing it again restarts that instance in place instead of adding a
// duplicate, so sendStop/sendMove always address the instance you played.
boolean sendEffect(ServerPlayer player, Identifier effectId, long instanceId, @Nullable Vec3 worldPos, Map<String, Float> overrides, @Nullable EasingType easing);

// Explicit variant without consulting the definition registry — all packet fields are set manually.
void sendEffect(ServerPlayer player, Identifier effectId, int durationTicks, Map<String, Float> params, EasingType easing);

// Full variant: duration, instance id, world position, entity UUID targets for entity effects
// (entity_tint/entity_outline) or entity-anchored world overlays, parameter overrides and
// easing. entityUuids — up to 16 UUIDs; an empty list for all other effect types. For world
// overlays whose definition declares entity-anchored "positions", the UUIDs fill the anchor
// slots in anchor order (the client tracks those entities per frame).
void sendEffect(ServerPlayer player, Identifier effectId, int durationTicks, long instanceId, @Nullable Vec3 worldPos, List<UUID> entityUuids, Map<String, Float> overrides, @Nullable EasingType easing);

// Stops the effect on the player's client (all its instances).
void sendStop(ServerPlayer player, Identifier effectId);

// Stops one specific instance of an effect (see sendEffect with instanceId).
void sendStop(ServerPlayer player, Identifier effectId, long instanceId);

// Stops every effect the server has recorded for the player, reusing the per-effect stop payload
// (no new wire action). Effects the player's client played locally (never seen by the server) are
// not covered - use the client-local stopAllEffects() for those.
void sendStopAll(ServerPlayer player);

// Live-overrides a parameter of a running effect (without restarting the timeline).
// Ignored by the client with a warning in the log if the effect is not currently running.
void sendSetParam(ServerPlayer player, Identifier effectId, String param, float value);

// Like sendSetParam, but replaces the parameter with a compiled math expression (same syntax
// as the JSON "expr" field; compiled per instance with its seed). Applies to every running
// instance of the effect on the player's client.
void sendSetParamExpr(ServerPlayer player, Identifier effectId, String param, String exprSource);

// Moves a running world-overlay effect instance to a new exact world point (sub-block
// precision; the instance keeps its timeline and fades). Call every tick for scripted motion.
// Ignored with a warning when no instance with that id (of that effect) is running.
void sendMove(ServerPlayer player, Identifier effectId, long instanceId, Vec3 worldPos);

// Moves one mask leaf of a running effect to a world position, by setting the three reserved
// mask.p<N>.center_x/y/z params (see "Mask leaf params" below). Applies to every running
// instance of the effect; ignored with a client-side warning when it is not running. This is
// also the server-driven workaround when a client cannot resolve an entity binding (the entity
// is outside the client's tracking range).
void sendMaskMove(ServerPlayer player, Identifier effectId, int primitive, Vec3 position);

// Writes a mask leaf's dynamic float data (the reserved mask.p<N>.d<J> params; a GLSL-plugin leaf
// reads them live through vfx_mask_data(...)). Reuses SET_PARAM - one packet per value, no new wire
// action. Up to MAX_LEAF_DATA (32) values are sent; the rest are ignored. Call every tick to drive
// plugin geometry without recompiling the shader variant.
void sendMaskData(ServerPlayer player, Identifier effectId, int primitive, float[] values);

// Adds/replaces a keyframe of a parameter of a running effect.
// A negative timeTicks means "from here": the value the parameter has right now is pinned at the
// current time and the animation runs to `value` over |timeTicks| ticks - so animation segments
// chain without knowing the current value (ramp a blur up, later send -20/0 to fade it back out).
void sendKeyframe(ServerPlayer player, Identifier effectId, String param, int timeTicks, float value, EasingType easing);

Client (locally, without the network)

// durationTicks: 0 = definition default, negative = persistent.
// Returns false if called off-client (e.g. on a dedicated server) —
// in that case use sendEffect().
boolean playEffect(Identifier effectId, int durationTicks, Map<String, Float> params, @Nullable EasingType easing);
boolean playEffect(Identifier effectId, int durationTicks, Map<String, Float> params); // linear easing

// Like playEffect, but returns the id of the created instance (0 on error) — so you can stop
// one specific instance out of several concurrent ones via stopEffect(instanceId).
long playEffectId(Identifier effectId, int durationTicks, Map<String, Float> params, @Nullable EasingType easing);

boolean stopEffect(Identifier effectId);          // every instance of the effect
boolean stopEffect(long instanceId);              // one specific instance
boolean stopAllEffects();

Anchored local playback

A client-local play can be anchored to a world position and/or to entities, so an effect lands where the event happened instead of at the definition's default spot (e.g. dent, shockwave, vortex centred on a hit). This is the local equivalent of /vfx playat / a network sendEffect(player, id, pos, ...) — no packet is involved.

// Anchor to a world point: replaces the definition's position slots and re-anchors the spatial
// world bindings (screen_x, screen_y, proximity, ...) to that point.
boolean playEffect(Identifier effectId, int durationTicks, Vec3 position, Map<String, Float> params, @Nullable EasingType easing);
boolean playEffect(Identifier effectId, int durationTicks, Vec3 position, Map<String, Float> params); // linear easing
long playEffectId(Identifier effectId, int durationTicks, Vec3 position, Map<String, Float> params, @Nullable EasingType easing);

// Anchor to entities: when `position` is null and the definition declares entity-anchored
// positions, the UUIDs are zipped with them in declaration order and the effect follows those
// entities. Datapack `entity_selector`s resolve on the server only, so a client-side caller finds
// the entities itself and passes their UUIDs here.
boolean playEffect(Identifier effectId, int durationTicks, @Nullable Vec3 position, List<UUID> entityUuids, Map<String, Float> params, @Nullable EasingType easing);
long playEffectId(Identifier effectId, int durationTicks, @Nullable Vec3 position, List<UUID> entityUuids, Map<String, Float> params, @Nullable EasingType easing);

// Re-anchor a running instance locally (the local equivalent of the network MOVE action). Call it
// every tick to make an anchored effect follow a moving point or entity.
boolean moveEffect(Identifier effectId, long instanceId, Vec3 worldPos);

Notes: - A non-null position wins over the definition's entity anchors (same rule as the network path). - If a definition declares entity anchors and a local play passes no UUIDs, the play is rejected (the placeholder slots would otherwise render at the world origin) — logged once. - A null easing keeps its meaning on this path: the definition's default easing is used. - moveEffect returns true when the request was applied (already on the render thread) or queued; a queued request that references an unknown instance fails silently. - Client-local plays with a position are recorded into Flashback replays with the same anchor, so a replay reproduces the effect where it originally happened.

Live control (client-local)

The live edits the network exposes also work locally, so a pure client-side mod never needs a server round-trip:

// Constant override of a parameter on every running instance (starts one when none is running).
boolean setParam(Identifier effectId, String name, float value);

// Swap a parameter for a live math expression (same syntax as the JSON "expr" field).
boolean setParamExpr(Identifier effectId, String name, String exprSource);

// Add/replace a keyframe. A negative time means "from here": pin the value the parameter has now
// and run the segment to `value` over |time| ticks, so animation variations chain seamlessly.
boolean setKeyframe(Identifier effectId, String name, int time, float value, @Nullable EasingType easing);
boolean setKeyframe(Identifier effectId, String name, int time, float value, String easing); // named curve

// Move one mask leaf locally (no packet) - the local counterpart of sendMaskMove; expands into
// the three mask.p<N>.center_* params (see "Mask leaf params" below).
boolean maskMove(Identifier effectId, int primitive, Vec3 position);

// Write one mask leaf's dynamic float data locally (no packet) - the local counterpart of
// sendMaskData; expands into the mask.p<N>.d<J> params. Up to MAX_LEAF_DATA (32) values.
boolean maskData(Identifier effectId, int primitive, float[] values);

Like moveEffect, these return true when applied on the render thread or queued for it. A null (or blank) easing means linear - a keyframe segment has no "definition default".

Mask leaf params (reserved names)

A mask's numeric leaves are registered as ordinary animatable effect parameters, so the whole live-control and animation surface above works on them unchanged:

Name Meaning
mask.p<N>.center_x / .center_y / .center_z leaf <N> centre (screen leaves use x/y)
mask.p<N>.rotation leaf rotation (degrees)
mask.p<N>.p<J> the leaf's per-shape parameter J (radius, half_width, … in VFXMaskShapeKind order)
mask.p<N>.d<J> the custom leaf's dynamic float data (J = 0..31; a GLSL plugin reads them via vfx_mask_data(...))
mask.p<N>.soft edge falloff width
mask.p<N>.stroke stroke width (fill: "stroke")
mask.p<N>.field_amount / .field_scale edge-field amount / scale

<N> is the leaf index in mask declaration order (0-based; a composition flattens depth-first). Every live path works on them: sendSetParam/setParam (also constant, expr/sendSetParamExpr, sendKeyframe/setKeyframe), graph { "from": node } driven inputs, and datapack bindings. sendMaskMove/maskMove are a convenience that sets a leaf's three centre components at once:

// Server: follow a moving point (or drive an entity binding the client cannot resolve).
VFXAPI.sendMaskMove(player, effectId, 0, entity.position());

// Client-local, same expansion, no packet:
VFXAPI.maskMove(effectId, 0, new Vec3(x, y, z));

sendMaskData/maskData are the same kind of convenience for the dynamic float data (one value per reserved mask.p<N>.d<J> slot, up to VFXMaskSlots.MAX_LEAF_DATA = 32):

// Server: move a plugin-authored set of circles every tick.
VFXAPI.sendMaskData(player, effectId, 0, new float[]{cx0, cy0, r0, cx1, cy1, r1, 0.0F, 0.0F});

// Client-local, same expansion, no packet:
VFXAPI.maskData(effectId, 0, values);

mask. is reserved: a user parameter with that prefix would be shadowed by the mask, not merged.

Custom mask shapes (client-local)

A mask leaf may reference a shape registered from code instead of a built-in figure. Two kinds exist, both registered by id and both usable in a datapack mask as a custom leaf.

Composed SDF - a figure built from primitives and operations:

VFXCustomShape shape = ...;                      // composed primitives + ops
VFXAPI.registerMaskShape(Identifier.fromNamespaceAndPath("mymod", "rune"), shape);
VFXAPI.unregisterMaskShape(id);
VFXCustomShape existing = VFXAPI.maskShape(id);  // null when not registered

The composed parts are literal-only: the leaf's animatable mask.p<N>.p<J> params are not fed into the parts (they are fed to a GLSL plugin, below).

GLSL plugin - a fragment of GLSL injected into the coverage shader. The plugin defines exactly one function, in the leaf's space (world units for a world leaf), negative inside:

float vfx_shape_custom(vec3 world, vec2 uv, vec4 p0, vec4 p1);

world is the depth-reconstructed world position (zero for a screen leaf), uv the screen UV, and p0/p1 are the leaf's eight animatable params, so a plugin shape stays drivable from the timeline (keyframes, expr, graph inputs, setParam). Register it with:

VFXAPI.registerMaskShapeGlsl(Identifier.fromNamespaceAndPath("mymod", "pentagram"), plugin);
VFXAPI.unregisterMaskShapeGlsl(id);

The field must be a conservative distance. With volume: "aura" the coverage shader sphere-traces the plugin SDF, so the returned value may never over-estimate the distance to the boundary: |grad d| <= 1 everywhere. max/min compositions of correct fields stay conservative (C1 smoothness is not required), but a field perturbed by noise or a biased blend over-estimates and the march over-steps. A plugin that cannot be scaled down can declare a true upper bound on its gradient; the value is clamped to >= 1.0 and a variant of several plugins compiles against the max:

VFXAPI.registerMaskShapeGlsl(Identifier.fromNamespaceAndPath("mymod", "zone_sdf"), plugin, 1.25F);

Dynamic per-leaf data. A custom leaf also carries 32 reserved dynamic floats (mask.p<N>.d0 … d31, authored as a "data": [...] array or set live with sendMaskData/maskData/ setParam). The wrapper declares int vfx_shape_data_base and float vfx_mask_data(int index) and points the base at the calling leaf's slice, so the plugin reads its own values as vfx_mask_data(vfx_shape_data_base + j):

float vfx_shape_custom(vec3 world, vec2 uv, vec4 p0, vec4 p1) {
    // Three circles at (cx, cy, r) from mask.p<N>.d0..d8, updated every tick.
    float acc = 1.0e6;
    for (int c = 0; c < 3; c++) {
        vec2 centre = vec2(vfx_mask_data(vfx_shape_data_base + c * 3),
                          vfx_mask_data(vfx_shape_data_base + c * 3 + 1));
        float radius = vfx_mask_data(vfx_shape_data_base + c * 3 + 2);
        acc = min(acc, length(uv - centre) - radius);
    }
    return acc;
}

The plugin must not declare vfx_mask_data or vfx_shape_data_base (the wrapper supplies them), and must not name a uniform or variable after a GLSL built-in. A plugin that ignores the helper compiles and behaves exactly as before. A data update is an ordinary param edit — the shader variant is keyed only by the set of plugin ids, so it never triggers a recompile.

Aura, and the optional broad phase. A world plugin leaf may take "volume": "aura": the pixel's view ray is sphere-traced through the plugin's SDF (up to 40 entry + 16 exit + 4 refine steps, minimum step 0.5, maximum step 64, range capped at 1024) and the coverage is the volume's silhouette at the deepest point of the first chord, occluded by nearer scene geometry exactly like a built-in sphere/box aura. A plugin may declare a second, optional function to keep that cheap:

vec4 vfx_shape_custom_bounds();   // world (centre.xyz, radius); a negative radius means "no bound"

The wrapper only honours it when the source declares that exact signature, so a plugin that omits it compiles unchanged and gets the unbounded march. With a bound, a ray that misses the sphere costs zero SDF calls, and a hit starts the march at the sphere's near point. The bound may read the leaf's own values through vfx_mask_data, so it can follow moving geometry; a bound that is too small clips the aura.

Two more optional bounds, as boxes. A sphere is a poor fit for a field whose coverage region is unbounded, so a plugin may declare either of two axis-aligned boxes instead. They are optional in the same way (each is honoured only when the source declares that exact signature) and they are consulted per leaf in the order skip box → containment box → the sphere above → no bounds at all, so a plugin that declares none of the three keeps today's sphere behaviour exactly:

bool vfx_shape_custom_bounds_box(out vec3 bmin, out vec3 bmax);   // CONTAINS the coverage region
bool vfx_shape_custom_skip_bounds(out vec3 bmin, out vec3 bmax);  // lies inside a hole

The two contracts are mirrors of each other, and swapping them is the mistake that silently buys nothing:

  • The containment box must CONTAIN the whole region where the field is <= 0. Anything it leaves out is real coverage the march would no longer find. A box drawn around a hole — the inverse of this contract — is a declaration bug rather than a cull: it buys nothing at all, because the coverage region outside it is unbounded. An unbounded axis is declared with sentinels (-1e9 / +1e9), which the shader clamps to the reachable range. The cull only pays for a bounded coverage region, but the branch has a second, independent effect: it also shortens the march to the box's far exit plus the leaf's softness, so a cap-bounded exterior field (unbounded in XZ, finite top) benefits even though its cull never fires — the march ends a softness past the cap instead of crawling to the range limit. Declare it alongside the skip box; the two are complementary.
  • The skip box must lie ENTIRELY inside the region where the field is > 0 — a hole — and must be inscribed in it. The AABB of a set of circles does not qualify: its corners lie outside the circles, i.e. inside the coverage region (field <= 0), and would skip real coverage. The inscribed square of the largest circle does. This is the lever that fits an exterior (unbounded) field: the march then starts at the box's far side instead of at the camera.

Both are read through one shared slab test that runs after occDist/tLimit are known and is padded by softness / 2, so no cliff appears at a box face and a plugin never has to know softness. Either may read the leaf's own values, exactly like the sphere bound above — the dispatcher republishes vfx_mask_data and the leaf's vfx_shape_params0/vfx_shape_params1 before each call. A miss on the containment box's padded interval culls the leaf with no march at all; that box may also only ever raise the march's start (while lowering its range end, per the cap-bounded case above), so a leaf declaring both keeps the skip; and the skip advance applies only when the camera is inside the skip box (a camera outside it may have a coverage segment before the box, which the advance must not jump over) and keeps the last softness before tLimit sampled. All of that is the shader's business — the plugin only has to get the two contracts right.

A per-leaf cap: aura_steps. An optional mask-leaf field puts a ceiling on that leaf's march iterations. It is gated to a custom leaf with "volume": "aura" — a built-in sphere/box aura leaf samples one chord midpoint and a surface leaf classifies a point, so there is no march for a cap to bound — and anywhere else it is a per-file parse error naming the shape. A negative value is a per-file parse error too; a value above the compiled loop budget (40 + 16) is clamped to it, since asking for iterations the loop does not have is unreachable budget rather than an author error. 0, the default and what every leaf that does not march resolves to, means no cap: the leaf is bit-identical to today's. A cap trades silhouette for speed, and one high enough for the leaf's own worst ray is invisible — it is a last resort, not a lever. See masks.

A composed custom leaf has no raw SDF to march, so "volume": "aura" on one is a per-file parse error naming the shape (no silent fallback), and a screen-space plugin leaf is rejected the same way (a uv-space distance cannot be marched in world units). The march covers the first chord only. In aura mode a sky pixel is covered like any other; in surface mode a sky pixel contributes no coverage, so a plugin SDF that is intentionally unbounded no longer paints the sky.

Limits and failure behaviour, all of them deliberate:

  • A mask may hold at most 2 custom leaves; a third is a per-file parse error (it would alias row 0 in the packed coverage UBO).
  • A custom leaf carries at most 32 dynamic values (mask.p<N>.d0 … d31); a longer "data" array is a per-file parse error. The array is written to the coverage UBO as vec4-packed data (1024 bytes; the coverage UBO grows from 1312 to 2336 bytes), appended after every existing field.
  • The raised parameter cap (MAX_PARAMS 32 → 256) is a read-side bound only: the wire format is still a varint count plus entries and PROTOCOL_VERSION is unchanged. A 2.0.1 client still enforces its own 32-entry cap, so a play packet whose resolved map exceeds 32 params — which a mask with many data slots produces, since every reserved mask.p<N>.d<J> default is a constant param — cannot be decoded there; such a definition needs a matching 2.0.2 client.
  • The coverage shader variant is keyed by the sorted set of plugin ids, capped at 4 variants; a variant re-reads the live plugin source, so re-registering a plugin (or a resource reload) does not leave a stale program. A data value change never reaches the variant (it is a param edit). Only one distinct GLSL plugin id can be compiled into a mask: the injected source defines a single vfx_shape_custom, so two custom leaves that reference two different plugin ids make the variant fail to compile and the whole mask falls back to neutral coverage (two leaves sharing one plugin id, or a composed leaf beside a plugin leaf, compile fine).
  • A plugin that fails to compile degrades only the masks using it to neutral coverage and is reported once through VFXLog.warnOnce; it never takes down the mod or another effect.
  • On the 1.21.11 node there is no shader-source hook, so a GLSL-plugin shape renders nothing there; the composed-SDF kind works on every node.
  • Plugin coverage obeys the same per-leaf fail-closed contract as every other leaf: an unresolved leaf contributes zero and can never be inverted into "everywhere". A bound d<J> slot that cannot resolve fails its leaf closed like any other bound slot.

vfxweaver:ringed_glsl is a built-in plugin (a screen ring with 8 petal-modulated lobes) shipped so the path is testable in game; see the guide for the datapack side.

Block/item-particle presets (client-local)

Model particles — the particles effect with "particle": "block", "particle": "item" or a preset id — can be defined from code, mirroring registerDefinitions. A spec draws a block or an item (exactly one). Each live particle renders as a client-side display entity (BlockDisplay/ItemDisplay), so vanilla interpolates its motion; the spec's brightness maps onto the display's brightness override, size onto the transformation scale and spin (degrees/tick) into a rotation driven by the spec's spinMode/spinAxis (TUMBLE = physical full-3D spin about a random or fixed axis, YAW = uniform spin about one axis, NONE = no rotation) with spinRandom/ spinFriction/spinRoll tuning its randomness and contact response. Presets are client-local and never synced; the datapack layer (data/<ns>/vfx_particles/<name>.json) wins for the same id.

import dev.vfxweaver.effect.VFXBlockParticleSpec;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.item.Items;

VFXBlockParticleSpec ember = VFXBlockParticleSpec.builder(Blocks.MAGMA_BLOCK.defaultBlockState())
    .brightness(15, 15)  // block-display semantics: -1 = world light, or separate block/sky levels
    .gravity(0.8F).friction(0.94F).collide(1.0F).bounce(0.2F)
    .size(0.35F).life(60).spin(12.0F)
    .spinMode(VFXBlockParticleSpec.SpinMode.TUMBLE)  // TUMBLE / YAW / NONE
    .spinAxis(new Vector3f(0.0F, 1.0F, 0.0F))        // null = random axis
    .spinRandom(1.0F).spinFriction(1.0F).spinRoll(0.5F)
    .build();

// Item form: the item's baked model is drawn (skull/tool/ingot particles). `item(ItemStack)`
// is the shorthand for the default physics; a builder is available when you need to tune them.
VFXBlockParticleSpec skull = VFXBlockParticleSpec.item(new ItemStack(Items.SKELETON_SKULL));
boolean skullBlock = skull.hasBlock();  // false
boolean skullItem = skull.hasItem();    // true

// Register/unregister a preset in the local layer; register returns false when it is full (256).
boolean registered = VFXAPI.registerBlockParticle(Identifier.fromNamespaceAndPath("mymod", "ember"), ember);
boolean removed = VFXAPI.unregisterBlockParticle(Identifier.fromNamespaceAndPath("mymod", "ember"));

// Look one up (datapack layer wins). @Nullable.
VFXBlockParticleSpec found = VFXAPI.blockParticle(Identifier.fromNamespaceAndPath("mymod", "ember"));

// Spawn a single block particle immediately on this client (no packet, no effect instance);
// no-op on a dedicated server. `velocity` is in blocks/tick; the spec supplies the physics.
VFXAPI.spawnBlockParticle(ember, new Vec3(x, y, z), new Vec3(0.0, 0.25, 0.0));

A particles effect then reaches the preset with "particle": "mymod:ember"; the effect's brightness/gravity/friction/collide/bounce/size/life/spin/spin_random/ spin_friction/spin_roll params override its fields. spin_mode and spin_axis are spec/preset fields only (effect params are numeric), so an effect selects yaw/none by naming a preset. The same methods register/spawn item presets — the spec carries the model, the API surface is unchanged. See the particles block and item mode in the effects guide.

Spark presets use the same client-local registry: VFXAPI.registerSpark(id, VFXSparkSpec), VFXAPI.unregisterSpark(id), VFXAPI.spark(id) and VFXAPI.spawnSpark(spec, position, velocity) (the client-only spawnSpark is a default no-op on VFXLocalDispatcher, overridden by the mod's client dispatcher). A spark preset is a vfx_particles file with "kind": "spark"; see the Spark presets page.

Caller-supplied images. A mod can show an image it renders itself — an item icon, a live preview, a generated texture — anywhere a packed texture is accepted, without shipping a .png. Register it under a resource id and reference that id exactly as a pack texture id:

// `argb` is width*height pixels, row-major, row 0 the top row, in 0xAARRGGBB order
// (the order NativeImage.getPixels() returns). The array is copied; reuse it after the call.
VFXAPI.registerImage(Identifier.fromNamespaceAndPath("mymod", "held_icon"),
    width, height, argb);
VFXAPI.unregisterImage(Identifier.fromNamespaceAndPath("mymod", "held_icon"));

The one id then serves two consumers with two mechanisms, both handled for you: effect fields (surface_pattern / sky_pattern / field textures) resolve it through the game's texture manager, and an aux window uploads a CPU copy into its own GL context. Internally this is VFXImageRegistry (client singleton, bounded at 256 images); the common source set only sees the VFXLocalDispatcher.registerImage/unregisterImage bridge, so VFXAPI stays client-free. The call is render-thread-aware: off-thread it queues and returns true.

VFXAPI.EffectRequest (fluent builder)

// Builds one request and plays it on a player (over the network) or locally on this client:
VFXAPI.EffectRequest.of()
    .duration(60)                    // ticks, 0 = definition default
    .param("radius", 2.0F)           // constant overrides (repeatable)
    .target(entityUuid)              // entity UUID targets / entity-anchored overlay slots (repeatable)
    .easing(EasingType.EASE_OUT_CUBIC) // or .easing("ease_out_cubic")
    .play(player);                   // or .play() for a client-local play

VFXLocalDispatcher

A bridge the client entrypoint (VFXClient) registers via VFXAPI.setLocalDispatcher(...) so playEffect/stopEffect/stopAllEffects can run without a network packet. Other mods don't need to implement it — it's an internal part of the common↔client link of the mod. spawnBlockParticle, spawnSpark, registerImage and unregisterImage are default no-ops on the interface (so a dispatcher compiled before those features still links) and are overridden by the mod's client dispatcher.

Definition registry — VFXDefinitionManager

VFXDefinitionManager.get().get(effectId);        // VFXDefinition or null
VFXDefinitionManager.get().contains(effectId);   // whether the effect exists (built-in or datapack)
VFXDefinitionManager.get().getDefinitions();      // Map<Identifier, VFXDefinition> — snapshot of all known effects

Updated on every /reload (see VFXDefinitionManager.prepare/apply); one broken datapack entry is logged and skipped, the rest load normally.

Registering definitions from code

A client-only mod cannot own definitions through a datapack: Fabric does not reload mod-provided SERVER_DATA packs on a multiplayer client (so data/<ns>/vfx/*.json only loads in single player), and a server running this mod replaces the whole definition set over vfxweaver:vfx_sync. Register them from code instead:

// Client init (or any time). Same JSON shape as a datapack file; each entry is parsed with the
// same validation - a broken entry is logged, reported by /vfx validate and skipped, and the
// layer is bounded. Returns the ids that were rejected (empty = all good).
Set<Identifier> failed = VFXAPI.registerDefinitions(Map.of(
    Identifier.fromNamespaceAndPath("mymod", "burst"), """
        { "type": "particles", "duration": 40, "shape": "sphere", "params": { "rate": 80, "radius": 1.5 } }
        """,
    Identifier.fromNamespaceAndPath("mymod", "ring"), """
        { "type": "particles", "duration": 60, "shape": "ring", "params": { "rate": 60, "radius": 2.0 } }
        """));

boolean removed = VFXAPI.unregisterDefinition(Identifier.fromNamespaceAndPath("mymod", "burst"));

Semantics:

  • Usable immediately and it survives /reload and a server sync — local definitions live in a layer of their own; applySynced only replaces the datapack/server set. (A local definition was previously lost on joining a server, and playEffect("mymod:thing", ...) then failed to resolve.)
  • Private to this client: a client's local definitions are never synced to other players (getRawDefinitions(), the payload source, stays datapack-only).
  • The datapack/server layer wins for the same id, so a server can still override a local effect.
  • Registered ids show up in getDefinitions(), contains(), /vfx list and (when broken) in getParseErrors() / /vfx validate.
  • No protocol or datapack-format change; purely additive API.

Network protocol

Clientbound: vfxweaver:vfx_trigger (VFXTriggerPayload)

Field Type Description
protocolVersion byte Current value — VFXTriggerPayload.PROTOCOL_VERSION (6). The client silently ignores the packet on a version mismatch (see VFXClient.handleTrigger).
effectId Identifier Effect id (built-in or datapack)
action VFXAction (PLAY/STOP/SET_PARAM/KEYFRAME/SET_EXPR/MOVE) SET_PARAM/KEYFRAME apply to running effect instances: params carries exactly one name → value entry, for KEYFRAME the frame time is in durationTicks (negative = start the segment at the current time and run over |time| ticks), the segment easing in easing; SET_EXPR uses exprParam+exprSource; MOVE uses position + instanceId
durationTicks varint 0 = definition default, negative = persistent (only for PLAY)
elapsedTicks varint Resume offset: how far into the timeline the effect already is (only for PLAY, 0 = start fresh). Used when the server re-applies effects after a reconnect, computed from the effect's original start so time spent offline counts.
params Map<String, Float> Constant overrides, numbers only
easing EasingType (string)
exprParam / exprSource optional strings Only for SET_EXPR: the parameter name and the raw expression source

Serverbound: vfxweaver:vfx_request (VFXRequestPayload)

Lets a client mod ask the server to play an effect through the definition registry. Fields: protocolVersion (must match VFXTriggerPayload.PROTOCOL_VERSION), effectId, broadcast (boolean), instanceId (long, 0 = allocate), worldPos (optional Vec3), params (max VFXTriggerPayload.MAX_PARAMS = 256), easing (built-in easing name string).

  • broadcast = false: the effect plays only on the requesting player's client.
  • broadcast = true: the effect plays for every connected player, but the server only honours it from operators (gamemaster level) — anyone else is silently dropped (logged server-side).
  • Easing: built-in easing names resolve; custom datapack curve names fall back to LINEAR on this path.

Bump PROTOCOL_VERSION on any breaking packet-format change — otherwise old clients silently ignore new packets without a single warning in the log.


See also: guide/ — user guide (commands, datapacks), ARCHITECTURE.md — how rendering works under the hood.