Chris' Tutorials
Docs/Grid Placement

Grid Placement v6.0

Custom Placement Rules

Write your own placement rules for game-specific validation logic.

StatusDraft
Versionv6.0
UpdatedDevelopment docs generated from GDScript source

This is unreleased documentation in active development. APIs, class names, and behavior may change before the final release.

Draft — Unreleased:This page is in active development. APIs, class names, and behavior may change before the release is finalized. Use it as a preview of what's coming — not as a stable integration target.

This guide shows the safe beginner path for writing your own placement rules.

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

Version note: This guide is for Grid Placement 6.0.0. Examples use Godot 4.5-compatible GDScript patterns.


Pick the Right Base Class

Base class Use it when Examples
PlacementRule Your rule checks owner state, inventory, unlocks, economy, quests, or other game state. Credit cost, tech unlocks, faction permissions.
TileCheckRule Your rule needs tile positions or per-indicator feedback. Bounds, tile data, adjacency, collision footprint rules.

Start with PlacementRule unless you specifically need tile/indicator positions.


Required Rule Methods

validate_placement() -> RuleResult

This is the main pass/fail method.

The base PlacementRule.validate_placement() fails by default with the virtual-method message. Your custom rule should override it.

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 issue array means success.

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

Override this only when you need extra setup. If you do, call super.setup(p_gts) first or intentionally preserve the base behavior.

func setup(p_gts: GridTargetingState) -> Array[String]:
    var issues: Array[String] = super.setup(p_gts)
    if not issues.is_empty():
        return issues

    # custom setup here
    return []

apply() -> Array[String]

Use apply() for side effects that should happen only after placement succeeds.

Examples:

  • Spending materials.
  • Recording analytics.
  • Triggering game-specific post-placement state.

Do not spend inventory inside validate_placement(). Validation can run many times while the preview moves.

tear_down() -> void

Use this to clean up temporary state when the preview/rule lifecycle resets.


Setup Issues vs Runtime Issues

Use the right diagnostic method:

Method Meaning
get_setup_issues() Blocking problems that mean the rule cannot run.
get_runtime_issues() Diagnostics useful for debugging/inspection.
get_editor_issues() Editor-time resource/config problems.

When overriding runtime/editor/setup issue methods, call the base method before adding your own issues.

func get_runtime_issues() -> Array[String]:
    var issues: Array[String] = super.get_runtime_issues()
    if some_required_node == null:
        issues.append("MyRule: some_required_node is missing")
    return issues

TileCheckRule Indicator Behavior

TileCheckRule adds indicator support.

Important contract:

  • get_failing_indicators(p_indicators) calls validate_placement() by default.
  • If validation succeeds, no indicators fail.
  • If validation fails, all provided indicators fail.
  • get_runtime_issues() is not called automatically by get_failing_indicators().
  • Empty indicators pass by design because there are no covered cells to test.

Use the default behavior when your rule has one pass/fail answer for the whole placement.

Override get_failing_indicators() only when:

  • Some indicators should be red and others green.
  • You intentionally want runtime issues to control indicator display.
func get_failing_indicators(p_indicators: Array[RuleCheckIndicator2D]) -> Array[RuleCheckIndicator2D]:
    var failing: Array[RuleCheckIndicator2D] = []

    for indicator in p_indicators:
        if indicator == null:
            continue
        if _indicator_is_invalid(indicator):
            failing.append(indicator)

    return failing

Threaded Physics Safety

If your project has threaded 2D physics enabled, do not call collision methods directly on indicator/shapecast nodes from custom rules.

Avoid:

indicator.is_colliding()
indicator.get_collider(0)
indicator.force_shapecast_update()

Prefer the cached properties exposed by the plugin's indicator/targeting nodes, such as:

indicator.cached_is_colliding
indicator.cached_colliders

This avoids unsafe direct-space-state access outside the physics tick.


Example: Simple Economy Rule

class_name MyEconomyRule
extends PlacementRule

@export var required_credits: int = 100

func validate_placement() -> RuleResult:
    var owner_root: Node = _grid_targeting_state.get_owner_root()
    if owner_root == null:
        return RuleResult.build(self, ["No placement owner found"])

    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_root()
    var economy: Node = owner_root.get_node_or_null("Economy") if owner_root else null
    if economy:
        economy.credits -= required_credits
    return []

This is a PlacementRule, not a TileCheckRule, because it checks game state rather than individual covered tiles.


Example: Simple Tile Bounds Rule

class_name MyLocalBoundsRule
extends TileCheckRule

@export var min_cell := Vector2i(-5, -5)
@export var max_cell := Vector2i(5, 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_cell.x or target_cell.x > max_cell.x:
        return RuleResult.build(self, ["Placement is outside the allowed X range"])

    if target_cell.y < min_cell.y or target_cell.y > max_cell.y:
        return RuleResult.build(self, ["Placement is outside the allowed Y range"])

    return RuleResult.build(self, [])

This rule uses the default TileCheckRule behavior: if validation fails, all generated indicators fail.


Example: Runtime Issues Affect Indicator Display

Use this pattern only when get_runtime_issues() is intentionally the same logic you want for indicator failure.

class_name MyRuntimeIssueIndicatorRule
extends TileCheckRule

var _runtime_issues: Array[String] = []

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

func get_runtime_issues() -> Array[String]:
    var issues: Array[String] = super.get_runtime_issues()
    issues.append_array(_runtime_issues)
    return issues

func get_failing_indicators(p_indicators: Array[RuleCheckIndicator2D]) -> Array[RuleCheckIndicator2D]:
    if p_indicators.is_empty():
        return []

    if get_runtime_issues().is_empty():
        return []

    return p_indicators.duplicate()

Where to Attach Custom Rules

Location Best for
PlacementSettings.placement_rules Global defaults every object should usually follow.
PlacementProfile.placement_rules Shared category behavior, such as all fences or all decorations.
ScenePlacementEntry.placement_rules One specific placeable's unique requirements.

Start local on the ScenePlacementEntry while testing. Move the rule to a profile or global settings only after you know it should apply broadly.


Common Mistakes

  • Forgetting to override validate_placement().
  • Spending inventory in validate_placement() instead of apply().
  • Using TileCheckRule when no tile/indicator data is needed.
  • Expecting get_runtime_issues() to turn indicators red without overriding get_failing_indicators().
  • Writing a tile rule for a placeable that has no collision shapes/indicators.
  • Duplicating the same rule logic in UI scripts.
  • Returning vague messages like "failed" instead of player/developer-readable issue text.

Testing Checklist

For every custom rule, test:

  • A success case.
  • A failure case.
  • Missing setup data.
  • A placeable with the expected collision shape footprint.
  • A placeable without collision shapes if your rule depends on indicators.
  • The final PlacementReport.get_issues() text shown to the user.

Related Guides


Source

docs/v6-0/guides/custom-placement-rules.md

Plugin docs root:gdscript/plugins/grid_placement_dev/docs