Placement Rules

This guide covers runtime placement validation. For configuration and runtime-readiness checks, see Validation: Configuration. For writing your own rules, see Custom Placement Rules.

Version note: This guide is validated for Grid Building 5.0.8.


TileCheckRule API Contract (5.0.8)

⚠️ COMMON BUG ⚠️

get_runtime_issues() is NOT called automatically by get_failing_indicators(). If your rule returns failures from get_runtime_issues() but indicators show green, you forgot to override get_failing_indicators().

See test/demos/building/my_grid_bounds_rule_bug_test.gd for a full reproduction and custom-placement-rules.md for the fix pattern.

TileCheckRule has three methods that control indicator state:

MethodCalled by system?Controls indicator?Use for
validate_placement()✅ via get_failing_indicators()✅ yesPass/fail validation
get_runtime_issues()❌ not automatic❌ no (unless you override get_failing_indicators())Diagnostics
get_failing_indicators()✅ yes✅ yesOverride to call get_runtime_issues()

The critical fix in 5.0.8: get_runtime_issues() is NOT called automatically by get_failing_indicators(). If your custom rule overrides get_runtime_issues() and expects it to control indicator display, you MUST also override get_failing_indicators() to call it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class_name MyGridBoundsRule
extends TileCheckRule

func get_failing_indicators(p_indicators: Array[RuleCheckIndicator]) -> Array[RuleCheckIndicator]:
    if p_indicators.is_empty():
        return []
    var issues: Array[String] = get_runtime_issues()  # NOW called
    if issues.is_empty():
        return []
    return p_indicators.duplicate()

Also fix for 5.0.8: When overriding get_runtime_issues(), call super.get_runtime_issues() BEFORE appending custom issues. This prevents your issues from being lost when the base class returns early.

1
2
3
4
5
func get_runtime_issues() -> Array[String]:
    var issues: Array[String] = super.get_runtime_issues()  # Base first
    if _should_fail:
        issues.append_array(_runtime_issues)  # Custom after
    return issues

What Placement Rules Are

Placement rules are resources that validate whether an object can be placed at the current target. Each rule:

  • Receives the active targeting state via setup(p_gts: GridTargetingState).
  • Returns a RuleResult from validate_placement().
  • May run post-success side effects in apply().

Rule Sources

There are two rule layers in the 5.0.8 runtime, combined during preview creation:

Base Rules (GBSettings)

  • Configured on GBSettings.placement_rules.
  • Apply broadly to all placement workflows.
  • Set once on the composition container.

Placeable-Specific Rules (Placeable resource)

  • Configured on each Placeable via placeable.placement_rules.
  • Apply only to that placeable’s preview/placement flow.
  • Can include placeable.ignore_base_rules = true to skip base rules.

Rule Combination Flow

When enter_build_mode() is called, rules are combined via PlacementValidator.get_combined_rules():

1
2
3
4
5
6
7
Base rules (GBSettings.placement_rules)
   [if !ignore_base] → Combined with placeable rules
Placeable rules (placeable.placement_rules)
   [deduplicated] → Final rule list passed to IndicatorManager

The combination logic (PlacementRuleValidationLogic.combine_rules()):

  1. If ignore_base_rules = false: append base rules first
  2. Always append placeable rules
  3. Deduplicate while preserving order

Ignore Base Rules

If Placeable.ignore_base_rules = true, base rules are skipped — only placeable-specific rules apply.


Indicator Creation During Preview

When a preview is created (either for placement or manipulation), indicators are spawned and wired to rules:

Placement Flow

1
2
3
4
5
6
7
8
enter_build_mode(placeable)
  → create_preview(placeable)
  → _try_setup(preview_instance, placeable_rules, ignore_base_rules)
      → IndicatorManager.try_setup()
          → IndicatorService.setup_indicators()
              → IndicatorSetupUtils.execute_indicator_setup()
                  → CollisionMapper.map_collision_positions_to_rules()
                  → IndicatorFactory.generate_indicators()

Manipulation Flow

1
2
3
4
_start_move(move_data)
  → source.create_copy()  (manipulation copy)
  → _indicator_context.get_manager().try_setup(move_rules, targeting, ignore_base)
      → Same indicator creation path as placement

