Hyde Games

REFERENCE · BUILD 42.20 · UNOFFICIAL · UPDATED 2026-09-24

B42 Engine Notes

What the Build 42 engine actually does, written down while modding because none of it seemed to be published anywhere. It started as map-format notes and now covers the engine surfaces a mod runs into: lots, tiles, world objects, the Lua dialect, multiplayer authority, item and recipe scripts, animation, foraging and fishing. Each note says where it came from — the game's own Lua and data files, behaviour read from the shipped engine, or what happened in game. Nothing here is official, no game code is reproduced, and all of it will go stale.

Maps and lots

map.info

ChooseGameInfo.getMapDetails reads the file with BufferedReader.readLine(), trims each line, and prefix-matches seven keys: title=, lots=, description=, fixed2x=, zoomX= / zoomY= / zoomS=, demoVideo=, and only_for_game_mode=.

A malformed zoom value stops the map listing; an absent one is harmless. Only IOException is caught around the parse loop, so a Float.parseFloat failure propagates out of getMapDetails and the map never appears. A missing key just leaves the field at Java's 0.0f, and MapSpawnSelect.lua:727 explicitly guards not (zoomX == 0 and zoomY == 0 and zoomS == 0).

Line endings do not matter here. Both readers of the file use readLine(), which terminates on \n, \r or \r\n, and then trim. Vanilla's copies are CRLF; that is convention, not a requirement.

Map registration

MapGroups.createGroups enumerates a mod's maps with a DirectoryStream filtered on Files.exists(path.resolve("map.info")), and the whole per-mod body is gated on <mod>/common/media/maps/ existing. Ship the map only under 42/media/maps/ and it is skipped without a word. Once common/ exists the version directory is scanned as well — mount in exactly one place, or the cells register twice.

There is a positive signal worth knowing: doMapZones (metazoneHandler.lua:82) warns can't find map objects file: media/maps/<Map>/objects.lua for every registered lot directory that lacks one. Ship no objects.lua and that warning becomes proof your map registered.

Cell size — both numbers are live

A B42 cell is 256×256 tiles; Muldraugh's own map.info description says so. But worldmap.xml's spatial index is still on 300-tile cells, and forest.pyramid.zip is still on the 300 grid while pyramid.zip is on 256. Both numbers are live in 42.20, in different files. Never carry one into the other.

Negative levels cannot be authored in TMX

MapLevel::levelForLayer splits the layer name on _ and parses the prefix with toUInt. A layer named -1_Floor fails the conversion and is treated as having no level prefix at all. This holds in TileZed and in the maintained fork. Below-ground content reaches the world through the basement system instead (next section), authored at level 0 and placed at a negative world level.

.lotheader and .lotpack

Header: LOTH, version, tile-name count, the newline-separated palette, then chunkW, chunkH, minLevel, maxLevel, the room table (each room carrying a name and a plain signed level), the building table, and a 1024-byte per-cell zombie-intensity tail.

Pack: LOTP, version, chunk count 1024 (32×32 chunks of 8×8), an int64 offset table, then the per-square streams. A square is int count; count == -1 means the next int is a run of empty squares to skip, otherwise the next int is the room index and count - 1 palette indices follow. count includes the room slot — that detail is what makes the stream parse.

Both levels of chunk indexing are column-major.

