Skip to content

API reference / @jsprismarine/lighting

@jsprismarine/lighting ​

Join the Discord Servernpm

A block light and sky light engine for a Bedrock server. Both layers hold 0 to 15 per block, both stay current as blocks change, and the work runs in bounded steps so one tick cannot stall on a light flood.

ts
import { LightEngine } from '@jsprismarine/lighting';

const engine = new LightEngine(view);
engine.onChunkLoaded(chunkX, chunkZ);

engine.onBlockChanged({ x, y, z }, previousProperties, nextProperties);

const { queued } = engine.flush(2048);
if (queued > 0) {
    // Come back next tick.
}

This light never reaches a player ​

The Bedrock protocol has no light packet, and neither LevelChunkPacket nor SubChunkPacket carries a sky-light or block-light array. The client computes and renders light itself from the block palette it already has, which is the opposite of Java Edition, where the server ships both arrays in the chunk packet plus a dedicated light-update packet.

So nothing here changes what a player sees. The server keeps its own light because three rules read it:

  • Mob spawning, which tests the light level of a candidate block.
  • Crop growth, which needs a minimum level to advance a stage.
  • Daylight detectors, which turn the sky level into a redstone signal.

When this engine and the client disagree, the player sees the client's answer and the server acts on this one. There is no packet that settles the argument.

What is deliberately missing ​

Java's lighting has directional opacity: it reads the shape of pistons, daylight detectors, enchanting tables, farmland, lecterns, stonecutters, dirt paths, snow, end portal frames, slabs and stairs, and light passes through them only in some directions. Bedrock has no such thing. A Bedrock block state carries one minecraft:light_dampening scalar between 0 and 15, the client applies that scalar in every direction, and the wiki records the visible result as "Slabs, stairs, and farmland do not block light".

This engine follows Bedrock. Modeling Java's per-face rule would make the server compute light the client contradicts for every slab and stair in the world, which is the worst of both: slower, and wrong against what the player is looking at.

The seam ​

The engine reads the world through one injected interface and holds no reference to chunk, block or world classes:

ts
interface BlockLightView {
    readonly minY: number;
    readonly maxY: number;
    readonly hasSkyLight: boolean;
    blockAt(x: number, y: number, z: number): BlockLightProperties | undefined;
    topOfColumn?(x: number, z: number): number;
}

BlockLightProperties is { lightEmission, lightDampening }. @jsprismarine/mapping already produces that shape: a row from bedrockBlockPropertiesOf(name, states) satisfies the type with no conversion, so a server implementation of blockAt is a block lookup followed by a property lookup.

undefined means the chunk is not loaded. The engine never asks about a y outside minY to maxY, so it is the only meaning the value carries.

topOfColumn is one past the highest y in a block column that emits or dampens light, and it is worth answering. A chunk that loads reads every block of all 256 of its columns, so in an Overworld most of what the engine would otherwise read is the open sky above the ground: a view that answers 96 rather than 320 turns 98304 reads into 41000. A view that leaves it out is scanned from maxY. An answer that is too high only costs time; one that is too low loses the light of everything above it, so a view that is unsure answers maxY.

@jsprismarine/lighting/testing exports TestBlockLightView, an in-memory implementation with place, loadChunk and unloadChunk, plus a lightBlock helper that fills in the field a test does not care about.

How it computes ​

Storage. Each chunk section holds up to two LightSection arrays, one per layer, each 4096 levels packed two per byte into 2048 bytes. Sections are created on first write, so a dark section costs nothing. Sky light at or above the heightmap is a constant 15 and is never stored at all, which is most of the volume of an overworld chunk.

Block light uses the two-queue flood. An increase node spreads its level to each of the six neighbors, which costs max(1, lightDampening) of the block being entered. A decrease node darkens every neighbor dimmer than the level that went away and queues an increase from every neighbor that is at least as bright, so removing one torch in a lit room darkens its share and then refills from the torches that are left. A block that emits and blocks at once, such as a lit furnace, keeps its own emission when the decrease wave passes over it.

Sky light uses a per-column heightmap: for each of a chunk's 256 block columns, one past the y of the highest block with a dampening above 0. Everything at or above it reads 15. Below it, light propagates with the same attenuation block light uses, which is how the space under an overhang falls one level per block away from the edge and how water fades with depth.

