Skip to content

Rewind reference

A live, full-fidelity time machine for a running match. The host arms a recording, then can pause the game, scrub back to any earlier moment, and resume play from there, players, blocks, entities, and game scores all snap back.

Restore state, never replay behaviour.

Minecraft is not deterministic (random ticks, mob AI, redstone ordering), so re-simulating forward from a past moment is impossible, and unnecessary. The recorder snapshots state over time; a rewind reconstructs the world’s state at a chosen frame and hands control back to the live server, which keeps ticking forward fresh from there. The recording is therefore only ever read backwards. Forward play after a rewind is ordinary live gameplay.

This is tractable because Conduit games run in small per-match runtime instance dimensions (conduit:rt-N, created on demand by conduit-instance), the recorder only ever touches that one bounded level.

Frames are addressed by a recording-local frame index (0-based, 1 frame = 1 tick), not level.getGameTime(). TickRange.floor / head are these recording-local indices (20 frames ≈ 1 s of play) and are unrelated to the wall-clock level time. After a rewind-commit, wall-clock time keeps advancing but match progression resets to the committed frame; a local index keeps the timeline contiguous and meaningful (“frame N of actual play”). Frames are captured only while live (never while paused or scrubbing) so the timeline measures played ticks.

LayerCaptureRestore
Playersevery tick: PlayerPose (pos/rot, health/food/sat, gamemode, fire/air, xp, effects); inventory on a coarser keyframe cadenceSafeTeleport, all mutations in the post-arrival callback (engine teleport rule); nearest inventory keyframe ≤ target
BlocksRewindBlockWriteMixin on LevelChunk.setBlockState (RETURN gives the old state) journals (pos, old, new)BlockJournal undoes/redoes the minimal diff from the live head to the target sequence, no baseline needed for live rewind
EntitiesEntityLedger: per-tick spawn/remove detection, spawn NBT, stride-sampled transforms, keyframe-cadence state NBT (modern ValueInput/ValueOutput)despawn what shouldn’t exist; recreate removed entities from NBT (EntityType.create + setUUID); reload state into survivors; snap transforms
Game stateSnapshotSlice.capture polled each tick, change-dedupedSnapshotSlice.restore with the snapshot ≤ target

Display entities (engine screens/labels/NPCs) are excluded so their managers don’t desync. Players who disconnected within the window can’t be rewound back into play (skipped).

Two layers:

  1. Cooperative (game loops must suspend their own logic (round timers, spawns, scoring)) the engine can’t know to stop a mod’s custom tick. One line does it at the top of a tick handler:

    if (RewindRecorder.isFrozen(arenaLevel)) return; // paused or scrubbing

    or listen for GamePausedEvent / GameResumedEvent on the bus. Without this, vanilla ticking freezes but the mod’s round keeps running.

  2. Engine-side (RewindEntityTickMixin cancels non-player Entity.tick in a frozen level (mobs/items/projectiles hold still); players are held by a per-tick position anchor (not tick-cancelled) that would stall networking). A PAUSED title + boss-bar scrub timeline are shown.

Both mixins early-out on one volatile read (RewindRecorder.anyFrozen() / anyRecording()) so they’re free in normal play. (The project’s mixin @Inject handler prefix is conduit$.)

arm ──▶ (live capture) ──▶ pause/back/to ──▶ preview (world snaps) ──┬─▶ commit ─▶ resume from here (future discarded)
▲──────── step/back/to ────┘
└─▶ cancel ─▶ back to live

commit truncates the timeline at the preview head (single-branch undo). cancel (or resume mid-scrub) snaps back to the live head and abandons the preview.

Auto-emitted by the engine: ARM, PLAYER_DEATH (per death), REWIND_COMMIT, MATCH_END. Manual: /conduit rewind mark <label> (kind MANUAL). The Marker.MarkerKind enum also declares ROUND_START and SCORE_CHANGE, which are reserved for game-mode integrations, not emitted or coloured by the engine itself. Markers render as the nearest-marker hint on the scrub boss bar and are listed by /conduit rewind status.

Coverage & memory, rolling floor with eviction

Section titled “Coverage & memory, rolling floor with eviction”

Coverage is full up to rewind_memory_cap_mb. History lives entirely in RAM (there is no on-disk tape. Once a match exceeds the cap, the oldest per-tick history (frames + entity samples) is evicted and the rewindable floor advances (Recording.evictIfNeeded)) logged once, never silently. The recent rewind_hot_window_seconds span is always kept intact; eviction never advances the floor into it. After eviction the earliest moments are no longer seekable. Block-journal sequence indices stay absolute, so eviction never corrupts block restore.

