Custom Placement Rules

This guide covers how to author your own placement rules in Grid Building 5.0.8.

For the built-in rule catalog and runtime rule flow, see Placement Rules.

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


Godot 4.4 Compatibility

Grid Building 5.0.8 is intended to stay compatible with Godot 4.4 workflows.

That means custom rules should not rely on newer GDScript abstract-class syntax for enforcement. Instead, the plugin uses a 4.4-safe virtual-method contract:

  • PlacementRule.validate_placement() returns a failure by default.
  • Custom rules must override the methods they actually need.
  • Tests enforce the contract for common authoring mistakes.

If you forget to override validate_placement(), the base rule fails with:

  • This is a virtual condition function and should be implemented in a class that inherits from PlacementRule

Choose the Right Base Class

| Base class | Use it when | Typical examples | | *********- | ************- | ***************- | | PlacementRule | The rule depends on owner state, inventory, economy, or gameplay services | Credit cost, tech unlocks, faction rules | | TileCheckRule | The rule depends on indicator positions, tile coverage, or per-tile validity | Bounds checks, tile data checks, collision checks |


Thread Safety and Threaded Physics (5.0.8+)

Important

If your project has Godot’s threaded physics enabled (physics/2d/run_on_separate_thread = true), calling standard C++ collision methods (like is_colliding(), get_collider(), or force_shapecast_update()) directly on indicator nodes from your custom rules can trigger engine crashes or DirectSpaceState2D is locked errors.

To ensure your custom TileCheckRule is fully thread-safe:

  1. Do not call indicator.is_colliding() or indicator.get_collider(...) directly.
  2. Always query the transparently cached properties:
    • Use indicator.cached_is_colliding to check for any active collisions.
    • Use indicator.cached_colliders to retrieve the array of colliding nodes.

These properties will automatically update the collision state safely within the physics tick when indicators move, or return the last known safe state when accessed outside the physics thread.


Required Behavior

1. Override validate_placement()

This is the actual pass/fail entry point.

1
2
3
4
5
6
7
class_name MyRule
extends PlacementRule

func validate_placement() -> RuleResult:
    if _should_block_placement():
        return RuleResult.build(self, ["Placement is blocked"])
    return RuleResult.build(self, [])

An empty issues array means success.

2. Use get_setup_issues() only for blocking setup problems

Use this when the rule is not usable at all because required data is missing.

1
2
3
4
5
func get_setup_issues() -> Array[String]:
    var issues: Array[String] = super.get_setup_issues()
    if build_area == null:
        issues.append("Missing build area dependency")
    return issues

These issues are returned from setup(...) and will fail rule setup.

3. Use get_runtime_issues() for non-blocking diagnostics

Use this for notes that are useful to inspect but should not fail setup.

1
2
3
4
5
func get_runtime_issues() -> Array[String]:
    var issues: Array[String] = super.get_runtime_issues()  # Call super FIRST (5.0.8 fix)
    if debug_show_bounds and build_area != null and not build_area.has_point(last_checked_tile):
        issues.append("Informational: target is outside highlighted build area")
    return issues

5.0.8 fix: Always call super.get_runtime_issues() BEFORE appending your custom issues. The base class may return early (e.g., “Property [target_map] is NULL”) and if you call _get_issues() first, your issues get lost.

In 5.0.8, these messages remain available for diagnostics, but they do not get appended into setup failures. To make runtime issues affect indicator display, see the section on overriding get_failing_indicators() below.

4. For TileCheckRule, override get_failing_indicators() only when you need per-indicator precision

If you do not override it, TileCheckRule falls back to validate_placement():

  • success returns no failing indicators
  • failure marks all provided indicators as failing

That fallback is good enough for many custom rules. Override get_failing_indicators() only if you need some tiles red and others green.

5. Making get_runtime_issues() affect indicator display (5.0.8)

⚠️ QUICK REFERENCE - Common Bug ⚠️

If your rule overrides get_runtime_issues() and expects indicators to show red/green based on those issues, you MUST override get_failing_indicators() to call it. The base implementation only calls validate_placement().

The bug: Indicator shows green when it should be red, even though get_runtime_issues() returns failures. The fix: Override get_failing_indicators() (see pattern below).

See also: test/demos/building/my_grid_bounds_rule_bug_test.gd for a full reproduction.

If you want your rule’s get_runtime_issues() to control whether the indicator shows red or green, you MUST override get_failing_indicators():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class_name MyGridBoundsRule
extends TileCheckRule

var _should_fail: bool = false
var _runtime_issues: Array[String] = []

func validate_placement() -> RuleResult:
    var issues: Array[String] = _get_validation_issues()
    return RuleResult.build(self, issues)

func get_runtime_issues() -> Array[String]:
    var issues: Array[String] = super.get_runtime_issues()  # Base FIRST (5.0.8 fix)
    if _should_fail:
        issues.append_array(_runtime_issues)
    return issues

