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=.
only_for_game_mode=is rarely mentioned. It resolves throughRegistries.GAME_MODE; vanilla's March Ridge carriesonly_for_game_mode=Sandbox.lots=accumulates into a list — it is repeatable. Every vanilla sub-map sayslots=Muldraugh, KY, which adds its directory to that map GROUP; it is not an override declaration.description=is repeatable and concatenates with no newline inserted.title=defaults to the folder name. Vanilla's sub-maps put a literal pointer string there —title=See media/lua/shared/Translate/EN/Rosewood, KY/title.txt— and take the real name from that file.- Every value is extracted with
String.replace(key, ""), not a prefix strip, so a value containing its own key text again is mangled.
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.
.lotpacksquares are winner-takes-all. A cell shipped without vanilla's squares removes them from the world — observed in game as a hole through the surface that the player falls into..lotheaderrooms load from both files. Carrying vanilla's room table forward re-registers every one of its rooms and logsduplicate RoomDef.metaID, once per cell.- Building ids use the per-FILE index. Room ids are assigned from the
merged per-cell list, but a cell loaded twice numbers its buildings from 0 in each
file, so vanilla's copy of an overridden cell duplicates the override's building
ids; the live
IsoBuildingcache is keyed by that id, rooms of different buildings weld together, and the roof cutaway stops matching the player's building — county-wide, while the mod is enabled. Two lotheaders per cell is a configuration the engine does not support, and no header content can make it clean.
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
- Room loot keys off
room.getName().Distributions.luaholds 462 room keys and they are TAB-indented; a regex expecting four spaces finds 53 of them and misses the rest. - A mod's own room names are a first-class surface: a file in
media/lua/server/Items/that doestable.insert(Distributions, myTable)is merged into the liveSuburbsDistributionsbymergeDistributions(), which takesDistributions[1]as vanilla and recursively merges every later table in. Entries areroomname = { containerkind = { procedural = true, procList = { {name = <ProceduralDistribution>, min, max} } } }; prefix your room names. - A room named after a vanilla room is stocked as that room — a sewer chamber
called
toolstorefills with retail tool-shop stock. Keep container KIND names vanilla (crate,locker,counter,toolcabinet,metal_shelves...): a kind the room entry misses falls through to that container'salldistribution rather than to nothing. An inventedProceduralDistributionname is a silent nothing. - Only buildings inside
TownZone,TrailerParkorFarmrespawn loot. - Indoor zombie spawning is room-based (meta intensity × room area ×
building), and it runs for any real room def — including a mod's rooms below
ground. It fires from
IsoChunk.randomizeBuildingsEtcas the chunk loads, only while a player is within ONE level of the room;addSpawnedRoom(roomId)is public and marks a room already spawned.
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.
- Registration is a mod's own
media/maps/<Map>/basements.lua(the cell's lotheader directory must match), callingBasements.getAPIv1():addBasementDefinitions / addAccessDefinitions / addSpawnLocationswith plain tables of{ width, height, stairx, stairy, stairDir };loadMapBasementLuaFiles()walks EVERY lot directory onOnLoadMapZones, so a mod map directory is picked up automatically.metazoneHandler.luaaddsregisterBasementSpawnLocationfor zone-declared spots. - A spawn location with
choicesnames its layout; the origin is the spawn point minus the definition'sstairx/stairy;addSpawnLocationstakesx,y, optionalz,stairDir,choicesandaccessand sets w = h = 1 with no zone test;canPlaceAtcomputesbasementZ = spawn.z - header.levels, so a spawn at z = 1 puts the lot's TOP level on the street. - Lots are authored bottom-up (level 0 = deepest), a lot holds exactly ONE building, and its room rects define the stamped extent plus one square of shell. The building box is the UNION OF THE ROOM RECTS, which is the floor — not what the lot stamps: every wall and every doorway neck lies one square outside it. Anything a mod derives from “is this square in my lot” must use the stamped extent, and keep the room-union box for the engine's own overlap test.
- Placements are computed only on a fresh save (when
map_basements.binis absent) and stamped only into chunks with no save file, so a visited area in an old save never gets the lot. State saves tomap_basements.bin.Basement.SpawnFrequencyis read by nothing. The lua globalNewMapBinaryFile("TEST")targets a pre-format prototype and is useless. - The overlap rule, exact:
Basement.checkOverlapreturns false if the two placements' z ranges (placeZ .. placeZ + building.getMaxLevel()) miss, false if their building boxes miss, then walks the shared levels comparing room rects — and after that loop returns true unconditionally. So a box-and-z overlap with no room touching is still refused, and whichever placement is computed second is refused silently. A town-sized lot at z-1 would refuse every vanilla basement inside the town; many small lots with pairwise-disjoint boxes, clear of every vanillaBasementzone, do not. - The stamp and the lotpack loader build a square the same way
(
NewMapBinaryFile.addToIsoChunkagainstIsoCell.PlaceLot): both resolve tile names throughIsoSpriteManager.namedMap(a miss logsMissing tile definitionand skips) and callCellLoader.DoTileObjectCreationper tile in stream order. The stamp clears the square first under the attribute bits (0 clear everything, 1/2/4 keep floors, walls, other); the lotpack path clears only when the first tile is asolidfloor. The stamp SKIPS every square the lot has no tiles on, so an empty square's attribute is never read; it runsremoveFloorsAboveStairson the z0 column above every lot square. A lot is stamped into a chunk whenBasements.chunkOverlaps(chunk, placement, w + 1, h + 1)— the box plus one — fromonNewChunkLoadedinsideIsoChunk.LoadBrandNew. Rooms merge into the meta cell atroomDef.level - header.levels.SpawnBasementInChunkprints only underDebugType.Basement. - Surface access lots (
media/basement_access/, 147 in vanilla) cut the floor under the entrance and lay the stair or rope head; named on the spawn location throughaccess = "...". A square with NO floor at z0 is what makes a rope climbable there:canClimbDownSheetRopeon a floored square returnsgetZ() > 0. - B42's random-basement route — a
Basementzone with StairX/StairY/ StairDirection/Access and a seeded pick — is the ready-made mechanism for random layouts;duplicate RoomDef.metaIDonce per launch at a vanillaBasementzone is vanilla colliding with itself, not the mod. - The red basement pulse on the in-game map is debug-only
(
Core.debug && renderer.getBoolean("Basements"), an option the player UI never toggles), but the data it draws is lua-readable from the first frame of a fresh save:getWorld():getMetaGrid():getBuildings(),BuildingDef.getMinLevel/getRooms,RoomDef.getName/getZ/getRects. The draw idiom is vanilla's own map lua:mapAPI:worldToUIX(x, y)andself:drawPolygon(...)for a filled quad, orself.mapUI.javaObject:DrawLine(Texture:getWhite(), ...)for an edge. - Vanilla's own below-ground content, measured: 72 cells carry negative levels (66 one
deep, 3 at two, 1 at four, 2 at seventeen); the deepest is a research facility under
Hog Wallow Forest whose levels -1 to -11 are nothing but two elevators and a hall and
whose bottom is the only
caveroom in the game. Its dug spaces use one vocabulary: the cobble wall quadwalls_logs_96/97/98/99on floor squares, the concrete quadwalls_garage_01_32..35on the non-floor side,boulders_*props, and heavyoverlay_grime_*.
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.
- The cutaway keeps two wall classes per chunk level
(
FBORenderCutaways). A wall adjacent to a ROOM — on either side, or a room below — is cut by the room rule: only the wall squares whose floor one to three tiles behind them is a seen square of the player's own room, a per-square mask. A wall adjacent to NO room is an “exterior wall” cut by the fence rule: the whole run is cut whenever the player stands within 6 tiles north of a north-facing run or west of a west-facing run (the mouse pointer counts too, at 3), whether or not it hides anything. The test is per FACE (the square and the one across the face), not per square: a room's east wall stands on the square OUTSIDE it by construction and is still adjacent to the room. Unroomed corridors therefore fence at every turn; the fix is real room defs, orsetRoomIDon the wall squares atLoadGridsquare(the chunk loader assigns ids from RoomDefs and never saves them).NeverCutawayexists on four sign tiles and is not a lever. - The engine has no depth floor.
IsoChunk.minLevelandmaxLevelare plain instance ints,isValidLevelismin <= z <= max, andsetMinMaxLevelreallocates around whatever square is created; vanilla spans -17 to 29, andIsoChunkLevelrecycles through a 4096-entry pool that is a cache, not a cap. Chunks below z0 have a per-chunkgetMinLevel(), and “above ground” in vanilla lua is writtengetZ() > getChunk():getMinLevel(). - The engine's void filler is not ground.
underground_01_0/1carrysolid,exterior,cutWand nosolidfloor: a block that stops movement and is not a floor. Nothing in the base game places it (0 of 356 basement lots, 0 of 4,065 cells, no lua caller ofIsoGridSquare.addUndergroundBlock(String)); the void round a basement lot is genuinely empty, which is what “I can see into the void past a wall” below ground always comes back to.addUndergroundBlock,isUndergroundBlock()andremoveUnderground()are public;removeUnderground()has no vanilla lua caller and its reach beyond its own square is unread. Vanilla refuses to sledge such a wall:ISDestroyCursor:canDestroyopens withisBasementWallAdjacentToTheVoid. - No
IsoRegionexists below z0, so a dug space gets no rain protection and no room status from that system; the floor above is the ceiling. In a vanilla basement 97% of squares have a floor directly above and there are zero ceiling tiles; the deep facility placesceilings_01only where the level above has no floor. A ceiling tile is a walkable plane seen from above. - A wall-mounted or column sprite with no depth data renders at whole-tile depth and draws over the player standing in front of it. See the depth notes under Tiles.
- Light. A point light is a source, and a lattice of them announces itself as the player walks (each one arriving and leaving); the way to make an unlit level visible without sources is the engine's own ambient, through the climate modded tier (below, under World objects). A level with NO source at all renders black even with the ambient lifted, so a small, constant-colour, sparse set of sources on the player's own level is the cheap floor.
Tiles and tilesets
Shipping a tileset
- Two files and two
mod.infolines:media/texturepacks/<name>.packdeclared aspack=<name>, andmedia/<name>.tilesdeclared astiledef=<name> <N>.Nmust be 100–8189 and unique across every enabled mod, orloadModTileDefslogs a collision and skips the tiledef entirely. File names are lowercased before lookup; sprite names match case-sensitively. A pack or tiledef themod.infodoes not declare is simply never loaded, and the symptom is missing art with no message. - Inside the
.tilesbinary, the per-sheet integer is that sheet's INDEX WITHIN THE FILE, 1..512 — not the file's tiledef id. Anything else throwsinvalid tileset number N, must be from 1 to 512fromIsoWorld.LoadTileDefinitions, and the throw unwinds out ofIsoWorld.initfrom both the tiledef pass and the property-string pass, so every mod tiledef later in the load order never loads either — a mod three slots down loses its properties with nothing in the log naming it. Vanilla numbers its sheets sequentially per file; the limits read from the engine are 512 tilesets per file and 512 tiles per tileset. - A
.patch.tilesfile ADDS properties to sprites already defined (IsoWorld.LoadTileDefinitionsreads the patch flag from the file name, looks each<sheet>_<index>up innamedMapand adds to it; a sprite that does not exist is skipped). Declared inmod.infoastiledef=<name>.patch <id>, loaded after vanilla's seven; vanilla's owntiledefinitions_noiseworks.patch.tilesis the precedent. Any parser of the tiledefs must MERGE a patch per tile rather than replace — the noiseworks patch re-lists nearly every floor with onlyFootstepMaterial, and a replacing parser reports 588 floors with nosolidfloor. - The
.txtform of a tile definition is a TileZed export artifact. The string “tiles.txt” does not appear anywhere in the game jar. - A sprite can exist as art and have no definition (all 64 sprites of
overlay_graffiti_wall_02, for one); the engine logsSEVERE: Missing tile definitionand the object cannot be described. A script-declared sprite must be defined; only a runtime-created one may be path-loaded (see World objects). - A pack sprite is trimmed to its ink and carries an offset into its own
128×256 canvas; draw it at that offset or everything floats. Vanilla's
Tiles2x.packandTiles2x.floor.packboth name their pagesTiles2x0, Tiles2x1..., so key pages by pack AND name. - Canvas size decides the scale fixer (
IsoSprite.performRenderFrame): a 64×128 original at tile scale 2 is force-doubled LINEARLY and comes out blurred; a 128×256 original draws 1:1 crisp. Pre-double 1x art with nearest-neighbour at build time. A pack whose recorded original-canvas fields disagree with its images gets doubled twice and renders huge. - Depth. Per-pixel depth comes from three per-mod files, all read from
common/media/ONLY:depthmaps/DEPTH_<sheet>.png(one 128×256 cell per slot on the sheet's own grid; alpha is the coverage mask and L is the depth — vanilla paints depth a few pixels past the silhouette and nothing beyond it),tileGeometry.txt(box/cylinder/polygon primitives rasterised into cells at load), andtileDepthTextureAssignments.txt(myTile = otherTile,borrows a cell outright; vanilla's ownconstructedobjects_01_30 = constructedobjects_01_28borrows a geometry-baked cell). Depth reaches REGISTERED sprites only —TileDepthTextureAssignmentManager.initSpritesskips any sprite with notilesetName— andIsoSpriteexposes no depth setter. A borrowed cell has holes the moment your ink grows past the donor's; a byte copy may borrow safely, a repaint must ship its own depthmap (start from the donor's cell and fill your extra ink from the nearest defined pixel). Walls take the presetspreset_depthmaps_01_4/5/6/7(W/N/NW/SE), exactly as vanilla assigns them towalls_logs_96..99. Floors need nothing. - The floor pass takes only
solidfloororRenderLayer = Floorsprites (IsoGridSquare.renderFloorInternal); everything else is an object.RenderLayer = Flooris what puts a non-floor mod sprite — a shore edge, a hole cover — on the floor pass (vanilla's 252 railroad tiles use it). Floor status itself comes from thesolidfloorproperty and nothing else:IsoWorldsetsspr.solidflooronly from that property,IsoGridSquare.RecalcPropertiesaggregates it, andgetHeightAboveFloorwalks down to the firstTreatAsSolidFloor()square (that flag, stairs or a slope) or the chunk's minLevel. A propertyless cover tile under a well is therefore safe:getFloor()readssolidflooronly. - A tile's art can encode a level relationship: a floor diamond sits at
the bottom of its 128×256 canvas, diamonds tessellate edge to edge, so a diamond
pushed DOWN is clipped and the surface notches; a surface sunk a few inches is placed a
LEVEL down and drawn back UP inside its tile by
96 - depth. Such a sprite is no longer interchangeable with the flat one it was painted from — laid anywhere else it floats by that many pixels. Build any face that fills the cut from the tile's OWN measured edge, never the ideal diamond (vanilla's water diamond rasterises one row low on its north edge and one row high on its west, and the bench tile is off the same way, so the two tessellate and the ideal is what is wrong). A floor raised inside its tile also breaks its junction with every unraised wall behind it.
The property vocabulary — ask the definitions what a thing is
DoorSoundis how the game separates openables: exactly fourteen families —GarageDoor(84 sprites),MetalGate(48),MetalPoleGate/ Double / Small,WoodGate/ Small,WoodLogGate,FarmGate,PrisonMetalDoor(4),MetalDoor,WoodDoor,WoodShackDoor,SlidingGlassDoor. There is nogarageorgateproperty anywhere.- Garage doors carry
GarageDoor = 1..6(1–3 the closed three-tile run, 4–6 its open twin) plusCutawayHintL/M/R; the engine groups a door itself —IsoDoor.getGarageDoorIndex(obj)(-1 for a non-piece),getGarageDoorFirst/Prev/Next,toggleGarageDoor,destroyGarageDoorare static and lua-reachable, and vanilla'sbuildUtil.getGarageDoorObjectsis fourteen lines walking Prev/Next.getDoubleDoorIndex/getDoubleDoorObject(obj, i)do the same for four-piece double doors;DoubleDoor1is followed byDoubleDoor2on the next index, and open runs carry no wall property so the k-th open run inherits the k-th closed run's facing. HoppableN/HoppableWis a barrier you VAULT (a railing is hoppable by definition; there is no non-hoppable railing in the game). A square owns only its north and west faces, so a barrier is vaultable from the north and the west and nowhere else; vaulting needs a square on the far side to exist, and if that square has no floor the fall resolves normally. The vanilla minimum is a see-through wall:HoppableN+WallNTrans+wall+IsLow; 142 hoppable tiles pair the face withcollideN/Winstead.wallis a classification, NOT the collision flag. Plain walls carryWallN/WallW, door framesDoorWallN/W, windowsWindowN/W— all three also carrywall, and a window is obviously climbable. A bareDoorWall*frame with noIsoDoorin it is passable (a zombie-broken door is one of the commonest states in the game).WallNTrans/WallWTransis a see-through wall;forceFadefades the piece when it would hide what is behind it.WallSEis the corner post that closes the diagonal where a north run and a west run pass each other. Vanilla places it where the tile WEST carries a north wall and the tile NORTH carries a west wall and the post square carries no WALL of its own — but usually a floor (450 of 633 posts over seven cells stand on floor). A rule that also demands the square be outside the space deletes the post at every inner corner.- 237 of the game's 240
WallNWcorner sprites carryCornerNorthWallandCornerWestWallnaming their two single-face halves, readable on the object withhasProperty/getPropertyas vanilla's destroy cursor does — so cutting one face of a corner needs no per-theme table. Do not copy vanilla'sISDestroyStuffAction:getCornerWallSprite: withDESTROY_NORTHit returnsCornerNorthWalland places it, i.e. puts the north face back. - Stairs are exactly six sprites in the whole game —
constructedobjects_01_88/89/90(stairsBW/MW/TW, west) and_96/97/98(stairsBN/MN/TN, north). A run is three tiles on ONE level; the player arrives on the level above, on the square past the top piece (hasFloorAtTopOfStairs). Any sprite becomes a real staircase on the object withobj:setType(IsoObjectType.stairsBN)(HasStairs()reads the object's type; the enum is a lua global) — on the OBJECT, never the shared sprite, and it is not saved. Vanilla's owntileGeometry.txtdescribes its wooden flights as boxes. - Ramps:
ramps_01inB42ChunkCaching2x.packwithtiledefinitions_b42chunkcaching.tiles— twenty segments per direction carryingSlopedSurfaceDirectionandSlopedSurfaceHeightMin/Maxin percent (5% a tile, so a level is twenty tiles,PhysicsMesh = ramp20segmentNx), walked smoothly bygetSlopedSurfaceHeight.floors_exterior_street_01_32/33/34are only the ART of a street ramp (solidflooralone). Whether a MOD tiledef'sSlopedSurface*is honoured is untested. - Ropes: the sheet-rope tiles carry real climb properties — tops
climbSheetTopW/N/E/S(crafted_01_0/1/2-3/4-5), bodiesclimbSheetW/N/E/S(_8/_9/_10/_13) — and the climb predicates read those square PROPERTIES, not the per-objectsheetRopebit, so a lua-laid rope is climbable in principle. Vanilla'sladderN/W/E/Stiles (carpentry_02_84..87,location_sewer_01_32/33/48/49) do nothing: there is no ladder climb in the engine. The engine parks a climber at a FIXED point per rope direction (setIdealDirection: N 0.54/0.39, S 0.118/0.5756, W 0.4/0.7, E 0.5417/0.3144 in square fractions, so +10 / -29 / -19 / +15 px on screen), which is why a rope's ink must sit on that column and why one climb clip serves all four facings. At the top,calculateClimbOutcomerecordsUndefinedwhen no window, frame or hoppable fence is ahead, andfinishClimbinghas no case for it — the character hangs until something else moves it. - Moveables:
IsMoveAblealone is the pickup gate;MoveTypedefaults to"Object"(ISMoveableSpriteProps.lua:136) and matters only forWallObject,WallOverlay,FloorRug,WindowObject.GroupNameis PREPENDED to the displayed name. What a pickup becomes is the tile'sCustomItem(read byMoveable.ReadFromWorldSprite); with none, a single-sprite tile yieldsMoveables.<spriteName>(a multi-sprite one the genericMoveables.Moveable), which no recipe can name. An item'sWorldObjectSpritedecides only what it LAYS and where loot places it — the two claims point opposite ways and a round trip needs both. WithCustomItemthe pickup weighs the ITEM'sWeight, notPickUpWeight/10; vanilla keeps them honest for barrels (200 / 20.0) and not for pallets.Icon = defaultis the convention on 337 of ~350 vanilla moveables. A moveable that also owns a FluidContainer keeps its fluid both ways (GameEntityFactory.TransferComponentson pickup and placement). - Containers:
container = <kind>+ContainerCapacityon a tile are read by the CHUNK LOADER, not byIsoObject.new; an object placed from lua needs vanilla's trough idiom (setContainer(ItemContainer.new())).crateandsmallboxare real distribution kinds although no tile carries them. A multi-tile container (vanilla's coffin, a two-square shelf) puts thecontainerproperty on ONE of its halves. - Multi-tile objects:
SpriteGridPosis relative to the min-x / min-y square, E/W-facing objects run along x and N/S along y — settled by the one object drawn in all four facings (the coffin,crafted_04_44..51); a survey countingFacingagainst grid positions over every object says the opposite and is wrong. EntitySpriteConfigfaces (face S { row = ... }) lay out down-right at (+64, +32) per cell. - Ground blends are declared and one-sided. A blend tile carries
FloorMaterial = <the material being blended in>plusFloorAttachmentN/W/E/S; measured over five shipped cells the letter points AT the blended material (97.7% of 3,107 boundary placements), the overlay sits on the RECEIVING square (99.1%), and blending is a hierarchy — grass feathers onto gravel, dirt and road, water onto dirt, never the reverse. Sixteen materials ship complete families. A solid ground tile declaresFloorMaterialorFootstepMaterialand neitherFloorOverlaynorIsFloorAttached; base tiles declareFootstepMaterialwhile their blends declareFloorMaterialat a finer grade. Shores are laid with the ground tile at object index 0 and theblends_natural_02edge at index 1, every time.Gravelandfloors_exterior_street_01_0have no blend family. - Water is a floor sprite (
blends_natural_02_0..15,FloorMaterial = Water) carryingwater+solidtrans;FootstepMaterial = Waterdoes not exist and inventing it makes a channel silent. Awater-flagged floor is handed to the WATER SHADER whenever the square's water geometry is valid, and the shader draws nothing four levels down — a deep pool needs static copies of the art WITHOUT the flag, opaque past the diamond edge (vanilla's anti-aliased edges are harmless under the shader and a dotted seam between static tiles). The shore pieces are thin strips made to finish the shader's soft edge and look wrong against static water. - Every vanilla tree sheet is six seasonal frames of each size and the erosion
system owns them.
NatureTreesnames all elevene_*tilesets, matches any object whose sprite name starts with one, reads the trailing index as the tree's growth STAGE, and drives stage and season from then on (NatureBushclaimsf_bushes_1_andvegetation_foliage,WallVinesclaimsf_wallvines_1_). Frames are 0 bare / 1 bare+snow / 2 blossom / 3 green / 4 autumn / 5 thinning: sizes 1–4 one_<x>_1atframe*4 + (size-1), 5–6 one_<x>JUMBO_1atframe*2 + (size-5)(3×2 tiles), 7 onJUMBOXL(5×3), 8 onJUMBOXXL(7×4). Three evergreens ship frames 0 and 1 only. Only frames 0 and 1 carry thetreeproperty, whichIsoTree.initTree()reads for size, log yield and health, defaulting to size 4 when absent — so create a tree on frame 0 andsetSpritethe season, as vanilla does.ErosionObj.setStageObjectclears the object's attached sprites. The small sheets are defined intiledefinitions_erosion.tiles; jumbo art is inJumboTrees2x.pack/JumboTreesBigs2x.pack.crafted_02_86is the only stump sprite in the main atlas. f_bushes_1is a layer sheet: twig frames, snowy twins, a green leaf layer, an autumn leaf layer and berry/flower layers all landing pixel-perfect on the same frames; the green-to-tan difference is a pure hue rotation (same luminance and saturation).f_wallvines_1sorts itself into 24 west, 24 north and 24 corner pieces throughCornerNorthWall/WestWall. Overlay sheets mix plain-wall pieces with pieces drawn for a window or doorway, and only the art says which: a plain-wall piece paints the full face width (x 2..65 in a west sprite's canvas, 60..123 in a north one). Only the “full” floor grime sprites tile alone (overlay_grime_floor_011, 9, 32, 33, 40, 41, 44, 82–85); wall grime is per face throughattachedW/N.- A multi-tile wall decal runs in opposite screen directions on the two faces. A north face walks +x (down-right on screen), a west face walks +y (down-left). Each tile of a graffiti tag is a whole syllable, so a reversed run reads as three unrelated fragments. Count from the far end on a west face.
- Other flags worth knowing:
BlockRain(1,838 tiles, all roofs);waterPiped(56 vanilla fixtures — see Water);solidtranson water and on tyre stacks decides whether they block;AmbientSound = VentilationSmallAmbienceonindustry_01_4/5hums with no code;waterAmount = waterMaxAmount = 999999on the five well and pump sprites makes each an infinite water source;IsoType = IsoFireplaceon four of the ninecrafted_01metal drums makes them fireplaces — aCustomNameis not a classification. A tile withIsMoveAbleandIsStackablemay be built on an occupied square, withISMoveableSpriteProps:getTotalTableHeight(square)giving the draw offset.
The tools' sheets
- A TMX references sheets by RELATIVE path; the editor derives a sheet's slot count as
(w/128) × (h/256)and ignores the size field inconfig/Tilesets.txt(bookkeeping it regenerates on scan). TheTiles/1xvsTiles/2xfolder name declares the scale to PRESENT at, not the scale of the art: the game's own sheets are 2x art at 128×256 cells and belong under1x, where2xquarters every tile. Custom folder names are rejected by the setup assistant. - Every extracted sheet set found on the way was wrong somewhere — flattened to 8
columns where the game declares 16, or short on rows so high indices simply do not
exist. Rebuild sheets from the game atlas, whose column count and row count are declared
in the tiledefs; agreeing columns is necessary and not sufficient (16 sheets had the
right grid and different art). A hand-rolled TMX must escape attributes: three sheets
carry a bare
&in their names. config/TMXConfig.txtis the authority for TMX layer names and their draw order (34 layers, Floor through RoofTop); a layer carries its level as a name prefix AND alevel=attribute, and both producers exist, so write both. A TMX layer is a grid of single tiles, so a corner piece and its two wall singles on one cell overwrite each other — and TMX layers do not survive into the game; every overlay becomes another object on the same square.- The Steam build of TileZed's Lua console crashes on a bare
print(Qt5Core.dll, one fault bucket, every script).setTile(x, y, nil)and any write outside the map crash it too; the erase call isclearTile(x, y).
World objects from Lua
Creating geometry at runtime
- A mod can create grid squares below ground at runtime. The primitive is
vanilla's own (
ISBuildRampCursor:addRampObject):IsoGridSquare.new(getCell(), nil, x, y, z)thengetCell():ConnectNewSquare(square, false), or the one callgetCell():getOrCreateGridSquare(x, y, z); thenIsoObject.new(getCell(), square, getSprite(name))andsquare:AddTileObject(obj). Vanilla-sprite objects PERSIST across save/reload (their sprite names carry resolvable ids), so every build path must be idempotent or a reopened space stacks a second copy. Creating a square that already exists returns the existing one andAddTileObjectnever validates — count existing floored squares across a footprint before writing anything, because a vanilla basement may already be there. Follow every runtime geometry change withsquare:RecalcAllWithNeighbours(true), as vanilla does after every build action, or lighting and collision never refresh. - A mod's own PNG can be a world sprite by full media-relative path WITH
extension:
IsoObject.new(square, "media/textures/MyThing.png")(the form vanilla's forageworldSpritestable uses). The bare name, the name with.png, atextures/-relative path and anItem_prefix all resolve to a textureless sprite. Its limits:IsoSpriteManager.AddSprite(String)assigns no id, so the object cannot be written into a save and comes back spriteless; the sprite carries an EMPTY property container (noattachedN/W, which vanilla reads to derive a wall object's facing); and a path-loaded texture is linear-filtered with a one-pixel fringe, where a pack sprite is not. Fine for a runtime overlay; anything a script names or the world persists needs a tiledef. - An attached sprite's offset lives on the sprite
(
AttachExistingAnimwrites it there andIsoSpriteInstanceexposes no offset accessor), so caching one sprite per texture makes every carrier move together. Make a fresh wrapper per carrier:IsoSprite.CreateSprite(IsoSpriteManager.instance)thenLoadSingleTexture(path); the texture is still shared by name. getSprite(name):getTextureForCurrentFrame(IsoDirections.N)is the test that a sprite name resolves; the method TAKES A DIRECTION and has no zero-argument overload (calling it bare throws Java-side, past anypcall). Always include a known-good and a known-bad control in a sprite test.- Never use one
IsoObjectper moving particle:AddTileObjectre-bakes the chunk-level FBO, flushes every player's LOS cache and dirties pathfinding (invalidateRenderChunkLevel,LosUtil.cachecleared,PolygonalMap2.squareChanged).OnPostFloorLayerDrawnever fires on the FBO renderer. For free-standing world sprites usegetIsoMarkers():addIsoMarker(textureName, square, r, g, b, a)— engine-managed, occluded correctly, tile-snapped, drawn on the player's own floor only (vanilla's forage icons run on it); the marker exposessetSquare,setAlpha,setR/G/B,remove.IsoSprite.renderTextureWithDepthhas ZERO vanilla lua call sites and using it fromRenderOpaqueObjectsInWorldleaves render state disturbed so that no roof or wall cutaway works game-wide;IndieGLis not lua-exposed, so the state cannot be put back.Render3DItem(item, sq, x, y, z, rot)is the one established lua draw inside that event (the fishing bobber). square:setHasFlies(bool)toggles the engine's own depth-correct corpse-fly flipbook.
Telling clients about an object
transmitCompleteItemToClients()CREATES a new object on every client; it never updates one. The packet isAddItemToMap, andloadFromRemoteBufferINSERTS a fresh object at the server's index with no lookup or dedupe; call it on an object clients already hold and every client gains a copy, older copies shift up one index, and because sprite and mod-data updates are INDEX-addressed only the newest copy changes from then on while the stale ones draw on top. Vanilla calls it ~57 times, always on an object it has just constructed (buildRecipeCode.lua:150:if created then transmitCompleteItemToClients() else sendObjectChange(STATE)). For an EXISTING object: sprite =setSpriteFromName+transmitUpdatedSpriteToClients(), data =transmitModData(), state =sendObjectChange(IsoObjectChange.STATE / SPRITE), a floor item's fluid =worldItem:sync(). Replace = remove + create +AddSpecialObject(new, index)+ complete-item. A buildable'sOnCreatemust NOT transmit:ISBuildIsoEntitysends the thumpable once right after it returns (return{ objectAlreadyTransmitted = true }only if you really did).ObjectModDataPacket.processServerAPPLIES a client's table to the server object before relaying, so a client that transmits a world object's mod data overwrites the server's copy; MP clients must nevertransmitModDataa server-owned object.GameServer.sendObjectModDatasilently drops the packet while the server fast-forwards (softReset || fastForwardreturns early, and fast-forward is set whenever sleep is allowed and every player is asleep).UpdateItemSpritehas no such guard andEveryHoursstill fires, so an object publishing state only throughtransmitModDatacan wake clients to a fresh sprite over stale data. Give it a player-triggered re-sync point.- The hook for “this object is going” is
OnObjectAboutToBeRemoved(obj), fired fromIsoGridSquare.RemoveTileObjectfor every removal there is — sledgehammer, fire, explosion, felling, another mod — and a second time on the server from its own packet path, so a handler must be idempotent and authority-gated. The object is STILL ON THE SQUARE when it fires and the engine throws if a handler removes it; record the square and act next tick.OnDestroyIsoThumpablefires only fromIsoThumpable.destroy(), which the sledgehammer never calls:ISDestroyStuffActiontakestransmitRemoveItemFromSquareon the authority and the lua globalsledgeDestroyon a client — whose entire body isif client then GameClient.destroy(obj), a packet plus a local removal. In single player or on a hostsledgeDestroyremoves nothing and apcallround it still returns true.OnWeaponHitTreefires only on a free melee swing (CombatManager.processMaintenanceCheck); the right-click chop action callstree:WeaponHitfrom lua and triggers nothing, andIsoTree.toppleTreeremoves the tree through the same removal path. ItemPickerJava.getItemContainer(room, kind, proceduralName, bool)NPEs on a nil third argument (pass"") and its last argument must be nil, notfalse— both throw from Java past anypcall. Write your built-ness marker BEFORE anything that calls into Java, or the throw leaves the room built and unmarked and the next build stacks a second copy.
Light, climate, power, fire
getCell():addLamppost(x, y, z, r, g, b, radius)constructs and returns a light source;removeLampposttakes the source or an (x, y, z) triple;getLightSourceAtfinds one; the source is mutable in place (setRadius,setR/G/B,setActive). A lamppost is not save state and is not synced — a pure client render concern. It accepts an out-of-bounds coordinate and returns a live source, so a coordinate bug is invisible. Do not scale a light's radius by its brightness (a dim shaft of light is the same width all day).- Climate has a modded tier a mod is meant to drive.
getClimateManager():getClimateFloat(ClimateManager.FLOAT_AMBIENT)(andFLOAT_FOG_INTENSITY,FLOAT_TEMPERATURE,FLOAT_DESATURATION... ) andgetClimateColor(ClimateManager.COLOR_GLOBAL_LIGHT)each carry override, admin and MODDED tiers withsetEnableModded,setModdedValue,setModdedInterpolate; vanilla's climate debug panel drives them from lua.setModdedValueREPLACES the world's value, so a “lift” must bemax(getInternalValue(), target)(nevergetFinalValue(), which reads your own number back and ratchets) and re-applied on a tick as the clock moves.interpolateis a BLEND WEIGHT, not a glide: 0.15 leaves a 30 C afternoon at 27 in a cave meant to sit at 12. Clear with per-objectsetEnableModded(false), never the manager'sresetModded(), which clears every mod's tier. Climate is one global state per machine, so it colours the whole world, not a room.getClimateManager():getDayLightStrength()already folds cloud and storm in. - A working generator can be made from Lua:
IsoGenerator.new(nil, cell, square)(nil item is legal), thensetConnected(true),setCondition,setFuel,setActivated,setSurroundingElectricity(); add it togetCell():addToProcessIsoObjectRemoveand the engine never burns its fuel while still delivering power in radius. A buildable'sOnCreatemay return{ replaceObject = true, object = X }and the built thumpable is swapped for any Java object. A generator reaches three levels (generatorVerticalPowerRange = 3, private) and two chunks; the world's power levels are -32..31, so one on the street powers z-1..-3.square:haveElectricity()is the one lua test for “is this square powered” and covers the grid before the shutoff and a generator after it;IsoGenerator.isPoweringSquaretakes six ints and has no vanilla lua call site. - The generator/toxic contract:
setActivated(b), on a non-exterior square with a building, callsbuilding.setToxic(b)— set on start, cleared on any stop — and a running generator re-asserts it once per game hour; an idle generator touches nothing;remove()never clears it, so picking up a running generator leaves its building toxic.IsoBuilding.setToxicis a field write plus a packet on every call from a server; the flag has no radius, it is per BUILDING. Vanilla's own toxic-air effect on the player (isProtectedFromToxic, the NoxiousSmell moodle) rides it. IsoFireManager.StartFire(cell, sq, true, 100, 500)is the vanilla server call (campfires, client commands); a safehouse check isSafeHouse.isSafeHouse(square, username, true)underisClient()/isServer().IsoTree.dropWood()is public and pays a felled tree's logs from its own per-size yield table, already rolling acorns, pinecones and holly berries; the remove-stump idiom isgetStump()+transmitRemoveItemFromSquare.setbFalling(true)+setFallTime(0)is a drop with no injury.getGameTime():getWorldAgeHours()is the clock for anything that should age; hash a per-square threshold from coordinates and compare, no state and no packets.
Ready-made frameworks
- The GlobalObject framework (
SGlobalObjectSystem/CGlobalObjectSystem; campfires, farming, traps, rain barrels run on it) gives a world object with server-simulated state: one-object-per-square identity enforced in Java, persistence of whitelisted lua fields togos_<name>.binindependent of chunk saves, a connect-time client mirror (unloaded chunks included),sendCommandboth ways, and chunk reconciliation (dead objects pruned, streamed-in ones adopted from mod data, a sledged buildable auto-unregistered). Limits: one object per square per system, state that survives table serialisation, sync to ALL clients with no proximity filter or secrecy — andregisterSystemreturns an existing system of the same name, so name it with a prefix. - Entity components are declarative.
component Resources { group slots { Item@Any@1 ... } }on a buildable gives engine-synced item slots (resource:offerItem(item)moves from a container or a world square with the right packets);component FluidContainergives a real fluid tank drainable through vanilla's own transfer UI;UiConfig { uiEnabled = true }plus an xuiSkin lineLuaWindowClass = MyWindowmakesISEntityUIopen the mod's own window from the world menu with per-player registry, position persistence and networked mutual exclusion (entity:setUsingPlayer). Vanilla'sISTableLayout+calculateLayout(w, h)is the panel protocol. AnxuiSkinblock must bemodule Baseor the engine logs it ignored at boot and the entity reports no UI info; the entity block may live in the mod's own module. The build-menu icon isIconin the entity's xuiSkin style (141 of 149 vanilla values areBuild_*, the restItem_*, none a tile sprite); without it the menu draws the “?” placeholder. - An entity
SpriteConfigsprite belongs to exactly one entity type. A second entity naming a sprite vanilla's own entity claims throwsSprite 'X' is duplicatefromSpriteConfigManager.parseEntityScriptand world load dies. Ship a byte-identical copy under your own tileset name. - Wall-mounted buildables mount through their
faceletters:N/Won the square's own two faces,S/Eon the north wall of y+1 and the west wall of x+1 (buildRecipeCode.lua:18-21).square:getWall(north)returns the wall object or nil. Refusals belong inOnIsValid, neverOnCreate(by then the inputs are spent).Tags = AutoRotatewalks all four sprite slots for a valid facing; vanilla's two-face buildables leave it off.isValidPerSquare's whole floor block is unreachable for any tile with a sprite (if not sprite or not sprite:getType() == wallparses asnot sprite or false), so the stairs test,square:isFree(true)and the water case are skipped whenever the tile has art; a floor may therefore be built over nothing at all.floor.OnIsValidisconnectedWithFloor()— true at z0, otherwise the four orthogonal neighbours AT THE SAME LEVEL must carrysolidfloor, never looking down — andfloor.OnCreatecallssquare:EnsureSurroundNotNull(), which CREATES the eight neighbouring squares, so one floor bootstraps the next across a gap. A recipe's or entity'sOnCreate/OnIsValidresolve throughLuaManager.getFunctionObject(String)at call time, so a mod's own dotted function name is as legal there asBuildRecipeCode.floor.OnCreate. - Vanilla ships a whole plumbing feature that nothing turns on.
ISPlumbItemandonPlumbItemexist andContextMenu_PlumbItemis translated in all 29 locales, but no line adds the option.waterPipedis a TILE FLAG (56 vanilla fixtures) read byisUnmovedPipedWaterSource(); a moved fixture getscanBeWaterPiped = true, and the action (equipped pipe wrench, 100 ticks) callssetUsesExternalWaterSource(true)+sendObjectChange(USES_EXTERNAL_WATER_SOURCE);hasWater/getFluidAmount/useFluidthen branch tohasReserveWater(), the mains, which the water shutoff drains. There is no radius:FindExternalWaterSourcewalks only the object's own sprite-grid footprint.
Lua and Kahlua
The dialect
- Lua 5.1 on Kahlua: no
goto(and a table field namedgotoparses here and fails a real 5.4 parse), no integer division, no bitwise operators.nextis not exposed as a global (vanilla uses it exactly once); run thepairs()loop instead. Rangedmath.random(a, b)throws a Java exception at runtime; zero-arg works; useZombRand(n)(0..n-1) andZombRand(a, b)(a..b-1 — soZombRand(1, 1)is not a roll),ZombRandFloat.table.newarrayandtable.wipeare Kahlua extensions.unpackis a global here (vanilla's debug windows call it) where Lua 5.4 moved it totable.unpack. - 200 local slots per FUNCTION, counting every
localdeclared anywhere in it (block scope never releases one) plus ~4 per numericforplus the parameters. Breaching it is a COMPILE error (ArrayIndexOutOfBoundsException: Index 200inLexState.new_localvar) that kills the WHOLE FILE silently — every event hook and menu entry in it vanishes. A real Lua parse passes such a file; only a per-function count catches it. Split big functions into methods and pass one state table. - Two adjacent string literals (
"a " "b") are a 5.1 syntax error that kills the file the same way. A missing-method call (self:gone()) is NOT a syntax error — Lua resolves it at call time — so a deleted function surfaces as a runtime error on the frame that reaches it. - Kahlua's
pcalldoes NOT catch Java exceptions. A wrong arity or a wrong argument type on an engine call throws fromMultiLuaJavaInvokeron the Java side and unwinds past thepcall; validate arguments before the call. Indexing a Java null throws too:IsoPlayer.getPlayers()is a fixed list whose empty splitscreen slots are Java nulls, and any java list'sget(i)can be null — usegetNumActivePlayers()withgetSpecificPlayer(i), and nil-TEST every list element. A method chain through a null (sq:getFloor():getSprite():getName()) is the same crash; one call per step, nil-tested between. Indexing a Java object for a method it lacks yields nil on an EXPOSED class (which is whyx.getInventory and x:getInventory()guards) but THROWS on an unexposed one: a fresh zombie'sGenericDefaultStateis not exposed, sostate.getNamethrows fromtableget;tostring(obj)is the one call every Java object answers, and its class name names the state. local ok, v = obj and pcall(f)is not a guard: an expression is adjusted to one value, sovis always nil. Two statements, or guard first and pcall second. Same for anything on the right of a multiple assignment.Events.X.Add(fn)is a dot call; a stub with aselfparameter swallows the handler silently.- What Kahlua reaches: public methods and public STATIC fields only, never a
public instance field (
RoomDef.level,IsoChunk.wx/wy,getWorld().helicopterare all invisible). A class being in the jar says nothing about whether lua can reach it — a vanilla lua call site does.InventoryItemFactoryis not a global (itsCreateItemis nil from lua; the global isinstanceItem("Base.X"));StateMachineis not exposed (getStateMachine()throws);RecipeCodeOnCreate.cutFishis a Java static lua can call and cannot wrap, whereFishing.onCreateFishis a plain lua function and can be.PerkFactory,CharacterTraitDefinition,ItemVisual,ItemVisuals,HumanVisual,WornItems,NinePatchTexture,IsoLightSource(viaaddLamppost),IsoGenerator,IsoAnimal,IsoDeadBody,IsoMetaGrid.registerZoneare all reachable. - Vanilla ships 217 events in
LuaEventManager;OnPlayerConnectis not one of them (server-side player events areOnCreatePlayerandOnPlayerDeath), andOnPlayerUpdateNEVER fires in a server process (a server sweeps withEveryOneMinute/OnTickovergetOnlinePlayers()).triggerEvent("Name", ...)fires any registered event by hand (vanilla uses it).Events.OnWeaponSwingandOnWeaponSwingHitPointare real;OnThrowableExplode(trap, square)fires first intriggerExplosion, on the authority, once in single player.Events.LevelPerk(player, perk, level)fires on level-up.
Wrapping vanilla
- Assigning a vanilla method more than once discards every other mod's wrapper of
it. The community idiom wraps at file load (
local orig = Class.method; function Class:method() ... end), so a later mod wraps YOUR wrapper, and any later re-assignment from an event handler throws theirs away silently. Install ONCE behind a one-shot flag; if the behaviour must survive a lua reload, install a stable dispatcher that forwards to a mutable field. Forward varargs (you sit mid-chain) andpcallyour own body on a per-frame path. - Never gate a mod file's whole body on a vanilla UI class existing at load
(
if not ISInventoryPane then return end): mod client lua can run before vanilla's ISUI files, and the file bails with zero errors and the feature never exists. Same for:deriveat file scope — arequireof a vanilla class can fail there and:deriveon nil kills the file with one boot stack trace. Make the table at file scope and hang the parent on it atOnGameStart(setmetatable(MyClass, Parent); Parent.__index = Parent), plus immediately for a reload. - Class-table wrapping of vanilla timed actions is not dependable: with a
tracer on six fluid-action classes, zero wrapper executions ever ran. Watch items and
state on
OnPlayerUpdate/OnContainerUpdateinstead, or use a real NetTimedAction of your own. Mods thatrequirethe vanilla file first do chain-wrap successfully — plausibly load order — but never trust a wrap without a tracer print. - The
WeaponSwingHOOK is a trap:Event.triggercalls every hook withprotectedCallVoidand returns true whenever ANY hook is registered, whatever it returns, so registering one reverts every swing of every player with every weapon.SwipeStatePlayer.enterfiresOnWeaponSwingand on the next line callsStopAllActionQueue(), wiping anything queued inside the handler; andSwipeStatePlayer.executeclears the action queue every frame it runs, indefinitely under a held aim, so no timed action can live there — count hits fromOnWeaponSwingHitPointinstead, which fires at the hit frame target or not. - The engine's own patch surface:
__classmetatables[JavaClass.class].__indexrewrites a Java method for EVERY instance game-wide; real, powerful, last resort.MapObjects.OnLoadWithSprite / OnNewWithSprite(sprite, fn, priority)hands you every object of a sprite as it loads — the honest way to find your own objects without sweeping the world every minute.PZAPI.ModOptionsis the B42 client options panel, the right home for display-only settings that would otherwise burn sandbox options.
Keybinds
- Append to the global
keyBindingtable: an action is{ value = "ID", key = default }, a section header is{ value = "[Inner]" }with no key.MainOptions.loadKeys()is the ONLY caller ofgetCore():addKeyBinding()and runs atMainOptions:create(), so register onOnGameBoot. Display names areUI_optionscreen_binding_<value>; for a header the brackets are STRIPPED first. Read back withgetCore():getKey(id). - Default every binding to 0. Vanilla's table already claims 1–8, A–J,
L–Z and most of the function row; a mod that grabs G, L and U silently takes Toggle
Safety, the Skill Panel and Vehicle Mechanics away from everyone, and a shipped default
never goes through
ISDuplicateKeybindDialog.getKeyreturns 0 for an unbound action, so a baregetKey(id) == keyfires every unbound action at once on a key event carrying code 0 — always testb ~= 0.Core.addKeyBindingis a map keyed by name; a second registration path is dead code that looks alive and resets the player's bindings on a hot reload.
UI and rendering
- A full-screen ISUIElement in
UIManagerkills every vanilla “mouse over UI or world?” walk — drag-to-ground item dropping (ISInventoryPaneDraggedItems:getDropContainer), the build cursor, the moveable cursor all call the JAVA element'sisPointOver, which for a top-level element is visible + bounds + “no other element covers the point”. It never dispatches to a luaisPointOveroverride, andsetConsumeMouseEvents(false)only gates event consumption. An overlay that only draws keeps its java element ZERO-SIZED and tracks its viewport in lua fields;UIElement.renderhas no size gate for a top-level element and draws are not clipped to bounds. A clickable button must have bounds, which is why a HUD readout and a HUD button cannot be the same element. - Never draw into
ISInventoryPanefromrender(): the page opens a stencil, the pane draws its rows in PRERENDER, and the pane'srender()ends withclearStencilRect(), after which every draw inside the pane is stencil-rejected. Draw from aprerenderwrap, viadrawItemDetails, orrepaintStencilRectthe area first. Under Inventory Tetris a foreign prerender wrap draws orphan bars UNDER the grid children; its sanctioned hook isTetrisEvents.OnPostRenderGrid. drawRect/drawRectBordertake(x, y, w, h, a, r, g, b)butdrawTexttakes(str, x, y, r, g, b, a, font);drawTextureScaled(tex, x, y, w, h, a, r, g, b)accepts a tint, which is how one white PNG becomes every colour (Lua has no polygon primitive).self.javaObject:DrawTexturePercentage(tex, ratio, x, y, w, h)exists.bar:setConsumeMouseEventsis not a lua method on ISUIElement — call it onjavaObject— andaddToUIManageris what instantiates thejavaObject, so anything set on it goes after.getTextaccepts at most 4 substitution arguments (vanilla's own maximum); a fifth throwsNo implementation foundand, from arender(), repeats every frame until the error box cannot be closed. A translation VALUE takes%1..%4; a lua fallback string goes throughstring.formatand takes%s— a%1in a fallback prints the placeholder, silently, on exactly the day a key is missing. A literal%in a translation value must be%%(42.20 hotfix; the temporary tolerance is being removed).- Native 9-patch textures:
NinePatchTexture.getSharedTexture(path)+tex:render(x, y, w, h, r, g, b, a), Android's.9.pngformat (opaque guide pixels along the top row and left column mark the stretch spans). Two warts:getSharedTextureALWAYS RETURNS NULL on the first call for a path (it loads and caches, then falls through) so call it at install and again at draw; a path that fails once is poisoned until texture packs reload. - The world render is depth-buffered: static geometry bakes into per-chunk
FBOs with depth, every sprite carries a depth from its world coordinates, and iso overlap
is a depth question.
RenderOpaqueObjectsInWorldfires once per player per frame inside the world pass (after the first in-world mouse move). Per-square fog-of-war is lua-readable:square:isCanSee(pn),isCouldSee,isSeen,getDarkMulti(pn); zombies hide by exactly that plus a per-object alpha lerp (step 0.28 × game multiplier, ×0.25 rising, /14 falling). Walls withouttransparent, closed doors, closed curtains, barricades,solid/blocksightsquares and trees of size ≥ 3 block sight; windows and hoppable fences never do.square:getRoomID() ~= -1is the cheapest interior test. - A world-menu option for a thing the object picker ignores: a flat floor
decoration may not be in
worldobjectsat all, andISWorldObjectContextMenu.createMenubails atif fetch.c == 0 then return endBEFOREOnFillWorldObjectContextMenufires (and skips it whensafehouseAllowInteractis false).OnPreFill...is not a workaround (the fallback callscontext:clear()). B42's second system has no such gate: registerISWorldMenuElements.<Name> = function() ... endreturning anISMenuElement.new()whosecreateMenu(data)getsdata.context,.player,.playerNum,.objects,.squares; register atOnGameBoot(the loader runs atOnGameStart). Resolve the square from the cursor withscreenToIsoX(0, getMouseX(), getMouseY(), z)when needed. A wall FACE click maps to the square beyond it unless it hits the bottom third of the face. - The farming hover popup is an
ISToolTipfollowing the mouse, not world-space drawing: lease one fromISWorldObjectContextMenu.addToolTip(),followMouse = true, rich text in.description(<LINE>,<RGB:r,g,b>),setTexture()for an icon; it exists only because a cursor mode is active, so a mod without one must show and hide it every frame itself. - The HUD clock (
UIManager.getClock(),zombie.ui.Clock) is one Java element gated on a WORNAlarmClockClothing; its faces are inmedia/ui/ClockAssets/(small 81×32, large 156×62; digit sheets at three sizes) and drawable from lua withgetTexture("media/ui/ClockAssets/<name>.png"). A wrist instrument isbase:clothingonbase:leftwrist/rightwristpaired throughClothingItemExtra; vanilla spawns the LEFT one. The time-speed controls live under the clock, so a second readout goes beside it. - Map symbols (B42.20):
MapSymbolDefinitions.getInstance():addTexture(id, path, tab)(the B41 4-argument form is gone and throws); the store ismapAPI:getSymbolsAPIv2(); per-symbol MP sharing exists; annotation colour is stored as raw floats. Freehand drawing is symbol spam (one dot per ~1.3 squares, 250k cap). Colour buttons are a duplicated table built inISWorldMapSymbols,ISTextBoxMapandISMapSymbolDialogfrom rows of{ item, colorInfo, tooltip }, and the availability test iscontainsTagRecurse(tag) or containsTypeRecurse(item)— a new hue is a new item plus an appended row in all three (vanilla itself appends a White row in editor mode); patch one and edited annotations silently repaint to the default. Journals and notebooks have no colour concept at all.
Multiplayer and authority
- B42 simulates a player's body and stats on the SERVER; an MP client is a display
terminal.
BodyDamage.UpdateandBodyPart.DamageUpdatereturn immediately on an MP client even for the local player, and the server pushes snapshots down: per-part health every 500 ms, full Stats every 1000, the whole BodyDamage every 2000. A client-sidestats:addor body write is reverted on that cadence (a HUD that climbs and resets wildly). Apply damage-over-time and environmental effects on the server and let the packets deliver; immediate pushes are the server-only globalssyncBodyPart(bodyPart, bitmask)andsyncPlayerStats(player, bitmask). The whole-body PAIN stat is derived from per-part pain every frame, so writeBodyPart:setAdditionalPain; the health panel's “Muscle Strain” isBodyPart.stiffness. Wounds from lua:setScratched(bool, forceNoInfection),setCut,bodyPart:AddDamage,BodyPartType.FromString("Hand_L"). A hosting player is an MP CLIENT of its own server process (isClient()true); only single player takes the direct path. - A client may not create or destroy items.
sendAddItemToContaineris guarded to the server and does nothing on a client, whilesendRemoveItemFromContaineris not — so the usualAddItem+Remove+ both sends is half-synced: the original is deleted server-side and the replacement exists on one screen, cannot be moved or crafted with, and vanishes on relog. The client reports item ids viasendClientCommand; the server re-derives the decision from the item's own state and performs the swap. The server's push for an item it mutated in place issendItemStats(item)(condition, uses, drainable delta and a full FluidContainer copy, routed to the owning player) — but it applies its payload withcopyFluidsFromon the receiver, a clear-then-re-add that an input-locked container refuses, so it cannot repair a per-instance flag that gatescanAddFluid.item:syncItemFields()on an item nested inside a bag crashes other clients' packet parsers; sync top-level items only. - A lua timed action that defines
complete()is engine-networked and server-authoritative for free.LuaTimedActionNewswitches it toNetTimedAction: the client'sstart()ships it to the server, which re-instantiates it by callingType:new(...)with deserialised arguments, recomputes the duration under its own clock, runscomplete()in server context when the timer lands, and force-stops the client's action if it returns false. The codec reads the PARAMETER NAMES ofnewand ships the same-namedself.fields — store constructor params under identical names, and anything that must not cross the wire under a different one. It carries InventoryItem (by id), IsoObject, GameEntity, IsoGridSquare, characters, vehicles, recipes and tables. Insidecomplete()the AddItem/Remove pairs are legal;perform()stays cosmetic; re-validate everything, because it runs later on server data. - ModData: a player's table is ONE wholesale-replaced table — a server
transmitModData()reaches the owning client too and receipt wipes the whole table before reloading, so two writers erase each other's keys on every send (vanilla transmits the player table from the CLIENT only). One writer per table. A GLOBAL table passed toModData.transmitis serialised whole to every client, andGlobalModData.receiveRequestreturns ANY named table to any client with no allowlist — no ModData table can hold a secret in MP; keep a value out of the broadcast by a separate never-transmitted table, and protect it by making the server the only thing that acts on it, with a proximity gate on the handler. A client transmitting a shared global table pushes its whole stale copy over everyone's rows. Vanilla'sserver/Items/AcceptItemFunction.luaassigns= {}, wiping shared-lua registrations — register from server lua. sendClientCommandwithout the player argument is attributed to player 0 of the connection; send the player form only, and key any dedupe on the token alone. EveryOnClientCommandlistener sees every module's commands including vanilla's own, which is free sync triggers (vehiclemodule events) but means handler order against vanilla's is undefined. In single playersendServerCommanddoes NOT loop back toOnServerCommand; run the same handler table in process. A handler reached bysendClientCommandis reachable with any arguments, so a coordinate needs a proximity gate and a grant must be conditioned on the world change actually having happened.- Common Sense's one network handler is an ungated “destroy any object anywhere by sprite and square” in a mod with millions of subscribers — copy the shape, add the gate it lacks.
- Test order: single player first, then multiplayer. Both of the worst live bugs seen while writing these notes were client-authoritative item edits that worked perfectly in single player.
- A client file may not call a function defined in
lua/server/: the engine loads client, server and shared into ONE environment in single player, so a cross-tree call works on the machine it was written on and is a nil on every multiplayer client (and a server file calling client-only code bites on the dedicated server). A real parse is happy because the name is defined somewhere; only a scan that knows the three trees can see it. mod.info: a server'sMods=line matches the declaredid=case-sensitively (setModIdToDiris a plain map), and a mismatch fails silently — the item downloads and no content appears. B42 requires backslash-prefixed ids in server modlists (\ModID).require=added to a LIVE mod pushes the dependency into every subscriber's save; a Workshop mod auto-updates.incompatible=<ModID>is honoured.versionMax=and acommon/tree that merges under the selected version directory let one item serve two builds; B42 reads only the version-dirmod.info.
Items and recipes
Item scripts
- B42 has no
Type =field; it isItemType = base:<kind>, fifteen values:normal,clothing,food,literature,weapon,moveable,container,drainable,radio,alarmclockclothing,map,weaponpart,key,alarmclock,animal. An item with a missing or unknown kind returns null fromgetItemType()and the debug item viewer throws on it for EVERY mod at once.DisplayNameandObsoleteare B41 fields B42 discards; names come fromItemNametranslations. Script field names are a vocabulary:needToBeLearn(forNeedToBeLearn) andToolTip(forTooltip) are ignored without a word, and a perk name that is not a perk (CarpentryforWoodwork,MetalworkingforBlacksmith) is a gate that silently does not exist. - Item tags are a registry. Every custom tag in
Tags =ortags[...]must beItemTag.register("ns:name")inmedia/registries.lua; an unregistered tag resolves to the NULL tag, every item carrying any unregistered tag lands in one shared bucket, and atags[unregistered]recipe input matches that whole bucket.base:names cannot be registered by mods; do not invent abase:tag, and do not register another mod's (duplicate registration throws). A missingbase:prefix on a real vanilla tag is a use the item silently never gets.item:hasTag()takes anItemTagENUM, never a string — a string throws past anypcall; there is ahasTag(ItemTag[])overload and no String one. - Comments in script
.txtfiles are unsafe: an apostrophe inside a--comment is taken as a string delimiter, the parser resumes mid-sentence on a bare word, the entity is dropped andScriptManager.LoadthrowsScript load errors— the GAME does not start. Vanilla ships zero comments in any entity script. fluid = Name:Nin a FluidContainer component is a FRACTION of capacity, not litres (addInitialFluidmultiplies byfs.getPercentage()); vanilla writes1.0in 148 of 149 declarations, so a litres reading and a fraction reading agree on every vanilla item and nothing in the base game can disprove the wrong guess. Write1.0for full,0.0for empty.setCapacityfloors capacity at 0.05.- A bare
ReplaceOnDeplete/ReplaceOnUsename resolves to the item's OWN module (item.module + "." + name), and a miss logs a warning and creates nothing; the Base-fallback loop inFindItemre-queries the same module every iteration. Mod items must writeBase.-prefixed replace names.ReplaceOnDepleteis implemented only forItemType.Drainable; it does fire from recipe consumption. A Normal-mode recipe input on a DRAINABLE consumes COUNT doses (vanilla:item 2 [Base.Woodglue]).item:isSealed()is the authoritative sealed check; FluidContainer has noisOpened. - Vanilla never swaps item types on drain. A beer bottle opens by
flags[Unseal]on amode:keepinput, drinks to empty and stays the same item, namedEmpty %1byFluid_HoldingNone. Prefer that shape; a type swap is only for an empty thing that is genuinely a different item. - Icons:
IconFluidMask = Namedrawsmedia/textures/Item_Name.pngtinted with the fluid's colour and clipped from the bottom toamount/capacityon every icon path (73 vanilla items); the clip ratio is floored at 0.15 and an EMPTY container's colour is white, so make the mask's bottom 15% transparent. It drains only for a real FluidContainer, never a drainable'sUseDelta.IconColorMasktints a second mask with the item's ownColorRed/Green/Blue(0–255) — but not on abase:moveable, whose icon path never populates the mask (0 of 82 vanilla users are moveables).Item.DoParamregisters a script-declared icon underTexture.getSharedTexture("Item_" + icon), so an icon a script names is in the cache and one named ONLY in lua may not be; a mod's icon is a loose PNG atmedia/textures/<Icon>.png, andgetTexture("Item_X")resolves pack art but not a loose mod PNG (draw an item's icon withInventoryItem.getTexture()instead). Vanilla icons carry binary alpha and a one-pixel dark outline; a generated icon must threshold alpha and black the boundary. An item's inventory picture is overridable per instance through the mod-data keycustomInventoryIcon(a bareItem_-prefixed texture name), persisted, carried in the add-item packets, and falling back to the normal icon when the name resolves to nothing — vanilla's own mechanism for gas-mask filters and SCBA tanks. - World models tint from the item's colour with no
Clothinggate:WorldItemAtlasmultiplies the WHOLE texture by the item's colour, so one greyscale mesh texture serves every colour of a ONE-colour object (vanilla'sRubberduckyTINTED:Icon+IconColorMask+ a greyscaleWorldStaticModel+OnCreate = ItemCodeOnCreate.onCreateRandomColor) and cannot serve a body-plus-accessory object. Not one vanilla model block declares a shader. A colour is set at construction on every machine with zero packets;SyncItemFieldsPacketcarries an instance colour but is client-authoritative.StaticModel(in hand) andWorldStaticModel(on the ground) are different fields; a timed action shows its item only if it says so (self:setOverrideHandModels(primary, secondary)instart(), which accepts an unequipped item), and only if the item has aStaticModel. Amodelblock may point at any vanilla mesh and any vanilla texture; a model block'sattachmentdeclares which attachment points it may be drawn at, and an attachment no model declares renders nothing. - A container item's
WeightReductionapplies only while it is worn or held:getInventoryWeight()walks the top level and asks an unequipped item forgetUnequippedWeight(), which is actual + contents with no reduction. That is why every vanilla packed item is a plainbase:normalwith a fixed weight. An item CAN carry its own weight, name and mod data per instance (setActualWeight+setCustomWeight(true),setName+setCustomName(true)) and all three persist and cross the wire throughsave(); the inventory pane stacks bygetDisplayName(), so renamed instances get their own rows. A container can refuse items engine-side through a lua function named as a dotted string (AcceptItemFunction = X.Yon the script, orcontainer:setAcceptItemFunction(String)at runtime), invoked asfn(container, item)and compared againstBoolean.TRUEso a lua error fails closed; onlyhasRoomForandisItemAllowedconsult it, notAddItem. A dropped item's FluidContainer lives on the WORLD OBJECT (TransferComponentmoves it), so readitem:getFluidContainer() or item:getWorldItem():getFluidContainer(). - Light:
canEmitLight()isgetLightStrength() > 0(andgetCurrentUses() > 0for a drainable);isEmittingLight()addsnot canBeActivated or isActivated, so anActivatedItem = truecandle with noSetActivatedon its light recipe glows only after a hidden toggle, while vanilla'sCandleLitisActivatedItem = falseand glows unconditionally.getActiveLightItemswalks the two hands and every ATTACHED item, at most four (LightingJNI.checkPlayerTorches) — worn clothing never emits light, and the path is typedIsoPlayer, so a zombie cannot carry one. Bulbs do not burn out: nothing reducesLightBulb's condition,SandboxVars.LightBulbLifespanis read by nothing, andIsoLightSwitch.removeLightBulbbuilds a fresh item copying only the colour. - Attachments: a garment's
AttachmentsProvided, the hotbar definition'stype, the item'sAttachmentTypeand theAttachedLocationsid are four bare strings across four files, each join failing silently.ISHotbarbuilds its slots from ALL worn items with no body-location filter, so a hat can offer a slot as a belt does; a slot name with noISHotbarAttachDefinitionrow is skipped.AttachedLocationsis plain appendable shared lua.getAttachedItem("Holster Left")reads a clip by location id.
Recipes
- All 969 vanilla
craftRecipes are inmodule Baseand none anywhere else; a recipe in a mod's own module never marks its product as a craft product. A bare fluid name resolves across modules (-fluid 5.0 [Slip], like vanilla's[Water]). Every item reference in a recipe must be module-qualified, even inside its own module: an unqualified name isitem not foundatOnPostWorldDictionaryInitand aWorldDictionaryExceptionthat stops the world loading. - A
mode:mixturefluid draw requires everyitemline ABOVE it to have amount 1 (Lines prior to a '-' line should have 1 item amount), and breaking it is the same world-load crash; put the bulk ingredient below the draw. A plain named draw carries no such rule. - A craftRecipe re-declared with an existing name does NOT override; both load and the
recipe shows doubled requirements. Same-name replacement is an ITEM behaviour;
the recipe schema has no override grammar. A vanilla recipe's input list therefore cannot
be widened from a script — a mod item as an alternative input needs a distinctly
named recipe. An unknown ITEM in an input list is dropped silently (debug log only); an
unknown FLUID in an input throws
Fluid not found; an invalid OUTPUT throws. So cross-mod compatibility is an alternative in an INPUT list, never a cross-mod output or fluid draw. - A Normal-mode input that owns a FluidContainer is never consumed without
ItemCount(processDestroyAndUsedItemsskips it); an empty fluid item that should be used up needsmode:destroy— written plainly it is an infinite jug.flags[DontRecordInput]exists in zero vanilla recipes; there is no welded ITEM in the base game, every welding requirement is a buildable ENTITY. AnitemMapperpairs inputs to outputs (a mapper is not a pool);flags[InheritCooked;InheritFoodAge;InheritFreezingTime]carry food state; count-prefixed names (10:Base.FishingHook) give per-type counts inside one recipe;recipeGroupcollapses menu lines. - A recipe's hooks resolve through the Lua namespace: five of them
(
OnTest,OnStart,OnUpdate,OnCreate,OnFailed; vanilla uses two), each aLuaCallstring resolved withLuaManager.getFunctionObject(string)at call time, so a mod's ownMyMod.onXis the same mechanism asRecipeCodeOnCreate.x(23 vanilla item blocks already sayOnCreate = Fishing.onCreateFish, a plain lua table function). The MP context of the call is unverified.ItemCodeOnCreate's own method set cannot be extended, but need not be. The script'soutputsblock is not the recipe's outputs: 146 recipes carry anOnCreatehook that hands out items the script never names (dismantling electronics rolls amplifier, bulbs, receiver at50 + 5 × Electricitypercent each). - The packing precedent: 32 vanilla recipes in category
Packing, 63 pack/open pairs, output weight ×1.20 of inputs for generic packing (40 of 63), ×2.50 for nails and screws into a box, ×3.00 for a log stack; a packed item is a plain item withDoubleClickRecipepointing at its open recipe;RecipeCodeOnCreate.addToPack+RecipeCodeOnTest.canAddToPackis the incremental add with an EMPTY outputs block. Paint isbase:drainableatUseDelta = 0.1, so a recipe count is DOSES, not cans; no vanilla recipe consumes paint and colour mixing does not exist in the game.EvolvedRecipebelongs to the ingredient (Soup:3;Stew:3on the item side, hunger contributed);fixingblocks are repair; vanilla's whole fish carries NO EvolvedRecipe — the fillet does, andCutFishtakestags[base:uncutfish], so cookability is a tag question. - What vanilla ships and never wires:
fluid Acid(referenced by no item or recipe);Base.LargeMeteorite(full art, one legendary forage entry, no consumer); thebase:ironore/ironsource/hasmetaltags (zero recipe inputs — the live smelting hook is thebase:smeltableiron*family); an unused garter-snake icon inUI2.pack; a froglet icon; a rotten squirrel icon; eight*_Greyscale.pngworld textures, three wired to nothing;ContextMenuCode.TakeLogswired to no entity; four ore-chunk moveables naming a sheet that exists in no atlas. Check for an unused vanilla icon before painting one.
Traits, professions, skills
- B42 traits and professions are declarative:
CharacterTrait.register("ns:name")/CharacterProfession.registerinmedia/registries.lua, thencharacter_trait_definition/character_profession_definitionscript blocks (Cost, XPBoosts, MutuallyExclusiveTraits, GrantedTraits, GrantedRecipes,DisabledInMultiplayer); the creation screen picks it all up with no UI code. Runtime detection isCharacterTrait.get(ResourceLocation.of(id))thenplayer:hasTrait(t)anddesc:getCharacterProfession():getName();getTraits(),HasTrait(string),ProfessionFactoryanddesc:getProfession()do not exist on 42.20 and B41-shaped helpers silently return false. ScriptXPBoostsdo not re-apply on a runtime add. - A profession must grant a trait whose
IsProfessionTrait = true, or the player can remove it at creation and keep the points:isFree()returns that field andremoveTraitrefundsgetCost()for anything not free. All 12 vanilla profession-granted traits are free at Cost 0; vanilla ships both twin shapes (a pure badge likebase:cook2, or an effect-carrying twin likebase:herbalist_prof), and a twin wears the purchasable trait's ownUI_trait_*keys and a copied icon for free.MutuallyExclusiveTraitsis one-directional (the parser callsaddMutuallyExclusive), which is why vanilla writes every pair on both traits; the two-way fix from lua is the public staticCharacterTraitDefinition.setMutualExclusive(a, b), called at load, boot and start with a contains-check first. A trait's icon ismedia/ui/Traits/trait_<name>.png, name lowercased, namespace stripped, falling back totrait_generic.png; a profession icon isIconPathName. Vanilla's professionCostis points ADDED to the pool and tracks the unique gate, not the level count. - A mod adds a real skill with one text file:
media/perks.txt(VERSION = 1,thenperk Id { parent = Crafting, translation = Key, passive = false, xp1 = 50 ... xp10 = 6000 }), read byCustomPerksfrom every enabled mod's version and common dirs, callingPerkFactory.AddPerkwithsetCustom();Perks.<Id>becomes a lua global; XP is saved and loaded BY ID STRING, so it survives reload; the skills panel renders straight offPerkFactory.PerkListwith no UI code; a dedicated server loads it too. Every xp value must be > 0;parentmust be an existing perk. Two traps: a scriptXPBoostsresolves perk names at SCRIPT LOAD, a boot-order race against perks.txt, so grant a custom skill's starting levels from lua atOnCreatePlayer(xp:setXPToLevel+setPerkBoost); and skill books work only onceSkillBook["Id"] = { perk = Perks.Id, maxMultiplier1..5 }is in vanilla's table, which is assigned= {}at load.
Characters, clothing, animals
Zombies and outfits
- A live zombie is drawn from its outfit VISUALS, never its worn items.
IsoZombie.getItemVisualscopies the ItemVisual list its outfit filled unlessisUsingWornItems(), which for a walking zombie is false; at the kill the corpse's clothing AND pockets are rebuilt from the visuals and anything put on or into a live zombie is discarded. Dress a live zombie from lua withItemVisual.new(),setItemType,pickUninitializedValues(getClothingItem()),zombie:getItemVisuals():add(iv),resetModel(); death loot isaddItemToSpawnAtDeath(item). Visuals are not saved with the zombie (a reload rebuilds them from the outfit id). - The model manager dresses a zombie itself the first time it builds the model,
after anything done at spawn:
ModelManager.AddandResetboth rundressInRandomOutfitwhen the zombie's flag is up (a random outfit plus blood, dirt, holes and an attached weapon), thendressInPersistentOutfitIDwhen the persistent id is uninitialised — andcreateZombiehands back exactly that state. Shut both gates BEFORE the outfit:setDressInRandomOutfit(false)(vanilla's tutorial idiom) andsetPersistentOutfitID(0, true); thengetHumanVisual():setSkinTextureName("MaleBody01")AFTER dressing (the skin falls back to aZedBodytexture whenever the name is empty and the owner is a zombie, andHumanVisual.clear()resets it);clearAttachedItems(),removeBlood()/removeDirt(),getBodyVisuals():clear(). - An NPC on a zombie shell is the shape every B42 NPC mod uses:
createZombie(x, y, z, nil, 0, IsoDirections.S)oraddZombiesInOutfit(...)(13-argument overload: fall-on-front, knocked-down and health are arguments; returns the spawned list), then AI suppressed every update (setUseless(true),setNoTeeth(true),setZombiesDontAttack(true), lunge/attack/eat states forced back to idle), movement through the engine's own pathfinder (getPathFindBehavior2():pathToLocationF(x, y, z)+setVariable("bPathfind", true)+changeState(PathFindState.instance())), one-shot clips throughsetBumpType("Name")matched by abumped-state node that firesBumpAnimFinished, hits withvictim:Hit(weapon, getCell():getFakeZombieForHit(), dmg, false, 1, false), and MP ownership asked of the engine:shell:isRemoteZombie()on the client,shell:getOwnerPlayer()on the server. Vanilla runs a REMOTE zombie throughwalktoward-network/lunge-network/attack-network, so nodes added towalktowardalone show humans walking like zombies on every client but the owner.pathToLocationnever setsanimalRunning; only the engine's own flee does. - Vanilla's body wound marks are hidden clothing items: 96 items
(
base:clothing,BodyLocation = base:wound,WorldRender = false,hidden = true) whose model-less clothingItem XML carries onlym_BaseTextures, a 256×256 overlay composited under clothing becausewoundis first in render order and multi-item. Vanilla creates and wears them client-side withsetWornItemand syncs with the METHODplayerObj:syncVisuals(); the lua GLOBALsyncVisuals(player)is server-only and silently does nothing on a client.HumanVisual:setBlood(BloodBodyPartType, n)is the untintable alternative. - A mod's
clothing.xmlmerges:OutfitManager.loaded()parses each mod'smedia/clothing/clothing.xml, replacing a known outfit name and appending a new one. Outfits address garments by GUID (one perclothing/clothingItems/*.xml); a GUID naming nothing is dropped silently. Vanilla ships 239 male outfits against 170 female, and its work outfits are male-only. Mod clothing models need nofileGuidTableentry and may be referenced by full media path with extension. Hats control hair throughhairStyles.xmlcategories (nohair,nobeard,nohairnobeardhide outright). Clothing textures alpha-blend, including on static attachments; an RGB-saved PNG has silently discarded its alpha. Two vanilla hard hats are one mesh differing only in texture; nothing is drawn on the head beyond the mesh. - A static head attachment is
m_Static = true+m_AttachBone = Bip01_Headwith the mesh in head-bone LOCAL space (+X up, +Z forward). The attach frame is the bone's inverse-bind matrix in the body mesh's own SkinWeights block (MaleBody.x/FemaleBody.x), an exact axis swap; composing the skeleton's FrameTransformMatrix chain gives a frame ~9.6° pitched and ~5 cm off. Female = male × 0.897 about the origin. Skinned mod clothing is proven live; a mesh with unpainted weights ships as a static attachment.
Animals and animation
- A mod animal is a vanilla animal's state machine wearing new skin. A type is
a lua table
AnimalDefinitions.animals[type]in any shared file, naming abodyModel(mesh = Skinned/X,shader = animalEffect,animationsMesh = Name), per-breedtexture(any size; every vanilla body texture is square) andanimset. The animset string is used twice:AnimationSet.Loadresolvesmedia/AnimSets/<name>mod-aware and additively, butActionGroup.loadreadsactiongroups/<name>from the GAME directory only (ZomboidFileSystem.getMediaFileisnew File(workdir, path)), so a new animset name loads its clips and gets NO state machine and the animal freezes.animsetmust be one of vanilla's 22 animal groups (buck chick cockerel cow cowcalf deerdead doe ewe fawn hen lamb mouse pig piglet rabbit rabkitten raccoon ram rat turkey turkeypoult), whose states and transitions are then the whole FSM; a mod may only add NODES to an existing state, scoped by a variable it sets (animal:setVariable("isX", true)) atm_ConditionPriorityabove vanilla's unconditioned 0. The same holds for the player: a mod adds a conditioned node to an existing player state and cannot add a state.AdvancedAnimator.load()walks every mod'sAnimSetsandactiongroupsfolders into the animation checksum a client must match. - Three visual routes on a borrowed FSM: a new mesh skinned to the vanilla
skeleton by bone name plus
animationsMesh = Ratplays vanilla's clips for free; an own rig with the clips EMBEDDED in a glb, named to match the AnimSet'sm_AnimNames, declaredanimationsMesh X { meshFile = Skinned/X, keepMeshAnimations = true }(the companion-cat and companion-dog route); or the mouse shape, same skeleton and own animation directory. A skeleton cannot carry a longer-legged animal's ground contact: IK-planted feet force the body down and something goes through the floor; ship such an animal on its own rig. - An animal is moved by its clip's
Translation_Datatravel, so a clip baked in place runs on the spot; vanillaRat_Walkmoves that bone 0.18 over 0.533 s toward the head, and glb bodies carry theirs onTranslation_Dataalong +Z (glTF faces +Z where a DirectX.xfaces −Z).IsoAnimal:getBreed()returns an object;IsoDeadBody:getBreed()a string. Spawn:IsoAnimal.new(cell, x, y, z, type, breed)+setWild+addToWorld()(vanilla's trap release); an animal is world state and persists, so cap, despawn and sweep. A carcass isIsoAnimal.newnever added plusIsoDeadBody.new(animal, true, true). A held animal is already dropped by any item swap. The brain is ONE Java class for every species; species differ by ~100 definition keys plus lua on a tick. Rats and mice are real vanilla animals with breeds. - A PZ
.xclip is readable and rewritable text: the rest hierarchy, a 617-vertex skinned proxy body with weights, and per-bone R/S/T tracks at 4800 ticks a second. A key composes to the column-form local matrixT @ R(conj q) @ Swith the quaternion CONJUGATED; skinning isworld_bone @ SkinWeights offset @ vertex; aFrameTransformMatrixis sixteen floats row-major, so the column form is its transpose; a bone's local translation is in its PARENT's frame, andBip01's parentDummy01carries a 90° rotation about X. On a skinned body the SkinWeights offsets differ from the inverse frame-rest worlds by up to 0.93, so deforming bypose @ inv(frame rest)tears the mesh. Vanilla's ownBob_ClimbRopehas one bone that does not loop. A hand-rolled parser trips on two things: anAnimationblock CONTAINS itsAnimationKeyblocks, and the header'stemplate Meshprecedes the data. Rewrite the AnimationSet of a vanilla clip and write rotations only, so the skeleton cannot stretch. - The model-to-world scale:
Model.vectorToWorldCoordsnegates x, rotates by the rendered angle, swaps y and z, then scales x and y by 1.5 and z by 0.6123723 — so one z level is 1.63299 rig units, a storey draws at 192 px at tile scale 2 (IsoUtils.YToScreen), and the Bob rig's head at 0.85 units is a 100 px character. Every ladder sprite in the game has a 32 px rung pitch, so a storey is exactly six rungs. The rope climb:ClimbSheetRopeState.executeaddsspeed/10 × GameTime.getMultiplier()per frame (a multiplier sums to 48 a second),getClimbRopeSpeedis one of eleven values from a clamped stat (0.096 to 0.672 levels a second; stats 4 and 5 are the default case), andanimFrameIncreasereaches only the legacy 2D sprite classes — a 3D clip does NOT speed up with the climb, so syncing feet to rungs means one node per speed with its ownm_SpeedScale. Vanilla'sclimbropeandclimbdownropeare one unconditioned node each, both namingBob_ClimbRope, a one-second rope shimmy with no root motion (the engine raises the body); there is no ladder clip in the game.ModelManager.loadModAnimationsloads a mod's clips frommedia/anims_X/<animationDirectory>/under bothcommon/and the version dir; the player's mesh isanimationsMesh HumanwithanimationDirectory = Boband no prefix. Whether a clip LOADED cannot be asked from lua (ModelManagerhas no lua call site); the tell is a frozen pose. - The tile-art camera is orthographic at rotation X 60, Y 0, Z 45; ~26.57° is
the screen-space slope of a tile edge, not a camera value, and true isometric (54.736°)
never lines up. With 1 unit = 1 tile, ortho scale √2 against a 128 px-wide render
fills a tile exactly (
sensor_fit = HORIZONTALis load-bearing); render at 4x and downscale; Standard view transform, not AgX or Filmic; EEVEE. Vanilla's grain lives in the materials. For a 3D fish or any flat model, build the camera basis from the mesh's own bounding box (look down the shortest axis);to_track_quattakes its up from world Y and stands a fish on its tail; and the Standard transform clips at 1.0 with no roll-off, so measure each render against its own texture and pull exposure down until it stops clipping. The game's 12 fish meshes and 20 skins are paired bymodels_fish.txt's<Species>_Groundblocks.
Breathing gear and the body
isProtectedFromToxic(drain)walks the worn items once: an SCBA with the tag,isActivated(),hasTank()and delta left returns true without draining; otherwise a gas mask / respirator / improvised mask withhasFilter()spends0.01 × the filter item's UseDelta. The tank is spent bycheckSCBADrainwhenever the bottle is worn and switched on, in fresh air the same as foul (0.001 × 0.0625 × multiplierper update). Neither is removed at empty;drainSCBAfloors at zero and deactivates.hasTank/hasFilterare item mod data (tankType/filterType). Fitting a filter or tank does not swap the garment:RecipeCodeOnCreate.attachTankOrFilterwrites the type and remaining amount onto the CLOTHING as its owngetUsedDelta(), and the_nofiltertwins exist for their art; vanilla gates its recipes ontags[base:gasmask], which only the with-filter items carry.BodyPart.setScratched(scratched, forceNoInfection): the second boolean suppresses Knox.getWornItemtakes anItemBodyLocationenum; ask what is worn by walkinggetWornItems().Base.BareHandsis a real item, so an empty hand may be nil OR that item.
Foraging, fishing, farming, water
Foraging
- The most mod-friendly subsystem in the game:
lua/shared/Foraging/forageDefinitions.luais 115 lines and every field is commented. Registration is public and additive (forageSystem.addForageDef(name, def);Categories/Artifacts.luais the worked example). Custom CATEGORY and SKILL defs must be STAGED intoforageSystem.categoryDefinitions/forageSkillDefinitions, neveraddCatDef/addSkillDef:forageSystem.initclears the live tables and rebuilds them from the staging tables, so a category registered live is wiped before the game ever uses it.addForageDefrefuses to overwrite an existing key. itemTagson an item def is a pure tool gate (hasRequiredItemssearches the pack RECURSIVELY for a non-broken item with every listed tag, AND not OR), shipped and used nowhere — vanilla's one example is commented out and would throw, because it passes a string andhasTaghas no String overload; passItemTag.get(ResourceLocation.of("ns:tag")). Other gates:traits,recipes,skill+perks(averaged), per-item weather and time modifiers, month control.spawnFuncsruns on pickup — on the SERVER in MP, the client in single player.doIsoMarkerSpriteoverrides the world marker;itemSizeModifierfeeds the vision-radius check. CategoryidentifyCategoryPerk/Levelshows the fuzzytypeCategorybelow a skill;focusChanceMin/Maxis the search-focus mechanic;validFloorsis a texture-NAME prefix test; the categoryvalidFuncis DEAD in 42.20 (called, discarded, returns false). Skill defs match by profession name orhasTrait();testFuncscan veto per effect (vanilla's ShortSighted cancels its penalty when glasses are worn).forageSystem.isForageableFuncsis a list of function-name STRINGS a mod may append to. The search panel's focus dropdown is data-driven and needs onlyIGUI_SearchMode_Categories_<name>to list a category; the zone display's images are a fixed file set.zones = {Zone = n}is the NUMBER OF ROLLS, not a weight or a percent (vanilla's schema flags it twice).pickRandomItemTypepicks a CATEGORY bycatDef.zones[zone]first, then an item byitemDef.zones[zone], each ×validMonths[month] or 0and weather and time.ForestGoodsis hidden with weight 0 everywhere and no item lists it, so a def whose only category is ForestGoods never spawns;hasNeededPerksexcludes a def below itsskill, so a skill-1 mod def in Fruits is 100% of a novice's Fruits finds. Compare weights against the eligible vanilla set per skill band. Forage months are 1-based (getMonth() + 1).- The zone lookup ignores the player's level:
getForageZoneAt(x, y)callsgetZones(x, y, 0), so a zone is found by x, y alone; registration needs no map file —getWorld():getMetaGrid():registerZone(name, type, x, y, z, w, h)is public and zones save with the world. Forage icons are stamped at LEVEL 0 and seen only on the searcher's own level (createForageIconswrites z = 0;getCanSeeThisUpdatedemands equal z andgetDistance3Dweighs a level as TEN squares against a cap of 15), so a below-ground zone must PLACE its own icons by wrappingcreateForageIcons(SP),forageServer.generatePool(MP) andISSearchManager.doChangePosition.getAndActivateZoneAtXYreturns any ACTIVE zone containing the point by a z-0 rectangle test before it asks the lookup. A mod zone must give every category a roll count, zero included, or the first search throws inprocessEntries(generateLootTablemultipliescatDef.zones[zoneName]for every category).fillZonesizes a zone's budget from the meta zone's box. A refused icon gets ten relocation rolls at z 0 and is then removed, taking one from the zone's budget; darkness underlightPenaltyCutoff = 50shrinks visibility to 1.5 squares.
Fishing
Fishing.RiverZonesandNoFishZonesare plain lua rectangle tables in shared lua, read by trivialisRiver(x, y)lookups — appendable, but static rectangles on bare x/y, so a dynamic water body must be re-appended on load and be identical on server and every client (derive it from positions).Fishing.lureis the bait table (Fishing.lure.<Category>["Base.Item"]); all 41 rows arechanceModifier = 1.0and the food field is a flag, so what vanilla hands over is the CATEGORY per item.Fishing.fishesholds the species withsetMaxWeightin KILOGRAMS andsetActualWeight(weight * 2.2)in POUNDS (its own comment says so).isWaterCoords/isNearShorereadgetGridSquare(x, y, 0), z0 hard-wired; the bite isFishSchoolManagernoise seeded on x, y with no water test; the splash issquare:startWaterSplash, the bobber aRender3DItem.Fishing.onCreateFishis a plain lua function 23 item blocks name (wrappable, and its first line isif not item or isClient() then return end, so it is authority-only for free);RecipeCodeOnTest.cutFishcomparesgetMonth()against 3 and 4, ZERO-BASED, so it is April and May.
Feeding troughs, rain, plants
IsoFeedingTrough.checkWaterFromRain()does not fill anything from rain — its whole body drops an empty fluid container, and nothing in the game rain-fills a trough. The rain pattern is the RAIN BARREL:SRainBarrelSystem:checkRain(), a GlobalObject system onEveryTenMinutesreturning unlessRainManager.isRaining(), adding a fixed amount perisOutside()object. What the trough does give free: water as a first-class value (getWater,setMaxWater,addWater(FluidType, float),removeWater), a real FluidContainer,getLinkedAnimals,getMasterTrough()for multi-tile troughs, a lua def table (FeedingTroughDef, appendable, keyssimple/double/doublemetal/triplemetal/quadmetal), andMOFeedingTroughretrofitting every map-placed trough sprite into a real object at chunk load. ItsisItemAllowedInContaineris a Java override with no lua call site; the lua filter issetAcceptItemFunction. A trough's water and the plumbedusesExternalWaterSourcepath are two systems that never meet.- A method whose name states a behaviour is a hypothesis until its body is read. Both of the above were assumed from their names first.
- Vanilla's plant decay:
SFarmingSystem:EveryTenMinutesdrains one water point everyhourForWaterhours (default 5; 12/8/5/3/2 byPlantResilience); 49 of 64 crops declarewaterNeeded = 70, struggling belowwaterNeeded / 1.10and dying below/ 1.30; a pour adds 10. Cure items areSlugRepellentandGardeningSprayMilkat10 + farming level. Vanilla's ownwormCheckglobal rolls worms on a dig (better bare-handed). A field's furrows are clutter; a planted crop refuses a build. - Traps:
TrapAnimalsis a plain appendable table (declaredor {});STrapGlobalObject.removeAnimalItemruns Food arithmetic on every caught item with no guard, so a catchable item must bebase:food+CantEat = true(vanilla's flax-sheaf shape, 96 blocks) with aHungerChangethat makes the size multiplier exactly 1.0; the size roll isZombRand(minSize, maxSize), min..max-1.
Odds and ends
- Translations: 42.20 reads JSON per category (
UI.json,Sandbox.json,ItemName.json,Tooltip.json,Recipes.json,Fluids.json,ContextMenu.json,IG_UI.json...), no BOM, and a key is only found in the file its category names — aContextMenu_*key inUI.jsonshows the raw key with no error.ItemName.jsonkeys are bareModule.Item,Recipes.jsonkeys bare recipe names. Fluid names have no txt category. Placeholders are%1..%4.UI_optionscreen_Noneand vanilla's recipe, species and profession strings are translated in all 29 locale trees; check before inventing a key. A machine translation loses its sense on SHORT LABELS first (a term inside a sentence keeps it), and a count cannot agree with a placeholder in a language with cases — use the colon form (Ill: %1). - Sound: clips are
file =, notevent =. Positional long audio isgetWorld():getFreeEmitter(x, y, z)+playSoundImpl(sound, srcObject)— a nil source crashes — and FMOD culls beyonddistanceMax, soisPlaying()false is indistinguishable from track end. Continuous attraction is periodicaddSound(...)at the source (campfires do it).testHelicopter()/endHelicopter()drive the vanilla flyover;getGameTime():setHelicopterDay()reschedules it. B42's recorded-media API is lua-readable (item:getMediaData():getTitleEN()). - Vehicles:
VehicleZoneDistributionis a shared-lua global; append.zone.vehicles["Base.X"] = { index = -1, spawnChance = N }, weights RELATIVE per zone; per-zonespawnRate,baseVehicleQuality,chanceToPartDamage.addVehicleDebug(type, dir, nil, square)spawns a parked vehicle from lua. Part animations are non-static model blocks withboneWeightinto the family FBX andBaseVehicle.playPartAnim. The canonical WorldEd stall zone names: bad, good, medium, sport, farm, junkyard, trafficjams(n/e/w), rtrafficjams(n/e/w), police, ranger, fire, mccoy, fossoil, postal, spiffo, radio, ambulance, burnt; a stall is a multiple of 4×3 tiles. - Teleporting: client
setX/Y/Z+setLastX/Y/Z+faceLocation(there is nosetLx); after a teleportgetCurrentSquare()is nil until the chunk streams; teleport on the NEXT TICK, never inside a timed action'sperform();UIManager.FadeOut(playerNum, seconds)/FadeInin the two-argument form, withsetFadeBeforeUI(int, bool). A coordinate is not a place to stand:IsoGridSquare.hasFloor()has a real zero-argument overload; gate every teleport on it, and derive arrival squares from the shipped floor set rather than a bounding box. - The map tools: TileZed, WorldEd and BuildingEd ship with the game on Steam (2x config, 128×256 cells); a maintained fork adds native 256-cell projects, a working Lua console and expanded tilesets. The official authoring pipeline is a painted BASE image, a VEG image and a 10×-smaller greyscale zombie spawn map, one pixel per tile, exact RGB keys, converted with BMP to TMX and compiled with Generate Lots; images must be a multiple of 256, not the guide's 300.
- Measuring: a checker that has never failed is an untested checker — negative-test every validator against a file you know is broken; a round-trip proves the codec, not the interpretation; a test that buries its subject where nothing is expected to change cannot prove the code reached it; a rule that merges can invent its own subject; and a suspiciously round total (every option dead, every walk wrong) is nearly always the checker.
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.