Both flows use the same indicator creation path. The only difference is:

  • Placement: rules come from placeable.placement_rules
  • Manipulation: rules come from source.get_move_rules()
  • Manipulation additionally sets collision_exclusions = [source.root] to exclude the original object

Rule → Indicator Wiring

Each RuleCheckIndicator holds references to its rules via add_rule(). When update_validity_state() runs:

1
2
3
4
5
6
indicator.update_validity_state()
  → indicator.validate_rules(rules)
      → for each rule: rule.get_failing_indicators([self])
          → Returns which indicators are failing this rule

indicator.valid = (failing_indicators.size() == 0)

How Indicators Are Generated From Collision Shapes

Indicators are not magical — they are created at positions derived from collision shapes on your placeable’s packed_scene. Understanding this pipeline is essential for diagnosing silent failures.

The Indicator Generation Pipeline

1
2
3
4
5
6
7
packed_scene (PackedScene)
  → instance the scene (preview or placed object)
  → find CollisionObject2D / CollisionShape2D nodes
  → map shape positions → grid tile coordinates
  → create RuleCheckIndicator at each tile
  → wire TileCheckRules to indicators
  → rules evaluate against indicator positions

Layer Configuration: Two Separate Settings

1. collision_layer on your placeable’s nodes — “What layer this object IS”

1
2
3
4
5
6
# In your placeable's packed_scene (e.g., house.tscn)
StaticBody2D:
  collision_layer: 1, 10    # Object IS on layers 1 and 10
  collision_mask: 0           # Object doesn't detect anything
  CollisionShape2D:
    ...

2. collision_mask on TileCheckRules (like CollisionsCheckRule) — “What layers this rule CHECKS”

1
2
3
4
5
# In your CollisionsCheckRule.collision_mask
shape_cast_collision_mask: 10    # Rule looks for objects on layer 10

# Rule shapecast sweeps across indicator positions
# → Detects any object whose collision_layer includes 10

Why Misassigned Layers Cause Silent Failures

Scenariocollision_layerrule collision_maskResult
Correct1, 1010✅ Rule detects object
Wrong - object not on target layer1 only10❌ Rule finds nothing — placement passes vacuously
Wrong - rule doesn’t look for layer1, 101❌ Rule looks for wrong layer — finds nothing

Example of silent failure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Placeable has collision_layer = 1 (no targeting layer)
StaticBody2D:
  collision_layer: 1
  collision_mask: 0
  CollisionShape2D: ...

# CollisionsCheckRule has collision_mask = 10
# Indicator is created at shape position
# Rule casts to find layer 10 objects → finds nothing
# Validation passes vacuously (no collisions found)
# But placement actually succeeded on top of another object!

Special Case: Packed Scenes With No Collision Shapes

If your placeable’s packed_scene has no CollisionObject2D or CollisionShape2D nodes:

  • No indicators are generated — the system has no positions to create indicators at
  • No TileCheckRule rules run — rules like WithinTileMapBoundsRule, CollisionsCheckRule, and ValidPlacementTileRule have nothing to validate
  • All tile-based validation is bypassed — this is logically correct but can be surprising

This can be intentional for objects that:

  • Are purely visual (no gameplay collision)
  • Use a custom validation mechanism (e.g., raycasts from a different system)
  • Should always pass tile-based checks regardless of position

If you want tile-based validation but have no collision shapes:

  1. Add collision shapes to your placeable’s packed scene (recommended)
  2. Use VisualBoundsFallback for WithinTileMapBoundsRule only — other rules still won’t work
  3. Create a custom TileCheckRule that uses a different position source (advanced)

Manipulation vs Placement

AspectPlacementManipulation
Rules sourceplaceable.placement_rulessource.get_move_rules()
ignore_base fromplaceable.ignore_base_rulessource.settings.ignore_base_rules
Collision exclusionsDefault (none)[source.root] — excludes original object
Preview instanceNew instance createdCopy of source normalized to identity

Both use IndicatorManager.try_setup() with the same rule combination logic.

