Events

Run code when an animation reaches a frame, on the server or on clients, once and only once.

What an event is

A clip in a .strutanim can carry event keyframes. When playback crosses one, Strut calls the handler for that event's type. There are two kinds of thing to run:

The guarantee

For the server, each event that playback crosses is delivered exactly once, in order, whether the server ticks slowly, a clip is replaced or sped up, or the animation is seeked. Clients get each event at most once, and exactly once unless a network loss forces a guess (a client that missed the message that started a clip may fire a cosmetic event slightly early or late, or twice across a lost start). Gameplay never depends on a client.

What that means in practice:

Authoring events in Blockbench

An event keyframe has three channels:

ChannelWhat goes in it
SoundA sound id (mymod:creature.cry), or a bare binding key (see bindings). Plays on clients.
ParticleA particle effect, played on clients.
Custom instructionsOne typed event per line. This is where gameplay events go.

The instruction line

mymod:swell 2.5           a namespaced type id, then its payload text
mymod:fade_to_black          a type with no payload
swap_end                  a bare word: shorthand for strut:marker swap_end
[server] mymod:strike        modifiers in square brackets, before the type
[both, reverse] swap      several modifiers, separated by commas or spaces

One keyframe may hold several lines; they fire in line order at that keyframe's time. The optional bracket at the very start sets how that line is delivered:

ModifierEffect
server, client, bothWhere it runs, instead of the type's default. At most one.
reverseAlso fire when playback crosses it backwards (a seek back, a negative rate). The default is to skip.

A bare word is a marker: a named point that any code can listen for. Markers are lower-cased, so changeLook and changelook are the same marker.

Built-in event types

TypePayloadRuns on
strut:markerA nameBoth
strut:soundA sound id or binding keyClient
strut:particleA particle specClient
strut:loopThe lap numberBoth
strut:endNoneBoth

Listening for a marker

The lightest way to react to a clip is to listen for a marker by name:

StrutEvents.onServerMarker("swap_end", context -> {
	Entity entity = context.entity();
	// runs once per crossing of the marker, on the server, whatever the lag
});

For the client, StrutEvents.onClientMarker is the counterpart. Both take the marker name case-insensitively. To listen to a whole type rather than one marker, use StrutEvents.onServer(typeId, listener) and onClient.

On the entity itself

A StrutEntity is offered its own events through two overridable methods, so a handler can use the entity's fields directly:

public class HeroEvents extends PathAwareEntity implements StrutEntity {
	public HeroEvents(EntityType<? extends HeroEvents> type, World world) {
		super(type, world);
	}

	@Override
	public void onAnimationEvent(EventContext context) {
		if (context.isMarker("footstep"))
			playSound(net.minecraft.sound.SoundEvents.BLOCK_GRASS_STEP, 0.5f, 1f);
	}

	@Override
	public List<Identifier> getEventBindings() {
		return List.of(Identifier.of("mymod", "creatures/ava"), Identifier.of("mymod", "creatures/default"));
	}
}

Your own event types

A type is a name, the shape of its payload on the wire and in a clip, where it runs by default, and its handlers. One builder call per event your mod owns replaces a hand-written string parser and a trigger packet:

return StrutEventTypes.register(StrutEventType
		.builder(Identifier.of("mymod", "strike"), PacketCodecs.FLOAT.cast())
		.authority(EventAuthority.SERVER)
		.parser(Float::parseFloat)
		.serverHandler((context, strength) -> {
			// context.entity(), context.exactGameTime(), context.random() ... and the parsed payload
		})
		.build());

Now a clip line mymod:strike 2.5 parses 2.5 with your parser and calls the handler. Register types while your mod initialises: types are frozen before the first world loads, and registering late is an error, so a clip can never resolve against half the set.

Deterministic random

context.random() returns the same sequence on the server and on every client for one occurrence. A "random line from this set" chosen in an event handler is therefore the same everywhere, without sending the choice.

Bindings: sounds without code

The sound channel can carry a binding key instead of a sound id. A binding set maps keys to what to play, per character or per situation, in a resource-pack file assets/<ns>/strut/event_bindings/<path>.json (set id <ns>:<path>):

{
	"strike":    { "sounds": ["mymod:misc.impact"] },
	"thumpmsg1": { "sounds": ["mymod:thump"],
	              "random_sounds": ["mymod:ahh", { "sound": "mymod:mmm", "weight": 2 }, { "none": true, "weight": 3 }] }
}

Where the server gets a clip's events

The server does not need the model, only the clip's timeline: its length, loop mode and event keyframes. It reads them, in priority order, from:

  1. Timelines your mod registers in code (ClipTimelines).
  2. Sources you add (addSource), and animations delivered by asset sync (data/<ns>/asset_sync/strut/animations/).
  3. Data packs: data/<ns>/strut/animations/**.strutanim.
  4. Animations inside mod jars.

The client uses the clip it loaded from its own resources. Both derive a content hash, so a mismatch between the two copies can be detected.

To fail loudly at load instead of silently at play time, declare the markers your gameplay needs. A clip that lacks one is reported:

StrutEvents.requireMarkers("bia_scene", "strike", "finish");

Limits worth knowing

Seeing events run

/strut events stats shows cumulative counts. /strut events trace <entity> prints a line for every server event delivered on that entity: type, payload, sequence, loop and exact game time. The F9 overlay shows events fired: server N / client N. See Commands and debugging.