npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

minecraft-block-reader

v2.1.0

Published

Read blocks, block states, and entities from Minecraft structure files and world saves

Readme

minecraft-block-reader

Read blocks, block states, and entities from Minecraft structure files and world saves. Works in Node.js and the browser, with no dependencies.

npm version jsDelivr License: MPL 2.0

Features

  • Reads structure files: .nbt, .litematic, .schem, and .mcstructure
  • Reads world saves: a world folder as a .zip, or a single .mca region file
  • One read function for every format, with one output shape
  • Block entity nbt attached to its block, entities in a list of their own
  • Lazy world reading: pass a Blob and multi-GB worlds are read in pieces, never loaded whole

Install

For Node.js, or the browser through a bundler:

npm install minecraft-block-reader

Or in the browser, import it straight from a CDN:

import { read } from "https://cdn.jsdelivr.net/npm/minecraft-block-reader/+esm"

On Vite's dev server the WebAssembly module fails to load and the reader silently falls back to its slower JavaScript path. Exclude the library from pre-bundling:

// vite.config.js
export default defineConfig({
  optimizeDeps: {
    exclude: ["minecraft-block-reader"]
  }
})

Vite builds are unaffected. Reading inside a web worker also needs worker: { format: "es" }, since the default iife format cannot code-split. Other bundlers may need their own equivalent.

Quick Start

read takes any supported file and works out what it is. Structure files come back as structures, saves as worlds:

import { read } from "minecraft-block-reader"

const structure = await read(bytes)   // .nbt, .litematic, .schem, or .mcstructure
const world = await read(blob)        // a world zip or region file

Every structure comes back in the same shape:

{
  size: [x, y, z],
  palette: [{ id: "minecraft:oak_stairs", properties: { facing: "north", … } }, …],
  blocks: [{ state: 0, pos: [x, y, z], nbt: { … } }, …],   // nbt only on block entities
  entities: [{ pos: [x, y, z], nbt: { … } }, …]
}

And a world reads only what you ask for:

const { palette, blocks, entities } = await world.blocks({ x0: -64, y0: 60, z0: -64, x1: 64, y1: 100, z1: 64 })

raw

Every result also carries raw, the same blocks as one flat Int32Array of [state, x, y, z, state, x, y, z, …]. blocks is built from it only if something reads it, and for millions of blocks those objects cost far more than the parse does, so stay on raw if you can:

const { palette, raw } = await world.blocks(box)
for (let i = 0; i < raw.length; i += 4) {
  const state = palette[raw[i]], x = raw[i + 1], y = raw[i + 2], z = raw[i + 3]
}

blockNbt is a Map from a block's index in raw to its nbt, so raw can reach block entities without the objects.

Documentation

The full export list:

| Export | | |---|---| | read(src, options) | Reads any supported file. Structure files give a structure, saves give a world | | chunkBlocks(nbt, options) | The blocks in a chunk, from its NBT | | chunkBiomes(nbt, options) | The biome of every block in a chunk, from its NBT | | readNBT(bytes, options) | Reads NBT into an object | | parseState(str) | A block state string as { id, properties } | | normState(v) | Converts an older block state shape to the current one | | AIR, REAL_AIR | Regexes that match air ids |

read(src, options)

src is the file: a Buffer, Uint8Array, ArrayBuffer, or Blob. Pass a Blob (a browser File, or fs.openAsBlob(path) in node) and a world is read straight off disk in pieces, so multi-GB worlds never load whole.

All options are optional:

| Option | Description | |---|---| | region | A lone region file's coordinates as [x, z] (the r.x.z.mca numbers), defaulting to [0, 0] | | dimension | Worlds: the dimension to open on, instead of the overworld | | onProgress | Worlds: called with (done, total) while the regions are scanned | | cacheSize | Worlds: how many bytes of unpacked region files to keep for reuse, defaulting to 320MB |

The format is worked out from the bytes. Gzip is unpacked wherever it turns up, and Bedrock block states are mapped as closely as they can be.

size is how big the build actually is. Litematic, schem and mcstructure files are trimmed to the blocks they contain. Vanilla .nbt files keep the size they declare.

Vanilla .nbt files read from any version, including the 1.9-era format, whose numeric ids come back as the block states they became.

