Placement Rules (5.0.3)

This guide covers runtime placement validation. For configuration and runtime-readiness checks, see Validation: Configuration.

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 practical rule layers in the 5.0.3 runtime:

  • Base rules
    • configured on GBSettings.placement_rules
    • apply broadly to placement workflows
  • Placeable-specific rules
    • configured on each Placeable
    • apply only to that placeable’s preview/placement flow

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

GridBuilding 5.0.3 includes these built-in placement rules:

Rule ClassBase ClassPurpose
WithinTileMapBoundsRuleTileCheckRuleRestricts placement to valid tilemap cells
CollisionCheckRuleTileCheckRuleChecks 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.

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.

Example: custom grid bounds rule

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class_name MyGridBoundsRule
extends TileCheckRule

@export var min_x: int = -5
@export var max_x: int = 5
@export var min_y: int = -5
@export var max_y: int = 5

func validate_placement() -> RuleResult:
    var target_map: TileMapLayer = _grid_targeting_state.target_map
    var positioner: Node2D = _grid_targeting_state.positioner
    
    if target_map == null or positioner == null:
        return RuleResult.build(self, ["Targeting state incomplete"])
    
    var local_pos: Vector2 = target_map.to_local(positioner.global_position)
    var target_cell: Vector2i = target_map.local_to_map(local_pos)
    
    if target_cell.x < min_x or target_cell.x > max_x or target_cell.y < min_y or target_cell.y > max_y:
        return RuleResult.build(self, ["Placement is outside the allowed bounds"])
    
    return RuleResult.build(self, [])

Example: non-tile gameplay rule

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class_name MyEconomyRule
extends PlacementRule

@export var required_credits: int = 100

func validate_placement() -> RuleResult:
    var owner_root: Node = _grid_targeting_state.get_owner()
    var economy: Node = owner_root.get_node_or_null("Economy")
    
    if economy == null:
        return RuleResult.build(self, ["Economy service not available"])
    
    if economy.credits < required_credits:
        return RuleResult.build(self, ["Insufficient credits"])
    
    return RuleResult.build(self, [])

func apply() -> Array[String]:
    var owner_root: Node = _grid_targeting_state.get_owner()
    var economy: Node = owner_root.get_node_or_null("Economy")
    if economy:
        economy.credits -= required_credits
    return []

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

Example of recommended setup:

1
2
3
4
5
6
7
# Add a CollisionShape2D child to your placeable scene
# This provides precise control over which tiles are validated
func _ready():
    var collision_shape = CollisionShape2D.new()
    collision_shape.shape = RectangleShape2D.new()
    collision_shape.shape.extents = Vector2(32, 32)  # 64x64 pixel footprint
    add_child(collision_shape)

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

Example: Proper collision setup for ValidPlacementTileRule:

1
2
3
4
5
6
7
8
# For a 2x2 building that requires "buildable" custom data on all covered tiles
func _ready():
    var collision_shape = CollisionShape2D.new()
    collision_shape.shape = RectangleShape2D.new()
    collision_shape.shape.extents = Vector2(64, 64)  # Covers 2x2 tiles at 32px each
    add_child(collision_shape)

    # The ValidPlacementTileRule will then check all 4 tiles for the required custom data

CollisionCheckRule

  • extends TileCheckRule
  • checks each indicator with a shapecast collision mask
  • excludes preview bodies and configured collision exclusions
  • supports both clear-space and required-overlap flows through pass_on_collision

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

  • 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