> ## Documentation Index
> Fetch the complete documentation index at: https://docs.marioded.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Add a custom replay event

> Store a game-specific event and play it back with a matching codec and renderer.

# Add a custom replay event

**Advanced integration** · Shared payload on recording and viewing machines

Use a custom recordable when a game-specific event needs to be stored and replayed, not merely shown live in the integration's UI. A complete feature spans capture, serialization, and rendering; implementing only one side does not make a recording portable.

## The data path

```mermaid theme={null}
flowchart LR
  Game[Gameplay event] --> Event[Recordable]
  Event --> Capture[Recorder session]
  Capture --> Codec[Codec and event ID]
  Codec --> Storage[(Replay storage)]
  Storage --> Decode[Matching decoder]
  Decode --> Render[Player renderer]
```

The same custom data contract must be available on the recording and viewing machines. Keep shared payload/codec code independent of `api-player` and `api-recorder` when both sides need it; put renderer/capture bindings in the relevant integration modules.

## Transient versus durable

`Recordable` is the marker for an event. `StatefulRecordable` adds `apply(WorldState)` for a mutation that participates in timeline folding.

* A transient announcement, particle burst, or one-off sound is typically emitted during normal playback.
* A durable entity/block change must update the accumulated scene so pause/seek/resync reproduce the target state.

Implementing a live renderer alone does not make a custom durable event seek-safe. Use typed folded extension state and a playback policy with restoration/cleanup hooks, as below. No changes to Reverse internals or terrain handling are needed.

> [!NOTE]
>
> **Start with a transient event.** A milestone announcement is a small end-to-end example. Persistent scene changes also need correct folding and resynchronization when viewers seek.

## Create a small payload

Let's record an announcement when the game enters a new round. Start with an immutable payload shared by recording and playback integrations:

```java theme={null}
public record Milestone(String text) implements Recordable {}
```

The data is deliberately small: one string. It contains no Bukkit player handles, live world objects, or protocol packet types.

## Teach Reverse how to save it

Create a symmetric codec in your shared integration code:

```java theme={null}
RecordableCodec<Milestone> codec = RecordableCodec.of(
    input -> new Milestone(input.readString()),
    (output, event) -> output.writeString(event.text()));
```

The reader consumes exactly the string written by the writer. Next, register the same ID, payload class, and layout with the recorder and player runtime's `RecordableService`:

```java theme={null}
codecs.register(1000, Milestone.class, codec);
```