\n5.0.4 update: CollisionCheckRule now includes a manual post-cast exclusion filter to suppress false-positive collisions during manipulation moves. Godot silently ignores ShapeCast2D.add_exception() when the cast origin is outside the excluded body’s bounds.

Core Rule Classes

ClassPurpose
PlacementRuleBase class for all placement rules
TileCheckRuleBase class for rules that evaluate tile/indicator state
RuleResultContains validation outcome and issues

Built-in Rules

Grid Building 5.0.8 includes these built-in placement rules:

Rule ClassBase ClassPurpose
WithinTileMapBoundsRuleTileCheckRuleRestricts placement to valid tilemap cells
CollisionsCheckRuleTileCheckRuleChecks for overlapping physics
ValidPlacementTileRuleTileCheckRuleBasic validity check
SpendMaterialsRuleGenericPlacementRuleConsumes inventory/materials after successful placement; reusable generic inventory-spend rule

Rule Lifecycle

1. setup(p_gts: GridTargetingState) -> Array[String]

Called before the rule is used. PlacementRule.setup(...) stores the targeting state and marks the rule ready.

2. validate_placement() -> RuleResult

Called during validation. This is where the rule decides pass/fail.

⚠️ CRITICAL: validate_placement() MUST be overridden in your custom rule. The base class implementation returns a failure by default with the message “This is a virtual condition function…”. If you don’t override it, your rule will always fail validation.

For the full custom-rule contract, including get_setup_issues(), get_runtime_issues(), and TileCheckRule indicator behavior, see Custom Placement Rules.

\n5.0.4 update: CollisionCheckRule now includes a manual post-cast exclusion filter to suppress false-positive collisions during manipulation moves. Godot silently ignores ShapeCast2D.add_exception() when the cast origin is outside the excluded body’s bounds.

3. apply() -> Array[String]

Called after successful placement if the workflow uses the apply phase for side effects.

4. tear_down() -> void

Called when the preview/rule evaluation cycle is reset or completed.


Writing Custom Rules

If you are defining your own rules, use the dedicated guide:

That guide covers:

  • Godot 4.4-compatible custom rule patterns
  • When to extend PlacementRule vs TileCheckRule
  • get_setup_issues() vs get_runtime_issues()
  • TileCheckRule indicator fallback behavior
  • Common authoring mistakes and tested expectations

Example: Generic Inventory Spend Rule

SpendMaterialsRuleGeneric is the reusable rule to use when placement should deduct materials from the player’s inventory or resource stack after a successful placement.

Use it when:

  • The cost belongs to the placement flow, not the UI.
  • The spend should happen only after validation passes.
  • Your inventory lives under the owner root or a node the rule can locate through its configuration.
  • You want one shared cost rule instead of custom spend logic on each placeable.

Good fit examples:

  • A house costs wood and stone.
  • A trap costs gold after a valid placement.
  • A build action spends a generic resource stack from the owning player.

Keep the rule generic and let placeables or config supply the actual cost data. That keeps cost handling consistent across guides, demo scenes, and future placeables.


Built-in Rule Behavior Plugin Users Should Know

WithinTileMapBoundsRule

  • Extends TileCheckRule.
  • Checks each indicator against the active TileMapLayer.
  • Fails when indicator cells resolve to no TileData.

Visual Bounds Fallback

When no collision shapes are detected on a placeable object, WithinTileMapBoundsRule automatically falls back to using visual component bounds for validation. This fallback:

  1. Detects visual components: Searches for Sprite2D and Polygon2D nodes in the object hierarchy.
  2. Calculates bounding box: Uses VisualBoundsHelper.get_visual_bounding_box() to compute the union of all visual component rectangles.
  3. Checks corner points: Validates that all four corners of the bounding box are over valid tiles.

When the fallback triggers:

  • The rule has no indicators (collision shapes not detected).
  • The object contains visual components (Sprite2D with texture or Polygon2D with polygon data).
  • No collision region is defined for the object.

