Web Export Guide

Grid Building supports web export for all three demo types: top-down, platformer, and isometric. This guide covers what you need to know to get your project running in a browser.

Requirements

  • Godot 4.4+ with Web export template installed
  • GDScript builds only (C# / Mono does not support web export in Godot 4.x)
  • export_filter="all_resources" or explicit inclusion of rules and template directories

How Resource Loading Differs on Web

The main issue: embedded SubResources fail to load on web

Resources that are embedded as SubResource(...) entries inside a .tres file — rather than saved as standalone .tres files on disk — fail to load in web exports. The parent resource loads, but any nested SubResource fields remain null.

This affects any @export resource field that Godot saves as an inline [sub_resource] block instead of an [ext_resource] reference. For example, a CollisionsCheckRule with its messages field saved as an embedded subresource will have messages = null on web.

The fix: Save every resource that needs to exist at runtime as its own .tres file on disk, and reference it via ExtResource(...):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# BAD — embedded subresource (fails on web)
[sub_resource type="CollisionRuleSettings" id="CollisionRuleSettings_abc"]
success_message = "Clear to build"

[resource]
messages = SubResource("CollisionRuleSettings_abc")

# GOOD — external .tres file (works on web)
[ext_resource type="Resource" path="res://rules/my_messages.tres" id="1_messages"]

[resource]
messages = ExtResource("1_messages")

As of Godot 4.6.2 stable, all three Grid Building demos (top-down, platformer, isometric) export correctly to web because their settings resources use external .tres references. The _ensure_messages() lazy-loading safeguard in CollisionsCheckRule compensates for any embedded SubResources that may fail to deserialize in exported builds.

Lazy-loading safety nets

Some rules include _ensure_*() methods as defensive guards against null resources. For example, CollisionsCheckRule has _ensure_messages():

1
2
3
4
func _ensure_messages() -> void:
    if messages != null:
        return
    messages = CollisionRuleSettings.new()

This runs in validate_placement() and get_editor_issues(), so exports do not crash on null messages regardless of why it is null. In practice on Godot 4.6.2 with external .tres files, _init() initializes these fields correctly, but the safety net remains for robustness.

Best Practices for Web-Compatible Resources

Save base rules as standalone .tres files

Base placement_rules in GBSettings should reference external .tres files, not embedded SubResource(...) entries:

Good — external rule resource:

1
2
3
4
5
[ext_resource type="Resource" uid="uid://xxx" path="res://rules/my_collision_rule.tres" id="1_rule"]

[resource]
script = ExtResource("settings_script")
placement_rules = [ExtResource("1_rule")]

Avoid — embedded subresource:

1
2
3
4
5
6
[sub_resource type="Resource" id="Resource_abc"]
script = ExtResource("collisions_script")

[resource]
script = ExtResource("settings_script")
placement_rules = [SubResource("Resource_abc")]

Array syntax for placement_rules

Both plain arrays and typed-array wrapper syntax work in web exports. All three Grid Building demos use typed-array syntax and export correctly.

Plain array (used by platformer demo):

1
placement_rules = [ExtResource("1_rule")]

Typed array (used by top-down and isometric demos):

1
placement_rules = Array[ExtResource("script")]([ExtResource("1_rule"), ExtResource("2_rule")])

Both formats are valid. If you encounter empty arrays after export, see Godot issue #97782 and try the plain array format as a workaround.

Externalize GBSettings from GBConfig

Avoid embedding GBSettings as a subresource inside GBConfig. Save it as its own .tres file:

1
2
3
4
5
[ext_resource type="Resource" path="res://config/my_settings.tres" id="1_settings"]

[resource]
script = ExtResource("config_script")
settings = ExtResource("1_settings")

Serialize nested resources explicitly

Save important nested resources directly in the .tres file rather than relying on _init() to create them:

  • CollisionsCheckRule.messages
  • CollisionsCheckRule.fail_visual_settings
  • Tile rule fail_visual_settings

Verify indicator scenes serialize collision flags

RuleCheckIndicator scenes should explicitly set collision flags based on your needs:

1
2
3
[node name="RuleCheckIndicator" type="ShapeCast2D"]
collide_with_areas = true
# Set collide_with_bodies = true if your rules need to detect StaticBody2D objects

Most placement rules only need collide_with_areas = true since they detect other RuleCheckIndicator instances or Area2D objects. Set collide_with_bodies = true only if your validation needs to detect StaticBody2D objects directly.

Collision Masks by Demo Type

Different demos use different physics layers. Do not copy collision masks between demos blindly:

| Demo | Rule collision_mask | Rule apply_to_objects_mask | ||-|***************| | Top-down | 1 or project-specific | 1 | | Platformer | 1 or project-specific | 1 | | Isometric | 2560 | 2561 |

Verification

Automated tests

Run the web export compatibility test suite:

1
godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd runtest -a res://test/grid_building/placement/web_export

This validates:

  • .tres-loaded rules lazy-load messages on first use
  • validate_placement() does not crash when messages is null
  • Demo config chains load rules correctly

Pre-export checklist

  • Base rules in GBSettings are external .tres files
  • GBConfig.settings references an external GBSettings .tres
  • Placeable packed_scene references a real .tscn file
  • Collision rules serialize messages and fail_visual_settings
  • Indicator scenes serialize collide_with_bodies and collide_with_areas
  • placement_rules arrays load correctly (both plain and typed-array syntax are supported)
  • Collision masks match your project’s physics layer setup
  • Export filter includes res://templates/ and any custom rules directories

Troubleshooting

Placement indicators do not appear

  1. Check browser console for Failed to load resource errors
  2. Verify placement_rules is non-empty by adding a temporary print in GBCompositionContainer.get_placement_rules()
  3. Ensure base rules are external .tres files, not embedded subresources

Potential issues from earlier Godot versions

The following issues have been reported in earlier Godot versions but do not reproduce on Godot 4.6.2 stable. If you are on an older version and experiencing problems, check these:

  1. _init() not called during .tres deserialization@export variables may be set after _init() runs, leaving _init()-initialized fields at null (Godot issue #70575). Workaround: ensure rules use _ensure_*() lazy-loading or serialize fields explicitly in the .tres.

  2. Typed array syntax deserializing as emptyArray[ExtResource("...")]([...]) may deserialize as an empty array in exported builds (Godot issues #97782 and #72489). Workaround: switch to plain array syntax (placement_rules = [ExtResource("1_rule")]).

Placement always succeeds (no red tiles)

  1. Verify CollisionsCheckRule.collision_mask overlaps your target objects’ collision_layer
  2. Check that indicator scenes have collide_with_bodies = true
  3. For isometric projects, verify masks are 2560 / 2561, not top-down defaults

Rules load but validation is wrong

  1. Check messages is serialized in the rule .tres or that _ensure_messages() is being triggered
  2. Verify IndicatorFactory receives a valid logger (it falls back to a default GBLogger automatically)

References

  • Godot issue #97782 — @export Array[ResourceCustom] empty in exported builds (typed array deserialization bug)
  • Godot issue #72489 — Typed arrays break resource deserialization when some elements are null
  • Godot issue #70575 — @export variables set after _init() runs (deserialization order)
  • Godot Docs: Exporting for the Web

Last updated: 2026-05-03