Crafty · Chapter 2 Block Voxel Terrain

Chapter 2 · Crafty: Building a Voxel Sandbox Game

Block Voxel Terrain

The voxel world is what distinguishes Crafty from a generic rendering demo. This chapter opens the book with the foundation everything else in Crafty stands on. It covers the data structures, generation, and rendering of a Minecraft-style block-based terrain: chunks, greedy meshing, biomes, illuminated blocks, water, and village generation. The pattern is one specific terrain style — block voxels — not the only one a WebGPU engine can render; continuous heightmap terrain has its own companion book, Heightmap Terrain, and other terrain styles (terraformable planets, infinite procedural landscapes) are covered in the Sample Deep Dives.

§ 2.1

Voxel Data Structure#

The world is divided into chunks — fixed-size 3D arrays of block IDs. Each chunk is a 16×256×16 volume (X, Y, Z):

Chunk structure: 16 × 256 × 16 = 65,536 blocks
Chunk structure: 16 × 256 × 16 = 65,536 blocks
export class Chunk {
  static CHUNK_WIDTH  = 16;
  static CHUNK_HEIGHT = 16;
  static CHUNK_DEPTH  = 16;
  static SEA_LEVEL    = 15;

  blocks = new Uint16Array(Chunk.CHUNK_WIDTH * Chunk.CHUNK_HEIGHT * Chunk.CHUNK_DEPTH);
  globalPosition = new Vec3();

  constructor(px: number, py: number, pz: number) {
    this.globalPosition.set(px, py, pz);
  }

  getBlock(x: number, y: number, z: number): number { /* ... */ }
  generateVertices(neighbors?: ChunkNeighbors): ChunkMesh { /* greedy mesher */ }
  generateBlocks(seed: number, getErosion?: (gx, gz) => number): void { /* worldgen */ }
}

A chunk is a 16-cube of 4,096 blocks anchored at a world-space min corner; vertical worlds are built by stacking chunks along Y. Block types are stored as 16-bit integers — Crafty's block palette is built from a texture pack and runs well past the 255 a single byte can hold, so the per-block array is Uint16Array, not Uint8Array. The six neighbor-chunk references in ChunkNeighbors are likewise Uint16Array.

The BlockType Enum Is Generated#

BlockType and its companion data tables are no longer hand-written. They are emitted into src/block/block_type_data.ts by scripts/codegen_blocks.js, which merges two inputs:

The generated module is wrapped by a hand-authored facade, src/block/block_type.ts, which re-exports the data and owns the helper functions (isBlockOpaque, isBlockProp, getBlockName, …). The plain-data classes shared by both modules live in src/block/block_type_classes.ts. To change block data you edit block_metadata.json (or the atlas inputs) and re-run node scripts/codegen_blocks.js — see §2.13 for the full pipeline.

Each block type carries several parallel data tables, all indexed by the BlockType integer:

TableContents
blockTextureOffsetDataAtlas tile coords for the side / bottom / top faces, plus an optional sRGB tint
blockMaterialDataMaterialType (Opaque / Semi-transparent / Water / Prop), emitsLight and collidable flags
blockHardnessMining-time multiplier (× 1500 ms)
blockLightDataPoint-light color / intensity / radius for light-emitting blocks
blockTypeNameHuman-readable display name
blockPlaceableWhether the block appears in the inventory block picker
§ 2.2

Chunk Management#

Chunks are loaded and unloaded based on distance from the player. The World class maintains a map of loaded chunks:

export class BlockWorld {
  static readonly MAX_CHUNKS = 2048;

  renderDistanceH = 8;     // horizontal radius in chunks
  renderDistanceV = 4;     // vertical radius in chunks
  chunksPerFrame  = 2;     // generation throttle

  private _chunks = new Map<number, Chunk>();   // bit-packed (cx, cy, cz) key

  getChunk(wx: number, wy: number, wz: number): Chunk | undefined { /* ... */ }
  getBiomeAt(wx: number, wy: number, wz: number): BiomeType { /* ... */ }
}

Chunk coordinates are computed from the world-space block position. _chunks is keyed by a 48-bit packed (cx, cy, cz) integer rather than a string, because the per-frame number of Map.get / Map.has lookups makes template-literal allocation a measurable GC source:

static normalizeChunkPosition(wx: number, wy: number, wz: number): [number, number, number] {
  return [
    Math.floor(wx / Chunk.CHUNK_WIDTH),
    Math.floor(wy / Chunk.CHUNK_HEIGHT),
    Math.floor(wz / Chunk.CHUNK_DEPTH),
  ];
}

Far-Terrain LOD Rings#

Full-detail chunks only reach renderDistanceH chunks from the player. Past that, the world keeps going as far-terrain LOD rings: coarse chunks whose cells are scale blocks on a side. BlockWorld.lodScales lists the ring scales, and each must be double the one before. Crafty takes them from the device profile — [2, 4, 8], so the horizon reaches eight times the full-detail range — and ?lod=0 or ?lod=2,4 overrides that for comparison shots:

crafty/main.tsTypeScript
// Far-terrain LOD rings (see BlockWorld.lodScales): coarse chunks of 2, 4
// and 8-block cells out to 8× the view range, from the device profile.
const lodParam = new URLSearchParams(location.search).get('lod');
world.lodScales = lodParam === null ? [...profile.lodScales]
  : lodParam.split(',').map(Number).filter((s) => Number.isFinite(s) && s >= 2);

Ring i covers the annulus from where the previous ring ends out to R × s_i blocks, where R = renderDistanceH × 16. Because the cell size and the radius double together, every ring holds roughly the same number of cells, so adding a ring costs about as much as the near field — not four times as much.

Far-terrain LOD rings: full detail out to R, then rings of 2-, 4- and 8-block cells out to 2R, 4R and 8R, each overlapping the ring inside it by one coarse chunk

The ring test is a pair of squared distances. The inner edge is pulled in by one coarse chunk so neighboring rings overlap rather than leave a crack, and a slack flag widens both edges when deciding what to evict, giving hysteresis so a column on the boundary doesn't flicker in and out as the player moves:

private _inLodRing(level: number, cx: number, cz: number, px: number, pz: number, slack: boolean): boolean {
  const R = this.renderDistanceH * 16;
  const scale = this.lodScales[level];
  const span = 16 * scale;
  const outer = R * scale + (slack ? span : 0);
  // Clamped at 0: a negative inner edge would square into a spurious hole around the player.
  const inner = Math.max(0, (level === 0 ? R : R * this.lodScales[level - 1]) - span - (slack ? span : 0));
  const dx = (cx + 0.5) * span - px, dz = (cz + 0.5) * span - pz;
  const d2 = dx * dx + dz * dz;
  return d2 <= outer * outer && d2 >= inner * inner;
}

Each update() evicts columns that left their ring, then queues the nearest missing columns across all rings — at most lodColumnsPerFrame (8) per frame — to the mesh worker pool. The workers run Chunk.generateLodColumn, which samples the terrain at a few block columns per cell and makes a cell solid only when its top lies under the lowest sample. The coarse surface therefore never pokes above the true one, and where rings overlap the finer terrain simply draws over it. LOD columns are surface-only (no caves, ores, structures, or props) and never enter the block map, so they cost nothing in physics or editing; they reach the renderer through onLodChunkAdded / onLodChunkUpdated / onLodChunkRemoved. A request is dropped if the world, the ring scales, or the player's ring membership changed while it was in flight.