## Override this to make get_runtime_issues affect indicator state
func get_failing_indicators(p_indicators: Array[RuleCheckIndicator]) -> Array[RuleCheckIndicator]:
    if p_indicators.is_empty():
        return []
    var issues: Array[String] = get_runtime_issues()
    if issues.is_empty():
        return []
    return p_indicators.duplicate()

func _get_validation_issues() -> Array[String]:
    var issues: Array[String] = []
    var target_map: TileMapLayer = _grid_targeting_state.target_map
    var positioner: Node2D = _grid_targeting_state.positioner
    if target_map == null or positioner == null:
        issues.append("Targeting state incomplete")
    return issues

func setup(p_gts: GridTargetingState) -> Array[String]:
    _grid_targeting_state = p_gts
    _ready = true
    return []

Why this is needed: The base TileCheckRule.get_failing_indicators() only calls validate_placement(). It never calls get_runtime_issues(). So without this override, your runtime diagnostics are invisible to the indicator system.


Authoring Patterns

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

Simple tile-based rule with validate-only fallback

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

Because this extends TileCheckRule, a failed result will also make all supplied indicators invalid unless you override get_failing_indicators() with more specific logic.

Tile-based rule with explicit per-indicator failures

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class_name MySelectiveTileRule
extends TileCheckRule

func validate_placement() -> RuleResult:
    var failing: Array[RuleCheckIndicator] = get_failing_indicators(indicators)
    if failing.is_empty():
        return RuleResult.build(self, [])
    return RuleResult.build(self, ["Some covered tiles are invalid"])

func get_failing_indicators(p_indicators: Array[RuleCheckIndicator]) -> Array[RuleCheckIndicator]:
    var failing: Array[RuleCheckIndicator] = []
    for indicator in p_indicators:
        if indicator == null:
            continue
        if _indicator_is_invalid(indicator):
            failing.append(indicator)
    return failing

Making get_runtime_issues() affect indicator display (5.0.8)

If your rule overrides get_runtime_issues() and you want those issues to control whether the indicator shows red or green, you MUST override get_failing_indicators():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class_name MyGridBoundsRule
extends TileCheckRule

var _should_fail: bool = false
var _runtime_issues: Array[String] = []

func validate_placement() -> RuleResult:
    return RuleResult.build(self, _get_validation_issues())

func get_runtime_issues() -> Array[String]:
    var issues: Array[String] = super.get_runtime_issues()  # Base FIRST (5.0.8 fix)
    if _should_fail:
        issues.append_array(_runtime_issues)
    return issues

## Override this to make get_runtime_issues affect indicator state
func get_failing_indicators(p_indicators: Array[RuleCheckIndicator]) -> Array[RuleCheckIndicator]:
    if p_indicators.is_empty():
        return []
    var issues: Array[String] = get_runtime_issues()
    if issues.is_empty():
        return []
    return p_indicators.duplicate()

func _get_validation_issues() -> Array[String]:
    var issues: Array[String] = []
    var target_map: TileMapLayer = _grid_targeting_state.target_map
    var positioner: Node2D = _grid_targeting_state.positioner
    if target_map == null or positioner == null:
        issues.append("Targeting state incomplete")
    return issues

func setup(p_gts: GridTargetingState) -> Array[String]:
    _grid_targeting_state = p_gts
    _ready = true
    return []

Why this is needed (5.0.8): The base TileCheckRule.get_failing_indicators() only calls validate_placement(). It never calls get_runtime_issues(). So without this override, your runtime diagnostics are invisible to the indicator system.


What the Tests Enforce

The focused custom-rule suites currently lock in these behaviors:

| Situation | Expected result | | ********* | *************** | | validate_placement() returns issues | Placement fails with those issues | | TileCheckRule fails and does not override get_failing_indicators() | All provided indicators become invalid | | get_runtime_issues() returns an informational note | The note is visible in runtime diagnostics only | | get_setup_issues() returns a message | setup(...) fails with that message | | setup_rules(...) is called with null targeting state | Setup fails with GridTargetingState is null |

This matters because it keeps setup failures, placement failures, and indicator visuals aligned instead of mixing them together.


Common Mistakes

  • Not overriding validate_placement().
  • Putting placement pass/fail logic into get_runtime_issues().
  • Returning setup-breaking messages from diagnostics that should be informational only.
  • Overriding setup(...) and not calling super.setup(...) unless you are deliberately reproducing the base behavior yourself.
  • Extending PlacementRule when the rule really needs tile or indicator data from TileCheckRule.

Practical Recommendations

  • Start with validate_placement() only.
  • Add get_setup_issues() only when the rule truly cannot run.
  • Add get_runtime_issues() only for diagnostics you want to inspect without blocking setup.
  • Stay on the TileCheckRule fallback unless you need per-indicator coloring.
  • Keep side effects in apply() so they happen only after successful validation.