When a view can answer section palette hints, the initial scan skips sections whose palette cannot emit or dampen light. The hint is derived from the palette's runtime ids and cached against the subchunk revision, so a later block edit refreshes the facts. Views without palette data keep the full scan. Border seeding walks only nonempty light sections and keeps the existing x/z/y order.

Only two kinds of sky-exposed cell have anywhere to send their 15: the one at the heightmap, which lights the column below, and the ones facing a horizontal neighbor whose heightmap is higher, which light that neighbor sideways. Seeding is therefore proportional to local relief rather than to the height of the world.

A block change is patched, not rebuilt. The heightmap entry moves only when the change was at or above the column's highest blocking block, and the only scan is the walk down the column that finds the next blocker after the top one breaks. When the heightmap rises, the cells that just lost their open sky are darkened from 15 and refilled through the new blocker.

Flushing is budgeted. flush(budget) processes at most that many queue nodes and returns processed, queued and deferred. Every decrease node runs before any increase node. Stopping mid-flush leaves readable but not yet correct levels; they are correct once queued is 0.

Chunk borders are ordinary propagation. The engine crosses section and chunk boundaries through the view, and an unloaded neighbor is skipped rather than treated as opaque, so the lit block next to the border keeps its level instead of being shaded against a surface that is not there. The column is recorded and counted in deferred. onChunkLoaded computes the new chunk's light and re-seeds across the border of every loaded neighbor, which is what finishes the propagation that stopped there.

Decisions this package makes ​

  • The server announces chunks. The engine stores light only in columns it was given through onChunkLoaded, and onChunkUnloaded drops a column's light. A chunk the view can read but the engine has not been told about is treated as unloaded. One entry point for a chunk is simpler to reason about than lazily inventing a heightmap for a column nobody declared.
  • Callers choose which loaded chunks need light. The Prismarine world initializes the simulation area and a one-chunk guard ring. A light-dependent read outside that ring initializes and settles its loaded chunk on demand; hasChunk tells a caller whether a column is resident.
  • Deferred work is reconstructed, not stored. Propagation into an unloaded chunk is dropped and rebuilt from the neighbors' borders when that chunk loads. A queue of parked nodes would grow with every edit next to an unloaded border and would still need the border pass for chunks that unload and come back, so it would be a second mechanism for a case the first one already covers.
  • Unloading does not correct the neighbors. Light that a departing chunk contributed to its neighbors stays until something changes. Vanilla does the same, and the border re-seed makes it right again when the chunk returns.
  • The daylight cycle is not applied here. getSkyLight returns the geometric level. Scaling it for the time of day and the weather, 15 at clear noon down to 4 at midnight, belongs to the rule that reads it, because the three consumers scale it differently.

Classes ​

Interfaces ​

BlockLightProperties ​

Defined in: BlockLightView.ts:9

The two per-state block values the light engine reads.

@jsprismarine/mapping's BedrockBlockProperties already has both fields with these names, so a property row from bedrockBlockPropertiesOf satisfies this type without conversion. The engine declares its own shape rather than importing it so that @jsprismarine/lighting depends on nothing.

Properties ​

lightDampening ​

readonly lightDampening: number

Defined in: BlockLightView.ts:18

The light the block swallows, 0 to 15. A full opaque block is 15, air and glass are 0. Light entering the block loses max(1, lightDampening) levels, so a dampening of 15 stops it. Bedrock applies one scalar in every direction; Java's per-face opacity is deliberately not modeled, see the package README.

lightEmission ​

readonly lightEmission: number

Defined in: BlockLightView.ts:11

The light the block emits, 0 to 15. Glowstone and a lantern are 15, a torch 14.


BlockLightView ​

Defined in: BlockLightView.ts:41

The world as the light engine sees it: one lookup that answers what a block does to light, and whether that block is loaded at all.

The server injects an implementation. The engine never touches chunk, block or world classes, so the two sides can change independently and the engine is testable against a plain array. See @jsprismarine/lighting/testing for a double that implements this interface.

Positions outside minY to maxY are never queried; the engine bounds-checks first. Everything else that returns undefined is an unloaded chunk, and the engine defers the propagation that would have crossed into it rather than treating it as opaque.

Properties ​

hasSkyLight ​

readonly hasSkyLight: boolean

Defined in: BlockLightView.ts:50

Whether the dimension has a sky. The nether and the end have none, so the engine keeps the sky layer at 0 there and skips heightmap work entirely.

maxY ​

readonly maxY: number

Defined in: BlockLightView.ts:45

