Skip to main content

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

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.
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:
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:
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:
Here codecs is the borrowed runtime service. Register once before emitting or loading this event. The complete Milestone example 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:
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:
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:
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:
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.