A declarative scene composition framework for Project Zomboid (Build 42), Single Player - part of the DREAM family.
Steam Workshop → [42SP] SceneBuilder
SceneBuilder lets modders spawn in-world scenes in a declarative fashion — corpses, containers, clutter, blood, and more — using a Lua DSL.
Scenes are conceptually similar to vanilla randomized stories (e.g. from RBBasic) or ItemStories (Steam Workshop → ItemStories B42).
Unlike those, SceneBuilder scenes are not automatically distributed into the world of Project Zomboid but are meant to be used by modders for any purpose.
This framework is low-level in the sense that it does not provide opinions on what should spawn in which environments, room types etc.
The shipped DSL can currently be extended with custom resolvers and spawn hooks.
local Scene = require("SceneBuilder/core")
Scene:begin(roomDef, { tag = "demo_lab" })
:deterministic(true)
:anchors(function(a)
a:name("AnywhereInRoom")
:where("any")
a:name("deskLike")
:where("tables_and_counters")
end)
:corpse(function(c)
c:outfit("Agent")
:onBody("Bag_ToolBag", "Screwdriver")
:dropNear("RemoteCraftedV1", "Speaker")
:blood({ bruising = 4, floor_splats = 20 })
:where("any", { anchor = "deskLike", anchor_proximity = 2 })
end)
:container("Bag_Schoolbag_Travel", function(b)
b:addTo("MoneyBundle", "Whiskey")
:where("tables_and_counters", { anchor = "AnywhereInRoom" })
end)
:scatter(function(s)
s:items("Notepad", "Pencil")
:maxPlacementSquares(3)
:where("any", { anchor = "AnywhereInRoom" })
end)
:spawn()- Build 42 only.
- Unfit for Multiplayer.
- Currently only works indoors with a RoomDef given.
- Z-height for placed items may not be visually correct on a number of tiles. For improved accuracy and visual realism, use the ItemStories B42 mod, which, combined with SceneBuilder’s included SpritesSurfaceDimensions Polyfill, provides good-enough results.
- Type hinting for scene definitions is incomplete
Assuming that you are in debug mode and your character stands in a room, paste this into your in-game console:
fulldemo = require("SceneBuilder/prefabs/demo_full")
fulldemo.makeForRoomDef(nil)| Term | Meaning |
|---|---|
| Scene | A one-shot composition combining multiple placers; committed via :spawn(). |
| Placer | Defines what to spawn (corpse, container, or scatter) and knows how to do it. |
| Resolver | Defines where to spawn ("any", "tables_and_counters", …). |
| Anchor | A reusable reference point helpful to group focus spawn positions |
SceneBuilder internally tracks which placer is currently active and when its definition is complete.
- A new scene begins with
Scene:begin(roomDef, opts). - Each call to a placer (e.g.
:corpse,:container,:scatter) activates that placer’s builder state until another placer begins or the scene is spawned.
- While a placer is active, you can chain setup calls (
:outfit,:items,:blood, …). - Every placer must define a valid
:wherebefore it can spawn. - Some placers may require specific setup (e.g.
:itemsfor:scatter).
- Starting a new placer implicitly commits the previous one (but doesn’t spawn it yet).
- You can interrupt scene construction at any time and continue later with the
same
Sceneobject. Just make sure the previous placer has been completed with:where(...).
- Use
:spawnNow()to immediately spawn all placers defined so far. - Use
:spawn()at the end of your builder chain to finalize and spawn everything.
Tip: Think of each placer as a “sub-scene.” It becomes part of the active scene once committed, and all are spawned together when
:spawn()is called.
Scenes are deterministic by default — same inputs yield the same layout, item selection etc.
Setting :deterministic(false) disables this.
SceneBuilder’s resolvers, placers, and fallbacks are deliberately tuned toward making placements happen whenever possible. The default behavior is to degrade gracefully — relaxing distance limits or falling back to simpler strategies — rather than fail outright. Authors who want stricter behavior can override this by registering custom resolvers, setting
fallback = nilorproximity_fallback = "fail"in their placer specs.
These are used by placers and anchors to restrict which "pool" of squares they should consider. Whether a placer or anchor works on the entire pool or picks a subset of squares depends on implementation details and sometimes settings. E.g. the "Scatter" placer tries to distribute among the return squares.
| Resolver name | Description |
|---|---|
any |
Free squares inside the room, including surfaces. |
freeOrMidair |
Walkable squares (not solid/tree; allows stairs). |
centroid |
Center-out pool based on room square centroid (good for L-shape). |
centroidFreeOrMidair |
Centroid pool filtered to walkable squares. |
tables_and_counters |
Valid surface squares with table or counter sprites. |
Fallbacks:
:where({
strategy = "tables_and_counters",
retries = 4,
fallback = { "any" }
})If the primary resolver fails, SceneBuilder retries with each fallback in order.
Resolvers are designed to be extensible — you can define and register your own to determine which squares qualify for placing under a given strategy name.
To explore existing examples, review the shipped resolvers in the mod’s resolvers/ directory.
To add your own resolver, register it from anywhere:
local Resolvers = require("SceneBuilder/resolvers")
Resolvers.register("my_custom_strategy", function(roomDef, place, state)
-- Return a list of IsoGridSquares that match your conditions.
-- For example: all tiles tagged as medical.
return matchingSquares
end)Once registered, your custom resolver can be used in :where or :anchor like so:
:container("Bag_Schoolbag_Travel", function(b)
b:addTo("Disinfectant", "Bandage")
:where("my_custom_strategy")
end)Strategies are global so consider namespacing and watch the log for name conflicts.
All placers begin with their specific call (current variants :corpse, :container, :scatter, :zombies).
:corpse(function(c)They share the same call to a resolver under the friendly alias :where which determines the squares that may qualify for the current placer block.
:where(strategyOrSpec, [opts])Spawn live zombies around a chosen square using addZombiesInOutfit(...).
Scene:begin(roomDef, { tag = "demo_zombies" })
:zombies(function(z)
z:count(6)
:outfit("Police") -- optional (nil = random)
:femaleChance(50) -- 0..100 (percent)
:where("freeOrMidair", { fallback = { "any" } })
end)
:spawn()strategyOrSpec: either a string resolver name ("any","tables_and_counters", etc.)
or a full table spec ({ strategy="any", retries=4, fallback={...} }).opts(optional): may includeanchor,anchor_proximity, andproximity_fallback.
Resolved items and containers automatically attempt to drop at the correct surface Z-height and also adjust to a plausible x,y within the square using the SpritesSurfaceDimensions polyfill.
A single corpse in random orientation.
:corpse(function(c)
c:outfit("Agent")
:onBody("Bag_ToolBag", "Screwdriver")
:dropNear("RemoteCraftedV1", "Speaker")
:blood({ bruising = 4, floor_splats = 20 })
:where("any", { anchor = "deskLike", anchor_proximity = 2 })
end)API
:outfit(name)– Optional (defaults to "Survivor").:onBody(itemType, ...)– Optional. Equip extra items on the corpse. Keep in mind "outfit" adds random vanilla items associated with it already. Accepts"ItemType"or{ "ItemType", count }.:dropNear(itemType, ...)– Optional. drop items on the floor nearby (currently same square as the body). Accepts"ItemType"or{ "ItemType", count }.:blood(opts)– Optional. add blood effects.:where(strategyOrSpec, [opts])– Mandatory. Choose resolver, anchor, proximity, fallbacks etc.
A single item that must be of type container.
Take this — it’s dangerous to go alone.
:container("Bag_Schoolbag_Travel", function(b)
b:addTo("MoneyBundle", "Whiskey")
:where("tables_and_counters", { anchor = "AnywhereInRoom" })
end)API
:addTo(itemType, ...)– Optional. Accepts"ItemType"or{ "ItemType", count }:where(strategyOrSpec, [opts])– Mandatory. See shared description above.
Drops multiple world inventory items.
:scatter(function(s)
s:items(
{ "ElectronicsScrap", 3 },
{ "AluminumFragments", 2 },
"Notepad",
"Pencil"
)
:maxItemNum(4)
:maxPlacementSquares(10)
:where("any", { anchor = "AnywhereInRoom" })
end)API
:items(...list)– Mandatory. List of unique items to spawn. Accepts"ItemType"or{ "ItemType", count }. May include same entries multiple times.:maxItemNum(n)– Optional. Limit unique entries spawned. Entries in the item-list are treated as "unique" even if they appear multiple times which can be used as a lazy man's "weighted distribution":maxPlacementSquares(n)– Optional. Limit the number of squares eligible for spawning.:where(strategyOrSpec, [opts])– Mandatory. See shared description above.
Setting maxItemNum to a value smaller than the number of unique items increases the observed variety.
Anchors are named spatial reference points that can be resolved once and reused across multiple placers.
They let scene elements line up spatially — e.g., a corpse near a desk, a bag on that desk, and notes around it.
:anchors(function(a)
a:name("deskLike")
:where("tables_and_counters")
end)
:corpse(function(c)
c:outfit("Agent")
:where("any", { anchor = "deskLike", anchor_proximity = 2 })
end)For each placer, SceneBuilder first gathers all candidate squares that match the strategy passed to :where (e.g., all desk or counter surfaces for "tables_and_counters").
If anchor and anchor_proximity are set, it then keeps only candidates within the given radius (Chebyshev/Chessboard distance) of the anchor’s square.
If none survive the proximity filter, a proximity fallback (scoped to the current :where ) decides what to do.
Configure via proximity_fallback in the same :where spec:
| Strategy | What it does | Effect |
|---|---|---|
widen-proximity |
Deterministically expand radius from r to r+1, r+2, …, r+6 (hard cap) |
Keeps the same strategy; tries bigger radii step-by-step until a candidate exists. |
ignore-proximity-keep-strategy |
Remove the radius constraint once; keep the same resolver strategy | Still uses e.g. "tables_and_counters", just without the proximity limit. |
fail |
Do not relax conditions | The current placer is skipped if no candidates are within proximity. |
Planned but missing:
ignore-strategy-keep-proximityandignore-proximity-and-strategy(equivalent toany).
Examples
:container("Bag_Schoolbag_Travel", function(b)
b:addTo("MoneyBundle")
:where({
strategy = "tables_and_counters",
anchor = "deskLike",
anchor_proximity = 2,
proximity_fallback = "widen-proximity",
})
end)Key points
- Proximity fallback is a per placer setting; it doesn’t affect the anchor or other placers.
- It runs after any resolver chain, including after potential resolver fallback (
fallback = { ... }) has yielded a pool of squares to choose from. - Two different fallbacks, for two different use cases!
Hook functions let you execute custom logic immediately before or after a placer spawns its objects. They’re useful for tagging, syncing, or other modifications.
Use them per placer via :preSpawn(fn) or :postSpawn(fn):
:scatter(function(s)
s:items("Notebook", "Pencil")
:postSpawn(function(_, created)
-- Briefly highlight the spawned items
for _, obj in ipairs(created) do
obj:setHighlighted(true)
obj:setHighlightColor(0.8, 0.8, 0.3, 1)
end
end)
:where("tables_and_counters")
end)Each hook gets called with two parameters:
---@param ctx table -- scene context (player, anchor, spec, etc.)
---@param created table -- list of spawned IsoWorldInventoryObject or InventoryItem
function myHook(ctx, created)
end| Param | Description |
|---|---|
ctx |
Scene context including the active player, resolved anchor, and the current spec.place info. |
created |
All objects produced by the placer; usually a mix of IsoWorldInventoryObject and InventoryItem. |
Tip: Hooks are just closures — build your own mini hook factories to reuse logic across prefabs.
The mod ships with demo prefabs to showcase and test the builder:
require("SceneBuilder/prefabs/demo_full").makeForRoomDef(nil)Included examples:
demo_full.lua– mixed anchor + corpse + container + scatterdemo_corpse.lua– corpse-focused scenedemo_on_tables.lua– surface/container placementdemo_scatter.lua– scatter placementdemo_proximity.lua– proximity based spawning around an anchor
Scene authors are encouraged to organize and name their own scenes however they prefer.
- Caching & async building (though no performance bottlenecks are presently observed).
- Additional inbuilt resolvers e.g. by doors, windows.
- Additional placers for live zombies, possibly vehicles.
- Support passing an IsoGridSquare instead of a named anchor.
- Find a way to suppress ItemStories automatic world spawning for those who want to include that mod just for the SpriteSurfaceDimensions.present.
- Improve and clean up type hinting
- Support for outdoor scenes.
Pull requests are welcome — preferably crafted with a survivor’s sense of caution. If you have improvements, new resolvers, placement tweaks, automatic tests (one can dream eh), feel free to open a PR.
Please:
- Follow the StyLua default style already used (indentation, inline comments, lowercase function names).
- If you can, include a short comment or example prefab showing how your addition works.
- Keep debug printouts clear and colon-free (
[SceneBuilder]prefix recommended).
If you discover bugs or broken behavior, open an issue instead of silently suffering. Suggestions, balance opinions, and weird edge-case reports are all welcome — just keep it constructive.
Parts of this project’s code, documentation, and README were drafted or refined with the assistance of AI tools (including OpenAI’s ChatGPT). All code and text have been reviewed and edited by a human before inclusion.
AI assistance was used for:
- Grammar and style editing of documentation
- Formatting and type hinting improvements
- Generating examples or scaffolding code under developer supervision
No game assets, proprietary content, or copyrighted material from Project Zomboid or The Indie Stone were ever generated, reproduced, or distributed using AI tools.
AI may have helped write some lines, but the bloody rags are human-made.
SceneBuilder is provided entirely AS-IS — still experimental and largely untested in live worlds. There are no guarantees, no stable releases, and not even a notion of versioning at this point. It may break tomorrow, corrupt your save, or be abandoned without notice.
By using or modifying this code, you accept that:
- You do so at your own risk.
- The authors take no responsibility for any harm, loss, or unintended side effects to your game world, saves, or mods.
- There is no warranty, express or implied, of fitness for survival — much like Knox County itself.
- This project is an independent fan-made modification for Project Zomboid and is not affiliated with, endorsed by, or approved by The Indie Stone Ltd.
- All rights, trademarks, and assets related to Project Zomboid remain the property of The Indie Stone Ltd.
- Licensed under the MIT License (See LICENSE file for details)
If it breaks, panic quietly, eat some beans, and try again.