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)callsvalidate_placement()by default.- If validation succeeds, no indicators fail.
- If validation fails, all provided indicators fail.
get_runtime_issues()is not called automatically byget_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 ofapply(). - Using
TileCheckRulewhen no tile/indicator data is needed. - Expecting
get_runtime_issues()to turn indicators red without overridingget_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