Skip to content

Writing an Effect

Leaf26 edited this page Jun 17, 2026 · 1 revision

The teleport warmup delay is an extension point. LeafRTP fires effects at fixed stages of the teleport lifecycle - on command, before the teleport, after it lands, on cancel - and the set of effects is open: an addon can register its own. This page is for developers who want to ship a new effect type (a countdown bossbar, a screen fade, a client-side "rift" animation). If you only want to play the bundled effects (fireworks, particles, potions, sounds, glide, title) without code, you do not need this page - see the admin-facing Effects page and just edit the effects/ config.

!!! note "Why effects are a clean extension point" Effects run during the warmup window and are presentation only: client-side packets, particles, sounds, potions. They must not edit the world and must not load chunks on a region thread. Because of that contract, adding an effect can never compromise destination safety (S-001..S-007) - the worst a buggy effect can do is fail to render. That is why effects-api is a separate, platform-neutral module (api/effects-api) shared across Bukkit/Paper/Folia, Fabric, and NeoForge.

The model

effects-api is a small registry of named effect prototypes:

Piece Role
Effect<T extends Enum<T>> A Runnable prototype. T is an enum of its argument keys; defaults live in an EnumMap. The engine clone()s the prototype per use, fills its data, sets a target, and runs it.
EffectFactory The static registry. addEffect(name, prototype) registers; buildEffect(name[, data]) clones one for use; buildEffects(prefix, nodes) parses the permission/token grammar.
ValueCoercer Per-platform token-to-object conversion (e.g. a sound name to the platform's sound type). Bound by the platform initializer.
Lifecycle stages firstjoin, join, presetup, postsetup, preload, postload, preteleport, postteleport, cancel, queuepush, queuepop - the when of an effect group.

The shipped prototypes are FIREWORK, NOTE, PARTICLE, POTION, SOUND, GLIDE, and TITLE. Yours joins them by name.

Path 1 (no code): configure effect groups

Most "I want a different teleport effect" requests are solved by editing the effects/ config directory - one file per group, each with a when stage and a list of dot-separated effect tokens. This needs no addon. The grammar, the stage vocabulary, and a full example are documented on the Effects page.

!!! tip "Try this first" A VIP firework on postteleport, a warmup sound on preteleport, or a potion on landing are all pure config. Only write code (Path 2) when you need a behavior the token grammar cannot express - a bossbar countdown, a title sequence with custom timing, a packet-level animation.

Path 2 (code): register a new effect type

Register your prototype from your RTPAddon.onLoad() (or a platform initializer):

import io.github.dailystruggle.effectsapi.common.EffectFactory;

@Override
public void onLoad() {
    // Register once; the name is matched case-insensitively (stored upper-cased).
    EffectFactory.addEffect("COUNTDOWN", new CountdownEffect());
}

Once registered, your effect is usable exactly like the built-ins - by name in an effects/ group token or a rtp.effect.<stage>.countdown.<args> permission:

when: preteleport
effects:
  - COUNTDOWN.5          # your effect, 5-second countdown
  - SOUND.ENTITY_PLAYER_LEVELUP.100.100.0.0.0

Implementing the prototype

Extend Effect<T> with an enum of your argument keys and an EnumMap of defaults, and implement run() to do the rendering against the effect's target:

public final class CountdownEffect extends Effect<CountdownEffect.Key> {
    public enum Key { SECONDS }

    public CountdownEffect() {
        super(defaults());   // register argument keys + defaults
    }

    private static EnumMap<Key, Object> defaults() {
        EnumMap<Key, Object> d = new EnumMap<>(Key.class);
        d.put(Key.SECONDS, 3);   // integer default
        return d;
    }

    @Override
    public void run() {
        // Presentation only. No world edits. No chunk loads on a region thread.
        // Read this.getData().get(Key.SECONDS) and show a bossbar/title to the target player.
    }
}

!!! warning "Numeric arguments are integers, and . is the separator" The token grammar splits on ., so 1.0 becomes two tokens (1 then 0), not the float 1.0. Use whole numbers for all arguments. This is the same rule the bundled effects follow (see Effects).

Platform / threading rules (non-negotiable)

  • Do not coerce tokens before the platform initializer has run. EffectFactory.getCoercer() throws IllegalStateException (S-006) until BukkitEffectsInitializer.registerAll() / FabricEffectsInitializer.registerAll() has bound a ValueCoercer. Register your prototype from onLoad() (which runs after core init), not from a static initializer.
  • Presentation only. No world mutation, no synchronous chunk I/O on the main/region thread (S-005). If you need to schedule a repeating tick (e.g. a per-second bossbar update), use RTP.scheduler, never a raw Thread/Executors.
  • Never swallow exceptions in run() (S-004) - log via the platform logger. A throwing effect is isolated and logged, but a silently-swallowed failure hides bugs.
  • Keep org.bukkit.* out of the common effect. Put platform types behind the ValueCoercer / platform initializer so the same prototype works on Fabric/NeoForge.

Where this fits

Clone this wiki locally