<!-- https://minecraftdocs.dev/systems/world/lighting — this page as markdown. The book's index: https://minecraftdocs.dev/llms.txt; the whole book in one file: https://minecraftdocs.dev/llms-full.txt -->

# Lighting

> Verified against **Minecraft 26.3** · Part IV · A torch is placed on a cave wall: the write queues a task, a worker floods the change, and the sections it touched are published as a copy and mailed to the client.

A player right-clicks a torch onto stone. The block goes into its
`LevelChunkSection` immediately, the heightmaps move, and then
`LevelChunk.setBlockState` reaches the light engine, for this torch, only to
call `LevelLightEngine.checkBlock`, which on the server computes nothing at
all.
It wraps the call in a runnable and puts it on a queue the level tick never
drains itself. There is no light thread and no light phase of
the tick: the flood that turns the torch's 14 into a sphere of falling
numbers runs because **the Server thread went looking for something to do**,
usually because the tick finished early. The kick is
`ThreadedLevelLightEngine.tryScheduleUpdate`, and its usual caller is
`ServerChunkCache.MainThreadExecutor.pollTask`, which the idle poll
`MinecraftServer.pollTaskInternal` reaches while the server is ahead of its
schedule, blocked or sprinting, and a synchronous chunk wait reaches in the
middle of a tick. Ask the server engine to light something synchronously and
it refuses: `ThreadedLevelLightEngine.runLightUpdates` throws.

## The cast

| class | what it decides | thread |
|---|---|---|
| `LevelLightEngine` | the facade both sides hold — one `LightEngine` per layer, the read side, and the padding above and below the world | whoever calls it |
| `ThreadedLevelLightEngine` | the server's wrapper: every mutator becomes a queued task, and running updates on demand is an error | queued from Server, drained on the light executor |
| `BlockLightEngine` · `SkyLightEngine` | the algorithm — what a changed block means, and how far the change travels | the light executor (Client: the Render thread) |
| `LayerLightSectionStorage` | the double buffer: which sections hold data, which changed, and when to publish | the same |
| `DataLayer` | 2048 bytes of nibbles for one section, or no bytes at all | whoever holds it |
| `ChunkSkyLightSources` | the chunk's 256-entry *lowest sky source Y* table — the one piece of light work that is not deferred | Server, inside the block write |
| `ServerChunkCache` | the `LightChunkGetter`: which chunks the engine may read, and where the one callback lands | callback on the light executor, body on Server |
| `ChunkHolder` | which sections each watching player is owed, as two `BitSet`s | Server |

## From a click to a lit wall

```mermaid
sequenceDiagram
    participant LC as LevelChunk
    participant TLE as ThreadedLevel<br/>LightEngine
    participant SCC as ServerChunkCache
    participant BLE as BlockLightEngine
    participant LLSS as LayerLight<br/>SectionStorage
    participant CH as ChunkHolder
    participant CL as ClientLevel

    Note over LC,CH: one server tick, inside LevelChunk.setBlockState
    LC->>LC: the light properties differ, so the column's sky-light source is repaired
    LC->>TLE: checkBlock, as a PRE_UPDATE task at the chunk's queue level
    Note over LC,SCC: still the same tick, and nothing has been lit
    SCC->>TLE: tryScheduleUpdate, because the poll found no chunk work
    Note over TLE,LLSS: the light executor, off the Server thread
    TLE->>BLE: the window's PRE tasks reach checkBlock, then runLightUpdates on this layer
    BLE->>BLE: checkNode enqueues a pull-in decrease and an emission increase of 14
    BLE->>LLSS: setStoredLevel, 14 then 13, 12 and down, as the increases propagate
    LLSS->>LLSS: a fresh copy of the updating map is published
    LLSS->>SCC: onLightUpdate once per affected section
    Note over SCC,CH: back on the Server thread, whenever the posted task is polled
    SCC->>CH: sectionLightChanged — it bails below INITIALIZE_LIGHT, and again with no ticking chunk
    Note over SCC,CH: end of ServerChunkCache.tickChunks, normally the next tick
    SCC->>CH: broadcastChanges, once the chunk tick is over
    CH->>CL: Clientbound<br/>LightUpdatePacket,<br/>to a player at the<br/>edge of the view
    Note over CL: the next frame, not the next tick
    CL->>CL: pollLightUpdates, applyLightData, then runLightUpdates on the client's own engine
```

*A torch placed, and the four boundaries the server's light crosses before it
reaches a player at the edge of the view: the placement and the lighting
are in the same tick but not on the same thread, the packet waits for the end
of the chunk tick, and the client applies it on a frame rather than a tick.
Nothing on this page blocks the Server thread on a light result.*

Most sections below walk one stretch of that diagram. Two classes are absent
because they carry no decision: `ChunkTaskDispatcher`, which holds the queued
task under the chunk's priority and hands it to the executor, and
`ClientPacketListener`, which on arrival only wraps the packet in a closure
for later.

## Two 4-bit fields, and the sections that may not exist

Light is `LightLayer.SKY` and `LightLayer.BLOCK`, computed by separate
machinery that shares only its shape. `LevelLightEngine` holds one
`LightEngine` per layer — `LevelLightEngine.skyEngine` is null in a dimension
whose `DimensionType.hasSkyLight` is false, and
`LevelLightEngine.getLayerListener` hands out
`LayerLightEventListener.DummyLightLayerEventListener` in its place. Readers
rarely see the layers apart: `LevelLightEngine.getRawBrightness` is the
maximum of block light and sky light minus a darkening term, which is what
`LevelReader.getMaxLocalRawBrightness` passes `LevelReader.getSkyDarken` into,
and `BlockAndLightGetter.canSeeSky` is just sky light at 15.

Storage is per layer and per *section*, one section taller than the world at
each end — `LevelLightEngine.LIGHT_SECTION_PADDING` is 1 and
`LevelLightEngine.getLightSectionCount` is the world's sections plus two. A
section's storage is a `DataLayer`: 16×16×16 nibbles in `DataLayer.SIZE`
bytes, indexed *y* then *z* then *x*, and allocated lazily. `DataLayer.data`
starts null and every read answers `DataLayer.defaultValue` until the first
write forces the array into existence, and `DataLayer.fill` throws it away
again. `DataLayer.isEmpty` therefore means *homogeneous zero*, not *all
zeroes I checked*, and it is the test both the chunk saver and the packet
builder use to charge nothing for a dark section.

Which sections have a `DataLayer` at all is decided by
`LayerLightSectionStorage.sectionStates`, one byte per section holding a
has-data bit and a five-bit count of how many of its 26 neighbours have data
(`LayerLightSectionStorage.SectionState`). A byte of zero means no storage,
so a section of pure air beside a built-up one is allocated and a section in
the middle of nothing is not. Above the sky column's top section there is no
storage at all, and `SkyLightSectionStorage.getLightValue` answers 15 without
looking; its upward walk happens *below* the top, when a
section inside the column has no `DataLayer` and the search climbs for one.

## The write that queues nothing but a task

`LevelChunk.setBlockState` asks two questions about light. If the section
flipped between all-air and not — the torch is the first block into an empty
section, or the last one out — `LevelLightEngine.updateSectionStatus` goes in.
Then, if `LightEngine.hasDifferentLightProperties` says the old and new states
differ in emission or in dampening, or if *either* of them uses a shape for
light occlusion, two things happen. The first is `ChunkSkyLightSources.update`
on the chunk's own table, and it runs **inline, on the Server thread**, inside
the block write: the sky column's *lowest source Y* for that one *(x, z)* is
repaired immediately, because everything downstream reads it. The second is
`LevelLightEngine.checkBlock`, and on the server that is where the work stops.
`ProtoChunk.setBlockState` does the same pair, but only once the chunk's
status has reached `ChunkStatus.INITIALIZE_LIGHT`; before that a generator
writing blocks tells the light engine nothing.

