-
Notifications
You must be signed in to change notification settings - Fork 10
Writing an Effect
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.
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.
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.
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.0Extend 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).
-
Do not coerce tokens before the platform initializer has run.
EffectFactory.getCoercer()throwsIllegalStateException(S-006) untilBukkitEffectsInitializer.registerAll()/FabricEffectsInitializer.registerAll()has bound aValueCoercer. Register your prototype fromonLoad()(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 rawThread/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 theValueCoercer/ platform initializer so the same prototype works on Fabric/NeoForge.
- The extension-surface map: Extending RTP.
- The addon lifecycle your registration runs in: Addon development, Addon loading.
- The admin/config side of effects: Effects.
- Stability of the surface: API stability (effects-api is its own module with its own ADR series).