Skip to content

Level Metadata Subsystem

mtt edited this page Sep 15, 2026 · 3 revisions

Overview

The Level Metadata Subsystem provides a data-driven layer between level assets and gameplay logic.

Instead of embedding gameplay behavior directly into models, textures, or engine code, levels provide a separate metadata file that describes what elements in the environment represent. The visual level and gameplay information are stored separately.

levels/
└── test_level/
    ├── level_model.glb
    └── level_metadata.json

level_model.glb contains the visual scene created in Blender. level_metadata.json contains gameplay-related information such as:

  1. collision configuration
  2. spawn settings
  3. surface types
  4. asset instancing (props spawned from INST_ placeholders)
  5. dialog and trigger zones
  6. object / object-type / zone metadata (schema defined, not yet consumed by gameplay code — see [Incomplete](#incomplete))

This subsystem allows the gameplay system to understand the environment without needing to inspect visual assets.

The subsystem follows common design principles:

1. Separation of Concerns

Visual assets and gameplay data should be independent. A Blender model describes how something looks, while metadata describes what it does.

2. Data-driven Gameplay

Gameplay systems should retrieve information from level data rather than relying on hardcoded object names or assumptions.

(Not)

if floor == "Road":
    play_stone_sound()

(Yes)

COL_Road -> metadata.json -> surface = stone

3. Level-design friendly

Level designers can easily modify level behavior by editing metadata rather than changing engine code.


Architecture Overview

Level Metadata Subsystem Workflow

Two classes make up the subsystem, both under engine/managers/:

Class File Responsibility
LevelManager level_manager.py Loads the level model, builds collision, spawns instanced props, resolves the spawn point
MetadataManager metadata_manager.py Loads level_metadata.json and exposes typed query functions to the rest of the engine

LevelManager owns a MetadataManager instance and consults it while building the level. Other gameplay systems (surface detection, dialog triggers, player physics toggles) read from level_manager.metadata directly rather than touching the raw JSON.

LevelManager

LevelManager is responsible for loading, managing, and preparing a playable level inside the engine. It acts as the bridge between external level resources (3D models, metadata files, and collision meshes) and the gameplay systems that need access to them (player controller, physics, audio, dialog).

Constructor

def __init__(self, base):
    self.base = base

    self.metadata = MetadataManager()

    self.current_level = None
    self.current_level_path = None

    self.spawn_point = None
    self.collision_root = None

    self.loaded_assets = {}
    self.instance_root = None

base is the Panda3D ShowBase application instance, giving access to model loading, the scene graph (render), and window management.

Variable Purpose
current_level Reference to the loaded 3D model (or the placeholder node if none was found)
current_level_path Folder containing the level's resources
spawn_point NodePath of the player's start location, or None if not found
collision_root Parent node containing generated collision geometry
loaded_assets Cache of instanced prop models, keyed by asset name (see [Asset Instancing](#asset-instancing))
instance_root Parent node containing spawned prop instances

Loading a Level

load_level(level_folder) is the main entry point and runs the full level-initialization sequence:

  1. Unload the previous level (unload_level()) and clear metadata (metadata.unload())
  2. Load level_metadata.json — abort and return False if this fails
  3. Find and load the level model (or create a placeholder if none exists)
  4. Locate the SpawnPoint empty in the loaded model
  5. Build collision geometry from COL_ meshes (load_collision)
  6. Spawn instanced props from INST_ placeholders (load_instances)
  7. Print level information (name/version) and return True
def load_level(self, level_folder):
    level_folder = Path(level_folder)

    self.unload_level()
    self.metadata.unload()

    metadata_file = level_folder / "level_metadata.json"

    if not self.metadata.load(metadata_file):
        return False

    model_file = self.find_level_model(level_folder)

    if model_file is not None:
        self.current_level = self.base.loader.loadModel(
            model_file.as_posix(), noCache=True
        )
        self.current_level.reparentTo(self.base.render)
        self.find_spawn_point()
    else:
        self.current_level = self.create_placeholder()

    self.current_level_path = level_folder

    self.load_collision(level_folder)
    self.load_instances(level_folder)

    return True

Note: the model is loaded with noCache=True, and paths are kept relative (using as_posix()) because Panda3D's MinGW build does not resolve Windows-style absolute paths (C:\...) through its virtual file system.

Clearing the Previous Level

def unload_level(self):
    if self.collision_root:
        self.collision_root.removeNode()
        self.collision_root = None

    if self.current_level:
        self.current_level.removeNode()
        self.current_level = None

    self.spawn_point = None

unload_level() tears down the previous model and collision root before a new level loads.

Known limitation: instance_root and loaded_assets are not cleared here. Instanced props and cached asset models currently persist across a level switch. If a workflow needs to reload a level with a different assets table, this cache should be taken into account or cleared manually.

Finding and Loading the Level Model

The manager supports multiple model formats, and the first existing file wins:

supported_models = ["level_model.glb", "level_model.gltf", "level_model.bam"]

This lets the asset pipeline change formats without modifying engine code — *.glb can be swapped for *.bam without touching gameplay code.

Spawn Point Detection

Spawn points are authored in Blender as an Empty named SpawnPoint. After export, find_spawn_point() searches the loaded scene graph:

def find_spawn_point(self):
    self.spawn_point = None

    spawn = self.current_level.find("**/SpawnPoint")

    if spawn.isEmpty():
        return

    self.spawn_point = spawn

Other systems read it via get_spawn_point(). The player system additionally applies a metadata-driven offset:

def get_spawn_offset(self):
    offset = self.metadata.get_spawn().get("offset", [0, 0, 1])
    return Vec3(*offset)
"spawn": {
    "offset": [0, 0, 0]
}

If no SpawnPoint is found, callers fall back to a hardcoded default position (see main.py).

Missing Model Handling

If none of the supported model formats exist in the level folder, the engine creates a simple placeholder instead of crashing:

def create_placeholder(self):
    text = TextNode("missing_model")
    text.setText("Missing level model")
    node = self.base.render.attachNewNode(text)
    node.setScale(2)
    node.setPos(0, 20, 5)
    return node

This gives a visible debugging indicator during development instead of a hard failure.


Collision System

One of LevelManager's main responsibilities is converting Blender-authored collision meshes into Panda3D collision geometry.

Collision meshes are named with a configurable prefix (COL_ by default) and are simplified geometry made specifically for physics — not gameplay-visible objects:

COL_Terrain
COL_Road
COL_Wall

The prefix and collide mask both come from metadata:

"collision": {
    "mask": 1,
    "node_prefix": "COL_",
    "debug": true,
    "player_physics": true
}
def load_collision(self, level_folder):
    settings = self.metadata.get_collision()
    mask = BitMask32.bit(settings.get("mask", 1))
    node_prefix = settings.get("node_prefix", "COL_")

    self.collision_root = self.base.render.attachNewNode("LevelCollision")

    if self._load_collision_meshes(node_prefix, mask):
        return

    fallback = settings.get("fallback")
    if fallback:
        self._load_box_collision(fallback, mask)

Converting Mesh Geometry into Collision

_load_collision_meshes finds every node matching **/{node_prefix}*. Because glTF import can produce either a single GeomNode or a parent containing several GeomNodes, the search descends into children (**/+GeomNode) and deduplicates by node identity so shared geometry isn't processed twice.

For each geometry source, Panda3D cannot use the Blender mesh directly as a physics object, so the manager reads its vertex data and decomposes every primitive into triangles, adding each as a CollisionPolygon:

collision_node = CollisionNode(f"Collision-{geom_path.getName()}")
collision_node.setIntoCollideMask(mask)
collider = geom_path.attachNewNode(collision_node)

for index in range(geom_node.getNumGeoms()):
    geom = geom_node.getGeom(index)
    reader = GeomVertexReader(geom.getVertexData(), "vertex")
    for primitive_index in range(geom.getNumPrimitives()):
        primitive = geom.getPrimitive(primitive_index).decompose()
        vertices = primitive.getVertexList()

        for vertex_index in range(0, len(vertices) - 2, 3):
            points = []
            for offset in range(3):
                reader.setRow(vertices[vertex_index + offset])
                points.append(Point3(reader.getData3f()))
            collision_node.addSolid(CollisionPolygon(*points))

The original COL_ mesh is hidden (or shown in wireframe, see below) and removed from the render path once its triangles have been extracted, since it should never be rendered as a visible object.

Surface name resolution: downstream systems (see [Surface Data](#surface-data)) resolve a footstep surface from the collision object's name. GroundDetector.find_collision_name() walks up from the collided CollisionNode until it finds a parent whose name starts with COL_ and returns that name (e.g. COL_Road) — this is the string looked up in collision_surfaces.

Debug Collision Mode

When collision.debug is true in metadata, the original mesh is shown in wireframe instead of hidden, so collision geometry can be inspected visually during development:

if debug_collision:
    collision_mesh.show()
    collision_mesh.setColor(1, 0, 0, 1)
    collision_mesh.setRenderModeWireframe()
    collision_mesh.setTwoSided(True)
else:
    collision_mesh.removeNode()

Collision Fallback

If no COL_ meshes are found in the model, the system can optionally generate a single box collider from metadata instead of leaving the level with no collision:

"collision": {
    "fallback": {
        "type": "box",
        "center": [0, 0, 0],
        "half_extents": [10, 10, 1]
    }
}
def _load_box_collision(self, settings, mask):
    if settings.get("type") != "box":
        return

    center = Point3(*settings["center"])
    half_extents = Point3(*settings["half_extents"])

    collision_node = CollisionNode("LevelCollisionFallback")
    collision_node.addSolid(CollisionBox(center, *half_extents))
    collision_node.setIntoCollideMask(mask)
    self.collision_root.attachNewNode(collision_node)

Currently only "type": "box" is supported; any other fallback type is ignored with a warning.

Player Physics Toggle

A separate collision.player_physics flag (default true) is read directly by main.py rather than by LevelManager, and controls whether the player's physics/pusher collision is enabled for the level:

self.player_physics_enabled = self.level_manager.metadata.get_collision().get(
    "player_physics",
    True,
)

Asset Instancing

(Not present in earlier revisions of this subsystem — added since the original write-up.)

Levels can place repeated props (trees, barriers, clutter, etc.) without duplicating full geometry in the level model. Placeholder empties named with an INST_ prefix are authored in Blender at the desired position/rotation, and the actual model is instanced onto them at load time from a separate asset file.

INST_tree_oak
INST_tree_oak.001   # Blender duplicate suffix, handled automatically
"assets": {
    "tree_oak": {
        "path": "assets/models/environment/tree_oak.glb",
        "scale": [0.5, 0.5, 0.5]
    }
}

load_instances(level_folder) scans the loaded model for every node whose name starts with INST_ and calls create_instance() for each:

def load_instances(self, level_folder):
    self.instance_root = self.base.render.attachNewNode("LevelInstances")

    all_nodes = self.current_level.findAllMatches("**")

    instances = [n for n in all_nodes if n.getName().startswith("INST_")]

    for instance in instances:
        self.create_instance(instance)

create_instance():

  1. Strips the Blender duplicate suffix (INST_tree_oak.001 → INST_tree_oak) and the INST_ prefix to get the asset name (tree_oak).
  2. Loads the model referenced by assets.<name>.path in metadata via get_asset() (cached in self.loaded_assets so repeated instances of the same prop don't reload the model).
  3. Copies the model onto instance_root, matching the placeholder's position and rotation.
  4. Applies the scale from the asset's metadata entry.
  5. Hides the original INST_ placeholder node.
def get_asset(self, asset_name):
    if asset_name in self.loaded_assets:
        return self.loaded_assets[asset_name]

    asset_info = self.metadata.get_asset(asset_name)
    if asset_info is None:
        return None

    model = self.base.loader.loadModel(asset_info["path"], noCache=True)
    model.reparentTo(self.base.render)
    model.hide()

    self.loaded_assets[asset_name] = model
    return model

If an INST_ node references an asset name with no matching entry in assets, the placeholder is left as-is and a warning is printed — the instance is simply skipped.


MetadataManager

MetadataManager is a lightweight subsystem responsible for handling level metadata independently from the 3D assets. LevelManager handles loading the physical level, collision geometry, and locating objects inside the scene graph; MetadataManager provides a centralized way for other gameplay systems to query information about the level without knowing the raw JSON structure.

Player walks on COL_GrassFloor
        |
        v
GroundDetector finds collision object, walks up to COL_ name
        |
        v
SurfaceDetector -> MetadataManager.get_collision_surface("COL_GrassFloor")
        |
        v
collision_surfaces: COL_GrassFloor -> "grass"
        |
        v
Audio system plays grass footsteps

Loading / Unloading Metadata

def load(self, metadata_path):
    metadata_path = Path(metadata_path)

    if not metadata_path.exists():
        return False

    try:
        with metadata_path.open("r", encoding="utf-8") as file:
            self._metadata = json.load(file)
        return True
    except json.JSONDecodeError as error:
        print("[MetadataManager] Invalid JSON:", error)
        return False

load() also now guards against malformed JSON: a json.JSONDecodeError is caught and logged rather than raised, and load() simply returns False so LevelManager.load_level() can fail gracefully instead of crashing.

def unload(self):
    self._metadata = {}

LevelManager calls unload() at the start of every load_level() call so metadata from a previous level never leaks into the next.

Metadata Query Functions

Instead of allowing other systems to access _metadata directly, the manager exposes dedicated getters. This keeps the rest of the engine independent from the exact JSON structure.

Function Metadata key Purpose
get_level() level General level info (name, version)
get_collision() collision Collision mask, node prefix, debug flag, fallback, player_physics
get_spawn() spawn Player spawn offset
get_surface(name) surface_types.<name> Footstep sound/volume for a surface type
get_collision_surface(name) collision_surfaces.<name>.surface Maps a COL_ object name to a surface type
get_asset(name) assets.<name> Model path + scale for an INST_ prop
get_dialog() dialog Whether dialog is enabled for the level
get_object(name) objects.<name> Per-object gameplay data (schema defined, see [Incomplete](#incomplete))
get_object_type(type) object_types.<type> Shared data for a class of object (schema defined, see [Incomplete](#incomplete))
get_zone(name) zones.<name> Zone/trigger metadata (schema defined, see [Incomplete](#incomplete))
get_node_metadata(node) — Resolves a Panda3D node's "metadata" tag through get_object()
data (property) — Raw dict access — bypasses the abstraction layer, use sparingly

Level Information

def get_level(self):
    return self._metadata.get("level", {})
"level": {
    "name": "Test Level",
    "version": 1
}

Collision Configuration

def get_collision(self):
    return self._metadata.get("collision", {})
"collision": {
    "mask": 1,
    "node_prefix": "COL_",
    "debug": true,
    "player_physics": true
}

Spawn Configuration

def get_spawn(self):
    return self._metadata.get("spawn", {})
"spawn": { "offset": [0, 0, 0] }

Surface Data

def get_surface(self, surface_name):
    return self._metadata.get("surface_types", {}).get(surface_name)
"surface_types": {
    "grass": { "footstep_sound": "grass", "footstep_volume": 0.6 }
}

Collision Surface Mapping

Collision meshes themselves should not contain gameplay logic. Instead, the collision object's name is mapped to a surface type:

def get_collision_surface(self, collision_name):
    return (
        self._metadata.get("collision_surfaces", {})
        .get(collision_name, {})
        .get("surface")
    )
"collision_surfaces": {
    "COL_Road": { "surface": "stone" }
}

SurfaceDetector.get_surface() is the consumer of this function:

def get_surface(self, player):
    collision_name = player.ground_detector.last_surface

    if collision_name is None:
        return "default"

    return self.metadata.get_collision_surface(collision_name) or "default"

Asset Data

def get_asset(self, asset_name):
    return self._metadata.get("assets", {}).get(asset_name)

See [Asset Instancing](#asset-instancing) above.

Dialog Configuration

def get_dialog(self):
    return self.data.get("dialog", {})
"dialog": { "enabled": true }

main.py reads this once during setup to decide whether to construct a DialogTrigger at all:

dialog_settings = self.level_manager.metadata.get_dialog()
self.dialog_enabled = dialog_settings.get("enabled", True)

if self.dialog_enabled:
    self.dialog_trigger = DialogTrigger(
        self.player, self.dialog_manager, self.level_manager.metadata
    )

Node Metadata Lookup

Connects a Panda3D scene node to metadata via a "metadata" tag set on the node (see engine/scenes/test_scene.py for an example — e.g. door.setTag("metadata", "old_door_01")):

def get_node_metadata(self, node):
    if not node.hasTag("metadata"):
        return None

    object_id = node.getTag("metadata")
    return self.get_object(object_id)

Accessing Raw Metadata

@property
def data(self):
    return self._metadata

Provides direct access when needed, but bypasses the abstraction layer — most engine systems should use the dedicated getter functions instead.