English
Content Registration
Content comes in two tracks: the code track (ContentRegistry, this page) and the data track (TOML content packs, at the end). Both share one pipeline: declarations are collected during startup and materialized into native content at the registry freeze point, after which they are indistinguishable from vanilla content.
The registry freezes
Minecraft's registries freeze early in startup; afterward nothing can be added or removed. OML materializes your declarations at that freeze point — after it (including at runtime) no new content can appear. Declare content inside declareContent.
declareContent
Make the @Mod class implement OMLContentProvider, and the loader calls it before the game's main logic starts:
kotlin
@Mod(id = "my_mod", name = "My Mod", version = "1.0.0")
class MyMod : OMLModInitializer, OMLContentProvider {
override fun declareContent(registry: ContentRegistry) {
// declare here
}
}The registry is already bound to the mod's namespace: ids in declarations carry no domain, and OML binds them under the mod id.
Blocks
kotlin
val block = registry.declareBlock("ruby_ore") {
destroyTime = 3.0f
explosionResistance = 6.0f
requiresCorrectToolForDrops = true
}| Property | Default | Meaning |
|---|---|---|
destroyTime | vanilla default | hardness (time to break) |
explosionResistance | follows destroyTime | blast resistance |
requiresCorrectToolForDrops | false | drops only with the correct tool |
Every block automatically registers its block item. The returned OMLBlock exposes platform (the native block object) once content registration completes — the escape hatch for version-specific behavior.
Behavior hooks
Blocks can carry declarative behavior hooks; their implementation is the version adapter's (it materializes its own Block subclass), and your handlers are plain lambdas. The vanilla behavior always still runs — a behavior block is a plain block plus hooks.
kotlin
registry.declareBlock("trap_floor") {
onStepOn { event ->
if (!event.isClient) {
// server side: apply damage, effects — via event.level / event.entity if needed
}
}
onHit { event ->
// a player just started breaking the block
}
}| Hook | Fires | Event |
|---|---|---|
onStepOn | every tick an entity stands on the block, both sides | OMLStepOnEvent — x/y/z, isClient, level, entity |
onHit | a player starts breaking the block | OMLBlockHitEvent — x/y/z, isClient, level, player |
level and entity/player are the raw version objects — the same escape hatch as platform. Gate gameplay effects on isClient: the hook fires on both sides.
Block entities: machines
blockEntity gives the block a server-side tick and a persistent data store — the pieces a machine is made of:
kotlin
registry.declareBlock("press") {
blockEntity {
tick { event ->
val progress = event.data.getInt("progress") + 1
event.data.putInt("progress", progress) // survives save/reload
}
}
}tickruns once per game tick while the chunk is loaded, server side only (the client never sees the event — a machine tick is a simulation concept).event.datais anOMLBlockData: primitive-typed key-value pairs (int/long/float/double/boolean/string) that persist with the world; writes mark the block entity for the next autosave.- A block may combine behavior hooks and a block entity freely.
World generation: ores
generateAsOre makes the block generate naturally as a vein. It materializes into datapack worldgen files merged into the target biomes — like any datapack ore, only newly generated chunks are affected:
kotlin
registry.declareBlock("ruby_ore") {
destroyTime = 3.0f
requiresCorrectToolForDrops = true
generateAsOre {
veinSize = 8 // blocks per vein
perChunk = 6 // placement attempts per chunk
minY = 16
maxY = 64 // trapezoid height distribution
biomes += listOf("minecraft:forest", "minecraft:taiga") // empty = every biome
}
}The vein replaces the stone family (minecraft:stone_ore_replaceables); an empty biomes list is safe outside the overworld for the same reason — netherrack and end stone do not match.
Items
kotlin
registry.declareItem("ruby_sword") {
maxDamage = 592
attackDamage = 7.0
attackSpeed = -2.4 // vanilla base 4.0 → effective 1.6
miningSpeed = 1.0f
toolDamagePerBlock = 2
minesAndDrops("minecraft:stone", 8.0f) // on stone: mining speed 8, drops loot
overrideSpeed("minecraft:dirt", 4.0f) // on dirt: mining speed 4, vanilla drops
deniesDrops("minecraft:bedrock") // never drops
}| Property | Default | Meaning |
|---|---|---|
maxDamage | none (unbreakable) | max durability; > 0 shows the durability bar |
attackDamage | none | main-hand attack damage modifier (vanilla fists: 1.0) |
attackSpeed | none | main-hand attack speed modifier (vanilla base: 4.0) |
miningSpeed | – | default mining speed when no rule matches (requires tool rules) |
toolDamagePerBlock | 1 | durability cost per broken block |
canDestroyBlocksInCreative | true | instant breaking in creative mode |
Tool rule semantics: minesAndDrops = matched blocks mine at the given speed and drop loot; overrideSpeed = speed only, vanilla drops; deniesDrops = vanilla speed, never drops. These map onto the native DataComponents.TOOL.
Recipes and drops
Crafting
kotlin
// shaped: 2x2 ruby from 4 ruby items (pattern rows reference the key map, ' ' = empty cell)
registry.declareShapedCrafting(
result = "my_mod:ruby_block",
pattern = listOf("RR", "RR"),
key = mapOf('R' to "my_mod:ruby"),
)
// shapeless: any arrangement of the ingredients
registry.declareShapelessCrafting(
result = "my_mod:ruby",
ingredients = listOf("my_mod:ruby_ore", "minecraft:stick"),
count = 2,
)Pattern rules: 1-3 rows of 1-3 cells, all rows the same width, every non-blank character must have a key entry — a malformed pattern throws at declaration time, before the pack ever loads. Bare ingredient ids are qualified with the mod's namespace. The datapack file id derives from the result (my_mod:ruby_block → my_mod_ruby_block); two recipes with the same result get a numeric suffix.
Smelting
kotlin
// smelting (furnace: SMELTING / BLASTING / SMOKING, default SMELTING)
registry.declareSmelting(
input = "minecraft:raw_iron",
result = "minecraft:iron_ingot",
experience = 0.7,
cookingTime = 200,
)
// drop override: breaking ruby_ore drops 1–3 ruby
registry.declareBlockDrop(block = "ruby_ore", drop = "my_mod:ruby", dropCountMin = 1, dropCountMax = 3)Recipes and loot tables materialize into datapack JSON served through OML's injected resource pack — the vanilla datapack reload path, server-authoritative and synced to clients.
The data track: TOML content packs
Content without code: a pack's declarations live in content.toml at the root of an .oml archive (a plain zip renamed — see the distribution section below for the layout). The archive name is the namespace, and the pack declares blocks, items and recipes:
toml
[block.ruby_ore]
destroy_time = 3.0
explosion_resistance = 6.0
requires_correct_tool = true
[item.ruby]
max_damage = 64
attack_damage = 2.0
# Tool rules are arrays of inline tables, one entry per block.
mines_and_drops = [{ block = "minecraft:stone", speed = 8.0 }]
override_speed = [{ block = "minecraft:dirt", speed = 4.0 }]
[crafting.ruby_block]
type = "shaped"
pattern = ["RR", "RR"]
key = { R = "ruby" }
[crafting.ruby_from_ore]
type = "shapeless"
count = 2
ingredients = ["ruby_ore", "minecraft:stick"]
[smelting.ruby]
input = "raw_ruby"
experience = 0.7
cooking_time = 100
# furnace = "blasting" # smelting (default) / blasting / smoking
[loot.ruby_ore]
drop = "raw_ruby"
drop_count_min = 1
drop_count_max = 3The fields map one-to-one onto the code API (destroy_time → destroyTime, …); mines_and_drops and override_speed entries carry block and speed, denies_drops entries carry block alone. A [crafting.<result-id>] section declares a crafting recipe whose result is that item: type selects shaped / shapeless; shaped needs pattern (rows) plus a key inline table mapping pattern characters to item ids; shapeless needs an ingredients list; count is optional (default 1). A [smelting.<result-id>] section declares a furnace recipe (input required, furnace selecting smelting / blasting / smoking, optional experience and cooking_time); a [loot.<block-id>] section declares that breaking the block drops drop instead of itself. The ore inline table on a block mirrors generateAsOre (vein_size, per_chunk, min_y, max_y, biomes). Validation errors (ragged patterns, unknown types, missing fields) fail the pack with the reason. TOML packs share the same collect → freeze-materialize pipeline as code mods, and asset injection treats the pack's namespace like a mod domain. A misspelled field is called out by name in the startup log — nothing is silently ignored. What the data track does not cover: behavior hooks and block entities are mod code and have no TOML form.
A loose .toml file dropped into mods/ is not a pack form and is refused with an explicit message: it cannot carry the pack's own textures, so its blocks would render with missing textures. Ship the pack as an .oml archive.
Distribution form: .oml archives
A content pack ships as a .oml archive (a plain zip renamed): content.toml at the root, plus the pack's own assets/ and data/:
MyPack.oml (zip)
├── content.toml
└── assets/
└── my_pack/
├── textures/block/ruby_ore.png
└── lang/en_us.jsonDrop it into mods/. The archive name is the namespace; the archive's textures and lang files are served through OML's injected resource pack.
Hot reload
Recipes declared through the content tracks ride vanilla's datapack reload:
- Textures / lang / models:
F3+Tre-scans the mod jars and.omlarchives — edited files are picked up on the spot. - Crafting / smelting recipes and loot: edit the pack, then run
/reload(orF3+T) — the loader re-reads the packs from disk and the datapack JSON is served fresh. Ore worldgen files reload the same way, but world generation itself only touches newly generated chunks. - Blocks / items themselves: not reloadable. The registries freeze early in startup; adding or changing a block or item requires a restart. This is a vanilla constraint, not an OML limitation.