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.

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:

FieldTypeMeaning
x, yfloatpixel offset from the glyph’s natural position
rotfloatrotation in radians around the glyph center
scalefloatuniform scale (1.0 = normal)
r, g, b, afloatRGBA color, 0—1 range
indexintposition of this glyph within the tagged span
absoluteIndexintposition within the entire message
codepointintUnicode codepoint of the glyph
isShadowbooleantrue 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.

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:

MethodParamDef kindSignature
.f(...)FLOATf(canonical, default, min, max, desc, aliases...)
.i(...)INTi(canonical, default, min, max, desc, aliases...)
.bool(...)BOOLbool(canonical, default, desc, aliases...)
.str(...)STRstr(canonical, default, desc, aliases...)
.en(...)ENUMen(canonical, Class<E>, default, desc, aliases...)
.color(...)COLORcolor(canonical, desc, aliases...): value arrives as float[] RGB
.font(...)FONTfont(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 BaseEffect
float angle = b.f("angle");
float speed = b.f("speed");
boolean mirror = b.bool("mirror");

TiltEffect rotates each glyph by a configurable angle, optionally alternating direction for even-indexed characters.

src/main/java/com/example/TiltEffect.java
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, not Effect directly. BaseEffect stores the Params and wires bound() for you.
  • Pass params and SCHEMA to the super call so bound() has a value.
  • Read all params in the constructor, not in apply. The constructor runs once; apply runs every frame per glyph.
  • settings.rot is in radians and additive; other active effects may have already written to it.

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):

YourClientInit.java
import net.tysontheember.emberstextapi.config.markup.EffectCategory;
import net.tysontheember.emberstextapi.immersivemessages.effects.EffectRegistry;
// No category -- effect will fall under OTHER in config
EffectRegistry.register("tilt", TiltEffect::new);
// Or with a category so it groups correctly in the config screen
EffectRegistry.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.

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).

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.

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.