Repository navigation
Level Metadata Subsystem
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:
- collision configuration
- spawn settings
- surface types
- asset instancing (props spawned from
INST_placeholders) - dialog and trigger zones
- 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.
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 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).
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 = Nonebase 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 |
load_level(level_folder) is the main entry point and runs the full level-initialization sequence:
- Unload the previous level (
unload_level()) and clear metadata (metadata.unload()) - Load
level_metadata.json— abort and returnFalseif this fails - Find and load the level model (or create a placeholder if none exists)
- Locate the
SpawnPointempty in the loaded model - Build collision geometry from
COL_meshes (load_collision) - Spawn instanced props from
INST_placeholders (load_instances) - 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 TrueNote: the model is loaded with
noCache=True, and paths are kept relative (usingas_posix()) because Panda3D's MinGW build does not resolve Windows-style absolute paths (C:\...) through its virtual file system.
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 = Noneunload_level() tears down the previous model and collision root before a new level loads.
Known limitation:
instance_rootandloaded_assetsare 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 differentassetstable, this cache should be taken into account or cleared manually.
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 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 = spawnOther 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).
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 nodeThis gives a visible debugging indicator during development instead of a hard failure.
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)_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 collidedCollisionNodeuntil it finds a parent whose name starts withCOL_and returns that name (e.g.COL_Road) — this is the string looked up incollision_surfaces.
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()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.
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,
)(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():
- Strips the Blender duplicate suffix (
INST_tree_oak.001→INST_tree_oak) and theINST_prefix to get the asset name (tree_oak). - Loads the model referenced by
assets.<name>.pathin metadata viaget_asset()(cached inself.loaded_assetsso repeated instances of the same prop don't reload the model). - Copies the model onto
instance_root, matching the placeholder's position and rotation. - Applies the
scalefrom the asset's metadata entry. - 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 modelIf 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 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
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 Falseload() 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.
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 |
def get_level(self):
return self._metadata.get("level", {})"level": {
"name": "Test Level",
"version": 1
}def get_collision(self):
return self._metadata.get("collision", {})"collision": {
"mask": 1,
"node_prefix": "COL_",
"debug": true,
"player_physics": true
}def get_spawn(self):
return self._metadata.get("spawn", {})"spawn": { "offset": [0, 0, 0] }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 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"def get_asset(self, asset_name):
return self._metadata.get("assets", {}).get(asset_name)See [Asset Instancing](#asset-instancing) above.
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
)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)@property
def data(self):
return self._metadataProvides direct access when needed, but bypasses the abstraction layer — most engine systems should use the dedicated getter functions instead.