This is the corrected reality: the floor is not permanent, and older segments do not live on a tape, they are dropped.

On stop (or a match-end trigger), whoever armed the recording is prompted Save / Discard (RewindSavePrompt; rewind_save_prompt_seconds timeout means discard). A recording armed from the console or RCON has no host, so it is decided under a fixed CONSOLE key rather than discarded unanswerable. Save writes a tape: a directory under <world>/conduit_rewind/<id>/ holding the manifest, palette, baseline, entities and frame segments. See Saved tapes and replays.

Scope: a saved tape reopens as a read-only replay in its own instance dimension (/conduit rewind load <id>), not as resumed live play. A recording saved before tapes existed is a flat <id>.nbt v0 record, which keeps its summary and marker timeline and is never scrubbable.

The /conduit rewind … host command tree is gated by the CAP_REWIND_CONTROL capability. A recording targets the level the host is standing in, in practice the match’s instance dimension.

CommandEffect
armArm a recording on the current level (host-attributed). No-op if one is already active here, or if rewind_enabled is false.
stopStop the active recording and open the Save/Discard prompt.
pauseFreeze the game (capture stops, entities/players held).
resumeResume live play from any frozen state (abandons a preview; does not commit).
back <seconds>Scrub the preview head <seconds> earlier than its current position. Auto-enters scrub mode.
step <frames>Step the preview head by <frames> (± integer).
to <target>Jump to a marker (substring, case-insensitive) or, if a number, to <seconds> from the oldest rewindable moment (window floor), e.g. to death, to 30. After eviction this differs from seconds-after-arm.
commitTruncate the timeline at the preview head and resume live play from there. Future frames discarded.
cancelAbandon the scrub, snap back to the live head, resume.
mark <label>Place a manual marker at the live head.
statusShow recording id, state (LIVE/PAUSED/SCRUBBING), window length, frame/entity/block-edit counts, preview head, recent markers.
save / save <name>Persist the just-stopped recording’s manifest (resolves the pending save prompt).
discardDiscard the just-stopped recording.
listList saved recording ids under <world>/conduit_rewind/.
load <id>Print a saved recording’s summary + event/marker timeline.
speed <mult>Choose the live game speed, a multiplier clamped to [0.1, 8] (0.5 = slow-mo, 2 = fast-forward). Applies immediately while live; chosen while paused or scrubbing, it applies when play resumes. Above 1× it depends on the client. A vanilla client caps its own tick at 20/s, so the server runs faster, its walkers get a matching walk-speed modifier, and jumps and falls stay at 1×. The Conduit client (the mod that draws the in-world screens) lifts that cap and runs at the server’s rate, so everything is genuinely faster and no modifier is applied. Server-wide, so it is refused, with the reason, while any person online is outside the recorded level (engine puppets such as replay figures do not count).
panelOpen the in-world scrubber panel (RewindPanel): a slider timeline, transport row, speed presets, and commit/cancel actions. Freezes the game and enters scrub mode.
package me.zlex.conduit.rewind;
// Lifecycle
public static void init(); // once, from ConduitRewindMod
public static Recording start(ServerLevel level, RewindConfig cfg); // no host (save prompt skipped)
public static Recording start(ServerLevel level, RewindConfig cfg, UUID host); // host-attributed
public static void stop(Recording r);
// Lookup
public static Recording get(ServerLevel level);
public static Recording get(ResourceKey<Level> dimension);
public static Collection<Recording> active();
// Cooperative-pause gate (the one-liner for game loops)
public static boolean isFrozen(ServerLevel level);

start honours the rewind_enabled kill-switch: it returns null (without recording) when the flag is off, so auto-arm callers respect it the same way /conduit rewind arm does. Arming twice on the same level returns the existing recording (idempotent). Use the three-argument start(level, cfg, host) form for normal host play, the two-arg form arms with no host, so the match-end save prompt is skipped.

