Thanks for helping improve HammerForge.
This project follows a Code of Conduct — the short version is: be kind, be specific, and assume good faith. Security problems go through the Security Policy privately, not into a public issue.
- Good first issues are scoped so you don't need to understand the whole architecture first.
- Help wanted is the work that most needs another pair of hands.
area:labels (build,paint,objects,test,architecture,docs) map to the dock tabs, so you can find issues in a part of the editor you already use.- Discussions is the right place for "am I holding this wrong?" questions that aren't bug reports.
Bug reports and feature requests both have issue forms that prompt for what's actually needed to act on them.
- Small, focused changes are preferred.
- Large changes should start with an issue or discussion before a PR.
- Keep changes aligned with the current MVP and architecture.
Yes, you can use them. This project is built with them and it would be odd to ask otherwise. See AI.md for how and why.
The terms are the same ones I hold myself to.
- You are the author. Whatever produced a line, you are the one submitting it and you are answerable for it. That does not change.
- Be able to explain it. If you cannot say why a change is correct, or what it does when the input is empty, it is not ready for review. This is the only rule here that actually bites.
- Tests have to test something. A test that asserts nothing, or that was written to match the implementation rather than the requirement, is worse than no test.
- Run the checks before you open the PR. They are listed under Running Checks Locally and they catch most of what a review would otherwise spend its time on.
You do not need to say whether you used an assistant on a given pull request. I am not going to ask, and I could not verify the answer anyway. What gets reviewed is the change.
- Open an issue describing the problem and proposed fix.
- Keep PRs small and limited to one topic.
- Update docs and tests when behavior changes.
- Run formatting, lint, and test checks before submitting (see below).
- Use the displayed UI names—Build, Paint, Objects, and Test—in user-facing instructions. Legacy
entity_*andmanage_*filenames may be named when explaining internals. - Verify behavior claims against current code and tests. Do not copy test totals or source line counts from an older document.
- Update the README, relevant guide/spec, roadmap status, and
[Unreleased]changelog together when a change affects users or contributors. The changelog takes one line per change; see Changelog Entries below. - Only publish aggregate test totals from a successful full CI run; include the verification date so readers can distinguish a measured snapshot from a permanent guarantee.
- Leave the published totals alone in a pull request. They are a snapshot refreshed when a release is cut (see DEVELOPMENT.md, Cutting a release), so a pull request that adds tests does not touch them. CI checks on every run that the shards covered the whole suite and prints the totals in the run summary.
- Check relative Markdown links and
git diff --checkbefore submitting documentation-only changes. - Describe known limitations plainly and link the tracking issue instead of implying unfinished safety or fidelity work is complete.
CHANGELOG.md gives one dated line per change, grouped by release and then by week, newest first. python tools/check_changelog.py holds it to that shape, and CI runs it.
-
Under
## [Unreleased], find this week's heading. Weeks start on a Monday:### Week of 5 Oct 2026. If it is not there yet, add it above the newest week. -
Under the week, find or add the category:
#### Added,#### Changed,#### Deprecated,#### Removed,#### Fixed,#### Securityor#### Behind the scenes, in that order. Tests, CI, tooling, docs and code moves are Behind the scenes, and so is a bug fixed in one of them. -
Add one line at the top of that list:
- **5 Oct** One key press in the viewport runs once; Ctrl+D used to make two duplicates. (issue [#927](https://github.com/saworbit/hammerforge/issues/927), PR [#934](https://github.com/saworbit/hammerforge/pull/934))Say what changed in the words someone using the editor would use, and for a fix, what used to go wrong. Keep it to 200 characters as it reads; links count by their label. The how and the why go in the pull request description, which is where the detail lives.
The date is the day it merges, as near as you can tell. If it lands a day later, leave it. End the line with links to the issue it closes, the pull request, or both. A change with no issue needs its pull request open first, so it has a number to link. When two pull requests add lines to the same list, keep both, newest first.
At release time the [Unreleased] section becomes the release. DEVELOPMENT.md, Cutting a release, has the steps.
- Follow the subsystem architecture (LevelRoot is the public API).
- Prefer undo actions that use stable IDs and state snapshots. Use
collation_tagfor rapid operations. - Wrap a multi-brush operation in
root.begin_signal_batch()/root.end_signal_batch(), so listeners hear it once. - New entity types go in
entities.json, not hardcoded in GDScript. Project-side additions belong inres://hammerforge_entities.json, which overlays it. Both dock pickers read the merged result, so do not add a second load path for one of them. - New tools subclass
HFEditorTool(hf_editor_tool.gd). Typed exact values during a draw or extrude go throughHFPluginNumericInput(plugin_numeric_input.gd). - Subscribe to LevelRoot signals instead of polling in
_process(). - Keyboard shortcuts go through
_keymap.matches("action_name", event), never hardcodedKEY_*checks. Add new default bindings inHFKeymap._default_bindings(). Keep the primary toolbar limited to clear, user-facing Draw/Select/Paint/More/Help actions. - External tools should implement
can_activate()for tool availability andget_settings_schema()for auto-generated dock UI. Seehf_editor_tool.gdfor the full API. - New dock sections should use
HFCollapsibleSection.create()and register with_register_section()for persisted collapse state. Use 70px label widths for form rows. - User-facing messages should use
dock.show_toast(msg, level)orroot.user_message.emit(msg, level)instead of (or in addition to)push_error/push_warning. Level: 0=INFO, 1=WARNING, 2=ERROR. - Brush mutations should call
root.tag_brush_dirty(id)so the reconciler can skip unchanged geometry. - Multi-brush operations should wrap in
begin_signal_batch()/end_signal_batch()(or use transactions, which batch automatically) to prevent UI thrash. - User preferences (application-scoped) go in
HFUserPrefs. Level settings go on LevelRoot. - Operations that can fail (hollow, clip, delete, flip) should return
HFOpResult. Use_op_fail(msg, hint)in brush_system to emituser_messageand return a fail result in one call. Include an actionablefix_hintstring so users know how to resolve the issue. - Destructive operations should preview before commit: operations that permanently modify or delete geometry (carve, clip, hollow) must show a wireframe overlay preview and a
ConfirmationDialogbefore executing. Pattern: (1) validate withcan_*(), (2) callpreview.show_preview(...), (3) createConfirmationDialog, (4) on confirmed: clear preview + commit viaHFUndoHelper; on canceled: clear preview +queue_free()dialog. All lambdas must guard withis_instance_valid(self)andis_instance_valid(root). Use_add_confirmable_dialog(dlg)in plugin.gd to track dialogs for teardown cleanup. - Bulk delete confirmation: deleting 3+ brushes should show a confirmation dialog. Single/dual deletes remain instant to avoid friction. The dialog should remind users of Ctrl+Z availability.
- Snapping goes through
HFSnapSystem(onlevel_root.snap_system). New snap modes should be added as bitmask flags inSnapModeenum and collected in_collect_candidates(). - Deletion cleanup is handled automatically by
_cleanup_brush_references()in brush_system. If you add new cross-reference types (beyond groups, visgroups, entity I/O), add cleanup logic there. - Viewport hints should be added to
MODE_HINTSinshortcut_hud.gd. Each mode key maps to an instructional string. Hints auto-dismiss and persist viahf_user_prefs.gd. - Prefabs use
HFPrefab(hf_prefab.gd) for capture/instantiate. Add new prefab-related UI toui/hf_prefab_library.gd. Prefab instantiation should always usebegin_signal_batch()/end_signal_batch(). - Dock tab builders: New UI sections should be added to the appropriate builder file (
ui/paint_tab_builder.gd,ui/entity_tab_builder.gd,ui/manage_tab_builder.gd,ui/selection_tools_builder.gd) rather than directly indock.gd. Each builder hasbuild()(creates controls) andconnect_signals()(wires them up). - Registered tools (HFEditorTool subclasses) get automatic dock settings UI via
get_settings_schema(), keyboard dispatch viahandle_keyboard(), and poll-based button state viacan_activate(). Register in plugin.gd via_tool_registry.register_tool(). Tools that create brushes should useself.undo_redo(set by the registry on activation). - Vertex system operations (
split_edge,merge_vertices) should useget_pre_op_snapshots()for face snapshot undo. Edge splitting skips convexity validation (mathematically safe on convex hulls). Vertex merging validates convexity and reverts on failure. - Brush visual refresh: After replacing
mesh_instance.mesh(for example inrebuild_preview()), call_sync_visual_overlays(). Ordinary additive brushes have no topology overlay; subtractive brushes keep one red semantic overlay and brush entities keep one blue overlay. Reuse or synchronously detach fixed-name visual children before queueing deletion—never queue a child and immediately add a same-name replacement under the same parent. Hover and selection boundaries come fromhf_outline_util.gd. - Grid snap changes must emit
grid_snap_applied: If adding a new code path that changesgrid_snap, ensure it flows throughdock._apply_grid_snap()or that the root'sgrid_snap_changedsignal fires (which dock relays viagrid_snap_applied). This keeps the viewport HUD indicator in sync. - Face winding convention: All faces must use CW vertex winding as seen from outside the brush (Godot 4's front-face convention).
_compute_normal()produces outward normals for CW faces automatically. Never negate normals manually afterensure_geometry(). When adding new face generators, verify normals point outward from the brush centroid. - A builder describes its own settings. Adding a generator means writing the builder, giving it a
settings_schema(), and naming it inHFGeneratorSystem.builder_for(). If you find yourself editing the dock to add a generator, the schema is missing a field type rather than the dock needing another member. - Write the general case and let the shape collapse it.
HFConvexClip.solid_from_rings()drops coincident corners, so a builder writes the eight-corner box once and gets a wedge where the shape pinches. Carrying the pinch through the arithmetic instead is how special cases get one axis wrong. - Check that a test file still loads. GUT skips a file it cannot parse with a warning, not a failure, so deleting a helper a test calls takes that whole file out of the suite silently.
tests/test_suite_integrity.gdcatches it now; do not weaken it. - A generator record is a hint, not ownership.
HFGeneratorSystemkeeps brush ids so a structure can be rebuilt, but ids are reissued as the counter moves and brushes get deleted, clipped and merged outside its knowledge. Check the brush's ownhf_generator_idmeta before acting on it. Never delete by id alone. - Validate before you destroy. Anything that replaces geometry — regeneration, clip, carve, hollow — must know the replacement is buildable before removing the original, or a bad input leaves the user with nothing.
- Build a solid, then measure its winding. Any generator that constructs faces from scratch should finish with
HFConvexClip.orient_faces_outward(faces, interior)rather than getting the vertex order right by hand. Every face of a convex solid points away from any interior point; that is checkable, and checking it is cheaper than the bug. - Do not trust an individual face normal for a boolean. Use
HFConvexClip.outward_planes(), which orients each plane against an interior point. Brushes store triangles, and primitive meshes contain near-degenerate ones whose computed normal is numerical noise — one of them is enough to make an inset plane face the wrong way. - Booleans are capped at
MAX_BOOLEAN_PLANESdistinct planes. Cost and output both climb steeply with the plane count; a sphere built from Godot's default mesh had thousands. Booleans cut by planes, so they also refuse a brush that is not convex, such as a torus (HFConvexClip.is_convex_solid()). If you add another boolean operation, checkHFConvexClip.boolean_plane_budget()before doing the work. - Rebuilt faces are where winding goes wrong. Faces carried through a transform keep their order; faces built — a clip's cut surface, a hull rebuild — have to be ordered deliberately, and the natural ordering of a ring of coplanar vertices is counter-clockwise about its normal, which is the opposite of what HammerForge needs. Use
HFConvexClip.sort_coplanar_cw(); do not write a second ring sorter. A duplicate one is how Clip to Convex shipped producing entirely inside-out geometry. - Never leave a brush basis with a negative determinant. Godot decides triangle winding from the composed transform, so a mirrored basis silently inverts every face on the brush — invisible in the viewport preview, obvious the moment it bakes. Mirroring is done by folding the reflection back through a reflection along one local axis (
R * basis * H), which cancels the two-1s while leaving world vertices exactly mirrored. SeeHFTransformSystem.flipped_transform(). A mirror that arrives from outside Flip — Godot's own scale gizmo, the Inspector, a level file written before this was enforced — is folded off byHFTransformSystem.normalize_handedness()at the two doors a brush transform comes through,create_brush_from_info()and the brush change tracker's reconcile. Put any third door through it too rather than handling a negative determinant where you find one. If you add an operation that reflects geometry, assert the winding at bake level with an untouched control beside it, astests/test_transform_integration.gddoes — an inverted normal is not visible in any assertion that only checks positions. - Carry a face normal through a brush's basis with
FaceData.normal_through().basis * normalis a normal only while the basis is a turn or a turn and one scale on every axis. A brush stretched with Godot's scale gizmo has every slanted face lean towards the stretched axis that way, and the bake, the selection filters, Clip to Face Plane, Extrude, the sculpt stroke, the occluders and Box UV all went wrong together (#884, #887). For a mesh normal rather than a face,FaceData.normal_basis(basis)is the inverse transpose, worked out once. Test on a stretched slope: a box's faces lie along its axes and pass either way. - Do not assume a winding to tell which side of an edge a point is on. Faces wind clockwise seen from outside.
point_near_polygon_3d()assumed the other way and turned away every sculpt stroke on every face for half a year, while its only test used a counter clockwise fixture. Take the inside from the polygon's middle, and build test faces the way the brushes build them. - Polygon/path tools create brushes via
root.brush_system.create_brush_from_info()with afaceskey containing serialized face data. UseFaceData.from_dict()/to_dict()for serialization. Face dicts includewinding_version: 1; omitting this key triggers load-time migration. - Spawn system (
root.spawn_system): useget_active_spawn()for primary-flag-aware spawn lookup,validate_spawn()for physics-based validation,auto_fix_spawn()to apply suggested fixes,create_default_spawn()for fallback creation. The user-facing Test Level flow calls these automatically. Debug visualisation usesshow_validation_debug()/cleanup_debug(). Spawn properties (primary,angle,height_offset) are defined inentities.jsonand auto-generated in the Objects dock. - Validation tolerances:
HFValidationSystemhas two configurable tolerances —weld_tolerance(default 0.001) for vertex coincidence in welding/micro-gap detection, andplanarity_tolerance(default 0.01) for face-plane deviation. The_edge_key()function used by non-manifold/open-edge topology checks uses a fixed 0.001 precision and must NOT be coupled toweld_tolerance— changing_edge_keyprecision would mask real topology issues when users raise the weld knob. Keep new spatial-hash lookups distance-based with 27-cell neighbor search (see_cell_keys()) rather than single-bucket — bucket boundaries silently miss valid pairs. Always callface.ensure_geometry()after mutatinglocal_vertsso normals and bounds stay in sync. - Incremental bake:
bake_selected()merges into the existingbaked_container— never replace the container wholesale.bake_dirty()uses_last_bake_successto decide whether to clear dirty tags; failed bakes must retain all tags so they can be retried. - Bake preview modes: use the
PreviewModeenum (FULL, WIREFRAME, PROXY). Wireframe must useShaderMaterialwithrender_mode wireframe—StandardMaterial3Dhas nowireframeproperty in Godot 4.7. - Test Level variants (internally Quick Play): the handlers live in
HFDockManageHandler(dock_manage_handler.gd), behind the dock's_on_quick_play*()delegates.on_quick_play_from_camera()andon_quick_play_selected_area()must follow the same severity ≥ 2 blocking, auto-create, and fix-dialog patterns ason_quick_play(), handshow_spawn_fix_dialog()the overrides they launch with (#1010), and put back what they change on both success and error paths: the spawn's position and angle throughrestore_spawn(), and every cordon with its name and switch throughcapture_cordons()before andrestore_cordon_state()after. Give a local read from an untyped spawn reference an explicit type (e.g.var old_angle: float =), because:=cannot infer through it. - Camera yaw propagation: write yaw to
entity_data["angle"](notset_meta). The playtest runtime readsdeg_to_rad(entity_data.get("angle", 0.0))inLevelRoot._resolve_playtest_spawn(). - Avoid adding new dependencies unless necessary.
- HammerForge's own agent bridge is the listener in
addons/hammerforge/mcp/and the server inhammerforge_mcp/. The protocol between them is indocs/superpowers/specs/2026-10-11-mcp-bridge-design.md; change both ends together, and runpython -m pytest hammerforge_mcpandpython tools/mcp_bridge_smoke.py --godot <your godot>after. - No third-party editor bridge is vendored here. Install the one you use into your own
addons/folder;.gitignoreallowlistsaddons/, so it stays untracked. Enabling it rewrites the trackedproject.godot;tools/check_project_settings.pyfails CI if that reaches a branch, and DEVELOPMENT.md has the way to keep it out ofgit status. - Never commit bridge tokens,
user://settings, generated verification logs, editor screenshots, or local client overrides. A token belongs in an environment variable, not in a committed file.
python -m pip install -r requirements-ci.txt
python tools/run_local_checks.py
That runs everything in CI's two lint jobs that can be run here: gdformat, gdlint, ruff, and the guards. The ones it cannot run are listed at the end with the reason for each. Do not keep a copy of that list on this page -- the copy that used to be here had drifted to three commands out of twenty-odd, and CI fails the runner if a step stops being accounted for.
One of the guards refuses a global_position or global_transform written to a
node that is not in the tree yet. Godot writes the local transform in that case
without complaining, and the node lands shifted by whatever its container's
transform is, so parent it first and place it second. See DEVELOPMENT.md if you
need the deliberate-case escape hatch.
Godot hides warnings from scripts under addons/, so CI checks for them
instead. With Godot on your machine and the project imported:
python tools/check_script_warnings.py --godot <path to godot>
Tests live in tests/ and use the GUT framework (installed in addons/gut/). On a fresh clone:
- Get Godot 4.7-stable. The commands below call it
godot, so either put it on your PATH under that name or setGODOTto its path and use that. On Windows the binary isGodot_v4.7-stable_win64.exe:$env:GODOT = "C:\path\to\Godot_v4.7-stable_win64.exe"in PowerShell, then& $env:GODOTin place ofgodot. Elsewhere,export GODOT=/path/to/godot. - Python 3.12, then
python -m pip install -r requirements-ci.txt. - Import the project once. This is not optional: without it GUT prints
"Some GUT class_names have not been imported", runs no tests, and still
exits 0, which reads as a pass.
godot --headless --import --path . - Run one script by name.
-gselectruns every script whose name contains the text. Use it rather than-gtest, which does not narrow a run while.gutconfig.jsonsetsdirs.godot --headless -s res://addons/gut/gut_cmdln.gd --path . -gselect=test_keymap -gexit - Run the whole suite, which takes about eight minutes. Redirect it to a file
and read the summary at the end; a long run piped to a terminal can lose it.
godot --headless -s res://addons/gut/gut_cmdln.gd --path . -gexit > gut.log 2>&1 - Run the lint jobs with
python tools/run_local_checks.py, as above.
- Test files go in
tests/with thetest_prefix (e.g.test_my_feature.gd). - Extend
GutTestand useassert_eq,assert_true,assert_almost_eq, etc. - Use the real
LevelRoot:LevelRoot.new()withauto_spawn_playerandhflevel_autosave_enabledoff, added withadd_child_autoqfree(), and the systems it builds (root.brush_system,root.bake_system, and so on). Where a test has to intercept a call, use an inner class that extendsLevelRootand overrides only that method.tests/test_level_root_shims.gdrefuses a hand-written stand-in: the ones the suite used to build had drifted from the class (#922). It also refuses a test class, or a script a test builds from source, that copies one of the level's enums or the settings it saves, such asgrid_snap(#946, #951). Write a script built from source as one"""block, since that is the only form the guard reads (#957). - Keep tests focused: one behavior per test function.
- For negative-path tests that trigger runtime warnings, use
HFLog.warn()in production code andHFLog.begin_test_capture()/end_test_capture()in tests. This prevents expected warnings from polluting the test output. Seetest_bevel.gdfor the pattern.
Only relevant if you touch tools/ or .github/workflows/. CI runs these and
will fail the build on them.
pip install -r requirements-ci.txt
ruff check tools/
ruff format --check tools/
zizmor .
actionlint is downloaded by the workflow rather than pinned in the requirements
file, so grab the release binary if you want it locally. It runs shellcheck over
every run: block, which is usually what catches things.
Three jobs run on every push and pull request to main: GDScript Lint and
Format, Workflow and Tooling Lint, and GUT Unit Tests. All three have to
pass before anything can merge, and main takes no direct pushes from anyone.
CI does not write to your branch. The published test counts are refreshed when a release is cut, so leave them alone.
If you are waiting on the checks before merging, wait on the commit, never the
branch: the newest run on a branch is often the previous one. python tools/wait_for_ci.py <pr> does this properly and exits non-zero if the run
fails, times out, or never appears.
That job reads the shard logs as artifacts, and they are kept for three days. To re-run a pull request whose shards ran longer ago than that, re-run the whole workflow. Re-running only the failed job finds no logs.
- Be clear about tradeoffs and known limitations.
- Include before/after behavior notes in PR descriptions.
- The PR template lists the checks above; tick what you ran rather than guessing. "I couldn't run GUT locally" is a fine thing to say — CI will.
- Reviews are on the code, not the person. See the Code of Conduct.
Do not open a public issue. See SECURITY.md — level-file
parsing, unintended file writes, secret handling, and HFIORuntime are the
areas most likely to matter.