Custom Placement Rules

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

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

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


Godot 4.4 Compatibility

Grid Building 5.0.7 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 classUse it whenTypical examples
PlacementRuleThe rule depends on owner state, inventory, economy, or gameplay servicesCredit cost, tech unlocks, faction rules
TileCheckRuleThe rule depends on indicator positions, tile coverage, or per-tile validityBounds checks, tile data checks, collision checks

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()
    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

In 5.0.7, these messages remain available for diagnostics, but they do not get appended into setup failures.

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.


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

What the Tests Enforce

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

SituationExpected result
validate_placement() returns issuesPlacement fails with those issues
TileCheckRule fails and does not override get_failing_indicators()All provided indicators become invalid
get_runtime_issues() returns an informational noteThe note is visible in runtime diagnostics only
get_setup_issues() returns a messagesetup(...) fails with that message
setup_rules(...) is called with null targeting stateSetup 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.