World
How to add custom biomes and their arenas, arena environment themes, campaigns, between-level events, and Village / Bartering Station scenes to Craftics.
Biomes
Biomes are the arenas players fight through. Each biome has a grid size, floor and obstacle blocks, an environment theme, an enemy pool, and a loot table. The active campaign determines the order biomes are played in. A biome's order value sequences biomes that are not placed by the active campaign and acts as a tiebreaker, so an addon biome with "order": 50 sorts after any built-in biome with a lower order and before any with a higher one.
Biomes can be added two ways: through a JSON datapack file (no code required) or programmatically through CrafticsAPI.registerBiome(). The JSON path is simpler for pure content; the code path is needed when you want to share a BiomeTemplate object built entirely in Java.
A biome works with the JSON alone: Craftics generates its arenas from the grid size and floor blocks. To give it arenas you built yourself, add schematics as described in Arenas for a biome.
Biome card art
The title screen and the level-select screen show a square cover image per biome. Craftics looks for it by biome id, in your own namespace:
| Biome id | Texture looked up | File in your jar |
|---|---|---|
mymod:cavern |
mymod:textures/gui/biomes/cavern.png |
assets/mymod/textures/gui/biomes/cavern.png |
plains (no namespace) |
craftics:textures/gui/biomes/plains.png |
Craftics' own art |
Your namespace rather than a shared folder under Craftics', so you ship art without coordinating a directory layout with anyone, and two addons that both name a biome route_1 cannot overwrite each other's cards.
Ship nothing and the screens draw a flat backdrop tinted with the biome's region color instead. That is a supported outcome, not a broken one - art is optional.
JSON Datapack
Place a JSON file at data/<namespace>/craftics/biomes/<biome_id>.json. Craftics discovers every file at that path automatically on resource reload. Use /reload to hot-reload biome JSON without restarting the server.
{
"id": "mymod:cavern",
"name": "Cavern",
"order": 20,
"levels": 5,
"grid": {
"base_width": 10,
"base_height": 10,
"width_growth": 1,
"height_growth": 1
},
"floor_blocks": ["minecraft:stone_bricks", "minecraft:mossy_stone_bricks"],
"obstacle_blocks": ["minecraft:cobblestone_wall"],
"obstacle_density": 0.1,
"obstacle_density_growth": 0.02,
"environment": "cave",
"night": true,
"enemies": {
"passive": [
{"type": "minecraft:bat", "weight": 3, "hp": 2, "attack": 0, "defense": 0, "range": 1}
],
"hostile": [
{"type": "minecraft:skeleton", "weight": 8, "hp": 10, "attack": 3, "defense": 0, "range": 3},
{"type": "minecraft:spider", "weight": 6, "hp": 8, "attack": 3, "defense": 0, "range": 1},
{"enemy": "mymod:cave_witch", "weight": 3}
],
"boss": {"type": "minecraft:warden", "hp": 50, "attack": 8, "defense": 4, "range": 2}
},
"loot": [
{"item": "minecraft:diamond", "weight": 5},
{"item": "minecraft:iron_ingot","weight": 15}
],
"enchantment_loot": [
{"enchantment": "minecraft:sharpness", "weight": 3}
]
}
Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique biome identifier, e.g. "mymod:cavern". Using the same id as a built-in biome overrides it. |
name | string | Yes | Display name shown in the HUD and level select. |
order | int | Yes | Sort position used to sequence biomes outside the active campaign, and as a tiebreaker. The active campaign drives the play order. Lower values sort earlier. |
levels | int | Yes | Total number of levels in this biome. The last level is the boss fight. |
grid.base_width | int | Yes | Arena grid width on level 1. When the biome has an arena schematic, its markers set the size instead. |
grid.base_height | int | Yes | Arena grid height on level 1. Same rule as the width. |
grid.width_growth | int | No | Columns added per level. Default 0. |
grid.height_growth | int | No | Rows added per level. Default 0. |
floor_blocks | string[] | Yes | Block ids used for the arena floor, drawn in a checkerboard pattern. |
obstacle_blocks | string[] | No | Block ids placed as obstacles. Default none. |
obstacle_density | float | No | Base probability (0.0 to 1.0) that a tile spawns an obstacle. Default 0. |
obstacle_density_growth | float | No | Additional obstacle probability added per level. Default 0. |
environment | string | No | Environment id for the arena's visual theme. Built-in values: plains, forest, desert, jungle, river, mountain, snowy, cave, nether_wastes, end, deep_dark. Addon environments are accepted here too. Default plains. |
night | bool | No | When true the arena is treated as nighttime, preventing undead from burning. Default false. |
enemies | object | Yes | Container for the enemy pools. Must be present even if all sub-fields use their defaults. |
enemies.passive | array | No | Weighted pool of passive mobs that populate the arena alongside hostile enemies. |
enemies.hostile | array | No | Weighted pool of hostile mobs that spawn each level. |
enemies.boss | object | No | Single enemy definition used for the boss level. |
loot | array | Yes | Weighted item table. Craftics rolls 1 to 3 items per level from this pool. |
enchantment_loot | array | No | Optional list of enchantment ids with weights. When present, enchantment book drops are restricted to these entries. |
Enemy entries. Each object in passive, hostile, and boss accepts two forms. The inline form uses "type" (a Minecraft entity id) plus stat fields: hp, attack, defense, range, and speed (defaults: 6 / 2 / 0 / 1 / 0). The reference form uses "enemy": "<id>" to point at a registered enemy template, with an optional "weight"; the template supplies all stats and AI. The attack field is the base attack value. Craftics scales it up with biome depth, so keep base values modest (roughly 1 to 10).
Java API
To register a biome from code, build a BiomeTemplate and pass it to CrafticsAPI.registerBiome(). You can also call CrafticsAPI.getTotalLevels() at any time to query the total level count across all registered biomes.
// In your CrafticsAddon.onCrafticsInit():
CrafticsAPI.registerBiome(myBiomeTemplate);
// Query total level count
int totalLevels = CrafticsAPI.getTotalLevels();
The Biome Atlas page
Every registered biome gets a guide book page automatically, under the Biome Atlas category. You do not register it, and there is nothing to keep in sync: the page is built from your BiomeTemplate at the moment the server sends it, so retuning a loot weight or swapping a mob into a pool changes the page with it.
Each page shows the biome's level count, whether it is fought at night, its environmental effect and the level that effect starts on, the boss, and both sources of loot. Item names and icons are resolved on the client, so an item your addon adds shows its own name and texture.
An Enemies & Drops page lists every creature in the biome by its mob head, with its role and its own drop table. A creature with no drop table still appears, saying so, rather than being left out.
There are two separate loot sources, and the atlas shows both. A biome's loot pool is rolled once when a level is cleared. Every enemy also has its own drop table, rolled once per kill, and that is where most of what a player carries out of a run actually comes from. A page listing only the biome pool describes a minority of a run's rewards, so the two are listed separately: Level Reward for the biome pool, Enemies & Drops for the per-mob tables.
Their weights are not comparable and are never merged into one percentage. A share of the biome pool and a share of a zombie's drop table answer different questions.
Pages are gated on discovery. Until the island has run the biome, its page exists but is empty, and the contents are not merely hidden on the client - the server does not send them. Discovery is recorded against the island (the party leader's record), so party members share one atlas.
| Shown on the page | Read from |
|---|---|
| Title, sidebar icon | display_name, matched on biome_id |
| Level count | level_count |
| Day or night | night |
| Environmental effect | biome_effect and its start level |
| Boss | boss |
| Enemies & Drops | hostile, passive, boss (each type once), each with its live per-mob drop table |
| Level Reward, Enchantments | loot, enchantment_loot, heaviest first |
The atlas is synced, not shipped. Biomes live in a server-side registry loaded from datapacks, so a multiplayer client has never seen a loot pool. The atlas arrives on join and again after every /reload, which is what lets a live datapack edit show up in the book without a reconnect.
Long pools are capped at 24 entries, keeping the heaviest. A pool longer than that is summarised rather than listed in full. Lists longer than a page split across as many pages as they need, so nothing is silently clipped.
Mob heads come from MobHeadTextures, which probes craftics:textures/mob_heads/<namespace>/<path>.png and then <namespace>:textures/mob_heads/<path>.png. Ship either and your mobs get real portraits; ship neither and they fall back to a coloured swatch with the creature's initial.
Environments
An environment defines the visual theme of an arena: which block covers the floor, which block forms the border light-posts, which block sits on top of each post as a light source, and which built-in decoration style is used for flavor obstacles. Biomes select an environment with their "environment" field.
Craftics ships built-in environments for plains, forest, desert, jungle, river, mountain, snowy, cave, nether_wastes, end, and deep_dark. Addons can register new ones via a JSON datapack or the Java API.
JSON Datapack
Place a JSON file at data/<namespace>/craftics/environments/<env_id>.json. Only id is required; the block fields fall back to plains defaults (grass block floor, oak fence posts, lantern lights) when omitted.
{
"id": "mymod:aether",
"floor_block": "minecraft:quartz_block",
"post_block": "minecraft:quartz_pillar",
"light_block": "minecraft:sea_lantern",
"decor_style": "forest"
}
Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique environment id, e.g. "mymod:aether". Referenced by biomes with their "environment" field. |
floor_block | string | No | Block id used for normal arena floor tiles. Default minecraft:grass_block. |
post_block | string | No | Block id used for the arena border light-posts. Default minecraft:oak_fence. |
light_block | string | No | Block id placed on top of each light-post. Default minecraft:lantern. |
decor_style | string | No | Flavor-obstacle style. Pass a built-in environment id (e.g. "forest") to reuse its obstacles. Defaults to this environment's own id (no flavor obstacles). |
Java API
Use EnvironmentDef.builder(id) for a fluent build, then pass the result to CrafticsAPI.registerEnvironment(). You can verify registration with CrafticsAPI.hasEnvironmentStyle(id).
import com.crackedgames.craftics.api.EnvironmentDef;
import net.minecraft.block.Blocks;
// In your CrafticsAddon.onCrafticsInit():
CrafticsAPI.registerEnvironment(
EnvironmentDef.builder("mymod:aether")
.floorBlock(Blocks.QUARTZ_BLOCK)
.postBlock(Blocks.QUARTZ_PILLAR)
.lightBlock(Blocks.SEA_LANTERN)
.decorStyle("forest")
.build()
);
// Check registration
boolean exists = CrafticsAPI.hasEnvironmentStyle("mymod:aether");
EnvironmentDef builder methods
| Method | Default | Description |
|---|---|---|
floorBlock(Block) | Blocks.GRASS_BLOCK | Block used for normal arena floor tiles. |
postBlock(Block) | Blocks.OAK_FENCE | Block used for arena border light-posts. |
lightBlock(Block) | Blocks.LANTERN | Block placed atop each light-post. |
decorStyle(String) | This environment's own id | Flavor-obstacle style. Pass a built-in environment id to reuse its obstacle decorations. |
Arenas for a biome
The biome JSON says what is fought. A schematic says where. Every level is played in an arena, which comes from a schematic when the biome has one and is generated from the JSON when it does not. Building a schematic, placing its marker blocks and testing it are covered in Custom Arena Maps. This section is how the two files find each other.
Where a biome's arenas live
The folder is worked out from the biome's id. A built-in biome has no namespace and lives under Craftics' own. A biome you add lives under yours, so two addons that both name a biome cavern cannot overwrite each other.
| Biome id | Arena folder in a data pack or jar |
|---|---|
plains (built in, no namespace) | data/craftics/arenas/plains/ |
mymod:cavern | data/mymod/arenas/cavern/ |
Inside the folder go 1.schem to 10.schem, numbered from 1 with no gaps, and boss.schem. A biome with "levels": 5 plays four ordinary levels and then the boss. The ordinary levels cycle through the numbered files and the boss uses boss.schem. With no boss.schem, the boss is fought in one of the numbered arenas.
data/
mymod/
craftics/
biomes/
cavern.json "id": "mymod:cavern"
arenas/
cavern/
1.schem
2.schem
boss.schem
While you are building, the same files can sit loose in the game folder instead, at craftics_arenas/mymod/cavern/.
Addon biomes need Craftics 0.5.0 or later. Before that the arena lookup did not understand a namespaced id, and a biome that used one failed to build its arena.
What each file decides
| With a schematic | Without one | |
|---|---|---|
| Shape and size of the arena | The marker blocks in the schematic. | grid.base_width and grid.base_height, plus the growth per level. |
| Floor | Numbered arenas are repainted with floor_blocks. A boss room keeps the floor you built. | floor_blocks, in a checkerboard. |
| Obstacles | Numbered arenas get obstacle_blocks scattered by obstacle_density. A boss room keeps the obstacles you built. | obstacle_blocks, scattered by obstacle_density. |
| Lamp posts and decoration | The biome's environment, either way. | |
| Enemies, boss and loot | The biome JSON, either way. | |
A biome with no schematics at all is complete as it stands. Add them when you want a place that looks like somewhere.
Replacing a built-in biome's arenas
No JSON is needed for this. Use the built-in folder name: a data pack holding data/craftics/arenas/desert/1.schem replaces Desert arena 1. Custom Arena Maps lists the folder names and the rules for loose files, and the Example_Arena_Addon folder in the repository is a data pack that does exactly this for Plains.
Campaigns
A campaign is a full custom playthrough: an ordered list of regions, each holding an ordered list of biome nodes. The campaign is what actually drives the run, deciding which biomes the player fights through and in what order. Craftics ships a built-in craftics:vanilla campaign with three regions (Overworld, Nether, End).
Campaigns use full-replace semantics. A registered campaign replaces the vanilla progression entirely, rather than appending to it. Only one campaign is active per world: the most-recently-registered non-vanilla campaign wins, and vanilla is the default when no addon campaign is registered. This makes a campaign the right tool for a total-conversion playthrough, not for adding a few biomes alongside the base game.
The last node of the last region is the final boss. Beating it completes the campaign and triggers New Game+. This is count-agnostic, so a single-region campaign with one biome completes on that biome's boss, and a five-biome campaign completes on the fifth biome's boss.
JSON Datapack
Place a JSON file at data/<namespace>/craftics/campaigns/<id>.json. Craftics discovers every file at that path on resource reload. The canonical worked example is the built-in vanilla campaign at data/craftics/craftics/campaigns/vanilla.json.
{
"id": "mymod:descent",
"display_name": "The Descent",
"regions": [
{
"id": "surface",
"display_name": "Surface",
"color": "§a",
"icon": "",
"map_color": "FF6BBF59",
"nodes": [
{ "biome": "mymod:village" },
{ "biome": "mymod:wildwood", "label": "The Wildwood" }
]
}
],
"branch": {
"region": "surface",
"swap": ["mymod:village", "mymod:wildwood"]
}
}
Top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique campaign id, e.g. "mymod:descent". |
display_name | string | No | Campaign name shown to players. Defaults to the id. |
regions | array | Yes | Ordered, non-empty list of region objects. Regions are played in order; the last node of the last region is the final boss. |
branch | object | No | Optional branch definition (see below). Omit for a purely linear campaign. |
Region fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Region id, unique within the campaign. Referenced by branch.region. |
display_name | string | No | Region name shown on its tab. Defaults to the id. |
color | string | No | Chat color code for the region label/tab, e.g. "§a". Default "§f" (white). |
icon | string | No | Unicode glyph shown on the region tab, e.g. "". Default "?". |
map_color | string | No | ARGB hex string for the region tint, e.g. "FF6BBF59". An 8-digit value is AARRGGBB; a 6-digit value gets opaque alpha. Malformed values default to white (FFFFFFFF). |
nodes | array | Yes | Ordered, non-empty list of node objects played in order within the region. |
Node fields
| Field | Type | Required | Description |
|---|---|---|---|
biome | string | Yes | Biome id this node plays. Should match a biome registered from a craftics/biomes/ datapack or via the API. |
label | string | No | Optional override for the node's map label. Defaults to the biome's own display name. |
Branch fields
The optional branch lets a single region present a choice. When the player picks branch choice 1, the two named biomes (or two named segments) are swapped. The two biomes or segments must be contiguous in the named region; an invalid swap falls back to a linear region and is logged.
| Field | Type | Required | Description |
|---|---|---|---|
region | string | Yes | The id of the region this branch applies to. |
swap | string[] or array[] | Yes | Two contiguous biomes to swap, as two strings (["a", "b"]), or two contiguous segments, as two arrays of strings ([["a", "b", "c"], ["d", "e"]]). Branch choice 1 plays them in swapped order. |
Java API
Build a campaign with Campaign.builder(id) and its CampaignRegion.builder(id) regions, then pass the result to CrafticsAPI.registerCampaign(). Use CampaignBranch.of(region, a, b) for the single-biome swap convenience, or construct a CampaignBranch directly with two lists for a segment swap.
import com.crackedgames.craftics.level.campaign.Campaign;
import com.crackedgames.craftics.level.campaign.CampaignRegion;
import com.crackedgames.craftics.level.campaign.CampaignBranch;
import java.util.List;
// In your CrafticsAddon.onCrafticsInit():
CrafticsAPI.registerCampaign(
Campaign.builder("mymod:descent")
.displayName("The Descent")
.region(
CampaignRegion.builder("surface")
.displayName("Surface")
.color("§a")
.icon("")
.mapColor(0xFF6BBF59)
.node("mymod:village")
.node("mymod:wildwood", "The Wildwood")
.build()
)
// Single-biome swap convenience
.branch(CampaignBranch.of("surface", "mymod:village", "mymod:wildwood"))
// Segment swap: new CampaignBranch("surface", List.of("a", "b"), List.of("c", "d"))
.build()
);
Events
Between-level events fire after a combat level completes. Craftics rolls through all registered events by probability and triggers one when the roll succeeds. Events are code-only for now: there is no JSON datapack loader for events. Register them through CrafticsAPI.registerEvent().
EventEntry fields
Construct an EventEntry record directly and pass it to CrafticsAPI.registerEvent().
import com.crackedgames.craftics.api.registry.EventEntry;
import com.crackedgames.craftics.api.EventHandler;
CrafticsAPI.registerEvent(new EventEntry(
"mymod:treasure_chest", // unique event id
"Treasure Chest", // display name shown in the event popup
0.15f, // probability per level (0.0 to 1.0)
2, // minBiomeOrdinal: earliest biome (0-indexed) where this can appear
true, // isChoiceEvent: true if the player picks from multiple options
myEventHandler // EventHandler implementation
));
| Field | Type | Description |
|---|---|---|
id | String | Unique event identifier, e.g. "mymod:treasure_chest". |
displayName | String | Name shown in the event popup. |
probability | float | Chance the event triggers after each level (0.0 to 1.0). |
minBiomeOrdinal | int | The earliest biome index (0 = first biome) where this event can appear. Use this to keep late-game events from appearing in early biomes. |
isChoiceEvent | boolean | When true, the event presents the player with a set of options to choose from. |
handler | EventHandler | The logic executed when the event fires. |
EventHandler interface
EventHandler is a functional interface. Its execute method receives the list of participating players, the server world, and the EventManager, which provides helpers for showing UI popups, giving items, applying effects, and managing event state.
@FunctionalInterface
public interface EventHandler {
void execute(List<ServerPlayerEntity> participants,
ServerWorld world,
EventManager eventManager);
}
Turning an event into a fight
An event does not have to end in rewards. It can replace the level that follows it, which is how a trainer battle, a gym, or an ambush is built: the event is the encounter, and the level it queues is the fight.
Set the level on the EventManager your handler was handed. Craftics reads it back after your handler returns and builds that level instead of the one it had queued.
CrafticsAPI.registerEvent(new EventEntry(
"mymod:trainer_battle",
"Trainer Battle",
0.15f,
1,
false, // forced: no accept/decline prompt, it just happens
(participants, world, eventManager) -> {
for (ServerPlayerEntity p : participants) {
p.sendMessage(Text.literal("§eA trainer blocks the road!"), false);
}
// The next "level" is the battle itself.
eventManager.setPendingNextLevel(buildTrainerLevel());
}
));
This works for forced and choice events alike. A forced event used to be able to hand out rewards and nothing else - it built the level it had queued before your handler ran, so a redirect was discarded and a forced event could never become a fight. Both paths now read the pending level back afterwards.
If your handler starts a fight itself rather than queuing one, leave the pending level alone. Craftics checks whether combat is already running and will not build a second arena on top of yours.
Forcing an event
During development or for scripted sequences you can force a specific event to trigger at the next between-level transition for a given player. Pass null to clear any forced event.
// Force the next event for a specific player
CrafticsAPI.forceNextEvent(player, "mymod:treasure_chest");
// Clear the forced event
CrafticsAPI.forceNextEvent(player, null);
Example: custom event
CrafticsAPI.registerEvent(new EventEntry(
"mymod:healing_spring",
"Healing Spring",
0.10f,
1, // available from biome index 1 onwards
false, // not a choice event, it just fires
(participants, world, eventManager) -> {
for (ServerPlayerEntity player : participants) {
player.heal(4.0f);
}
}
));
EventTemplates. A future EventTemplates class will provide pre-built patterns for common event types such as gamble, reward, ambush, and trader. For now implement EventHandler directly as shown above.
Village & Bartering Station scenes
The Village and Bartering Station are walk-around hub scenes the player teleports into from the level select. A scene is built from a schematic, like an arena, using three scene marker blocks: one that sets where the player appears, a pair that marks the clickable area of each merchant booth ("stand"), and one that places the booth's NPC. You build the layout in-world or in a schematic editor, drop the .schem in the scenes folder, and Craftics scans the markers when the scene loads.
Marker blocks are invisible in the finished scene. craftics:scene_spawn, craftics:stand_marker, and craftics:npc_marker are placement markers, not decoration. When the scene is scanned, each marker is replaced with the most common block touching it, so it blends into the surrounding floor and the player never sees a marker block (the same way arena corner markers disappear). Give yourself the blocks in creative with /give @s craftics:scene_spawn, /give @s craftics:stand_marker, and /give @s craftics:npc_marker.
The marker blocks
| Block | Count | Purpose |
|---|---|---|
craftics:scene_spawn | Exactly one | Where the player materializes when they enter the scene, and the direction the third-person camera faces. Place it looking toward the booths: the block stores the way you were facing when you placed it, and the camera aims along that facing. If a scene has no spawn marker the scanner reports it as an authoring error and the scene will not open. |
craftics:stand_marker | Two per booth | Corner markers, placed in pairs. The two markers mark the opposite corners of a rectangle, and clicking anywhere inside that rectangle counts as clicking the booth. Corner markers hold no data of their own; the booth is identified by the NPC marker inside the rectangle. |
craftics:npc_marker | One per booth, inside the corner rectangle | Sits inside the booth's two-corner rectangle and is the booth's identity: it places the NPC (its position and facing) and carries the occupant id. The player walks up one tile in front of the NPC, facing it. Every booth needs exactly one NPC marker inside its corners; that is also how the scanner knows which two corners form which booth. |
Building a scene, step by step
- Build the booth structures and walkways however you like, using any blocks. This is the visible scene.
- Place one
craftics:scene_spawnon the floor where the player should appear, facing the booths. - At each booth, place two
craftics:stand_markerblocks at opposite corners of the area you want to be clickable for that booth. - Place one
craftics:npc_markerinside that rectangle, where the NPC should stand, facing the player's approach. This both positions the NPC and ties the two corners together into one booth. - Select the whole build (including the markers) and export it as a Sponge
.schem(WorldEdit / Litematica / FAWE all produce this format, the same one arenas use). - Drop the file at
data/craftics/scenes/village.schemordata/craftics/scenes/barter_station.schemin a datapack, or override it on disk by placing it in acraftics_scenes/folder next to the server run directory. The disk copy wins if both exist, so you can iterate without rebuilding the jar.
How booths are paired. For each NPC marker, the scanner finds the tight pair of stand corners whose rectangle contains it (and no other corner). Those two corners plus the NPC marker form one booth, and the rectangle is its clickable area. If an NPC marker has no clean containing pair around it (or the layout is ambiguous), that booth is skipped with a warning in the log and the rest of the scene still loads.
The village is for villager merchants; the bartering station is for piglins. The two scenes are authored identically. The only difference is which occupant ids you give their NPC markers (villager professions vs. piglin barter categories) and which schematic file name you save under.
Assigning booth occupants
A booth's occupant decides which merchant stands there, and it lives on the booth's npc_marker. Occupants are not baked into the schematic: schematic export saves block states but not a marker's stored data, so an NPC marker loaded from a .schem comes in blank. Instead the scene assigns occupants when it is built, keyed by booth index (the deterministic order the scanner produces, sorted by the rectangle's corner). This keeps "where the booths are" (the schematic) separate from "who staffs them" (live progression), so a booth can show its merchant for one player and sit empty for another who has not met them yet.
An occupant id is one of:
| Occupant id | Meaning |
|---|---|
craftics:weaponsmith (a villager profession id) or craftics:warmonger (a piglin barter category id) | Dedicated booth. Hosts exactly that one merchant. The booth is empty until the player has met that merchant during a normal level. |
villager:addon | Overflow booth. Hosts every met villager merchant that does not have a dedicated booth of its own (i.e. addon/modded traders). Walking up shows a choice of which one to talk to. |
piglin:addon | Overflow booth for met piglin barter categories that do not have a dedicated booth. Use this on the bartering station scene. |
Testing a booth in-world. While iterating you can set an NPC marker's occupant directly with a command, then re-scan the live world (rather than re-exporting):
/data merge block ~ ~ ~ {Occupant:"craftics:weaponsmith"}
Target the npc_marker and confirm it stuck with /data get block ~ ~ ~ Occupant. This is for ad-hoc testing only; the real scene populates occupants by booth index when it builds.
Reusing a scene as an addon
Because the scenes are loaded from data/<namespace>/craftics/scenes/<name>.schem via datapack, an addon can ship its own village.schem / barter_station.schem to replace the built-in layout, and the booth occupants resolve from the merchant registries at build time. A modded trader or barter category that has been met automatically appears at the matching overflow booth (villager:addon / piglin:addon) with no schematic change required.