Worlds

Opening a world only scans it. Blocks are read on demand, through the handle read gives back. Chunks from 1.13+ are supported.

| API | Description | |---|---| | world.name | The level name | | world.blocks(box, onProgress?) | The blocks in a box, given as { x0, y0, z0, x1, y1, z1, includeAir }. Every side is inclusive and the y bounds are optional. Comes back with one palette, the entities in the box, and chunks: { read, missing, outdated } | | world.chunks | Every chunk the save has on disk, as { cx, cz, region, index } | | world.chunk(c) | A chunk's raw NBT, including its entities | | world.chunkExtent(c, { yMin, yMax }) | The lowest and highest y the chunk has a block at, as { top, bottom }, or null if it has none. Much cheaper than reading the chunk. The optional y bounds limit the check to that range | | world.chunkBlocks(c, { yMin, yMax, includeAir }) | The blocks in a single chunk, or null if the chunk is missing or too old | | world.chunkGrid(c, { yMin, yMax }) | Every position in the chunk as one array, so a block can be looked up by its coordinates rather than searched for in a list | | world.dimension | The current dimension id | | world.dimensions | The dimension ids, e.g. ["overworld", "the_nether"] | | world.setDimension(id, onProgress?) | Switches the world to another dimension | | world.structures | The names of the structure files in the world's generated/ folder | | world.structure(rel) | One of those structure files, read | | world.file(path) | Any other file from the save (level.dat, map items, datapacks), as bytes |

chunkBlocks is also exported, for NBT you already have. Positions come back in world space:

import { chunkBlocks } from "minecraft-block-reader"

for (const c of world.chunks) {
  const { palette, blocks, entities } = chunkBlocks(await world.chunk(c), { yMin: 0, yMax: 128 })
}

chunkGrid

chunkBlocks gives the blocks that are there as a list, along with the chunk's entities. chunkGrid gives an array with a slot for every position instead, air included, so coordinates index straight into it.

Reach for it when you need to ask what is at a position. Stay on chunkBlocks to walk the blocks that exist, or when you need the entities, which the grid does not carry.

grid is a Uint16Array of 256 * height cells. A cell holds 0 for air, or a palette index with 1 added:

const { palette, grid, blockEntities, empty } = await world.chunkGrid(c, { yMin: 60, yMax: 80 })

// x and z are 0-15 inside the chunk, y is absolute
const at = (x, y, z) => {
  const cell = grid[(y - 60) * 256 + z * 16 + x]
  return cell === 0 ? null : palette[cell - 1]
}

at(3, 64, 7)   // { id: "minecraft:dirt" }
at(3, 65, 7)   // { id: "minecraft:grass_block", properties: { snowy: "false" } }
at(3, 66, 7)   // null, air

empty is true when the y range held no blocks at all, so a whole tile of sky can be skipped without walking the cells.

blockEntities are the ones inside that y range, at world coordinates, since the grid cells only carry a palette index:

blockEntities   // [{ x: 2493, y: 74, z: -1010, nbt: { id: "minecraft:beehive", … } }]

biomes holds the chunk's biomes over the same y range, one byte per block indexed like grid. Its palette is biome ids; colors and climate come from the biome's own data, which the reader doesn't load. A cell holds 0 when the chunk has no biome data there, or a palette index with 1 added. 26.4 saves a biome per block; older chunks save one per 4x4x4 blocks, which fills the blocks it covers:

const { biomes } = await world.chunkGrid(c, { yMin: 60, yMax: 80 })

const biomeAt = (x, y, z) => {
  const cell = biomes.grid[(y - 60) * 256 + z * 16 + x]
  return cell === 0 ? null : biomes.palette[cell - 1]
}

biomeAt(3, 64, 7)   // "minecraft:plains"

chunkBiomes(nbt, { yMin, yMax }) gives the same { palette, grid } for chunk NBT you already have, over every section the chunk has when the y range is left out. Biomes are only read per chunk: world.blocks doesn't return them.

NBT

import { readNBT } from "minecraft-block-reader"

const root = await readNBT(bytes)

