Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Scheduled ticks

Verified against Minecraft 26.3 · Part IV · A repeater’s input goes high, and the two ticks before its output follows are one entry in a queue.

A repeater set to its shortest delay is not counting anything. When the wire behind it changes, DiodeBlock.neighborChanged, which RepeaterBlock inherits, runs on the Server thread, notices that DiodeBlock.POWERED no longer matches the input, and books an appointment — a ScheduledTick naming this Block at this BlockPos, due two game ticks from now, at TickPriority.HIGH — and then forgets. Two ticks later a scheduler the repeater has never heard of hands the position back to ServerLevel.tickBlock, which checks that a repeater is still there and calls the block again. Nearly everything the world does later is one of these: a fluid flowing, a pressure plate releasing, a dispenser firing, a redstone torch burning out. They all share two queues per chunk and one rule that surprises everybody: the queue dedups on type and position alone, so a second tick for the same block — even a much sooner one — is silently dropped.

“Rescheduling moves the tick” is folklore. ScheduledTick.UNIQUE_TICK_HASH hashes the position and compares the type by object identity — the trigger tick, the priority and the sub-order are no part of it — and LevelChunkTicks.schedule queues a tick only if its dedup set, LevelChunkTicks.ticksPerPosition, did not already hold that pair. The booking that loses is not queued, not merged and not logged, which is why so many blocks ask before they book: DiodeBlock, ComparatorBlock and RedstoneTorchBlock consult LevelTickAccess.willTickThisTick, and seven blocks — ObserverBlock, TargetBlock, LightningRodBlock, TripWireBlock, SculkSensorBlock, DriedGhastBlock and SpeleothemBlock — consult TickAccess.hasScheduledTick.

The cast

classwhat it decidesthread
ScheduledTickthe appointment itself — type, position, trigger tick, TickPriority, sub-order — and the comparisons the system sorts and dedups bya record, no thread
LevelChunkTicksone chunk’s queue and its dedup set: whether a booking is new, and which of its ticks is nextServer
LevelTicksthe per-level scheduler: which chunks are due, which ticks run this level tick, and where the budget fallsServer
ScheduledTickAccessthe write side every block sees, so no block knows which container it is booking intoany — the worldgen executor books through it
ServerLevelwhen the drain runs, the per-chunk gate it runs under, and the type re-check that makes a tick cancellableServer
SavedTickthe disk form: a relative delay in place of an absolute timeServer, written by the IO worker
ProtoChunkTicksa generating chunk’s bookings, all at delay zerothe worldgen executor
BlackholeTickAccessaccept every booking and run nothing — the client’s answer to the whole system, and also an ImposterProtoChunk’sRender, and the worldgen executor

Those containers implement one small interface stack — TickAccess (schedule, ask, count), TickContainerAccess per chunk, LevelTickAccess per level, which also answers LevelTickAccess.willTickThisTick, and SerializableTickContainer for one that can SerializableTickContainer.pack itself for disk — which is why LiquidBlock and RepeaterBlock run unchanged during generation, on a client that will never tick them, and on a server that will.

The pipeline, end to end

The pipeline has a seam in it, booking and the drain, so it is two figures. This is booking into a live level; the worldgen executor books into a generating chunk’s own list instead (below).

flowchart TD
    B["a block books: ScheduledTickAccess.scheduleTick"]
    C["LevelAccessor.createTick — game time plus delay, a TickPriority, a sub-order"]
    S{"LevelTicks.schedule looks for the chunk's container"}
    DROP["Util.logAndPauseIfInIde — logged and dropped, never deferred"]
    D{"LevelChunkTicks.ticksPerPosition already holds this type and position"}
    SKIP["dropped in silence, even when this one is sooner"]
    Q[("one priority queue per chunk, ordered by ScheduledTick.DRAIN_ORDER")]
    B --> C
    C --> S
    S -- "no container" --> DROP
    S -- "found" --> D
    D -- "yes" --> SKIP
    D -- "no" --> Q

Everything that can happen to an appointment booked into a live level before it is a queued tick — and two of the three outcomes are that it is not one. The right-hand refusal is the page’s subject: the dedup slot is checked against the type and position alone, so the second booking is thrown away whether or not it is due sooner than the one already there.

The drain is the other half, it runs once per level tick per type, and it never leaves the Server thread.