Frustum Culling#

Before rendering, each chunk is tested against the camera frustum. Only chunks that intersect the view frustum are submitted to the GPU. This culling is performed on the CPU each frame.

§ 2.3

Procedural World Generation#

World generation runs per-chunk in Chunk.generateBlocks (src/block/chunk.ts). There is no longer a separate generator.ts — terrain, biomes, caves, trees, and props are all built inside the chunk that owns them.

Noise-Based Terrain#

generateBlocks runs in two passes: the first fills solid terrain, the second decorates the surface with trees and props once the ground is in place.

To keep noise evaluations cheap, everything that depends only on world XZ is computed once per column before the per-block loop:

const cont = perlinNoise3Seed(gx / 2048, 10, gz / 2048, 0, 0, 0, seed);
colHeightMult[ci] = Math.abs(perlinNoise3Seed(gx / 1024, 0, gz / 1024, 0, 0, 0, seed) * 450)
                  * Math.max(0.1, (cont + 1) * 0.5);
colFlatness[ci]   = perlinRidgeNoise3(gx / 256, 15, gz / 256, 2.0, 0.6, 1.2, 6) * 12;

cont is a low-frequency continentalness field deciding how high terrain may climb; colHeightMult is the per-column amplitude; colFlatness is a ridge-noise term that adds mountain ridges. Both are then scaled by a per-biome multiplier (see below). The per-block loop turns those into a surface height:

const surfaceHeight =
  Math.abs(perlinNoise3Seed(g_x / 256, g_y / 512, g_z / 256, 0, 0, 0, seed) * colHeightMult[ci])
  + colFlatness[ci] + colErosion[ci];

Everything below surfaceHeight becomes solid (unless carved into a cave); everything below Chunk.SEA_LEVEL that is not solid is filled with water.

Deterministic Seed-Based Randomness#

Terrain height and the climate channels use perlinNoise3Seed — a seeded variant of Perlin noise. Every noise call receives the world seed combined with a unique per-feature offset so that the same world seed always produces identical terrain:

// Continental shape + height share the base seed; the climate channels each
// add a unique offset so they sample the permutation table independently.
const continentalness = perlinNoise3Seed(gx / 2048, 10, gz / 2048, 0, 0, 0, seed);
const heightMult      = perlinNoise3Seed(gx / 1024,  0, gz / 1024, 0, 0, 0, seed);
// Chunk._sampleClimate — temperature / humidity / special
const temperature = perlinNoise3Seed(gx / 512, -5, gz / 512, 0, 0, 0, seed + 31337);
const humidity    = perlinNoise3Seed(gx / 600,  7, gz / 600, 0, 0, 0, seed + 91019);
const special     = perlinNoise3Seed(gx / 900,  3, gz / 900, 0, 0, 0, seed + 54321);

Internally the seed is hashed into the permutation-table lookup (seed & 0xFF), shifting which gradient vectors are chosen at each lattice point. Two noise calls with different seeds sample independently and produce decorrelated values, even at the same spatial coordinate.

Why seed offsets matter. If the same seed were used for every feature, the continental shelf, the mountain ridges, the biome temperature, and the cave networks would all share the same base pattern — a player would see ridges precisely where caves appear, for example. Each feature is given a large, prime-like offset (seed + 777, seed + 13579, seed + 31337, etc.) so the eight-bit hash lands on different permutation entries and the features are statistically independent:

FeatureSeed offsetScale
Continental shapeseed2048
Height multiplierseed1024
Temperatureseed + 31337512
Humidityseed + 91019600
Special (rare biomes)seed + 54321900
Grand cavernsseed + 1111200
Cheese cavesseed + 77760
Spaghetti tunnelsseed + 13579, seed + 2468024
Secondary tunnelsseed + 55555, seed + 9999928
Ore veinsseed + 30011 … seed + 301034.5–9

This approach gives deterministic worlds: any player visiting world seed = 42 sees mountains at the same coordinates, the same cave systems, and the same village locations, regardless of platform or renderer.

Biomes#

Crafty has thirteen biomes, defined in src/block/biome_type.ts: Grassy Plains, Forest, Savanna, Desert, Jungle, Swamp, Taiga, Snowy Plains, Snowy Mountains, Rocky Mountains, Badlands, Cherry Grove, and Mushroom Fields.

Biome selection is driven by the three climate noise channels sampled by Chunk._sampleClimate: temperature, humidity, and a low-frequency special channel. The first two place the block on a 2D climate grid; the third occasionally overrides the result with a rare biome.

Biome map: temperature × humidity classification
Biome map: temperature × humidity classification

biomeAt(t, h) is the base climate lookup — a cascade of threshold tests on the temperature and humidity values, both in [-1, 1]:

export function biomeAt(t: number, h: number): BiomeType {
  if (t < -0.60) return BiomeType.RockyMountains;
  if (t < -0.42) return BiomeType.SnowyMountains;
  if (t < -0.15) return h > 0.25 ? BiomeType.Taiga : BiomeType.SnowyPlains;
  if (t <  0.32) {
    if (h > 0.42) return BiomeType.Swamp;
    if (h > 0.02) return BiomeType.Forest;
    return BiomeType.GrassyPlains;
  }
  if (h > 0.22)  return BiomeType.Jungle;
  if (h > -0.28) return BiomeType.Savanna;
  return BiomeType.Desert;
}

pickSpecialBiome(t, s) then checks the special noise: a very high value yields Mushroom Fields, a high value yields Badlands (hot) or Cherry Grove (cooler). When the special noise selects nothing, the base climate biome is used. pickBiome(t, h, s) composes the two.

Biome Border Blending#

Hard biome boundaries produce visible seams — a wall of grass meeting a wall of sand. getBiomeBlend(t, h, s) softens them. It returns the biome at the sampled point, its nearest neighboring biome, and a blend weight that rises from 0 to about 0.5 as the sample approaches the climate-space border:

export interface BiomeBlend {
  biome1: BiomeType;   // biome at the sampled point
  biome2: BiomeType;   // nearest neighboring biome
  blend: number;       // 0 = pure biome1, ~0.5 right at the border
}

It locates biome2 by probing outward along the temperature and humidity axes until the climate lookup resolves to a different biome. World generation uses the blend two ways:

Special biomes (Badlands / Cherry Grove / Mushroom Fields) do not blend — they return blend === 0.

Per-Biome Generation#

Each biome controls several aspects of its terrain:

Caves#

Chunk._isCave carves underground voids with layered noise tests, skipping the top three blocks below the surface so the ground keeps a solid cap. Three cave types intersect:

Ores#

Underground rock is seeded with ore veins of increasing rarity and depth. Whenever the per-biome soil column resolves to STONE, generateBlocks calls Chunk._oreAt, which may replace that stone with an ore. Ores appear only in rock — never in dirt, sand, gravel, or the banded terracotta of the badlands — and never inside a cave, because the cave carve is tested first and short-circuits to air.

Ore distribution: a rock cross-section with ores thinning out as depth increases, beside a tier legend of min depth and relative abundance
Ore distribution: a rock cross-section with ores thinning out as depth increases, beside a tier legend of min depth and relative abundance

Each ore is an independent 3-D Perlin field. A stone block becomes that ore where its field rises above a per-ore threshold and the column is at least minDepth blocks below the surface:

// Ore veins in stone, ordered rarest → most common.
private static readonly _ORES = [
  { block: BlockType.DIAMOND_ORE,  scale: 5.0, threshold: 0.86, minDepth: 36, salt: 30011 },
  { block: BlockType.EMERALD_ORE,  scale: 4.5, threshold: 0.85, minDepth: 30, salt: 30029 },
  { block: BlockType.GOLD_ORE,     scale: 6.0, threshold: 0.80, minDepth: 22, salt: 30047 },
  { block: BlockType.REDSTONE_ORE, scale: 6.0, threshold: 0.76, minDepth: 18, salt: 30059 },
  { block: BlockType.LAPIS_ORE,    scale: 6.0, threshold: 0.76, minDepth: 14, salt: 30071 },
  { block: BlockType.IRON_ORE,     scale: 7.0, threshold: 0.70, minDepth:  9, salt: 30089 },
  { block: BlockType.COPPER_ORE,   scale: 8.0, threshold: 0.66, minDepth:  6, salt: 30097 },
  { block: BlockType.COAL_ORE,     scale: 9.0, threshold: 0.62, minDepth:  5, salt: 30103 },
];

static _oreAt(gx, gy, gz, seed, depthBelowSurface): BlockType {
  for (const ore of Chunk._ORES) {
    if (depthBelowSurface < ore.minDepth) {
      continue;
    }
    const n = perlinNoise3Seed(gx / ore.scale, gy / ore.scale, gz / ore.scale, 0, 0, 0, seed + ore.salt);
    if (n > ore.threshold) {
      return ore.block;
    }
  }
  return BlockType.NONE;
}

Four knobs set each ore's character:

Ore veins: large scale + low threshold give big frequent veins, small scale + high threshold give tiny rare ones; ores are tested rarest first and the first passing test wins

The ores are tested rarest first, and the loop returns on the first hit. Where a rich, deep field (diamond) happens to overlap a common one (coal) in the same block, the rarer ore wins. Common ores — coal, copper, iron — use large scales and low thresholds, so they form sizeable veins starting just under the dirt; the uncommon middle (lapis, redstone) and the rare ores (gold, emerald, diamond) use tighter scales, higher thresholds, and deeper gates, so they surface as small, sparse pockets only far underground. Because every sample is seeded, the ore layout is fully deterministic — world seed = 42 always has the same diamonds in the same blocks.

§ 2.4

Chunk Meshing#

Before a chunk can be rendered, its block data must be converted into vertex buffers — a process called meshing. Each frame, every visible chunk submits its mesh as instanced draw calls in the block geometry pass. Meshes are regenerated whenever a block in the chunk changes (placement, breaking, water flow).

Face Culling#

The core principle of chunk meshing is to only generate geometry for faces that are actually visible. A block face should produce a quad only when the adjacent block in that direction is not solid. "Solid" here means a block whose faces would obstruct the view — opaque blocks are solid, semi-transparent blocks (leaves, glass) are not, and water is handled separately.

Greedy meshing: naive vs merged
Greedy meshing: naive vs merged

The skipCheck function in Chunk.generateVertices encodes this logic:

b1 = current block type
b2 = neighbor block type in the face direction

skip face if:
  b2 is not BlockType.NONE              (something is there)
  AND neither b1 nor b2 is a prop       (props are always rendered as billboards)
  AND (b1 is not water OR b2 is not water)   (water-water faces are hidden)
  AND (b1 is not opaque OR b2 is not semi-transparent) (semi-transparent blocks
       do not hide opaque faces behind them)

A face is only emitted when skipCheck returns false, meaning the neighbor is either air (BlockType.NONE), water adjacent to a non-water block, or a prop. This culling typically eliminates 50–70% of potential faces, since interior blocks are entirely surrounded by other solids.

The Padded Block Grid#

To avoid per-axis boundary checks in the hot mesher loop, the chunk first builds a (W+2)×(H+2)×(D+2) padded copy of its block array. The interior (indices [1..W]×[1..H]×[1..D]) is a direct copy of the chunk's own blocks. The six outer faces are filled from the ChunkNeighbors object, which exposes the raw Uint8Array block data of each adjacent chunk:

ChunkNeighbors:
  negX, posX — east/west neighbor block arrays
  negY, posY — bottom/top (depth) neighbor block arrays
  negZ, posZ — north/south neighbor block arrays
Greedy meshing: naive vs merged
Greedy meshing: naive vs merged

When a neighbor exists, its facing layer of blocks is copied into the padding. When no neighbor exists (chunk at world boundary), the padding remains BlockType.NONE, which causes all chunk-edge faces to emit geometry — the correct behavior for open edges.

With this padded grid, the mesher always reads getType(x, y, z) via a single array index padded[(x+1) + (y+1)*PW + (z+1)*PWPH] with no conditional logic for chunk boundaries.

Vertex Layout#

Each vertex is encoded as five float32 values packed into a single interleaved buffer:

ComponentDescription
x, y, zWorld-space position of the vertex (after greedy merge scaling)
faceFace normal index: 0=back(-Z), 1=front(+Z), 2=left(-X), 3=right(+X), 4=bottom(-Y), 5=top(+Y), 6=billboard
blockTypeNumeric block type ID, used by the fragment shader to look up texture atlas tiles

Texture coordinates are not stored per vertex. Instead, the fragment shader computes UVs from the world position and face normal using the atlas_uv function in chunk_geometry.wgsl:

fn atlas_uv(world_pos: vec3<f32>, face: u32, block_type: u32) -> vec2<f32> {
  let bd = block_data[block_type];
  // Select tile based on face: bottomTile (face 4), topTile (face 5), sideTile (others)
  var tile: u32;
  if face == 4u      { tile = bd.bottomTile; }
  else if face == 5u { tile = bd.topTile; }
  else               { tile = bd.sideTile; }

  // Compute local UV from fractional world position, mapping the face axis
  var local_uv: vec2<f32>;
  if face == 2u || face == 3u {           // left/right: ZY plane
    local_uv = fract(world_pos.zy);
  } else if face == 4u || face == 5u {   // bottom/top: XZ plane
    local_uv = fract(world_pos.xz);
  } else {                                // back/front: XY plane
    local_uv = fract(world_pos.xy);
  }

  // Offset into the texture atlas by the block's tile indices
  let tileX = f32(tile % ATLAS_COLS);
  let tileY = f32(tile / ATLAS_COLS);
  return (vec2<f32>(tileX, tileY) + local_uv) * vec2<f32>(INV_COLS, INV_ROWS);
}

Each block type specifies three atlas tiles (topTile, sideTile, bottomTile) so that a grass block, for example, shows grass on top, dirt on the bottom, and a grass-dirt blend on the sides. The face index selects which tile to use. The local UV is derived from fract(world_pos) of the two axes that lie in the face plane, giving a continuous 0–1 tiling along each block face.

Blocks that ship as grayscale masks in the RTX texture pack — notably grass and leaves — also carry an sRGB tint in their BlockData entry, packed as 0x00BBGGRR. The fragment shader linearizes it and multiplies it into the sampled albedo, so grass reads green and spruce leaves blue-green without needing separately colored atlas tiles. A tint of white (0xFFFFFF) means no tint. Tint values are authored per BlockType in block_metadata.json.

