Skip to content

HammerForge Data Portability

Last updated: October 25, 2026

This document describes how to move data in and out of HammerForge safely.

What Each Format Keeps

One row for each thing a level holds, one column for each file it goes through. The .map column is an export and then an import into an empty level. The .glb column is the bake, exported. The .hfprefab column is every piece of the level saved as one prefab and placed again. tests/test_format_table.gd takes a level that holds every row through each file and holds every cell to what comes back, so a change to an exporter that this table does not follow fails the tests. An export that loses something names it in the Console, for example ".map export left out 2 cordons, 1 note".

What the level holds .hflevel .tscn .map .glb .hfprefab
Brushes Kept Kept Kept Kept as mesh Kept
Brush shapes Kept Kept Lost Kept as mesh Kept
Cuts Kept Kept Lost Kept as mesh Kept
Face materials Kept Kept Kept Kept Kept
Texture settings Kept Kept Kept as the map's texture axes Kept as mesh UVs Kept
Hand laid UVs Kept Kept Lost Kept as mesh UVs Kept
Face paint Kept Kept Lost Kept as a baked texture Kept
Sculpts Kept Kept Lost Kept as mesh Kept
Smoothing groups Kept Kept Lost Kept as mesh normals Kept
Hidden faces Kept Kept Kept as the skip texture Kept as faces the mesh leaves out Kept
Texture groups Kept Kept Lost Not applicable Kept
Point entities Kept Kept Kept Lost Kept
Entity properties Kept Kept Kept as text Lost Kept
I/O wires Kept Kept Kept Lost Kept
Brush entities Kept Kept Kept Kept as named nodes Kept
Visgroups Kept Kept Lost Not applicable Lost
Groups Kept Kept Lost Not applicable Kept
Structures Kept Kept Lost Not applicable Lost
Prefab copies Kept Kept Lost Not applicable Lost
Cordons Kept Kept Lost Not applicable Not applicable
Bake options Kept Kept Lost Not applicable Not applicable
A brush's bake options Kept Kept Lost Not applicable Kept
Floor paint Kept Kept Lost Kept as mesh Not applicable
Notes Kept Kept Lost Lost Kept
  • Kept as mesh. A .glb holds what the bake built, so a row arrives as the triangles it made and not as something you can still edit. A cylinder, a cut or a sculpt cannot be got back from it; keep the .hflevel for that.
  • Not applicable. The format has no place for the row, and the export leaves out nothing by not having one. A .glb has no visgroups or structures, and the bake has applied the cordons and the bake options already. A prefab is a set of pieces rather than a level, so it holds no cordons, level bake options or floor paint.
  • The .map column. A brush keeps its faces and loses the shape it was made as, so a cylinder comes back as a plain brush with a cylinder's faces. A cut is left out rather than written as a solid that would fill its hole. Texture settings come back as the map's own texture axes, so a face draws the same while its numbers differ, and entity properties come back as text, which is how the format writes every value.
  • Groups in a prefab. A prefab keeps each group the selection holds whole, nested as it was. A group the selection holds only part of belongs to the level, and the prefab leaves it out (#1897).
  • Structures and prefab copies keep their brushes wherever brushes are kept. What is lost is the record that can still change them: a staircase comes back as its steps.
  • The .tscn column is the default Scene Keeps setting, Brushes and bake. A scene set to keep only its bake loads its .hflevel when it opens, below.
  • Bake profiles are editor preferences rather than part of a level, and no file here carries them.

Source of Truth: .hflevel

  • .hflevel files are the canonical save format for brushes, paint layers, materials, entities, decals, and settings.
  • When region streaming is enabled, per-region paint data is stored in a sibling <level>.hfregions/ folder as one .hfr file per region. Save As copies the streamed out regions' files into the new name's folder and clears any other region file there, the files of a level saved under that name before (#1929).
  • A region is written before its chunks are streamed out of memory. If that write fails the region stays loaded and you are told, so unsaved paint is not dropped by moving the cursor.
  • A region is only listed in the .hflevel index once its .hfr file exists on disk.
  • A region file holds every paint layer that has paint in that region, each as {"id", "chunks"}. A rewrite keeps the entries of a layer the level no longer has, so undoing the layer's removal brings its paint back from disk. A region erased to nothing is written without that paint, and removed with its .previous copy once no layer, current or removed, has paint there, so it does not come back when it streams in again (#1550).
  • An undo of paint in a streamed region rewrites the region files it changed, or removes them, so the undone paint does not stream back in (#1514). A new paint layer never takes the id of one removed this session or found in a region file, so it never loads another layer's paint (#1564).
  • Files include a version field and default missing keys on load for backward compatibility. The bundle version is HFLevelIO.FORMAT_VERSION (current: 2), raised by every change to what a level saves (#1638). A file stamped higher than this build reads is refused before anything is applied, with a message, rather than clearing the open level and restoring what it can parse. A file also names the format fragments the development build that wrote it was waiting on, in format_pending, and a build at the same number that lacks one refuses it too. A file with no version, or version 0, is an older one and still loads, because every key defaults.
  • Per-face UV data includes uv_format_version (current: 3). Version 2 data keeps the old wall axes on every face laid on by hand (#907). Legacy data (version 0, pre-April 2026) used a different UV transform order (scale+offset before rotation). On load, legacy faces are auto-migrated: uniform-scale faces get their offset adjusted; non-uniform-scale faces with rotation are baked to custom_uvs. No manual intervention is needed.
  • Per-face vertex winding includes winding_version (current: 3). Legacy data (version 0, pre-April 2026) used CCW vertex winding for manually-created faces, which rendered inside-out under Godot 4's CW front-face convention. On load, apply_serialized_faces() detects v0 faces and runs a centroid-based migration: each face's normal is checked against the outward direction from the brush center, and faces pointing inward have their vertices reversed to CW. Mesh-extracted faces (already CW) are left unchanged. Version 1 data is correct except on the five shapes whose builders wound every face inside out before September 2026: PRISM_TRI, PRISM_PENT, OCTAHEDRON, DODECAHEDRON and ICOSAHEDRON. A v1 brush of one of those shapes runs the same centroid migration, which is exact because all five are convex; every other v1 face is left alone, so a torus or another concave brush is never touched. Version 2 data is correct except for path tool brushes, whose two builders wound every face inside out before September 2026. Those are CUSTOM brushes, so there is no shape to key the migration on; instead a brush saved below version 3 whose every face points at its own centroid runs the same centroid migration. That is what an inverted convex solid looks like and what a correctly wound closed solid cannot look like, concave or not, because the faces on its convex hull always point away from the centre. No manual intervention is needed.
  • Per-face smoothing_group says which faces shade as one smooth surface, 0 for flat. Every face writes it. A file from before it loads every face flat, which is how such a brush baked. New round brushes and the curved faces of arches and domes start in group 1 (#1429).
  • Per-face uv_group and uv_group_justify record a linked surface and the Justify last used on it. They are written only on a face in a group, so a level with no linked faces writes what it did before (#1437).
  • Per-face uv_projection 5 is the Face projection (#1419). A build from before it reads 5 as a projection it does not know and puts the face on Box UV.
  • Per-face displacement data writes flip_diagonals: true only on a sculpt whose cells now split along the other diagonal: one that Flip or the negative-scale repair mirrored, or one that a resize turned a quarter round to put its face back in the corner order its shape generates. The second happens to a box face saved starting at a turned corner, which older flips left behind. The key records which diagonal splits each grid cell, so the surface folds the way it did. Every other sculpt saves without the key, exactly as before. A build without the key loads such a sculpt with every grid point in place but splits its cells the original way, so cells that are not flat fold the other way.
  • Autosaves write to res://.hammerforge/<scene name>.hflevel by default, derived from the scene the level was opened from. A level whose scene has never been saved has no name to derive from and uses res://.hammerforge/autosave.hflevel until it does.
  • Files carry a scene field naming the scene they were saved from, a .tscn or a binary .scn alike. It is what lets a file say which level it holds when every level shares the default autosave path. Files written before September 2026 do not have it, and every key defaults, so they still load.
  • Store .hflevel in version control for reliable recovery. Compare With... in the dock shows what changed since another version, an older save or one taken out of git with git show HEAD~3:level.hflevel > old.hflevel, and godot --headless -s res://tools/compare_levels.gd --path . -- old.hflevel new.hflevel prints the same list, exiting 1 when something changed (#1701).
  • A WorldEnvironment you add to the level for its sky or fog is a node in the .tscn, not part of the .hflevel. Only the Include Level Environment switch (export_level_environment) is a level setting.
  • The level settings include bake_cast_shadows, the Cast Shadows of every brush that sets none (#1444), and the picked pivot and Mirror Copy's choices: transform_pivot_point, mirror_copy_axes and mirror_copy_at_edge. The Active Material is saved with the scene and not in the .hflevel, since it is the material of the next brush rather than anything in the level.
  • A brush record carries bake_cast_shadow and bake_lightmap_scale when the brush sets its own Cast Shadows or Lightmap Scale, and a visgroup record carries cast_shadow beside visible. A file without them bakes on the level's default, as before. A .map export leaves them out (#1444, #1438).
  • A structure's record carries run for a Trim Run: the wall edges and doorway outlines it was built along, as plain data, so an Update after a reopen or an undo follows the same walls. It is empty for every other type (#1414).
  • Visgroups and groups are saved with the order they were made in, beside the registry itself. The registries are JSON objects and JSON.stringify sorts object keys, so without it the panel came back alphabetised on every reload. A file written before September 2026 has no order recorded and loads the way it always did.
  • Save compression (Console > Controls) decides the form on disk. On, the default, the bundle is deflated: a 45-brush level is about 3 KB. Off, it is plain JSON written one value per line with keys in a stable order, which is the form to use when the level is reviewed and merged like any other file in the repository. The same level is about 250 KB that way, and the loader reads either form without being told which.
  • A .hflevel records saved_at, stamped as the file is written rather than when the level was captured. An autosave of a level nothing has changed therefore writes nothing, because what decides that is the level and not the clock.
  • Save Level writes the file it is given, every time. A save to a second path is a copy of the level, not a no-op, even when nothing has changed since the last one.

Which File Opens: the .tscn Wins

A level lives in two files, and they are written by two different commands. Godot's own Ctrl+S writes the scene; Save Level writes the .hflevel. Nothing reconciles them, and nothing merges them; HammerForge only tells you when they have come apart.

On open, the scene wins, because the scene is what Godot loads. A .hflevel saved after the last Ctrl+S is not what comes up. Use Load Level to bring it in.

HammerForge says so when it happens. On the frame the dock binds to a level it compares the two files' modification times, and if the .hflevel is the newer one it puts a line in the Console and a toast on screen. It says it once per open, not once per rebind, because after that the scene in the editor has moved on and Load Level would cost you whatever you did since.

A .hflevel records the scene it was saved from, so a file that belongs to another level is not reported against this one. That matters because a level can still be pointed at a shared path by hand, and Load Level on another level's file would overwrite the open one. Files written before September 2026 carry no such record; they are still reported, and the message says it cannot tell which level they hold.

The same record is what stops an autosave landing on someone else's file. An autosave is a write nobody asked for, on a timer, so it is refused when the target .hflevel says it holds a different scene, and the refusal is said once. Save Level is never refused: writing over another level's file on purpose is a thing you are allowed to do.

Save Scene As takes the record with it. A scene saved under a new name keeps its .hflevel path when only the extension changes (level.tscn to level.scn) or when the level has a path of its own, so the file would go on naming the old scene and every autosave after it would be refused. Instead, when that file names the scene as it was a moment before the save, the level autosaves as soon as the scene is saved, and the file names the new scene. The copy left under the old name is then the other level: if you open it, its autosave is the one refused. With autosave off nothing is written, and there is no autosave to refuse.

The one exception is a level set to keep only its baked geometry, below: that scene has no brushes to win with, so it loads its .hflevel when it opens.

What the Scene Keeps

HammerForge gives an owner to almost everything it makes, so Ctrl+S writes the brushes and the geometry baked from them into the .tscn. A 100-brush level is about 261 KB of scene against 4 KB of .hflevel, and a bake adds another 149 KB that is derivable from the brushes already in the file. For a greybox session that is what you commit and what a teammate has to merge.

Scene Keeps on the LevelRoot chooses what goes in:

Setting The .tscn holds Notes
Brushes and bake (default) Both The scene is the whole level on its own, and has geometry at runtime without the plugin.
Brushes only The sources The scene stays the size of its brushes. Bake again to get geometry back; there is none at runtime until you do.
Baked geometry only The geometry The lightest scene. The brushes live in the .hflevel and are loaded when the scene opens.

Changing the setting re-owns what is already in the level, so the next Ctrl+S writes what the setting says rather than what the level happened to be built with.

Each setting's scene size and open time, as .tscn and as .scn, from 100 to 3,000 brushes, are measured on Performance and Limits.

Whichever setting is on, the scene also carries the records that describe the brushes without being brushes: visgroups and their visibility, groups, arrays, hollows, structures and prefab instances. They are not nodes, so they ride in a live_registries property on the LevelRoot rather than as children. Before September 2026 they did not ride anywhere, and a level reopened from its scene came back as loose geometry the structure panels could no longer edit. A scene saved back then is repaired on open as far as it can be: a visgroup is put back from the members that still name it, and comes back visible.

Floor paint rides there too since October 2026: each paint layer's cells, heightmap, terrain slots and wall heights, and the layer in use (#1854). Before that the scene kept the layer nodes and none of their paint, so a level reopened from its scene had empty layers under floors nothing could edit. A streamed level carries the index of its region files and the level file they sit beside, and its cells stay in those files, so the reopened scene streams them in (#2006). A save of a level that has lost track of its region files is refused rather than clearing a folder. A scene saved before the change opens with what it has; Load .hflevel brings the paint back from a file saved after it was painted.

A brush's faces go into the scene with everything on them: material, UV settings, custom UVs, paint layers and, since October 2026, the sculpt (#854). Before that the scene dropped every sculpt and only the .hflevel kept it; Load .hflevel brings one back from a file saved after the sculpt was made. A build from before the change still reads a sculpt from a newer scene, but drops it again the next time it saves.

A sphere, ellipsoid, torus or capsule is rebuilt from its shape when the scene opens, and since October 2026 it has the same faces at every size (#852, #858). An older build let rounding split some of its flat quads in two at some sizes: a sphere at 1,000 units had 2,340 faces rather than 2,240, and a stretched ellipsoid or torus usually had a few extra. Such a brush opens with those quads whole, and each face takes the material and UV settings of the old face that faced its way (#851), so it looks the same. Since #1057 these shapes are built from 16 segments by 8 bands, 128 faces, rather than Godot's 64 by 32; an older one opens rebuilt that way, each face again taking the look of the old face that faced its way. Since #1085 a brush's sides sets its segments, as it does a cylinder's, so one that carries 5 or more opens with that many, and the .hflevel carries sides for every round shape.

Baked geometry only needs somewhere to put the brushes. A level with no .hflevel path keeps them in the scene regardless, because dropping a level's only copy of its brushes is not a trade worth making silently. If the .hflevel is missing when such a scene opens, HammerForge says so rather than opening an empty level.

Building a Map Out of Level Pieces

Saving a piece of level as its own scene and instancing it two or three times into a parent is supported, and is how a corridor, a room module or a prop cluster gets reused.

A brush id is unique within one level, not within a scene. A saved scene freezes the ids its brushes had, so two instances of the same piece carry the same set of ids, on purpose. That is not a collision, because every lookup by id resolves inside its own LevelRoot: each instance has its own brush cache and its own containers, and one copy cannot answer with the other's brush. Adding a second copy does not renumber, move or unregister anything in the first, and each copy bakes its own geometry.

The alternative would be re-minting every id when an instance enters the tree. That was deliberately not done: it makes ids unstable across loads, and the prefab system tracks by stable UID rather than by name for exactly that reason. A piece that came back with different ids every time it was opened would stop being the same piece.

What is a defect is one level holding two brushes with the same id. The brush cache is keyed by it, so the second to register overwrites the first and one of the two cannot be addressed at all: not by a visgroup, a group, a hollow or array record, nudge_brushes_by_id, tie_brushes_to_entity, or the Console. The ways in are a hand-edited .hflevel, a .tscn where a DraftBrush was copied with Godot's own node duplication instead of Ctrl+D, or a state record naming the same id twice. Validate reports it, naming the id. There is no auto-fix: re-minting one of the pair silently re-points whichever records happened to mean it, and which one you wanted is not knowable.

Entity I/O Serialization

  • Entity I/O connections are stored per-entity in the io_outputs key of each entity record.
  • Each connection is a Dictionary: {output_name, target_name, input_name, parameter, delay, fire_once}.
  • Connections are captured by capture_entity_info() and restored by restore_entity_from_info(). A load that cannot give an entity its saved node name, a @Node3D@14 saved before #1179, retargets the wires aimed at it to the name it gets.
  • Missing io_outputs key on load = no connections (backward-compatible).

Brush Entity Class Serialization

  • Brush entity class (any class entities.json marks is_brush_entity: func_detail, func_wall, func_clip, func_physbox, func_illusionary, func_door, func_button, door_basic, trigger_once and trigger_multiple) is stored in the brush_entity_class key of each brush record.
  • func_detail brushes bake into one mesh per material and one collision body, the way the structural path groups and split by Cast Shadows and Lightmap Scale as it splits, with a convex hull per brush so a pile of clutter still collides as the separate solids it is. A detail brush that carries an entity name, I/O outputs or a node name you typed keeps a node of its own instead, because that is the address the runtime finds it by. The Box_3 style name every brush starts with does not count, whatever shape it names, so a detail brush that was bevelled or vertex-edited still groups (#1144).
  • Missing key on load = no entity class (standard structural brush).

.map Import / Export

  • Use .map to exchange basic brush layouts with other editors.
  • Units are converted in both directions. A .map file carries bare numbers and never says what a unit is. Every editor that writes one is on Quake units, where a player is 56 to 72 tall and the grid steps in 16s; HammerForge is on Godot's metric scale, where the playtest player is 1.6. The Map units/m row in the dock File section says how many .map units one HammerForge unit is worth, and it applies to import and to export. The default is 32, which is the figure Func_Godot uses for the same exchange and which puts a 56-unit player at 1.75.
  • A 128 x 32 x 112 corridor imports as 4 wide, 3.5 high and 1 deep. A 12-metre room exports as 384 units, which is 24 of the other editor's grid squares.
  • Point entity origins take the same conversion as the plane points, because an origin is a position in the same space. Other numeric keys do not: a func_door's speed is a distance per second in the source game's units, and only that game knows what it meant, so the keys travel as written.
  • The texture scale takes the conversion too. A .map reader divides the projected point by the scale field, so scaling the points and leaving the scale alone would tile the texture 32 times more often on a room that is otherwise the right size.
  • An export records the figure it used in worldspawn, as _hf_units_per_metre. An import reads it and uses it in preference to the dialog, so reopening your own export gives back the level you exported however the setting has moved since. A file from another editor states nothing and is read at whatever the row says. A figure of zero, a negative one or a non-number is refused and the row's value stands.
  • Changing the row between an import and an export rescales the level, on purpose. That is the way to move a map from one convention to another.
  • The conversion lives in HFFileSystem, not in MapIO. MapIO.parse_map_text() and MapIO.export_map_from_level() convert nothing unless a figure is passed, so a caller that wants the file's own numbers gets them.
  • The axes are converted in both directions too. .map is Z-up across the whole Quake family; Godot is Y-up. Nothing converted, so a corridor drawn 112 units high arrived 3.5 metres deep and one metre high, and a floor exported from here opened in TrenchBroom as a wall. It went unnoticed for a long time because it is symmetric: out of HammerForge and back came home, and only the crossing was wrong.
  • The turn is a rotation about X: the file's (x, y, z) becomes (x, z, -y) here, and the reverse on the way out. Being a rotation, it leaves handedness alone, so the winding reversal below is unaffected by it.
  • That particular rotation is chosen so the level looks from above exactly as it did in the editor it came from. Func_Godot uses a cyclic (y, z, x), which is equally correct and puts the map down at ninety degrees to the way the source editor drew it. That is noticeable the moment a piece is imported alongside geometry that is already there.
  • Point entity origins take the turn, for the same reason they take the scale. An angle does not: it is a compass bearing in the file's own horizontal plane and would need the turn applied to it as a direction rather than as a point, which is a per-key decision the importer does not make. A func_door needs none: it reads its angle in the file's own sense, 0 east and 90 north, so a door opens the same way on both sides.
  • The Valve 220 texture axes take the turn as well. The reader computes the texture coordinate by projecting the point onto those axes, and rotating both by the same rotation leaves that projection unchanged, so the UVs come out of the conversion exactly as they went in.
  • An export records which way up it wrote, in worldspawn, as _hf_axis_convention: quake for the format's own axes and godot for this project's. A file that says godot is left alone on import. A file that says nothing is turned, because every editor other than this one writes the format's axes.
  • A .map exported by a HammerForge build from before this change carries this project's axes and does not say so, and there is nothing in such a file to tell it apart from one another editor wrote. Export it again from a current build, which records the convention, rather than relying on the import to guess.
  • Axis-aligned boxes use the optimized primitive path; tilted, clipped, and other non-axis-aligned convex brushes import and export as CUSTOM face geometry. An axis-aligned brush thinner than 0.1 on a side imports as CUSTOM at its real thickness, since a box that thin would be widened to 0.1 (#1635).
  • Import works the face polygons out rather than reading them off the file. A .map face line names three points on an infinite plane, not the corners of a face, and the solid is the intersection of the half spaces behind its planes. A set of planes that closes nothing still imports, on the old reading of the points as corners, so an ill-formed brush arrives wrong rather than not at all.
  • Key/value properties round-trip through the supported Classic Quake and Valve 220 adapters, on all three kinds of block: point entities, brush entities, and worldspawn. A func_door keeps its speed, wait and angle, a trigger keeps the target it points at, and the map keeps its wad list, its message and its mapversion. worldspawn's keys live on LevelRoot.map_worldspawn_properties and travel in the .hflevel.
  • A point entity keeps every key on it, including those its class does not name, and since October 2026 the scene saves them all (#1855). Before that a Ctrl+S kept only the keys the class names, so an imported key was gone after a reopen.
  • target/targetname is kept as an ordinary property rather than translated into HammerForge's own I/O. The reverse mapping is not one-to-one, so the pair survives a round trip without the editor guessing what it means.
  • Per-face material names round-trip. Each face line names the material its material_idx points at, and an import maps that name back to a palette slot by name.
  • The texture field is positional and whitespace delimited, so a palette name with a space in it is written with underscores (Red Brick becomes Red_Brick). Import matches on the same token.
  • A name the palette does not already hold mints a placeholder slot named after it, so the palette mirrors the file and the Surface panel says *water1 on the water. A .map names a texture without saying where it lives, so a placeholder carries no resource path rather than a guessed one that would resolve to nothing. Loading the palette first still matches the real materials by name.
  • The name is also kept on the face itself, in FaceData.map_texture, so an import and an export are lossless in a project with no materials loaded at all. __default is written only for a face that has neither a palette slot nor an imported name.
  • The name stays with the face through everything that rebuilds or copies it: a resize, a sides change, Godot's Duplicate, a save and reopen of the scene, the faces that Clip, Carve, Bevel, Inset and Clip to Convex make from existing ones, a Flip, and a generated structure's Update. Before #859 each of those dropped it or put it on the wrong face, so an imported trigger whose palette had been cleared exported as a plain solid after its first resize.
  • UV offset, rotation and scale are written in both formats, converted into the units a .map uses: the rotation goes out in degrees (FaceData.uv_rotation is radians), and the offset and scale go out in texels of the face's texture, which is how every Quake family editor reads them (#894). The scale is units_per_metre / (uv_scale * size) and the offset uv_offset * size, per axis, where size is that side of the texture in pixels: the palette material's albedo texture, or 64 for a face with no texture, an empty slot or a placeholder. A face at the default alignment on a 64 pixel texture is written 0 0 0 0.5 0.5 at 32 units a metre. A zero or non-finite scale is written as the default and warned about, since every Quake family compiler divides by it. A negative scale keeps its sign; it mirrors the texture. Valve 220 additionally carries the texture axes, turned by the face's rotation, because a Valve 220 reader projects with the axes as written and never applies the rotation field (#899); Classic Quake has no field for them, and its readers turn their own axes by the rotation.
  • Import reads the offset, rotation and scale back from any file, the same conversion in reverse, against the palette texture of the same name or 64 pixels when the palette has none. A round trip keeps every face's alignment in both formats (#885), and a file from another editor arrives aligned the way that editor showed it, given the same textures (#894). A scale written in place of zero comes back as 1. The Valve 220 axes are not read back; the face keeps its own projection.
  • Authored entity names round-trip as targetname, on both point entities and brush entities. The authored name is the address every I/O connection is aimed at, so without it a wired level comes back inert.
  • Brushes tied as one entity export as one entity block with a brush block for each brush, named or not, and an import gives the brushes of a block back tied as one entity, so an unnamed two-leaf door comes back as one door.
  • A func_clip is the exception. It exports into worldspawn with every face named clip, whatever its faces are painted with, which is how Hammer and TrenchBroom write clip collision; a face that came in as common/clip or tools/toolsclip keeps that spelling. An import gives a world brush whose every face is named clip, common/clip or tools/toolsclip, in any case, back as a func_clip. A clip with a Physics Layer, a name or another property off its default goes out as a func_clip block instead, so it comes back with them (#1409).
  • A Hidden face exports named skip, the name map compilers drop, or under the name of that kind it came in with. An import gives a face named skip, nodraw, null, common/nodraw, common/caulk or tools/toolsnodraw, in any case, back as a Hidden face, with no palette slot made for it (#1719).
  • A wire aimed at a brush by its node name, as the Objects tab can aim one, is written aimed at its entity's address in the file: the entity's name, or for an entity with no name the node name of its first brush, which the block then carries as its targetname. Nothing in the file answers to a brush's node name, so the wire would otherwise dangle (#1155). When another entity in the file already answers to that name, the block gets the next free one instead, Box_1 for Box_0, so the wire does not reach both (#1229).
  • Entity I/O connections round-trip, one key/value line per connection. The key is the output name and the value is target,input,parameter,delay,fire_once, which is the order Hammer writes a VMF connection:
{
"classname" "func_button"
"targetname" "btn"
"OnPressed" "door,Open,,0.0,0"
}
  • A line with the five fields of a connection but a blank target or input, or a delay that is not a number, cannot be used and is dropped on import. The import result names how many were dropped rather than losing them in silence. The editor refuses to create one, so a file carrying them was written somewhere else or by hand.
  • There is no connections { } block, because .map entity bodies are key/value lines and nothing else: a nested brace inside an entity is read as a brush by every parser including this one, so a block would not survive its own round trip.
  • One line per connection, so two outputs on the same event both reach the file. The import reads the key/value lines in file order rather than through a dictionary, which would keep only the last of a repeated key.
  • A line is read back as a connection only if it has five comma-separated fields, a numeric delay, and a target and input that are actually there. An ordinary entity property does not look like that, so wiring is told apart from settings without a naming rule on the key.
  • The format defines no escape for a comma, so a comma inside a field is written as a space rather than escaped. A reader splitting on the comma would otherwise get a different number of fields than the writer wrote.
  • HammerForge surface-paint layers are not preserved, so .hflevel remains the editable source of truth.
  • Treat .map as a blockout exchange format, not a full fidelity export.
  • Cutters are not exported. A .map worldspawn holds additive solids only, so a cut brush written into one would fill the hole it was made for instead of cutting it. Carved shapes export uncut; bake or export .glb when the carve has to come with them.
  • Face planes are written in .map winding, which is the reverse of the clockwise-from-outside order FaceData stores, so exported hulls are the right way out for compilers and other editors. Import applies the same conversion in reverse. Cylinders used to be written the other way round and now match the box and custom-face writers.
  • One plane per flat surface. A .map brush is an intersection of half spaces, so a cylinder cap is a single plane rather than one plane per fan wedge: a prism exports as sides + 2 planes. Writing the fan produced 3 * sides, most of them exact duplicates, which several external compilers report as a degenerate brush.
  • A cylinder exports the prism on screen at any side count, and each plane carries the texture of the face that lies on it under any scale. The planes come from the same ring as Godot's CylinderMesh, starting on +Z, and each plane's face is found by its direction in the brush's own frame, before the transform. The export used to start on +X, so a 5, 6 or 7 sided cylinder came out turned against the one drawn, and it compared transformed normals, which lean towards the stretched axis, so a cylinder at a scale of (4, 1, 1) wrote 12 of its 18 planes with a neighbouring face's texture (#880).
  • A file that does not parse is refused before the level is touched. Unbalanced braces, face lines that are not three points, and text outside any block are reported, the current level is left alone, and no undo entry is created.
  • Multi-format export: Classic Quake and Valve 220 format adapters are available via the format selector in the dock File section. Valve 220 includes UV texture axes from FaceData. The axes are resolved against the face normal, not against the stored projection alone: Valve 220 requires both axes to lie in the face plane, and a stored projection knows nothing about which way the face points. A projection whose axes would lie along the normal falls back to the dominant-normal choice.
  • Entity property keys and values may contain quotes, backslashes and //. HammerForge escapes \ and " on export and reads key/value lines as quoted tokens, so what it writes it reads back unchanged. The .map format itself defines no escaping rule, so a file written this way shows a literal \" if it is opened by a tool that does not expect one — keep quotes out of values you intend to hand to another editor.
  • Reading is deliberately conservative in the other direction: only \" and \\ are treated as escapes, so an unescaped Windows path from another tool (textures\maps\wall) survives intact. The one case that cannot be resolved either way is a literal \\ written by a tool that does not escape; HammerForge reads it as a single backslash.

Import Vertex Welding

Legacy .map files from Hammer, TrenchBroom, and other editors often carry floating-point representation drift in vertex coordinates. Two vertices that should be coincident may differ by a fraction of a unit, producing micro-gaps or non-planar faces after import.

MapIO.parse_map_text() automatically welds near-coincident parsed vertices before constructing brush geometry. The tolerance is controlled by MapIO.import_weld_tolerance (default 0.01). Vertices within this distance are averaged to a shared position via BFS grouping over a spatial hash with 27-cell neighbor lookup, so pairs that straddle a snap-grid boundary are still caught.

The tolerance is in .map units and the weld runs before the unit conversion above, because the drift it exists to close is drift in the numbers as the other editor wrote them. At the default scale 0.01 of a map unit is about a third of a millimetre on the imported level.

To adjust the tolerance:

MapIO.import_weld_tolerance = 0.05  # increase for very noisy legacy files
MapIO.import_weld_tolerance = 0.0   # disable welding entirely

After import, run Check Only (Test tab) to detect any remaining non-planar faces or micro-gaps between brushes. The validation system offers auto-fix methods (weld_brush_vertices, fix_non_planar_faces) for post-import cleanup.

.glb Export

  • .glb export writes the baked geometry only.
  • A successful bake is required before export.
  • Use Bake -> Export .glb when you need DCC or engine interoperability.

Project Presets: hammerforge_presets/

  • Brush presets (.tres), the team's bake profiles (bake_profiles.json) and structure presets (structure_presets.json) live in res://hammerforge_presets/, outside the addon, so an upgrade does not delete them. Commit the folder.
  • structure_presets.json is {"version": 1, "presets": {type: {name: {setting: value}}}}, named Structure settings by type. A preset is read through the type's settings: a value out of range refuses that preset by name, a setting the type no longer has is dropped, and a missing one takes its default. A type this version does not know is kept on write, and a file that does not parse is never written over (#1415).
  • A preset or prefab name is turned into a file name one way (HFFileNames): characters a file name cannot hold are replaced, leading dots are dropped, a name Windows reserves for a device, such as CON or LPT1, gets the kind after it (CON_preset), and a name already taken is numbered _2, _3 (#1464).
  • bake_profiles.json is {"version": 1, "profiles": {name: {setting: value}}}, written with sorted keys and one option a line so a change reads as a small diff. A value this version cannot use is left out with a warning on read and kept in the file on write, for anyone whose version can. A number past its setting's range is held to the range on read, with a warning. A file that does not parse, such as one with merge conflict markers, is never written over: Save and Delete refuse until it is fixed.

Material Library

  • The material palette can be saved and loaded independently, from Save Library and Load Library in the Paint tab or via MaterialManager.save_library() / load_library().
  • Library files are JSON containing material resource paths — portable across projects. A material with no resource path, which is any made in the editor session rather than loaded from disk, cannot be recorded: its slot saves empty and get_dropped_save_slots() names it. save_library() returns ERR_SKIP when no slot could be recorded at all.
  • The library path can be stored alongside .hflevel saves.

Prototype Textures

  • HammerForge ships with 150 built-in SVG prototype textures at addons/hammerforge/textures/prototypes/.
  • Click Refresh Prototypes in Paint tab → Materials → Material Library to batch-load all textures into the palette.
  • Once loaded, prototype materials are serialized in .hflevel saves, as resource paths. The same rule as the material library applies: a material with no resource path, which is any made in the editor session rather than loaded from disk, cannot be recorded. Saving one now warns and records its class and name, and the slot loads empty.
  • Prototype textures are included in the plugin directory and travel with the project automatically.

Entity Definitions

  • Entity types and brush entity classes are loaded from entities.json (data-driven, not hardcoded).
  • Add your own in res://hammerforge_entities.json. That file overlays the plugin's entities.json, and an entry with the same classname replaces the plugin one. Prefer it over editing res://addons/hammerforge/entities.json, which is overwritten when the plugin is upgraded.
  • entity_definitions_path can also name an .fgd file, or several files joined with ;, a later class over an earlier one; see Entity classes from an FGD in the user guide (#1721).
  • A LevelRoot can point somewhere else through its entity_definitions_path export. The point entity palette and the brush entity dropdown both read the same merged result, so a custom point entity is placeable and a custom brush class is assignable without further setup.
  • Definitions include classname, description, color, is_brush_entity, properties, optional scene_path, the optional class and scene which name the Godot node or PackedScene that Test Level and the exports build for the entity, and optional outputs and inputs naming the I/O the class fires and answers to. A property may carry maps_to naming the engine property its value is written to. An entity that stores no value for a property, such as one placed before the class gained it, is exported with the property's default. The dock also reads presentation keys straight from the JSON: label, preview, and category.
  • scene_property names the property an instance uses to choose its own model, where scene names one for the whole class. prop_static uses it: the path a mapper types is instantiated as the entity's preview in the viewport and as the node the bake and the playtest export carry. A path that does not resolve leaves the class's own preview in place and says so in the log.
  • resource_properties maps a property name to the resource class it holds, for a property that stores a path and a node that wants the thing at the end of it: ambient_sound's Stream is a path a mapper types and an AudioStream the player needs. A path that does not resolve leaves the property empty and says so in the log. The Entity panel gives such a property a file picker.
  • presets names sets of property values a class offers, such as a light's Warm Ceiling. They are only values: a preset cannot add a property or change a class.
  • A property may carry tooltip, which the Entity panel shows on it.
  • input_methods maps an input name to the engine method it means, for a class whose node already implements it: logic_timer maps Start to Timer.start(). See the I/O section of the user guide.
  • output_signals maps an engine signal on the class's node to the output it raises: logic_timer maps timeout to OnTimer. See the I/O section of the user guide.
  • Keys this list does not mention are carried through to the level root untouched, so a project's own definition file can hold whatever it likes beside the ones the plugin reads.

Prefabs: .hfprefab

  • .hfprefab files store reusable brush + entity groups as JSON.
  • Transforms are stored relative to the group centroid, so prefabs can be placed at any world position.
  • Brush IDs are stripped on capture; new ones are assigned on instantiation.
  • A record's place in its list says which piece of the prefab it is. When a Push comes from a copy missing an earlier piece, each record after the gap carries prefab_piece, the piece it was, so every linked copy pairs its pieces with the right records and keeps what it keeps on the right piece. A file written before prefab_piece existed has none, and its pieces are their places, as they always were (#1976).
  • A group the selection holds whole is kept: a groups list records each one's name and the parent group it is inside, outer ones first, and each piece's group_id names its own. Placement makes a group of its own for each, nested as they were, under its name or the next free one, so a prefab of a building with a roof group and a wall group places with both. A group the selection holds only part of is not named, so a prefab never puts a piece in a group of the level it is placed into. A file written before groups existed has none, and places as it did (#1897).
  • Entity I/O connections are captured and remapped to new entity names when instantiated. The remap runs over the entities the placement just created, not over a name lookup, so an authored name that collides with another copy's node name cannot send it to the wrong one.
  • The authored entity name travels verbatim, so placing a prefab twice gives both copies the same name. The I/O remap works off node names, which are made unique on placement; rename the copies yourself if two of them are meant to be told apart by an output.
  • Face materials travel by path, not by slot number. A face’s material is an index into the level’s palette, so a materials block records what each referenced slot meant and placement resolves those paths against the destination palette, appending any it does not already hold and rewriting the face indices to match. Placing the same prefab twice does not add the material twice. A material with no file of its own (made in the editor session, a .map import's placeholder, or one saved inside a scene) is recorded by name: placement finds that same material or one of that name in the destination palette, and appends it otherwise. While the editor that wrote the file still has the material, the material itself is appended; after that only a blank stand-in carrying its name can be, and the placement says so. One with no name either cannot be carried, so the placement warns and the face keeps whatever the destination holds there — save the material to disk first if its look should travel.
  • A recorded material the destination project cannot load is reported by name, and those faces keep the index they had rather than being pointed somewhere wrong.
  • A .hfprefab written before the materials block existed has none, and is placed exactly as it was before.
  • Data encoding uses the same HFLevelIO.encode_variant() / decode_variant() pipeline as .hflevel (handles Vector3, Transform3D, Basis, etc.).
  • Prefab files are saved to res://prefabs/ by default. The directory is created automatically on first save.
  • A prefab is written whole, as a level is: to a .writing file first, with the old one kept as .previous until the new one is in place. A save cut short, or a Push to Source that fails, leaves the old prefab for every linked copy to read, and a prefab left only as .previous still loads. The .map export and Settings Export write the same way (#1758).
  • Prefabs are portable between projects — just copy .hfprefab files to another project's res://prefabs/ folder.

Autosave Safety

  • Autosave serializes and writes on a background thread. The editor blocks only for the part that has to read the live level: walking the scene and resolving the materials it references. At 400 brushes that is about 50 ms, against about 125 ms of encoding that happens on the thread.
  • Saves first write a .writing sidecar, then replace the destination.
  • The existing file is copied to <level>.hflevel.previous before it is replaced, and that copy is restored if the rename fails. On load, a missing destination with a .previous beside it is promoted automatically. When a whole .writing is beside them too, the save stopped after the old file was taken away and before the new one took its name, and the .writing is promoted instead: it was written in full before the old file was copied aside, so a crash at that moment does not cost the last save (#1642).
  • If a write fails (e.g., disk full, permissions), autosave_failed fires for autosaves and hflevel_save_failed for manual saves, and the dock shows a red warning label.
  • The next autosave interval retries automatically.
  • Manual save is always available via Test tab → File section. Success is reported only after the matching background write finishes, not when the work is queued.
  • Queued writes run in the order they were requested, so two saves to the same path in quick succession leave the newer one on disk.
  • When region streaming is on, a failed .hfregions sidecar fails the whole save rather than writing a .hflevel whose index points at region files that are missing or stale.
  • Keep .hflevel files in version control and treat the warning state as the authoritative failure signal.

Damaged Level Files

A level file reaches the loader from disk, git merges, sync tools and hand edits, so the loader reads it as untrusted (#1641): - A file that does not parse is refused with a warning, and the open level is left as it was. The file is checked whole before anything is applied, the settings included. - A file with an unresolved merge in it (<<<<<<<, ======= and >>>>>>> lines) is refused with a message that says so. Keep one side of each block and open it again. - One brush record that cannot be read costs that brush, not the load: a size, transform or centre that is not a finite number, or a field of the wrong kind, leaves that brush out with a warning naming it. - A primitive brush whose face corners are damaged (a corner that is not three finite numbers, a face with fewer than three, or a corner moved so the faces no longer close) is built again from its shape and size, without its faces' materials, and says so. A custom brush in that state is left out. Corners within a millimetre count as one, and a corner on the middle of a face's edge is not a seam, so no brush this build saves is touched (#1769). - A setting of the wrong kind is left as it was, with a warning naming it. - tests/test_damaged_level_files.gd feeds the loader over a hundred damaged copies of real levels from a fixed seed on every run.

  1. Design and iterate in HammerForge.
  2. Save .hflevel to preserve full fidelity editing data.
  3. Bake when you need runtime geometry.
  4. Export .glb for downstream tools or external engines.