Reads Java (big-endian) and Bedrock (little-endian) NBT. The endianness is worked out from the bytes and gzip is unpacked on the way in. Longs come back as BigInt, long arrays as Uint32Array [lo, hi] pairs.

| Option | Description | |---|---| | only | Keep just these root keys | | skip | Drop these keys, at any depth | | littleEndian | Force the endianness instead of detecting it |

Pass littleEndian when you already know it, and detection is skipped.

Reading part of a file

only and skip leave out values you are never going to look at. Both take a name, a list of names, or a Set:

{ only: "sections" }
{ only: ["sections", "block_entities"] }
{ skip: new Set(["block_light", "sky_light"]) }

They filter at different depths.

only applies to the root. Everything under a key it keeps is decoded in full:

await readNBT(chunk, { only: ["sections", "xPos", "zPos"] })
// { sections: [ … ], xPos: -22, zPos: -29 }   whole sections, nothing else

skip applies at every depth, matching on name rather than path, so it drops a key wherever it appears:

await readNBT(bytes, { skip: "Heightmaps" })
// { keep: 1, deep: { keep2: 2 } }   the nested Heightmaps went too

Be careful with a name the format reuses, data being the obvious one. The two combine, only choosing the branches and skip pruning within them.

Use them when a file is large or there are thousands of it. On a 19KB world chunk, dropping the lighting, biome and heightmap data takes a parse from 0.22ms to 0.15ms, around a third; for a single small file it is not worth the thought. Passing a Set avoids rebuilding one per call, which only matters in that thousands case.

Block states

The helpers the readers use to produce the { id, properties } shape.

parseState turns a block state string, the form used in commands, .schem palettes and datapacks, into that shape. An id with no namespace gets minecraft:, properties are always strings, and the key is left off when there are none:

parseState("oak_stairs[facing=north]")  // { id: "minecraft:oak_stairs", properties: { facing: "north" } }
parseState("minecraft:stone")           // { id: "minecraft:stone" }
parseState("garbage!!")                 // { id: "minecraft:air" }   anything unparseable reads as air

normState takes a state in any form Minecraft has used and returns the current one. Values that are not block states come back untouched, so it is safe to map over mixed data:

normState({ Name: "minecraft:chest", Properties: { facing: "north" } })
// { id: "minecraft:chest", properties: { facing: "north" } }

normState("minecraft:stone")        // { id: "minecraft:stone" }
normState({ id: "minecraft:stone" }) // unchanged
normState(42)                        // 42

AIR and REAL_AIR test whether an id is air. Both match with or without the namespace:

| | Matches | |---|---| | AIR | air, cave_air, void_air, structure_void | | REAL_AIR | air, cave_air, void_air |

The difference is structure_void, which only exists in structure files. Use AIR when reading a structure and REAL_AIR for world data.

Rust

The parsing is a Rust crate, which the WebAssembly module is built from. It works on its own:

[dependencies]
minecraft-block-reader = { git = "https://github.com/ewanhowell5195/minecraft-block-reader.git" }
use minecraft_block_reader::{read_any, BoxQuery, Region};

let structure = read_any(&bytes).unwrap();

let region = Region::new(std::fs::read("r.4.-2.mca")?);
let mut query = BoxQuery::new([2048.0, 60.0, -1024.0], [2300.0, 90.0, -800.0], false);
query.add_chunk(&region, index, true);
let blocks = query.finish();

read_any takes any structure format and gives back Option<Structure>. Worlds go through Region, which holds one region file, and BoxQuery, which walks many chunks into a single shared palette. add_chunk returns Read, Missing or TooOld, and counts() totals them. Region also has chunk for the raw NBT, chunk_blocks for one chunk, and chunk_extent for the lowest and highest non-air y.

Blocks come back as flat, a Vec<i32> of [state, x, y, z, state, x, y, z, …], with blocks() for the object form. Finding the region files is left to the caller, so the crate reads no zips. The wasm feature is off by default and adds the JavaScript bindings.

Examples

Two runnable scripts in examples/:

npm run structure -- path/to/build.litematic
npm run world -- path/to/world.zip

The first summarises any structure file. The second opens a world zip or region file and reports its dimensions, chunks, and a sample chunk's contents.

License

MPL-2.0 © Ewan Howell