Mesh Categories#

Blocks are classified into four material categories, each producing a separate vertex buffer:

CategoryBlock typesRenderingVertex count
OpaqueDirt, stone, sand, planks, etc.G-Buffer (deferred)36 per non-culled face, merged into greedy quads
Semi-transparentLeaves, glassG-Buffer (deferred) with alpha testSame as opaque
WaterWaterForward pass with refraction/SSR3 floats per vertex, 6 verts per face, no greedy merge
PropFlowers, torches, dead bushesForward billboard6 verts per block at center position, expanded in vertex shader

Opaque and semi-transparent meshes use the same vertex layout (5 floats, greedy-merged quads) and are rendered together in the block geometry pass. The fragment shader discards fragments where the texture alpha is below 0.5, which handles leaf and glass edges naturally.

Water is meshed as individual non-merged quads using a simpler 3-float layout (position only). The water surface always emits a top face, plus any side faces that are not adjacent to another water block. At chunk edges, water sides are only suppressed when the neighbor chunk is truly absent (not loaded), not merely when the neighbor has air — this prevents visual gaps at chunk boundaries.

Props are single-point billboards: each prop emits 6 vertices all at the block center position (x+0.5, y+0.5, z+0.5) with face index 6. The vertex shader expands these into camera-facing quads using the billboard_offset function, which maps the vertex index modulo 6 to a corner offset scaled by the camera right/up vectors.

Prop Billboard Rendering#

chunk.ts — Billboarding a prop block:

if (isProp) {
  // Single camera-facing billboard quad centered in the block.
  for (let v = 0; v < 6; v++) {
    propBuffer[propIdx++] = x + 0.5;
    propBuffer[propIdx++] = y + 0.5;
    propBuffer[propIdx++] = z + 0.5;
    propBuffer[propIdx++] = 6;         // face=6 signals billboard
    propBuffer[propIdx++] = blockType;
  }
  continue;
}

All six vertices share the same center position (x+0.5, y+0.5, z+0.5) and the marker face index 6. The vertex shader vs_prop in chunk_geometry.wgsl expands them into a camera-facing quad:

fn billboard_offset(vid: u32) -> vec2<f32> {
  switch vid % 6u {
    case 0u: { return vec2<f32>(-0.5, -0.5); }
    case 1u: { return vec2<f32>( 0.5, -0.5); }
    case 2u: { return vec2<f32>(-0.5,  0.5); }
    case 3u: { return vec2<f32>( 0.5, -0.5); }
    case 4u: { return vec2<f32>( 0.5,  0.5); }
    default: { return vec2<f32>(-0.5,  0.5); }
  }
}

@vertex
fn vs_prop(vin: VertexInput, @builtin(vertex_index) vid: u32) -> PropVertexOutput {
  let center = vin.position + chunk.offset;
  let cam_right = vec3<f32>(camera.view[0].x, camera.view[1].x, camera.view[2].x);
  let cam_up    = vec3<f32>(camera.view[0].y, camera.view[1].y, camera.view[2].y);
  let off = billboard_offset(vid);
  let wp  = center + cam_right * off.x + cam_up * off.y;
  // ...
}

The camera right and up vectors are extracted from the view matrix (column-major storage), so the quad always faces the camera regardless of orientation. The UVs follow the same vertex-index pattern, mapping the atlas tile onto the expanded quad.

Because props are alpha-tested (discarded below 0.5 alpha) and use cullMode: none, they work as thin billboards visible from both sides — ideal for grass blades, flowers, and torches.

Shadow passes render props twice — once with right=(1,0,0) and once with right=(0,0,1) — forming a cross-shaped shadow that avoids the paper-thin appearance a single billboard would produce when viewed edge-on from the light source.

Greedy Merge#

The mesher does not emit one quad per block face. Instead it scans each axis plane and merges adjacent faces of the same block type into the largest possible rectangle (the full algorithm is described in 16.5 Greedy Meshing). The merge is tracked with a drawnFaces bitfield (uint16 per (x, y, face) combination, with the z bit marking faces that have already been claimed by a larger quad). This merge step is what makes chunk rendering viable — a flat terrain chunk may have only a few hundred quads instead of tens of thousands.

§ 2.5

Greedy Meshing#

Rendering each visible block face as two triangles creates millions of quads — far too many for real-time performance. Greedy meshing solves this by merging adjacent faces of the same block type into larger quads:

Greedy meshing: naive vs merged
Greedy meshing: naive vs merged

Algorithm#

For each face direction (6 directions), the algorithm:

  1. Mask generation. For each slice perpendicular to the face direction, generate a 2D binary mask of solid blocks whose neighbor in the face direction is air.
  2. Greedy merge. Scan the mask and merge contiguous runs into the largest possible rectangle.
  3. Emit quad. Each merged rectangle becomes a single quad (4 vertices, 6 indices).

This reduces the vertex count by 10-100× compared to naive face-per-block rendering. The result is stored in a chunk's mesh, which is regenerated when blocks in the chunk change.

class ChunkMesh {
  vertexBuffer: GPUBuffer;
  indexBuffer: GPUBuffer;
  indexCount: number;
  opaque: boolean;  // Separate meshes for opaque and transparent blocks
}

Separate Opaque and Transparent Meshes#

Each chunk produces two meshes: one for opaque blocks (dirt, stone, etc.) and one for transparent/translucent blocks (water, leaves, glass). The opaque mesh writes depth and G-Buffer normally. The transparent mesh uses alpha blending in the forward pass.

§ 2.6

Block Interaction#

Ray Casting#

The player interacts with blocks by aiming at them. A ray is cast from the camera through the crosshair, and the voxel traversal uses a DDA (digital differential analyzer) algorithm — at each step it advances to whichever grid line is closer along the ray, visiting cells in exact order:

DDA voxel ray cast
DDA voxel ray cast
function raycastVoxels(origin: Vec3, direction: Vec3, world: World, maxDist: number): BlockHit | null {
  // DDA traversal through the voxel grid
  // Returns the first non-air block intersected, plus the face normal
}

Block Placement#

Right-clicking a block face places a new block adjacent to the targeted face. Placement is instantaneous — the block ID is written to the chunk's blocks array and the chunk mesh is marked dirty for regeneration. The BlockInteractionState fires an onLocalEdit callback with { kind: 'place', x, y, z, blockType }.

Instant Breaks#

Blocks with hardness = 0 (props: flowers, torches, dead bushes; also water and air) are removed immediately with no crack animation. The BlockInteractionState detects hardness 0 and calls completeBreak() directly without entering the progressive mining loop.

Chunk Dirtying#

Both placement and breaking follow the same re-meshing pattern:

  1. The block ID is updated in the chunk's blocks array via chunk.setBlock().
  2. The chunk is marked for re-mesh via world._updateChunk().
  3. If the modified block lies at a chunk boundary (within 1 block of the edge), neighboring chunks are also marked dirty, since their face-culling depends on this chunk's blocks.
  4. Re-meshing is deferred: changes are accumulated in a _dirtyChunks set and processed once per frame.

On the server side (server/src/world_state.ts), edits are validated (integer coords, deduplicated against prior edits at the same cell) and broadcast to all clients as { t: 'block_edit', edit } messages.

Block Breaking Progression#

When the player holds left-click on a block within reach, the break timer advances each frame. The BlockInteractionState in crafty/game/block_interaction.ts tracks:

