Sending chunks
Use ChunkHelper to build lit LevelChunkWithLight payloads from block state IDs.
helpers.ChunkHelper is an in-memory block store that serializes to the LevelChunkWithLight packet, lighting included. It exists so you can get a player standing on something without writing a chunk section encoder and a light engine first. To fill it with terrain instead of placing blocks by hand, see World generation.
import { helpers } from "mc-protocol";
const world = new helpers.ChunkHelper();
for (let x = -10; x < 10; x++) {
for (let z = -10; z < 10; z++) world.setBlock(x, 64, z, helpers.getBlockStateId("stone"));
}
world.setBlock(0, 65, 0, helpers.getBlockStateId("torch"));
await client.sendPacket("LevelChunkWithLight", world.getLevelChunkWithLight(0, 0));
Options
new helpers.ChunkHelper({
minY: -64, // lowest block Y in the dimension
height: 384, // total block height
biomeId: 0, // biome registry index for every section (0 = the first biome, plains in vanilla order)
hasSkyLight: true, // false for the Nether and the End
});
minY and height must match the dimension type you send in Login, or the client will render sections in the wrong place.
Block state IDs, not block IDs
setBlock takes a global block state ID for protocol 776. Each orientation, waterlogged variant, and power level of a block has its own ID. Use helpers.getBlockStateId(name, properties?) to resolve a name and optional property overrides:
helpers.getBlockStateId("stone"); // 1
helpers.getBlockStateId("minecraft:oak_log"); // 137 (axis=y by default)
helpers.getBlockStateId("oak_log", { axis: "x" }); // 136
helpers.getBlockStateId("redstone_lamp", { lit: true }); // 9479
helpers.getBlockStateId("water", { level: 15 }); // 101
world.setBlock(0, 66, 0, helpers.getBlockStateId("oak_stairs", {
facing: "east",
waterlogged: true,
}));
Names are case-sensitive, with or without minecraft:. Omitted properties use the block’s vanilla defaults, not its first state. Values accept strings, numbers, or booleans ("true" and true are equivalent). Only own properties are used; the input is not mutated. Unknown names, properties, and invalid values throw instead of silently choosing air or another state.
The helpers.BlockStateProperties type is Readonly<Record<string, string | number | boolean>>; property names and allowed values are checked at runtime. The bundled table covers all 1,196 vanilla blocks / 32,366 states from the official 26.2 server’s blocks.json report. No runtime download is needed. Use tools/generate-blocks.ts to regenerate it; the script records the server download and checksum and validates every state’s ordering.
These are state IDs, not block registry IDs or the item IDs returned by getItemId. They are pinned to 26.2 and can also be used in packet fields that require a global block state ID. setBlock still accepts numeric IDs; it does not resolve names itself.
Pass an object to override fluid detection:
world.setBlock(x, y, z, { stateId: 80, containsFluid: true });
Methods
| Method | Takes | Returns |
|---|---|---|
setBlock(x, y, z, stateId | BlockState) |
world coordinates | this |
getBlock(x, y, z) |
world coordinates | state ID (0 for air) |
getChunkData(cx, cz) |
chunk coordinates | Buffer of packed sections only |
getHeightmap(cx, cz, type?) |
chunk coordinates | bigint[]; type 1 = WORLD_SURFACE, 4 = MOTION_BLOCKING |
getLevelChunk(cx, cz) |
chunk coordinates | { chunkX, chunkZ, heightmaps, chunkData } |
getLevelChunkWithLight(cx, cz) |
chunk coordinates | the above plus sky/block light arrays and masks, ready for sendPacket |
Note the split: setBlock/getBlock use world coordinates, the get*Chunk* methods use chunk coordinates (Math.floor(x / 16)).
Lighting
getLevelChunkWithLight computes sky light, block emission, attenuation, and face occlusion using per-state properties extracted from the official 26.2 server. Slabs, waterlogged blocks, and lit versus unlit lamps behave as they do in vanilla.
The calculation looks 16 blocks past the chunk edge and one section above and below the world, so place neighboring blocks in the same helper before requesting packets. Results are cached; setBlock invalidates the affected cache. Buffers you get back are copies, so mutating them is safe.
The neighbor rule
The client will not build a chunk’s mesh until its eight horizontal neighbors are loaded and lit. Unset chunks are air, but they are not sent automatically. Send a full ring, including empty chunks, around anything you want rendered:
const RADIUS = 2;
await client.sendPacket("ChunkBatchStart");
let count = 0;
for (let cx = -RADIUS; cx <= RADIUS; cx++) {
for (let cz = -RADIUS; cz <= RADIUS; cz++) {
await client.sendPacket("LevelChunkWithLight", world.getLevelChunkWithLight(cx, cz));
count++;
}
}
await client.sendPacket("ChunkBatchFinished", { batchSize: count });
Updating blocks later
After setBlock at runtime, resend the changed chunk and its eight neighbors so their light updates too. For single-block edits BlockUpdate is cheaper for the block itself, but light will not follow until the chunk is resent.
Out of scope
- Block entities (chests, signs, spawners): send them separately.
- Dynamic behavior (redstone, lamps toggling): pass the new state ID to
setBlockyourself. - Terrain generation: the helper stores what you set and nothing else. World generation fills one with flat layers or seeded noise terrain.
- Worker threads:
createNoiseChunkPoolgenerates lit noise chunks concurrently and returns packet-ready payloads without exposing a shared mutable world.
The source README at src/Helpers/README.md has the same information plus notes on regenerating the lighting table.