flowchart TD
    IDX[("LevelTicks.nextTickForContainer: one entry per container, the head's time")]
    SC{"LevelTicks.sortContainersToTick asks LevelTicks.tickCheck"}
    DR["LevelTicks.drainContainers, while the budget lasts"]
    RUN["LevelTicks.runCollectedTicks, to ServerLevel.tickBlock or ServerLevel.tickFluid"]
    CL["LevelTicks.cleanupAfterTick empties all four working collections"]
    AGAIN["a block books again, from inside its own run"]
    IDX --> SC
    SC -- "the chunk is not ticking" --> IDX
    SC -- "due, and ticking" --> DR
    DR -- "overtaken, budget left" --> DR
    DR -- "not due, or the budget spent" --> IDX
    DR --> RUN
    RUN --> CL
    RUN --> AGAIN

The three phases of one drain, and the ways back to the index, none of them a failure: a container whose next tick is not yet due waits for it, and one whose chunk is not ticking or whose budget ran out is late, never lost. The bottom-right box is the loop this page’s repeater lives in — the run books the next appointment, which re-enters the figure above.

Everything below is one stage of those two figures.

Booking: a type, a position, a time and a tie-breaker

A block calls one of the ScheduledTickAccess.scheduleTick defaults with a delay in ticks and, optionally, a TickPriority. The default asks the level for LevelAccessor.createTick, which stamps the appointment with LevelAccessor.getGameTime plus the delay, the priority (TickPriority.NORMAL if none was given) and a fresh sub-order from LevelAccessor.nextSubTickCount, then hands it to ScheduledTickAccess.getBlockTicks or ScheduledTickAccess.getFluidTicks. Two type arguments, two parallel worlds: every class here — LevelTicks, LevelChunkTicks, ScheduledTick — declares one parameter, and it is filled with Block in one world and Fluid in the other, so a block tick and a fluid tick never meet and ServerLevel owns one LevelTicks of each.

The sub-order is the FIFO tie-breaker for two ticks at the same time and priority, and it carries the one threading fact of this page. Level.subTickCount is a plain counter incremented by Level.nextSubTickCount, because a level’s scheduler is touched only from the Server thread; WorldGenRegion.subTickCount is an atomic one, because generation books ticks off the Server thread, on the worldgen executor. The drain is server-thread only. Booking is not.

TickPriority runs TickPriority.EXTREMELY_HIGH (−3) through TickPriority.NORMAL (0) to TickPriority.EXTREMELY_LOW (3), lower first, and it is not a queue-jump across time: a TickPriority.LOW tick due now still beats a TickPriority.EXTREMELY_HIGH tick due next tick. Priority only settles ties between ticks already due together.

Fluids are the scheduler’s largest customer by a distance, and they exploit the dedup key: Fluids.WATER and Fluids.FLOWING_WATER are different registry objects, so one tick of each can be pending at one position. What those ticks then do is fluids.

Where an appointment waits

Every LevelChunk owns exactly two containers, LevelChunk.blockTicks and LevelChunk.fluidTicks, and they are the only place a live chunk’s tick waits until a drain collects it (the four shapes a chunk takes): a priority queue, LevelChunkTicks.tickQueue, in ScheduledTick.DRAIN_ORDER, beside the dedup set that decides what gets into it.

LevelTicks never scans those queues looking for work. It keeps LevelTicks.allContainers, chunk key to container, and LevelTicks.nextTickForContainer, chunk key to the earliest trigger time that chunk holds, defaulting for an unknown chunk to the largest possible long. The index is maintained by LevelTicks.chunkScheduleUpdater, the callback every container is handed through LevelChunkTicks.setOnTickAdded when it registers, and it acts only when the tick just added is the container’s new head. A chunk with nothing due costs one map entry and one comparison per level tick, and a chunk with an empty queue costs nothing at all — which is what makes tens of thousands of loaded chunks affordable.

Registration follows the chunk’s life exactly: ChunkStatusTasks.full calls LevelChunk.registerTickContainerInLevel (LevelTicks.addContainer, both types), and ServerLevel.unload calls LevelChunk.unregisterTickContainerFromLevel → LevelTicks.removeContainer, which also drops the callback so an orphaned container can no longer touch the index. In between, a tick aimed at a chunk with no registered container is neither deferred nor queued: LevelTicks.schedule finds nothing, calls Util.logAndPauseIfInIde, and the appointment ceases to exist. ClientLevel short-circuits all of it — ClientLevel.getBlockTicks and ClientLevel.getFluidTicks return BlackholeTickAccess.emptyLevelList, which accepts every booking, answers false to every question and runs nothing, so everything a client sees of a flowing fluid or a firing repeater arrives as block-update packets. (ContainerSingleItem also lives in world/ticks, a one-slot inventory interface with nothing to do with ticks — a packaging accident, noted only so it does not confuse you.)

What one drain actually does