interface BlockInteractionState {
  targetBlock: Vec3 | null;
  breakProgress: number;        // accumulated ms spent mining this block
  breakingBlock: Vec3 | null;   // world position of the block being broken
  breakTime: number;            // total ms required (hardness × 1500)
  crackStage: number;           // -1 = none, 0-9 = current crack overlay stage
  onBlockBroken: (x: number, y: number, z: number, blockType: BlockType) => void;
  onBlockChip: (x: number, y: number, z: number, blockType: BlockType) => void;
}

Break Time#

The total time to break a block is determined from its hardness value in blockHardness[] (src/block/block_type.ts):

function getBreakTime(blockType: BlockType): number {
  return blockHardness[blockType] * 1500;  // ms
}

A hardness of 0 means the block breaks instantly (no crack animation). Examples:

BlockHardnessBreak Time
Dirt, Sand0.5750 ms
Grass0.6900 ms
Stone, Amethyst1.52250 ms
Trunk, Planks2.03000 ms
Diamond, Iron ore3.04500 ms
Obsidian10.015000 ms

Per-Frame Accumulation#

Each frame, updateBlockInteraction() in crafty/game/block_interaction.ts:233 accumulates breaking time:

// Called every frame with dt in seconds
this.breakProgress += dt * 1000;

The current crack stage is computed from progress:

const newStage = Math.floor(this.breakProgress / this.breakTime * 10);
if (newStage !== this.crackStage && newStage < 10) {
  this.crackStage = newStage;
  this.onBlockChip(x, y, z, blockType);  // emit chip particles
}
if (this.breakProgress >= this.breakTime) {
  this.completeBreak();  // remove block, emit break particles + sound
}

When the player releases the mouse button or looks away from the block, all break state resets to zero — progress is not preserved between attempts.

Crack Overlay Rendering#

The BlockHighlightPass (src/renderer/render_graph/passes/block_highlight_pass.ts) renders a crack overlay on the targeted block face. The crack atlas occupies the rightmost column of the block texture atlas: 9 crack stages stacked vertically, mapped by crackStage (0-9). The WGSL shader in block_highlight.wgsl samples this tile and composites a dark overlay with luminance-based alpha:

// Crack alpha from luminance of the crack tile sample
let crack_luma = dot(crack_sample.rgb, vec3<f32>(0.299, 0.587, 0.114));
let crack_alpha = smoothstep(0.3, 0.7, crack_luma);
let final_alpha = min(0.35 + crack_alpha * 0.5, 0.9);

The final fragment combines a base dark overlay (0.35 alpha) with the crack texture, capped at 0.9 alpha. This is drawn as 36 face vertices and 144 edge wireframe vertices around the targeted block.

Particle Emission#

Block breaking produces two kinds of particles, both emitted through a dedicated GPU particle pass (ParticlePass with blockBreakConfig):

Chip Particles on Crack Stage Advance#

Each time the crack stage advances (every 10% of break time), a burst of 4 particles is emitted from the block center:

blockInteraction.onBlockChip = (x, y, z, blockType) => {
  const [r, g, b] = getBlockColor(blockType);
  passes.blockBreakPass?.burst(
    { x: x + 0.5, y: y + 0.5, z: z + 0.5 },
    [r, g, b, 1],
    4
  );
};

The block color is extracted at startup by loadBlockColors() (crafty/game/block_colors.ts), which samples each block type's top-face tile from the texture atlas and averages it into an sRGB-to-linear converted [r, g, b] triplet.

Break Particles on Destruction#

When the block is fully broken, a larger burst of 14 particles is emitted from the same position, following the same color-tinting scheme.

Particle Configuration#

Both bursts use the blockBreakConfig in crafty/config/particle_configs.ts:

export const blockBreakConfig: ParticleGraphConfig = {
  emitter: {
    maxParticles: 1024,
    spawnRate: 0,          // bursts only — no continuous emission
    lifetime: [0.5, 1.0],
    shape: { kind: 'sphere', radius: 0.15, solidAngle: Math.PI },
    initialSpeed: [2.0, 4.5],
    initialColor: [1, 1, 1, 1],  // overridden per burst by the block's tint
    initialSize: [0.025, 0.05],
    roughness: 0.9, metallic: 0.0,
  },
  modifiers: [
    { type: 'gravity', strength: 14.0 },
    { type: 'drag', coefficient: 0.6 },
  ],
  renderer: { type: 'sprites', blendMode: 'alpha', billboard: 'camera', shape: 'pixel',
      renderTarget: 'hdr' },
};

Key properties:

The ParticlePass (src/renderer/render_graph/passes/particle_pass.ts) simulates particles entirely on the GPU via compute shaders generated by ParticleBuilder (src/particles/particle_builder.ts). The burst() method queues a one-shot spawn that replaces any prior pending burst:

burst(position: Vec3, color: [number, number, number, number], count: number): void {
  this._pendingBurst = { position, color, count };
}

On the next GPU dispatch, these particles are emitted into the simulation buffer and rendered as billboard sprites into the HDR render target, composite over the scene.

Audio#

Block breaking and placement trigger spatial audio via the AudioManager (crafty/game/audio_manager.ts).

Surface-to-Sound Mapping#

Each block type maps to a SurfaceGroup via blockTypeToSurface() (src/engine/audio_surface.ts):

Surface GroupBlock Types
grassGRASS, DIRT, TREELEAVES, SNOW, GRASS_SNOW, GRASS_PROP, SNOWYLEAVES
sandSAND
woodTRUNK, SPRUCE_PLANKS
stoneSTONE, GLASS, GLOWSTONE, MAGMA, OBSIDIAN, DIAMOND, IRON, SPECULAR, CACTUS, AMETHYST

Dig Sound Playback#

When a block is fully broken, the onBlockBroken callback fires:

blockInteraction.onBlockBroken = (x, y, z, blockType) => {
  const [r, g, b] = getBlockColor(blockType);
  passes.blockBreakPass?.burst(/* ... */, 14);     // break particles
  const surface = blockTypeToSurface(blockType);
  audio.playDig(surface, new Vec3(x + 0.5, y + 0.5, z + 0.5));  // dig sound
};

playDig() selects a random audio buffer from the matching surface group's pre-loaded pool (assets/sounds/player/dig/) — each surface has 4 .wav variants for variety — and plays it as a spatial one-shot:

playDig(surface: SurfaceGroup, pos: Vec3): void {
  const list = this._digBuffers.get(surface);
  const buf = list[Math.floor(Math.random() * list.length)];
  this.playBufferAt(buf, pos, 0.8);
}

Spatial Audio Pipeline#

playBufferAt() creates a OneShot instance with Web Audio API nodes:

  1. AudioBufferSourceNode — plays the sound buffer once.
  2. PannerNode — HRTF-based 3D panning with inverse distance model:
    • maxDistance: 50 blocks
    • refDistance: 5 blocks
    • rolloffFactor: 1.0
  3. GainNode — final volume = volume × sfxVolume × masterVolume (0.8 × 0.7 × 0.5 = 0.28 by default).

The listener position and orientation are updated each frame from the camera transform via updateListener() in AudioManager. Finished one-shots are automatically pruned.

Audio Asset Inventory#