Why collision regions are recommended: While the visual bounds fallback provides basic validation, implementing explicit collision regions is strongly recommended for the following reasons:

  1. Precise control: Collision shapes allow you to define the exact footprint of your object, independent of visual representation.
  2. Accurate validation: Visual bounds may include transparent areas or visual effects that don’t represent the actual placement footprint.
  3. Performance: Collision-based detection is more efficient than traversing node hierarchies for visual components.
  4. Consistency: Using collision shapes ensures consistent behavior across all placement rules (WithinTileMapBoundsRule, CollisionCheckRule, ValidPlacementTileRule).
  5. Flexibility: You can create complex collision shapes (concave polygons, multiple shapes) that accurately represent your object’s placement requirements.

Visual bounds fallback limitations:

  • May include non-solid visual areas in validation.
  • Cannot represent complex footprints (L-shapes, concave areas).
  • Less performant for objects with many visual components.
  • Visual effects (particles, animations) may cause unexpected validation behavior.

ValidPlacementTileRule

  • Extends TileCheckRule.
  • Validates that tiles have required custom data fields and matching values.
  • Does NOT have a visual bounds fallback — requires collision shapes for indicator generation.

Important: No Visual Bounds Fallback

Unlike WithinTileMapBoundsRule, ValidPlacementTileRule does not fall back to visual bounds when collision shapes are missing. This rule requires collision-based indicators to function properly.

Why collision regions are critical for ValidPlacementTileRule:

  1. No fallback mechanism: Without collision shapes, the rule cannot generate indicators and will fail to validate.
  2. Tile data validation: The rule checks specific tile custom data fields (e.g., “buildable”, “walkable”) which require precise tile position detection.
  3. Multiple tile coverage: Collision shapes define exactly which tiles need to have matching custom data.
  4. Complex footprint support: Buildings often span multiple tiles, and collision shapes accurately represent which tiles must be validated.

If you don’t implement collision regions for ValidPlacementTileRule:

  • The rule will have no indicators to check.
  • Validation will pass vacuously (no indicators = no violations) — this is logically correct but provides no validation.
  • You lose the ability to validate that all covered tiles have the required custom data.
  • Placement may succeed on tiles that shouldn’t be valid for building.

Why vacuous truth is the correct behavior:

  • The rule checks tile custom data on a per-tile basis.
  • If there are no collision shapes, there are no tiles to check.
  • No tiles to check means no violations can exist.
  • This is semantically correct: “all covered tiles have valid data” is true when there are no covered tiles.
  • A visual bounds fallback would be inappropriate because visual bounds don’t tell you which tiles to check for custom data.

CollisionsCheckRule

  • Extends TileCheckRule.
  • Checks each indicator with a shapecast collision mask.
  • Uses collision_exclusions from GridTargetingState (not on the rule itself) to exclude preview bodies and other nodes.
  • Supports both clear-space and required-overlap flows through pass_on_collision.

\n**Note: collision_exclusions are configured on GridTargetingState.collision_exclusions, not on the rule. See Targeting Flow for details.

5.0.4 update:

  • Added _ensure_messages() lazy-loading safeguard to prevent null derefs when nested resources fail to deserialize in exported builds.
  • Fixed is_instance_valid check missing — prevents accessing freed indicator objects.
  • Added manual post-cast exclusion filter to work around Godot ShapeCast2D.add_exception() outside-bounds bug.
  • Templates now use collision_mask = 1 and apply_to_objects_mask = 1 for project-agnostic defaults.

Practical Guidance

  • Always call super.setup(...) if you override setup.
  • Use TileCheckRule when your rule depends on indicator positions or tilemap cells.
  • Use PlacementRule directly when the rule depends on owner/inventory/game-state logic.
  • Use SpendMaterialsRuleGeneric when the rule needs to deduct resources after successful placement.
  • Keep side effects in apply() if they should only happen after successful placement.
  • Put rules that should affect every placement into GBSettings.placement_rules.

Common Mistakes

  • Not overriding validate_placement() — The base class returns a failure by default. Custom rules MUST override this method to return RuleResult.build(self, []) for success.
  • Forgetting to call setup(...) before validation.
  • Treating rules as editor-only resources instead of runtime logic.
  • Duplicating grid-position logic in UI instead of reading targeting state.
  • Putting placeable-specific rules into global settings when they should live on the Placeable.