ServerLevel.tick calls LevelTicks.tick twice in its tickPending section, blocks first and then fluids, each with the current game time and a budget of ServerLevel.MAX_SCHEDULED_TICKS_PER_TICK, 65536 — a budget per call, so 65536 block ticks and 65536 fluid ticks (the level tick). The whole section is skipped in a debug world and whenever TickRateManager.runsNormally is false. Each call is three phases.

Collect. LevelTicks.sortContainersToTick walks the index, not the containers: it leaves a future entry alone, deletes one whose container has vanished or emptied, corrects one whose head is later than the index claimed, and tests a genuinely due one against LevelTicks.tickCheck. That predicate is ServerLevel.isPositionTickingWithEntitiesLoaded, asked about the chunk, not the position, and true only when the chunk is in DistanceManager.inBlockTickingRange, its ChunkHolder.getTickingChunkFuture has already succeeded, and PersistentEntitySectionManager.areEntitiesLoaded holds (two graphs, one store). A chunk that fails keeps its index entry untouched and is asked again next tick — its ticks are late, never lost — and one that passes moves into LevelTicks.containersToTick, a priority queue of containers ordered by LevelTicks.CONTAINER_DRAIN_ORDER, on their heads.

LevelTicks.drainContainers polls the best container, takes one tick and hands to LevelTicks.drainFromCurrentContainer, which keeps pulling from that same container while its next tick is still due and still beats the next-best container’s head — containers are re-heaped only when the winner stops winning. Where a container goes next turns on the budget as much as on its own head. Overtaken but still due, with budget left: back into the container queue, to be polled again this tick. Head no longer due: back to the index at its head’s time. Still due but the budget is spent: back to the index, and asked again next tick. Drained empty: neither. Then LevelTicks.rescheduleLeftoverContainers runs over whatever is still sitting in the container queue when the drain stopped and returns each to the index at its head’s trigger time, already in the past — which gets it collected next tick but buys it no place in the order, because LevelTicks.CONTAINER_DRAIN_ORDER compares priority and sub-order and has no time term at all.

Run. LevelTicks.runCollectedTicks drains LevelTicks.toRunThisTick in order, moving each entry to LevelTicks.alreadyRunThisTick and handing its position and type to ServerLevel.tickBlock or ServerLevel.tickFluid. Both re-read the world there and run BlockBehaviour.BlockStateBase.tick or FluidState.tick only if the block or fluid is still the one the appointment named. A tick is a promise to a type, and that check is the whole of cancellation for anything a block does: break the block and its pending ticks evaporate with no cancellation code anywhere. The only code that removes a pending tick outright is bulk — LevelChunkTicks.removeIf, through LevelTicks.clearArea below. Clean up is LevelTicks.cleanupAfterTick, emptying all four working collections including LevelTicks.toRunThisTickSet, which is built lazily and only if somebody asks LevelTicks.willTickThisTick. That list is the whole difference between the two questions a block can ask. TickAccess.hasScheduledTick reads the container and answers is one booked; LevelTickAccess.willTickThisTick reads this already-collected list and answers is one about to run in this very level tick — a thing TickAccess.hasScheduledTick can no longer see, because LevelChunkTicks.poll freed the slot during collect.

The comparisons, and which is used where

comparisoncomparesused by
ScheduledTick.DRAIN_ORDERtrigger tick, then priority, then sub-orderthe priority queue inside every LevelChunkTicks
ScheduledTick.INTRA_TICK_DRAIN_ORDERpriority, then sub-order — no time termLevelTicks.drainFromCurrentContainer, comparing two already-due ticks
LevelTicks.CONTAINER_DRAIN_ORDERthe same, applied to two containers’ headsLevelTicks.containersToTick
LevelChunkTicks.SUB_TICK_ORDERINGsub-order aloneLevelChunkTicks.pack, on save only
ScheduledTick.UNIQUE_TICK_HASHnot an ordering — identity on type and positionLevelChunkTicks.ticksPerPosition and LevelTicks.toRunThisTickSet

Time drops out of the second comparison because a container reaches the collect queue only once its head is already due, and once everything in play is due, time no longer discriminates.

Separating the phases has two consequences. A tick booked during the run phase lands in its container after collect has finished, so it waits for a later drain even at delay zero — the one exception being a block tick that books a fluid tick at delay zero, which the fluid drain, running afterwards in the same ServerLevel.tick, still catches. And the dedup slot is released by LevelChunkTicks.poll during collect, not at run, so a tick may book its own successor from inside its own run: exactly how a fluid keeps flowing and how a repeater arms its turn-off.

That the run list outlives the run is what makes bulk edits correct across the phase boundary. LevelTicks.copyAreaFrom, called by /clone in CloneCommands, harvests matching ticks from LevelTicks.alreadyRunThisTick, from LevelTicks.toRunThisTick and from the containers in the area, then re-bases every sub-order above the highest it found so copies keep their relative order without colliding with the originals. LevelTicks.clearArea does the mirror image for the gametest framework (StructureUtils.clearSpaceForStructure, GameTestInfo). Both touch block ticks only — nothing in the game copies or clears fluid ticks by area.

A repeater, appointment by appointment

A repeater with RepeaterBlock.DELAY 1 — RepeaterBlock.getDelay doubles it, so two game ticks — with a redstone wire behind it that goes to 15 and then back to 0 one tick later.

sequenceDiagram
    participant SL as ServerLevel
    participant RB as RepeaterBlock
    participant LTs as LevelTicks
    participant LCTs as LevelChunkTicks

    SL->>RB: neighborChanged — the wire behind went to 15
    RB->>RB: DiodeBlock.<br/>checkTickOnNeighbor<br/>— unlocked, unpowered,<br/>so it should turn on
    RB->>LTs: willTickThisTick at this position? no
    RB->>SL: scheduleTick — delay 2, TickPriority.HIGH
    SL->>SL: createTick — gameTime plus 2, HIGH, nextSubTickCount
    SL->>LTs: schedule
    LTs->>LCTs: schedule — the dedup slot is free, the queue takes it
    LCTs-->>LTs: updateContainerScheduling — the index learns gameTime plus 2
    Note over SL,RB: next tick, the wire drops to 0 — the diode books nothing and cancels nothing
    Note over SL,LCTs: two ticks after the booking, in the level tick's pending-block phase
    LTs->>LCTs: poll — the tick leaves the queue and the dedup set
    LTs->>SL: tickBlock at this position, for Blocks.REPEATER
    SL->>RB: still a repeater, so<br/>BlockBehaviour.<br/>BlockStateBase.tick
    RB->>SL: setBlock POWERED true, with Block.UPDATE_CLIENTS
    SL->>RB: the section write lands, then DiodeBlock.onPlace
    RB->>SL: neighborChanged on the block it powers, then that block's other sides
    RB->>SL: scheduleTick again — the turn-off, at TickPriority.VERY_HIGH

One repeater, from the wire behind it changing to the appointment for turning itself off again. The first note bar is the tick the page is built on — the wire went back to zero and the booking already made was neither cancelled nor noticed — and the second opens the tick in which the appointment runs.

The booking is a priority the block chooses, not one this queue assigns. The repeater above asks for TickPriority.HIGH to turn on and TickPriority.VERY_HIGH to turn off, and which of the seven each redstone block asks for in which situation — and why a comparator cannot ask for the two most urgent at all — is diodes and the observer. What this page supplies is what the priority then buys: the sub-tick order above.

A booking cannot be called off. Nothing in the game cancels a single scheduled tick: the only removal is the gametest framework’s bulk LevelTicks.clearArea above. So a booking made while the input was present survives the input going away, and the block finds out only when its turn comes. That is the queue’s half of pulse extension; what a repeater does when it gets that turn is the diode page’s.

Nothing here uses Block.UPDATE_NEIGHBORS (block update flags has the bits), so the write in the diagram above wakes no neighbour through Level.setBlock’s neighbour channel; only its shape pass runs. How a diode’s signal leaves instead is diodes and the observer.

The other kind of turn: random ticks

The appointment book is one of the ways a block gets a turn, and the contrast with random ticks is what defines it: a random tick is booked by nobody, aimed at no block, and carries no promise.

It also reaches a different set of chunks, and the difference is one ring. Random ticks walk ChunkMap.forEachBlockTickingChunk, which despite its name is the entity-ticking set; the scheduled-tick gate, ServerLevel.isPositionTickingWithEntitiesLoaded, reads the wider block-ticking radius instead. So at the edge of a player’s simulation distance there is a ring of chunks where appointments still come due and nothing is ever chosen at random. How the level tick then spends the rule’s number, and why a section of solid stone costs nothing, belongs to the level tick.

Two things about that walk belong here, because they are about what a promise is worth. The first is that the eligible blocks are decided at bootstrap, not per tick: BlockBehaviour.BlockStateBase.isRandomlyTicking is baked into the state at BlockBehaviour.BlockStateBase.initCache from BlockBehaviour.isRandomlyTicking, which defaults to BlockBehaviour.Properties.randomTicks, so unlike an appointment, which names a type and is checked against the world when it comes due, a random tick’s eligibility is settled before the world exists.

