Skip to content

Light Engine

TheMeinerLP edited this page Aug 1, 2026 · 3 revisions

Light engine

A light propagation for a single section that resolves the properties of a block once instead of on every visit, and stores a uniform section without an array. The algorithm itself knows nothing about Minestom, which is what makes it testable without a running server. This page covers what the engine computes, how to call it and where it is deliberately incomplete; it does not restate the Javadoc, and it is not the methodology page for the numbers it quotes. It is written for someone choosing between this engine and the one Minestom ships with, and for someone checking whether the claims made for it hold. All references point at the sources of this repository and at Minestom 2026.06.20-26.1.2.

Experimental. Every public type of net.onelitefeather.falco.light is annotated @ApiStatus.Experimental. The API may still change.

Read this before quoting a number. Every figure on this page comes from a JMH benchmark run on one machine, on one JVM, under one load, and none of them is an absolute statement about lighting a world. How the numbers were produced is in Benchmarking; why they are believable and what they do not license is in Rationale: Measurement, which also carries the one definition of what the ± means.

Scope. Block light and sky light for a chunk, across section borders, across chunk borders, and incrementally after a single block changed. Works with any chunk of any loader. Either call it yourself, or hand the instance a chunk supplier and let the light keep itself up to date. See Limits for what is still missing.

Contents

When this is worth using

Honest answer first, because it decides whether the package is relevant at all:

Workload Does light computation run?
Loading pre-lit worlds from .mca No. The stored light is applied with Light#set, which clears requiresUpdate(). Nothing is recomputed — unless you call this engine explicitly.
Generated worlds without stored light Yes
Runtime block placement Yes

For loading pre-built maps from region files — the dominant Falco use case — a light engine contributes nothing, because that code path never executes. That is a structural statement, not a measured one: the stored arrays go straight into the section through Light#set, which clears the update flag, so nothing asks either engine for a result. What the load path does cost instead is NBT parsing and zlib inflation, and that ordering comes from one-off micro-measurements taken while designing the loader rather than from any published table — treat it as an order of magnitude and nothing finer.

Design

Eight types compute the light, each with one responsibility. Only the two on the right know Minestom exists. A further five, listed further down, decide when it is computed and never how.

                      ┌─ engine, no Minestom ─────────────┐   ┌─ adapter ──────────────┐
                      │                                   │   │                        │
  Chunk ──────────────┼──► int[] stateIds                 │   │  ChunkLightService     │
                      │         │                         │   │        │               │
                      │         ▼                         │   │        │ uses          │
                      │   SectionOpacity ◄── BlockLightSource ◄── MinestomBlockLight-  │
                      │         │            (one lookup   │   │        Source          │
                      │         ▼             per state)   │   │                        │
                      │   ChunkLightPropagator             │   │  writes back through   │
                      │         │  (crosses section        │   │  Light#set(byte[])     │
                      │         ▼   borders)               │   │                        │
                      │   List<LightNibbles> ──────────────┼───┼──►  Chunk sections     │
                      └───────────────────────────────────┘   └────────────────────────┘
Type Responsibility
LightNibbles Storage. Two levels per byte, uniform sections without an array.
BlockLightSource Abstraction over "how bright is this block and which faces does it block".
SectionOpacity Precomputed table of those properties for one section.
LightPropagator The breadth-first propagation, with reusable buffers.
ChunkLightPropagator The same search across all sections of a chunk, so light crosses their borders.
MinestomBlockLightSource Answers BlockLightSource from the block registry.
ChunkLightState Keeps a calculated result and updates it incrementally, including the retraction pass and the sky heightmap.
ChunkLightService Reads a chunk, runs the propagation, settles the borders against the neighbours, writes the result back.

BlockLightSource exists for the same reason PaletteEntryResolver does in the Anvil package: it keeps the registry out of the algorithm, so the propagation is verified with a handful of fake blocks and no server at all.

Where the resources are saved

Time

The dominant cost of a naive propagation is not the search but the block lookups. A breadth-first search reaches a block from up to six directions, and resolving palette → block → registry → occlusion shape on each of those visits repeats the same work.

SectionOpacity resolves every distinct state id of a section exactly once when the table is built and answers from two flat byte[] afterwards — an array index instead of a registry walk. A section of 4096 stone blocks costs one lookup, which SectionOpacityTest#testEveryDistinctStateIsResolvedOnlyOnce pins down.

Two further short cuts:

  • A section without any emitting block returns immediately with a uniform dark result. No buffer is touched, no queue is built.
  • Because a level drops by exactly one per block and the search is breadth-first, every position is reached with its final level on the first visit. No position is ever revisited or re-queued.

Memory

  • Uniform sections carry no array. LightNibbles keeps a single level and allocates the 2048-byte array only when a level actually differs. Most sections of a world are either fully dark or fully lit, so this is the common case rather than an edge case. fill releases the array again.

  • A fully dark section reports an empty array (toArray().length == 0), which is how the file format stores "no light" — nothing is written for it.

  • The propagator reuses its buffers. The level buffer and the queue are allocated once per instance and cleared per run, so repeated propagation allocates nothing beyond the result. An instance is therefore reusable but thread confined — use one per worker rather than sharing one.

  • The queue is an int[], not a collection. No boxing, no growth: a section has 4096 positions and each is queued at most once, so the array is sized exactly once.

  • Building the table allocates nothing per block. The lookup is a linear probing table over the raw state id, so no key is boxed and no value object is created; a resolved state is packed into a short that carries the occluded faces and the emission together. A run of one repeated state is answered from the previous block instead of the table, because the blocks of a world come in runs. This is what took the build of one section from 74 040 to 8 664 bytes per call, and it is the single largest reason for the times further down.

    That pair of numbers is the most robust quantitative claim on this page, and worth more than any timing here. Allocation per invocation is essentially deterministic: it does not move with the fork count, the machine load, the JIT plan or the garbage collector, which is exactly what every timing on this page is vulnerable to. A reader who trusts nothing else can reproduce it with -prof gc and read gc.alloc.rate.norm.

    SectionOpacity.of over one section, before and after the change that removed the per-block lambda; from a -prof gc run whose output is not committed — parameter values, iteration counts and machine unrecorded.