`ThreadedLevelLightEngine` overrides every mutator the same way. The call
becomes a `Runnable` tagged `ThreadedLevelLightEngine.TaskType.PRE_UPDATE` or
`ThreadedLevelLightEngine.TaskType.POST_UPDATE` and goes through
`ChunkMap.lightTaskDispatcher`, at the chunk's own queue level for the five
mutators that belong to one chunk —
`LevelLightEngine.checkBlock`, `LightEventListener.propagateLightSources`,
`LevelLightEngine.setLightEnabled`, `ThreadedLevelLightEngine.initializeLight`
and `ThreadedLevelLightEngine.lightChunk` — and at a flat top priority for
bookkeeping such as `LevelLightEngine.updateSectionStatus` and
`LevelLightEngine.queueSectionData`. When the dispatcher releases the task it
runs on the *light* executor, not on the Server thread, and all it does there
is append to `ThreadedLevelLightEngine.lightTasks` — a plain array list with
no synchronisation, safe precisely because only that executor appends to it.

## What actually kicks it

The light executor is a `ConsecutiveExecutor` named *light*, built in
`ChunkMap`'s constructor over the shared background pool. It has no thread of
its own: it takes one task from its queue, runs it on a borrowed pool thread,
and re-registers itself. Two callers ever schedule a batch on it.
`ServerChunkCache.MainThreadExecutor.pollTask` runs the distance-graph
updates first and calls `ThreadedLevelLightEngine.tryScheduleUpdate` only if
they had nothing to do — so light propagates in the Server thread's spare
moments, after the ticket system is quiescent
([when the graphs run](tickets-and-loading#when-the-graphs-run)). Whichever
caller gets there, `ThreadedLevelLightEngine.scheduled`, an `AtomicBoolean`,
keeps exactly one batch in flight. The other caller is
`ChunkMap.scheduleUnload`, kicking the engine right after it has *queued* the
nulling of an unloading chunk's data.

If nobody kicks, the queue is still not unbounded:
`ThreadedLevelLightEngine.addTask` runs a batch inline as soon as
`ThreadedLevelLightEngine.lightTasks` has reached a thousand, the number
`ThreadedLevelLightEngine.DEFAULT_BATCH_SIZE` names — still on the light
executor, still never on the Server thread.

## One batch, and what it publishes

The light executor's stretch of that trace is one batch, and a batch is
defined by the two maps it sits between rather than by the flooding it
does.

```mermaid
flowchart TD
    PRE["ThreadedLevelLightEngine.runUpdate takes a window of up to 1,000 tasks"]
    C["LightEngine.checkNode, on every queued position"]
    D["LightEngine.propagateDecreases, to an empty queue"]
    I["LightEngine.propagateIncreases, to an empty queue"]
    UP[("LayerLightSectionStorage.updatingSectionData: the scratch map, cloned per section")]
    S["LayerLightSectionStorage.markNewInconsistencies,<br/>then LayerLightSectionStorage.swapSectionMap"]
    VIS[("LayerLightSectionStorage.visibleSectionData: volatile, what every reader sees")]
    POST["the window's POST_UPDATE tasks run, and the window is dropped"]
    PRE --> C
    C --> D
    D --> I
    C -- "writes" --> UP
    D -- "writes" --> UP
    I -- "writes" --> UP
    I --> S
    UP -- "copied whole" --> S
    S --> VIS
    S --> POST
```

*One batch, drawn for one of the two layers it runs in turn between the same
PRE and POST tasks, and the two maps that layer is written between: the three
stages down the left all write the same scratch map, and none of them writes
the one another thread reads. That is why the picture has two cylinders and
not one — and why a reader of the visible map gets that layer before the batch
or after it, never a half-propagated flood.*

The two maps are why no reader of light ever waits on the light engine.
`LayerLightSectionStorage.updatingSectionData` is the engine's scratch copy
and no other thread reads it; `LayerLightSectionStorage.visibleSectionData` is
volatile and is what every reader sees, and what the saver and the packet
builder see wherever no newer layer is still queued.
`LayerLightSectionStorage.setStoredLevel` clones a section's `DataLayer`
through `DataLayerStorageMap.copyDataLayer` the first time the batch touches
that section, recording it in `LayerLightSectionStorage.changedSections`, and
`LayerLightSectionStorage.swapSectionMap` copies the whole updating map into
a new visible map at the end. A reader of the visible map on another thread
sees each layer before the batch or after it, never a half-propagated flood.

Inside a layer the order is fixed. Every position from
`LightEngine.blockNodesToCheck` goes through `LightEngine.checkNode`, which
zeroes the stored level where the light that is there must go and enqueues the
rest as work; then `LightEngine.propagateDecreases` runs the decrease
queue to empty, then `LightEngine.propagateIncreases` runs the increase queue
to empty. Decreases always finish first because a decrease discovers brighter
neighbours and enqueues them as *increase back toward me* refills — running
increases first would spread light that is about to be removed. Both queues
hold pairs of longs whose second member is a `LightEngine.QueueEntry`: four
bits of level, six of allowed directions, and the two flags
`LightEngine.QueueEntry.FLAG_FROM_EMPTY_SHAPE` and
`LightEngine.QueueEntry.FLAG_INCREASE_FROM_EMISSION`. The torch's own entries
are `LightEngine.PULL_LIGHT_IN_ENTRY` — a level-1 decrease in all six
directions, meaning *re-pull from my neighbours* — and an emission increase of
14, a torch, `Blocks.TORCH` or `Blocks.WALL_TORCH`, being a
`BlockBehaviour.Properties.lightLevel` of 14.
`BlockLightEngine.propagateIncrease` then spreads level minus
`LightEngine.getOpacity`, which is
`BlockBehaviour.BlockStateBase.getLightDampening` — by default derived rather
than declared: 15 for a solid render, 0 for a state that
`BlockBehaviour.BlockStateBase.propagatesSkylightDown`, 1 otherwise, though
leaves and tinted glass declare their own — floored
at `LightEngine.MIN_OPACITY`. It stops where a neighbour is already as bright
and where `LightEngine.shapeOccludes`. A level of 1 is still *written*; what
it does not do is propagate, because only a new level above 1 is enqueued
again. That is why a torch at 14 reaches thirteen blocks and no further.

The batch tells the chunk holders which sections changed only through
`LightChunkGetter.onLightUpdate`, fired from the map swap. The engine has no
idea that `ChunkHolder` exists.

## The sky column is a table, not a flood

Sky light does not use the heightmaps, and it does not usually propagate
downward one block at a time. Each chunk owns a `ChunkSkyLightSources`
(`ChunkAccess.skyLightSources`), a bit-packed 256-entry table of *the lowest
Y at this (x, z) that still sees the sky*, filled by
`ChunkSkyLightSources.fillFrom` and repaired per block by
`ChunkSkyLightSources.update`. What ends a column is
`ChunkSkyLightSources.isEdgeOccluded`: the lower block dampens light at all,
or the two faces occlude each other.

`SkyLightEngine.checkNode` reads that table and takes one of three paths. A
block at or above the column's lowest source enqueues a remove-source
decrease and an add-source increase — the expensive case. A block below it
that held light has that light zeroed and decreased away. A block below it
that was already dark, which is what a torch under a solid ceiling is, gets a
pull-in that changes nothing: placing a torch in a cave does no sky work
worth measuring. Before any of the three, `SkyLightEngine.updateSourcesInColumn`
runs `SkyLightEngine.removeSourcesBelow` and `SkyLightEngine.addSourcesAbove`
so the stored 15s agree with the table again.

Two more pieces of the sky model exist because sections may have no storage.
`SkyLightEngine.propagateFromEmptySections` handles light crossing a section
edge sideways at the bottom row of a section: if the source column had no
data for a run of sections below it, light had been continuing downward
implicitly, so the same level is written straight down the destination column
through that run. And `SkyLightSectionStorage.createDataLayer` seeds a newly
needed section below existing data by repeating the bottom slice of the
section above it (`SkyLightSectionStorage.repeatFirstLayer`) rather than
starting dark.

## Lit before you ever see it

Generation lights a chunk in two steps, and the first usually turns light
*off*. `ChunkStatusTasks.initializeLight` builds the sky table with
`ChunkAccess.initializeLightSources`, then calls
`ThreadedLevelLightEngine.initializeLight`, whose PRE task marks every
non-air section as having data and whose POST task sets the column's enabled
flag to whatever `ChunkStatusTasks.isLighted` says — *false* for a freshly
generated chunk, whose persisted status has not reached `ChunkStatus.LIGHT`.
Enabling is the next step's doing: `ChunkStatusTasks.light` calls
`ThreadedLevelLightEngine.lightChunk`, which for an unlit chunk runs
`LevelLightEngine.propagateLightSources`, and both
`BlockLightEngine.propagateLightSources` and
`SkyLightEngine.propagateLightSources` open by enabling the column before
seeding it — the block engine from `LightChunk.findBlockLightSources`, the sky
engine from its own table and its four neighbours'. Only then does a POST
task set `ChunkAccess.setLightCorrect`. A chunk read from disk with
*isLightOn* set skips the propagation entirely, because
`ChunkStatusTasks.isLighted` hands `ThreadedLevelLightEngine.lightChunk` a
flag that says it is lit.

Two consequences are worth naming. `ServerChunkCache.getChunkForLighting`
hands the engine chunks at `ChunkStatus.FEATURES`, one status below
`ChunkStatus.INITIALIZE_LIGHT` — the engine reads chunks the rest of the game
is not allowed to see yet. And nothing waits for light at the send itself: a
chunk reaches `ChunkStatus.FULL` only after its own `ChunkStatus.LIGHT` task
has run on the light executor, and `ChunkPyramid.GENERATION_PYRAMID` gives
`ChunkStatus.LIGHT` a requirement of `ChunkStatus.INITIALIZE_LIGHT` at radius
1, so a chunk cannot climb to `ChunkStatus.FULL` until its neighbours have
their sections marked too ([the pyramid,
drawn](chunk-generation-pipeline#the-pyramid-drawn)), and it is sent only
from `ChunkMap.prepareTickingChunk`, which waits for all eight neighbours at
`ChunkStatus.FULL`, so each of them has run its own `ChunkStatus.LIGHT` task
as well. The one real send
dependency, `ChunkMap.waitForLightBeforeSending` →
`ThreadedLevelLightEngine.waitForPendingTasks` → `ChunkHolder.addSendDependency`,
has exactly one caller: `EnderDragonFight`, holding back the send of the
chunks around a new exit portal until its light is in.

### And what unloading throws away

Unloading is the mirror: `ChunkMap.scheduleUnload` calls
`ThreadedLevelLightEngine.updateChunkStatus`, which disables the column,
drops the retain flag and nulls every section's data
([a chunk nobody needs any
more](chunk-storage#a-chunk-nobody-needs-any-more)). On load
`SerializableChunkData.read`
calls `LevelLightEngine.retainData` once and then
`LevelLightEngine.queueSectionData` for each saved `DataLayer`.

## Off the server thread and onto the wire

`ServerChunkCache.onLightUpdate` is the whole of the hop back. It is called
on the light executor, and its body is a task posted to
`ServerChunkCache.mainThreadProcessor` — no `ChunkHolder` is touched
off-thread. When the Server thread later runs that task it finds the holder
in the visible map and calls `ChunkHolder.sectionLightChanged`, which has two
gates and does its one piece of bookkeeping between them. It gives up at once
on a chunk not present at `ChunkStatus.INITIALIZE_LIGHT`; otherwise it marks
the chunk unsaved (light is saved data); then it gives up again if there is no
ticking chunk to broadcast for. Only past both, for a section in range whose
bit is not already set, does it set one bit in
`ChunkHolder.skyChangedLightSectionFilter` or
`ChunkHolder.blockChangedLightSectionFilter` and answer yes, and the posted
task then puts the holder in `ServerChunkCache.chunkHoldersToBroadcast`. So a
chunk short of `ChunkStatus.INITIALIZE_LIGHT` is not even marked dirty by a
light change, and one that is loaded but not ticking is
marked dirty and never broadcast.

### One packet, and only to the players who cannot work it out

The packet goes out at the end of `ServerChunkCache.tickChunks` — once a tick
even while the tick rate is frozen, and never at all in a debug world,
whose `Level.isDebug` guard wraps that whole method.
`ServerChunkCache.broadcastChangedChunks` calls `ChunkHolder.broadcastChanges`,
which builds **one** `ClientboundLightUpdatePacket` per chunk from the two
filters and sends it before any block-change packet — but not to everyone
watching. It asks `ChunkHolder.PlayerProvider.getPlayers` with *borderOnly*
true, so `ChunkMap.isChunkOnTrackedBorder` keeps only the players for whom
this chunk has an untracked neighbour. A player in the middle of their own
view distance is never sent light for a block change: they were sent the
block, they have every neighbouring chunk, and their own engine will reach
the same numbers. The packet exists for the players who cannot, because the
flood may be arriving from a chunk they do not have. Its
`ClientboundLightUpdatePacketData` carries four bitsets — a data mask and an
empty mask per layer — and the 2048 bytes of each non-empty changed section,
read through `LayerLightEventListener.getDataLayerData`, which answers the
queued layer if there is one and the *visible* map otherwise, never the
updating copy. A section whose `DataLayer` is empty costs one bit; one with
no `DataLayer` at all appears in neither mask. The same
`ClientboundLightUpdatePacketData` rides inside
`ClientboundLevelChunkWithLightPacket` with both filters null — every section
— when `PlayerChunkSender.sendChunk` first sends a chunk.

**Up to fourteen sections** can be dirtied by one torch, and not because a
write marks its neighbours. `LayerLightSectionStorage.setStoredLevel` marks
only the
sections within one block of the position written
(`SectionPos.aroundAndAtBlockPos`): one for an interior block, up to eight
for a block on a corner. The 14 comes from the flood, which writes as far as
thirteen blocks away in taxicab distance. Grow that octahedron by the
one-block halo and it can reach three sections deep on every axis, but never
fills the 3×3×3 box of 27, whose outer sections need diagonals the octahedron
does not have. Fourteen sections, across as
many as seven chunk columns — and each of the fourteen is one the client will
have to re-mesh. The real 3×3×3
marking, `LayerLightSectionStorage.markSectionAndNeighborsAsAffected`, fires
only when a section is first given a `DataLayer`.

## The client lights per frame

The client runs the same `LevelLightEngine` unwrapped: `ClientChunkCache.lightEngine`
is a plain one, and its `LightChunkGetter.onLightUpdate` goes straight to
`LevelExtractor.setSectionDirty`. It runs from `ClientLevel.update`, which
`Minecraft.renderFrame` calls once a **frame**, not once a tick, so light
converges at your framerate. `ClientPacketListener.handleLightUpdatePacket`
applies nothing; it pushes a closure onto `ClientLevel.lightUpdateQueue`, and
`ClientLevel.pollLightUpdates` runs a tenth of them per frame, at least ten,
or, once a thousand are queued, all of them ([what it
does simulate: the two
cadences](../client/the-client-level#what-it-does-simulate-the-two-cadences)
owns that budget). Each closure is `ClientPacketListener.applyLightData`:
`ClientPacketListener.readSectionList` turns every masked section into a
cloned or empty `DataLayer` through `LevelLightEngine.queueSectionData` and
dirties it with its neighbours, and then the chunk's column is enabled with
`LevelLightEngine.setLightEnabled`. Only after the polled closures does
`ClientLevel.update` call `LevelLightEngine.runLightUpdates`, splicing the
queued layers in and swapping the map exactly as the server does.

Enabling a column is a separate thing from lighting it, and on the client that
distinction reaches all the way to the geometry: a section is not meshed at all
until `LevelLightEngine.lightOnInColumn` is true for each of its eight
surrounding columns, so **a light flag decides whether a section may have a
mesh** ([the sweep that only looks at what you can
see](../rendering/section-meshing#the-sweep-that-only-looks-at-what-you-can-see)).
Meanwhile the client had already lit this torch itself:
`MultiPlayerGameMode.startPrediction` runs the placement locally through the
same `LevelChunk.setBlockState`, and for the player who placed it, in the
middle of their own view, no light packet follows at all: their own engine's
numbers are the ones they see.

## Questions players ask

**Why does the server's light reach some players a tick after the torch
lands?** Only a player at the edge of the view is sent light at all, and for
them the write only queues a task, the queue waits for
the Server thread's spare moments, the callback is posted back to the Server
thread, and the packet is built at the end of `ServerChunkCache.tickChunks` —
four hand-offs, only the last of them on a schedule. Every watching client
lights the torch itself when the block arrives; the packet brings what flooded
in from chunks that player does not hold.

**Why do the F3 light numbers not match what I see?** They are three
numbers, and none of them is the one you are looking at. `DebugEntryLight`
prints a combined figure and then the two raw layers, and the combined figure
is `LevelLightEngine.getRawBrightness` called with the time-of-day darkening
argument set to **zero** — so even it is a noon answer. What renders is the
two layers looked up in the `Lightmap` texture, which is where the hour, the
dimension and the mob effects arrive ([the lightmap](../rendering/lightmap-fog-and-sky)).

> **For a 1.21-era reader.** *getLightBlock* is now
> `BlockBehaviour.BlockStateBase.getLightDampening`, and *LightTexture* is
> `Lightmap` (Part XI). `DynamicGraphMinFixedPoint`, `LeveledPriorityQueue`
> and `SpatialLongSet` still live in `world/level/lighting`, but no light
> engine uses them any more: `DynamicGraphMinFixedPoint` survives for
> `ChunkTracker` ([tickets](tickets-and-loading)) and `SectionTracker`
> ([points of interest](points-of-interest)) and takes `LeveledPriorityQueue`
> with it, and `SpatialLongSet` has no callers at all.

## Where to look

Follow the torch: `LevelChunk.setBlockState` ·
`LightEngine.hasDifferentLightProperties` ·
`ThreadedLevelLightEngine.tryScheduleUpdate` ·
`ThreadedLevelLightEngine.runUpdate` · `BlockLightEngine.checkNode` ·
`BlockLightEngine.propagateIncrease` ·
`LayerLightSectionStorage.swapSectionMap`. The sky's own machinery is a
separate read: `ChunkSkyLightSources.update` · `SkyLightEngine.checkNode` ·
`SkyLightSectionStorage.getLightValue`. Then the way out:
`ServerChunkCache.onLightUpdate` · `ChunkHolder.broadcastChanges` ·
`ClientPacketListener.applyLightData` · `ClientLevel.pollLightUpdates`. Where
generation turns light on and computes it:
`ThreadedLevelLightEngine.initializeLight` ·
`ThreadedLevelLightEngine.lightChunk`.

---

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