Skip to content

How do registries work

A registry is Steel’s source of truth for a category of named game data: blocks, items, entity types, biomes, chat types, banner patterns, fluids, and many other vanilla systems. It stores the definitions for those entries, not the runtime instances. For example, the block registry stores the definition of the stone block, but not every stone block placed in a chunk.

Registries matter because Minecraft protocols and game logic need stable IDs, names, and shared definitions. Steel uses registries to map an Identifier such as minecraft:stone to a numeric ID, expose typed Rust references, group entries with tags, and sync the registries that the vanilla client needs during login. Most vanilla entries are generated from extracted JSON or datapack data instead of being hand-written.

This guide uses these terms consistently:

  • Registry entry: one definition stored in a registry, such as a block, item, entity type, biome, or banner pattern.
  • Entry reference type: a registry-specific type alias for a static reference to an entry, such as BannerPatternRef = &'static BannerPattern.
  • Identifier: the stable namespaced key for an entry, such as minecraft:stone.
  • Numeric ID: the usize assigned when an entry is registered. Registries use it for fast lookup and protocol data.
  • Tag: a named group of entry identifiers, such as minecraft:fence_gates.

At a high level, the data flows through the following stages:

build_assets/*.json
│ (build script)
src/generated/*.rs ── static entries + register fn
│ (server start)
Registry::new_vanilla() ── fills each registry
registry.freeze() ── no further modification
REGISTRY.init(registry) ── exposes the frozen registry globally
RegistryCache::new() ── builds registry + tag packets
Send cached packets ── during login configuration

All registries are in the cargo package steel-registry, which holds the code to generate, initialize, access, and freeze registry data.

There are three useful categories to keep in mind:

  • Simple registries map an entry key to a numeric ID and back.
  • Tagged registries do the same thing, but also group entries under tag identifiers such as minecraft:fence_gates.
  • Complex registries have extra domain-specific behavior. Blocks and items are the main examples, because their registry entries connect to states, components, behavior, and other systems.

This guide focuses on simple and tagged registries. After reading it, you should also have enough context to follow the more complex registries. For a concrete walkthrough of blocks and items, see Block/Item registration.

This gives a short overview of the important paths in the steel-registry package. They are listed in the same order as the build flow: source data, build scripts, handwritten source, then generated source.

This folder contains only JSON and NBT data, which are extracted via the extractor or from the Minecraft jar. The builtin_datapacks folder is where the data from the Minecraft jar goes, which is only required for upgrading Minecraft — you can find the guide here. All JSON files in this directory are extracted from vanilla through the SteelExtractor.

This directory contains the build files, which convert the JSON files to Rust code to load the registries. Most registries have their own build file — for example, the chicken variants registry has the build file chicken_variants. Because this guide only focuses on how the registries work, it does not cover the build scripts.

This is where all registries are saved, and the primary focus of this guide.

This contains all Rust files generated by the build scripts and should not be edited manually.

Use this as the short checklist for how registry data moves through Steel:

  1. The build script checks whether JSON or datapack source files changed.
  2. The build script regenerates Rust files in steel-registry/src/generated.
  3. Registry::new_vanilla creates empty registries and registers all generated Steel data.
  4. Plugin and mod registration will happen before freeze once that system is ready.
  5. Registry::freeze locks the registries so no later code can mutate IDs or tags.
  6. The login flow syncs the registries and tags that the Minecraft client needs.

The registry file declares the entry type that the registry contains, along with a public entry reference type for that registry. Here is a real example from steel-registry/src/banner_pattern.rs:

pub struct BannerPattern {
pub key: Identifier,
pub asset_id: Identifier,
pub translation_key: &'static str,
}
pub type BannerPatternRef = &'static BannerPattern;

The entry reference type (BannerPatternRef) is what code passes around when it needs to reference a banner pattern entry. This also hints at how data is stored: every registry entry points to static data. In Steel, most vanilla statics live in steel-registry/src/generated.

The generated file steel-registry/src/generated/vanilla_banner_patterns.rs contains entries like this:

pub static RHOMBUS: BannerPattern = BannerPattern {
key: Identifier::vanilla_static("rhombus"),
asset_id: Identifier {
namespace: Cow::Borrowed("minecraft"),
path: Cow::Borrowed("rhombus"),
},
translation_key: "block.minecraft.banner.rhombus",
};

The generated register function then inserts entry references into the registry:

pub fn register_banner_patterns(registry: &mut BannerPatternRegistry) {
registry.register(&RHOMBUS);
}

That means registry entries are globally unique static values. If two references point to the same registry entry, they point to the same static memory. Pointer equality is possible for registry references, but normal code should still prefer explicit equality implementations so == has the expected meaning.

Each registry stores entries by numeric ID and by identifier. Tagged registries also store tag membership. The real banner pattern registry looks like this in steel-registry/src/banner_pattern.rs:

pub struct BannerPatternRegistry {
banner_patterns_by_id: Vec<BannerPatternRef>,
banner_patterns_by_key: FxHashMap<Identifier, usize>,
tags: FxHashMap<Identifier, Vec<Identifier>>,
allows_registering: bool,
}

The macros at the bottom of the same file connect that storage to the shared registry traits:

crate::impl_standard_methods!(
BannerPatternRegistry,
BannerPatternRef,
banner_patterns_by_id,
banner_patterns_by_key,
allows_registering
);
crate::impl_registry!(
BannerPatternRegistry,
BannerPattern,
banner_patterns_by_id,
banner_patterns_by_key,
banner_patterns
);
crate::impl_tagged_registry!(
BannerPatternRegistry,
banner_patterns_by_key,
"banner pattern"
);

In steel-registry/src/lib.rs, a struct named Registry holds all of Steel’s registries. It is exposed through the REGISTRY static in the steel-registry crate. More of the macros will be described later in this guide.

In steel-registry/src/lib.rs, the Registry struct has a function called new_vanilla which fills all registries. For banner patterns, it calls the generated registration functions:

vanilla_banner_patterns::register_banner_patterns(&mut registry.banner_patterns);
vanilla_banner_pattern_tags::register_banner_pattern_tags(&mut registry.banner_patterns);

This is still a TODO and currently a work in progress.

Registry has a method named freeze which validates cross-registry references and then freezes every individual registry. After that, registration panics instead of modifying the registry:

pub fn freeze(&mut self) {
self.validate_references();
self.attributes.freeze();
self.blocks.freeze();
self.items.freeze();
self.banner_patterns.freeze();
// ...
}

The Minecraft client requires certain registries to be synced; this is handled in steel-core/src/server/registry_cache.rs. First, the registry entries are synced in the function build_registry_packets via the macro add_registry. This macro requires that the registry implements the trait ToNbtTag. The important part is that the registry needs to implement the trait as a reference, like in the banner pattern registry: impl ToNbtTag for &BannerPattern.

The real sync list in steel-core/src/server/registry_cache.rs includes banner patterns like this:

add_registry!(BANNER_PATTERN_REGISTRY, banner_patterns);

After syncing the registries, the tags are sent to the client; this is done in the same file in the function build_tags_packet. This is again done via a macro named add_tags. To use that macro, the registry needs to have tags correctly implemented:

add_tags!(BANNER_PATTERN_REGISTRY, banner_patterns);

Both syncs are prepared after the registry is built and frozen at boot, then sent during login. The vanilla client supports the synced entries, so server-side modding is currently possible with Steel.

This section covers the common read patterns you’ll use when working with Steel’s registries.

The registries are accessed via REGISTRY, but this needs to be imported first:

use steel_registry::{RegistryEntry, REGISTRY, RegistryExt};

To better illustrate the concept, both the long and short solutions will be shown, but please USE the short solution!

This is the long version, which more directly demonstrates how to use the registry in general.

REGISTRY.chat_types.id_from_key(vanilla_chat_types::CHAT.key()).unwrap_or(0);

To explain this example: first, the target registry is selected — here it is chat_types — and then the id is retrieved from the key. The key is an identifier consisting of a namespace and a path. The namespace defaults to minecraft, and the path is, for example, stone.

The key is extracted from the definition of CHAT, where you find the key() function that gives you the identifier of that entry. The return value is an Option, so when nothing is in the registry for that identifier it returns None.

You may have already noticed the id() function on the entry, which performs the long version for you. So the same functionality can be achieved like this:

let registry_id = vanilla_chat_types::CHAT.id() as i32;

It will panic if this entry is not registered! If you need a non-panicking alternative, use try_id() — it is generated by impl_registry_entry and returns an Option<usize>.

The example here comes from the player (steel-core/src/player/mod.rs), in the handle_chat method.

For this, the Steel registries provide two functions: by_id and by_key. Both return an Option. The ID is a usize, which you can get via the id function or id_from_key — more information can be found here. The key can be accessed via the key() function on the entry.

First, the registry needs to be a tagged registry, which gives you access to many more functions. The function that is important for this task is is_in_tag().

Here is an example:

let block = state.get_block();
REGISTRY.blocks.is_in_tag(block, &vanilla_block_tags::FIRE_TAG)

This example checks whether a block is in the FIRE_TAG. The first parameter is the entry you want to check, and the second parameter is the tag to check against.

Another example is checking whether the neighbor block is a specific block. Instead of checking for each wood variant of the fence gate, you can use the tag, and all wood variants are included in that check.

if REGISTRY.blocks.is_in_tag(neighbor_block, &FENCE_GATES_TAG){
// The neighbor block is a fence gate of any specific wood type
}
else
{
// The neighbor block is not a fence gate
}

The macros are:

  • impl_registry_entry
  • impl_registry_ext
  • impl_registry
  • impl_standard_methods
  • impl_tagged_registry

You can also find some information here

Only two functions will be generated:

  • key
  • try_id

from the RegistryEntry trait. More information about that can be found here

This implements the RegistryExt trait with all functions:

  • freeze
  • by_id
  • by_key
  • id_from_key
  • len
  • is_empty

More information about each function can be found here

This macro only implements the macros impl_registry_ext and impl_registry_entry.

This macro generates the code for the functions:

  • register
  • iter
  • default from the Default trait

This macro can be used if no special logic is needed for the registration. The iter function only enumerates over the id field and the default function uses the new function of the registry.

Implements the TaggedRegistryExt trait and all its functions. The TaggedRegistryExt requires the RegistryExt, so if this macro is used, the macro impl_registry_ext should also be considered or written by hand! More information about the TaggedRegistryExt trait can be found here

Registries come in different versions; some need more logic, like the block registry, but this guide only gives you a basic understanding of how to write a registry.

DISCLAIMER: importing of types is not covered!

Before you start, here’s a checklist of files you will touch:

  • Create steel-registry/src/<your_registry>.rs — the registry itself and its entry type
  • Edit steel-registry/src/lib.rs — add the field to Registry, wire it into new_empty and freeze, and add the registry identifier constant
  • Create steel-registry/build/<your_registry>.rs — the build script (covered in the next section)
  • Edit steel-registry/build/build.rs — register the build function

For our example, the registry we will develop together will store beer types (guide written by a Bavarian person). So first, create a file in steel-registry/src.

In the beginning, we define our struct with all the data we want to store

#[derive(Debug)]
pub struct BeerType {
pub key: Identifier,
pub beer_type: &'static str,
pub min_l: u32, //minimal liter of drink size
pub max_l: u32, //maximum liter of drink size
}

The only relevant field here is key; it is the entry identifier. All other fields are dummies and not relevant from now on!

It is always recommended to implement ToNbtTag for the entry reference, because that is needed for the sync. This can look like this:

impl ToNbtTag for &BeerType {
fn to_nbt_tag(self) -> NbtTag {
use simdnbt::owned::{NbtCompound, NbtTag};
let mut compound = NbtCompound::new();
let beer_type = self.beer_type.to_string();
compound.insert("beer_type", beer_type.as_str());
let min_l = self.min_l.to_string();
compound.insert("min_l", min_l.as_str());
let max_l = self.max_l.to_string();
compound.insert("max_l", max_l.as_str());
NbtTag::Compound(compound)
}
}

Now we need to define the entry reference type, which is a static reference to the entry. More information about that is above!

pub type BeerTypeRef = &'static BeerType;

Once done, the prerequisites for the type are finished and we can start with the registry itself.

For the registry, three fields are required: one field to store entries by numeric ID (beer_type_by_id); one field to connect an entry Identifier to its numeric ID (beer_type_by_key); and the last field, to make the registry freezable (allows_registering). This will look like this:

pub struct BeerTypeRegistry {
beer_type_by_id: Vec<BeerTypeRef>,
beer_type_by_key: FxHashMap<Identifier, usize>,
allows_registering: bool,
}

No worries, we are already halfway through! The next step is to define the new function of the registry, which will look like this:

impl BeerTypeRegistry {
#[must_use]
pub fn new() -> Self {
Self {
beer_type_by_id: Vec::new(),
beer_type_by_key: FxHashMap::default(),
allows_registering: true,
}
}
}

Before we finish this, we need to add our registry to some other places. In the file steel-registry/src/lib.rs there is the struct Registry.

We add our registry to it:

pub struct Registry {
pub attributes: AttributeRegistry,
pub blocks: BlockRegistry,
pub items: ItemRegistry,
pub data_components: DataComponentRegistry,
pub beer_types: BeerTypeRegistry,
...
}

Next, wire the registry into the two relevant methods on Registry: new_empty (which constructs every registry) and freeze (which locks them after registration).

Add it to the new_empty function:

#[must_use]
pub fn new_empty() -> Self {
Self {
attributes: AttributeRegistry::new(),
blocks: BlockRegistry::new(),
data_components: DataComponentRegistry::new(),
entity_data_serializers: EntityDataSerializerRegistry::new(),
items: ItemRegistry::new(),
beer_types: BeerTypeRegistry::new(),
...
}
}

And to the freeze function:

pub fn freeze(&mut self) {
self.attributes.freeze();
self.blocks.freeze();
self.data_components.freeze();
self.entity_data_serializers.freeze();
self.items.freeze();
self.biomes.freeze();
self.beer_types.freeze();
...
}

As the last step, in the steel-registry/src/lib.rs file we need to add an identifier for our registry. This identifier is used to refer to the registry itself when syncing or referencing it from other code (for example, from packet/sync code):

pub const BEER_TYPE_REGISTRY: Identifier = Identifier::vanilla_static("beer_type");

Now we have finished the self-written code! Only macros are needed from here. You can find more information about what each macro does here. Switch back to your registry file.

So the first macro:

crate::impl_standard_methods!(
BeerTypeRegistry,
BeerTypeRef,
beer_type_by_id,
beer_type_by_key,
allows_registering
);

The first parameter is the registry we are currently writing; the second parameter is the defined type from earlier; then come the three fields of our registry, in the order: id, key, allow.

and the second macro:

crate::impl_registry!(
BeerTypeRegistry,
BeerType,
beer_type_by_id,
beer_type_by_key,
beer_types
);

Here the first four parameters are the same as before, but the last parameter is the field name of this registry in the Registry struct.

Now we are finished and have a working registry! But don’t be surprised that your registry will be empty — for that, the guide for build scripts was written.

This is much easier than writing a new registry!

It’s only three steps!

Add a tag field to the registry like this:

pub struct BeerTypeRegistry {
beer_type_by_id: Vec<BeerTypeRef>,
beer_type_by_key: FxHashMap<Identifier, usize>,
tags: FxHashMap<Identifier, Vec<Identifier>>,
allows_registering: bool,
}

Initialize it in the new function.

pub fn new() -> Self {
Self {
tags: FxHashMap::default(),
...
}
}

Now add this macro (more information for a deep dive into rust macros can be found here):

crate::impl_tagged_registry!(
BeerTypeRegistry,
beer_type_by_key,
"beer type"
);

The first parameter is the registry, the second one is the key field, and the last parameter is a string where you can provide information about which registry it is for error messages! This macro depends on the tags field in your registry; if you named it differently, you will need to write all the functions on your own!

Create your own build script for a registry

Section titled “Create your own build script for a registry”

Every registry build script is a little different, because every vanilla data file has its own shape. The safest way to add one is to start from an existing registry with similar data. The beer registry below stays as the tutorial example; steel-registry/build/banner_patterns.rs is the real SteelMC build script this example is modeled after.

The build script starts by defining the JSON shape it expects from the datapack file:

#[derive(Deserialize, Debug)]
pub struct BeerTypeJson {
beer_type: String,
min_l: u32,
max_l: u32,
}

Then it reads all JSON files from the vanilla datapack directory. The cargo:rerun-if-changed line tells Cargo when this generated file needs to be rebuilt:

pub(crate) fn build() -> TokenStream {
println!("cargo:rerun-if-changed=build_assets/builtin_datapacks/minecraft/beer_type/");
let beer_type_dir = "build_assets/builtin_datapacks/minecraft/beer_type";
let mut beer_types = Vec::new();
for entry in fs::read_dir(beer_type_dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
let beer_type_name = path.file_stem().unwrap().to_str().unwrap().to_string();
let content = fs::read_to_string(&path).unwrap();
let beer_type: BeerTypeJson = serde_json::from_str(&content)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", beer_type_name, e));
beer_types.push((beer_type_name, beer_type));
}
}
// Token generation continues below.
}

The same function then emits Rust code for each static entry and for the generated registration function:

let mut stream = TokenStream::new();
stream.extend(quote! {
use crate::beer_type::{BeerType, BeerTypeRegistry};
use steel_utils::Identifier;
});
let mut register_stream = TokenStream::new();
for (beer_type_name, beer_type) in &beer_types {
let beer_type_ident = Ident::new(
&beer_type_name.to_shouty_snake_case(),
Span::call_site(),
);
let beer_type_name_str = beer_type_name.clone();
let beer_type_kind = beer_type.beer_type.as_str();
let min_l = beer_type.min_l;
let max_l = beer_type.max_l;
let key = quote! { Identifier::vanilla_static(#beer_type_name_str) };
stream.extend(quote! {
pub static #beer_type_ident: BeerType = BeerType {
key: #key,
beer_type: #beer_type_kind,
min_l: #min_l,
max_l: #max_l,
};
});
register_stream.extend(quote! {
registry.register(&#beer_type_ident);
});
}
stream.extend(quote! {
pub fn register_beer_types(registry: &mut BeerTypeRegistry) {
#register_stream
}
});

After the build file exists, wire it into steel-registry/build/build.rs. The constant controls the generated filename in steel-registry/src/generated:

const BEER_TYPES: &str = "beer_types";
let vanilla_builds = [
(attributes::build(), ATTRIBUTES),
(blocks::build(), BLOCKS),
(block_tags::build(), BLOCK_TAGS),
(items::build(), ITEMS),
(item_tags::build(), ITEM_TAGS),
(beer_types::build(), BEER_TYPES),
// ...
];

Finally, expose the generated module and register it in Registry::new_vanilla in steel-registry/src/lib.rs:

#[expect(warnings)]
#[rustfmt::skip]
#[path = "generated/vanilla_beer_types.rs"]
pub mod vanilla_beer_types;
pub fn new_vanilla() -> Self {
let mut registry = Self::new_empty();
// Other vanilla registries are registered here too.
vanilla_beer_types::register_beer_types(&mut registry.beer_types);
// If BeerTypeRegistry has tags:
// vanilla_beer_type_tags::register_beer_type_tags(&mut registry.beer_types);
registry
}

For a new registry, replace the beer type names with your registry type, generated module name, source directory, and JSON struct. If the source data comes from the Steel extractor instead of builtin_datapacks, read from the matching file in steel-registry/build_assets; you can find more information about the Steel extractor here.