CategoryFilesPurpose
dig/grass1-4.wav4Breaking grass, dirt, leaves
dig/sand1-4.wav4Breaking sand
dig/stone1-4.wav4Breaking stone, ores, glass
dig/wood1-4.wav4Breaking wood trunks, planks

Audio Initialization#

The Web Audio AudioContext is created lazily on the first user gesture (click/touch) in crafty/main.ts:189 to comply with browser autoplay policies. Sound buffers are loaded asynchronously and cached in AudioManager._digBuffers, _stepBuffers, and _fallBuffers maps.

§ 2.7

Illuminated Blocks and Point Lights#

Certain block types emit light, adding dynamic illumination to the world. Light-emitting blocks fall into two categories: blocks whose glow is baked into the G-Buffer emission channel, and blocks that create runtime PointLight components.

Emissive Blocks (G-Buffer)#

Blocks with emitsLight = 1 in blockMaterialData write their emission color into the G-Buffer's normal_emission target (mer.g channel), which is accumulated during the deferred lighting pass. These blocks glow without creating a dynamic light source — they illuminate only themselves, not the surrounding geometry:

BlockColorIntensity
GlowstoneWarm yellow24.8
ObsidianPurple12.0

Point Light Blocks#

Torches and magma blocks create full PointLight components at runtime, producing dynamic lighting that affects nearby geometry through the deferred lighting pass. The PointLight data (position, color, intensity, radius) is uploaded to a GPU storage buffer each frame and evaluated in the PBR lighting loop.

Light parameters (blockLightData in block_type.ts):

BlockColorIntensityRadius
TorchWarm orange (0.95, 0.56, 0.01)4.03.22
MagmaHot red (0.95, 0.06, 0.12)32.08.72

Runtime light management (lights.ts):

export function addTorchLight(bx: number, by: number, bz: number, scene: Scene): void {
  const go = new GameObject({ name: 'TorchLight' });
  go.setPosition(bx + 0.5, by + 0.9, bz + 0.5);
  const pl = go.addComponent(new PointLight());
  pl.color = new Vec3(1.0, 0.52, 0.18);
  pl.intensity = 4.0;
  pl.radius = 6.0;
  scene.add(go);
}

Torch lights are positioned at y + 0.9 (near the top of the block) and offset from each torch's base position by a unique phase value. Magma lights sit at y + 0.5 (block center).

Lights are created and destroyed on block interaction:

EventAction
Player places torchaddTorchLight() called from block_interaction.ts:143
Player breaks torchremoveTorchLight() called from block_interaction.ts:81
Multiplayer remote editSame add/remove triggered from network handler
Chunk loadAll torch/magma blocks in the chunk spawn lights via world.onChunkAdded

Flicker Animation#

Both light types animate via sinusoidal flicker, updated each frame in updateTorchFlicker and updateMagmaFlicker (lights.ts):

export function updateTorchFlicker(t: number): void {
  for (const { pl, phase } of torchLights.values()) {
    const flicker = 1.0
      + 0.08 * Math.sin(t * 11.7 + phase)
      + 0.05 * Math.sin(t *  7.3 + phase * 1.7)
      + 0.03 * Math.sin(t * 23.1 + phase * 0.5);
    pl.intensity = 4.0 * flicker;
  }
}

Three summed sine waves with different frequencies produce a natural, non-repetitive flicker. Torch flicker has a small amplitude (±16%) for a steady candle-like glow. Magma flicker uses slower, deeper modulation (±34%) to simulate the pulsing of molten rock.

GPU Light Loop#

Point lights are evaluated in the deferred shading fragment shader (point_spot_lighting.wgsl). Each pixel loops over all active point lights, culling those outside their radius:

for (var i = 0u; i < lightCounts.numPoint; i++) {
  let pl   = pointLights[i];
  let diff = pl.position - world_pos;
  let dist = length(diff);
  if (dist >= pl.radius) { continue; }
  let L     = diff / dist;
  let NdotL = max(dot(N, L), 0.0);
  if (NdotL <= 0.0) { continue; }
  let att  = point_attenuation(dist, pl.radius);
  accum += brdf * pl.color * pl.intensity * NdotL * att;
}

The point_attenuation function applies a smooth falloff so light fades to zero at the radius boundary, avoiding hard cut-off lines. The per-frame loop in main.ts:981 gathers all PointLight components from the scene and uploads them to the GPU via point_spot_light_pass.ts.

§ 2.8

Erosion Simulation#

Crafty includes an optional erosion simulation for more realistic terrain. A compute shader simulates water flow and sediment transport:

  1. Water deposition. Rain adds water to heightfield cells.
  2. Flow. Water moves downhill, carrying sediment.
  3. Erosion and deposition. Fast-moving water erodes the terrain; slow-moving water deposits sediment.

The simulation runs as a background compute pass and updates the terrain height map, which is sampled during chunk generation.

§ 2.9

Water Propagation#

When water blocks are placed or generated in the world, they spread according to a simple cellular automaton run on the CPU each tick. The algorithm lives in World._tickWater() in src/block/world.ts.

Water propagation: flow priority rules
Water propagation: flow priority rules

Flow Rules#

Water spreads in a fixed priority order:

  1. Flow down first. Water always attempts to flow downward first before spreading horizontally. If the block below is air or a prop, the water block moves down and the original position becomes air.

  2. Support check. Horizontal spreading only occurs when the water has "support" — defined as a solid block within 4 blocks vertically below it. Without support, water behaves as "fountain" water and can still spread 1 block horizontally.

  3. Horizontal spread. If supported, water spreads to adjacent empty cells in the four horizontal directions (N, S, E, W). Only one direction is filled per tick to prevent instant flooding.

// In src/block/world.ts
private _flowWater(wx: number, wy: number, wz: number): void {
  const below = this.getBlockType(wx, wy - 1, wz);
  if (below === BlockType.NONE || isBlockProp(below)) {
    this.setBlockType(wx, wy - 1, wz, BlockType.WATER);
    this.setBlockType(wx, wy, wz, BlockType.NONE);
    return;
  }
  // ... then horizontal spreading logic
}

Performance Optimizations#

§ 2.10

Water Rendering#

Water is a transparent block type rendered through the WaterPass (src/renderer/render_graph/passes/water_pass.ts), a forward pass that runs after deferred lighting. It composites over the HDR buffer using src-alpha blending, combining screen-space refraction, depth-based murkiness, and screen-space reflections.

Water rendering: screen-space refraction with DUDV distortion, and depth-based attenuation
Water rendering: screen-space refraction with DUDV distortion, and depth-based attenuation

Screen-Space Refraction#

Before rendering water, the current HDR scene is copied to a refractionTex. During water shading, the scene behind the water is sampled with UV distortion driven by an animated DUDV normal map:

Screen-space refraction: 3-step pipeline (render → copy → distort) and UV distortion visual
Screen-space refraction: 3-step pipeline (render → copy → distort) and UV distortion visual
// Animated DUDV distortion — two-pass stacked sampling for complex ripples
let base_uv = vec2<f32>(world_pos.x, world_pos.z) * (1.0 / 8.0);
let d1 = textureSample(dudv_tex, samp, vec2<f32>(base_uv.x + water.time * 0.02, base_uv.y)).rg;
let d2_uv = d1 + vec2<f32>(d1.x, d1.y + water.time * 0.02);
let distortion = (textureSample(dudv_tex, samp, d2_uv).rg * 2.0 - 1.0) * 0.02;