package me.zlex.conduit.rewind;
public static void beginScrub(Recording r); // freeze + enter preview at live head
public static void previewAt(Recording r, long index); // seek to a frame index (clamped to window)
public static void back(Recording r, double seconds);
public static void stepFrame(Recording r, int delta);
public static void commit(Recording r); // truncate + resume from preview head
public static void cancel(Recording r); // snap back to live + resume
public static void pause(Recording r);
public static void resume(Recording r);
// Time scale: a choice per recording, applied while live and on resume
public static final float NORMAL_TICKRATE = 20f;
public static final float MIN_SPEED = 0.1f, MAX_SPEED = 8f;
public static final Identifier FAST_FORWARD_ID; // the walk modifier above 1x
public static String setSpeed(Recording r, float mult); // null = applied; else the refusal reason
public static float speed(Recording r); // the choice, 1.0 = normal
public static void resetSpeed(Recording r); // back to 1x, modifier off everyone

RewindController is the full programmatic scrub API, useful for scripted cutscenes, slow-motion cinematics, or automated tests. setSpeed drives the server tick-rate manager, which is one number for the whole server, so it returns a refusal instead of null while any person online is outside the recorded level (fake bodies such as replay figures do not count). While the recording is frozen the server is held at 1× and the choice applies on commit, cancel or resume: the panel’s speed row is “speed on resume”. Above 1× a vanilla client keeps ticking at 20/s, so its walkers are scaled by the same factor with a MOVEMENT_SPEED modifier while the server-side world (mobs, items, clock, callbacks) is genuinely faster; a Conduit client ticks at the server’s rate itself (FastForwardTickMixin), gets no modifier, and sees everything faster, jumps included.

package me.zlex.conduit.rewind;
public long frameCount(); // headIndex + 1
public long headTick(); // live head frame index
public long previewTick(); // preview index while scrubbing, else head
public boolean isScrubbing();
public boolean isPaused();
public boolean isFrozen(); // paused || scrubbing
public TickRange window(); // [floor, head] rewindable span
public List<Marker> markers();
public void addSlice(SnapshotSlice slice); // attach custom rewindable state
public void mark(String label); // manual marker at head
public void mark(long index, String label, Marker.MarkerKind kind);

TickRange exposes lengthTicks(), lengthSeconds() (20 ticks = 1 s), and clamp(tick). Its floor/head are recording-local frame indices, see Timeline clock.

All implement ConduitEvent and fire on the global bus.

EventFired whenPayload
RecordingStartedEventa host arms a recordingrecordingId, dimension, armGameTime
RecordingStoppedEventa recording stops capturingrecordingId, dimension, armGameTime, framesCaptured
RewindBeganEventscrub mode enteredrecordingId, fromIndex (the live head it started from)
RewindCommittedEventa rewind is committedrecordingId, fromIndex, toIndex (resume point)
GamePausedEventa recorded level freezes (pause or scrub)recordingId, dimension
GameResumedEventa recorded level unfreezesrecordingId, dimension

GamePausedEvent / GameResumedEvent are the cooperative-pause hooks, game loops listen for them (or poll RewindRecorder.isFrozen(level)) and suspend/resume their own round logic.

A pluggable extra slice of state that rewinds alongside the engine’s players / blocks / entities, the seam for game-specific state like a Match score tally, a custom team map, or a minigame’s phase.

package me.zlex.conduit.rewind;
public interface SnapshotSlice {
/** Stable id for this slice within a recording (e.g. "match-score"). */
String id();
/** Snapshots the slice's current state, or null to skip this tick. */
Object capture(ServerLevel level);
/** Re-applies a previously captured snapshot on rewind. */
void restore(ServerLevel level, Object snapshot);
}

A slice is attached via Recording.addSlice. Its capture is polled each tick and results are change-deduped (so a rarely-changing slice costs little memory. The captured snapshot must be an immutable value (or a defensive copy) and implement equals()) identical states are deduped and not re-stored. On a rewind, the snapshot at-or-before the target frame is handed back to restore.

Recording recording = RewindRecorder.start(instanceLevel, RewindConfig.fromConfig(), hostUuid);
recording.addSlice(new SnapshotSlice() {
public String id() { return "my-state"; }
public Object capture(ServerLevel l) { return mySnapshot(); } // immutable + equals()
public void restore(ServerLevel l, Object s) { applySnapshot(s); }
});

MatchRewind.bind(level, match) is the arena-side use of SnapshotSlice, it attaches a match-score slice so a match’s point tally and round number roll back alongside a world rewind. It ships in conduit-arena (me.zlex.conduit.match.MatchRewind), so using it requires a conduit-arena dependency, not just conduit-rewind.

// requires conduit-arena
RewindRecorder.start(instanceLevel, RewindConfig.fromConfig(), host);
MatchRewind.bind(instanceLevel, match); // arena: Match score rewinds too

If no recording is active for the level, bind is a no-op and returns false, arm the recording first.