x = (chunkIndex // 32) * 8 + (squareIndex % 64) // 8
y = (chunkIndex  % 32) * 8 + (squareIndex % 64)  % 8
level = minLevel + squareIndex // 64

A byte-identical round-trip cannot detect a transpose here, because re-encoding a stream in its own order matches whatever order you read it in. Test against known world coordinates instead — pick a sprite you can locate on the real map and check it lands where it should.

A chunk stream has no length field and no terminator. Every chunk must account for exactly (maxLevel - minLevel + 1) × 64 squares, including the trailing run of empties. Come up short and the reader runs straight on into the next chunk's bytes, putting every square after that point at the wrong index and the wrong Z — with no error, because nothing is counting. Vanilla is exact in all 70,656 of its chunks.

A .lotpack may not have absent chunks either. IsoLot.load seeks a chunk's recorded offset with no zero guard, so a chunk whose offset table entry is 0 reads the file magic as a square count and hits end-of-file — that chunk renders as void. Write an all-skip run for every empty chunk; vanilla leaves no zero offsets. And once a save has touched a corrupt chunk it keeps it: a saved chunk overrides the map file for ever.

The B41 forms of both files carry no magic at all. A B41 lotheader opens with int32 version = 0 and the palette, a B41 lotpack straight into the chunk count, cells are 300 tiles of 900 chunks (30×30 of 10), and the art is 1x. Everything above is the B42 shape.

Overriding vanilla cells

Squares and rooms resolve by different rules, and the two pull against each other.

A square stores the index of its room in the header, so the squares cannot be carried without the room table that gives those indices meaning. For adding content inside a vanilla cell, the basement lot route below is the one the engine supports; overriding whole cells is for maps that own their ground.

The in-game map

worldmap.xml is legacy. WorldMapDataAssetManager checks Files.exists(path + ".bin") and loads worldmap.xml.bin whenever it exists; WorldMapBinary rejects “version 1 (cell size 300)”. The lua still gates on fileExists(directory .. '/worldmap.xml') before the asset manager gets a chance, so a stub XML has to be present as a trigger even though nothing reads it.

The binary layout, little-endian throughout:

"IGMB"
int32  version   (2)
int32  cellSize  (256)
int32  width, int32 height          -- the world in cells
int32  stringCount
  stringCount x ( int16 byteLen, byteLen bytes of utf-8 )
width*height cells, ROW MAJOR, each either
  int32 -1                          -- no features here
or
  int32 cellX, int32 cellY, int32 featureCount
  featureCount x feature:
    int16  typeStringIndex
    uint8  ringCount
      ringCount x ( int16 pointCount, pointCount x (int16 x, int16 y) )
    uint8  propertyCount
      propertyCount x ( int16 keyIndex, int16 valueIndex )

The ring and property counts are written as qint8 but must be read UNSIGNED. The forest layer contains a polygon with 140 rings, which reads as −116 if you take it signed and sends the parser off into the weeds. worldmap.xml.bin round-trips either way because nothing in it exceeds 127 — exactly the accident that lets a wrong reader look proven.

There is no offset table, so a cell has to be parsed to find where the next one starts. streets.xml and worldmap-annotations.lua are added with no per-directory close, so they are additive and need no copy.

The image pyramid is a separate thing. pyramid.zip is the ground image, not the vector data. Its pyramid.txt gives bounds 0 0 19968 16128 against the same imageSize — 78×63 cells at 256, one pixel per world tile, no margin — and level 0 holds exactly one 256px tile per cell, named 0/tile{cellX}x{cellY}.png. Levels 1–4 halve each time. worldmap.png is a different image again: the in-fiction tourist map at 2 tiles per pixel with a 250-tile margin, so px = (tile + 250) / 2. spawnSelectImagePyramid.zip is a third, at yet another scale.

The spawn-select screen is a real vector map on self.javaObject:getAPIv3(), not a static image blit, so an overlay drawn on it must anchor its corners each frame through mapAPI:worldToUIX/worldToUIY (vanilla's zone editor is the worked example); a naive full-panel blit cannot track pan or zoom. Spawn locations are separate: SpawnRegionMgr.loadSpawnRegionsFile reads media/maps/<Map>/spawnregions.lua, falling through to spawnpoints.lua.

The 300-grid override mask

This is the one worth knowing if you ship cells that are not contiguous. MapFiles.postLoad builds a per-directory bgHasCell300, marking a 300-cell as covered when:

c256x   = floor(c300x * 300 / 256)
c256y   = floor(c300y * 300 / 256)
covered = hasCell(c256x, c256y) && hasCell(c256x + 1, c256y + 1)

Only the 300-cell's top-left and bottom-right 256-cells are tested — never the other two. WorldMapGeometry then loops every higher-priority map directory and clips a 300×300 axis-aligned box out of the lower-priority cell's polygons for each covered 300-cell. For a mod replacing a contiguous region this is exact; own two cells diagonally and it blanks all four, including the two you do not own. It is driven by your .lotheader files, not your map data, so the only fix is to supply map data for the spill as well. Not involved, both checked: WorldMapRenderer concatenates features from every data source for a cell and never consults isLastDataInDirectory; WorldMap.addImages is purely additive.

Room loot and room names

Basement lots — adding below-ground content to a vanilla cell

A basement is a .pzby lot the engine stamps INTO a chunk vanilla owns, on that chunk's first generation, merging its rooms and one building into the meta grid and recalculating ids. It is the one engine path that adds z<0 content to a cell without a second lotheader, and it is what the cutaway, the room system and the map all understand.

Rendering below ground

Below ground the engine draws exactly one level: the one the player is standing on. IsoCell.recalculateAnyGridStacks fills the per-level render lists from the chunk map's min and max height, then, whenever player.getZ() < 0, clamps BOTH ends to fastfloor(playerZ). A square on another negative level is not unlit and not dark: it is not in the render list at all, so no light, lamp, ambient lift or redraw can reach it. On the surface the clamp is skipped and the chunk's whole range draws, which is why an open shaft shows its depth from the street and shows nothing from one level down. Anything meant to be seen from a given standing level must be ON that level, with depth painted inside the tile rather than built out of levels.

Tiles and tilesets

Shipping a tileset

The property vocabulary — ask the definitions what a thing is

The tools' sheets

World objects from Lua

Creating geometry at runtime

Telling clients about an object

Light, climate, power, fire

Ready-made frameworks

Lua and Kahlua

The dialect

Wrapping vanilla

Keybinds

UI and rendering

Multiplayer and authority

Items and recipes

Item scripts

Recipes

Traits, professions, skills

Characters, clothing, animals

Zombies and outfits

Animals and animation

Breathing gear and the body

Foraging, fishing, farming, water

Foraging

Fishing

Feeding troughs, rain, plants

Odds and ends

Where this came from. The IGMB layout and the toUInt level-prefix behaviour were read from the map tools' own published source — ingamemapwriterbinary.cpp and MapLevel::levelForLayer in the TileZed / WorldEd repositories. Cell sizes, the pyramid registration, the vanilla map.info examples, the chunk counts, the tile property censuses, the basement lot census and the animation numbers were measured from the game's own data files. Engine behaviour is described from reading the shipped code and from what happened in game while building mods on it; no game code is reproduced here, method and class names are given so a reader can look for themselves, and nothing on this page is official. It is notes, for Build 42.20, last brought up to date on 2026-09-24, and it will go stale. Corrections are welcome at the address below.