Correctness details worth knowing

Occlusion is per face, not per block. A great many block types of the game occlude some faces and not others — slabs, stairs, snow, farmland, dirt paths, lecterns, stonecutters. A design storing one flag per block answers those wrongly. SectionOpacity stores a six-bit mask per block; MinestomBlockLightSourceTest#testABottomSlabBlocksItsBottomFaceOnly and #testATopSlabBlocksItsTopFaceOnly pin the behaviour down on real block data, which is what makes this a checked property rather than an assertion.

Whether that is one block type in seven or one in three does not change the design, because a single wrongly answered slab is a visible patch of wrong brightness.

Only the entered face is tested. Light passing from A to B is blocked by the face of B it enters, not by the face of A it leaves. Testing both would leave every emitting block that is opaque itself dark — and glowstone is exactly that. This is checked end to end against the real registry.

Unknown block states are transparent, not fatal. Block.fromStateId indexes an array without a bounds check and throws for an id outside the known range. The adapter turns that into an absent block, because a propagation must not lose a whole section over one unknown state.

The face mapping is pinned by a test. The adapter maps its faces onto the server's by ordinal. testTheFaceOrderMatchesTheOneOfTheServer fails if Minestom ever reorders its enum, which would otherwise silently shift every occlusion answer to the wrong face.

Compared with the light engine Minestom ships with

Everything in this section is about block light. Both the comparison and the equivalence check call BlockLight.buildInternalQueue and LightCompute.compute, which are the built-in block light path; no benchmark and no test in this repository puts Falco's sky light next to Minestom's. Sky light appears further down as a Falco-only feature, and nothing on this page compares it with anything. That limit is repeated where it belongs, in Limits.

LightEngineComparisonBenchmark runs both engines over the same section, from a block palette to a finished light array of 2048 bytes. It lives in net.minestom.server.instance.light because the two methods that make up the built-in path — BlockLight.buildInternalQueue and LightCompute.compute — are package-private, which is the only way to measure the original instead of a copy of it. Neither side gets to skip its preparation: the built-in path builds its seed queue, and the Falco path builds its opacity table through the real block registry rather than a stand-in.

The two engines produce the same block light, and that is checked rather than claimed. This is the part of the comparison that does not depend on a measurement at all, and it is the part a hostile reader cannot dismiss, so it comes first. LightEngineEquivalenceTest#testBothEnginesLightEverySectionIdentically compares them byte for byte over 54 scenarios — nine source counts {0, 1, 2, 4, 8, 16, 64, 128, 512} against six shares of solid blocks {0, 10, 30, 50, 70, 90} percent — and runs with ./gradlew test like any other test. It also asserts that every scenario holding a source actually carried light, so a pair of dark sections cannot agree by accident, and a second test pins the fully dark section, which the two engines represent differently — Minestom as an empty array, Falco without an array at all — onto the same bytes (LightEngineEquivalenceTest#testBothEnginesAgreeOnASectionWithoutAnyLight). The benchmark repeats the byte comparison in its @Setup (LightEngineComparisonBenchmark.verifyBothEnginesAgree) and aborts the trial when the two disagree, which closes the most common way a benchmark lies — winning time by computing something else. Until recently this document asserted the byte identity while nothing in the build verified it; the statement happened to be true, but a change that broke it would have passed unnoticed. Nothing in this section is a statement about correctness — on block light correctness is equal, not better, and on sky light it is unmeasured against the built-in engine. Everything below is about time.

One section per operation, score ± error, lower is better. Every source emits level 15; sections whose sources differ in brightness are measured separately, further down.

java -jar build/libs/falco-*-jmh.jar "LightEngineComparisonBenchmark.(falco|minestom)" \
    -p emissionMix=UNIFORM -f 1 -wi 5 -i 10
Light sources Solid blocks Falco Minestom Falco is Conservative bounds
1 0 % 44.5 ± 0.6 µs/op 49.4 ± 1.3 µs/op 1.11× faster 1.07× to 1.16×
8 0 % 98.3 ± 2.4 µs/op 121.1 ± 5.5 µs/op 1.23× faster 1.15× to 1.32×
64 0 % 109.2 ± 1.6 µs/op 126.5 ± 5.6 µs/op 1.16× faster 1.09× to 1.23×
1 30 % 39.3 ± 0.8 µs/op 62.0 ± 2.0 µs/op 1.58× faster 1.50× to 1.66×
8 30 % 119.3 ± 3.5 µs/op 204.2 ± 3.7 µs/op 1.71× faster 1.63× to 1.80×
64 30 % 122.6 ± 1.3 µs/op 206.6 ± 4.2 µs/op 1.68× faster 1.63× to 1.74×

LightEngineComparisonBenchmark.falco / .minestom, emissionMix = UNIFORM, lightSources and occlusionPercent as the first two columns, one thread, one fork, 5 warmup and 10 measurement iterations of 1 s — both counts override the class annotation, which specifies 3 and 5 — -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, no results.json committed. The bounds column is (minestom − error) / (falco + error) to (minestom + error) / (falco − error), so it is what the two intervals allow rather than the quotient of the means. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

All six rows resolve a difference in the same direction, and this is the strongest evidence on the page. The intervals are disjoint at every point, and the confidence level behind them is generous — a 99.9 % interval over ten iterations is wide by construction, so two intervals that do not touch are not a near miss. The finding stands without hedging: on this machine, in this run, Falco's path is the faster of the two everywhere it was measured, by between 1.07× and 1.80× depending on the section.

What it does not license: it is a per-section time from one JVM process on one machine that was not idle, it is not a chunk lighting time and not a tick time, and the single-fork limitation applies in full. The gap on the three open-sky rows is small enough that a different machine could move it; the gap on the three 30 % rows is not.

These numbers replace an earlier set in which Falco lost four of the six scenarios. What changed is not the algorithm but the table it works from: building SectionOpacity allocated a throwaway lambda per block, which is broken down below. No repeat run of this table exists. The two genuine cross-run repeats this project has are the nine-chunk AreaVsPerChunkBenchmark re-run and the earlier IncrementalVsFullBenchmark run, both cited at their own tables in Project Status; neither of them is this benchmark.

