diff --git a/assets/tiled-fixtures/p4_enemy.tx b/assets/tiled-fixtures/p4_enemy.tx
new file mode 100644
index 00000000..d8f39a8e
--- /dev/null
+++ b/assets/tiled-fixtures/p4_enemy.tx
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/assets/tiled-fixtures/p4_map.tmx b/assets/tiled-fixtures/p4_map.tmx
new file mode 100644
index 00000000..e963f586
--- /dev/null
+++ b/assets/tiled-fixtures/p4_map.tmx
@@ -0,0 +1,19 @@
+
+
+
diff --git a/assets/tiled-fixtures/p4_types.xml b/assets/tiled-fixtures/p4_types.xml
new file mode 100644
index 00000000..57a877e9
--- /dev/null
+++ b/assets/tiled-fixtures/p4_types.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/changes/tiled-maps.md b/changes/tiled-maps.md
index ff0a1c57..9602c17d 100644
--- a/changes/tiled-maps.md
+++ b/changes/tiled-maps.md
@@ -8,3 +8,4 @@ type: feat
- **P1 core load + render (#69)** — the runtime `rt_tmap` model (heap-allocated to `w·h`, lifting the old `96×64` cap), the GID resolver (`Tiled.resolve` → tileset / local id / H·V·D flips), image-backed rendering (`Tiled.draw`, flips applied at blit), and the legacy-tilemap compatibility projection so `Grid.*`/`Path.*`/`esys_move` keep working. `Tiled.load` reads either format, resolves external tilesets + images, and auto-projects a `collision` layer. The Kenney sample loads and renders pixel-identically from `.tmx` and `.tmj`; the `grid` and `physics_tiles` demos now run off a loaded map.
- **P2 collision & grid (#70)** — normalise three collision sources into the byte tilemap `esys_move`/`Grid.*`/`Path.*` read, in the design's priority order: per-tile `` hitboxes, the `solid`/`oneway`/`trigger` property convention (`Tiled.collision_kind`/`Tiled.tile_shapes`), and the designated collision layer (`Tiled.project`, any non-zero GID solid) — or drive collision from a visual layer's per-tile metadata alone (`Tiled.collide`). The property convention and the collision-layer fallback produce the same feed; `Path.a_star` over a loaded map matches the hand-authored baseline.
- **P3 animated tiles + tile objects (#71)** — a tileset `` advances deterministically as a pure function of the fixed 60/s engine frame clock (`Tiled.frame_gid`/`Tiled.animated`), so an animated GID resolves at draw to the current frame's GID with its flip flags preserved and reproduces frame-for-frame across runs; `Tiled.draw_anim` draws a map with animations advanced. Object-layer entries with a `gid` render the tile image (with their own flips), bottom-anchored, as placeable sprites.
+- **P4 objects, properties, templates, spawning (#72)** — all object shapes (rectangle / ellipse / point / polygon / polyline / text) and custom properties parse and are queryable (`Tiled.object`, `Tiled.object_shape`, `Tiled.prop`/`Tiled.prop_int`/`Tiled.prop_type`); class properties resolve their defaults against a project custom-type table (`Tiled.load_types` over `objecttypes.xml`); template instances inherit their `.tx`/`.tj` template's fields; and an object maps onto Ludic components on demand (`Tiled.spawn`/`Tiled.spawn_layer`, off by default, via the reflection ABI).
diff --git a/docs/language/tiled/tiled-load_types.md b/docs/language/tiled/tiled-load_types.md
new file mode 100644
index 00000000..552da742
--- /dev/null
+++ b/docs/language/tiled/tiled-load_types.md
@@ -0,0 +1,14 @@
+---
+id: tiled-load_types
+name: Tiled.load_types
+category: tiled
+kind: namespace-method
+tokens: Tiled.load_types
+sig: Tiled.load_types(path: string) -> int
+tip: Load a project custom-type table.
+order: 26
+ns: Tiled
+member: load_types
+---
+
+Loads an objecttypes.xml custom-type table so class/enum properties resolve their defaults; returns the number of types loaded. Each <objecttype> maps to its default <property> list.
diff --git a/docs/language/tiled/tiled-object.md b/docs/language/tiled/tiled-object.md
new file mode 100644
index 00000000..de23f667
--- /dev/null
+++ b/docs/language/tiled/tiled-object.md
@@ -0,0 +1,14 @@
+---
+id: tiled-object
+name: Tiled.object
+category: tiled
+kind: namespace-method
+tokens: Tiled.object
+sig: Tiled.object(map, layer: int, index: int) -> value
+tip: An object on an object layer by index.
+order: 21
+ns: Tiled
+member: object
+---
+
+Returns the object Value at index on object layer — its x/y/width/height, shape, gid, type (class) and properties.
diff --git a/docs/language/tiled/tiled-object_count.md b/docs/language/tiled/tiled-object_count.md
new file mode 100644
index 00000000..3010dfee
--- /dev/null
+++ b/docs/language/tiled/tiled-object_count.md
@@ -0,0 +1,14 @@
+---
+id: tiled-object_count
+name: Tiled.object_count
+category: tiled
+kind: namespace-method
+tokens: Tiled.object_count
+sig: Tiled.object_count(map, layer: int) -> int
+tip: How many objects an object layer holds.
+order: 20
+ns: Tiled
+member: object_count
+---
+
+Returns the number of objects on object layer (0 for a non-object layer).
diff --git a/docs/language/tiled/tiled-object_shape.md b/docs/language/tiled/tiled-object_shape.md
new file mode 100644
index 00000000..2ec21d28
--- /dev/null
+++ b/docs/language/tiled/tiled-object_shape.md
@@ -0,0 +1,14 @@
+---
+id: tiled-object_shape
+name: Tiled.object_shape
+category: tiled
+kind: namespace-method
+tokens: Tiled.object_shape
+sig: Tiled.object_shape(object) -> string
+tip: Classify an object's shape.
+order: 22
+ns: Tiled
+member: object_shape
+---
+
+Returns an object's shape: tile (a gid object), point, ellipse, polygon, polyline, text, or rectangle (the default).
diff --git a/docs/language/tiled/tiled-prop.md b/docs/language/tiled/tiled-prop.md
new file mode 100644
index 00000000..c6b3148d
--- /dev/null
+++ b/docs/language/tiled/tiled-prop.md
@@ -0,0 +1,14 @@
+---
+id: tiled-prop
+name: Tiled.prop
+category: tiled
+kind: namespace-method
+tokens: Tiled.prop
+sig: Tiled.prop(container, name: string) -> string
+tip: A custom property's value, with class-default fallback.
+order: 23
+ns: Tiled
+member: prop
+---
+
+Returns the string value of custom property name on container (an object / tile / layer / map), falling back to the container class's default from the project custom-type table (Tiled.load_types). "" when absent everywhere.
diff --git a/docs/language/tiled/tiled-prop_int.md b/docs/language/tiled/tiled-prop_int.md
new file mode 100644
index 00000000..46a6b7b8
--- /dev/null
+++ b/docs/language/tiled/tiled-prop_int.md
@@ -0,0 +1,14 @@
+---
+id: tiled-prop_int
+name: Tiled.prop_int
+category: tiled
+kind: namespace-method
+tokens: Tiled.prop_int
+sig: Tiled.prop_int(container, name: string) -> int
+tip: A custom property parsed as an integer.
+order: 24
+ns: Tiled
+member: prop_int
+---
+
+Like Tiled.prop but parsed as an integer (true/false → 1/0), with the same class-default fallback.
diff --git a/docs/language/tiled/tiled-prop_type.md b/docs/language/tiled/tiled-prop_type.md
new file mode 100644
index 00000000..0b65db82
--- /dev/null
+++ b/docs/language/tiled/tiled-prop_type.md
@@ -0,0 +1,14 @@
+---
+id: tiled-prop_type
+name: Tiled.prop_type
+category: tiled
+kind: namespace-method
+tokens: Tiled.prop_type
+sig: Tiled.prop_type(container, name: string) -> string
+tip: A custom property's declared type.
+order: 25
+ns: Tiled
+member: prop_type
+---
+
+Returns the declared type of custom property name (string/int/float/bool/color/file/object/class/an enum), with the class-default fallback.
diff --git a/docs/language/tiled/tiled-spawn.md b/docs/language/tiled/tiled-spawn.md
new file mode 100644
index 00000000..7d57f55a
--- /dev/null
+++ b/docs/language/tiled/tiled-spawn.md
@@ -0,0 +1,14 @@
+---
+id: tiled-spawn
+name: Tiled.spawn
+category: tiled
+kind: namespace-method
+tokens: Tiled.spawn
+sig: Tiled.spawn(map, object, model: string) -> int
+tip: Spawn a Ludic entity from an object.
+order: 28
+ns: Tiled
+member: spawn
+---
+
+Opt-in: spawns an entity of model from an object — its Position (if the model has one) takes the object's x/y, and each object custom property sets a like-named int/bool component field through the reflection ABI. Returns the entity, or -1 if the model is unknown. The core load never calls this.
diff --git a/docs/language/tiled/tiled-spawn_layer.md b/docs/language/tiled/tiled-spawn_layer.md
new file mode 100644
index 00000000..2f952b77
--- /dev/null
+++ b/docs/language/tiled/tiled-spawn_layer.md
@@ -0,0 +1,14 @@
+---
+id: tiled-spawn_layer
+name: Tiled.spawn_layer
+category: tiled
+kind: namespace-method
+tokens: Tiled.spawn_layer
+sig: Tiled.spawn_layer(map, layer: int, model: string) -> int
+tip: Spawn every object on a layer.
+order: 29
+ns: Tiled
+member: spawn_layer
+---
+
+Spawns every object on object layer as model (see Tiled.spawn); returns how many were spawned.
diff --git a/docs/language/tiled/tiled-template.md b/docs/language/tiled/tiled-template.md
new file mode 100644
index 00000000..8d8b9974
--- /dev/null
+++ b/docs/language/tiled/tiled-template.md
@@ -0,0 +1,14 @@
+---
+id: tiled-template
+name: Tiled.template
+category: tiled
+kind: namespace-method
+tokens: Tiled.template
+sig: Tiled.template(path: string) -> value
+tip: Read a template file into an object value.
+order: 27
+ns: Tiled
+member: template
+---
+
+Reads a .tx/.tj template file and returns its object Value — the reusable object definition an instance inherits. Tiled.load resolves each object's template reference automatically, merging the template's fields under the instance's overrides.
diff --git a/examples/library/tiled_p4.ludic b/examples/library/tiled_p4.ludic
new file mode 100644
index 00000000..8ccc4de6
--- /dev/null
+++ b/examples/library/tiled_p4.ludic
@@ -0,0 +1,78 @@
+# tiled_p4.ludic — Tiled P4 objects, custom properties/types, templates, opt-in
+# spawning (issue #72). All object shapes (rect/ellipse/point/polygon/polyline/
+# text) parse and are queryable; class properties resolve their defaults against
+# the project custom-type table (objecttypes.xml); a template instance inherits
+# its template's fields; and an object maps onto Ludic components on demand.
+#
+# A full run prints: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
+program TiledP4 {
+ property Position { x: int = 0, y: int = 0 }
+ property Health { hp: int = 0 }
+ model Enemy { Position, Health }
+
+ handler Boot phase Start {
+ let m = Tiled.load("assets/tiled-fixtures/p4_map.tmx")
+ Tiled.load_types("assets/tiled-fixtures/p4_types.xml")
+
+ # --- all object shapes parse and are queryable (layer 0 "shapes") ---
+ if Tiled.object_count(m, 0) == 6 { print(1) }
+ if Tiled.object_shape(Tiled.object(m, 0, 0)) == "rectangle" { print(2) }
+ if Tiled.object_shape(Tiled.object(m, 0, 1)) == "ellipse" { print(3) }
+ if Tiled.object_shape(Tiled.object(m, 0, 2)) == "point" { print(4) }
+ if Tiled.object_shape(Tiled.object(m, 0, 3)) == "polygon" { print(5) }
+ if Tiled.object_shape(Tiled.object(m, 0, 4)) == "polyline" { print(6) }
+ if Tiled.object_shape(Tiled.object(m, 0, 5)) == "text" { print(7) }
+ # polygon points + text content, via the value tree
+ let poly = Tiled.object(m, 0, 3)
+ if Value.count(Value.get(poly, "polygon")) == 4 { print(8) }
+ let txt = Tiled.object(m, 0, 5)
+ if Value.as_str(Value.get(Value.get(txt, "text"), "text")) == "Hello" { print(9) }
+
+ # --- custom properties + class defaults (layer 1 "spawns") ---
+ let e0 = Tiled.object(m, 1, 0) # id 10, hp override = 99
+ if Tiled.prop_int(e0, "hp") == 99 { print(10) }
+ let e1 = Tiled.object(m, 1, 1) # id 11, no hp -> Enemy default 10
+ if Tiled.prop_int(e1, "hp") == 10 { print(11) }
+ if Tiled.prop_int(e1, "speed") == 3 { print(12) } # class default
+ if Tiled.prop_type(e1, "hp") == "int" { print(13) }
+
+ # --- template instance inherits its template's fields (id 12) ---
+ let e2 = Tiled.object(m, 1, 2)
+ if Value.as_str(Value.get(e2, "type")) == "Enemy" { print(14) } # type inherited from p4_enemy.tx
+ if Tiled.prop_int(e2, "hp") == 25 { print(15) } # hp inherited from p4_enemy.tx
+
+ # --- opt-in spawn maps an object's class/properties -> components ---
+ let ent = Tiled.spawn(m, e0, "Enemy")
+ let pp = World.prop_id("Position")
+ let ph = World.prop_id("Health")
+ let ok_pos = World.get(ent, pp, World.field_id(pp, "x")) == 32
+ let ok_hp = World.get(ent, ph, World.field_id(ph, "hp")) == 99
+ if ok_pos and ok_hp { print(16) }
+
+ # --- the design's named fixture: orthogonal-outside.tmx object shapes ---
+ let og = Tiled.load("assets/tiled-fixtures/orthogonal-outside.tmx")
+ var olayer = 0 - 1
+ var oli = 0
+ while oli < Tiled.layer_count(og) {
+ if Tiled.object_count(og, oli) > 0 { olayer = oli }
+ oli = oli + 1
+ }
+ var nell = 0
+ var npt = 0
+ var npg = 0
+ var npl = 0
+ var oi = 0
+ while oi < Tiled.object_count(og, olayer) {
+ let sh = Tiled.object_shape(Tiled.object(og, olayer, oi))
+ if sh == "ellipse" { nell = nell + 1 }
+ if sh == "point" { npt = npt + 1 }
+ if sh == "polygon" { npg = npg + 1 }
+ if sh == "polyline" { npl = npl + 1 }
+ oi = oi + 1
+ }
+ if nell == 1 and npt == 1 and npg == 1 { print(17) }
+ if npl == 2 { print(18) }
+ }
+
+ handler Run phase Update { quit() }
+}
diff --git a/runtime/native/tiled.ludic b/runtime/native/tiled.ludic
index fb586337..3eca6191 100644
--- a/runtime/native/tiled.ludic
+++ b/runtime/native/tiled.ludic
@@ -106,6 +106,7 @@ function tmx_object_to_value(ob: Xml) -> Val {
if xml_has(ob, "name") == 1 { value_put(o, "name", value_str(xml_attr(ob, "name"))) }
if xml_has(ob, "type") == 1 { value_put(o, "type", value_str(xml_attr(ob, "type"))) }
if xml_has(ob, "class") == 1 { value_put(o, "type", value_str(xml_attr(ob, "class"))) }
+ if xml_has(ob, "template") == 1 { value_put(o, "template", value_str(xml_attr(ob, "template"))) }
value_put(o, "x", value_int(xml_attr_int(ob, "x", 0)))
value_put(o, "y", value_int(xml_attr_int(ob, "y", 0)))
value_put(o, "width", value_int(xml_attr_int(ob, "width", 0)))
@@ -807,6 +808,7 @@ function tmap_add_layer(m: Tmap, lv: Val) -> void {
function tiled_load(path: pointer) -> Tmap {
let tree = tiled_read(path)
let m = tmap_build(tree, Path.dir(path))
+ tmap_resolve_templates(m, Path.dir(path)) # #72: fill template-instance objects
let c = tmap_find_collision(m)
if c >= 0 { tmap_project(m, c) }
return m
@@ -916,3 +918,194 @@ function tmap_draw(m: Tmap, camx: int, camy: int) -> void { tmap_draw_full(m, ca
# animated draw at engine `frame` (pass Time.frame): animated tiles advance,
# deterministically and frame-identically across runs.
function tmap_draw_anim(m: Tmap, camx: int, camy: int, frame: int) -> void { tmap_draw_full(m, camx, camy, frame, 1) }
+
+# ============================================================================
+# P4 (#72) — object layers (shapes + text), custom properties/types, templates,
+# and opt-in entity spawning. Objects, shapes and properties already parse into
+# the intermediate tree (P0.5); this phase exposes them, resolves custom-type
+# defaults and template inheritance, and maps an object onto Ludic components.
+# ============================================================================
+
+# ---- object accessors ------------------------------------------------------
+function tmap_object_layer(m: Tmap, layer: int) -> Val {
+ if layer < 0 or layer >= len(m.layers) { return value_null() }
+ return m.layers[layer].data
+}
+function tmap_object_count(m: Tmap, layer: int) -> int {
+ return value_count(value_get(tmap_object_layer(m, layer), "objects"))
+}
+function tmap_object(m: Tmap, layer: int, i: int) -> Val {
+ return value_at(value_get(tmap_object_layer(m, layer), "objects"), i)
+}
+
+# the shape of an object: "tile" (a gid object), "point", "ellipse", "polygon",
+# "polyline", "text", or "rectangle" (the default).
+function tiled_object_shape(obj: Val) -> pointer {
+ if value_has(obj, "gid") == 1 { return "tile" }
+ if value_has(obj, "point") == 1 { return "point" }
+ if value_has(obj, "ellipse") == 1 { return "ellipse" }
+ if value_has(obj, "polygon") == 1 { return "polygon" }
+ if value_has(obj, "polyline") == 1 { return "polyline" }
+ if value_has(obj, "text") == 1 { return "text" }
+ return "rectangle"
+}
+
+# ---- custom properties -----------------------------------------------------
+# the {name,type,value} property node named `name` in a container's "properties"
+# list (an object / tile / layer / map), or a null node.
+function tiled_prop_node(container: Val, name: pointer) -> Val {
+ let props = value_get(container, "properties")
+ if value_kind(props) != 5 { return value_null() }
+ var i = 0
+ while i < value_count(props) {
+ let p = value_at(props, i)
+ if value_as_str(value_get(p, "name")) == name { return p }
+ i = i + 1
+ }
+ return value_null()
+}
+
+# a property's raw string value, with a fallback to the object's custom-type
+# default (objecttypes.xml, via the type table). "" when absent everywhere.
+function tiled_prop_str(container: Val, name: pointer) -> pointer {
+ let p = tiled_prop_node(container, name)
+ if value_kind(p) == 6 { return value_as_str(value_get(p, "value")) }
+ # fall back to the container's class default
+ let cls = value_as_str(value_get(container, "type"))
+ if cls != "" {
+ let d = tiled_type_default(cls, name)
+ if value_kind(d) == 6 { return value_as_str(value_get(d, "value")) }
+ }
+ return ""
+}
+
+# a property parsed as an integer ("true"/"false" -> 1/0), with the same default
+# fallback.
+function tiled_prop_int(container: Val, name: pointer) -> int {
+ let s = tiled_prop_str(container, name)
+ if s == "true" { return 1 }
+ if s == "false" { return 0 }
+ return xml_atoi(s)
+}
+
+function tiled_prop_type(container: Val, name: pointer) -> pointer {
+ let p = tiled_prop_node(container, name)
+ if value_kind(p) == 6 { return value_as_str(value_get(p, "type")) }
+ let cls = value_as_str(value_get(container, "type"))
+ if cls != "" {
+ let d = tiled_type_default(cls, name)
+ if value_kind(d) == 6 { return value_as_str(value_get(d, "type")) }
+ }
+ return ""
+}
+
+# ---- custom types (objecttypes.xml) ----------------------------------------
+# The project custom-type table: an object mapping a type/class name to its list
+# of {name,type,value(default)} property definitions. class/enum properties then
+# resolve their defaults against it.
+var tiled_type_table: Val = null
+
+function tiled_types() -> Val {
+ if tiled_type_table == null { tiled_type_table = value_object() }
+ return tiled_type_table
+}
+
+# load an objecttypes.xml file into the type table. Each maps
+# to its list.
+function tiled_load_types(path: pointer) -> int {
+ let text = Fs.read_text(path)
+ if text == null { return 0 }
+ if text == "" { return 0 }
+ let root = xml_parse(text)
+ let tbl = tiled_types()
+ var n = 0
+ var i = 0
+ while i < xml_child_count(root) {
+ let ot = xml_child(root, i)
+ if xml_tag(ot) == "objecttype" {
+ let props = value_list()
+ var j = 0
+ while j < xml_child_count(ot) {
+ let pr = xml_child(ot, j)
+ if xml_tag(pr) == "property" {
+ let o = value_object()
+ value_put(o, "name", value_str(xml_attr(pr, "name")))
+ var ty = xml_attr(pr, "type")
+ if ty == "" { ty = "string" }
+ value_put(o, "type", value_str(ty))
+ value_put(o, "value", value_str(xml_attr(pr, "default")))
+ push(props.kids, o)
+ }
+ j = j + 1
+ }
+ value_put(tbl, xml_attr(ot, "name"), props)
+ n = n + 1
+ }
+ i = i + 1
+ }
+ return n
+}
+
+# the default {name,type,value} property node for a custom type, or a null node.
+function tiled_type_default(typename: pointer, propname: pointer) -> Val {
+ let tbl = tiled_types()
+ let props = value_get(tbl, typename)
+ if value_kind(props) != 5 { return value_null() }
+ var i = 0
+ while i < value_count(props) {
+ let p = value_at(props, i)
+ if value_as_str(value_get(p, "name")) == propname { return p }
+ i = i + 1
+ }
+ return value_null()
+}
+
+# ---- templates (.tx / .tj) -------------------------------------------------
+# read a template file -> its object Value (the