The second is that lava gets its random tick twice. LiquidBlock.isRandomlyTicking and LiquidBlock.randomTick both delegate straight to the fluid, so a chosen lava position runs LavaFluid.randomTick once through the block branch of that loop and again through the fluid branch. It is the only fluid it happens to: Fluid.isRandomlyTicking is false by default and LavaFluid.isRandomlyTicking is the sole override. Water is never randomly ticked at all — every inch of its motion is a scheduled tick.

Appointments that survive a restart

A SavedTick stores a relative delay rather than an absolute time, so a chunk that unloads while the world runs on fires its ticks the same number of ticks after it starts ticking again: ScheduledTick.toSavedTick subtracts the current game time on the way out and SavedTick.unpack adds the new one on the way back. LevelChunkTicks.pack writes the pending list first and then the live queue sorted by LevelChunkTicks.SUB_TICK_ORDERING, and SerializableChunkData stores the two lists under block_ticks and fluid_ticks through SavedTick.codec (copy on the server, encode on a worker, write on the IO lane). On the way in, SavedTick.filterTickListForChunk discards any saved tick whose position is not in the chunk being loaded.

Coming back is two-stage. SerializableChunkData builds a LevelChunkTicks for a chunk at ChunkStatus.FULL and a ProtoChunkTicks for one below it, and the LevelChunkTicks constructor holds the saved list as LevelChunkTicks.pendingTicks — not in the queue — while pre-seeding the dedup set from it, so a fresh booking cannot double up a saved one that is not unpacked yet. The saved ticks enter the queue only when ChunkMap.prepareTickingChunk reaches ServerLevel.startTickingChunk → LevelChunk.unpackTicks → LevelChunkTicks.unpack, which counts sub-orders up from minus the list’s length. Every unpacked tick gets a negative sub-order, and Level.subTickCount starts each session at zero and only rises, so a loaded tick always sorts before anything this session booked at the same time and priority.

Generation takes the same road. A ProtoChunk holds ProtoChunkTicks, which records everything at delay zero and dedups under SavedTick.UNIQUE_TICK_HASH, and WorldGenRegion exposes both of its containers as a WorldGenTickAccess — a router that finds the right chunk per position and answers LevelTickAccess.willTickThisTick with a flat false. At promotion ProtoChunk.unpackBlockTicks turns that list into a LevelChunkTicks in the pending state, and it becomes real at the same ServerLevel.startTickingChunk a loaded chunk goes through — due immediately, at the game time the chunk started ticking.

Questions players ask

Why did breaking one block stop a machine that was two ticks from firing? A tick names a type. The appointment stays in the queue and still runs, but ServerLevel.tickBlock re-reads the position, finds a different block, and runs it to nothing.

Does /tick freeze stop scheduled ticks? Yes, and among the tick commands it is the only one that does — a debug world skips the section too. TickRateManager.runsNormally returns TickRateManager.runGameElements, recomputed every tick as not frozen, or stepping — so /tick step runs the tickPending section normally for the ticks it steps, and /tick sprint clears the freeze flag outright for the length of the sprint (ServerTickRateManager.requestGameToSprint) and puts back whatever it found when the sprint ends.

Where do my ticks go when a chunk stops ticking? Nowhere. They sit in the chunk’s own queue, the index entry is left untouched, and the moment the chunk is block-ticking again the due ones are collected, as far as the drain’s budget goes. If the chunk unloads first they are written to disk with it. Outside generation and the test framework’s area clear, the only appointment lost is one booked into a chunk with no registered container.

For a 1.21-era reader. BlockBehaviour.updateShape no longer takes a LevelAccessor. It takes a LevelReader and a separate ScheduledTickAccess — a small interface for booking: ScheduledTickAccess.createTick, ScheduledTickAccess.getBlockTicks, ScheduledTickAccess.getFluidTicks and four ScheduledTickAccess.scheduleTick overloads that compose them. Every waterloggable block books water’s tick from its own override of it.

Where to look

The appointment and the two things that decide its fate: ScheduledTick · ScheduledTick.UNIQUE_TICK_HASH · ScheduledTick.DRAIN_ORDER. Booking, from the block’s side in: ScheduledTickAccess.scheduleTick · LevelAccessor.createTick · LevelChunkTicks.schedule. One drain, in its three phases: LevelTicks.tick · LevelTicks.sortContainersToTick · LevelTicks.drainContainers · LevelTicks.runCollectedTicks · ServerLevel.tickBlock · LevelTicks.cleanupAfterTick. The gate the collect phase asks: ServerLevel.isPositionTickingWithEntitiesLoaded. And the disk form and the two stand-ins for a live chunk’s containers: SavedTick · ProtoChunkTicks · BlackholeTickAccess.


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.