One past the highest block y in the dimension. The overworld uses 320, the nether 128.

minY ​

readonly minY: number

Defined in: BlockLightView.ts:43

The lowest block y in the dimension, inclusive. Overworld worlds use -64.

Methods ​

blockAt() ​

blockAt(x, y, z): BlockLightProperties | undefined

Defined in: BlockLightView.ts:60

The light properties of the block at a world position.

Parameters ​
x ​

number

World x.

y ​

number

World y, always within minY to maxY.

z ​

number

World z.

Returns ​

BlockLightProperties | undefined

The block's properties, or undefined when the chunk holding the position is not loaded.

sectionHintAt()? ​

optional sectionHintAt(chunkX, sectionY, chunkZ): LightSectionHint | undefined

Defined in: BlockLightView.ts:71

Facts about every block state in a chunk section's palette, when the view can answer them. A missing hint keeps the full block scan. Palette entries that are unused may cause extra reads, but never let the engine skip a block that could emit or dampen light.

Parameters ​
chunkX ​

number

The chunk x.

sectionY ​

number

The signed subchunk y.

chunkZ ​

number

The chunk z.

Returns ​

LightSectionHint | undefined

The section facts, or no hint for a generic view.

topOfColumn()? ​

optional topOfColumn(x, z): number

Defined in: BlockLightView.ts:87

One past the highest y in a block column that does anything to light, so a chunk's first scan can start below the empty sky instead of at BlockLightView.maxY.

Optional, and a view that leaves it out is scanned from maxY, which is what every view was scanned from before. It is worth answering: a fresh chunk reads every block of all 256 of its columns, so an Overworld view that says 96 rather than 320 turns 98304 reads into 41000. An answer that is too high only costs time; one that is too low loses the light of every block above it, so a view that is unsure says maxY.

Parameters ​
x ​

number

World x.

z ​

number

World z.

Returns ​

number

One past the highest y that emits or dampens light. Everything from there up to maxY has to be transparent and unlit.


BlockPosition ​

Defined in: LightEngine.ts:7

A world block position.

Properties ​

x ​

readonly x: number

Defined in: LightEngine.ts:9

World x.

y ​

readonly y: number

Defined in: LightEngine.ts:11

World y.

z ​

readonly z: number

Defined in: LightEngine.ts:13

World z.


LightFlushResult ​

Defined in: LightEngine.ts:28

What one LightEngine.flush call did and what is left.

Extends ​

Properties ​

deferred ​

readonly deferred: number

Defined in: LightEngine.ts:24

Chunk columns that propagation reached and could not enter because they are not loaded. Their light is computed when LightEngine.onChunkLoaded announces them.

Inherited from ​

LightQueueDepth.deferred

processed ​

readonly processed: number

Defined in: LightEngine.ts:30

Nodes taken off a queue during the call, never more than the budget.

queued ​

readonly queued: number

Defined in: LightEngine.ts:19

Nodes waiting in the increase and decrease queues. Zero means the light has settled.

Inherited from ​

LightQueueDepth.queued


LightQueueDepth ​

Defined in: LightEngine.ts:17

How much light work is outstanding.

Extended by ​

Properties ​

deferred ​

readonly deferred: number

Defined in: LightEngine.ts:24

Chunk columns that propagation reached and could not enter because they are not loaded. Their light is computed when LightEngine.onChunkLoaded announces them.

queued ​

readonly queued: number

Defined in: LightEngine.ts:19

Nodes waiting in the increase and decrease queues. Zero means the light has settled.


LightSectionHint ​

Defined in: BlockLightView.ts:22

Palette facts that let a section scan skip states which cannot affect one light layer.

Properties ​

allTransparent ​

readonly allTransparent: boolean

Defined in: BlockLightView.ts:26

No palette state dampens light, so the section cannot move the sky heightmap.

mayEmit ​

readonly mayEmit: boolean

Defined in: BlockLightView.ts:24

At least one palette state emits block light.

Variables ​

MAX_LIGHT_LEVEL ​

const MAX_LIGHT_LEVEL: 15 = 15

Defined in: LightSection.ts:8

The highest level either light layer can hold.


SECTION_SIZE ​

const SECTION_SIZE: 16 = 16

Defined in: LightSection.ts:2

The edge length of a chunk section in blocks.


SECTION_VOLUME ​

const SECTION_VOLUME: number

Defined in: LightSection.ts:5

The number of blocks in a chunk section.