A section without solid blocks: the narrow half of the result

This is where the two engines are closest, because it is where Falco has the least to gain: nothing blocks the light, so the search rarely has to ask whether it may pass.

%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#56B4E9, #E69F00"}}}}%%
xychart-beta
    title "No solid blocks: Falco (blue, lower) against Minestom (orange, upper)"
    x-axis "Light sources in the section" [1, 8, 64]
    y-axis "Microseconds per section, lower is better" 0 --> 220
    line [44.5, 98.3, 109.2]
    line [49.4, 121.1, 126.5]
Loading

xychart-beta draws no legend, so: the first line, the lower one, is Falco (blue); the upper one is Minestom (orange). The scale runs to 220 although nothing here comes close to it, so that this chart and the next one can be held against each other.

Why the margin is small here. Before Falco computes anything, it goes through the section once and notes down for every block whether light passes through it. That note costs time before a single ray has moved. In a section with nothing in it, the search hardly ever consults it, so the preparation is paid for and barely used. This is the shape of the workload on which Falco was behind until the preparation itself became cheap enough for the remainder to be earned back; 1.11× at one source is what is left of that, and it is the smallest margin in the table for exactly this reason.

How firm that is. The spreads do not overlap at any of the three points — 44.5 ± 0.6 against 49.4 ± 1.3, 98.3 ± 2.4 against 121.1 ± 5.5, 109.2 ± 1.6 against 126.5 ± 5.6. The direction is established; the size of the gap at one source is small enough that a different machine could move it.

A section with solid blocks: the wide half

Once 30 % of the blocks are solid, the same mechanism works in the other direction.

%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#56B4E9, #E69F00"}}}}%%
xychart-beta
    title "30 percent solid blocks: Falco (blue, lower) against Minestom (orange, upper)"
    x-axis "Light sources in the section" [1, 8, 64]
    y-axis "Microseconds per section, lower is better" 0 --> 220
    line [39.3, 119.3, 122.6]
    line [62.0, 204.2, 206.6]
Loading

Same order and the same colours as before: the first line is Falco (blue), the second Minestom (orange). The distance between the lines is several times what the previous chart shows, on the same scale.

Why. Now the note earns its keep. Solid blocks are exactly what a spreading light keeps running into, and every time it does, the same question comes up again: does light get through here? Falco reads the answer off the note it wrote at the start — one position in an array. Minestom asks the registry again each time. The more often the question is asked, the more the one-off cost of writing the note is worth; and how often it is asked is set by how many light sources are spreading and how much they run into.

That is the whole shape of the result, and it is what the optimisation moved:

flowchart TB
    AV["Falco<br/>pays once up front: one registry lookup per<br/>distinct block state of the section<br/>then one array read per question"]
    MI["Minestom<br/>pays nothing up front<br/>then one registry lookup per question"]
    Q{"How many times does the search ask<br/>'does light pass through here?'"}
    AV --> Q
    MI --> Q
    Q -->|"few times: nearly empty section,<br/>the search runs out quickly"| L["the up-front cost is barely used.<br/>Since it became cheap it is still<br/>earned back, but only just:<br/>1.11× to 1.23× faster, UNIFORM"]
    Q -->|"many times: solid blocks everywhere,<br/>every step runs into one"| W["the cheap answers add up:<br/>1.58× to 1.71× faster, UNIFORM"]
Loading

The up-front cost has not disappeared, it has shrunk. The break-even point that used to sit inside the measured range now sits below its sparsest configuration, which is why the left branch reads as a small win instead of a loss. A section that is sparser still — no sources at all — is answered without a search or a table at all, so it never reaches this decision.

How firm that is. None of the six spreads overlap. The widest margins, 8 and 64 sources at 30 % solid, are also the ones with the tightest errors on both sides: 119.3 ± 3.5 against 204.2 ± 3.7 and 122.6 ± 1.3 against 206.6 ± 4.2.

Where the gain came from

LightEngineStageBenchmark splits both engines into their stages, which is what identifies the cost. For one light source in an open section, in microseconds. This is a genuine before/after across a single commit — the same benchmark, the same parameters, two builds of Falco:

readStates opacity propagate collect falcoFull (measured) sum of the four stages
before 69381af 7.70 31.33 33.85 0.24 77.1 73.12
after 69381af 7.23 8.07 31.41 0.23 46.3 46.94

LightEngineStageBenchmark.falcoReadStates / .falcoOpacity / .falcoPropagate / .falcoCollect / .falcoFull, lightSources = 1, occlusionPercent = 0, one thread, one fork, 3 warmup and 5 measurement iterations of 1 s per the class annotation, -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, commit 69381af against its predecessor, no results.json committed, run dates unrecorded. No error bars were kept for any cell, so no rule about interval overlap can be applied to this table and no ratio drawn from it is defensible to more than one significant figure. The last column is arithmetic on the four preceding ones, not a measurement.

The table validates itself, which is why the two extra columns are worth having. falcoFull is measured separately rather than summed, so the residual against the stage sum — +3.98 before, −0.64 after — is what the method exists to expose, and it stays under 6 % on both rows. More usefully, two stages were not touched by the commit at all: readStates moves by 6 % and collect by 4 %. Those two are an internal control. Both moved by under 7 % and both moved in the same direction, which suggests a small systematic shift between the two runs rather than symmetric noise; collect at 0.23 against 0.24 is one printed digit and constrains little on its own. Even taking the larger of the two as an upper bound on that shift, opacity going from 31.33 to 8.07 — a factor near 4 — is an order of magnitude outside it. That is the argument the table makes, and it does not depend on error bars nobody recorded.

Building the opacity table was 41 % of falcoFull before (31.33 of 77.1) and is 17 % of a much shorter falcoFull after (8.07 of 46.3). The table allocated a throwaway lambda per block; removing that took the allocation of one call from 74 040 bytes to 8 664, and that allocation figure is the sturdier half of this claim for the reasons given above.

One number in there corrects the story this document used to tell. The Falco search was already the faster of the two before the change: 33.9 µs against 53.9 µs for the built-in search. The 53.9 is derived, not measured — Minestom's search consumes the queue it is handed and cannot be run on a prepared one, so it is minestomFull − minestomQueue, which the class javadoc states outright. A difference of two means carries the sum of both uncertainties, and neither operand was published with one, so 53.9 has no interval and must not be turned into a factor. What it supports is the direction: the entire deficit came from the preparation, not from the propagation. The earlier text explained the losses as a property of the algorithm's shape; they were a property of one allocation.

Sources of mixed brightness

Every scenario above places glowstone, so every source starts at level 15. Real interiors are lit with torches, lanterns and magma blocks side by side, and emissionMix=MIXED builds exactly that: the same positions, drawn from the same seed, filled with glowstone 15, lantern 15, torch 14, redstone torch 7 and magma block 3.

java -jar build/libs/falco-*-jmh.jar "LightEngineComparisonBenchmark.(falco|minestom)" \
    -p emissionMix=MIXED -p lightSources=8,64 -f 1 -wi 3 -i 5
Light sources Solid blocks Falco Minestom Falco is Conservative bounds
8 0 % 118.97 ± 8.89 µs/op 126.54 ± 9.55 µs/op no difference resolvable
8 30 % 116.50 ± 7.63 µs/op 201.46 ± 16.83 µs/op 1.73× faster 1.49× to 2.01×
64 0 % 150.42 ± 32.73 µs/op 162.20 ± 3.11 µs/op no difference resolvable
64 30 % 149.42 ± 12.64 µs/op 252.26 ± 9.28 µs/op 1.69× faster 1.50× to 1.91×

LightEngineComparisonBenchmark.falco / .minestom, emissionMix = MIXED, lightSources = 8, 64, occlusionPercent as the second column, one thread, one fork, 3 warmup and 5 measurement iterations of 1 s per the class annotation, -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, no results.json committed. These settings are not the ones the UNIFORM table above was measured at (-wi 5 -i 10 there), so the two tables are two runs and their rows must not be subtracted from one another. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

A single source is not measured under MIXED: the first block of the set is glowstone, so a lone source is the identical section UNIFORM already covers.

Two of the four rows resolve nothing, and the two that do are the ones with solid blocks. At 0 % solid the intervals overlap in both cases — [110.08, 127.86] against [116.99, 136.09] at 8 sources, and [117.69, 183.15] against [159.09, 165.31] at 64. The means put Falco ahead by 6 % and 8 % respectively, and neither is established; nor is equality. The 64-source row is the loosest measurement on this page, with a Falco half-width of 22 % of its mean, and that dispersion is itself the interesting part: mixed brightness makes Falco's time less predictable, not merely larger.

At 30 % solid the intervals are disjoint on both rows and the lead is the same one the uniform table shows — 1.73× and 1.69× here against 1.71× and 1.68× there. These are not a repeat of each other: UNIFORM and MIXED are two different inputs, measured at different iteration counts, so what the closeness shows is a fact about the mechanism rather than about run-to-run stability. Where solid blocks are present the cheap opacity answers dominate what the search spends, so the source mixture barely touches the result. The project's two genuine cross-run repeats are elsewhere — see Project Status.

Mixed levels cost Falco more than they cost Minestom, and the mechanism is in the search: it assumes the queued positions are ordered by level, which is true only while every source starts at the same one. With mixed levels a position can be reached again later by a brighter wave, and the same positions are touched more than once. Comparing the open-sky rows across the two tables shows the direction — 109.2 µs at 64 sources under UNIFORM against 150.42 µs under MIXED — but those are two runs at different iteration counts, so the difference is not a measured quantity and no percentage is quoted for it here.

If a bucket queue is ever added to the propagator, emissionMix is the parameter that will show whether it was worth it — the two rows where the intervals overlap are exactly where it would be expected to help — and that is why it is a parameter rather than a constant.

Which of the two is the steadier

In the UNIFORM run above, Falco has the smaller absolute half-width at every one of the six points: ± 0.6 to ± 3.5 against ± 1.3 to ± 5.6. Measured relative to the score the picture is almost the same, with one exception — at 8 sources and 30 % solid Minestom is the tighter of the two (1.8 % against 2.9 %). The earlier version of this document reported the opposite across the board, on an earlier run; the drop in allocation is the likeliest reason the Falco spread fell, since it removes the garbage collector from the measurement. Likeliest, not established: nothing here isolates the garbage collector from any other cause.

Two limits on how far that reading goes. The ± is a confidence interval over the measurement iterations of one JVM process, so it describes dispersion inside a single run and not how either engine behaves across JVM launches — a comparison of two such intervals is a comparison of two within-run dispersions and nothing wider. And under MIXED the ordering reverses on the loosest row: 150.42 ± 32.73 against 162.20 ± 3.11, where Falco is the noisier of the two by an order of magnitude. Whichever engine is the steadier, it is not a property that holds across the parameter space.

The machine that produced every table on this page is recorded as not idle, which is a live candidate explanation for any spread on it and has not been ruled out for any row.

On concurrency there is nothing to win here

The Anvil comparison in Anvil Chunk Loader turns on a lock that is held across expensive work. It would be convenient to claim the same thing on the light side, and it is not true. Minestom's light path is already built for several threads, at 2026.06.20-26.1.2: LightCompute.compute is static and allocates its result buffer per call (instance/light/LightCompute.java:115), BlockLight keeps its buffers in instance fields, one instance per section (instance/light/BlockLight.java:20-22), and LightingChunk already runs its work on an Executors.newWorkStealingPool() (instance/LightingChunk.java:38). Nothing in there serialises work that could be running in parallel, so there is no contention to remove. This is a structural reading of the source, not a measurement — no benchmark on this page contends the light path from several threads, and none should be quoted as if it did.

How the light reaches the chunk

This is the one argument for this engine that does not depend on a measurement, and it is the strongest one. Minestom computes light only inside LightingChunk, which extends DynamicChunk (instance/LightingChunk.java). Use any other chunk implementation and no light is computed at all. Falco computes outside the chunk and hands the finished array over through Light#set, which every chunk accepts.

flowchart TB
    subgraph mine["Minestom: the light lives inside one chunk class"]
        direction TB
        M1["LightingChunk<br/>(extends DynamicChunk)"] --> M2["computes its own light internally"]
        M2 --> M3["sections are lit"]
        M4["any other Chunk implementation"] --> M5["no light at all"]
    end
    subgraph falco["Falco: the light is computed outside and handed in"]
        direction TB
        A1["any Chunk — LightingChunk,<br/>DynamicChunk, your own"] --> A2["ChunkLightService reads the block states"]
        A2 --> A3["propagation runs outside the chunk,<br/>knowing nothing about Minestom"]
        A3 --> A4["Light#set(byte[]) per section"]
        A4 --> A5["sections are lit"]
    end
Loading

The same property has a second consequence: because the propagation references no Minestom class at all, it can be tested against a handful of fake blocks without a running server. Only the adapter that answers from the real registry needs one.

Being outside the chunk does not mean giving up the convenience of being inside it. FalcoLightingChunk offers the same one-line setup as LightingChunk while the computation stays in a service that any chunk type can be handed to — see Letting the chunk keep its own light. The two are not alternatives: the chunk is a caller of the service like any other.

When to use Minestom's engine instead

The reason this section used to give — the built-in engine is faster on sparsely occupied sections — no longer holds, so it needs restating rather than deleting.

If you already use LightingChunk, there is still no obligation to change anything. The built-in engine is wired into the server, costs no extra code and no extra call site, and on an open section lit by sources of differing brightness no difference between the two is resolvable at all — both MIXED rows at 0 % solid have overlapping intervals. Swapping a working light path for a margin the measurement does not establish is not a trade at all.

What does argue for this engine is the case the built-in one does not cover: any chunk that is not a LightingChunk, which includes the chunk type an InstanceContainer uses unless it is told otherwise. That one is structural and needs no benchmark. After it come the workloads where the margin is both large and resolved — sections carrying a real share of solid blocks, where the tables above put Falco ahead by 1.58× to 1.71× under UNIFORM and 1.69× to 1.73× under MIXED, all five of those rows with disjoint intervals, though across two separate runs whose figures should not be pooled — and the control over when light is computed that comes from computing it outside the chunk.

The convenience argument has stopped being one either way. setChunkSupplier(scheduler.supplier()) is the same amount of setup as setChunkSupplier(LightingChunk::new), so choosing between the two is now a question about the engine rather than about how much wiring it takes.

Usage

import net.onelitefeather.falco.light.LightNibbles;
import net.onelitefeather.falco.light.LightPropagator;
import net.onelitefeather.falco.light.MinestomBlockLightSource;
import net.onelitefeather.falco.light.SectionOpacity;

// One per worker thread; it keeps reusable buffers.
LightPropagator propagator = new LightPropagator();
MinestomBlockLightSource source = new MinestomBlockLightSource();

int[] stateIds = new int[LightNibbles.BLOCK_COUNT];  // block states of one section
// ... fill stateIds from a palette ...

LightNibbles light = propagator.propagate(SectionOpacity.of(stateIds, source));

int level = light.get(8, 8, 8);
byte[] stored = light.toArray();                     // empty when the section is dark

BlockLightSource can be implemented directly to run the engine without a server:

BlockLightSource fake = new BlockLightSource() {
    @Override public int emission(int stateId) { return stateId == LAMP ? 15 : 0; }
    @Override public boolean blocksFace(int stateId, BlockFace face) { return stateId == STONE; }
};

Using it with a chunk loader or an instance

ChunkLightService is the entry point. It reads the block states of a chunk, propagates, and hands the result to the sections through Light#set(byte[]):

import net.onelitefeather.falco.light.ChunkLightService;

// Keeps no state between calls, so a single instance serves as many threads as you like.
ChunkLightService lighting = new ChunkLightService();

Chunk chunk = instance.loadChunk(0, 0).join();
lighting.calculate(chunk);

int level = lighting.blockLightAt(chunk, 8, 40, 8);

This works with any chunk, regardless of which loader produced it — the Anvil loader of Falco, the one Minestom ships with, or a generated chunk. A test covers the round trip through FalcoAnvilLoader explicitly.

Two properties make this the stable way in:

  • Light#set(byte[]) is not marked internal, unlike calculateInternal / calculateExternal. The service therefore does not implement the Light interface and cannot break when the signatures of those internal methods change.
  • set clears the update flag of the section, so the server does not recompute what was just written. A wrong result is therefore never corrected on its own, which is why every write path is covered by a test.

One service is enough for a whole server. Unlike LightPropagator above, ChunkLightService holds exactly one field, the BlockLightSource it was constructed with, and every working buffer lives in a ChunkLightPropagator built inside the call (ChunkLightService#calculate, #calculateSky). A single instance may therefore be used by as many threads as one likes, and that is a property of the class layout rather than of a benchmark. ChunkLightServiceConcurrencyTest#testOneServiceCalculatesTheSameBlockLightFromManyThreads and its sky-light sibling #testOneServiceCalculatesTheSameSkyLightFromManyThreads hold it in place. That shape is not a coincidence but the fix to a defect: the service once kept a propagator in a field, and two threads sharing one service then shared its scratch buffers, which produced wrong light in the large majority of concurrent calls.

Locking follows the same three-stage split the Anvil loader uses: the block states are read under the chunk's read lock (ChunkLightService#readStates), the propagation runs with no lock held, and only the transfer of the result takes the write lock (ChunkLightService#applyLight, which brackets its loop with chunk.lockWriteLock() and chunk.unlockWriteLock()).

Sky light

lighting.calculateSky(chunk);

Sky light enters from above and falls straight down without losing a level until something stops it — which is why an open field is fully lit at every height while a cave is dark. Only after the fall is interrupted does it spread like any other light, losing one level per block.

Across chunk borders

lighting.calculateWithNeighbours(instance, chunkX, chunkZ);

Lighting a chunk on its own ends its light at the border, which shows up as a straight dark line every sixteen blocks. This method exchanges the border levels with every already loaded neighbour in both directions, and writes only the chunk in the middle. Neighbours that are not loaded are skipped rather than forced to load.

One round of that exchange is not enough. A source in the corner of a chunk sends light through two borders, and the light that entered a neighbour has to leave it again on another side to arrive in the chunk diagonally behind it. The exchange therefore repeats over the whole area — the chunk and the eight positions around it — until no chunk of it raises a level any more:

Property How it is reached
Terminates An injection only ever raises a level, and a level is capped at fifteen, so the repetition walks towards a fixed point.
Same result every time The area is a fixed-size array walked in a fixed order, not a map. Since every step only raises levels, the fixed point does not depend on the order either.
Reads a chunk once The opacity tables of every participating chunk are built once, before the first round, and reused by all of them.
Cannot loop forever The amount of rounds is capped at sixteen, which is one more than the highest level that can exist. Hitting the cap is reported through LOGGER.warn instead of being accepted silently.

A radius of one chunk is enough because a level of fifteen cannot survive sixteen blocks of travel, so nothing the middle chunk emits can reach a second ring.

The eight chunks around the middle are read and never written, and that is not a shortcut. They only exchanged light inside the 3×3, so whatever they legitimately receive from outside it is missing from their result; writing that back would replace their correct light with a darker one. The middle chunk does not have the problem, and provably so: a source outside the 3×3 is at least seventeen blocks away from it and no path can be shorter than the direct distance, so not even a level of fifteen survives the trip. Writing one chunk instead of nine is therefore cheaper and correct. The method used to write all nine, which is the defect this replaces.

What this method still has over a one-chunk area. The 3×3 includes the four diagonal chunks; the ring of a ChunkLightArea is built from face neighbours only. A source in a diagonal chunk reaches the middle chunk through the chunk between them, and the neighbourhood carries that — an area of a single chunk never reads it. For one chunk this is therefore the more accurate of the two calls; for several connected chunks the area is the cheaper one, because it reads every chunk once instead of once per neighbourhood. That an area misses its own diagonals is a gap of its own, and it is recorded in Project Status rather than glossed over here.

Letting the chunk keep its own light

Everything above is a call you make yourself: you have to notice that a chunk changed and decide when to recompute it. FalcoLightingChunk removes that step, and it is the same entry point Minestom sets with setChunkSupplier(LightingChunk::new):

import net.onelitefeather.falco.light.ChunkLightScheduler;
import net.onelitefeather.falco.light.ChunkLightService;

ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService());
instance.setChunkSupplier(scheduler.supplier());

That is the whole setup. Keep one scheduler per instance — a second instance handed to the same one is refused with an IllegalStateException, because the dirty set is keyed by chunk coordinates alone and every instance of a server ticks with the same timestamp.

Five types, and only one of them holds any behaviour:

Type Responsibility
FalcoLightingChunk The drop-in, a DynamicChunk subclass. Five overrides and no computation: setBlock reports the changed position, onLoad reports a change of unknown extent, tick triggers the pass, invalidate drops the cached light packet, and onLightUpdated — its half of LightUpdateAware — resends it.
ChunkLightScheduler Everything else — the dirty set, the once-per-tick trigger, area forming, the executor, back pressure and the staleness rule — so a reader looking for the behaviour finds it in one place.
ChunkArea A chunk coordinate pair, and the flood fill that cuts a dirty set into capped connected groups. Pure arithmetic, no Minestom.
ChunkLightArea Computes one group in a single pass: reads its chunks plus one ring, exchanges borders until settled, writes back the group and never the ring. Keeps the light of each chunk it has computed, so the next pass replays the reported changes on it.
LightUpdateAware The hook Minestom does not have, so a computed result can be delivered without knowing which chunk type it belongs to.

A change marks the 3×3, not just the chunk it happened in. A lamp on the eastern edge of a chunk belongs in the light of the chunk east of it, and that chunk would otherwise never be told. The ring around an area is the mirror image of the same rule: it is read so the edge of the area is right, and never written, because a ring chunk has not seen what lies on its own far side.

A changed block costs a changed block, not nine chunks. setBlock reports the position that changed, not merely that its chunk is dirty. The area keeps the light of every chunk it has computed and replays the reported positions on it, so a placed torch costs one incremental update rather than nine full chunk searches. The eight neighbours are still marked, because light crosses borders, but nothing of theirs is discarded: their own blocks did not move, and what arrives across the border is derived again by every pass anyway. A change that cannot be placed — a chunk that was generated, loaded, or written past setBlock — is reported as being of unknown extent, and that chunk is searched again. What this is worth is measured by IncrementalVsFullBenchmark and the full table is in Project Status under Measured: replaying one changed block against relighting the 3×3 from block states is 2.07× cheaper on block light (incremental 7 747 ± 402 against full 16 028 ± 778 µs/op, conservative bounds 1.87× to 2.29×) and 5.60× cheaper on sky light (incremental 7 065 ± 438 against full 39 585 ± 1 512 µs/op, bounds 5.07× to 6.20×). Both pairs of intervals are disjoint, so both are established for that run. The third figure often quoted alongside them — a tick that pays for both kinds — is derived: it is the sum of the two rows above, not a measurement of its own, and summing assumes such a tick shares no work between the two kinds, which is an assumption about the implementation rather than a measured fact. Propagating the errors gives 55 613 ± 2 290 against 14 812 ± 840, so about 3.7×, bounds 3.41× to 4.14×.

IncrementalVsFullBenchmark.full / .incremental, sky = false and sky = true, one thread, one fork, 5 warmup and 10 measurement iterations — an override of the class annotation, which specifies 3 and 5 — no heap flags, which is the one class in the harness that pins neither -Xms nor -Xmx while building a real Minestom chunk world, JMH 1.37, one 16-core machine recorded as not idle, no results.json committed. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

An independent earlier run of the same benchmark gave 1.96× / 6.40× / 3.95×. That is the most useful sentence available about any number on this page: the ratios reproduce and the third digits do not. Two runs, two JVMs, same direction, same order of magnitude, different last digit — which is exactly what a single-fork measurement should be expected to deliver, and exactly the reason no third digit anywhere here is load-bearing.

What is kept is the light of a chunk alone, before any border exchange, and that is what makes the hard direction tractable. Taking light back is the case an incremental engine gets wrong, because a retraction has to run until light from somewhere else legitimately takes over — and a retraction that had to leave the chunk to find that point would need the neighbours retracted with it. It never has to here: the kept light contains nothing from any neighbour, so every level in it originates inside the chunk and the retraction is complete at the border by construction. The light that crosses borders is not stored at all; it is derived again on every pass, from a copy of the kept light, and an exchange only ever raises levels and so cannot carry a stale glow forward.

The kept light is bounded — roughly 100 KB per chunk and kind of light, for at most 128 chunks by default (ChunkLightArea.DEFAULT_MAX_CACHED_CHUNKS), least recently used first — and dropping an entry costs a full propagation and nothing else. The result is the same bytes a full recalculation produces, and a chunk that cannot be followed incrementally is simply propagated again.

Areas are formed once per tick and capped. Chunk#tick(long) runs per chunk, but a pass has to see every change of the tick before it groups anything, so the scheduler runs its pass for the first chunk reporting a timestamp it has not seen. Connected dirty chunks are lit together, and the group is closed at maxAreaSize chunks, 16 by default, with the remainder starting the next area. The cap is there because every chunk of an area and of its ring is read and turned into opacity tables inside one tick; a ChunkLightState of roughly 100 KB per chunk is the smaller half of that bill. The seam between two parts settles on the following tick, because each part reads the other as its ring.

Nothing ever blocks on a computation. A chunk hands out whatever its sections hold right now, which is the previous result while a new one is in flight. A chunk whose area is still running stays marked but is not submitted again, and a chunk that changed while its area ran has its result discarded and stays dirty rather than being written from block states that are already gone.

The scheduler's executor is injectable, and a test should inject one

// Deterministic: the task runs on the calling thread, so a tick is finished when onTick returns.
ChunkLightScheduler scheduler = new ChunkLightScheduler(new ChunkLightService(), Runnable::run, 16);

The default gives every area its own virtual thread and bounds them with a semaphore at the processor count, the same shape FalcoAnvilLoader uses for its saves. The bound sits inside the task rather than around the submission on purpose: acquiring it before starting the thread would block whichever chunk happened to trigger the pass, and that chunk is being ticked by the server.

A direct executor turns the whole cycle synchronous, which is what makes the tests of this path deterministic rather than timing-dependent — place a block, call tick, assert the light. A server that already has a pool can hand that one over instead.

A batch is covered by this too. AbsoluteBlockBatch#apply ends by calling sendLighting() on every touched chunk that is a LightingChunk and skips every other type, so a FalcoLightingChunk would never be resent by it. It does not have to be: a batch writes through setBlock, which marks the chunk here, so the next tick lights the whole touched region and sends it. The result arrives one tick later than Minestom's would, and it arrives for the ring around the batch as well, which Minestom's path does not manage.

Incremental updates

ChunkLightService#calculate always recomputes the whole chunk. For a single block change that is wasteful, and ChunkLightState exists for that case. It is what ChunkLightScheduler runs on: FalcoLightingChunk#setBlock reports the position, ChunkLightArea keeps the light of the chunk and replays the position on it. Use the type directly only if you drive the engine yourself.

ChunkLightState state = ChunkLightState.blockLight(opacityTables);

// after a block changed at that position
state.update(updatedOpacityTables, x, y, z);
List<LightNibbles> light = state.toSections();

Adding brightness is straightforward — it only spreads. Removing it is the hard case and the reason this class exists: when a light source disappears, the brightness it had spread is still stored in every block around it, and spreading again would keep that glow forever. The update therefore runs two passes. The first retracts every level that originated from the changed position and collects the still valid levels it meets at the edge of the retracted area; the second spreads those back in.

Which way an update goes, drawn out:

flowchart TB
    C["a block changed at x, y, z"] --> K{"is the position<br/>brighter or darker than before?"}
    K -->|brighter| S["one pass: spread outwards.<br/>Levels only ever rise, so nothing<br/>has to be taken back"]
    K -->|darker| R1["pass 1: retract.<br/>Walk outwards and clear every level<br/>that came from this position"]
    R1 --> R2["at the edge of the cleared area,<br/>collect the levels that came<br/>from somewhere else"]
    R2 --> R3["pass 2: spread those collected<br/>levels back in"]
    S --> D["result is identical to<br/>a full recalculation"]
    R3 --> D
Loading

The second pass is not a correction of the first. The light of every other source in the neighbourhood is legitimate and was cleared along with the rest simply because it stood in the way; collecting it at the edge and letting it back in is what puts it back. Skipping the retraction instead and only spreading again would leave the removed source's glow in place for good.

ChunkLightStateTest#testTheIncrementalResultMatchesAFullRecalculation asserts that the incremental result is identical to a full recalculation, block for block.

Incremental sky light updates

Sky light has an origin no block holds: it falls in from above. An update can therefore not tell from the levels alone which positions lost their origin and which gained one, and a state that holds sky light keeps a heightmap for that reason — the highest position that stops the sky, per column.

A block change moves exactly one column of that heightmap, and the difference between the old and the new height names the positions whose origin changed:

Change Effect on the column
A block is placed above the current height Everything between the old and the new height falls out of the open sky and gives its level back. What is left is refilled from the sides, which is why a single pillar leaves a level of fourteen below it rather than darkness.
The highest blocking block is removed The column opens down to the next block below, and every position in between receives the full level again and spreads it.
The change is below the height The height stays where it is. The changed position is retracted and refilled from its neighbours, exactly as a block light update works.

Only the changed column is walked again, so an update no longer re-seeds all two hundred and fifty six columns of the chunk.

SkyLightUpdateTest asserts the result against a full recalculation block for block, for both directions, for a change that is not in the highest blocking position, and for a seeded sequence of random changes that verifies the equality after every single one of them.

When to call what

Situation Method
A live world where light should simply be right setChunkSupplier(scheduler.supplier()), then nothing
Chunk loaded without stored light, or generated calculate / calculateSky
One chunk loaded and its neighbours matter calculateWithNeighbours — its 3×3 includes the four diagonal chunks, which a one-chunk area never reads
Several connected chunks at once ChunkLightArea#compute — measurably cheaper than one neighbourhood per chunk from four chunks on
A single block changed ChunkLightState#update, or nothing at all if the scheduler is driving

