Skip to content

Maps (cook level geometry → server)

A Citadel map is a small binary file (.map, CMAP format) that carries one level’s static collision geometry in world space. The server loads every map in its maps_dir at startup and indexes it by file name; when a room is created, the room’s server-chosen map name resolves to a loaded map. That geometry is the input the server uses for authoritative collision and — in a later phase — its own baked navmesh, so server-driven networked actors (NPCs) can path around the real level.

Unreal editor: Tools → Citadel → Cook Map Data
→ writes MyLevel.map (world-space collision triangles)
→ drop into <server>/maps/ (config: runtime.maps_dir)
→ server loads it at startup, indexed as "MyLevel"
→ room's on_room_create → { map = "MyLevel" } resolves to it

The map name the server matches is the .map file’s stem (MyLevel.mapMyLevel), so it must equal the name your on_room_create hook returns.

For a top-down 2D game the server can load a finite, orthogonal .tmx file from maps_dir directly, or convert it before deployment:

Terminal window
citadel cook-tmx --input GlobalGrove.tmx --output maps/GlobalGrove.map

Mark a tileset tile with the Boolean property citadel_collision=true, or put rectangle/convex-polygon objects in an object layer named collision (a layer property of the same name is also accepted). TMX X/Y pixels become Citadel X/Z; walls are extruded upward, with one pixel equal to one centimetre by default. Set citadel_units_per_pixel on the map to change that scale. Isometric, infinite, rotated/flipped, and unsupported collision shapes are rejected rather than silently losing authoritative collision.

Use this recipe for a map that Citadel can validate and load without any custom exporter.

  1. In File → New → New Map, choose Orthogonal and leave Infinite disabled. Set the tile dimensions to the same size as the tileset you plan to render (for example 32 × 32). Save the map as GlobalGrove.tmx.
  2. Add the tileset as an external .tsx where possible. This keeps tile metadata (including collision properties) shared between every map that uses it.
  3. Pick one collision authoring method below. The object-layer method is usually the quickest for walls, cliffs and irregular blockers; the tile-property method is best for grid-aligned solid tiles.
  4. Select the map in Tiled’s Properties panel and add a float property named citadel_units_per_pixel when your world scale is not one centimetre per pixel. For example, set it to 0.01 when one Tiled pixel represents one centimetre in a metre-based game world.
  5. Run the cook command below during CI or before copying the map to the server. The command fails loudly for unsupported content, so a broken collision map is caught before it reaches players.

The Tiled Properties panel, where map and tile custom properties are edited.

Screenshot from the official Tiled Custom Properties documentation, used here as a visual reference for the editor UI.

Section titled “Option A: collision object layer (recommended)”

Create an Object Layer named exactly collision. Draw visible rectangles or convex polygons for walls and blockers. Citadel turns each shape into a vertical collision prism; it does not render the layer or send its geometry to clients.

You may use another layer name if you give that layer the Boolean property citadel_collision=true. Keep collision shapes axis-aligned: rotated objects, ellipses, points, polylines and concave polygons are rejected.

The Tiled Layers panel shows visibility and lock controls for a layer.

Screenshot from the official Tiled Layers documentation. Lock the collision layer once authored to avoid accidental edits.

Select each solid tile in the tileset and add the Boolean tile property citadel_collision=true. Every placement of that tile produces a collision prism. Do not flip collision tiles in a tile layer: horizontal, vertical and diagonal flips are deliberately rejected, because they can silently change authoritative geometry.

Tile collision objects embedded in a tileset are also supported when they are rectangles or convex polygons. This is useful when the blocking area occupies only part of a decorative tile.

TMX feature Citadel behaviour
Finite, orthogonal map Supported
External TSX tileset Supported; resolved relative to the TMX file
collision object layer or layer property Supported
Rectangles and convex polygons Supported
citadel_collision=true tile property Supported
Isometric or infinite map Rejected
Rotated/flipped collision content Rejected
Ellipse, point, polyline or concave polygon collision Rejected