let ref_uv = clamp(screen_uv + distortion, vec2<f32>(0.001), vec2<f32>(0.999));
let refraction = textureSample(refraction_tex, samp, ref_uv).rgb;

The DUDV map is sampled twice with different time offsets to create a more complex wave pattern than a single scroll.

Depth-Based Attenuation#

Water opacity and tint are determined by the water depth — the distance from the water surface to the solid geometry below, computed by linearizing the G-Buffer depth and comparing it to the water fragment's depth:

let water_depth = floor_lin - water_lin;
const MURKY_DEPTH: f32 = 4.0;
let murk_factor = clamp(water_depth / MURKY_DEPTH, 0.0, 1.0);
let inv_depth = clamp(1.0 - murk_factor, 0.1, 0.99);
let water_color = textureSample(gradient_tex, samp, vec2<f32>(inv_depth, 0.5)).rgb;
let tinted = mix(refraction, water_color, murk_factor);

A gradient texture (gradient_tex) encodes the water color progression: shallow water is nearly transparent (showing the refracted background), while deep water transitions to a murky blue-green tint. Depth also controls alpha: shallow edges fade to transparent, while deep water becomes opaque.

Screen-Space Reflection + Sky Fallback#

Reflections use a hybrid approach:

  1. Screen-space reflection (SSR) ray-marches the reflected view direction in view space, sampling the refraction texture (pre-water HDR scene).
  2. HDR sky panorama is used as a fallback for rays that miss scene geometry or leave the screen bounds.
§ 2.11

Screen-Space Reflections (SSR)#

Screen-space reflection: view-space ray march, depth hit test, and equirectangular sky fallback
Screen-space reflection: view-space ray march, depth hit test, and equirectangular sky fallback

The SSR implementation in water.wgsl uses a view-space ray march with 32 steps:

fn ssr(world_pos: vec3<f32>, normal: vec3<f32>, view_dir: vec3<f32>) -> vec4<f32> {
  let reflect_dir = reflect(-view_dir, normal);
  let ray_vs = normalize((cam.view * vec4<f32>(reflect_dir, 0.0)).xyz);
  let origin_vs = (cam.view * vec4<f32>(world_pos, 1.0)).xyz;

  if (ray_vs.z >= -0.001) { return vec4<f32>(0.0); }  // only trace rays away from camera

  let NUM_STEPS: u32 = 32u;
  let MAX_DIST : f32 = 50.0;
  let THICKNESS: f32 = 1.5;

  for (var s = 0u; s < NUM_STEPS; s++) {
    let t = (f32(s) + 1.0) * MAX_DIST / f32(NUM_STEPS);
    let p = origin_vs + ray_vs * t;
    // project to UV, compare against stored G-Buffer depth...
  }
}

Algorithm#

  1. Transform to view space. Both the reflection origin (water surface point) and reflected direction are transformed into view space.
  2. Ray march. For each of 32 steps along the ray (up to 50 world units), the ray point is projected to screen UV.
  3. Depth test. The stored G-Buffer depth is linearized and compared against the ray point's view-space Z. If they differ by less than THICKNESS (1.5 units), it's a hit.
  4. Sky fallback. On miss, the equirectangular HDR sky panorama — baked from the atmosphere model and rebaked as the sun moves (Taos Engine §10.5) — is sampled using the reflection direction:
fn sky_uv(d: vec3<f32>) -> vec2<f32> {
  let u = 0.5 + atan2(d.z, d.x) / (2.0 * PI);
  let v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / PI;
  return vec2<f32>(u, v);
}

Confidence Blending#

SSR hits are not binary. The function returns vec4(color, confidence) where confidence fades:

Fresnel Blend#

The final reflection contribution is controlled by Schlick Fresnel with water's F₀ ≈ 0.02:

let VdotN = clamp(dot(view_dir, normal), 0.0, 1.0);
let fresnel_r = min(0.02 + 0.98 * pow(1.0 - VdotN, 5.0), 0.6);  // capped at 0.6
let world_color = mix(tinted, reflection, fresnel_r);
Fresnel blend: viewing angle effect (looking down vs grazing) and Schlick formula with 0.6 cap
Fresnel blend: viewing angle effect (looking down vs grazing) and Schlick formula with 0.6 cap

Reflection is minimal when looking straight down (high V·N), rising towards grazing angles. The 0.6 cap prevents bright HDR sky values from washing out the water at shallow viewing angles.

§ 2.12

Village Generation#

Villages are generated procedurally when chunks load, in crafty/game/village_gen.ts. The system hooks into the chunk load event and places clusters of houses under the right conditions.

Village generation: site selection and house placement
Village generation: site selection and house placement

Site Selection#

Villages only spawn in the GrassyPlains biome. When a chunk loads, the generator checks:

  1. Water check. No water blocks under the candidate chunk (sampled at 9 grid points).
  2. Flatness check. Terrain height varies by ≤ 1.5 blocks across the chunk.
  3. Probability roll. 25% chance if all other conditions pass.
const VILLAGE_CHANCE = 0.25;

function _isFlatEnough(world: World, baseX: number, baseZ: number, refY: number): boolean {
  for (let dx = 0; dx < CHUNK_SIZE; dx += 4) {
    for (let dz = 0; dz < CHUNK_SIZE; dz += 4) {
      const y = world.getTopBlockY(baseX + dx, baseZ + dz, 200);
      if (y <= 0 || Math.abs(y - refY) > 1.5) return false;
    }
  }
  return true;
}

House Placement#

When a village is selected, 2–4 houses are placed in a cluster around the chunk center. Each candidate position uses polar coordinates (random angle + random distance 4–10 blocks from center) and must pass three checks:

  1. Overlap avoidance. House footprints are 7×5 blocks. Before placing, the new house is tested against all already-placed houses using an AABB collision check with 1-block padding. If it overlaps, it is skipped.
  2. Footprint flatness. All 35 columns of the 7×5 area must have ground height within ±1.5 blocks of the village reference height. This prevents houses from overhanging terrain edges.
  3. No water. Scans a 5×5 grid of sample points one block below the house for water blocks.
const placed: { x: number; z: number }[] = [];
for (const p of placed) {
  if (hx < p.x + 8 && hx + 7 > p.x && hz < p.z + 6 && hz + 5 > p.z) {
    // overlap — skip
  }
}

function _isHouseFootprintFlat(world: World, hx: number, hz: number, refY: number,
    tolerance: number): boolean {
  for (let dx = 0; dx < 7; dx++) {
    for (let dz = 0; dz < 5; dz++) {
      const y = world.getTopBlockY(hx + dx, hz + dz, 200);
      if (y <= 0 || Math.abs(y - refY) > tolerance) return false;
    }
  }
  return true;
}

House Template#

Houses are simple 7×5×4 structures defined as layered arrays:

LayerPurpose
y=0Solid plank floor
y=1–2Walls with door opening (front) and glass window (back)
y=3Flat plank roof
const _WALL_L1: number[][] = [
  [1,1,1,2,1,1,1],  // z=0: back wall, glass at center x=3
  [1,0,0,0,0,0,1],
  [1,0,0,0,0,0,1],
  [1,0,0,0,0,0,1],
  [1,1,1,0,1,1,1],  // z=4: front wall, door opening at x=3
];