Limits

  • Sky light is never compared with Minestom's. Every comparison on this page runs against BlockLight.buildInternalQueue and LightCompute.compute, which are the built-in block light path. LightEngineEquivalenceTest compares block light only, and LightEngineComparisonBenchmark.falco / .minestom measure block light only. Falco's sky light is checked against Falco's own full recalculation — that is what SkyLightUpdateTest and SkyLightTest do — and against nothing else. No claim of byte identity with the built-in engine is made for sky light on this page, and none may be quoted from it.
  • No Light implementation. The engine deliberately does not implement net.minestom.server.instance.light.Light. It writes its result through set instead, which avoids depending on the internal calculation methods of that interface.
  • The exchange covers one ring of chunks. calculateWithNeighbours settles the chunk and the eight positions around it, and an area settles its own chunks against a ring. That is enough for the chunks being written, but the ring itself is not settled against its own neighbours further out.
  • The ring of an area holds no diagonals. It is built from the face neighbours of every chunk of the area, so a source in a chunk that touches the area only at a corner is missing from the result. calculateWithNeighbours reads its diagonals and does not have this gap, which is why it was not deprecated in favour of the area.
  • An area rebuilds every opacity table on every pass, for each of its chunks and each of its ring chunks, whether or not anything in them moved. Now that the light itself is kept between passes, this is the largest remaining cost of a pass.
  • An area split at the cap leaves a seam for one tick. Each part reads the other as its ring, so the following pass settles it.
  • Section.clone() discards foreign light. Should an adapter be built later, note that Section.clone() calls Light.sky() / Light.block() outright, so any custom implementation is silently replaced on copy. LightingChunk.copy() would have to be overridden.
  • LightPropagator and ChunkLightPropagator are thread confined. Both keep their level buffer and queue in instance fields and clear them per run (LightPropagator), so one instance per worker thread and never a shared one. ChunkLightService is the exception and is safe to share, because it builds a propagator inside every call. Nothing enforces the distinction at compile time; getting it wrong produces wrong light rather than an exception, which is the failure mode this engine has already been bitten by once.
  • One ChunkLightScheduler serves one instance. A second instance handed to the same scheduler is refused with an IllegalStateException, because the dirty set is keyed by chunk coordinates alone and every instance of a server ticks with the same timestamp (ChunkLightScheduler). A server with many worlds needs one scheduler per world, and the memory bound is therefore per world and not global.
  • The kept light is capped and the cap is not adaptive. ChunkLightArea.DEFAULT_MAX_CACHED_CHUNKS is 128 and ChunkLightScheduler.DEFAULT_MAX_AREA_SIZE is 16, both plain constants with no measurement behind either value. Dropping an entry costs a full propagation, which is correct but not free; no benchmark in this repository sweeps either constant.
  • Nothing here reports what a real server pays. Every measurement on this page is a per-section or per-chunk average time from a JMH harness, and Mode.AverageTime reports a mean. A server's felt lighting cost is a tail — the tick that took too long — and no percentile is measured anywhere in the suite. The comparison rows are valid against their own counterparts and against nothing else.

Tests

Everything that tests the algorithm itself runs without a Minestom server. Nine classes need one and use Cyano's MicrotusExtension for it, each because a server is what the test is about:

Class Why it needs a server
MinestomBlockLightSourceTest The directional occlusion of a slab is only meaningful against real block data.
LightEngineEquivalenceTest Compares the two engines byte for byte over 54 scenarios; an equivalence claim is only worth something if both sides see the same registry.
ChunkLightServiceIntegrationTest The service reads real chunks and writes through Light#set.
ChunkLightServiceConcurrencyTest The same, from several threads at once.
ChunkLightAreaTest Light crossing a real chunk border, and the ring keeping its own light.
ChunkLightSchedulerTest The tick cycle against a real instance, made deterministic with a direct executor.
ChunkLightSchedulerConcurrencyTest The same cycle under threads, where back pressure and the staleness rule are what is being tested.
IncrementalLightUpdateTest The incremental path has to agree with a full pass over real chunks, not over fake blocks, or the claim it rests on is worth nothing.
FalcoLightingChunkTest The chunk only means anything inside an instance that supplies and ticks it.

ChunkArea is the deliberate exception on the other side: area forming is coordinate arithmetic with no Minestom type in it, so ChunkAreaTest runs without a server even though the rule it checks is about chunks.

LightEngineEquivalenceTest reaches BlockLight.buildInternalQueue and LightCompute.compute through reflection rather than by placing a test inside a Minestom package, so no package of the server is split across two artifacts and the queue type of the built-in path stays off the test classpath.

References

Falco sources are cited by member name throughout, and every type of the engine lives under falco-light/src/main/java/net/onelitefeather/falco/light/. The tests are under falco-light/src/test/java/net/onelitefeather/falco/light/, and the two benchmark classes quoted here are LightEngineComparisonBenchmark, LightEngineStageBenchmark and IncrementalVsFullBenchmark.

Minestom sources cited here, at version 2026.06.20-26.1.2:

  • net/minestom/server/instance/light/Light.java
  • net/minestom/server/instance/light/BlockLight.java
  • net/minestom/server/instance/light/LightCompute.java
  • net/minestom/server/instance/LightingChunk.java
  • net/minestom/server/instance/DynamicChunk.java
  • net/minestom/server/instance/Section.java
  • net/minestom/server/instance/batch/AbsoluteBlockBatch.java
  • net/minestom/server/instance/block/Block.java

The methodology behind every number above is in Benchmarking for how to re-run it and in Rationale: Measurement for why it is believable and what it does not license. The design argument that sits behind the engine is in Rationale: Lighting; the loader that produces the chunks it lights, and which shares its three-stage locking shape, is in Anvil Chunk Loader.

Falco


Start here

The measured record

Why it is built this way

  • Rationale — the index for the five rationale pages
  • Research — the index for the five investigations
  • Research: Fluent API — the investigation behind the builders; a record, not a reference

Working on the build

Clone this wiki locally