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:
- Gameplay (a hit lands, a door opens, a scene advances): runs on the server, so it is authoritative and identical for everyone.
- Cosmetic (a sound, a particle, a screen fade): runs on each client that can see it.
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:
- An event is half-open: it fires when the playhead moves past it, so it is never fired twice at a boundary and never skipped between two ticks.
- A slow server tick that crosses several events delivers all of them, in order, with their exact game times.
- Changing the rate mid-clip, replacing the clip, or seeking does not lose or repeat events.
- Looping clips fire their events once per lap. Built-in
strut:loopandstrut:endevents mark the lap and the end. - A client that joins or reconnects mid-clip does not replay the past.
Authoring events in Blockbench
An event keyframe has three channels:
| Channel | What goes in it |
|---|---|
| Sound | A sound id (mymod:creature.cry), or a bare binding key (see bindings). Plays on clients. |
| Particle | A particle effect, played on clients. |
| Custom instructions | One 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:
| Modifier | Effect |
|---|---|
server, client, both | Where it runs, instead of the type's default. At most one. |
reverse | Also 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
| Type | Payload | Runs on |
|---|---|---|
strut:marker | A name | Both |
strut:sound | A sound id or binding key | Client |
strut:particle | A particle spec | Client |
strut:loop | The lap number | Both |
strut:end | None | Both |
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.
authority(...):SERVER,CLIENTorBOTH. A clip line can override it with a modifier.serverHandlerreceives anEventContext: the entity, the playback, the loop, the exact game time, and a deterministic random source.clientHandlerreceives the client equivalent.noPayload(value)declares a type that takes no payload text.- Payload codecs use the game's
PacketByteBuf. A payload that refers to a registry entry should carry its id and resolve it when handled, which also makes it safe on a client that lacks the entry.
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 }] }
}
soundsall play;random_soundspicks one, with optional weights.{"none": true}is an explicit silence.- An entity names its sets, in fallback order, by overriding
getEventBindings(). For each key the first set that defines it supplies the whole binding. - Mods can add actions beyond sounds with
EventBindings.registerAction. - A key no set defines is counted, and shown on the F9 overlay as unbound keys, so a typo is visible.
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:
- Timelines your mod registers in code (
ClipTimelines). - Sources you add (
addSource), and animations delivered by asset sync (data/<ns>/asset_sync/strut/animations/). - Data packs:
data/<ns>/strut/animations/**.strutanim. - 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
- A sweep that would cross more than 256 occurrences in one tick is capped, counted (F9 capped sweeps) and continued next tick.
- A client-side event that arrives more than ten ticks late is dropped rather than fired out of context.
- Events run from the game tick, never from rendering, so a culled or off-screen entity still plays its events.
- Events are read from binary
.strutanimfiles only.
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.