Start with the minimal collision-only example. It contains one rectangle and one convex polygon, so it can be cooked without a tileset:

Terminal window
citadel cook-tmx \
--input citadel-collision-example.tmx \
--output maps/GlobalGrove.map

For production, prefer the cooked .map: it makes startup input deterministic and avoids parsing editor assets on the server. Direct .tmx loading is useful during iteration; copy either the .tmx (and its relative .tsx/image assets) or the cooked .map into runtime.maps_dir, then restart the server. The map filename stem must match the map returned by on_room_create.

The cook tools ship as editor-only tools (they are never compiled into a packaged client). Each engine re-bakes its own static scene geometry into the engine-agnostic CMAP format.

The tool is a menu action, not code. With the Citadel plugin enabled:

  1. Open the level you want to cook.
  2. Tools → Citadel → Cook Map Data.
  3. In the Save As… dialog, pick a location and name. The default file name is the level’s name (e.g. Lvl_ThirdPerson.map) — keep it matching the map your on_room_create returns.
  4. A notification reports the exported vertex/triangle counts and the output path.

The tool scans every static, collidable StaticMeshComponent in the level (including instanced meshes) and Unreal Landscape collision-heightfield tiles, transforms them to world space, and writes one combined triangle mesh. Landscape visibility holes are omitted and its alternate collision-cell diagonal is retained. The exporter reads collision rather than render topology, so Merge Actors remains an optional artist workflow rather than a requirement. Components with collision disabled are skipped; a level with no static collision reports “nothing to export”.

Copy the cooked file into the server’s maps directory — by default ./maps relative to the working directory (runtime.maps_dir). The directory is created on first run if absent.

<server working dir>/
citadel.toml
game/main.lua
maps/
Lvl_ThirdPerson.map ← the cooked file

At startup the server scans this directory, decodes each .map, and logs what it loaded:

map catalog loaded loaded=1
loaded map map=Lvl_ThirdPerson verts=… tris=…

Malformed or unreadable files are skipped with a warning — a bad map never stops the server. When a room resolves a map name with no matching file, the server warns and lists the maps it does have (catches typos and uncooked levels).

Trusted server logic can inspect a loaded map without receiving its raw geometry: citadel.map_info(name) returns its bounds and collision vertex/triangle counts, or nil (Lua), None (Python), or null (JavaScript) when it is not loaded.

.map is a small, versioned, section-framed big-endian binary format (implemented by the citadel-map crate). A reader skips sections it does not understand, so future data (e.g. baked navigation) can be added without breaking older servers.

Part Contents
Header magic CMAP + format_version (u32)
METADATA section level name (u16-len UTF-8) + world-space AABB (bounds_min/bounds_max, f32×3 each)
COLLISION section vertex count (u32) + vertices (f32×3) + triangle count (u32) + triangles (u32×3 indices)
NAVMESH section optional server-baked Detour tile, guarded by Detour version and polygon-reference width

Coordinates remain in the source engine’s world units. Configure the server-side actors and transform-sync values in the same convention as the exported level so server-driven NPC positions line up with the cooked geometry with no conversion.

  • Terrain-aware exporter workflow. Unity exports built-in Terrain, Unreal exports Landscape collision heightfields, and Godot supports explicit terrain providers. Terrain uses collision-height samples and holes, not trees, grass/details, material displacement, or runtime deformation. The .map format itself is engine-agnostic.
  • Static collision only. Unreal exports static meshes and Landscape collision heightfields, but not BSP/geometry brushes, skeletal meshes, or procedural collision. Godot exports static-body MeshInstance3D nodes (or explicitly tagged meshes) plus an explicit terrain provider, not CSG or dynamic-body collision.
  • Navigation is static and server-side. Detour bakes/query uses the cooked collision mesh. Dynamic obstacles, off-mesh links, and server-side player-collision validation remain future work.