This guide covers runtime placement validation. For configuration and runtime-readiness checks, see [Validation: Configuration](./validation.html. For writing your own rules, see [Custom Placement Rules](./custom-placement-rules.html.
Version note: This guide is validated for Grid Building 5.0.8.
TileCheckRule API Contract (5.0.8)
⚠️ COMMON BUG ⚠️
get_runtime_issues()is NOT called automatically byget_failing_indicators(). If your rule returns failures fromget_runtime_issues()but indicators show green, you forgot to overrideget_failing_indicators().See
test/demos/building/my_grid_bounds_rule_bug_test.gdfor a full reproduction andcustom-placement-rules.htmlfor the fix pattern.
TileCheckRule has three methods that control indicator state:
| Method | Called by system? | Controls indicator? | Use for |
|---|---|---|---|
validate_placement() |
✅ via get_failing_indicators() |
✅ yes | Pass/fail validation |
get_runtime_issues() |
❌ not automatic | ❌ no (unless you override get_failing_indicators()) |
Diagnostics |
get_failing_indicators() |
✅ yes | ✅ yes | Override to call get_runtime_issues() |
The critical fix in 5.0.8: get_runtime_issues() is NOT called automatically by get_failing_indicators(). If your custom rule overrides get_runtime_issues() and expects it to control indicator display, you MUST also override get_failing_indicators() to call it.
class_name MyGridBoundsRule
extends TileCheckRule
func get_failing_indicators(p_indicators: Array[RuleCheckIndicator]) -> Array[RuleCheckIndicator]:
if p_indicators.is_empty():
return []
var issues: Array[String] = get_runtime_issues() # NOW called
if issues.is_empty():
return []
return p_indicators.duplicate()
Also fix for 5.0.8: When overriding get_runtime_issues(), call super.get_runtime_issues() BEFORE appending custom issues. This prevents your issues from being lost when the base class returns early.
func get_runtime_issues() -> Array[String]:
var issues: Array[String] = super.get_runtime_issues() # Base first
if _should_fail:
issues.append_array(_runtime_issues) # Custom after
return issues
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
RuleResultfromvalidate_placement(). - May run post-success side effects in
apply().
Rule Sources
In 6.0.0, rules are resolved through a three-tier model, combined during preview creation. This allows you to define global defaults, group-level behavior (via tags), and per-object overrides.
1. Base Rules (Global)
- Configured on
PlacementSettings.placement_rules. - Apply broadly to all placement workflows in the container.
- Use for rules every object must follow (e.g., "Must be within tilemap bounds").
2. Category Rules (PlacementProfile)
- Configured on each
PlacementProfileviaprofile.placement_rules. - Apply to any
Placeablethat carries the tag. - Use for grouping behaviors (e.g., all "fences" use a specific adjacency rule, or all "doodads" ignore base collisions).
3. Placeable-Specific Rules (Local)
- Configured on each
Placeableviaplaceable.placement_rules. - Apply only to that specific object's placement flow.
- Use for unique logic (e.g., "This specific fountain requires a nearby water source").
Rule Resolution Flow
When enter_place_mode() is called, rules are combined via the validator:
Base Rules (Global)
↓
[if tag.ignore_base_rules] → Skip Base?
↓
Category Rules (Tags)
↓
[if placeable.ignore_base_rules] → Skip Base & Category?
↓
Placeable-Specific Rules
↓
[Deduplicated] → Final active rules
Overriding and Ignoring Tiers
You can use "Ignore" flags to break the inheritance chain for specialized objects:
| Setting | Effect | Use Case |
|---|---|---|
PlacementProfile.ignore_base_rules |
Placeables with this tag will skip global base rules. | "Doodad" tag that allows objects to bypass global collision checks. |
Placeable.ignore_base_rules |
This specific object will skip both global and tag-level rules. | A "God-mode" object that can be placed anywhere regardless of rules. |
Note: If multiple tags are present, if any tag has
ignore_base_rules = true, the global base rules are skipped.
Configuring "Doodads" (Free-form Placement)
To create "doodad" style objects that ignore global collisions and snapping:
- Create a
PlacementProfile(e.g.,doodad_profile.tres). - Enable
ignore_base_ruleson the tag to skip globalCollisionsCheckRuleorWithinTileMapBoundsRuleif they are defined globally. - Enable
disable_grid_snapon the tag (see [Choosing Terrain vs Objects](./choosing-terrain-vs-objects.html for snapping details). - Assign the tag to your
Placeableresource.
This allows you to mix "structured" buildings (grid-snapped, strict collision) with "organic" decorations (free-form, overlap-allowed) in the same placement system.
Indicator Creation During Preview
When a preview is created (either for placement or manipulation), indicators are spawned and wired to rules:
Placement Flow
enter_place_mode(placeable)
→ create_preview(placeable)
→ _try_setup(preview_instance, placeable_rules, ignore_base_rules)
→ IndicatorManager.try_setup()
→ IndicatorService.setup_indicators()
→ IndicatorSetupUtils.execute_indicator_setup()
→ CollisionMapper.map_collision_positions_to_rules()
→ IndicatorFactory.generate_indicators()
Manipulation Flow
_start_move(move_data)
→ source.create_copy() (manipulation copy)
→ _indicator_context.get_manager().try_setup(move_rules, targeting, ignore_base)
→ Same indicator creation path as placement
Both flows use the same indicator creation path. The only difference is:
- Placement: rules come from
placeable.placement_rules - Manipulation: rules come from
source.get_move_rules() - Manipulation additionally sets
collision_exclusions = [source.root]to exclude the original object
Rule → Indicator Wiring
Each RuleCheckIndicator holds references to its rules via add_rule(). When update_validity_state() runs:
indicator.update_validity_state()
→ indicator.validate_rules(rules)
→ for each rule: rule.get_failing_indicators([self])
→ Returns which indicators are failing this rule
indicator.valid = (failing_indicators.size() == 0)
How Indicators Are Generated From Collision Shapes
Indicators are not magical — they are created at positions derived from collision shapes on your placeable's packed_scene. Understanding this pipeline is essential for diagnosing silent failures.
The Indicator Generation Pipeline
packed_scene (PackedScene)
→ instance the scene (preview or placed object)
→ find CollisionObject2D / CollisionShape2D nodes
→ map shape positions → grid tile coordinates
→ create RuleCheckIndicator at each tile
→ wire TileCheckRules to indicators
→ rules evaluate against indicator positions
Layer Configuration: Two Separate Settings
1. collision_layer on your placeable's nodes — "What layer this object IS"
# In your placeable's packed_scene (e.g., house.tscn)
StaticBody2D:
collision_layer: 1, 10 # Object IS on layers 1 and 10
collision_mask: 0 # Object doesn't detect anything
CollisionShape2D:
...
2. collision_mask on TileCheckRules (like CollisionsCheckRule) — "What layers this rule CHECKS"
# In your CollisionsCheckRule.collision_mask
shape_cast_collision_mask: 10 # Rule looks for objects on layer 10
# Rule shapecast sweeps across indicator positions
# → Detects any object whose collision_layer includes 10
Why Misassigned Layers Cause Silent Failures
| Scenario | collision_layer | rule collision_mask | Result |
|---|---|---|---|
| Correct | 1, 10 |
10 |
✅ Rule detects object |
| Wrong - object not on target layer | 1 only |
10 |
❌ Rule finds nothing — placement passes vacuously |
| Wrong - rule doesn't look for layer | 1, 10 |
1 |
❌ Rule looks for wrong layer — finds nothing |
Example of silent failure:
# Placeable has collision_layer = 1 (no targeting layer)
StaticBody2D:
collision_layer: 1
collision_mask: 0
CollisionShape2D: ...
# CollisionsCheckRule has collision_mask = 10
# Indicator is created at shape position
# Rule casts to find layer 10 objects → finds nothing
# Validation passes vacuously (no collisions found)
# But placement actually succeeded on top of another object!
Special Case: Packed Scenes With No Collision Shapes
If your placeable's packed_scene has no CollisionObject2D or CollisionShape2D nodes:
- No indicators are generated — the system has no positions to create indicators at
- No
TileCheckRulerules run — rules likeWithinTileMapBoundsRule,CollisionsCheckRule, andValidPlacementTileRulehave nothing to validate - All tile-based validation is bypassed — this is logically correct but can be surprising
This can be intentional for objects that:
- Are purely visual (no gameplay collision)
- Use a custom validation mechanism (e.g., raycasts from a different system)
- Should always pass tile-based checks regardless of position
If you want tile-based validation but have no collision shapes:
- Add collision shapes to your placeable's packed scene (recommended)
- Use
VisualBoundsFallbackforWithinTileMapBoundsRuleonly — other rules still won't work - Create a custom
TileCheckRulethat uses a different position source (advanced)
Manipulation vs Placement
| Aspect | Placement | Manipulation |
|---|---|---|
| Rules source | placeable.placement_rules |
source.get_move_rules() |
| ignore_base from | placeable.ignore_base_rules |
source.settings.ignore_base_rules |
| Collision exclusions | Default (none) | [source.root] — excludes original object |
| Preview instance | New instance created | Copy of source normalized to identity |
Both use IndicatorManager.try_setup() with the same rule combination logic.
5.0.4 update:
CollisionCheckRulenow includes a manual post-cast exclusion filter to suppress false-positive collisions during manipulation moves. Godot silently ignoresShapeCast2D.add_exception()when the cast origin is outside the excluded body's bounds.
Core Rule Classes
| Class | Purpose |
|---|---|
PlacementRule |
Base class for all placement rules |
TileCheckRule |
Base class for rules that evaluate tile/indicator state |
RuleResult |
Contains validation outcome and issues |
Built-in Rules
Grid Building 5.0.8 includes these built-in placement rules:
| Rule Class | Base Class | Purpose |
|---|---|---|
WithinTileMapBoundsRule |
TileCheckRule |
Restricts placement to valid tilemap cells |
CollisionsCheckRule |
TileCheckRule |
Checks for overlapping physics |
ValidPlacementTileRule |
TileCheckRule |
Basic validity check |
SpendMaterialsRuleById |
PlacementRule |
Canonical (6.0) id-keyed cost rule: Dictionary[StringName, int] of id -> amount, spent through the container's id-based interface |
SpendMaterialsRuleGeneric |
PlacementRule |
Deprecated — ResourceStack-keyed inventory spend; kept for existing projects, superseded by SpendMaterialsRuleById |
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.
⚠️ CRITICAL:
validate_placement()MUST be overridden in your custom rule. The base class implementation returns a failure by default with the message "This is a virtual condition function...". If you don't override it, your rule will always fail validation.
For the full custom-rule contract, including get_setup_issues(), get_runtime_issues(), and TileCheckRule indicator behavior, see [Custom Placement Rules](./custom-placement-rules.html.
5.0.4 update:
CollisionCheckRulenow includes a manual post-cast exclusion filter to suppress false-positive collisions during manipulation moves. Godot silently ignoresShapeCast2D.add_exception()when the cast origin is outside the excluded body's bounds.
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.
Writing Custom Rules
If you are defining your own rules, use the dedicated guide:
- [Custom Placement Rules](./custom-placement-rules.html
That guide covers:
- Godot 4.4-compatible custom rule patterns
- When to extend
PlacementRulevsTileCheckRule get_setup_issues()vsget_runtime_issues()TileCheckRuleindicator fallback behavior- Common authoring mistakes and tested expectations
Inventory Spend Rules
Canonical: SpendMaterialsRuleById (6.0)
The canonical cost shape is a Dictionary[StringName, int] of item id -> amount. Ids decouple the placement contract from your inventory schema: the container owns the id -> item mapping and exposes an id-based interface.
Unified Contract (SSOT):
The same id convention is used by BuildCost for the refund-on-demolish contract (see Refund on Demolish. Save the rule as its own .tres resource (e.g., smithy_costs_rule.tres) so the same file serves as the Single Source of Truth for both build costs and demolish values. The refund system will automatically calculate the partial refund based on the refund_ratio.
The container located via the rule's locator must implement:
| Method | Purpose |
|---|---|
get_count_by_id(id: StringName) -> int |
called by validate_placement() |
try_remove_by_id(id: StringName, amount: int) -> int |
called by apply(); returns the actual amount removed |
try_add_by_id(id: StringName, amount: int) -> int |
called for transactional rollback when a multi-id spend partially fails |
ItemContainer (the demo inventory at demos/shared/inventory/) implements this interface out of the box, deriving ids from BaseItem.display_name. The smithy in the top-down demo is the reference consumer — it costs 100 Gold. Save the rule as its own .tres so the same resource serves as the cost source of truth for both spend and refund (demos/top_down/rules/smithy_costs_rule.tres):
[gd_resource type="Resource" script_class="SpendMaterialsRuleById" format=3]
[ext_resource type="Script" path="res://addons/grid_building/placement/placement_rules/template_rules/spend_materials_rule_by_id.gd" id="1"]
[ext_resource type="Resource" path="res://demos/top_down/rules/materials_container_locator.tres" id="2"]
[resource]
script = ExtResource("1")
costs = Dictionary[StringName, int]({
&"Gold": 100
})
locator = ExtResource("2")
The placeable references it in placement_rules; a PlaceableInstance (or BuildCostSource for non-placeable scenes) lets the refund side resolve the same resource — see [Refund on Demolish](./refund-on-demolish.html.
apply() is transactional: when a multi-id spend fails partway, already-removed amounts are returned via try_add_by_id before the issues are reported. The locator semantics (search root, search methods) are shared with the legacy rule and documented below.
Legacy: SpendMaterialsRuleGeneric (deprecated)
SpendMaterialsRuleGeneric is the older ResourceStack-keyed spend rule. It remains supported for existing projects but is deprecated in favor of SpendMaterialsRuleById; new placeables should use the id-keyed shape.
Use it when:
- The cost belongs to the placement flow, not the UI.
- The spend should happen only after validation passes.
- Your inventory lives under the owner root or a node the rule can locate through its configuration.
- You want one shared cost rule instead of custom spend logic on each placeable.
Good fit examples:
- A house costs wood and stone.
- A trap costs gold after a valid placement.
- A build action spends a generic resource stack from the owning player.
Keep the rule generic and let placeables or config supply the actual cost data. That keeps cost handling consistent across guides, demo scenes, and future placeables.
How it executes
The rule participates in the normal rule lifecycle:
validate_placement()— callsget_count(type)on the located inventory for eachResourceStackinresource_stacks_to_spend. If any count is short, returns aRuleResultwith"Not Enough Materials: <name> : <missing>". Build is rejected, no spend happens.apply()— after a successful build, callstry_remove(type, count)(orremove(type, count)) for eachResourceStack. Returns issues if the actual removed amount is less than requested (e.g. concurrent inventory change between validate and apply).
Because the deduction happens inside apply(), the success signal fires after the inventory is already updated. Handlers like PlacementActionLog._on_build_success should only refresh the HUD by polling the inventory; they should not mutate state.
Inventory node contract (duck-typed)
The node returned by NodeLocator.locate_container(...) must implement three methods. VirtualItemContainer (res://addons/grid_building/systems/building/virtual/virtual_item_container.gd) is a base class with empty stubs; you are not required to extend it. A script on any node that exposes these methods works.
| Method | Required | Returns | Purpose |
|---|---|---|---|
try_remove(type: Resource, amount: int) -> int |
yes (or remove) |
actual amount removed | called by apply() |
try_add(type: Resource, amount: int) -> int |
no (but useful for symmetry / undo) | actual amount added | not called by the rule |
get_count(type: Resource) -> int |
yes | current count | called by validate_placement() |
The rule checks for try_remove first, then falls back to remove (spend_materials_rule_generic.gd:61–64). Same pattern for try_add / add. The script's return value is what the rule uses — try_remove should return the actual number removed, which may be less than requested (e.g. inventory capped).
The type parameter is whatever Resource subclass your project uses as an item ID (the canonical SpendMaterialsRuleById sidesteps this entirely by using StringName ids). As long as your try_remove and get_count accept the same type value, the legacy rule works.
Locating the inventory
The locator is per-rule, per-placeable — there is no global inventory setting on PlacementContainer or PlacementSession. Configure it in the Placeable .tres:
# Legacy shape (the demo smithy now uses SpendMaterialsRuleById, above)
[ext_resource type="Resource" path="res://demos/top_down/rules/materials_container_locator.tres" id="4_bq68b"]
[sub_resource type="Resource" id="Resource_s4dmr"]
script = ExtResource("spend_materials_rule_generic.gd")
resource_stacks_to_spend = [ResourceStack(type=gold, count=100)]
locator = ExtResource("4_bq68b")
The locator resource is a plain Resource (res://addons/grid_building/placement/placement_rules/template_rules/resources/node_locator.gd). One locator .tres can be referenced by many placeables.
Search root = PlacementOwner.owner_root. The rule does not search the world tree, the autoload tree, or the current scene root. It walks the builder owner (typically the player) and its descendants. If your inventory is in an autoload, the locator will never find it unless you point PlacementOwner.owner_root at the inventory node itself.
Three search methods are available (NodeLocator.SEARCH_METHOD):
| Method | Matches | Example search_string |
|---|---|---|
NODE_NAME (default) |
first descendant literally named | "MaterialsContainer" |
SCRIPT_NAME_WITH_EXTENSION |
first node whose attached script file matches | "my_inventory.gd" |
IS_IN_GROUP |
first node in the Godot group | "player_inventory" |
Group-based is most robust against node-tree refactors. Node-name matches the demo's idiomatic style. Script-name is useful when the same script class appears multiple times and you only want to find the one under the owner.
Wiring a typed production inventory (bridge pattern)
For a game with a strongly-typed inventory (e.g. MyInventorySystem with remove(item_id: StringName) -> int), prefer SpendMaterialsRuleById — implement the three *_by_id methods on a thin bridge node and you are done. If you are staying on the legacy generic rule, do not copy or subclass it; the same bridge pattern applies with the type: Resource contract:
# res://demos/<your_game>/inventory/my_inventory_bridge.gd
class_name MyInventoryBridge
extends Node
var _inventory: MyInventorySystem # your real inventory
func _ready() -> void:
add_to_group("player_inventory") # matches the locator's search_string
func try_remove(type: Resource, amount: int) -> int:
return _inventory.remove(type.item_id, amount) # map to your real API
func try_add(type: Resource, amount: int) -> int:
return _inventory.add(type.item_id, amount)
func get_count(type: Resource) -> int:
return _inventory.quantity_of(type.item_id)
Attach the bridge script to a Node somewhere under the owner root (e.g. as a sibling of your player). Point the locator at it (group / name / script). Your real inventory stays a plain autoload or singleton — the rule never touches it directly.
Why a bridge and not a subclass? A subclass would fork the rule. Every time upstream fixes a bug or adds a feature (e.g. the
push_errorper-guard split in 6.0.1), you have to re-merge. The bridge keeps the generic rule in your addon path so updates apply automatically.
Common pitfalls
- Locator returns
null— inventory is not a descendant ofPlacementOwner.owner_root, or no node matches the search criteria. Print_owner_root.get_path()inside the rule'ssetup()to confirm the search root. - Locator finds the wrong node — multiple descendants share the name/script/group. Pick a more specific method.
- Build passes but the count is not actually decremented — your
try_removereturned0or didn't actually mutate. The rule only knows what you return; if you return0, the build is marked as having a spend issue and the build report carries"Expected to spend X but actually spent 0". - Build passes even with insufficient materials — your
get_countreturns a stale value (caching, signal-driven update that hasn't fired yet). The rule checkshad_count < needed_resource.countstrictly; a stale higher value lets the build through, thenapply()'stry_removefails and reports a different issue. - Inventory in an autoload — the locator can't reach it. Either move it under the owner, or make the inventory the owner itself by setting
PlacementOwner.owner_rootto the inventory node.
Don't duplicate the spend in signal handlers
A common mistake is to also subtract in _on_build_success or _on_manipulation_finished. The rule already deducted inside apply(). Doing it again double-spends. Your signal handler should only:
- Refresh the HUD by polling the inventory's
get_countfor the displayed item. - Play a sound, particle, or other non-state-mutating feedback.
If you need an explicit hook (e.g. an "undo" stack, telemetry, or achievement tracking), prefer connecting to the building.success signal before it bubbles to the action log, or wrap the rule in a custom rule that captures the spend inside apply() and emits a game-specific event.
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:
- Detects visual components: Searches for
Sprite2DandPolygon2Dnodes in the object hierarchy. - Calculates bounding box: Uses
VisualBoundsHelper.get_visual_bounding_box()to compute the union of all visual component rectangles. - 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 (
Sprite2Dwith texture orPolygon2Dwith 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:
- Precise control: Collision shapes allow you to define the exact footprint of your object, independent of visual representation.
- Accurate validation: Visual bounds may include transparent areas or visual effects that don't represent the actual placement footprint.
- Performance: Collision-based detection is more efficient than traversing node hierarchies for visual components.
- Consistency: Using collision shapes ensures consistent behavior across all placement rules (
WithinTileMapBoundsRule,CollisionCheckRule,ValidPlacementTileRule). - Flexibility: You can create complex collision shapes (concave polygons, multiple shapes) that accurately represent your object's placement requirements.
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:
- No fallback mechanism: Without collision shapes, the rule cannot generate indicators and will fail to validate.
- Tile data validation: The rule checks specific tile custom data fields (e.g., "buildable", "walkable") which require precise tile position detection.
- Multiple tile coverage: Collision shapes define exactly which tiles need to have matching custom data.
- 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.
CollisionsCheckRule
- Extends
TileCheckRule. - Checks each indicator with a shapecast collision mask.
- Uses
collision_exclusionsfromGridTargetingState(not on the rule itself) to exclude preview bodies and other nodes. - Supports both clear-space and required-overlap flows through
pass_on_collision.
Note:
collision_exclusionsare configured onGridTargetingState.collision_exclusions, not on the rule. See [Targeting Flow](./targeting-flow.html for details.
5.0.4 update:
- Added
_ensure_messages()lazy-loading safeguard to prevent null derefs when nested resources fail to deserialize in exported builds. - Fixed
is_instance_validcheck missing — prevents accessing freed indicator objects. - Added manual post-cast exclusion filter to work around Godot
ShapeCast2D.add_exception()outside-bounds bug. - Templates now use
collision_mask = 1andapply_to_objects_mask = 1for project-agnostic defaults.
Practical Guidance
- Always call
super.setup(...)if you override setup. - Use
TileCheckRulewhen your rule depends on indicator positions or tilemap cells. - Use
PlacementRuledirectly when the rule depends on owner/inventory/game-state logic. - Use
SpendMaterialsRuleByIdwhen the rule needs to deduct resources after successful placement (SpendMaterialsRuleGenericis its deprecated ResourceStack-keyed predecessor). - Keep side effects in
apply()if they should only happen after successful placement. - Put rules that should affect every placement into
PlacementSettings.placement_rules.
Common Mistakes
- Not overriding
validate_placement()— The base class returns a failure by default. Custom rules MUST override this method to returnRuleResult.build(self, [])for success. - 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.
Related Guides
- [Custom Placement Rules](./custom-placement-rules.html
- [Placement Workflow](./placement-workflow.html
- [Validation: Configuration](./validation.html
- [Web Export Guide](./web-export.html