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
CollisionsCheckRuleTileCheckRuleChecks for overlapping physics
ValidPlacementTileRuleTileCheckRuleBasic validity check
SpendMaterialsRuleGenericPlacementRuleConsumes inventory/materials after successful placement

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 []

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, CollisionsCheckRule, 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

CollisionsCheckRule

  • 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
  • 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