Custom Effects
ETA effects are per-character render modifiers. Each frame, for each glyph inside a tagged span, ETA calls your effect’s apply method with a mutable EffectSettings object. You mutate whatever fields you want (position, rotation, color, scale) and ETA uses the result to draw that glyph.
The Effect interface
Section titled “The Effect interface”public interface Effect { void apply(@NotNull EffectSettings settings);
@NotNull String getName();
@NotNull default String serialize() { return getName(); }
@NotNull static Effect create(@NotNull String name, @NotNull Params params) { return EffectRegistry.create(name, params); }}You only need to implement apply and getName. serialize has a default that returns getName() and is enough for most effects; override it only if you need to round-trip params.
EffectSettings is a plain mutable struct. The fields your effect will touch most:
| Field | Type | Meaning |
|---|---|---|
x, y | float | pixel offset from the glyph’s natural position |
rot | float | rotation in radians around the glyph center |
scale | float | uniform scale (1.0 = normal) |
r, g, b, a | float | RGBA color, 0—1 range |
index | int | position of this glyph within the tagged span |
absoluteIndex | int | position within the entire message |
codepoint | int | Unicode codepoint of the glyph |
isShadow | boolean | true when ETA is rendering the drop-shadow pass |
In practice, built-in effects like WaveEffect only write to y, while RainbowEffect only writes to r, g, b. Touch only the fields your effect cares about and leave the rest alone.
Declaring parameters with ParamSchema
Section titled “Declaring parameters with ParamSchema”Effects that accept markup attributes (like <wave amp=3>) declare their schema with ParamSchema. The schema drives coercion, clamping, and default values; you never parse attribute strings yourself.
Build one static instance:
private static final ParamSchema SCHEMA = ParamSchema.of("myeffect") .f("angle", 15f, -180f, 180f, "Rotation in degrees", "a") .f("speed", 1f, 0.01f, 100f, "Animation speed", "sp") .bool("mirror", false, "Flip every other character") .build();Builder methods on ParamSchema.Builder:
| Method | ParamDef kind | Signature |
|---|---|---|
.f(...) | FLOAT | f(canonical, default, min, max, desc, aliases...) |
.i(...) | INT | i(canonical, default, min, max, desc, aliases...) |
.bool(...) | BOOL | bool(canonical, default, desc, aliases...) |
.str(...) | STR | str(canonical, default, desc, aliases...) |
.en(...) | ENUM | en(canonical, Class<E>, default, desc, aliases...) |
.color(...) | COLOR | color(canonical, desc, aliases...): value arrives as float[] RGB |
.font(...) | FONT | font(canonical, default, desc, aliases...) |
aliases are optional trailing varargs; omit them entirely for no alias. Values outside min/max are clamped with a warning logged, so you can skip manual bounds checking.
Read resolved values in the constructor via Bound:
Bound b = bound(); // provided by BaseEffectfloat angle = b.f("angle");float speed = b.f("speed");boolean mirror = b.bool("mirror");A worked example
Section titled “A worked example”TiltEffect rotates each glyph by a configurable angle, optionally alternating direction for even-indexed characters.
import net.tysontheember.emberstextapi.immersivemessages.effects.BaseEffect;import net.tysontheember.emberstextapi.immersivemessages.effects.EffectSettings;import net.tysontheember.emberstextapi.immersivemessages.effects.params.Bound;import net.tysontheember.emberstextapi.immersivemessages.effects.params.ParamSchema;import net.tysontheember.emberstextapi.immersivemessages.effects.params.Params;import org.jetbrains.annotations.NotNull;
public class TiltEffect extends BaseEffect {
private static final ParamSchema SCHEMA = ParamSchema.of("tilt") .f("angle", 15f, -180f, 180f, "Tilt angle in degrees", "a") .bool("alternate", true, "Flip direction on even-indexed glyphs") .build();
private final float angleRad; private final boolean alternate;
public TiltEffect(@NotNull Params params) { super(params, SCHEMA); Bound b = bound(); this.angleRad = (float) Math.toRadians(b.f("angle")); this.alternate = b.bool("alternate"); }
@Override public void apply(@NotNull EffectSettings settings) { float sign = (alternate && settings.index % 2 == 0) ? -1f : 1f; settings.rot += sign * angleRad; }
@NotNull @Override public String getName() { return "tilt"; }}Key points:
- Extend
BaseEffect, notEffectdirectly.BaseEffectstores theParamsand wiresbound()for you. - Pass
paramsandSCHEMAto thesupercall sobound()has a value. - Read all params in the constructor, not in
apply. The constructor runs once;applyruns every frame per glyph. settings.rotis in radians and additive; other active effects may have already written to it.
Registering the effect
Section titled “Registering the effect”Register before any message using the tag is sent. A safe place is your client-side mod initialization, after ETA’s own init has run (ETA locks built-ins during font reload, but your name won’t be in the built-in set):
import net.tysontheember.emberstextapi.config.markup.EffectCategory;import net.tysontheember.emberstextapi.immersivemessages.effects.EffectRegistry;
// No category -- effect will fall under OTHER in configEffectRegistry.register("tilt", TiltEffect::new);
// Or with a category so it groups correctly in the config screenEffectRegistry.register("tilt", EffectCategory.MOTION, TiltEffect::new);Both overloads accept Function<Params, Effect> as the factory. A method reference to a (Params) constructor satisfies this.
The registry locks after EffectRegistry.initializeDefaultEffects() runs. Built-in names (wave, rainbow, etc.) cannot be overwritten after that point; attempting to do so throws IllegalStateException. Your own name can be registered any time; registering the same third-party name twice logs a warning and overwrites the old factory.
Using your effect from markup
Section titled “Using your effect from markup”Once registered, the name becomes a valid tag in any ETA markup string:
<tilt>slanted text</tilt><tilt angle=25>steeper tilt</tilt><tilt a=10 alternate=false>uniform tilt</tilt>Short alias a works because the schema declared it. Boolean flags can be written as alternate=false or just alternate (bare flag = true).
Per-character vs per-message effects
Section titled “Per-character vs per-message effects”Effect (this page) is per-character: apply is called once per glyph per frame. It’s the right choice for anything that varies by glyph position or animates on a per-character basis.
MessageEffect is per-message: its apply method receives a MessageEffectContext representing the whole message and typically writes to message-level transform or timing state. Use it when the effect logically applies to the entire HUD element: a rock or breathe animation on the message box itself, for example.
Register message effects with MessageEffectRegistry.register(name, factory). The signature is identical to EffectRegistry.register. Message effects go in [bracket] tags in markup, not <angle> tags.
Effect categories
Section titled “Effect categories”The two-argument overload of register accepts an EffectCategory:
EffectRegistry.register("tilt", EffectCategory.MOTION, TiltEffect::new);Categories are: MOTION, COLOR, DISTORTION, SOUND, OTHER. They control which config toggle gates the effect; players can disable entire categories from the config screen. If you omit the category the effect defaults to OTHER.