Here `codecs` is the borrowed runtime service. Register once before emitting or loading this event. The complete [Milestone example](https://docs.marioded.com/examples/milestone.md) shows separate recorder/player binding helpers.

Its codec ID `1000` is an **example allocation**, not a reserved extension range. Check the actual registry and coordinate a collision-free ID across all installed extensions. Register before your recording can emit the event and before replay files containing it can be loaded.

Unknown codec IDs are a hard load failure. Never ignore an unknown record: without its payload layout, the decoder cannot know where the next event begins.

## Insert it into the match

When the round starts, append the event to the recording session your match owns:

```java theme={null}
recorder.getRecorderService().insertIntoSession(session.getUniqueId(), new Milestone("Round 2"));
```

The recorder assigns the session-relative tick. Do not mutate the immutable event snapshot or substitute a Bukkit world UUID for the target session.

## Render it for viewers

On the player runtime, register the renderer:

```java theme={null}
player.getRendererService().register(Milestone.class, (event, context) -> {
    for (Player viewer : context.getViewers()) {
        viewer.sendMessage(event.text());
    }
});
```

The callback receives the decoded event and the active session context. It sends a plain-text announcement to that session's viewers during ordinary playback. This is a transient effect, not a persistent field restored on every seek.

## Codec design

`RecordableCodec.of(reader, writer)` is suitable for small explicit schemas. Use the corresponding `ReplayDataInput`/`ReplayDataOutput` primitives in the same order and with the same signedness/units.

Avoid Java object serialization, Bukkit entity handles, and protocol-specific types in persisted data. Validate counts/ordinals before allocating collections or indexing enum arrays. An invalid payload must fail, not return a plausible but misaligned partial replay.

Payload/ID changes require coordinated extension releases on both recording and playback machines. Do not assume an earlier recording can be decoded after changing your custom event layout. Follow the release's recording-compatibility notes as well as your own extension's versioning policy.

## Use the session context for entity effects

The renderer receives only the recordable and the active `PlaybackContext`; use its viewer collection and mappings for the current session.

For entity effects, map recorded IDs through the context. Do not send the original captured entity ID directly to viewers, or reuse one session's remapped ID in another session.

The public player adapter describes the protocol-neutral playback contract. Keep saved data separate from packet-library types, and use the renderer/context contracts for ordinary custom-event playback.

## Durable state and playback policies

Keep folding protocol-neutral. For example, a durable label belongs to an entity incarnation:

```java theme={null}
public record Label(String text) {}

public record LabelChange(int entityId, String text)
        implements EntityRecordable, StatefulRecordable {
    @Override
    public void apply(WorldState world) {
        EntityState entity = world.getEntity(entityId);
        if (entity != null) entity.setExtension(Label.class, new Label(text));
    }
}
```

`EntityRecordable`, `StatefulRecordable`, `EntityState` and `WorldState` are in the shared API
(`com.marioded.reverse.api.recordable` and `com.marioded.reverse.api.state`). Extension values
must be immutable; the state retains them by reference. `getExtension`/`removeExtension` return
null for absence. `setExtension` rejects null and validates the value against its class key.
Entity replacement discards the old incarnation's extensions; world reset clears world extensions.
These maps are folded state, not a new persistence format: register your payload codec as usual.

On the player, register the renderer and the policy **before creating sessions**:

```java theme={null}
var renderers = player.getRendererService();
// Register a LabelChange renderer that writes your protocol's label representation.
// The example uses an empty string as that renderer's explicit clear operation.
renderers.registerPlayback(LabelChange.class, new RecordablePlayback<LabelChange>() {
    @Override
    public void forward(LabelChange event, PlaybackContext context, PlaybackProjection projection) {
        if (projection.isEntityKnown(event.entityId())) projection.emit(event);
    }

    @Override
    public void restoreEntity(EntityState entity, PlaybackContext context, PlaybackProjection projection) {
        Label label = entity.getExtension(Label.class);
        if (label != null) projection.emit(new LabelChange(entity.entityId(), label.text()));
    }

    @Override
    public void clearEntity(EntityState entity, PlaybackContext context, PlaybackProjection projection) {
        if (entity.getExtension(Label.class) != null) {
            projection.emit(new LabelChange(entity.entityId(), ""));
        }
    }
});
```

Player-side imports are `com.marioded.reverse.api.player.renderer.RecordablePlayback` and
`com.marioded.reverse.api.player.context.PlaybackContext`/`PlaybackProjection`. Policies must
be stateless: do not retain contexts, projections, players or session state in them. All callbacks
run on the main thread, with one recipient per context. Callback state is read-only; only
`apply` mutates folded state. `projection.emit` renders directly, without folding or recursively
calling the policy.

The core withholds forward callbacks until terrain is ready. On seek, explicit terrain resend,
or distance reentry it emits accepted absolute spawns, equipment and motion for the selected
batch before calling entity restore hooks; selected related entities are already known. Generic
synchronization hooks follow, then world restore hooks run after the complete base scene. `restoreWorld`
and `clearWorld` provide the same pattern for values stored on `WorldState`. Clear hooks receive
outgoing state before mappings are retired, including when a lifecycle fold already removed
the entity. Seek clears before resetting state. For a terrain resend, custom clear hooks run
synchronously when that recipient becomes pending, **before terrain retrieval starts**, while the
folded values still describe its displayed custom output. Native entity knowledge and mapping
timing are unchanged. Playback can continue folding while the recipient waits; completion restores
the then-current state without repeating custom clear hooks. Coalesced requests clear newly pending
recipients only. Never-projected pending viewers are not cleared. Failures remove that recipient
rather than restoring it; healthy recipients still complete.

Do not mutate folded state or the session cursor from callbacks. A seek requested through
`PlaybackService` during clear is deferred until the whole clear pass and its removal bookkeeping
finish, so every distinct policy can clear its outgoing values. The last requested target wins,
including the current cursor. A failed pass discards the deferred seek; stop/departure take priority.
This deferral applies to cleanup, not an arbitrary queue of lifecycle operations.

For a custom spatial event, call `projection.reconcileEntity(entityId)` after folding; emit the
original movement only if it was known before and remains known afterwards, so an entrant does
not receive its delta twice. The core invokes `synchronizeProjection` once after that recipient's
complete membership-transition batch; the built-in policy handles passengers there. For custom
lifecycle events override `retiredEntityIds(event)` with original IDs; the core processes these
once session-wide, even for pending viewers, before `forward`. Retirement also triggers generic
synchronization for affected ready recipients. `isBaseline()` declares silent pre-history folded
even beyond a seek target, never forwarded during normal play.

Bindings match exact classes, not supertypes. Register the same policy object for several
classes when they share restore/clear behavior: those hooks visit each distinct policy identity
once in first-registration order. Each session freezes its bindings; replacement affects only
new sessions. Without a binding, an entity record emits only for known subjects and other
records emit directly; no custom lifecycle or spatial behavior is guessed. During restore/clear,
`reconcileEntity` returns false without doing work to prevent nested restoration. Do not retain
the projection or manipulate terrain readiness or shared mappings yourself.

`PlaybackProjection` exposes only `emit`, `isEntityKnown` and `reconcileEntity`. Override
`prepareProjection` for setup before each spawn batch (including a full projection with zero visible
entities), and `synchronizeProjection` for relationships derived after a completed transition batch.
These hooks run once per distinct policy identity, not per entity; ordinary metadata/equipment updates
do not trigger them. Built-in teams and passenger relationships use the same hooks as extensions.

Session mutation and live state/map reads are main-thread-owned, not concurrent APIs. `getViewers()`
returns an immutable iteration snapshot. `projectedEntities(viewer)` is the single service-owned map
of known original IDs to outgoing entity incarnations; integrations may read it in hooks but must
not mutate it. During a resend its keys/references stay intact until native removal at completion,
even though custom output has already been cleared at admission. No separate mutable known-ID set
exists.

## Correct coordinate and state conventions

* Relative entity movement deltas use shorts in 1/4096-block units; they are not absolute coordinates.
* Rotation values carried alongside relative movement are absolute orientations.
* Packed block positions have signed Y semantics. Use `Vector3i.pack/unpack`, not an unsigned `& 0xFFF` Y extraction.
* Block values are state strings, not saved global protocol state IDs. Resolve IDs only at the protocol encode/decode boundary.
* Metadata and equipment packets can contain partial updates; durable folding merges entries rather than replacing the complete set.
* A baseline is pre-history for touched block positions. It seeds an untouched value and is not itself a block-change effect to draw.

## Built-in vocabulary

Reverse records supported block changes, entity activity, chat, particles, and world effects automatically. Sounds are recorded only when the integration declares them through `recordWorldSound`/`recordSessionSound`, because a clientbound sound packet does not reveal whether the sound was world-wide or personal. Add a custom event when your integration needs additional game-specific data, such as the milestone announcement above.

Use the public recordable and codec contracts documented here, and register your own types with the service. Keep custom IDs coordinated across extensions and insert into the intended session(s) rather than editing a captured event collection.

## Validation strategy

Run an actual scene containing the custom event, save/load it through the configured backend, and verify forward playback, pause, seek backward/forward, and multiple sessions. A transient chat example need not appear while scrubbing history; a durable block/entity extension must reconstruct correctly at the target tick.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.