Currently, all houses use SPRUCE_PLANKS for structure and GLASS for windows.

§ 2.13

Block Textures and the Atlas Pipeline#

Every block face Crafty draws, and every entry in the BlockType enum from §2.1, traces back to a texture pack run through three Node scripts in scripts/. Their outputs — the atlas PNGs, the JSON manifests under assets/generated_atlas/, and src/block/block_type_data.ts — are committed, so the scripts only need re-running when the texture set or the block definitions change.

Data-flow diagram of the block-texture pipeline: the Vanilla-RTX texture pack feeds build_atlas.js, which writes four PNG atlases plus atlas_data.json and blocks.json; group_blocks.js folds tiles into blocks.json using block_overrides.json; codegen_blocks.js merges blocks.json with block_metadata.json to emit block_type_data.ts and patch net_protocol.ts.

Texture Atlas Building#

Crafty's block textures come from the Vanilla-RTX texture pack — a PBR pack that ships an albedo, normal, and MER (metallic / emission / roughness) map for every block. scripts/build_atlas.js packs it into the texture atlases the chunk renderer samples.

Run with no arguments, it sparse-clones the Vanilla-RTX-Normals variant from GitHub into scripts/.cache/ (a --depth 1 clone plus git sparse-checkout, so only the one variant is fetched, not all three) and uses that. Pass a path to use a local texture pack instead:

Shell
npm run build-atlas                          # download + use Vanilla-RTX-Normals
npm run build-atlas path/to/texture/pack     # use a local pack

The texture list comes from the pack's textures/textures_list.json when present (the legacy layout); otherwise the script scans textures/blocks/ for base .tga files, skipping the _normal, _mer, and _heightmap siblings. It requires the sharp image library (npm install sharp) and parses the uncompressed TGAs itself. For each block texture it loads the albedo, _mer, and _normal tiles (falling back to _heightmap when there is no normal map), resizes each to a 16×16 tile with nearest-neighbor scaling, and packs them into a 25-column grid. Missing inputs get defaults: magenta albedo, a flat (128, 128, 255) normal, and a MER of no metal, no emission, mid roughness. It writes six files into assets/generated_atlas/:

FileContents
color_atlas.pngPacked albedo tiles
normal_atlas.pngPacked tangent-space normal maps
mer_atlas.pngPacked metallic / emission / roughness maps
heightmap_atlas.pngPacked heightmaps (mid-gray where the pack ships none)
atlas_data.jsonFlat per-tile listing: texture name → (x, y) grid cell
blocks.jsonPer-block face manifest (see below)

The game loads only the color, normal, and MER atlases (imported with ?url in crafty/main.ts), so the heightmap atlas is a by-product and is not committed. With the current pack the atlas is 400×1104 pixels: 25 × 69 tiles (43 rows of static tiles, then the appended animation-frame rows).

Grouping Tiles Into Blocks#

A texture pack is a flat list of tiles — cactus_top, cactus_side, cactus_bottom, dirt, … — and scripts/group_blocks.js folds those into per-block entries. A tile whose name ends in a recognized face suffix (_top, _bottom, _side, _front, _back, _north, _south, _east, _west) is merged into a block keyed by the stem, whether or not the stem exists as a tile of its own. A tile with no suffix becomes that block's all face — and, when it has suffixed siblings (log_spruce next to log_spruce_top), its side face as well. The result, sorted by name, is blocks.json:

JSON
{
  "atlasWidth": 400, "atlasHeight": 1104, "tileSize": 16,
  "blocks": {
    "acacia_shelf":   { "all":  { "x": 0, "y": 0 } },
    "ancient_debris": { "side": { "x": 4, "y": 0 }, "top": { "x": 5, "y": 0 } }
  }
}

group_blocks.js runs automatically at the end of build_atlas.js, but is also runnable standalone (node scripts/group_blocks.js) to regenerate blocks.json from an existing atlas_data.json without re-packing the PNGs. Tiles the suffix heuristic mis-groups can be corrected in scripts/block_overrides.json, whose blocks map of per-face tile coordinates is merged on top of the auto-grouped result (it is currently empty). At runtime, src/block/block_manifest.ts imports blocks.json directly for tile lookups and the atlas dimensions.

Generating Block Types#

scripts/codegen_blocks.js turns the manifest into TypeScript. It merges blocks.json with scripts/block_metadata.json and emits src/block/block_type_data.ts: the BlockType enum, the parallel data tables listed in §2.1, and BLOCK_TYPE_COUNT. The metadata file has several parts:

Every remaining manifest block is appended alphabetically, followed by a MAX sentinel. Its material is guessed from the texture name, since the Bedrock-named pack does not line up with any block-model data the codegen could read instead:

// Returns { material, collidable, hardness } guessed from the manifest name.
function classifyBlock(name) {
  if (matchesAny(name, PROP_PATTERNS) && !matchesAny(name, PROP_EXCLUDE)) {
    return { material: 'PROP', collidable: false, hardness: 0 };
  }
  if (matchesAny(name, TRANSPARENT_PATTERNS) && !matchesAny(name, TRANSPARENT_EXCLUDE)) {
    return { material: 'SEMI_TRANSPARENT', collidable: true };
  }
  return { material: 'OPAQUE', collidable: true };
}

PROP_PATTERNS catches flowers, saplings, grasses, mushrooms, torches, crop stages and the like; TRANSPARENT_PATTERNS catches glass, leaves, ice, slime, and honey (with exclusions such as the _opaque leaf variants and honeycomb). A propPatterns match then forces PROP, and a per-block overrides entry wins last. Because a manifest block may declare only some faces (a door with just top/bottom, a crafter with only compass faces), the chunk shader's side / bottom / top triple is resolved through fallback chains:

const FALLBACKS = {
  top:    ['top',    'all', 'side', 'north', 'south', 'east', 'west', 'front', 'back', 'bottom'],
  bottom: ['bottom', 'all', 'side', 'top',   'north', 'south', 'east', 'west',  'front', 'back'],
  side:   ['side',   'all', 'north', 'south', 'east', 'west', 'front', 'back', 'top',   'bottom'],
};

The codegen fails loudly rather than emitting a bad table: an unknown manifest name, a block with no usable tile, an unknown tint key, or two names that collide after conversion to SCREAMING_SNAKE all throw. Its last step patches BLOCK_TYPE_MAX in shared/net_protocol.ts to match the block count (currently 879), so the multiplayer server accepts edits for newly added block types.

Shell
node scripts/codegen_blocks.js   # after editing block_metadata.json or rebuilding the atlas

The full pipeline, then, is: texture pack → build_atlas.js → atlases + atlas_data.json → group_blocks.js (+ block_overrides.json) → blocks.json → codegen_blocks.js (+ block_metadata.json) → block_type_data.ts.

Two related tools repaint the atlas without going through the Vanilla-RTX pack. scripts/build_atlas_from_pack.js builds a fresh atlas from a Java-layout resource pack (PNG tiles, first frame of animated strips, flat normals and default MER) into a separate assets/generated_atlas_<name>/ folder, reusing groupBlocks(). tools/build_skin_atlas.mjs instead keeps Crafty's exact tile grid and repaints matching tiles from a resource pack into assets/generated_atlas_<name>_skin/, so the result drops in as an alternate world texture with no change to the block table, mesher, or UVs.

2.14 Summary#

The voxel terrain system features:

Further reading: