diff --git a/assets/gamedata/gootower/level-001.json b/assets/gamedata/gootower/level-001.json
new file mode 100644
index 0000000..bbf786b
--- /dev/null
+++ b/assets/gamedata/gootower/level-001.json
@@ -0,0 +1,155 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 1,
+ "name": "Ground Floor",
+ "chapter": 1,
+ "tip": "Drag goo from the heap onto the structure. Each ball needs two nearby friends to hold on to.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 740,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 802,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 771,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 771,
+ "y": 600,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 980,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1010,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1040,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1070,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1100,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1130,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1160,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1190,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 980,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1010,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1040,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1070,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1100,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1130,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1160,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1190,
+ "y": 804,
+ "type": "common"
+ }
+ ],
+ "required": 4,
+ "ocdTarget": 9
+}
diff --git a/assets/gamedata/gootower/level-002.json b/assets/gamedata/gootower/level-002.json
new file mode 100644
index 0000000..a46e71e
--- /dev/null
+++ b/assets/gamedata/gootower/level-002.json
@@ -0,0 +1,238 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 2,
+ "name": "Mind the Gap",
+ "chapter": 1,
+ "tip": "Spikes below. Build ACROSS — triangles carry weight, long chains do not.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 700
+ ],
+ [
+ 600,
+ 700
+ ],
+ [
+ 600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 860,
+ 700
+ ],
+ [
+ 1600,
+ 700
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 860,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "spike",
+ "poly": [
+ [
+ 600,
+ 930
+ ],
+ [
+ 860,
+ 930
+ ],
+ [
+ 860,
+ 1000
+ ],
+ [
+ 600,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 620
+ ],
+ [
+ 40,
+ 620
+ ],
+ [
+ 40,
+ 700
+ ],
+ [
+ 0,
+ 700
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 399,
+ "y": 686,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 461,
+ "y": 686,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 430,
+ "y": 632,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 980,
+ "y": 640,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 150,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 180,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 210,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 240,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 270,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 300,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 330,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 150,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 180,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 210,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 240,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 270,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 300,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 330,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 150,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 180,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 210,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 240,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 270,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 300,
+ "y": 624,
+ "type": "common"
+ }
+ ],
+ "required": 5,
+ "ocdTarget": 12
+}
diff --git a/assets/gamedata/gootower/level-003.json b/assets/gamedata/gootower/level-003.json
new file mode 100644
index 0000000..5ce61f2
--- /dev/null
+++ b/assets/gamedata/gootower/level-003.json
@@ -0,0 +1,216 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 3,
+ "name": "Headroom",
+ "chapter": 1,
+ "tip": "You cannot build through the slab. Come out from under it first.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 300,
+ 540
+ ],
+ [
+ 820,
+ 540
+ ],
+ [
+ 820,
+ 610
+ ],
+ [
+ 300,
+ 610
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 529,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 591,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 560,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 980,
+ "y": 470,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 1020,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1050,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1080,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1110,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1140,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1170,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1200,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1230,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1020,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1050,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1080,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1110,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1140,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1170,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1200,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1230,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1020,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1050,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1080,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1110,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1140,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1170,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1200,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1230,
+ "y": 774,
+ "type": "common"
+ }
+ ],
+ "required": 5,
+ "ocdTarget": 12
+}
diff --git a/assets/gamedata/gootower/level-004.json b/assets/gamedata/gootower/level-004.json
new file mode 100644
index 0000000..82d29a6
--- /dev/null
+++ b/assets/gamedata/gootower/level-004.json
@@ -0,0 +1,175 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 4,
+ "name": "Second Thoughts",
+ "chapter": 1,
+ "tip": "Green ivy goo can be pulled back off and used again. Grey goo is there for good.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 300,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 362,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 331,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 660,
+ "y": 560,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 540,
+ "y": 834,
+ "type": "ivy"
+ },
+ {
+ "x": 570,
+ "y": 834,
+ "type": "ivy"
+ },
+ {
+ "x": 600,
+ "y": 834,
+ "type": "ivy"
+ },
+ {
+ "x": 630,
+ "y": 834,
+ "type": "ivy"
+ },
+ {
+ "x": 660,
+ "y": 834,
+ "type": "ivy"
+ },
+ {
+ "x": 690,
+ "y": 834,
+ "type": "ivy"
+ },
+ {
+ "x": 540,
+ "y": 804,
+ "type": "ivy"
+ },
+ {
+ "x": 570,
+ "y": 804,
+ "type": "ivy"
+ },
+ {
+ "x": 600,
+ "y": 804,
+ "type": "ivy"
+ },
+ {
+ "x": 630,
+ "y": 804,
+ "type": "ivy"
+ },
+ {
+ "x": 660,
+ "y": 804,
+ "type": "ivy"
+ },
+ {
+ "x": 690,
+ "y": 804,
+ "type": "ivy"
+ },
+ {
+ "x": 860,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 890,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 920,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 950,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 980,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1010,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1040,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1070,
+ "y": 834,
+ "type": "common"
+ }
+ ],
+ "required": 4,
+ "ocdTarget": 9
+}
diff --git a/assets/gamedata/gootower/level-005.json b/assets/gamedata/gootower/level-005.json
new file mode 100644
index 0000000..858f4f1
--- /dev/null
+++ b/assets/gamedata/gootower/level-005.json
@@ -0,0 +1,186 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 5,
+ "name": "Overhead",
+ "chapter": 1,
+ "tip": "The ceiling bites. Stop short of it.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "spike",
+ "poly": [
+ [
+ 400,
+ 180
+ ],
+ [
+ 1200,
+ 180
+ ],
+ [
+ 1200,
+ 240
+ ],
+ [
+ 400,
+ 240
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 760,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 822,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 791,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 791,
+ "y": 340,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 1000,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1030,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1060,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1090,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1120,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1150,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1180,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1210,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1240,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1000,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1030,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1060,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1090,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1120,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1150,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1180,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1210,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1240,
+ "y": 804,
+ "type": "common"
+ }
+ ],
+ "required": 5,
+ "ocdTarget": 11
+}
diff --git a/assets/gamedata/gootower/level-006.json b/assets/gamedata/gootower/level-006.json
new file mode 100644
index 0000000..e906cf7
--- /dev/null
+++ b/assets/gamedata/gootower/level-006.json
@@ -0,0 +1,205 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 6,
+ "name": "Tall Order",
+ "chapter": 1,
+ "tip": "A tower stands on compression and falls on sway. Brace every level.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 760,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 822,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 791,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 791,
+ "y": 180,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 1020,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1050,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1080,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1110,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1140,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1170,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1200,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1230,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1260,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1020,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1050,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1080,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1110,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1140,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1170,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1200,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1230,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1260,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1020,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1050,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1080,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1110,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1140,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1170,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1200,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1230,
+ "y": 774,
+ "type": "common"
+ }
+ ],
+ "required": 6,
+ "ocdTarget": 14
+}
diff --git a/assets/gamedata/gootower/level-007.json b/assets/gamedata/gootower/level-007.json
new file mode 100644
index 0000000..4891a6f
--- /dev/null
+++ b/assets/gamedata/gootower/level-007.json
@@ -0,0 +1,248 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 7,
+ "name": "Anchors Aweigh",
+ "chapter": 2,
+ "sandbox": true,
+ "tip": "Brown anchor goo sticks straight to rock and needs no friends. Start a span from a wall.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 400,
+ 300
+ ],
+ [
+ 460,
+ 300
+ ],
+ [
+ 460,
+ 850
+ ],
+ [
+ 400,
+ 850
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 1120,
+ 300
+ ],
+ [
+ 1180,
+ 300
+ ],
+ [
+ 1180,
+ 850
+ ],
+ [
+ 1120,
+ 850
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 749,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 811,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 780,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 780,
+ "y": 400,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 860,
+ "y": 834,
+ "type": "anchor"
+ },
+ {
+ "x": 890,
+ "y": 834,
+ "type": "anchor"
+ },
+ {
+ "x": 920,
+ "y": 834,
+ "type": "anchor"
+ },
+ {
+ "x": 950,
+ "y": 834,
+ "type": "anchor"
+ },
+ {
+ "x": 980,
+ "y": 834,
+ "type": "anchor"
+ },
+ {
+ "x": 1010,
+ "y": 834,
+ "type": "anchor"
+ },
+ {
+ "x": 860,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 890,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 920,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 950,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 980,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1010,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 860,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 890,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 920,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 950,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 980,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1010,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 860,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 890,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 920,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 950,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 980,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 1010,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 860,
+ "y": 714,
+ "type": "common"
+ },
+ {
+ "x": 890,
+ "y": 714,
+ "type": "common"
+ }
+ ],
+ "required": 5,
+ "ocdTarget": 12
+}
diff --git a/assets/gamedata/gootower/level-008.json b/assets/gamedata/gootower/level-008.json
new file mode 100644
index 0000000..1522a59
--- /dev/null
+++ b/assets/gamedata/gootower/level-008.json
@@ -0,0 +1,258 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 8,
+ "name": "Sticking Point",
+ "chapter": 2,
+ "sandbox": true,
+ "tip": "Pokey goo grips rock. There is no ground here worth building from.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 240,
+ 200
+ ],
+ [
+ 320,
+ 200
+ ],
+ [
+ 320,
+ 760
+ ],
+ [
+ 240,
+ 760
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 1120,
+ 200
+ ],
+ [
+ 1200,
+ 200
+ ],
+ [
+ 1200,
+ 760
+ ],
+ [
+ 1120,
+ 760
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 340,
+ "y": 560,
+ "type": "pokey",
+ "pinned": true
+ },
+ {
+ "x": 340,
+ "y": 498,
+ "type": "pokey",
+ "pinned": true
+ },
+ {
+ "x": 398,
+ "y": 530,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 1080,
+ "y": 430,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 620,
+ "y": 834,
+ "type": "pokey"
+ },
+ {
+ "x": 650,
+ "y": 834,
+ "type": "pokey"
+ },
+ {
+ "x": 680,
+ "y": 834,
+ "type": "pokey"
+ },
+ {
+ "x": 710,
+ "y": 834,
+ "type": "pokey"
+ },
+ {
+ "x": 740,
+ "y": 834,
+ "type": "pokey"
+ },
+ {
+ "x": 770,
+ "y": 834,
+ "type": "pokey"
+ },
+ {
+ "x": 620,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 650,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 680,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 710,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 740,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 770,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 800,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 830,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 620,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 650,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 680,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 710,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 740,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 770,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 800,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 830,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 620,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 650,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 680,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 710,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 740,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 770,
+ "y": 744,
+ "type": "common"
+ }
+ ],
+ "required": 5,
+ "ocdTarget": 12
+}
diff --git a/assets/gamedata/gootower/level-009.json b/assets/gamedata/gootower/level-009.json
new file mode 100644
index 0000000..b9e7025
--- /dev/null
+++ b/assets/gamedata/gootower/level-009.json
@@ -0,0 +1,265 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 9,
+ "name": "Demolition",
+ "chapter": 2,
+ "sandbox": true,
+ "tip": "Drop a bomb by the fire and the soft rock goes with it. Or take the long way over.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 700,
+ 660
+ ],
+ [
+ 780,
+ 660
+ ],
+ [
+ 780,
+ 850
+ ],
+ [
+ 700,
+ 850
+ ]
+ ],
+ "destructible": true
+ },
+ {
+ "kind": "fire",
+ "poly": [
+ [
+ 600,
+ 806
+ ],
+ [
+ 680,
+ 806
+ ],
+ [
+ 680,
+ 850
+ ],
+ [
+ 600,
+ 850
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 369,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 431,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 400,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 980,
+ "y": 700,
+ "r": 46
+ },
+ "waypoints": [
+ {
+ "x": 740,
+ "y": 590
+ }
+ ],
+ "pile": [
+ {
+ "x": 160,
+ "y": 834,
+ "type": "bomb"
+ },
+ {
+ "x": 190,
+ "y": 834,
+ "type": "bomb"
+ },
+ {
+ "x": 220,
+ "y": 834,
+ "type": "bomb"
+ },
+ {
+ "x": 250,
+ "y": 834,
+ "type": "bomb"
+ },
+ {
+ "x": 160,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 190,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 220,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 250,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 280,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 310,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 340,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 160,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 190,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 220,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 250,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 280,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 310,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 340,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 160,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 190,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 220,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 250,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 280,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 310,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 340,
+ "y": 744,
+ "type": "common"
+ },
+ {
+ "x": 160,
+ "y": 714,
+ "type": "common"
+ },
+ {
+ "x": 190,
+ "y": 714,
+ "type": "common"
+ },
+ {
+ "x": 220,
+ "y": 714,
+ "type": "common"
+ }
+ ],
+ "required": 5,
+ "ocdTarget": 12
+}
diff --git a/assets/gamedata/gootower/level-010.json b/assets/gamedata/gootower/level-010.json
new file mode 100644
index 0000000..c33d8a0
--- /dev/null
+++ b/assets/gamedata/gootower/level-010.json
@@ -0,0 +1,280 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 10,
+ "name": "Updraft",
+ "chapter": 2,
+ "sandbox": true,
+ "tip": "The updraft takes weight off everything inside it. Spans reach further in the wind.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 700
+ ],
+ [
+ 600,
+ 700
+ ],
+ [
+ 600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 940,
+ 700
+ ],
+ [
+ 1600,
+ 700
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 940,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "spike",
+ "poly": [
+ [
+ 600,
+ 930
+ ],
+ [
+ 940,
+ 930
+ ],
+ [
+ 940,
+ 1000
+ ],
+ [
+ 600,
+ 1000
+ ]
+ ]
+ },
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 620
+ ],
+ [
+ 40,
+ 620
+ ],
+ [
+ 40,
+ 700
+ ],
+ [
+ 0,
+ 700
+ ]
+ ]
+ }
+ ],
+ "fans": [
+ {
+ "x": 600,
+ "y": 300,
+ "w": 340,
+ "h": 630,
+ "dx": 0,
+ "dy": -1,
+ "force": 1150
+ }
+ ],
+ "balls": [
+ {
+ "x": 329,
+ "y": 686,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 391,
+ "y": 686,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 360,
+ "y": 632,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 1060,
+ "y": 630,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 120,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 150,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 180,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 210,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 240,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 270,
+ "y": 684,
+ "type": "common"
+ },
+ {
+ "x": 120,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 150,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 180,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 210,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 240,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 270,
+ "y": 654,
+ "type": "common"
+ },
+ {
+ "x": 120,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 150,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 180,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 210,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 240,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 270,
+ "y": 624,
+ "type": "common"
+ },
+ {
+ "x": 120,
+ "y": 594,
+ "type": "common"
+ },
+ {
+ "x": 150,
+ "y": 594,
+ "type": "common"
+ },
+ {
+ "x": 180,
+ "y": 594,
+ "type": "common"
+ },
+ {
+ "x": 210,
+ "y": 594,
+ "type": "common"
+ },
+ {
+ "x": 240,
+ "y": 594,
+ "type": "common"
+ },
+ {
+ "x": 270,
+ "y": 594,
+ "type": "common"
+ },
+ {
+ "x": 120,
+ "y": 564,
+ "type": "common"
+ },
+ {
+ "x": 150,
+ "y": 564,
+ "type": "common"
+ }
+ ],
+ "required": 4,
+ "ocdTarget": 12
+}
diff --git a/assets/gamedata/gootower/level-011.json b/assets/gamedata/gootower/level-011.json
new file mode 100644
index 0000000..a7ad9e0
--- /dev/null
+++ b/assets/gamedata/gootower/level-011.json
@@ -0,0 +1,225 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 11,
+ "name": "Cogs",
+ "chapter": 2,
+ "sandbox": true,
+ "tip": "The cog is solid and it is turning. Anything it touches gets dragged along.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ }
+ ],
+ "gears": [
+ {
+ "x": 800,
+ "y": 470,
+ "r": 110,
+ "teeth": 9,
+ "omega": 1.4
+ }
+ ],
+ "balls": [
+ {
+ "x": 269,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 331,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 300,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 1240,
+ "y": 560,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 1000,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1030,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1060,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1090,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1120,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1150,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1180,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1210,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1240,
+ "y": 834,
+ "type": "common"
+ },
+ {
+ "x": 1000,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1030,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1060,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1090,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1120,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1150,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1180,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1210,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1240,
+ "y": 804,
+ "type": "common"
+ },
+ {
+ "x": 1000,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1030,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1060,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1090,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1120,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1150,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1180,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1210,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1240,
+ "y": 774,
+ "type": "common"
+ },
+ {
+ "x": 1000,
+ "y": 744,
+ "type": "common"
+ }
+ ],
+ "required": 5,
+ "ocdTarget": 13
+}
diff --git a/assets/gamedata/gootower/level-012.json b/assets/gamedata/gootower/level-012.json
new file mode 100644
index 0000000..e878e51
--- /dev/null
+++ b/assets/gamedata/gootower/level-012.json
@@ -0,0 +1,266 @@
+{
+ "world": {
+ "w": 1600,
+ "h": 1000
+ },
+ "level": 12,
+ "name": "Lighter Than Air",
+ "chapter": 2,
+ "sandbox": true,
+ "tip": "Balloons pull up, blocks hold rigid, bits are cheap and weak. Mix them.",
+ "terrain": [
+ {
+ "kind": "solid",
+ "poly": [
+ [
+ 0,
+ 850
+ ],
+ [
+ 1600,
+ 850
+ ],
+ [
+ 1600,
+ 1000
+ ],
+ [
+ 0,
+ 1000
+ ]
+ ]
+ }
+ ],
+ "balls": [
+ {
+ "x": 369,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 431,
+ "y": 836,
+ "type": "common",
+ "pinned": true
+ },
+ {
+ "x": 400,
+ "y": 782,
+ "type": "common"
+ }
+ ],
+ "strands": [
+ [
+ 2,
+ 0
+ ],
+ [
+ 2,
+ 1
+ ],
+ [
+ 0,
+ 1
+ ]
+ ],
+ "pipe": {
+ "x": 400,
+ "y": 300,
+ "r": 46
+ },
+ "pile": [
+ {
+ "x": 700,
+ "y": 834,
+ "type": "balloon"
+ },
+ {
+ "x": 730,
+ "y": 834,
+ "type": "balloon"
+ },
+ {
+ "x": 760,
+ "y": 834,
+ "type": "balloon"
+ },
+ {
+ "x": 790,
+ "y": 834,
+ "type": "balloon"
+ },
+ {
+ "x": 820,
+ "y": 834,
+ "type": "balloon"
+ },
+ {
+ "x": 850,
+ "y": 834,
+ "type": "balloon"
+ },
+ {
+ "x": 700,
+ "y": 800,
+ "type": "block"
+ },
+ {
+ "x": 730,
+ "y": 800,
+ "type": "block"
+ },
+ {
+ "x": 760,
+ "y": 800,
+ "type": "block"
+ },
+ {
+ "x": 790,
+ "y": 800,
+ "type": "block"
+ },
+ {
+ "x": 820,
+ "y": 800,
+ "type": "block"
+ },
+ {
+ "x": 850,
+ "y": 800,
+ "type": "block"
+ },
+ {
+ "x": 880,
+ "y": 800,
+ "type": "block"
+ },
+ {
+ "x": 910,
+ "y": 800,
+ "type": "block"
+ },
+ {
+ "x": 700,
+ "y": 766,
+ "type": "bit"
+ },
+ {
+ "x": 730,
+ "y": 766,
+ "type": "bit"
+ },
+ {
+ "x": 760,
+ "y": 766,
+ "type": "bit"
+ },
+ {
+ "x": 790,
+ "y": 766,
+ "type": "bit"
+ },
+ {
+ "x": 820,
+ "y": 766,
+ "type": "bit"
+ },
+ {
+ "x": 850,
+ "y": 766,
+ "type": "bit"
+ },
+ {
+ "x": 700,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 730,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 760,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 790,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 820,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 850,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 880,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 910,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 940,
+ "y": 734,
+ "type": "common"
+ },
+ {
+ "x": 700,
+ "y": 704,
+ "type": "common"
+ },
+ {
+ "x": 730,
+ "y": 704,
+ "type": "common"
+ },
+ {
+ "x": 760,
+ "y": 704,
+ "type": "common"
+ },
+ {
+ "x": 790,
+ "y": 704,
+ "type": "common"
+ },
+ {
+ "x": 820,
+ "y": 704,
+ "type": "common"
+ },
+ {
+ "x": 850,
+ "y": 704,
+ "type": "common"
+ },
+ {
+ "x": 880,
+ "y": 704,
+ "type": "common"
+ },
+ {
+ "x": 910,
+ "y": 704,
+ "type": "common"
+ },
+ {
+ "x": 940,
+ "y": 704,
+ "type": "common"
+ }
+ ],
+ "required": 5,
+ "ocdTarget": 12
+}
diff --git a/assets/gamedata/gootower/levels.json b/assets/gamedata/gootower/levels.json
new file mode 100644
index 0000000..7da1e78
--- /dev/null
+++ b/assets/gamedata/gootower/levels.json
@@ -0,0 +1,124 @@
+{
+ "version": 1,
+ "generatedAt": "1970-01-01T00:00:00.000Z",
+ "levels": [
+ {
+ "level": 1,
+ "name": "Ground Floor",
+ "chapter": 1,
+ "file": "level-001.json",
+ "required": 4,
+ "ocdTarget": 9
+ },
+ {
+ "level": 2,
+ "name": "Mind the Gap",
+ "chapter": 1,
+ "file": "level-002.json",
+ "required": 5,
+ "ocdTarget": 12
+ },
+ {
+ "level": 3,
+ "name": "Headroom",
+ "chapter": 1,
+ "file": "level-003.json",
+ "required": 5,
+ "ocdTarget": 12
+ },
+ {
+ "level": 4,
+ "name": "Second Thoughts",
+ "chapter": 1,
+ "file": "level-004.json",
+ "required": 4,
+ "ocdTarget": 9
+ },
+ {
+ "level": 5,
+ "name": "Overhead",
+ "chapter": 1,
+ "file": "level-005.json",
+ "required": 5,
+ "ocdTarget": 11
+ },
+ {
+ "level": 6,
+ "name": "Tall Order",
+ "chapter": 1,
+ "file": "level-006.json",
+ "required": 6,
+ "ocdTarget": 14
+ },
+ {
+ "level": 7,
+ "name": "Anchors Aweigh",
+ "chapter": 2,
+ "file": "level-007.json",
+ "required": 5,
+ "ocdTarget": 12,
+ "sandbox": true
+ },
+ {
+ "level": 8,
+ "name": "Sticking Point",
+ "chapter": 2,
+ "file": "level-008.json",
+ "required": 5,
+ "ocdTarget": 12,
+ "sandbox": true
+ },
+ {
+ "level": 9,
+ "name": "Demolition",
+ "chapter": 2,
+ "file": "level-009.json",
+ "required": 5,
+ "ocdTarget": 12,
+ "sandbox": true
+ },
+ {
+ "level": 10,
+ "name": "Updraft",
+ "chapter": 2,
+ "file": "level-010.json",
+ "required": 4,
+ "ocdTarget": 12,
+ "sandbox": true
+ },
+ {
+ "level": 11,
+ "name": "Cogs",
+ "chapter": 2,
+ "file": "level-011.json",
+ "required": 5,
+ "ocdTarget": 13,
+ "sandbox": true
+ },
+ {
+ "level": 12,
+ "name": "Lighter Than Air",
+ "chapter": 2,
+ "file": "level-012.json",
+ "required": 5,
+ "ocdTarget": 12,
+ "sandbox": true
+ }
+ ],
+ "chapters": [
+ {
+ "id": 1,
+ "name": "The Goo Filled Hills",
+ "blurb": "Learn to build.",
+ "from": 1,
+ "to": 6
+ },
+ {
+ "id": 2,
+ "name": "Sandbox",
+ "blurb": "One level per mechanic — throwaway, replaced in Wave 4.",
+ "from": 7,
+ "to": 12
+ }
+ ]
+}
diff --git a/assets/images/background-jello.png b/assets/images/background-jello.png
index 43db9f4..6d6a5d9 100644
Binary files a/assets/images/background-jello.png and b/assets/images/background-jello.png differ
diff --git a/assets/images/background-mahjongmatch.png b/assets/images/background-mahjongmatch.png
index bd375e4..4b02b6e 100644
Binary files a/assets/images/background-mahjongmatch.png and b/assets/images/background-mahjongmatch.png differ
diff --git a/assets/images/background-mm-01.png b/assets/images/background-mm-01.png
index 35825ce..2ec179b 100644
Binary files a/assets/images/background-mm-01.png and b/assets/images/background-mm-01.png differ
diff --git a/assets/images/background-mm-02.png b/assets/images/background-mm-02.png
index 44b929e..befd6da 100644
Binary files a/assets/images/background-mm-02.png and b/assets/images/background-mm-02.png differ
diff --git a/assets/images/background-mm-03.png b/assets/images/background-mm-03.png
index ea4b61b..173ed13 100644
Binary files a/assets/images/background-mm-03.png and b/assets/images/background-mm-03.png differ
diff --git a/assets/images/bloxorz/background-menu.png b/assets/images/bloxorz/background-menu.png
index cf16b3c..e39e700 100644
Binary files a/assets/images/bloxorz/background-menu.png and b/assets/images/bloxorz/background-menu.png differ
diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png
index 6ceac24..91c1062 100644
Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ
diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd
index 6af4c01..350bb7f 100644
Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ
diff --git a/assets/music/chinese-track01.mp3 b/assets/music/chinese-track01.mp3
new file mode 100644
index 0000000..6e29c25
Binary files /dev/null and b/assets/music/chinese-track01.mp3 differ
diff --git a/assets/music/chinese-track02.mp3 b/assets/music/chinese-track02.mp3
new file mode 100644
index 0000000..156b068
Binary files /dev/null and b/assets/music/chinese-track02.mp3 differ
diff --git a/assets/music/chinese-track03.mp3 b/assets/music/chinese-track03.mp3
new file mode 100644
index 0000000..ae448ed
Binary files /dev/null and b/assets/music/chinese-track03.mp3 differ
diff --git a/data/chinese-music.json b/data/chinese-music.json
new file mode 100644
index 0000000..1c412e6
--- /dev/null
+++ b/data/chinese-music.json
@@ -0,0 +1,20 @@
+{
+ "tracks": [
+ {
+ "file": "chinese-track01.mp3",
+ "artist": "Kage",
+ "title": "Spring Blossoms"
+ },
+ {
+ "file": "chinese-track02.mp3",
+ "artist": "Aiko",
+ "title": "Autumn Breeze"
+ },
+ {
+ "file": "chinese-track03.mp3",
+ "artist": "Steve",
+ "title": "I studied your Earth Music"
+ }
+ ],
+ "volume": 0.4
+}
diff --git a/docs/gootower-build-plan.md b/docs/gootower-build-plan.md
new file mode 100644
index 0000000..3dd110f
--- /dev/null
+++ b/docs/gootower-build-plan.md
@@ -0,0 +1,267 @@
+# Goo Tower (World of Goo) — build plan
+
+Living plan doc. Survives context clears: **read this file first**, pick the next unchecked
+item, update the checkboxes and the "Status" line as work lands.
+
+**Status:** **Waves 0–3 complete (2026-07-26).** All nine goo types and every hazard are
+live, with a 6-level sandbox tier on top of the 6-level teaching chapter —
+`node tools/verifyGooTower.js` → **303 checks green**, including a reference player that
+beats all **12** levels headlessly. **Never opened in a browser** — Brian playtests, and
+Waves 1–3 are not signed off until he has.
+
+---
+
+## Decisions taken up front
+
+| Question | Answer |
+|---|---|
+| Scope | Full campaign — 5 named chapters, ~75 levels. **No** Goo Corporation sandbox tower. |
+| Level authoring | **Both** — editor for hand-tuned levels, generator for drill/sandbox tiers |
+| Art | **Procedural, zero art dependency**; optional drop-in sprite hooks later |
+| Name | **Goo Tower**, slug `gootower`, iconFrame **90** (0–89 all taken) |
+
+## Files
+
+| File | Role | State |
+|---|---|---|
+| `src/games/gootower/GooTowerLogic.js` | Pure sim + rules. Zero imports, Node-testable. | Waves 0–1 done |
+| `src/games/gootower/GooTowerGame.js` | Phaser scene: level select, board, HUD | Wave 1 done |
+| `src/games/gootower/tutorial.md` | `hasTutorial: true` | done |
+| `src/games/gootower/GooTowerEditor.js` | Level editor, `?gootower-editor=1` | Wave 2 done |
+| `src/games/gootower/GooTowerAuto.js` | Greedy reference player = the winnability gate | done |
+| `tools/verifyGooTower.js` | Regression harness, **303 checks** | Waves 0–3 done |
+| `tools/genGooTower.js` | Level bank writer (12 hand-tuned levels) | Wave 3 done |
+| `assets/gamedata/gootower/levels.json` | Manifest + per-level `level-NNN.json` | 12 levels |
+
+`GooTowerAuto.js` lives in `src/`, not `tools/`, because the editor's "Test Winnable"
+button must run exactly the gate the verifier runs.
+
+Wiring is in place: `gamesRegistry.js` (iconFrame 90), `main.js` import + scene array,
+`GameRoomScene.js` slugDispatch, `PreloadScene.js` manifest load.
+
+Run the harness after **every** change to the sim. The physics is the game: a subtly wrong
+solver produces structures that look plausible and are unbuildable, and there is no way to
+tell by reading the code.
+
+---
+
+## Wave 0 — the solver — ✅ DONE 2026-07-26
+
+Landed: Verlet integration with XPBD distance constraints, terrain collision and pushout,
+ball–ball collision with a uniform-grid broadphase, tension-based strand breaking with a
+fatigue window, structure flood-fill, seeded RNG, and a 69-check harness.
+
+- [x] Verlet integrator with global drag and a per-substep travel cap
+- [x] XPBD distance constraints (compliance + axial damping)
+- [x] Terrain: point-in-poly, closest-point pushout, friction/restitution, slopes
+- [x] Ball–ball collision (uniform grid, rebuilt once per substep)
+- [x] Strand breaking on load, with a shock path and a length backstop
+- [x] Structure flood-fill; `rooted` components; adrift chunks fall as chunks
+- [x] Hazards: spikes, fire, falling out of the world
+- [x] Determinism: identical replay, frame-rate independence, ragged pacing, `cloneState`
+- [x] `hashState` fingerprint for the generator and verifier
+
+### Five findings worth not rediscovering
+
+1. **Plain PBD cannot break strands.** A near-rigid solver yanks every strand back to its
+ rest length each substep, so `length/rest` can never report load and *nothing ever
+ breaks*. XPBD's accumulated multiplier λ **is** the constraint force — that is why the
+ solver is XPBD and not simple relaxation. `tension = -λ/dt²` now reads the true static
+ load (verified: 1.00 / 2.01 / 4.08 ball-weights for 1/2/4-ball chains).
+2. **Break on force, not on stretch.** At the stiffness a buildable truss needs
+ (k ≈ 8333), a `BREAK_RATIO` threshold would demand ~350 ball-weights and nothing would
+ ever collapse. `BREAK_FORCE` is expressed in ball-weights (25) and is therefore
+ independent of any later stiffness retune. The length ratio survives only as a backstop.
+3. **Break on a *filtered* load.** A stiff lattice rings at ~15Hz and any impact spikes to
+ ~2× static, so judging raw tension snaps strands on the first frame of a level that is
+ merely settling. `LOAD_TAU` (0.12s) is the fatigue window; `SHOCK_FACTOR` (3×) still
+ lets a genuinely violent hit cut through it.
+4. **`isSettled()` is instantaneous, and that is a trap.** An oscillating structure passes
+ through zero speed at every turning point, so "step until settled" stops at the moment
+ of *maximum stretch* and reports the peak of a decaying oscillation as the static
+ equilibrium. This produced a completely bogus, non-monotonic tuning sweep before it was
+ spotted. `settle()` now requires a sustained quiet window.
+5. **Constraint damping cannot settle a tower.** It resists a strand *stretching*, but the
+ slowest mode is a pendulum swing that barely stretches anything — it rang for 13s.
+ Global `DRAG` (0.995/substep ≈ 0.30/s) is what actually settles structures.
+
+Also: `addStrand` takes its rest length from the distance it is **born** at. Clamping to a
+fixed 62 meant a level author's 87px diagonal started 1.41× pre-stressed and ripped the
+lattice apart on frame one.
+
+### Baselines
+
+```
+node tools/verifyGooTower.js # 69 passed, 0 failed
+```
+
+Perf (1s of sim, full 240Hz substepping):
+
+| Structure | ms/frame | Budget |
+|---|---|---|
+| 30 balls / 69 strands | 0.73 | 16.7 |
+| 100 balls / 261 strands | 2.18 | 16.7 |
+| 196 balls / 533 strands | 4.53 | 16.7 |
+
+### Known gaps, deliberate
+
+- Rigidity is purely emergent from triangulation — there are **no angular constraints**.
+ That makes `MIN_ANGLE_DEG` in the attachment rule (Wave 1) the most important number in
+ the game: it is the only thing forcing the player to build real trusses.
+- `GOO_TYPES` carries all nine varieties already, but only mass/buoyancy/stiffness/strength
+ and `spikeProof` are wired. Detachability, terrain-sticking and explosions are Wave 3.
+- `refreshStructure` runs whenever a ball touches terrain. Fine at current scale (see perf
+ table); revisit if a level ever needs 400+ balls.
+
+---
+
+## Wave 1 — playable core — ✅ DONE 2026-07-26 (pending playtest)
+
+- [x] `chooseAttachments()` — reach, line of sight, nearest-first, **MIN_ANGLE rejection**
+- [x] Drag a ball out of the pile; place or spring back; detachable types can be lifted off
+- [x] Crawling goo: `{onStrand, t, dir}`, deterministic node choice, seeded wander
+- [x] Pipe: opens on contact; BFS over the strand graph; crawlers path in and are drunk
+- [x] Win at `collected >= required`; OCD tracked as a separate award
+- [x] `GooTowerGame.js` — accumulator + events array, procedural rendering, `D` depth map
+- [x] Wiring (all four files) + `tutorial.md`
+- [x] Progress via `/puzzles/gootower/{progress,complete,reset}` + `/history/single-player`
+- [x] Six hand-built levels in `assets/gamedata/gootower/`
+- [x] Greedy reference player; all six levels beaten headlessly
+- [ ] **First browser playtest — NOT DONE.** `MIN_ANGLE_DEG`, `ATTACH_R`, `GRAVITY` and
+ `WANDER_SPEED` get tuned by feel here. No amount of Node testing substitutes.
+
+### Wave 1 findings
+
+1. **Loose goo has to walk.** The pipe would open and then collect *nothing*, because the
+ heap sat where it was authored and never touched the structure. In the original,
+ unattached goo mills around the tower; `wanderLoose()` reproduces that. Without it a
+ level whose heap is not already against the structure is simply unwinnable, and no
+ amount of building fixes it. Re-targeting is throttled to `WANDER_HZ` because it is
+ O(loose x attached).
+2. **A settling level is not a winnable level.** The bank lint (levels settle, keep their
+ strands, lose no goo) passed on all six while two of them could not actually be beaten.
+ Only playing them found it — hence `tools/lib/gooAutoPlay.js`.
+3. Two real level bugs it caught: **L4** handed out exactly enough goo to *reach* the pipe
+ and none to feed it; **L3** asked for a 677px diagonal traverse around an obstacle as the
+ third teaching level.
+4. The greedy gate is **one-directional**: if the naive builder wins, a human can. A greedy
+ failure is *not* proof a level is impossible — redesign or store a hand-authored
+ reference build, never conclude "unwinnable".
+
+### Baselines
+
+```
+node tools/verifyGooTower.js # 179 passed, 0 failed (~5s, includes the winnability gate)
+node tools/genGooTower.js # rewrites the 6-level bank
+```
+
+## Wave 2 — the editor — ✅ DONE 2026-07-26 (pending playtest)
+
+Reached at `index.html?gootower-editor=1`. Tools: **terrain** (click vertices, Enter or
+double-click to close; solid/spike/fire), **ball** (starting structure, auto-strands, shift
+for pinned), **pile** (loose goo), **pipe**, **erase** (or right-click anywhere). Ctrl-Z
+undoes. Settings live in a DOM panel on the right; **Settle**, **Test Winnable**, **Test
+Play**, and Blob-download **⬇ Level** / **⬇ Manifest** sit along the bottom.
+
+- [x] Terrain polygon drawing, all three kinds
+- [x] Structure goo with a live attachment preview — it calls the game's own
+ `chooseAttachments`, so the MIN_ANGLE rule is visible while authoring
+- [x] Pile, pipe, erase, undo
+- [x] Settle before export; validation blocks a broken export
+- [x] Test-play round-trip via `registry` + `scene.start('GooTowerGame', { testLevel, returnToEditor })`
+- [x] Test Winnable runs the same greedy gate the verifier uses
+- [x] Load from the manifest or from a local file
+- [ ] **Browser playtest — NOT DONE.**
+
+### Wave 2 findings
+
+1. **Settling for export must be physics-only.** `settle()` runs the *whole* sim, agents
+ included — so loose goo walks to the structure and climbs it while settling. Capturing
+ those positions bakes the heap INSIDE the tower, and reloading that level explodes as the
+ collision solver ejects it (measured: 1801px of drift, strands snapped, goo lost). Hence
+ `settlePhysics()`. The editor's Settle button would have quietly corrupted every level
+ with a heap.
+2. **`grounded` is only true on a simulated state.** It is set by terrain contact during a
+ substep, so validation on a never-stepped state sees `false` everywhere and a floating
+ structure sails straight through. `validate()` settles a throwaway copy first.
+3. **The hover preview cannot build a state per mouse event.** `drawHoverPreview` runs on
+ every `pointermove`; a version-stamped cache (`touch()` / `draftState()`) keeps it to one
+ rebuild per edit.
+4. The editor deliberately asks the *real* rules for everything — attachment, legality,
+ winnability — so what it shows can never disagree with what the game does.
+
+### Editor contract, pinned by the verifier
+
+`GooTowerEditor.js` imports Phaser and cannot run in Node, so §10 of the harness asserts the
+assumptions it rests on: structure ball index === ball id (it wires strands by index), pile
+goo comes after the structure in order, a settled level stays settled when reloaded, and an
+editor round-trip reproduces a byte-identical level.
+
+## Wave 3 — remaining goo types and hazards — ✅ DONE 2026-07-26 (pending playtest)
+
+- [x] **anchor / pokey** — bond to any solid surface within `STICK_DIST` and pin there.
+ `requiredStrands()` drops to 0 when stuck, which is what lets a structure start from
+ a wall instead of only from the ground.
+- [x] **bomb** — fire lights a fuse (`FUSE_TIME`), then the blast kills goo inside
+ `BLAST_KILL_R`, shears every strand inside `BLAST_R`, and removes terrain the author
+ marked `destructible`. Bombs chain-react; anchors survive.
+- [x] **balloon** — buoyant, single strand, ~1.3 ball-weights of lift
+- [x] **block / bit / skull** — stiffness, cheapness and spike immunity
+- [x] **fans** — axis-aligned force volumes; goo inside weighs less
+- [x] **gears** — rotating solid terrain that drags whatever touches its rim
+- [x] `hazardAt()` — "legal but lethal", used by both the reference player and the drag preview
+- [x] Sandbox tier: levels 7–12, one per mechanic (**throwaway**, replaced in Wave 4)
+- [x] Editor gained fan/gear tools and a destructible flag
+- [ ] **Browser playtest — NOT DONE.**
+
+### Wave 3 findings
+
+1. **Gears are cheap if you store them as terrain.** A gear is an ordinary `solid` entry
+ carrying a `gear` record; `updateMovers()` respins its polygon each substep and the
+ existing collision path does the rest. The only extra rule is that friction pulls a
+ contacting ball toward the **rim's surface velocity** instead of toward zero — that one
+ line is the whole mechanic. Time-varying terrain costs nothing in determinism.
+2. **Legal is not survivable.** The reference player happily dropped goo into fire 24 times
+ in a row, because `canPlace` only asks whether a strand can form. `hazardAt()` closes
+ that, and the drag preview now warns the player too (orange, not red — it *is* a legal
+ drop).
+3. **Greedy will not climb a wall.** Getting over an obstacle means first building *away*
+ from the pipe, which a nearest-to-target search never does. Levels can hand the gate
+ `waypoints` to route it — a hint to the verifier, not gameplay. `autoPlay` also accepts a
+ stored `reference` build for anything waypoints cannot express.
+4. **A sandbox level must not make its mechanic the only route.** L9's first draft capped
+ the destructible rock with fire, so the climb-over was lethal and the bomb was the sole
+ solution — which greedy cannot find, making the level unwinnable by the gate *and* by any
+ player who did not guess. The fire moved beside the rock; the bomb is now a shortcut.
+5. **Overlapping goo at level start is violent.** Two balls 6px apart are ejected hard
+ enough to snap their own strand (found when a balloon flew to y=-31120). Both the bank
+ lint and the editor now refuse it — and it immediately caught a real overlap in L2.
+
+### Baselines
+
+```
+node tools/verifyGooTower.js # 303 passed, 0 failed (~15s, includes 12 winnability runs)
+node tools/genGooTower.js # rewrites the 12-level bank
+```
+
+## Wave 4 — the campaign — ⬜ NEXT
+
+Five named chapters × ~15 levels, **replacing the throwaway sandbox tier**. Hand-authored
+teaching levels in the editor; `tools/genGooTower.js` fills drill slots. Gates adapted from `genPuddingMonsters.js`:
+**solvable** (a recorded reference build wins under the deterministic sim), **load-bearing**
+(strip the featured element and the level must break or get materially harder), **gentle**
+(no instant-fail opening in a teaching level).
+
+The uncertain piece: a physics sandbox has no clean BFS solver, so "solvable" means *a
+stored reference build replays to a win*. That is why levels are hand-authored first and
+generated second.
+
+---
+
+## Sources for original-game behaviour
+
+Design reconstructed from the shipped game's mechanics: goo balls as point masses joined by
+springy strands, rigidity from triangulation, loose goo crawling the structure, the pipe
+drinking a required count, and the OCD (Obsessive Completion Distinction) as a separate
+award. Level layouts, art, music and the original's physics constants are **not**
+reproduced — everything here is authored fresh, as Peggle and Jell-o Monsters were.
diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js
index 4baac30..ad65c0b 100644
--- a/src/data/gamesRegistry.js
+++ b/src/data/gamesRegistry.js
@@ -117,3 +117,4 @@ registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-cons
registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 87 });
registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 88 });
registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 });
+registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 });
diff --git a/src/games/gootower/GooTowerAuto.js b/src/games/gootower/GooTowerAuto.js
new file mode 100644
index 0000000..29bf6cb
--- /dev/null
+++ b/src/games/gootower/GooTowerAuto.js
@@ -0,0 +1,106 @@
+// Goo Tower — headless reference player.
+//
+// A physics sandbox has no clean BFS solver the way a grid puzzle does, so
+// "is this level winnable?" is answered by actually playing it: place goo with
+// a deliberately NAIVE greedy strategy and see whether the pipe opens and
+// drinks its fill. If this dumb builder can win, a human certainly can.
+//
+// The converse does not hold -- a greedy failure does not prove a level is
+// impossible -- so treat a failure as "redesign or hand-author a reference
+// build", never as proof of impossibility.
+//
+// Lives in src/ (not tools/) because the level editor calls it too: its
+// "Test Winnable" button must run exactly the gate the verifier runs.
+
+import {
+ TUNING, createState, stepSim, canPlace, beginDrag, endDrag, isWon,
+ hazardAt,
+} from './GooTowerLogic.js';
+
+const SETTLE_FRAMES = 24;
+
+// Greedy: of every legal drop position on a ring around each attached ball,
+// take the one nearest the current target.
+function bestDrop(st, type, target) {
+ let best = null;
+ let bestD = Infinity;
+ for (const anchor of st.balls) {
+ if (!anchor.attached || anchor.dead) continue;
+ for (let a = 0; a < 24; a += 1) {
+ const ang = (a / 24) * Math.PI * 2;
+ for (const rad of [TUNING.STRAND_REST, TUNING.STRAND_REST * 1.3]) {
+ const x = anchor.x + Math.cos(ang) * rad;
+ const y = anchor.y + Math.sin(ang) * rad;
+ if (!canPlace(st, x, y, type)) continue;
+ // Legal is not the same as survivable: dropping goo onto spikes or
+ // into fire is a valid placement that kills it instantly, and a
+ // greedy builder will do it over and over.
+ if (hazardAt(st, x, y, type)) continue;
+ const d = Math.hypot(x - target.x, y - target.y);
+ if (d < bestD) { bestD = d; best = { x, y }; }
+ }
+ }
+ }
+ return best;
+}
+
+export function autoPlay(level, { maxBalls = 60, maxSeconds = 60, seed } = {}) {
+ const st = seed == null ? createState(level) : createState(level, seed);
+ let placed = 0;
+
+ // A stored reference build beats guessing. Some mechanics -- lighting a bomb
+ // to blow open a wall, say -- a greedy builder will never discover, so the
+ // level author records the intended solution and the gate replays THAT.
+ if (Array.isArray(level.reference) && level.reference.length) {
+ for (const step of level.reference) {
+ const ball = st.balls.find((b) => !b.dead && !b.attached && !b.held
+ && (!step.type || b.type === step.type));
+ if (!ball) break;
+ beginDrag(st, ball, null);
+ endDrag(st, ball, step.x, step.y, null);
+ placed += 1;
+ for (let k = 0; k < SETTLE_FRAMES; k += 1) stepSim(st, 1 / 60);
+ }
+ for (let k = 0; k < 60 * maxSeconds && !isWon(st); k += 1) stepSim(st, 1 / 60);
+ return { state: st, won: isWon(st), placed, pipeOpen: !!st.pipe?.open, collected: st.collected, mode: 'reference' };
+ }
+
+ // Pure "always build toward the pipe" walks straight into a local minimum
+ // whenever clearing an obstacle means first building AWAY from the pipe. A
+ // level can hand the reference player waypoints to route around that; they
+ // are a hint to the gate, not gameplay.
+ const targets = [...(level.waypoints || []).map((w) => ({ x: w.x, y: w.y }))];
+ if (st.pipe) targets.push({ x: st.pipe.x, y: st.pipe.y });
+ let ti = 0;
+
+ for (let i = 0; i < maxBalls; i += 1) {
+ if (!st.pipe || st.pipe.open) break;
+ const ball = st.balls.find((b) => !b.dead && !b.attached && !b.held && !b.walking)
+ || st.balls.find((b) => !b.dead && !b.attached && !b.held);
+ if (!ball) break;
+
+ while (ti < targets.length - 1
+ && st.balls.some((b) => b.attached && !b.dead
+ && Math.hypot(b.x - targets[ti].x, b.y - targets[ti].y) < 72)) {
+ ti += 1;
+ }
+ const drop = bestDrop(st, ball.type, targets[ti]);
+ if (!drop) break;
+ beginDrag(st, ball, null);
+ endDrag(st, ball, drop.x, drop.y, null);
+ placed += 1;
+ for (let k = 0; k < SETTLE_FRAMES; k += 1) stepSim(st, 1 / 60);
+ }
+
+ // Let the crawlers do their work.
+ for (let k = 0; k < 60 * maxSeconds && !isWon(st); k += 1) stepSim(st, 1 / 60);
+
+ return {
+ state: st,
+ won: isWon(st),
+ placed,
+ pipeOpen: !!st.pipe?.open,
+ collected: st.collected,
+ mode: 'greedy',
+ };
+}
diff --git a/src/games/gootower/GooTowerEditor.js b/src/games/gootower/GooTowerEditor.js
new file mode 100644
index 0000000..9b1fc27
--- /dev/null
+++ b/src/games/gootower/GooTowerEditor.js
@@ -0,0 +1,773 @@
+import * as Phaser from 'phaser';
+import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
+import { Button } from '../../ui/Button.js';
+import {
+ TUNING, GOO_TYPES, gooType, createState, settlePhysics,
+ chooseAttachments, insideSolid, pointInPoly, isRooted,
+} from './GooTowerLogic.js';
+import { autoPlay } from './GooTowerAuto.js';
+
+// Secret level editor — reached only via index.html?gootower-editor=1.
+//
+// A static site cannot write files, so the editor exports a Blob download of
+// the level plus an upserted manifest and the author drops both into
+// assets/gamedata/gootower/. Same contract as PeggleEditor.
+//
+// The world is 1600x1000, which is wider than the play view can spare once a
+// settings column is on screen, so the editor draws it at SCALE in the left
+// column. Authored coordinates are still world coordinates — every conversion
+// goes through toWorld/toScreen.
+
+const WORLD_W = 1600;
+const WORLD_H = 1000;
+const SCALE = 0.78;
+const EX = 24;
+const EY = 96;
+
+const PANEL_X = EX + WORLD_W * SCALE + 22;
+
+const D = { bg: -2, world: -1, grid: 0, terrain: 2, pipe: 4, strand: 6, ball: 8, marker: 12, ui: 30 };
+
+const KIND_COLOR = { solid: 0x2f2a3d, spike: 0x7d3550, fire: 0xd4642a };
+const GOO_COLOR = {
+ common: 0x4a4258, ivy: 0x63c132, balloon: 0xffd7e6, bomb: 0xc8392b,
+ block: 0x8a6a4a, pokey: 0xb07d18, bit: 0x9ecbff, skull: 0xe8e2ff, anchor: 0x5a4a3a,
+};
+
+const TOOLS = ['terrain', 'ball', 'pile', 'pipe', 'fan', 'gear', 'erase'];
+
+export default class GooTowerEditor extends Phaser.Scene {
+ constructor() { super('GooTowerEditor'); }
+
+ init(data) {
+ this.resume = !!data?.resume;
+
+ this.meta = {
+ level: 1, name: 'New Level', chapter: 1,
+ required: 4, ocdTarget: 9, tip: '',
+ file: 'level-001.json',
+ };
+ this.terrain = []; // { kind, poly: [[x,y]...] }
+ this.balls = []; // { x, y, type, pinned } (the starting structure)
+ this.strands = []; // [i, j] into balls
+ this.pileItems = []; // { x, y, type }
+ this.fans = []; // { x, y, w, h, dx, dy, force }
+ this.gears = []; // { x, y, r, teeth, omega }
+ this.pipe = { x: 800, y: 520, r: TUNING.PIPE_R };
+ this.pendingFan = null; // first corner of a fan rectangle
+ this.destructible = false;
+ this.fanDir = 'up';
+ this.gearOmega = 1.4;
+
+ this.tool = 'terrain';
+ this.kind = 'solid';
+ this.gooType = 'common';
+ this.pinned = false;
+ this.pendingPoly = null;
+ this.manifest = [];
+ this.undoStack = [];
+ this.lastClick = { t: -9999, x: 0, y: 0 };
+ }
+
+ async create() {
+ this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0d1219).setDepth(D.bg);
+ this.input.mouse?.disableContextMenu();
+
+ if (this.resume) {
+ const saved = this.registry.get('gootower-editor-state');
+ if (saved) Object.assign(this, structuredClone(saved));
+ }
+
+ try {
+ const raw = this.cache.json.get('gootower-levels');
+ this.manifest = raw?.levels ?? [];
+ } catch (_) { this.manifest = []; }
+
+ this.worldG = this.add.graphics().setDepth(D.world);
+ this.gridG = this.add.graphics().setDepth(D.grid);
+ this.mainG = this.add.graphics().setDepth(D.terrain);
+ this.markerG = this.add.graphics().setDepth(D.marker);
+
+ this.buildToolbar();
+ this.buildForm();
+ this.bindInput();
+ this.redraw();
+ }
+
+ // ── Coordinate mapping ────────────────────────────────────────────────────
+
+ toWorld(px, py) { return { x: (px - EX) / SCALE, y: (py - EY) / SCALE }; }
+ sx(x) { return EX + x * SCALE; }
+ sy(y) { return EY + y * SCALE; }
+
+ inWorld(px, py) {
+ return px >= EX && px <= EX + WORLD_W * SCALE && py >= EY && py <= EY + WORLD_H * SCALE;
+ }
+
+ // ── Draft -> live state ───────────────────────────────────────────────────
+ //
+ // Everything the editor needs to know about legality it asks the REAL rules
+ // for, by building the level exactly as the game would. That is why the
+ // attachment preview here can never disagree with what happens in play.
+
+ draftLevel() {
+ return {
+ world: { w: WORLD_W, h: WORLD_H },
+ level: this.meta.level,
+ name: this.meta.name,
+ chapter: this.meta.chapter,
+ tip: this.meta.tip || undefined,
+ terrain: this.terrain.map((t) => ({
+ kind: t.kind,
+ poly: t.poly.map((p) => [p[0], p[1]]),
+ ...(t.destructible ? { destructible: true } : {}),
+ })),
+ ...(this.fans.length ? { fans: this.fans.map((f) => ({ ...f })) } : {}),
+ ...(this.gears.length ? { gears: this.gears.map((g) => ({ ...g })) } : {}),
+ balls: this.balls.map((b) => ({ x: b.x, y: b.y, type: b.type, pinned: !!b.pinned })),
+ strands: this.strands.map((s) => [s[0], s[1]]),
+ pile: this.pileItems.map((p) => ({ x: p.x, y: p.y, type: p.type })),
+ pipe: { x: this.pipe.x, y: this.pipe.y, r: this.pipe.r },
+ required: this.meta.required,
+ ocdTarget: this.meta.ocdTarget,
+ };
+ }
+
+ // A fresh, mutable state. Use this for anything that simulates.
+ freshState() { return createState(this.draftLevel()); }
+
+ // Cached read-only state for hover previews. drawHoverPreview() runs on every
+ // pointermove, and building a whole sim state per mouse event allocates the
+ // entire level 100+ times a second. Invalidated by touch() on every edit.
+ draftState() {
+ if (this._dsVersion !== this.version) {
+ this._ds = createState(this.draftLevel());
+ this._dsVersion = this.version;
+ }
+ return this._ds;
+ }
+
+ // Call after ANY mutation of terrain/balls/strands/pile/pipe.
+ touch() {
+ this.version = (this.version || 0) + 1;
+ this.refreshStats();
+ }
+
+ // ── Toolbar ───────────────────────────────────────────────────────────────
+
+ buildToolbar() {
+ this.toolBtns = {};
+ let x = EX + 70;
+ for (const t of TOOLS) {
+ const b = new Button(this, x, 44, t[0].toUpperCase() + t.slice(1), () => this.setTool(t),
+ { width: 130, height: 44, fontSize: 18, variant: t === this.tool ? 'solid' : 'ghost' });
+ b.setDepth(D.ui);
+ this.toolBtns[t] = b;
+ x += 142;
+ }
+
+ const py = GAME_HEIGHT - 46;
+ let bx = EX + 90;
+ const mk = (label, fn, opts = {}) => {
+ new Button(this, bx, py, label, fn, { width: 168, height: 46, fontSize: 17, ...opts }).setDepth(D.ui);
+ bx += 180;
+ };
+ mk('Settle', () => this.settleDraft());
+ mk('Test Winnable', () => this.testWinnable(), { variant: 'ghost' });
+ mk('Test Play', () => this.testPlay(), { variant: 'ghost' });
+ mk('⬇ Level', () => this.exportLevel());
+ mk('⬇ Manifest', () => this.exportManifest(), { variant: 'ghost' });
+ mk('Undo', () => this.undo(), { variant: 'ghost' });
+ mk('Exit', () => { window.location.search = ''; }, { variant: 'ghost' });
+ }
+
+ setTool(t) {
+ this.tool = t;
+ this.pendingPoly = null;
+ this.pendingFan = null;
+ for (const [k, b] of Object.entries(this.toolBtns)) {
+ b.options.variant = k === t ? 'solid' : 'ghost';
+ b._drawBg(b.options.bg, k === t ? 1 : 0.35);
+ }
+ this.redraw();
+ }
+
+ // ── Settings form ─────────────────────────────────────────────────────────
+
+ buildForm() {
+ if (this.formDom) this.formDom.destroy();
+ const rw = GAME_WIDTH - PANEL_X - 24;
+ const row = 'display:flex; align-items:center; justify-content:space-between; margin:7px 0;';
+ const input = 'background:#0d2036; color:#e8eef7; border:1px solid #2f5b8f; border-radius:6px; padding:6px 8px; font-size:15px;';
+ const gooOpts = Object.keys(GOO_TYPES)
+ .map((t) => ``).join('');
+ const kindOpts = ['solid', 'spike', 'fire']
+ .map((k) => ``).join('');
+ const loadOpts = ['',
+ ...this.manifest.map((m) => ``)].join('');
+
+ const el = document.createElement('div');
+ el.style.cssText = `width:${rw - 24}px; font-family:"Julius Sans One",sans-serif; color:#e8eef7;`;
+ el.innerHTML = `
+
+
LEVEL SETTINGS
+
+
+
+
+
+
+
+
+
+
Brush
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ this.formDom = this.add.dom(PANEL_X + rw / 2, EY + 330, el).setDepth(D.ui);
+
+ const q = (id) => el.querySelector(id);
+ const bindNum = (id, key, min = 0) => q(id).addEventListener('input', (ev) => {
+ this.meta[key] = Math.max(min, parseInt(ev.target.value, 10) || min);
+ this.refreshStats();
+ });
+ bindNum('#ge-level', 'level', 1);
+ bindNum('#ge-chapter', 'chapter', 1);
+ bindNum('#ge-required', 'required', 1);
+ bindNum('#ge-ocd', 'ocdTarget', 1);
+ q('#ge-name').addEventListener('input', (ev) => { this.meta.name = ev.target.value; });
+ q('#ge-file').addEventListener('input', (ev) => { this.meta.file = ev.target.value; });
+ q('#ge-tip').addEventListener('input', (ev) => { this.meta.tip = ev.target.value; });
+ q('#ge-kind').addEventListener('change', (ev) => { this.kind = ev.target.value; });
+ q('#ge-goo').addEventListener('change', (ev) => { this.gooType = ev.target.value; });
+ q('#ge-pinned').addEventListener('change', (ev) => { this.pinned = ev.target.checked; });
+ q('#ge-destruct').addEventListener('change', (ev) => { this.destructible = ev.target.checked; });
+ q('#ge-fandir').addEventListener('change', (ev) => { this.fanDir = ev.target.value; });
+ q('#ge-omega').addEventListener('input', (ev) => { this.gearOmega = parseFloat(ev.target.value) || 0; });
+
+ q('#ge-load').addEventListener('change', (ev) => {
+ const file = ev.target.value;
+ ev.target.value = '';
+ if (file) this.loadFromManifest(file);
+ });
+ q('#ge-fileinput').addEventListener('change', (ev) => {
+ const f = ev.target.files?.[0];
+ ev.target.value = '';
+ if (!f) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ try { this.loadLevel(JSON.parse(reader.result), f.name); }
+ catch (_) { this.warn(`Could not parse ${f.name}`); }
+ };
+ reader.readAsText(f);
+ });
+
+ this.warnEl = q('#ge-warn');
+ this.statsEl = q('#ge-stats');
+ this.refreshStats();
+ }
+
+ warn(msg) { if (this.warnEl) this.warnEl.textContent = msg; }
+
+ refreshStats() {
+ if (!this.statsEl) return;
+ this.statsEl.textContent =
+ `terrain ${this.terrain.length} · structure ${this.balls.length} (${this.strands.length} strands)\n`
+ + `pile ${this.pileItems.length} · needs ${this.meta.required}, OCD ${this.meta.ocdTarget}`;
+ }
+
+ // ── Input ─────────────────────────────────────────────────────────────────
+
+ bindInput() {
+ this.input.on('pointerdown', (p) => {
+ if (!this.inWorld(p.x, p.y)) return;
+ const w = this.toWorld(p.x, p.y);
+ const dbl = (this.time.now - this.lastClick.t < 320)
+ && Math.hypot(p.x - this.lastClick.x, p.y - this.lastClick.y) < 12;
+ this.lastClick = { t: this.time.now, x: p.x, y: p.y };
+
+ if (p.rightButtonDown()) { this.eraseAt(w.x, w.y); return; }
+ if (dbl && this.tool === 'terrain') { this.closePoly(); return; }
+ this.applyTool(w.x, w.y);
+ });
+ this.input.on('pointermove', (p) => {
+ this.hover = this.inWorld(p.x, p.y) ? this.toWorld(p.x, p.y) : null;
+ this.redraw();
+ });
+ this.input.keyboard?.on('keydown-ENTER', () => { if (this.tool === 'terrain') this.closePoly(); });
+ this.input.keyboard?.on('keydown-ESC', () => {
+ this.pendingPoly = null;
+ this.pendingFan = null;
+ this.redraw();
+ });
+ this.input.keyboard?.on('keydown-Z', (e) => { if (e.ctrlKey || e.metaKey) this.undo(); });
+ }
+
+ // Every mutation is preceded by pushUndo(), so bumping the cache version
+ // here guarantees no edit can leave a stale preview state behind.
+ pushUndo() {
+ this.version = (this.version || 0) + 1;
+ this.undoStack.push(JSON.stringify({
+ terrain: this.terrain, balls: this.balls, strands: this.strands,
+ pileItems: this.pileItems, pipe: this.pipe, fans: this.fans, gears: this.gears,
+ }));
+ if (this.undoStack.length > 60) this.undoStack.shift();
+ }
+
+ undo() {
+ const s = this.undoStack.pop();
+ if (!s) return;
+ const o = JSON.parse(s);
+ Object.assign(this, o);
+ this.pendingPoly = null;
+ this.touch();
+ this.redraw();
+ }
+
+ applyTool(x, y) {
+ if (this.tool === 'terrain') {
+ if (!this.pendingPoly) { this.pushUndo(); this.pendingPoly = { kind: this.kind, poly: [], destructible: this.destructible }; }
+ this.pendingPoly.poly.push([Math.round(x), Math.round(y)]);
+ } else if (this.tool === 'pipe') {
+ this.pushUndo();
+ this.pipe = { x: Math.round(x), y: Math.round(y), r: TUNING.PIPE_R };
+ } else if (this.tool === 'ball') {
+ this.placeStructureBall(x, y);
+ } else if (this.tool === 'pile') {
+ this.pushUndo();
+ this.pileItems.push({ x: Math.round(x), y: Math.round(y), type: this.gooType });
+ } else if (this.tool === 'fan') {
+ if (!this.pendingFan) {
+ this.pendingFan = { x: Math.round(x), y: Math.round(y) };
+ } else {
+ const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
+ const [dx, dy] = DIRS[this.fanDir] || DIRS.up;
+ const a = this.pendingFan;
+ this.pushUndo();
+ this.fans.push({
+ x: Math.round(Math.min(a.x, x)), y: Math.round(Math.min(a.y, y)),
+ w: Math.round(Math.abs(x - a.x)), h: Math.round(Math.abs(y - a.y)),
+ dx, dy, force: 1500,
+ });
+ this.pendingFan = null;
+ }
+ } else if (this.tool === 'gear') {
+ this.pushUndo();
+ this.gears.push({
+ x: Math.round(x), y: Math.round(y), r: 100, teeth: 9, omega: this.gearOmega,
+ });
+ } else if (this.tool === 'erase') {
+ this.eraseAt(x, y);
+ }
+ this.redraw();
+ this.refreshStats();
+ }
+
+ // Structure goo auto-connects using the GAME's own attachment rule, so what
+ // the editor shows is what the level will actually start with.
+ placeStructureBall(x, y) {
+ const st = this.draftState();
+ if (insideSolid(st, x, y)) { this.warn('Cannot place goo inside solid terrain.'); return; }
+ const targets = chooseAttachments(st, x, y, this.gooType);
+
+ this.pushUndo();
+ const idx = this.balls.length;
+ this.balls.push({ x: Math.round(x), y: Math.round(y), type: this.gooType, pinned: this.pinned });
+ // chooseAttachments returns ball ids, which for a freshly built draft state
+ // are exactly the indices of this.balls (createState adds structure balls
+ // first, in order, before the pile).
+ for (const id of targets) {
+ if (id >= 0 && id < idx) this.strands.push([idx, id]);
+ }
+ this.warn(targets.length ? '' : 'Placed with no strands — it will start unattached.');
+ }
+
+ closePoly() {
+ if (!this.pendingPoly) return;
+ if (this.pendingPoly.poly.length < 3) { this.warn('A terrain polygon needs at least 3 points.'); return; }
+ this.terrain.push(this.pendingPoly);
+ this.pendingPoly = null;
+ this.warn('');
+ this.touch();
+ this.redraw();
+ }
+
+ eraseAt(x, y) {
+ // Nearest-first: pile goo, then structure goo, then a terrain polygon
+ // containing the point.
+ const near = (arr, r) => {
+ let best = -1;
+ let bd = r;
+ arr.forEach((o, i) => {
+ const d = Math.hypot(o.x - x, o.y - y);
+ if (d < bd) { bd = d; best = i; }
+ });
+ return best;
+ };
+
+ let i = near(this.pileItems, 24);
+ if (i >= 0) { this.pushUndo(); this.pileItems.splice(i, 1); this.redraw(); this.refreshStats(); return; }
+
+ i = near(this.balls, 24);
+ if (i >= 0) {
+ this.pushUndo();
+ this.balls.splice(i, 1);
+ // Drop strands touching it and renumber the ones above.
+ this.strands = this.strands
+ .filter((s) => s[0] !== i && s[1] !== i)
+ .map((s) => [s[0] > i ? s[0] - 1 : s[0], s[1] > i ? s[1] - 1 : s[1]]);
+ this.redraw();
+ this.refreshStats();
+ return;
+ }
+
+ for (let k = this.gears.length - 1; k >= 0; k -= 1) {
+ const g = this.gears[k];
+ if (Math.hypot(g.x - x, g.y - y) < g.r) {
+ this.pushUndo(); this.gears.splice(k, 1); this.touch(); this.redraw(); return;
+ }
+ }
+ for (let k = this.fans.length - 1; k >= 0; k -= 1) {
+ const f = this.fans[k];
+ if (x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h) {
+ this.pushUndo(); this.fans.splice(k, 1); this.touch(); this.redraw(); return;
+ }
+ }
+ for (let k = this.terrain.length - 1; k >= 0; k -= 1) {
+ if (pointInPoly(x, y, this.terrain[k].poly)) {
+ this.pushUndo();
+ this.terrain.splice(k, 1);
+ this.redraw();
+ this.refreshStats();
+ return;
+ }
+ }
+ }
+
+ // ── Settling ──────────────────────────────────────────────────────────────
+ //
+ // Levels must be exported at rest. An unsettled level spends its first
+ // second absorbing its own startup transient -- which peaks near 2x static
+ // load and can snap strands before the player has touched anything.
+
+ settleDraft() {
+ const st = this.freshState();
+ settlePhysics(st, 14);
+ this.pushUndo();
+ this.balls.forEach((b, i) => {
+ const sb = st.balls[i];
+ if (!sb) return;
+ b.x = Math.round(sb.x);
+ b.y = Math.round(sb.y);
+ });
+ const offset = this.balls.length;
+ this.pileItems.forEach((p, i) => {
+ const sb = st.balls[offset + i];
+ if (!sb) return;
+ p.x = Math.round(sb.x);
+ p.y = Math.round(sb.y);
+ });
+ const lost = st.balls.filter((b) => b.dead).length;
+ const broke = st.strands.filter((s) => s.broken).length;
+ this.touch();
+ this.warn(lost || broke
+ ? `Settled, but ${lost} goo died and ${broke} strands snapped — fix before exporting.`
+ : 'Settled. Positions are now at rest.');
+ this.redraw();
+ }
+
+ testWinnable() {
+ const v = this.validate();
+ if (!v.valid) { this.warn(`Cannot test:\n${v.problems.join('\n')}`); return; }
+ const r = autoPlay(this.draftLevel());
+ this.warn(r.won
+ ? `Winnable: greedy placed ${r.placed} goo, collected ${r.collected}/${this.meta.required}.`
+ : `Greedy could NOT win (placed ${r.placed}, pipe ${r.pipeOpen ? 'open' : 'shut'}, `
+ + `collected ${r.collected}/${this.meta.required}).\nThat is not proof it is impossible — but check it.`);
+ }
+
+ testPlay() {
+ const v = this.validate();
+ if (!v.valid) { this.warn(`Cannot test:\n${v.problems.join('\n')}`); return; }
+ this.registry.set('gootower-editor-state', {
+ meta: this.meta, terrain: this.terrain, balls: this.balls,
+ strands: this.strands, pileItems: this.pileItems, pipe: this.pipe,
+ fans: this.fans, gears: this.gears,
+ tool: this.tool, kind: this.kind, gooType: this.gooType, pinned: this.pinned,
+ });
+ this.scene.start('GooTowerGame', { testLevel: this.draftLevel(), returnToEditor: true });
+ }
+
+ // ── Validation ────────────────────────────────────────────────────────────
+
+ validate() {
+ const problems = [];
+ if (!this.meta.name.trim()) problems.push('• Name is empty.');
+ if (this.meta.level < 1) problems.push('• Level number must be at least 1.');
+ if (!this.pipe) problems.push('• No pipe placed.');
+ if (this.meta.ocdTarget < this.meta.required) problems.push('• OCD target is below the requirement.');
+ if (this.pileItems.length < this.meta.ocdTarget) {
+ problems.push(`• Only ${this.pileItems.length} loose goo for an OCD target of ${this.meta.ocdTarget}.`);
+ }
+ if (!this.balls.length) problems.push('• No starting structure.');
+
+ for (const b of [...this.balls, ...this.pileItems]) {
+ if (insideSolid(this.draftState(), b.x, b.y)) {
+ problems.push('• Some goo starts inside solid terrain.');
+ break;
+ }
+ }
+
+ // Rootedness needs a SIMULATED state: `grounded` is set by terrain contact
+ // during a substep, so on a never-stepped state it is false everywhere and
+ // a floating structure would sail through this check.
+ if (this.balls.length) {
+ const sim = this.freshState();
+ settlePhysics(sim, 6);
+ const anyRooted = sim.balls.some((b, i) => i < this.balls.length && !b.dead && isRooted(sim, b.id));
+ if (!anyRooted) problems.push('• The starting structure is not anchored or resting on anything.');
+ const broke = sim.strands.filter((s) => s.broken).length;
+ if (broke) problems.push(`• ${broke} strand(s) snap on their own — press Settle and fix it.`);
+ const lost = sim.balls.filter((b) => b.dead).length;
+ if (lost) problems.push(`• ${lost} goo die(s) before the player touches anything.`);
+ }
+ for (const t of this.terrain) {
+ if (t.poly.length < 3) { problems.push('• A terrain polygon has fewer than 3 points.'); break; }
+ }
+
+ // Overlapping goo is ejected violently by the collision solver and can snap
+ // its own strands before the player has touched anything.
+ const all = [...this.balls, ...this.pileItems];
+ outer:
+ for (let i = 0; i < all.length; i += 1) {
+ for (let j = i + 1; j < all.length; j += 1) {
+ const ra = gooType(all[i].type).r;
+ const rb = gooType(all[j].type).r;
+ if (Math.hypot(all[j].x - all[i].x, all[j].y - all[i].y) < (ra + rb) * 0.6) {
+ problems.push('• Some goo starts overlapping — it will be flung apart on the first frame.');
+ break outer;
+ }
+ }
+ }
+ return { valid: problems.length === 0, problems };
+ }
+
+ // ── Export / load ─────────────────────────────────────────────────────────
+
+ download(name, obj) {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 2)}\n`], { type: 'application/json' }));
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 5000);
+ }
+
+ manifestEntry() {
+ return {
+ level: this.meta.level, name: this.meta.name, chapter: this.meta.chapter,
+ file: this.meta.file, required: this.meta.required, ocdTarget: this.meta.ocdTarget,
+ };
+ }
+
+ exportLevel() {
+ const v = this.validate();
+ if (!v.valid) { this.warn(`Export blocked:\n${v.problems.join('\n')}`); return; }
+ this.download(this.meta.file, this.draftLevel());
+ this.warn(`Saved ${this.meta.file} — drop it into assets/gamedata/gootower/`);
+ }
+
+ exportManifest() {
+ const levels = this.manifest.filter((m) => m.level !== this.meta.level).map((m) => ({ ...m }));
+ levels.push(this.manifestEntry());
+ levels.sort((a, b) => a.level - b.level);
+ const chapters = [{ id: 1, name: 'The Goo Filled Hills', blurb: 'Learn to build.', from: 1, to: levels.length }];
+ this.download('levels.json', { version: 1, levels, chapters });
+ this.warn('Saved levels.json — drop it into assets/gamedata/gootower/');
+ }
+
+ async loadFromManifest(file) {
+ try {
+ const res = await fetch(`assets/gamedata/gootower/${file}`);
+ this.loadLevel(await res.json(), file);
+ } catch (_) { this.warn(`Could not load ${file}`); }
+ }
+
+ loadLevel(def, file) {
+ this.pushUndo();
+ this.meta = {
+ level: def.level ?? 1,
+ name: def.name ?? 'Untitled',
+ chapter: def.chapter ?? 1,
+ required: def.required ?? 4,
+ ocdTarget: def.ocdTarget ?? def.required ?? 4,
+ tip: def.tip ?? '',
+ file: file || `level-${String(def.level ?? 1).padStart(3, '0')}.json`,
+ };
+ this.terrain = (def.terrain ?? []).map((t) => ({
+ kind: t.kind || 'solid',
+ poly: t.poly.map((p) => [p[0], p[1]]),
+ destructible: !!t.destructible,
+ }));
+ this.fans = (def.fans ?? []).map((f) => ({ ...f }));
+ this.gears = (def.gears ?? []).map((g) => ({ ...g }));
+ this.balls = (def.balls ?? []).map((b) => ({ x: b.x, y: b.y, type: b.type || 'common', pinned: !!b.pinned }));
+ this.strands = (def.strands ?? []).map((s) => [s[0], s[1]]);
+ this.pileItems = (def.pile ?? []).map((p) => ({ x: p.x, y: p.y, type: p.type || 'common' }));
+ this.pipe = def.pipe ? { ...def.pipe, r: def.pipe.r || TUNING.PIPE_R } : this.pipe;
+ this.pendingPoly = null;
+ this.touch();
+ this.buildForm();
+ this.redraw();
+ this.warn(`Loaded ${this.meta.name}`);
+ }
+
+ // ── Drawing ───────────────────────────────────────────────────────────────
+
+ redraw() {
+ const g = this.mainG;
+ g.clear();
+
+ // World frame + a light grid on STRAND_REST, so spacing reads at a glance.
+ this.worldG.clear();
+ this.worldG.fillStyle(0x151d28, 1);
+ this.worldG.fillRect(EX, EY, WORLD_W * SCALE, WORLD_H * SCALE);
+ this.worldG.lineStyle(2, 0x2f5b8f, 1);
+ this.worldG.strokeRect(EX, EY, WORLD_W * SCALE, WORLD_H * SCALE);
+
+ this.gridG.clear();
+ this.gridG.lineStyle(1, 0xffffff, 0.05);
+ for (let x = 0; x <= WORLD_W; x += TUNING.STRAND_REST) {
+ this.gridG.lineBetween(this.sx(x), EY, this.sx(x), EY + WORLD_H * SCALE);
+ }
+ for (let y = 0; y <= WORLD_H; y += TUNING.STRAND_REST) {
+ this.gridG.lineBetween(EX, this.sy(y), EX + WORLD_W * SCALE, this.sy(y));
+ }
+
+ for (const t of this.terrain) this.drawPoly(g, t, 1);
+ if (this.pendingPoly) this.drawPoly(g, this.pendingPoly, 0.45, true);
+
+ // Pipe.
+ const px = this.sx(this.pipe.x);
+ const py = this.sy(this.pipe.y);
+ g.fillStyle(0x3d5a6c, 1);
+ g.fillRoundedRect(px - this.pipe.r * 0.72 * SCALE, py - this.pipe.r * 1.5 * SCALE,
+ this.pipe.r * 1.44 * SCALE, this.pipe.r * 1.5 * SCALE, 6);
+ g.lineStyle(2, 0x8fd0e8, 0.9);
+ g.strokeCircle(px, py, this.pipe.r * SCALE);
+
+ // Structure strands, then goo.
+ g.lineStyle(4 * SCALE, 0x1c1626, 1);
+ for (const [i, j] of this.strands) {
+ const a = this.balls[i];
+ const b = this.balls[j];
+ if (!a || !b) continue;
+ g.lineBetween(this.sx(a.x), this.sy(a.y), this.sx(b.x), this.sy(b.y));
+ }
+ for (const f of this.fans) {
+ g.fillStyle(0x8fd0e8, 0.10);
+ g.fillRect(this.sx(f.x), this.sy(f.y), f.w * SCALE, f.h * SCALE);
+ g.lineStyle(2, 0x8fd0e8, 0.5);
+ g.strokeRect(this.sx(f.x), this.sy(f.y), f.w * SCALE, f.h * SCALE);
+ const cx = this.sx(f.x + f.w / 2);
+ const cy = this.sy(f.y + f.h / 2);
+ g.lineBetween(cx - f.dx * 20, cy - f.dy * 20, cx + f.dx * 20, cy + f.dy * 20);
+ }
+ for (const gr of this.gears) {
+ g.fillStyle(0x4a4260, 1);
+ g.fillCircle(this.sx(gr.x), this.sy(gr.y), gr.r * SCALE);
+ g.lineStyle(2, 0x8a7fb0, 1);
+ g.strokeCircle(this.sx(gr.x), this.sy(gr.y), gr.r * SCALE);
+ g.lineBetween(this.sx(gr.x), this.sy(gr.y),
+ this.sx(gr.x) + gr.r * SCALE * (gr.omega >= 0 ? 0.7 : -0.7), this.sy(gr.y));
+ }
+ for (const b of this.balls) this.drawGoo(g, b, true);
+ for (const p of this.pileItems) this.drawGoo(g, p, false);
+
+ this.drawHoverPreview();
+ }
+
+ drawPoly(g, t, alpha, pending = false) {
+ /* eslint-disable no-param-reassign */
+ const pts = t.poly.map(([x, y]) => ({ x: this.sx(x), y: this.sy(y) }));
+ const color = KIND_COLOR[t.kind] ?? KIND_COLOR.solid;
+ if (t.destructible) alpha *= 0.75;
+ if (pts.length >= 3) {
+ g.fillStyle(color, alpha);
+ g.fillPoints(pts, true);
+ }
+ g.lineStyle(2, t.kind === 'spike' ? 0xd46a8c : 0x4a4260, 1);
+ if (pts.length >= 2) g.strokePoints(pts, !pending);
+ if (pending) {
+ g.fillStyle(0xf0c75e, 1);
+ for (const p of pts) g.fillCircle(p.x, p.y, 4);
+ }
+ }
+
+ drawGoo(g, b, structural) {
+ const r = gooType(b.type).r * SCALE;
+ const c = GOO_COLOR[b.type] ?? GOO_COLOR.common;
+ g.fillStyle(c, 1);
+ g.fillCircle(this.sx(b.x), this.sy(b.y), r);
+ if (structural && b.pinned) {
+ g.lineStyle(2, 0xf0c75e, 1);
+ g.strokeCircle(this.sx(b.x), this.sy(b.y), r + 3);
+ }
+ if (!structural) {
+ g.lineStyle(1, 0xffffff, 0.35);
+ g.strokeCircle(this.sx(b.x), this.sy(b.y), r + 1);
+ }
+ }
+
+ // Live attachment preview for the ball tool — the same call the game makes,
+ // so the MIN_ANGLE rule is visible while authoring instead of a surprise.
+ drawHoverPreview() {
+ const m = this.markerG;
+ m.clear();
+ if (!this.hover) return;
+ const { x, y } = this.hover;
+
+ if (this.tool === 'ball') {
+ const st = this.draftState();
+ const targets = chooseAttachments(st, x, y, this.gooType);
+ const need = gooType(this.gooType).minStrands;
+ const ok = targets.length >= need && !insideSolid(st, x, y);
+ const color = ok ? 0x7fe38a : 0xff6b6b;
+ m.lineStyle(2, color, 0.85);
+ for (const id of targets) {
+ const t = this.balls[id];
+ if (!t) continue;
+ m.lineBetween(this.sx(x), this.sy(y), this.sx(t.x), this.sy(t.y));
+ }
+ m.strokeCircle(this.sx(x), this.sy(y), gooType(this.gooType).r * SCALE + 4);
+ m.lineStyle(1, color, 0.25);
+ m.strokeCircle(this.sx(x), this.sy(y), TUNING.ATTACH_R * SCALE);
+ } else if (this.tool === 'terrain' && this.pendingPoly?.poly.length) {
+ const last = this.pendingPoly.poly[this.pendingPoly.poly.length - 1];
+ m.lineStyle(2, 0xf0c75e, 0.6);
+ m.lineBetween(this.sx(last[0]), this.sy(last[1]), this.sx(x), this.sy(y));
+ } else if (this.tool === 'pile') {
+ m.lineStyle(2, 0x9ecbff, 0.7);
+ m.strokeCircle(this.sx(x), this.sy(y), gooType(this.gooType).r * SCALE + 3);
+ } else if (this.tool === 'fan' && this.pendingFan) {
+ const a = this.pendingFan;
+ m.lineStyle(2, 0x8fd0e8, 0.8);
+ m.strokeRect(this.sx(Math.min(a.x, x)), this.sy(Math.min(a.y, y)),
+ Math.abs(x - a.x) * SCALE, Math.abs(y - a.y) * SCALE);
+ } else if (this.tool === 'gear') {
+ m.lineStyle(2, 0x8a7fb0, 0.7);
+ m.strokeCircle(this.sx(x), this.sy(y), 100 * SCALE);
+ }
+ }
+}
diff --git a/src/games/gootower/GooTowerGame.js b/src/games/gootower/GooTowerGame.js
new file mode 100644
index 0000000..20b5567
--- /dev/null
+++ b/src/games/gootower/GooTowerGame.js
@@ -0,0 +1,729 @@
+import * as Phaser from 'phaser';
+import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
+import { Button } from '../../ui/Button.js';
+import { MusicPlayer } from '../../ui/MusicPlayer.js';
+import { playSound, SFX } from '../../ui/Sounds.js';
+import { Tooltip } from '../../ui/Tooltip.js';
+import { api } from '../../services/api.js';
+import {
+ TUNING, gooType, createState, stepSim,
+ pickBallAt, beginDrag, dragTo, endDrag, chooseAttachments, canPlace,
+ strandStress, isWon, hasOCD, hazardAt, requiredStrands,
+} from './GooTowerLogic.js';
+
+const BG = 0x121a24;
+const SKY_TOP = 0x1b2a3a;
+const SKY_BOT = 0x0d1219;
+const DIRT = 0x2f2a3d;
+const DIRT_RIM = 0x4a4260;
+const SPIKE = 0x7d3550;
+const SPIKE_RIM = 0xd46a8c;
+const FIRE = 0xd4642a;
+const STRAND = 0x1c1626;
+const STRAND_HOT = 0xff5a4a;
+const PIPE_BODY = 0x3d5a6c;
+const PIPE_RIM = 0x8fd0e8;
+const GHOST_OK = 0x7fe38a;
+const GHOST_NO = 0xff6b6b;
+
+// One colour per goo type. Everything is drawn procedurally; there is no art
+// dependency (see docs/gootower-build-plan.md).
+const GOO_COLOR = {
+ common: 0x4a4258,
+ ivy: 0x63c132,
+ balloon: 0xffd7e6,
+ bomb: 0xc8392b,
+ block: 0x8a6a4a,
+ pokey: 0xb07d18,
+ bit: 0x9ecbff,
+ skull: 0xe8e2ff,
+ anchor: 0x5a4a3a,
+};
+
+// Phaser Containers render children in insertion order and IGNORE child depth,
+// so every layer here is a root-level object with its own setDepth.
+const D = {
+ sky: -10, terrain: 0, pipe: 4, strand: 8, ball: 10, ghost: 14,
+ hud: 30, overlay: 60, overlayUI: 62,
+};
+
+// World-space -> screen-space. The world is 1600x1000 on a 1920x1080 canvas,
+// so it fits outright and there is no camera to drive.
+const OX = 160;
+const OY = 70;
+
+const shade = (color, f) => {
+ const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * f));
+ return (ch(16) << 16) | (ch(8) << 8) | ch(0);
+};
+const lerpColor = (a, b, t) => {
+ const ch = (s) => {
+ const va = (a >> s) & 0xff;
+ const vb = (b >> s) & 0xff;
+ return Math.round(va + (vb - va) * t) & 0xff;
+ };
+ return (ch(16) << 16) | (ch(8) << 8) | ch(0);
+};
+
+export default class GooTowerGame extends Phaser.Scene {
+ constructor() { super('GooTowerGame'); }
+
+ init(data) {
+ this.gameDef = data?.game ?? { slug: 'gootower', name: 'Goo Tower' };
+ this.testLevel = data?.testLevel ?? null;
+ this.returnToEditor = !!data?.returnToEditor;
+
+ this.manifest = [];
+ this.chapters = [];
+ this.levelsCompleted = 0;
+ this.canPersist = true;
+ this.levelCache = new Map();
+
+ this.view = 'select';
+ this.level = 0;
+ this.levelDef = null;
+ this.state = null;
+ this.accum = 0;
+ this.held = null;
+ this.boardObjs = [];
+ this.overlayUp = false;
+ this.finished = false;
+ }
+
+ async create() {
+ try {
+ const music = this.cache.json.get('music');
+ if (music?.tracks) new MusicPlayer(this, music.tracks);
+ } catch (_) { /* optional */ }
+
+ this.drawSky();
+
+ const raw = this.cache.json.get('gootower-levels');
+ this.manifest = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
+ this.chapters = raw?.chapters?.length
+ ? raw.chapters
+ : (this.manifest.length ? [{ id: 1, name: 'Levels', blurb: '', from: 1, to: this.manifest.length }] : []);
+
+ try {
+ const res = await api.get('/puzzles/gootower/progress');
+ this.levelsCompleted = res?.levelsCompleted ?? 0;
+ } catch (_) {
+ this.canPersist = false;
+ this.levelsCompleted = 0;
+ }
+
+ this.bindInput();
+
+ if (this.testLevel) this.startLevel(this.testLevel.level ?? 1, this.testLevel);
+ else this.showSelect();
+ }
+
+ drawSky() {
+ const g = this.add.graphics().setDepth(D.sky);
+ g.fillGradientStyle(SKY_TOP, SKY_TOP, SKY_BOT, SKY_BOT, 1);
+ g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
+ }
+
+ clearBoard() {
+ for (const o of this.boardObjs) o.destroy();
+ this.boardObjs = [];
+ if (this.tooltip) { this.tooltip.destroy?.(); this.tooltip = null; }
+ // These were tracked, so they are already destroyed -- but the fields still
+ // point at dead objects, and drawGhost()/drawDynamic() would happily call
+ // methods on them on the next level.
+ this.dyn = null;
+ this.ghost = null;
+ this.terrainG = null;
+ this.needText = null;
+ this.hudText = null;
+ }
+
+ track(obj) { this.boardObjs.push(obj); return obj; }
+
+ // ── Level select ──────────────────────────────────────────────────────────
+
+ // Leaving a level goes back wherever the player came from: the level list
+ // normally, or the editor when this is an editor test-play.
+ exitLevel() {
+ if (this.returnToEditor) this.scene.start('GooTowerEditor', { resume: true });
+ else this.showSelect();
+ }
+
+ showSelect() {
+ this.view = 'select';
+ this.state = null;
+ this.held = null;
+ this.clearBoard();
+
+ const cx = GAME_WIDTH / 2;
+ this.track(this.add.text(cx, 70, 'Goo Tower', {
+ fontFamily: 'Righteous', fontSize: '58px', color: COLORS.textHex,
+ }).setOrigin(0.5).setDepth(D.hud));
+
+ this.tooltip = new Tooltip(this);
+
+ let y = 190;
+ for (const ch of this.chapters) {
+ this.track(this.add.text(140, y, ch.name, {
+ fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex,
+ }).setDepth(D.hud));
+ if (ch.blurb) {
+ this.track(this.add.text(140, y + 36, ch.blurb, {
+ fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#9aa7b4',
+ }).setDepth(D.hud));
+ }
+
+ const levels = this.manifest.filter((m) => m.level >= ch.from && m.level <= ch.to);
+ let x = 150;
+ for (const m of levels) {
+ const unlocked = m.level <= this.levelsCompleted + 1;
+ const cleared = m.level <= this.levelsCompleted;
+ this.makeLevelTile(x, y + 110, m, unlocked, cleared);
+ x += 175;
+ }
+ y += 260;
+ }
+
+ this.track(new Button(this, cx, GAME_HEIGHT - 70, 'Back', () => this.scene.start('GameMenu'),
+ { variant: 'ghost', width: 200 }).setDepth(D.hud));
+ if (this.levelsCompleted > 0) {
+ this.track(new Button(this, 210, GAME_HEIGHT - 70, 'Reset Progress',
+ () => this.resetProgress(), { variant: 'ghost', width: 260, fontSize: 20 }).setDepth(D.hud));
+ }
+ }
+
+ makeLevelTile(x, y, m, unlocked, cleared) {
+ const w = 150;
+ const h = 120;
+ const g = this.add.graphics().setDepth(D.hud);
+ const base = cleared ? 0x2b4a3a : unlocked ? 0x2a3547 : 0x1b2029;
+ g.fillStyle(base, 1);
+ g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 10);
+ g.lineStyle(2, cleared ? 0x63c132 : unlocked ? 0x5b7fa8 : 0x2a3038, 1);
+ g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 10);
+ this.track(g);
+
+ this.track(this.add.text(x, y - 30, String(m.level), {
+ fontFamily: 'Righteous', fontSize: '34px',
+ color: unlocked ? COLORS.textHex : '#4a5560',
+ }).setOrigin(0.5).setDepth(D.hud));
+ this.track(this.add.text(x, y + 12, unlocked ? m.name : 'Locked', {
+ fontFamily: '"Julius Sans One"', fontSize: '15px',
+ color: unlocked ? '#b9c6d2' : '#4a5560',
+ wordWrap: { width: w - 16 }, align: 'center',
+ }).setOrigin(0.5).setDepth(D.hud));
+
+ const best = this.bestFor(m.level);
+ if (best.count > 0) {
+ this.track(this.add.text(x, y + 44, best.ocd ? `★ ${best.count}` : `${best.count}`, {
+ fontFamily: '"Julius Sans One"', fontSize: '16px',
+ color: best.ocd ? '#ffd166' : '#7f8c99',
+ }).setOrigin(0.5).setDepth(D.hud));
+ }
+
+ if (!unlocked) return;
+ const hit = this.add.rectangle(x, y, w, h, 0xffffff, 0.001)
+ .setInteractive({ useHandCursor: true })
+ .setDepth(D.hud + 1);
+ hit.on('pointerup', () => this.startLevel(m.level));
+ if (this.tooltip) {
+ this.tooltip.attachTo(hit, () => ({
+ title: m.name,
+ lines: [`Collect ${m.required} to finish`, `OCD target: ${m.ocdTarget}`],
+ }));
+ }
+ this.track(hit);
+ }
+
+ bestFor(level) {
+ try {
+ return {
+ count: Number(localStorage.getItem(`gt-best-${level}`) || 0),
+ ocd: localStorage.getItem(`gt-ocd-${level}`) === '1',
+ };
+ } catch (_) { return { count: 0, ocd: false }; }
+ }
+
+ recordBest(level, count, ocd) {
+ try {
+ if (count > Number(localStorage.getItem(`gt-best-${level}`) || 0)) {
+ localStorage.setItem(`gt-best-${level}`, String(count));
+ }
+ if (ocd) localStorage.setItem(`gt-ocd-${level}`, '1');
+ } catch (_) { /* private browsing */ }
+ }
+
+ async resetProgress() {
+ try { await api.post('/puzzles/gootower/reset'); } catch (_) { /* offline */ }
+ for (const m of this.manifest) {
+ try {
+ localStorage.removeItem(`gt-best-${m.level}`);
+ localStorage.removeItem(`gt-ocd-${m.level}`);
+ } catch (_) { /* ignore */ }
+ }
+ this.levelsCompleted = 0;
+ this.showSelect();
+ }
+
+ // ── Loading a level ───────────────────────────────────────────────────────
+
+ async fetchLevel(level) {
+ if (this.levelCache.has(level)) return this.levelCache.get(level);
+ const entry = this.manifest.find((m) => m.level === level);
+ if (!entry) return null;
+ const res = await fetch(`assets/gamedata/gootower/${entry.file}`);
+ const json = await res.json();
+ this.levelCache.set(level, json);
+ return json;
+ }
+
+ async startLevel(level, preloaded = null) {
+ const def = preloaded || await this.fetchLevel(level);
+ if (!def) { this.showSelect(); return; }
+
+ this.clearBoard();
+ this.view = 'play';
+ this.level = level;
+ this.levelDef = def;
+ this.state = createState(def, 0x60071e + level);
+ this.accum = 0;
+ this.held = null;
+ this.overlayUp = false;
+ this.finished = false;
+
+ this.terrainG = this.track(this.add.graphics().setDepth(D.terrain));
+ this.drawTerrain();
+ this.dyn = this.track(this.add.graphics().setDepth(D.strand));
+ this.ghost = this.track(this.add.graphics().setDepth(D.ghost));
+ this.buildHud();
+ }
+
+ // Redrawn on demand, not just once: a blast can delete destructible terrain.
+ drawTerrain() {
+ const g = this.terrainG;
+ if (!g) return;
+ g.clear();
+ for (const t of this.state.terrain) {
+ if (t.gear) continue; // gears rotate; they are drawn per-frame instead
+ const pts = t.poly.map(([x, y]) => ({ x: x + OX, y: y + OY }));
+ if (t.kind === 'solid') {
+ g.fillStyle(DIRT, 1);
+ g.fillPoints(pts, true);
+ g.lineStyle(4, DIRT_RIM, 1);
+ g.strokePoints(pts, true);
+ } else if (t.kind === 'spike') {
+ g.fillStyle(SPIKE, 1);
+ g.fillPoints(pts, true);
+ this.drawTeeth(g, t);
+ } else if (t.kind === 'fire') {
+ g.fillStyle(FIRE, 0.75);
+ g.fillPoints(pts, true);
+ }
+ if (t.destructible) {
+ // Cross-hatch marks rock a blast can open.
+ g.lineStyle(1, 0xd9a066, 0.5);
+ const bb = t.bounds;
+ for (let x = bb.minX; x < bb.maxX + (bb.maxY - bb.minY); x += 16) {
+ g.lineBetween(x + OX, bb.minY + OY,
+ x - (bb.maxY - bb.minY) + OX, bb.maxY + OY);
+ }
+ }
+ }
+ this.drawFans(g);
+ }
+
+ drawFans(g) {
+ for (const f of this.state.fans) {
+ g.fillStyle(0x8fd0e8, 0.07);
+ g.fillRect(f.x + OX, f.y + OY, f.w, f.h);
+ g.lineStyle(1, 0x8fd0e8, 0.28);
+ g.strokeRect(f.x + OX, f.y + OY, f.w, f.h);
+ // Chevrons pointing the way the wind blows.
+ const step = 56;
+ for (let x = f.x + step / 2; x < f.x + f.w; x += step) {
+ for (let y = f.y + step / 2; y < f.y + f.h; y += step) {
+ const cx = x + OX;
+ const cy = y + OY;
+ const ax = f.dx * 12;
+ const ay = f.dy * 12;
+ g.lineStyle(2, 0x8fd0e8, 0.32);
+ g.lineBetween(cx - ax, cy - ay, cx + ax, cy + ay);
+ g.lineBetween(cx + ax, cy + ay, cx + ax - ay * 0.5 - ax * 0.5, cy + ay + ax * 0.5 - ay * 0.5);
+ g.lineBetween(cx + ax, cy + ay, cx + ax + ay * 0.5 - ax * 0.5, cy + ay - ax * 0.5 - ay * 0.5);
+ }
+ }
+ }
+ }
+
+ drawGears(g) {
+ for (const t of this.state.terrain) {
+ if (!t.gear) continue;
+ const pts = t.poly.map(([x, y]) => ({ x: x + OX, y: y + OY }));
+ g.fillStyle(0x4a4260, 1);
+ g.fillPoints(pts, true);
+ g.lineStyle(3, 0x8a7fb0, 1);
+ g.strokePoints(pts, true);
+ const cx = t.gear.cx + OX;
+ const cy = t.gear.cy + OY;
+ g.fillStyle(0x2a2438, 1);
+ g.fillCircle(cx, cy, t.gear.r * 0.3);
+ // A spoke, so the rotation is legible.
+ g.lineStyle(4, 0x8a7fb0, 0.9);
+ g.lineBetween(cx, cy,
+ cx + Math.cos(t.gear.angle) * t.gear.r * 0.62,
+ cy + Math.sin(t.gear.angle) * t.gear.r * 0.62);
+ }
+ }
+
+ // Spikes read as spikes because of the teeth, not the fill colour. Teeth are
+ // stamped along whichever edge faces away from the polygon's interior.
+ drawTeeth(g, t) {
+ const poly = t.poly;
+ const cy = poly.reduce((s, p) => s + p[1], 0) / poly.length;
+ g.fillStyle(SPIKE_RIM, 1);
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const [ax, ay] = poly[j];
+ const [bx, by] = poly[i];
+ const mid = (ay + by) / 2;
+ if (Math.abs(ay - by) > 4) continue; // only horizontal edges
+ const up = mid < cy ? -1 : 1; // point away from the middle
+ const len = Math.abs(bx - ax);
+ const n = Math.max(2, Math.round(len / 26));
+ const step = (bx - ax) / n;
+ for (let k = 0; k < n; k += 1) {
+ const x0 = ax + step * k;
+ const x1 = x0 + step;
+ g.fillTriangle(
+ x0 + OX, mid + OY,
+ x1 + OX, mid + OY,
+ (x0 + x1) / 2 + OX, mid + OY + up * 20,
+ );
+ }
+ }
+ }
+
+ buildHud() {
+ const st = this.state;
+ this.hudText = this.track(this.add.text(40, 26, '', {
+ fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
+ }).setDepth(D.hud));
+ this.tipText = this.track(this.add.text(GAME_WIDTH / 2, 30, this.levelDef.tip || '', {
+ fontFamily: '"Julius Sans One"', fontSize: '19px', color: '#9aa7b4',
+ wordWrap: { width: 760 }, align: 'center',
+ }).setOrigin(0.5, 0).setDepth(D.hud));
+
+ this.track(new Button(this, GAME_WIDTH - 130, 44,
+ this.returnToEditor ? 'Editor' : 'Levels', () => this.exitLevel(),
+ { variant: 'ghost', width: 160, height: 48, fontSize: 20 }).setDepth(D.hud));
+ this.track(new Button(this, GAME_WIDTH - 310, 44, 'Retry', () => this.startLevel(this.level),
+ { variant: 'ghost', width: 160, height: 48, fontSize: 20 }).setDepth(D.hud));
+ if (st) this.refreshHud();
+ }
+
+ refreshHud() {
+ const st = this.state;
+ if (!this.hudText || !st) return;
+ this.hudText.setText(
+ `Collected ${st.collected} / ${st.required} OCD ${st.ocdTarget} Goo left ${st.pile.length}`,
+ );
+ }
+
+ // ── Input ─────────────────────────────────────────────────────────────────
+
+ bindInput() {
+ this.input.on('pointerdown', (p) => this.onDown(p));
+ this.input.on('pointermove', (p) => this.onMove(p));
+ this.input.on('pointerup', (p) => this.onUp(p));
+ this.input.keyboard?.on('keydown-R', () => { if (this.view === 'play') this.startLevel(this.level); });
+ this.input.keyboard?.on('keydown-ESC', () => { if (this.view === 'play') this.exitLevel(); });
+ }
+
+ toWorld(p) { return { x: p.x - OX, y: p.y - OY }; }
+
+ onDown(p) {
+ if (this.view !== 'play' || this.overlayUp || !this.state) return;
+ const w = this.toWorld(p);
+ const ball = pickBallAt(this.state, w.x, w.y, 46);
+ if (!ball) return;
+ if (beginDrag(this.state, ball, null)) {
+ this.held = ball;
+ playSound(this, SFX.UI_PICK);
+ }
+ }
+
+ onMove(p) {
+ if (!this.held || !this.state) return;
+ const w = this.toWorld(p);
+ dragTo(this.state, this.held, w.x, w.y);
+ }
+
+ onUp(p) {
+ if (!this.held || !this.state) return;
+ const w = this.toWorld(p);
+ const ball = this.held;
+ this.held = null;
+ const events = [];
+ const stuck = endDrag(this.state, ball, w.x, w.y, events);
+ playSound(this, stuck ? SFX.UI_PLACE : SFX.SQUISH);
+ this.handleEvents(events);
+ }
+
+ // ── Frame ─────────────────────────────────────────────────────────────────
+
+ update(time, delta) {
+ if (this.view !== 'play' || !this.state) return;
+
+ // Fixed-timestep accumulator; the sim substeps internally at 240Hz.
+ const dt = Math.min(delta / 1000, 0.05);
+ const events = stepSim(this.state, dt);
+ this.handleEvents(events);
+
+ this.drawDynamic();
+ this.refreshHud();
+
+ if (!this.finished && isWon(this.state)) this.finishLevel();
+ }
+
+ handleEvents(events) {
+ for (const e of events) {
+ if (e.type === 'strandBreak') playSound(this, SFX.SQUASH);
+ else if (e.type === 'collect') playSound(this, SFX.UI_CHIME);
+ else if (e.type === 'ballDie') playSound(this, SFX.SQUISH);
+ else if (e.type === 'pipeOpen') playSound(this, SFX.GEM_DROP);
+ else if (e.type === 'stick') playSound(this, SFX.PIECE_CLICK);
+ else if (e.type === 'ignite') playSound(this, SFX.WOOSH);
+ else if (e.type === 'explode') {
+ playSound(this, SFX.SQUASH);
+ this.cameras.main.shake(180, 0.006);
+ }
+ // A blast can delete destructible terrain, so the static layer is stale.
+ else if (e.type === 'terrainDestroyed') this.drawTerrain();
+ }
+ }
+
+ drawDynamic() {
+ const st = this.state;
+ const g = this.dyn;
+ if (!g || !st) return;
+ g.clear();
+
+ this.drawGears(g);
+
+ // Strands first, under the balls.
+ for (const s of st.strands) {
+ if (s.broken) continue;
+ const a = st.balls[s.a];
+ const b = st.balls[s.b];
+ if (!a || !b || a.dead || b.dead) continue;
+ const stress = strandStress(s);
+ const color = lerpColor(STRAND, STRAND_HOT, stress);
+ const width = 7 - 3 * stress; // strands thin as they are pulled taut
+ g.lineStyle(width, color, 1);
+ g.lineBetween(a.x + OX, a.y + OY, b.x + OX, b.y + OY);
+ }
+
+ // Balls.
+ for (const b of st.balls) {
+ if (b.dead) continue;
+ this.drawBall(g, b);
+ }
+
+ this.drawPipe(g);
+ this.drawGhost();
+ }
+
+ drawBall(g, b) {
+ const base = GOO_COLOR[b.type] ?? GOO_COLOR.common;
+ const x = b.x + OX;
+ const y = b.y + OY;
+
+ // Squash along the direction of travel, so goo reads as soft.
+ const vx = b.x - b.px;
+ const vy = b.y - b.py;
+ const sp = Math.min(1, Math.hypot(vx, vy) / 6);
+
+ g.fillStyle(shade(base, 0.55), 1);
+ g.fillCircle(x, y + 2, b.r);
+ g.fillStyle(base, 1);
+ g.fillCircle(x, y, b.r);
+ g.fillStyle(shade(base, 1.45), 0.5);
+ g.fillCircle(x - b.r * 0.3, y - b.r * 0.35, b.r * 0.32);
+
+ if (b.held) {
+ g.lineStyle(3, 0xffffff, 0.9);
+ g.strokeCircle(x, y, b.r + 5);
+ }
+
+ // A lit fuse flashes faster as it burns down.
+ if (b.fuse != null && !b.exploded) {
+ const t = 1 - Math.max(0, b.fuse) / TUNING.FUSE_TIME;
+ const flash = 0.45 + 0.55 * Math.abs(Math.sin(this.state.clock * (8 + t * 26)));
+ g.fillStyle(0xffd166, flash);
+ g.fillCircle(x, y, b.r + 4 + t * 6);
+ g.lineStyle(2, 0xff5a4a, flash);
+ g.strokeCircle(x, y, b.r + 7 + t * 8);
+ }
+
+ // Pinned goo (anchors, wall-stuck pokey) gets a collar so the player can
+ // see what is immovable.
+ if (b.pinned && b.attached) {
+ g.lineStyle(3, 0xd9a066, 0.85);
+ g.strokeCircle(x, y, b.r + 3);
+ }
+
+ // Eyes. Pupils lean into the direction of motion; loose goo has two eyes,
+ // structural goo one, which is a cheap way to read the pile at a glance.
+ const eyes = b.attached ? 1 : 2;
+ const er = Math.max(2.6, b.r * 0.30);
+ const pr = er * 0.52;
+ const dx = sp > 0.02 ? (vx / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0;
+ const dy = sp > 0.02 ? (vy / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0;
+ const spread = eyes === 2 ? b.r * 0.38 : 0;
+ for (let i = 0; i < eyes; i += 1) {
+ const ex = x + (eyes === 2 ? (i === 0 ? -spread : spread) : 0);
+ const ey = y - b.r * 0.12;
+ g.fillStyle(0xffffff, 1);
+ g.fillCircle(ex, ey, er);
+ g.fillStyle(0x101018, 1);
+ g.fillCircle(ex + dx, ey + dy, pr);
+ }
+ }
+
+ drawPipe(g) {
+ const p = this.state.pipe;
+ if (!p) return;
+ const x = p.x + OX;
+ const y = p.y + OY;
+ const open = p.open;
+
+ g.fillStyle(PIPE_BODY, 1);
+ g.fillRoundedRect(x - p.r * 0.72, y - p.r * 1.5, p.r * 1.44, p.r * 1.5, 8);
+ g.fillStyle(open ? PIPE_RIM : shade(PIPE_RIM, 0.45), 1);
+ g.fillEllipse(x, y - p.r * 0.35, p.r * 1.7, p.r * 0.62);
+ g.fillStyle(0x0a0f14, 1);
+ g.fillEllipse(x, y - p.r * 0.35, p.r * 1.24, p.r * 0.4);
+
+ if (open) {
+ const pulse = 0.35 + 0.25 * Math.sin(this.state.clock * 6);
+ g.lineStyle(3, PIPE_RIM, pulse);
+ g.strokeCircle(x, y - p.r * 0.35, p.r * (1.0 + 0.12 * Math.sin(this.state.clock * 4)));
+ }
+ }
+
+ // Live feedback while dragging: exactly which strands would form, and
+ // whether the drop is legal at all. Without this the MIN_ANGLE rule is
+ // invisible and the player just feels rejected at random.
+ drawGhost() {
+ const g = this.ghost;
+ g.clear();
+ const b = this.held;
+ if (!b) return;
+
+ const hazard = hazardAt(this.state, b.x, b.y, b.type);
+ const ok = canPlace(this.state, b.x, b.y, b.type);
+ const targets = chooseAttachments(this.state, b.x, b.y, b.type);
+ // Legal but lethal is still worth a warning -- dropping goo onto spikes is
+ // a perfectly valid placement that kills it on contact.
+ const color = !ok ? GHOST_NO : hazard ? 0xffb347 : GHOST_OK;
+
+ for (const id of targets) {
+ const t = this.state.balls[id];
+ if (!t) continue;
+ g.lineStyle(3, color, 0.8);
+ g.lineBetween(b.x + OX, b.y + OY, t.x + OX, t.y + OY);
+ }
+ g.lineStyle(2, color, 0.85);
+ g.strokeCircle(b.x + OX, b.y + OY, b.r + 8);
+
+ if (!ok || hazard) {
+ const need = requiredStrands(this.state, b.x, b.y, b.type);
+ g.lineStyle(1, color, 0.35);
+ g.strokeCircle(b.x + OX, b.y + OY, TUNING.ATTACH_R);
+ if (!this.needText) {
+ this.needText = this.track(this.add.text(0, 0, '', {
+ fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ff9d9d',
+ }).setOrigin(0.5).setDepth(D.ghost));
+ }
+ const label = !ok
+ ? (need === 0 ? 'no hold' : `needs ${need}`)
+ : (hazard === 'fire' && gooType(b.type).explodes ? 'will light!' : `${hazard}!`);
+ this.needText.setPosition(b.x + OX, b.y + OY - b.r - 26)
+ .setColor(ok ? '#ffb347' : '#ff9d9d')
+ .setText(label)
+ .setVisible(true);
+ } else if (this.needText) {
+ this.needText.setVisible(false);
+ }
+ }
+
+ // ── Finish ────────────────────────────────────────────────────────────────
+
+ async finishLevel() {
+ this.finished = true;
+ const st = this.state;
+ const ocd = hasOCD(st);
+ if (!this.returnToEditor) this.recordBest(this.level, st.collected, ocd);
+ playSound(this, SFX.VICTORY_SHORT);
+
+ // An editor test-play is not a real run: it must not advance saved
+ // progress or write a match record.
+ if (this.canPersist && !this.returnToEditor) {
+ try { await api.post('/puzzles/gootower/complete', { level: this.level }); } catch (_) { /* offline */ }
+ try {
+ await api.post('/history/single-player', {
+ game: 'gootower', score: st.collected, won: true,
+ });
+ } catch (_) { /* offline */ }
+ }
+ if (!this.returnToEditor && this.level > this.levelsCompleted) this.levelsCompleted = this.level;
+
+ // The overlay offers "Keep Playing" rather than ending the run outright,
+ // because OCD is a separate, higher target and hitting `required` should
+ // not shut the door on chasing it.
+ this.showWinOverlay();
+ }
+
+ showWinOverlay() {
+ if (this.overlayUp) return;
+ this.overlayUp = true;
+ const st = this.state;
+ const cx = GAME_WIDTH / 2;
+ const ocd = hasOCD(st);
+
+ this.track(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
+ .setDepth(D.overlay).setInteractive());
+ this.track(this.add.text(cx, 330, 'Level Complete', {
+ fontFamily: 'Righteous', fontSize: '58px', color: COLORS.textHex,
+ }).setOrigin(0.5).setDepth(D.overlayUI));
+ this.track(this.add.text(cx, 410, `${st.collected} goo collected (needed ${st.required})`, {
+ fontFamily: '"Julius Sans One"', fontSize: '26px', color: '#b9c6d2',
+ }).setOrigin(0.5).setDepth(D.overlayUI));
+ this.track(this.add.text(cx, 456, ocd ? `★ OCD met — ${st.ocdTarget}` : `OCD target: ${st.ocdTarget}`, {
+ fontFamily: '"Julius Sans One"', fontSize: '24px', color: ocd ? '#ffd166' : '#7f8c99',
+ }).setOrigin(0.5).setDepth(D.overlayUI));
+
+ const next = this.returnToEditor ? null : this.manifest.find((m) => m.level === this.level + 1);
+ if (next) {
+ this.track(new Button(this, cx - 180, 570, 'Next Level', () => this.startLevel(next.level),
+ { width: 250 }).setDepth(D.overlayUI));
+ }
+ this.track(new Button(this, cx + (next ? 180 : 0), 570,
+ this.returnToEditor ? 'Back to Editor' : 'Levels', () => this.exitLevel(),
+ { width: 250, variant: 'ghost' }).setDepth(D.overlayUI));
+ this.track(new Button(this, cx, 660, 'Keep Playing', () => {
+ this.overlayUp = false;
+ this.dismissOverlay();
+ }, { width: 250, variant: 'ghost' }).setDepth(D.overlayUI));
+ }
+
+ // "Keep Playing" tears the overlay widgets down but must leave the board
+ // itself alone, so the tracked list is filtered rather than cleared.
+ dismissOverlay() {
+ const keep = [];
+ for (const o of this.boardObjs) {
+ if (o.depth >= D.overlay) o.destroy();
+ else keep.push(o);
+ }
+ this.boardObjs = keep;
+ }
+}
diff --git a/src/games/gootower/GooTowerLogic.js b/src/games/gootower/GooTowerLogic.js
new file mode 100644
index 0000000..c45aa14
--- /dev/null
+++ b/src/games/gootower/GooTowerLogic.js
@@ -0,0 +1,1374 @@
+// Goo Tower — pure soft-body structure simulation + rules. Zero imports, Node-testable.
+// Shared by the client scene, the level editor, the offline generator and the verifier.
+//
+// ── WHY VERLET + XPBD ───────────────────────────────────────────────────────
+// The whole game is a lattice of point masses joined by springy strands. A
+// force-based spring integrator either explodes at the stiffness a buildable
+// tower needs, or demands a punishing timestep. Verlet integration with XPBD
+// distance constraints is unconditionally stable at any stiffness (corrections
+// are positional, never forces) and exactly reproducible given a fixed
+// iteration order.
+//
+// XPBD specifically, rather than plain position-based relaxation, for two
+// reasons that turned out to be load-bearing:
+// * stiffness is 1/COMPLIANCE regardless of RELAX_ITERS, so the iteration
+// count is a quality/perf knob and not a secret physics knob;
+// * the accumulated multiplier lambda IS the constraint force, so a strand
+// knows how hard it is being pulled. A near-rigid solver yanks every
+// strand back to its rest length every substep, which means length can
+// never report load and NOTHING WOULD EVER BREAK.
+//
+// Reproducibility is load-bearing, exactly as it is in PeggleLogic: the level
+// generator and tools/verifyGooTower.js replay recorded builds headlessly and
+// assert they still win. If the sim were not deterministic, a level bank could
+// not be validated at all.
+//
+// ── ONE SUBSTEP ─────────────────────────────────────────────────────────────
+// 1. INTEGRATE x' = x + (x - xPrev) * drag + a * dt^2 (unpinned balls)
+// 2. RELAX x N strand distance constraints, then terrain pushout,
+// iterated in a fixed order (ascending id)
+// 3. STRESS strand length / rest > BREAK_RATIO -> snap
+// 4. STRUCTURE flood-fill components; a component holding no anchor and
+// touching no ground is adrift and simply falls (it does NOT
+// disintegrate -- broken chunks stay chunks, like the original)
+// 5. AGENTS crawling goo advances; hazards resolve; the pipe drinks
+//
+// ── RIGIDITY IS EMERGENT ────────────────────────────────────────────────────
+// There are no angular constraints anywhere in this file. A structure is stiff
+// only because it is TRIANGULATED, which is why the attachment rule's
+// MIN_ANGLE test (see chooseAttachments) is the single most important number
+// in the game: it is what stops the player building a floppy chain of slivers
+// and forces real trusses. Tune it before tuning anything else.
+//
+// ── UNITS ───────────────────────────────────────────────────────────────────
+// Pixels and seconds, in a level's own logical world space (level.world). The
+// scene maps that to the 1920x1080 canvas with a camera; nothing in here knows
+// about the screen.
+
+export const TUNING = {
+ SUBSTEP_DT: 1 / 240,
+ RELAX_ITERS: 8, // Gauss-Seidel passes per substep
+ GRAVITY: 1500, // px/s^2
+ // Global velocity retention per substep (0.995^240 ~= 0.30/s). Constraint
+ // damping alone cannot settle a structure: it resists a strand STRETCHING,
+ // but a tower's slowest mode is a pendulum swing that barely stretches
+ // anything, so it rings for 13s without this.
+ DRAG: 0.995,
+ MAX_SPEED: 2600, // px/s, clamped per substep to stop blow-ups
+
+ BALL_R: 14,
+ STRAND_REST: 62, // natural strand length when freshly attached
+ STRAND_MIN: 30, // attachment refuses candidates closer than this
+ // XPBD compliance: px of stretch per unit of tension (i.e. 1/k). Tuned so a
+ // 4-bay cantilever truss settles at ~40px of tip sag -- visibly goo-like,
+ // but it holds itself up.
+ COMPLIANCE: 0.00012,
+ DAMPING: 0.0004, // constraint damping (axial only; see DRAG)
+
+ // Strands break on TENSION, not on stretch. At the stiffness a buildable
+ // truss needs, a length-ratio threshold would demand ~350 ball-weights and
+ // nothing would ever collapse -- but collapse is the whole drama of the
+ // game. XPBD hands us the real constraint force for free, so the threshold
+ // is expressed in ball-weights (1 weight = mass 1 x GRAVITY) and is
+ // therefore independent of how stiff we later decide the goo should be.
+ // ~25 ball-weights for a plain common strand. Calibrated so a well-
+ // proportioned 3-bay cantilever survives its own settling transient (which
+ // peaks near 2x static) while an 8-bay one tears its root off.
+ BREAK_FORCE: 25 * 1500,
+ // Breaking watches a low-pass-filtered load, not raw tension. A stiff
+ // lattice rings at ~15Hz, and any impact drives an instantaneous spike of
+ // roughly 2x the static load -- judging on the raw value would snap strands
+ // on the first frame of a level that is merely settling. LOAD_TAU is the
+ // fatigue window; SHOCK_FACTOR still lets a genuinely violent hit cut
+ // straight through it.
+ LOAD_TAU: 0.12,
+ SHOCK_FACTOR: 3,
+ BREAK_RATIO: 1.85, // backstop: a strand stretched this far is gone regardless
+ SLACK_RATIO: 0.55, // below this a strand goes limp and stops pushing
+
+ ATTACH_R: 108, // how far a dropped ball reaches for candidates
+ MIN_ANGLE_DEG: 24, // reject a candidate this close to an existing strand
+
+ FRICTION: 0.35, // tangential loss on terrain contact
+ RESTITUTION: 0.08, // normal bounce on terrain contact
+ BALL_PUSH: 0.5, // how hard overlapping balls shove apart, per pass
+ GRID_CELL: 34, // broadphase cell size; must exceed 2 * max ball radius
+
+ // Terrain-sticking goo (anchor, pokey) bonds to a solid surface within this
+ // distance of its rim, and is pinned there. It is what lets a player start a
+ // structure off a wall instead of only off the ground.
+ STICK_DIST: 14,
+
+ // A bomb touching fire detonates. The blast kills goo close in and shears
+ // strands over a wider radius, so an explosion collapses a structure rather
+ // than merely punching a hole in it. Terrain only goes if it is marked
+ // `destructible` -- the level author decides what can be blown open.
+ BLAST_R: 130,
+ BLAST_KILL_R: 74,
+ FUSE_TIME: 0.55, // seconds between ignition and detonation
+
+ CRAWL_SPEED: 150, // px/s along a strand
+ // Loose goo sitting on the ground ambles toward the structure, the way
+ // unattached goo mills around the tower in the original. This is not
+ // decoration: without it a heap dropped away from the structure can never be
+ // reached by the pipe, and the level is unwinnable no matter what is built.
+ WANDER_SPEED: 95, // px/s
+ WANDER_HZ: 20, // how often the target is re-picked (perf)
+ PIPE_R: 46,
+ SUCK_SPEED: 420,
+
+ SETTLE_SPEED: 4, // below this a ball counts as at rest
+ MAX_SUBSTEPS: 600, // guard against a runaway accumulator
+};
+
+// Goo varieties.
+// buoyancy scales gravity (1 = normal, 0 = weightless, negative = rises)
+// stiff divides TUNING.COMPLIANCE for strands this type owns
+// strength multiplies TUNING.BREAK_FORCE for strands this type owns
+// A strand takes the WEAKEST of its two endpoints for both, so one flimsy
+// ball cannot be used to hang a tower.
+export const GOO_TYPES = {
+ common: { minStrands: 2, maxStrands: 2, mass: 1.0, buoyancy: 1, stiff: 1.0, strength: 1.0, detachable: false, spikeProof: false, sticksToTerrain: false, r: 14 },
+ ivy: { minStrands: 2, maxStrands: 3, mass: 1.0, buoyancy: 1, stiff: 1.0, strength: 0.9, detachable: true, spikeProof: false, sticksToTerrain: false, r: 14 },
+ balloon: { minStrands: 1, maxStrands: 1, mass: 0.6, buoyancy: -2.2, stiff: 0.6, strength: 0.7, detachable: true, spikeProof: false, sticksToTerrain: false, r: 16 },
+ bomb: { minStrands: 2, maxStrands: 2, mass: 1.2, buoyancy: 1, stiff: 1.0, strength: 1.0, detachable: false, spikeProof: true, sticksToTerrain: false, r: 15, explodes: true },
+ block: { minStrands: 2, maxStrands: 4, mass: 1.6, buoyancy: 1, stiff: 2.4, strength: 1.8, detachable: false, spikeProof: false, sticksToTerrain: false, r: 15 },
+ pokey: { minStrands: 2, maxStrands: 2, mass: 1.0, buoyancy: 1, stiff: 1.2, strength: 1.2, detachable: false, spikeProof: false, sticksToTerrain: true, r: 14 },
+ bit: { minStrands: 1, maxStrands: 1, mass: 0.4, buoyancy: 1, stiff: 0.8, strength: 0.6, detachable: true, spikeProof: false, sticksToTerrain: false, r: 9 },
+ skull: { minStrands: 2, maxStrands: 2, mass: 1.1, buoyancy: 1, stiff: 1.1, strength: 1.1, detachable: false, spikeProof: true, sticksToTerrain: false, r: 14 },
+ anchor: { minStrands: 0, maxStrands: 4, mass: 0, buoyancy: 0, stiff: 3.0, strength: 3.0, detachable: false, spikeProof: true, sticksToTerrain: true, r: 15 },
+};
+
+export function gooType(name) {
+ return GOO_TYPES[name] || GOO_TYPES.common;
+}
+
+// ── Seeded RNG ──────────────────────────────────────────────────────────────
+// The only stochastic element in the sim is the crawling wander. Everything
+// else is fully determined by the level and the player's inputs.
+export function mulberry32(seed) {
+ let a = seed >>> 0;
+ return function rng() {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// ── Geometry ────────────────────────────────────────────────────────────────
+
+export function pointInPoly(px, py, poly) {
+ let inside = false;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const xi = poly[i][0], yi = poly[i][1];
+ const xj = poly[j][0], yj = poly[j][1];
+ if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {
+ inside = !inside;
+ }
+ }
+ return inside;
+}
+
+export function closestPointOnSegment(px, py, ax, ay, bx, by) {
+ const dx = bx - ax, dy = by - ay;
+ const len2 = dx * dx + dy * dy;
+ if (len2 < 1e-12) return { x: ax, y: ay, t: 0 };
+ let t = ((px - ax) * dx + (py - ay) * dy) / len2;
+ if (t < 0) t = 0; else if (t > 1) t = 1;
+ return { x: ax + dx * t, y: ay + dy * t, t };
+}
+
+// Nearest point anywhere on a polygon's boundary.
+export function closestPointOnPoly(px, py, poly) {
+ let best = null, bestD2 = Infinity;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const cp = closestPointOnSegment(px, py, poly[j][0], poly[j][1], poly[i][0], poly[i][1]);
+ const dx = px - cp.x, dy = py - cp.y;
+ const d2 = dx * dx + dy * dy;
+ if (d2 < bestD2) { bestD2 = d2; best = cp; }
+ }
+ return { x: best.x, y: best.y, d2: bestD2 };
+}
+
+export function segmentsIntersect(ax, ay, bx, by, cx, cy, dx, dy) {
+ const d1 = cross(cx, cy, dx, dy, ax, ay);
+ const d2 = cross(cx, cy, dx, dy, bx, by);
+ const d3 = cross(ax, ay, bx, by, cx, cy);
+ const d4 = cross(ax, ay, bx, by, dx, dy);
+ return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
+ ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
+}
+
+function cross(ax, ay, bx, by, px, py) {
+ return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
+}
+
+function polyBounds(poly) {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const [x, y] of poly) {
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ return { minX, minY, maxX, maxY };
+}
+
+// True when the straight line a->b is broken by solid terrain. This is what
+// stops a dropped ball attaching through a wall.
+export function lineBlocked(state, ax, ay, bx, by) {
+ for (const t of state.terrain) {
+ if (t.kind !== 'solid') continue;
+ const poly = t.poly;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ if (segmentsIntersect(ax, ay, bx, by, poly[j][0], poly[j][1], poly[i][0], poly[i][1])) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+// ── State ───────────────────────────────────────────────────────────────────
+
+export function createState(level, seed = 0x60071e) {
+ const world = level.world || { w: 1600, h: 1000 };
+ const state = {
+ world,
+ terrain: (level.terrain || []).map((t) => ({
+ kind: t.kind || 'solid',
+ poly: t.poly.map((p) => [p[0], p[1]]),
+ bounds: polyBounds(t.poly),
+ destructible: !!t.destructible,
+ })),
+ // Fans are axis-aligned force volumes: { x, y, w, h, dx, dy, force }.
+ fans: (level.fans || []).map((f) => ({
+ x: f.x, y: f.y, w: f.w, h: f.h,
+ dx: f.dx ?? 0, dy: f.dy ?? -1, force: f.force ?? 2200,
+ })),
+ balls: [],
+ strands: [],
+ pipe: level.pipe ? { x: level.pipe.x, y: level.pipe.y, r: level.pipe.r || TUNING.PIPE_R, open: false } : null,
+ pile: [],
+ required: level.required || 1,
+ ocdTarget: level.ocdTarget || level.required || 1,
+ collected: 0,
+ clock: 0,
+ acc: 0,
+ nextBallId: 0,
+ nextStrandId: 0,
+ seed: seed >>> 0,
+ rng: mulberry32(seed),
+ dirty: true, // strand graph changed -> components need recomputing
+ pipeDist: new Map(),
+ pipeDistDirty: true,
+ grid: null,
+ wanderClock: 0,
+ agentsOff: false, // settlePhysics() flips this
+ };
+
+ // Gears join the terrain list as ordinary solids carrying a `gear` record, so
+ // the existing collision path handles them and only their polygon has to be
+ // respun each substep.
+ for (const g of level.gears || []) {
+ const gear = {
+ cx: g.x, cy: g.y, r: g.r ?? 90, teeth: g.teeth ?? 8,
+ omega: g.omega ?? 1.2, angle: g.angle ?? 0,
+ };
+ const poly = buildGearPoly(gear);
+ state.terrain.push({ kind: 'solid', poly, bounds: polyBounds(poly), destructible: false, gear });
+ }
+
+ for (const b of level.balls || []) {
+ addBall(state, b.x, b.y, b.type || 'common', { pinned: !!b.pinned || (b.type === 'anchor') });
+ }
+ for (const s of level.strands || []) {
+ addStrand(state, s[0], s[1]);
+ }
+ // Loose goo waiting to be dragged. Given positions so it can pile up and be
+ // simulated like everything else.
+ for (const p of level.pile || []) {
+ const b = addBall(state, p.x, p.y, p.type || 'common', { free: true });
+ state.pile.push(b.id);
+ }
+ refreshStructure(state);
+ return state;
+}
+
+export function addBall(state, x, y, type = 'common', opts = {}) {
+ const def = gooType(type);
+ const ball = {
+ id: state.nextBallId++,
+ x, y,
+ px: x, py: y, // Verlet previous position == starts at rest
+ type,
+ r: def.r,
+ invMass: opts.pinned || def.mass === 0 ? 0 : 1 / def.mass,
+ pinned: !!opts.pinned,
+ attached: !opts.free, // part of the structure (vs loose in the pile)
+ strands: [],
+ dead: false,
+ collected: false, // drunk by the pipe (dead, but a success not a death)
+ fuse: null, // seconds until detonation once lit
+ exploded: false,
+ held: false, // under the player's cursor; physics leaves it alone
+ grounded: false,
+ // crawling
+ walking: false,
+ onStrand: -1,
+ t: 0,
+ dir: 1,
+ wanderDir: 0,
+ };
+ state.balls.push(ball);
+ state.dirty = true;
+ return ball;
+}
+
+export function addStrand(state, aId, bId, restOverride = null) {
+ const a = ballById(state, aId);
+ const b = ballById(state, bId);
+ if (!a || !b || a === b) return null;
+ if (a.strands.some((sid) => strandLinks(state, sid, aId, bId))) return null;
+ const dist = Math.hypot(b.x - a.x, b.y - a.y);
+ // Rest length is the distance the strand is BORN at, clamped to the reachable
+ // band. Anything else means a level author's own geometry starts pre-stressed
+ // — a diagonal drawn across a 62px grid is 87px long, and forcing it to a
+ // 62px rest would rip the lattice apart the instant the sim starts.
+ const strand = {
+ id: state.nextStrandId++,
+ a: aId,
+ b: bId,
+ rest: restOverride != null
+ ? restOverride
+ : Math.max(TUNING.STRAND_MIN, Math.min(dist, TUNING.ATTACH_R)),
+ stiff: Math.min(gooType(a.type).stiff, gooType(b.type).stiff),
+ strength: Math.min(gooType(a.type).strength, gooType(b.type).strength),
+ lambda: 0, // XPBD multiplier, reset every substep
+ tension: 0, // instantaneous constraint force, derived from lambda
+ load: 0, // low-pass-filtered tension; what actually breaks strands
+ broken: false,
+ };
+ state.strands.push(strand);
+ a.strands.push(strand.id);
+ b.strands.push(strand.id);
+ state.dirty = true;
+ return strand;
+}
+
+function strandLinks(state, sid, aId, bId) {
+ const s = strandById(state, sid);
+ if (!s || s.broken) return false;
+ return (s.a === aId && s.b === bId) || (s.a === bId && s.b === aId);
+}
+
+export function ballById(state, id) {
+ return state.balls[id] && state.balls[id].id === id
+ ? state.balls[id]
+ : state.balls.find((b) => b.id === id) || null;
+}
+
+export function strandById(state, id) {
+ return state.strands[id] && state.strands[id].id === id
+ ? state.strands[id]
+ : state.strands.find((s) => s.id === id) || null;
+}
+
+export function breakStrand(state, strand, events) {
+ if (strand.broken) return;
+ strand.broken = true;
+ state.dirty = true;
+ if (events) events.push({ type: 'strandBreak', id: strand.id, a: strand.a, b: strand.b });
+}
+
+export function liveStrands(state) {
+ return state.strands.filter((s) => !s.broken);
+}
+
+// ── Structure ───────────────────────────────────────────────────────────────
+// Flood-fill the live strand graph. A component is `rooted` when it contains a
+// pinned anchor or a ball resting on terrain. Adrift components are still
+// simulated as coupled lattices -- a snapped-off chunk falls as a chunk.
+
+export function refreshStructure(state) {
+ const comp = new Map();
+ let next = 0;
+ const adj = new Map();
+ for (const b of state.balls) adj.set(b.id, []);
+ for (const s of state.strands) {
+ if (s.broken) continue;
+ if (adj.has(s.a)) adj.get(s.a).push(s.b);
+ if (adj.has(s.b)) adj.get(s.b).push(s.a);
+ }
+ for (const b of state.balls) {
+ if (comp.has(b.id) || !b.attached || b.dead) continue;
+ const id = next++;
+ const stack = [b.id];
+ comp.set(b.id, id);
+ while (stack.length) {
+ const cur = stack.pop();
+ for (const nb of adj.get(cur) || []) {
+ if (!comp.has(nb)) { comp.set(nb, id); stack.push(nb); }
+ }
+ }
+ }
+ const rooted = new Set();
+ for (const b of state.balls) {
+ if (!comp.has(b.id)) continue;
+ if (b.pinned || b.grounded) rooted.add(comp.get(b.id));
+ }
+ state.components = comp;
+ state.rootedComponents = rooted;
+ state.componentCount = next;
+ state.dirty = false;
+ return state;
+}
+
+export function isRooted(state, ballId) {
+ if (state.dirty) refreshStructure(state);
+ const c = state.components.get(ballId);
+ return c != null && state.rootedComponents.has(c);
+}
+
+// ── Simulation ──────────────────────────────────────────────────────────────
+
+export function stepSim(state, dt) {
+ const events = [];
+ state.acc += dt;
+ const sub = TUNING.SUBSTEP_DT;
+ const eps = sub * 1e-6;
+ let guard = 0;
+ while (state.acc >= sub - eps && guard < TUNING.MAX_SUBSTEPS) {
+ state.acc -= sub;
+ substep(state, sub, events);
+ guard += 1;
+ }
+ return events;
+}
+
+export function substep(state, dt, events) {
+ updateMovers(state, dt);
+ integrate(state, dt);
+ for (const s of state.strands) s.lambda = 0; // XPBD accumulates per substep
+ buildGrid(state);
+ for (let i = 0; i < TUNING.RELAX_ITERS; i += 1) {
+ const last = i === TUNING.RELAX_ITERS - 1;
+ relaxStrands(state, dt);
+ resolveBallCollisions(state);
+ // Positional pushout runs every pass, but the velocity response (friction
+ // and restitution) and the lethal hazards run ONCE per substep. Applying
+ // friction on all eight passes compounds it to (1-f)^8 and glues
+ // everything to the terrain.
+ resolveTerrain(state, events, last);
+ }
+ const idt2 = 1 / (dt * dt);
+ const k = 1 - Math.exp(-dt / TUNING.LOAD_TAU);
+ for (const s of state.strands) {
+ s.tension = -s.lambda * idt2;
+ s.load += (s.tension - s.load) * k;
+ }
+ applyStress(state, events);
+ updateFuses(state, dt, events);
+ if (state.dirty) refreshStructure(state);
+ updateAgents(state, dt, events);
+ if (state.dirty) refreshStructure(state);
+ state.clock += dt;
+}
+
+function integrate(state, dt) {
+ const dt2 = dt * dt;
+ const maxStep = TUNING.MAX_SPEED * dt;
+ for (const b of state.balls) {
+ if (b.dead || b.invMass === 0 || b.walking || b.held) continue;
+ const def = gooType(b.type);
+ const fan = state.fans.length ? fanAccel(state, b) : null;
+ const ax = fan ? fan.ax : 0;
+ const ay = TUNING.GRAVITY * def.buoyancy + (fan ? fan.ay : 0);
+
+ let vx = (b.x - b.px) * TUNING.DRAG;
+ let vy = (b.y - b.py) * TUNING.DRAG;
+ // Clamp per-substep travel so a pathological configuration cannot launch a
+ // ball through the world in one frame.
+ const sp = Math.hypot(vx, vy);
+ if (sp > maxStep) { const k = maxStep / sp; vx *= k; vy *= k; }
+
+ b.px = b.x;
+ b.py = b.y;
+ b.x += vx + ax * dt2;
+ b.y += vy + ay * dt2;
+ b.grounded = false;
+ }
+}
+
+// XPBD (extended position-based dynamics) distance constraint.
+//
+// Plain PBD would make stiffness depend on RELAX_ITERS -- more iterations, a
+// stiffer world -- so tuning iterations for performance would silently retune
+// the physics. XPBD fixes stiffness at 1/COMPLIANCE regardless of iteration
+// count, and the accumulated multiplier lambda IS the constraint force, which
+// is what lets a strand know how hard it is being pulled. Without that, a
+// near-rigid solver snaps every strand back to rest each substep and nothing
+// ever breaks under load.
+function relaxStrands(state, dt) {
+ const idt2 = 1 / (dt * dt);
+ const strands = state.strands;
+ for (let i = 0; i < strands.length; i += 1) {
+ const s = strands[i];
+ if (s.broken) continue;
+ const a = state.balls[s.a];
+ const b = state.balls[s.b];
+ if (!a || !b || a.dead || b.dead) continue;
+ const wsum = a.invMass + b.invMass;
+ if (wsum === 0) continue;
+
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ const len = Math.hypot(dx, dy);
+ if (len < 1e-9) continue;
+ // A strand crushed below SLACK_RATIO goes limp rather than strutting its
+ // endpoints apart -- goo strands pull, they do not push.
+ if (len < s.rest * TUNING.SLACK_RATIO) continue;
+
+ const nx = dx / len;
+ const ny = dy / len;
+ const C = len - s.rest;
+
+ const alpha = (TUNING.COMPLIANCE / s.stiff) * idt2;
+ const beta = TUNING.DAMPING * s.stiff;
+ const gamma = (alpha * beta) / dt;
+
+ // Relative displacement over this substep, projected on the strand axis --
+ // the damping term.
+ const rvx = (a.x - a.px) - (b.x - b.px);
+ const rvy = (a.y - a.py) - (b.y - b.py);
+ const rn = rvx * nx + rvy * ny;
+
+ const dLambda = (-C - alpha * s.lambda - gamma * rn) / ((1 + gamma) * wsum + alpha);
+ s.lambda += dLambda;
+
+ a.x += nx * dLambda * a.invMass;
+ a.y += ny * dLambda * a.invMass;
+ b.x -= nx * dLambda * b.invMass;
+ b.y -= ny * dLambda * b.invMass;
+ }
+}
+
+// ── Movers: fans and gears ──────────────────────────────────────────────────
+//
+// Fans are force volumes: goo inside gets pushed, nothing collides.
+// Gears are ROTATING SOLID TERRAIN. They are stored as ordinary terrain
+// entries carrying a `gear` record, so the existing collision path handles
+// them for free; all this has to do is spin the polygon and recompute bounds.
+// Contact then drags goo along at the rim's surface speed.
+
+function buildGearPoly(gear) {
+ const { cx, cy, r, teeth, angle } = gear;
+ const poly = [];
+ const n = Math.max(3, teeth) * 2;
+ for (let i = 0; i < n; i += 1) {
+ const a = angle + (i / n) * Math.PI * 2;
+ const rad = i % 2 === 0 ? r : r * 0.74; // alternate tip / root
+ poly.push([cx + Math.cos(a) * rad, cy + Math.sin(a) * rad]);
+ }
+ return poly;
+}
+
+function updateMovers(state, dt) {
+ for (const t of state.terrain) {
+ if (!t.gear) continue;
+ t.gear.angle += t.gear.omega * dt;
+ t.poly = buildGearPoly(t.gear);
+ t.bounds = polyBounds(t.poly);
+ }
+}
+
+// Fan force, applied during integration alongside gravity.
+function fanAccel(state, b) {
+ let ax = 0;
+ let ay = 0;
+ for (const f of state.fans) {
+ if (b.x < f.x || b.x > f.x + f.w || b.y < f.y || b.y > f.y + f.h) continue;
+ ax += f.dx * f.force;
+ ay += f.dy * f.force;
+ }
+ return { ax, ay };
+}
+
+// Uniform-grid broadphase, rebuilt once per substep. Positions move by well
+// under a cell inside the relaxation loop, so one build per substep is enough.
+function buildGrid(state) {
+ const cell = TUNING.GRID_CELL;
+ const grid = new Map();
+ for (const b of state.balls) {
+ if (b.dead || b.walking || b.held) continue;
+ const key = `${Math.floor(b.x / cell)},${Math.floor(b.y / cell)}`;
+ let bucket = grid.get(key);
+ if (!bucket) { bucket = []; grid.set(key, bucket); }
+ bucket.push(b);
+ }
+ state.grid = grid;
+}
+
+// Goo balls are solid: loose goo must PILE on the ground rather than collapse
+// into a single point, and a tower must not swallow the ball you drop on it.
+// Structural balls sit ~62px apart against a 28px contact diameter, so this
+// almost never fires for attached goo -- it is mostly about the pile.
+function resolveBallCollisions(state) {
+ const cell = TUNING.GRID_CELL;
+ const grid = state.grid;
+ if (!grid) return;
+ for (const a of state.balls) {
+ if (a.dead || a.walking || a.held) continue;
+ const cx = Math.floor(a.x / cell);
+ const cy = Math.floor(a.y / cell);
+ for (let ox = -1; ox <= 1; ox += 1) {
+ for (let oy = -1; oy <= 1; oy += 1) {
+ const bucket = grid.get(`${cx + ox},${cy + oy}`);
+ if (!bucket) continue;
+ for (const b of bucket) {
+ if (b.id <= a.id || b.dead || b.walking || b.held) continue;
+ const wsum = a.invMass + b.invMass;
+ if (wsum === 0) continue;
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const minD = a.r + b.r;
+ const d2 = dx * dx + dy * dy;
+ if (d2 >= minD * minD) continue;
+ let d = Math.sqrt(d2);
+ let nx, ny;
+ if (d < 1e-9) {
+ // Exactly coincident: separate along a stable, arbitrary axis
+ // rather than dividing by zero.
+ nx = 0; ny = -1; d = 0;
+ } else {
+ nx = dx / d; ny = dy / d;
+ }
+ const push = (minD - d) * TUNING.BALL_PUSH;
+ const ka = a.invMass / wsum;
+ const kb = b.invMass / wsum;
+ a.x -= nx * push * ka;
+ a.y -= ny * push * ka;
+ b.x += nx * push * kb;
+ b.y += ny * push * kb;
+ }
+ }
+ }
+ }
+}
+
+function resolveTerrain(state, events, finalPass = true) {
+ const checkHazards = finalPass;
+ const applyResponse = finalPass;
+ for (const b of state.balls) {
+ if (b.dead || b.invMass === 0 || b.walking || b.held) continue;
+ for (const t of state.terrain) {
+ const bb = t.bounds;
+ if (b.x + b.r < bb.minX || b.x - b.r > bb.maxX ||
+ b.y + b.r < bb.minY || b.y - b.r > bb.maxY) continue;
+
+ const inside = pointInPoly(b.x, b.y, t.poly);
+ const cp = closestPointOnPoly(b.x, b.y, t.poly);
+ const d = Math.sqrt(cp.d2);
+ if (!inside && d >= b.r) continue;
+
+ if (t.kind === 'spike') {
+ if (checkHazards && !gooType(b.type).spikeProof) {
+ killBall(state, b, 'spike', events);
+ break;
+ }
+ continue;
+ }
+ if (t.kind === 'fire') {
+ if (checkHazards) {
+ // A bomb does not merely survive fire -- fire is how you set one off.
+ if (gooType(b.type).explodes) { ignite(state, b, events); continue; }
+ if (!gooType(b.type).spikeProof) { killBall(state, b, 'fire', events); break; }
+ }
+ continue;
+ }
+ if (t.kind !== 'solid') continue;
+
+ let nx, ny;
+ if (d > 1e-9) { nx = (b.x - cp.x) / d; ny = (b.y - cp.y) / d; }
+ else { nx = 0; ny = -1; }
+ if (inside) { nx = -nx; ny = -ny; }
+
+ b.x = cp.x + nx * b.r;
+ b.y = cp.y + ny * b.r;
+ if (!b.grounded) { b.grounded = true; state.dirty = true; }
+
+ // Velocity response runs once per substep (see substep()): split into
+ // normal / tangential, bounce the normal part, shed the tangential part
+ // as friction.
+ if (applyResponse) {
+ let vx = b.x - b.px;
+ let vy = b.y - b.py;
+ const vn = vx * nx + vy * ny;
+ const tx = vx - vn * nx;
+ const ty = vy - vn * ny;
+ const nvn = vn < 0 ? -vn * TUNING.RESTITUTION : vn;
+
+ // A spinning gear drags whatever touches it. Friction pulls the ball's
+ // tangential motion toward the RIM's surface velocity rather than
+ // toward zero -- that difference is the whole mechanic.
+ let stx = 0;
+ let sty = 0;
+ if (t.gear) {
+ const sdt = TUNING.SUBSTEP_DT;
+ const svx = -t.gear.omega * (cp.y - t.gear.cy) * sdt;
+ const svy = t.gear.omega * (cp.x - t.gear.cx) * sdt;
+ const svn = svx * nx + svy * ny;
+ stx = svx - svn * nx;
+ sty = svy - svn * ny;
+ }
+ vx = tx + (stx - tx) * TUNING.FRICTION + nx * nvn;
+ vy = ty + (sty - ty) * TUNING.FRICTION + ny * nvn;
+ b.px = b.x - vx;
+ b.py = b.y - vy;
+ }
+ }
+
+ // Falling out of the world is fatal.
+ if (checkHazards && !b.dead && b.y - b.r > state.world.h + 200) killBall(state, b, 'fell', events);
+ }
+}
+
+function applyStress(state, events) {
+ for (const s of state.strands) {
+ if (s.broken) continue;
+ const a = state.balls[s.a];
+ const b = state.balls[s.b];
+ if (!a || !b) continue;
+ if (a.dead || b.dead) { breakStrand(state, s, events); continue; }
+ const cap = TUNING.BREAK_FORCE * s.strength;
+ if (s.load > cap || s.tension > cap * TUNING.SHOCK_FACTOR) {
+ breakStrand(state, s, events);
+ continue;
+ }
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
+ if (len > s.rest * TUNING.BREAK_RATIO) breakStrand(state, s, events);
+ }
+}
+
+// ── Explosions ──────────────────────────────────────────────────────────────
+
+// Light the fuse. Detonation is deferred by FUSE_TIME so the player sees it
+// coming and a chain reaction reads as a chain rather than one frame of noise.
+export function ignite(state, ball, events) {
+ if (!ball || ball.dead || ball.fuse != null) return;
+ if (!gooType(ball.type).explodes) return;
+ ball.fuse = TUNING.FUSE_TIME;
+ if (events) events.push({ type: 'ignite', ball: ball.id });
+}
+
+export function detonate(state, ball, events) {
+ if (!ball || ball.exploded) return;
+ ball.exploded = true;
+ const bx = ball.x;
+ const by = ball.y;
+ if (events) events.push({ type: 'explode', ball: ball.id, x: bx, y: by });
+
+ // Close in: goo dies. Anchors are bolted to the world and survive.
+ for (const b of state.balls) {
+ if (b.dead || b === ball) continue;
+ const d = Math.hypot(b.x - bx, b.y - by);
+ if (d > TUNING.BLAST_KILL_R) continue;
+ if (b.type === 'anchor') continue;
+ if (gooType(b.type).explodes) ignite(state, b, events); // chain reaction
+ else killBall(state, b, 'blast', events);
+ }
+
+ // Wider: strands shear. This is what makes a bomb collapse a structure
+ // instead of just punching a hole through it.
+ for (const s of state.strands) {
+ if (s.broken) continue;
+ const a = state.balls[s.a];
+ const c = state.balls[s.b];
+ if (!a || !c) continue;
+ const mx = (a.x + c.x) / 2;
+ const my = (a.y + c.y) / 2;
+ if (Math.hypot(mx - bx, my - by) <= TUNING.BLAST_R) breakStrand(state, s, events);
+ }
+
+ // Terrain only goes if the author said it could.
+ const kept = [];
+ for (const t of state.terrain) {
+ const reaches = pointInPoly(bx, by, t.poly)
+ || Math.sqrt(closestPointOnPoly(bx, by, t.poly).d2) <= TUNING.BLAST_R;
+ if (t.destructible && reaches) {
+ if (events) events.push({ type: 'terrainDestroyed', kind: t.kind });
+ continue;
+ }
+ kept.push(t);
+ }
+ state.terrain = kept;
+
+ killBall(state, ball, 'blast', events);
+ state.dirty = true;
+}
+
+function updateFuses(state, dt, events) {
+ for (const b of state.balls) {
+ if (b.dead || b.fuse == null || b.exploded) continue;
+ b.fuse -= dt;
+ if (b.fuse <= 0) detonate(state, b, events);
+ }
+}
+
+export function killBall(state, ball, cause, events) {
+ if (ball.dead) return;
+ ball.dead = true;
+ ball.attached = false;
+ for (const sid of ball.strands) {
+ const s = strandById(state, sid);
+ if (s) breakStrand(state, s, events);
+ }
+ ball.strands = [];
+ state.dirty = true;
+ if (events) events.push({ type: 'ballDie', ball: ball.id, cause });
+}
+
+// ── Attachment ──────────────────────────────────────────────────────────────
+//
+// THE most important rule in the game. Rigidity is emergent from triangulation
+// (there are no angular constraints), so what the player is allowed to connect
+// to is the only thing standing between a proper truss and a floppy chain of
+// slivers. Four gates, in order:
+//
+// reach within ATTACH_R, and no closer than STRAND_MIN
+// line of sight the strand must not pass through solid terrain
+// nearest-first take candidates by distance, up to the type's maxStrands
+// MIN_ANGLE reject a candidate lying within MIN_ANGLE_DEG of a strand
+// already chosen -- this is what forces triangles
+//
+// Below minStrands the placement is refused outright and the ball goes back to
+// the pile.
+
+export function insideSolid(state, x, y) {
+ for (const t of state.terrain) {
+ if (t.kind !== 'solid') continue;
+ const bb = t.bounds;
+ if (x < bb.minX || x > bb.maxX || y < bb.minY || y > bb.maxY) continue;
+ if (pointInPoly(x, y, t.poly)) return true;
+ }
+ return false;
+}
+
+export function chooseAttachments(state, x, y, type = 'common') {
+ const def = gooType(type);
+ if (def.maxStrands <= 0) return [];
+
+ const cands = [];
+ for (const b of state.balls) {
+ if (!b.attached || b.dead || b.walking || b.held) continue;
+ const dx = b.x - x;
+ const dy = b.y - y;
+ const d = Math.hypot(dx, dy);
+ if (d > TUNING.ATTACH_R || d < TUNING.STRAND_MIN) continue;
+ if (lineBlocked(state, x, y, b.x, b.y)) continue;
+ cands.push({ id: b.id, d, ang: Math.atan2(dy, dx) });
+ }
+ // Ties broken by id so the choice is deterministic -- the generator and the
+ // verifier replay recorded builds and must get the same structure back.
+ cands.sort((p, q) => (p.d - q.d) || (p.id - q.id));
+
+ const minAng = (TUNING.MIN_ANGLE_DEG * Math.PI) / 180;
+ const chosen = [];
+ for (const c of cands) {
+ if (chosen.length >= def.maxStrands) break;
+ let ok = true;
+ for (const prev of chosen) {
+ let da = Math.abs(c.ang - prev.ang);
+ if (da > Math.PI) da = Math.PI * 2 - da;
+ if (da < minAng) { ok = false; break; }
+ }
+ if (ok) chosen.push(c);
+ }
+ return chosen.map((c) => c.id);
+}
+
+// How close a point is to any solid surface. Terrain-sticking goo bonds when
+// this is within STICK_DIST of its rim.
+export function distanceToSolid(state, x, y) {
+ let best = Infinity;
+ for (const t of state.terrain) {
+ if (t.kind !== 'solid') continue;
+ const d = Math.sqrt(closestPointOnPoly(x, y, t.poly).d2);
+ if (d < best) best = d;
+ }
+ return best;
+}
+
+export function canStickToTerrain(state, x, y, type) {
+ if (!gooType(type).sticksToTerrain) return false;
+ return distanceToSolid(state, x, y) <= gooType(type).r + TUNING.STICK_DIST;
+}
+
+// Terrain-sticking goo bonded to a surface needs no strands at all: the
+// surface IS its anchor. That is the whole point of anchor and pokey goo --
+// they let a structure start from a wall instead of only from the ground.
+export function requiredStrands(state, x, y, type) {
+ if (canStickToTerrain(state, x, y, type)) return 0;
+ return gooType(type).minStrands;
+}
+
+// Would goo of this type dropped here die on contact? Spikes and fire are
+// lethal terrain, and a placement that lands on them is legal but suicidal --
+// the reference player must not walk into it, and the drag preview warns.
+export function hazardAt(state, x, y, type = 'common') {
+ const def = gooType(type);
+ const r = def.r + 2;
+ for (const t of state.terrain) {
+ if (t.kind !== 'spike' && t.kind !== 'fire') continue;
+ const bb = t.bounds;
+ if (x + r < bb.minX || x - r > bb.maxX || y + r < bb.minY || y - r > bb.maxY) continue;
+ if (!pointInPoly(x, y, t.poly) && Math.sqrt(closestPointOnPoly(x, y, t.poly).d2) > r) continue;
+ if (t.kind === 'spike' && def.spikeProof) continue;
+ if (t.kind === 'fire' && def.explodes) return 'fire'; // lights it, not lethal per se
+ if (t.kind === 'fire' && def.spikeProof) continue;
+ return t.kind;
+ }
+ return null;
+}
+
+export function canPlace(state, x, y, type = 'common') {
+ if (insideSolid(state, x, y)) return false;
+ if (x < 0 || y < 0 || x > state.world.w || y > state.world.h) return false;
+ const need = requiredStrands(state, x, y, type);
+ if (need === 0) {
+ // An anchor still has to have something to anchor TO.
+ if (gooType(type).sticksToTerrain) return canStickToTerrain(state, x, y, type);
+ return true;
+ }
+ return chooseAttachments(state, x, y, type).length >= need;
+}
+
+export function placeBall(state, ball, x, y, events = null) {
+ if (!ball || ball.dead) return false;
+ const targets = chooseAttachments(state, x, y, ball.type);
+ const need = requiredStrands(state, x, y, ball.type);
+ const stuck = canStickToTerrain(state, x, y, ball.type);
+ if (insideSolid(state, x, y) || targets.length < need || (need === 0 && !stuck && gooType(ball.type).sticksToTerrain)) {
+ ball.held = false;
+ return false;
+ }
+ ball.x = x; ball.y = y; ball.px = x; ball.py = y;
+ ball.held = false;
+ ball.walking = false;
+ ball.onStrand = -1;
+ ball.attached = true;
+ // Bonded to terrain: it becomes immovable, which is what makes it an anchor
+ // point the rest of the structure can hang from.
+ if (stuck) {
+ ball.pinned = true;
+ ball.invMass = 0;
+ if (events) events.push({ type: 'stick', ball: ball.id });
+ }
+ const i = state.pile.indexOf(ball.id);
+ if (i >= 0) state.pile.splice(i, 1);
+ for (const t of targets) addStrand(state, ball.id, t);
+ state.dirty = true;
+ if (events) events.push({ type: 'place', ball: ball.id, strands: targets.length });
+ return true;
+}
+
+// The nearest ball the player is allowed to pick up: anything loose, plus
+// attached goo of a detachable type.
+export function pickBallAt(state, x, y, reach = 42) {
+ let best = null;
+ let bestD = Infinity;
+ for (const b of state.balls) {
+ if (b.dead) continue;
+ if (b.attached && !gooType(b.type).detachable) continue;
+ const d = Math.hypot(b.x - x, b.y - y);
+ if (d > reach + b.r || d >= bestD) continue;
+ bestD = d;
+ best = b;
+ }
+ return best;
+}
+
+export function beginDrag(state, ball, events = null) {
+ if (!ball || ball.dead) return false;
+ if (ball.attached) {
+ if (!gooType(ball.type).detachable) return false;
+ for (const sid of ball.strands.slice()) {
+ const s = strandById(state, sid);
+ if (s) breakStrand(state, s, events);
+ }
+ ball.strands = [];
+ ball.attached = false;
+ if (!state.pile.includes(ball.id)) state.pile.push(ball.id);
+ }
+ ball.held = true;
+ ball.walking = false;
+ ball.onStrand = -1;
+ state.dirty = true;
+ if (events) events.push({ type: 'pickup', ball: ball.id });
+ return true;
+}
+
+export function dragTo(state, ball, x, y) {
+ if (!ball || !ball.held) return;
+ ball.x = x; ball.y = y;
+ ball.px = x; ball.py = y; // dropped goo starts from rest, never flung
+}
+
+// Returns true when the ball stuck to the structure; false means it was
+// released into the world as loose goo again.
+export function endDrag(state, ball, x, y, events = null) {
+ if (!ball) return false;
+ if (placeBall(state, ball, x, y, events)) return true;
+ ball.held = false;
+ ball.x = x; ball.y = y; ball.px = x; ball.py = y;
+ if (!ball.attached && !state.pile.includes(ball.id)) state.pile.push(ball.id);
+ if (events) events.push({ type: 'placeFailed', ball: ball.id });
+ return false;
+}
+
+// ── The pipe ────────────────────────────────────────────────────────────────
+
+function updatePipe(state, events) {
+ const p = state.pipe;
+ if (!p) return;
+ const was = p.open;
+ p.open = false;
+ for (const b of state.balls) {
+ if (!b.attached || b.dead) continue;
+ if (Math.hypot(b.x - p.x, b.y - p.y) <= p.r + b.r) { p.open = true; break; }
+ }
+ if (p.open !== was) {
+ state.pipeDistDirty = true;
+ if (events) events.push({ type: p.open ? 'pipeOpen' : 'pipeClose' });
+ }
+}
+
+// Hop count from the pipe, over the live strand graph. Crawlers walk downhill
+// on this; without it they wander and the level never ends.
+function refreshPipeDist(state) {
+ state.pipeDistDirty = false;
+ const dist = new Map();
+ const p = state.pipe;
+ if (!p || !p.open) { state.pipeDist = dist; return; }
+
+ const adj = new Map();
+ for (const s of state.strands) {
+ if (s.broken) continue;
+ if (!adj.has(s.a)) adj.set(s.a, []);
+ if (!adj.has(s.b)) adj.set(s.b, []);
+ adj.get(s.a).push(s.b);
+ adj.get(s.b).push(s.a);
+ }
+ const queue = [];
+ for (const b of state.balls) {
+ if (!b.attached || b.dead) continue;
+ if (Math.hypot(b.x - p.x, b.y - p.y) <= p.r + b.r) { dist.set(b.id, 0); queue.push(b.id); }
+ }
+ for (let i = 0; i < queue.length; i += 1) {
+ const cur = queue[i];
+ const d = dist.get(cur);
+ for (const nb of adj.get(cur) || []) {
+ if (!dist.has(nb)) { dist.set(nb, d + 1); queue.push(nb); }
+ }
+ }
+ state.pipeDist = dist;
+}
+
+function collectBall(state, ball, events) {
+ ball.collected = true;
+ ball.dead = true;
+ ball.walking = false;
+ ball.held = false;
+ const i = state.pile.indexOf(ball.id);
+ if (i >= 0) state.pile.splice(i, 1);
+ state.collected += 1;
+ state.dirty = true;
+ if (events) events.push({ type: 'collect', ball: ball.id, total: state.collected });
+}
+
+// ── Crawling goo ────────────────────────────────────────────────────────────
+//
+// Loose goo that touches the structure climbs onto it and crawls along the
+// strands. A crawler rides a strand as a parameter t in [0,1]; at a node it
+// picks the next strand -- downhill toward the pipe when the pipe is open,
+// otherwise a seeded wander that prefers not to double straight back.
+
+function dropWalker(state, ball) {
+ ball.walking = false;
+ ball.onStrand = -1;
+ ball.px = ball.x;
+ ball.py = ball.y;
+}
+
+function strandEndpoints(state, s) {
+ return [state.balls[s.a], state.balls[s.b]];
+}
+
+function recruitWalkers(state, events) {
+ const cell = TUNING.GRID_CELL;
+ const grid = state.grid;
+ if (!grid) return;
+ for (const b of state.balls) {
+ if (b.dead || b.attached || b.walking || b.held) continue;
+ const cx = Math.floor(b.x / cell);
+ const cy = Math.floor(b.y / cell);
+ let target = null;
+ let bestD = Infinity;
+ for (let ox = -1; ox <= 1 && !target; ox += 1) {
+ for (let oy = -1; oy <= 1; oy += 1) {
+ for (const other of grid.get(`${cx + ox},${cy + oy}`) || []) {
+ if (!other.attached || other.dead) continue;
+ const d = Math.hypot(other.x - b.x, other.y - b.y);
+ if (d > b.r + other.r + 4 || d >= bestD) continue;
+ const live = other.strands.map((id) => strandById(state, id)).filter((s) => s && !s.broken);
+ if (!live.length) continue;
+ bestD = d;
+ target = { node: other, strand: live[0] };
+ }
+ }
+ }
+ if (!target) continue;
+ b.walking = true;
+ b.onStrand = target.strand.id;
+ b.dir = target.strand.a === target.node.id ? 1 : -1;
+ b.t = target.strand.a === target.node.id ? 0 : 1;
+ if (events) events.push({ type: 'climb', ball: b.id });
+ }
+}
+
+function advanceWalkers(state, dt, events) {
+ const p = state.pipe;
+ for (const b of state.balls) {
+ if (!b.walking || b.dead) continue;
+ let s = strandById(state, b.onStrand);
+ if (!s || s.broken) { dropWalker(state, b); continue; }
+
+ let [a, c] = strandEndpoints(state, s);
+ if (!a || !c || a.dead || c.dead) { dropWalker(state, b); continue; }
+
+ const len = Math.hypot(c.x - a.x, c.y - a.y) || 1;
+ b.t += ((TUNING.CRAWL_SPEED * dt) / len) * b.dir;
+
+ if (b.t < 0 || b.t > 1) {
+ const nodeId = b.t < 0 ? s.a : s.b;
+ const node = state.balls[nodeId];
+ if (!node || node.dead) { dropWalker(state, b); continue; }
+
+ // Arrived at the pipe?
+ if (p && p.open && Math.hypot(node.x - p.x, node.y - p.y) <= p.r + node.r) {
+ b.x = node.x; b.y = node.y;
+ collectBall(state, b, events);
+ continue;
+ }
+
+ const opts = node.strands
+ .map((id) => strandById(state, id))
+ .filter((o) => o && !o.broken);
+ if (!opts.length) { dropWalker(state, b); continue; }
+
+ let next = null;
+ if (p && p.open && state.pipeDist) {
+ const here = state.pipeDist.get(nodeId);
+ let bestD = here == null ? Infinity : here;
+ for (const o of opts) {
+ const far = o.a === nodeId ? o.b : o.a;
+ const d = state.pipeDist.get(far);
+ if (d != null && d < bestD) { bestD = d; next = o; }
+ }
+ }
+ if (!next) {
+ // Wander, but prefer not to double straight back the way we came.
+ const fwd = opts.filter((o) => o.id !== s.id);
+ const pool = fwd.length ? fwd : opts;
+ next = pool[Math.floor(state.rng() * pool.length) % pool.length];
+ }
+
+ s = next;
+ b.onStrand = s.id;
+ b.dir = s.a === nodeId ? 1 : -1;
+ b.t = s.a === nodeId ? 0 : 1;
+ [a, c] = strandEndpoints(state, s);
+ if (!a || !c) { dropWalker(state, b); continue; }
+ }
+
+ b.x = a.x + (c.x - a.x) * b.t;
+ b.y = a.y + (c.y - a.y) * b.t;
+ b.px = b.x;
+ b.py = b.y;
+ }
+}
+
+// Loose goo resting on terrain walks toward the nearest part of the structure.
+// Re-targeting is throttled to WANDER_HZ because it is O(loose x attached) and
+// would otherwise run 240 times a second.
+function wanderLoose(state, dt) {
+ state.wanderClock += dt;
+ const period = 1 / TUNING.WANDER_HZ;
+ const retarget = state.wanderClock >= period;
+ if (retarget) state.wanderClock -= period;
+
+ for (const b of state.balls) {
+ if (b.dead || b.attached || b.walking || b.held) continue;
+ if (retarget) {
+ b.wanderDir = 0;
+ let bestD = Infinity;
+ let best = null;
+ for (const a of state.balls) {
+ if (!a.attached || a.dead) continue;
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
+ if (d < bestD) { bestD = d; best = a; }
+ }
+ // Close enough to climb (recruitWalkers takes over), or nothing to aim at.
+ if (best && bestD > b.r + best.r + 8) {
+ b.wanderDir = Math.sign(best.x - b.x);
+ }
+ }
+ if (!b.grounded || !b.wanderDir) continue;
+ const vy = b.y - b.py;
+ b.px = b.x - b.wanderDir * TUNING.WANDER_SPEED * dt;
+ b.py = b.y - vy;
+ }
+}
+
+function updateAgents(state, dt, events) {
+ if (state.agentsOff) return;
+ updatePipe(state, events);
+ if (state.pipeDistDirty || state.dirty) refreshPipeDist(state);
+ wanderLoose(state, dt);
+ recruitWalkers(state, events);
+ advanceWalkers(state, dt, events);
+}
+
+// ── Win conditions ──────────────────────────────────────────────────────────
+
+export function isWon(state) {
+ return state.collected >= state.required;
+}
+
+export function hasOCD(state) {
+ return state.collected >= state.ocdTarget;
+}
+
+// Balls the player could still get into the pipe: loose goo plus crawlers.
+export function availableGoo(state) {
+ let n = 0;
+ for (const b of state.balls) {
+ if (b.dead || b.attached) continue;
+ n += 1;
+ }
+ return n;
+}
+
+// ── Introspection helpers (used heavily by the verifier) ────────────────────
+
+// 0 = slack, 1 = about to snap. The renderer reddens a strand by this.
+export function strandStress(strand) {
+ const cap = TUNING.BREAK_FORCE * strand.strength;
+ if (cap <= 0) return 0;
+ const v = strand.load / cap;
+ return v < 0 ? 0 : v > 1 ? 1 : v;
+}
+
+export function strandLength(state, strand) {
+ const a = state.balls[strand.a];
+ const b = state.balls[strand.b];
+ return Math.hypot(b.x - a.x, b.y - a.y);
+}
+
+export function ballSpeed(state, ball) {
+ return Math.hypot(ball.x - ball.px, ball.y - ball.py) / TUNING.SUBSTEP_DT;
+}
+
+export function isSettled(state) {
+ for (const b of state.balls) {
+ if (b.dead || b.invMass === 0) continue;
+ if (ballSpeed(state, b) > TUNING.SETTLE_SPEED) return false;
+ }
+ return true;
+}
+
+// Run until the structure is genuinely quiet.
+//
+// isSettled() is instantaneous, and an oscillating structure passes through
+// zero speed at every turning point of its swing -- so a naive
+// "step until isSettled" stops at the moment of MAXIMUM stretch and reports
+// the peak of a decaying oscillation as if it were the static equilibrium.
+// Require the state to stay quiet for a sustained window instead.
+export function settle(state, maxSeconds = 8, quietSeconds = 0.25) {
+ const step = 1 / 60;
+ let t = 0;
+ let quiet = 0;
+ while (t < maxSeconds) {
+ stepSim(state, step);
+ t += step;
+ quiet = isSettled(state) ? quiet + step : 0;
+ if (quiet >= quietSeconds) break;
+ }
+ return t;
+}
+
+// Settle with the agents switched off: no wandering, no crawling, no pipe.
+//
+// This is what "put the level at rest" means, and it is what the editor's
+// Settle button must use before exporting. Settling with agents ON does
+// something quite different and quite wrong: loose goo walks to the structure
+// and climbs it, so the positions you capture have the heap baked INSIDE the
+// tower, and reloading that level explodes as the collision solver ejects it.
+export function settlePhysics(state, maxSeconds = 12) {
+ const was = state.agentsOff;
+ state.agentsOff = true;
+ const t = settle(state, maxSeconds);
+ state.agentsOff = was;
+ return t;
+}
+
+export function cloneState(state) {
+ const copy = {
+ ...state,
+ terrain: state.terrain.map((t) => ({
+ kind: t.kind,
+ poly: t.poly.map((p) => [p[0], p[1]]),
+ bounds: { ...t.bounds },
+ destructible: t.destructible,
+ gear: t.gear ? { ...t.gear } : undefined,
+ })),
+ fans: state.fans.map((f) => ({ ...f })),
+ balls: state.balls.map((b) => ({ ...b, strands: b.strands.slice() })),
+ strands: state.strands.map((s) => ({ ...s })),
+ pipe: state.pipe ? { ...state.pipe } : null,
+ pile: state.pile.slice(),
+ components: null,
+ rootedComponents: null,
+ dirty: true,
+ pipeDist: new Map(),
+ pipeDistDirty: true,
+ grid: null,
+ rng: mulberry32(state.seed),
+ };
+ refreshStructure(copy);
+ return copy;
+}
+
+// Bit-exact fingerprint of every simulated position, for determinism tests.
+export function hashState(state) {
+ const buf = new ArrayBuffer(8);
+ const f64 = new Float64Array(buf);
+ const u32 = new Uint32Array(buf);
+ let h = 0x811c9dc5;
+ const mix = (v) => {
+ f64[0] = v;
+ h ^= u32[0]; h = Math.imul(h, 0x01000193);
+ h ^= u32[1]; h = Math.imul(h, 0x01000193);
+ };
+ for (const b of state.balls) {
+ mix(b.x); mix(b.y); mix(b.px); mix(b.py);
+ mix(b.dead ? 1 : 0);
+ mix(b.attached ? 1 : 0);
+ mix(b.walking ? b.onStrand + b.t : -1);
+ }
+ for (const s of state.strands) mix(s.broken ? 1 : 0);
+ mix(state.collected);
+ return h >>> 0;
+}
diff --git a/src/games/gootower/tutorial.md b/src/games/gootower/tutorial.md
new file mode 100644
index 0000000..be4fdbd
--- /dev/null
+++ b/src/games/gootower/tutorial.md
@@ -0,0 +1,68 @@
+# Goo Tower
+
+Build a structure out of goo balls, reach the pipe, and get enough goo into it.
+
+## Building
+
+**Drag a goo ball** from the heap and drop it near your structure. It grabs the nearest
+balls it can reach and hangs on by stretchy strands.
+
+A ball needs **two** friends within reach to hold on — a single strand isn't enough. While
+you're dragging, green lines show exactly which strands would form. Red means the drop
+won't take.
+
+Two balls **almost in line** with each other don't count as separate anchors. That's
+deliberate: it's what forces you to build **triangles**, and triangles are the only reason
+a structure stands up at all.
+
+## Physics is doing the work
+
+Nothing here is on rails. Strands stretch under load and **redden as they strain** — when
+one is glowing, it's about to go. Towers sway. Long unbraced arms sag and tear off at the
+root.
+
+- **Compression is cheap, tension is expensive.** A tower standing on the ground mostly
+ pushes down and holds fine. The same structure stuck out sideways pulls its own root
+ apart.
+- **Brace every level.** An unbraced column will lean, and once it leans it keeps leaning.
+- **Weight is cumulative.** The strands at the bottom carry everything above them.
+
+## The kinds of goo
+
+| Goo | What it does |
+|---|---|
+| **Grey** | The standard. Two strands, permanent once placed. |
+| **Green (ivy)** | Can be pulled back off and used again. |
+| **Pink (balloon)** | Rises. One strand. Worth about one goo of lift — tie them to sagging spans. |
+| **Red (bomb)** | Detonates in fire. Kills goo close in, shears strands wider out, and opens **cross-hatched rock**. |
+| **Brown (block)** | Much stiffer. Holds a span rigid where grey goo would droop. |
+| **Amber (pokey)** and **anchor** | Grip bare rock. They need **no** friends at all — place one against a wall and build out from there. |
+| **Blue (bit)** | Small, light, cheap, and weak. One strand. |
+| **Pale (skull)** | Walks on spikes unharmed. |
+
+Goo with a **gold collar** is pinned and will never move.
+
+## Hazards
+
+- **Spikes** and **fire** kill on contact. While dragging, an orange ring and a
+ warning label mean the drop is *legal but fatal*.
+- **Fans** blow. Inside the wind everything weighs less, so spans reach further.
+- **Cogs** are solid and turning. Whatever touches the rim gets dragged along with it.
+- Anything that falls off the world is gone.
+
+## The pipe
+
+The pipe opens once any part of your structure touches it. Loose goo then **crawls up the
+strands by itself** and gets drunk. You don't have to carry it there.
+
+You can pick loose goo back off the structure at any time, and **green ivy goo** can be
+pulled off after it's been placed and used somewhere else. Plain grey goo is permanent
+once it's down.
+
+## Finishing
+
+- **Collect** the required number of goo to complete the level.
+- **OCD** is a separate, higher target for collecting far more than you needed. Reaching
+ the required count doesn't end the level — hit *Keep Playing* and go for it.
+
+Press **R** to retry, **Esc** for the level list.
diff --git a/src/main.js b/src/main.js
index a07adaa..268f9be 100644
--- a/src/main.js
+++ b/src/main.js
@@ -99,6 +99,8 @@ import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js';
import TetrisAttackGame from './games/tetrisattack/TetrisAttackGame.js';
import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGame.js';
import BloxorzGame from './games/bloxorz/BloxorzGame.js';
+import GooTowerGame from './games/gootower/GooTowerGame.js';
+import GooTowerEditor from './games/gootower/GooTowerEditor.js';
const config = {
type: Phaser.AUTO,
@@ -211,6 +213,8 @@ const config = {
TetrisAttackGame,
TotalAnnihilationGame,
BloxorzGame,
+ GooTowerGame,
+ GooTowerEditor,
],
};
diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js
index cba8f79..3324e42 100644
--- a/src/scenes/GameRoomScene.js
+++ b/src/scenes/GameRoomScene.js
@@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
- const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame' };
+ const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {
diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js
index 40131d7..ff88159 100644
--- a/src/scenes/PreloadScene.js
+++ b/src/scenes/PreloadScene.js
@@ -53,6 +53,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('rushhour', 'data/rushhour.json');
this.load.json('bloxorz', 'data/bloxorz.json');
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
+ this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json');
this.load.json('shift-artwork', 'data/shift-artwork.json');
this.load.json('slots-artwork', 'data/slots-artwork.json');
this.load.json('blockfighter', 'data/blockfighter.json');
@@ -227,6 +228,13 @@ export default class PreloadScene extends Phaser.Scene {
return;
}
+ // ...and the Goo Tower level editor: ?gootower-editor=1.
+ if (params.has('gootower-editor')) {
+ window.history.replaceState(null, '', window.location.pathname + window.location.hash);
+ this.scene.start('GooTowerEditor');
+ return;
+ }
+
// Deep link: index.html?game= jumps straight to the same place a
// main-menu click would (GameMenuScene.openGame's branching logic).
const deepLinkGame = getGame(params.get('game'));
diff --git a/tools/genGooTower.js b/tools/genGooTower.js
new file mode 100644
index 0000000..43fe3d2
--- /dev/null
+++ b/tools/genGooTower.js
@@ -0,0 +1,304 @@
+// Goo Tower — level bank writer.
+//
+// Wave 1: the six hand-designed teaching levels below are authored here and
+// emitted verbatim. They are HAND-TUNED, not generated -- the script exists so
+// the repetitive parts (goo piles, spike strips, the manifest) are constructed
+// rather than typed, and so the bank is byte-reproducible.
+//
+// Wave 4 will add generated drill/sandbox tiers alongside these; when it does,
+// levels 1-6 must be carried over untouched, exactly as genPeggleLevels.js
+// freezes Peggle's hand-tuned 1-25.
+//
+// Usage: node tools/genGooTower.js [outDir]
+
+import { writeFileSync, mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const outDir = process.argv[2] || join(__dirname, '..', 'assets', 'gamedata', 'gootower');
+
+const W = 1600;
+const H = 1000;
+const GROUND = 850;
+
+const rect = (x1, y1, x2, y2, kind = 'solid') => ({ kind, poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] });
+const ground = (top = GROUND) => rect(0, top, W, H);
+
+// A loose heap of goo resting on a surface. Laid out in rows so the balls start
+// apart -- dumping them on one spot just makes the collision solver do work.
+function pile(type, n, x, surfaceY, { perRow = 8, gap = 30 } = {}) {
+ const out = [];
+ for (let i = 0; i < n; i += 1) {
+ const row = Math.floor(i / perRow);
+ const col = i % perRow;
+ out.push({ x: x + col * gap, y: surfaceY - 16 - row * 30, type });
+ }
+ return out;
+}
+
+// A small planted tripod: two pinned feet and a free apex, already triangulated
+// so the player has something legal to attach to on frame one.
+function footing(x, y, type = 'common') {
+ return {
+ balls: [
+ { x: x - 31, y, type, pinned: true },
+ { x: x + 31, y, type, pinned: true },
+ { x, y: y - 54, type },
+ ],
+ strands: [[2, 0], [2, 1], [0, 1]],
+ };
+}
+
+const LEVELS = [];
+
+// ── 1 ───────────────────────────────────────────────────────────────────────
+{
+ const f = footing(771, GROUND - 14);
+ LEVELS.push({
+ level: 1, name: 'Ground Floor', chapter: 1,
+ tip: 'Drag goo from the heap onto the structure. Each ball needs two nearby friends to hold on to.',
+ terrain: [ground()],
+ ...f,
+ pipe: { x: 771, y: 600, r: 46 },
+ pile: pile('common', 16, 980, GROUND),
+ required: 4, ocdTarget: 9,
+ });
+}
+
+// ── 2 ───────────────────────────────────────────────────────────────────────
+{
+ const f = footing(430, 686);
+ LEVELS.push({
+ level: 2, name: 'Mind the Gap', chapter: 1,
+ tip: 'Spikes below. Build ACROSS — triangles carry weight, long chains do not.',
+ terrain: [
+ rect(0, 700, 600, H),
+ rect(860, 700, W, H),
+ rect(600, 930, 860, H, 'spike'),
+ // Lip on the outer rim. Without it the heap spreads left, rolls off the
+ // edge of the world and the level quietly loses goo before the player
+ // has touched anything.
+ rect(0, 620, 40, 700),
+ ],
+ ...f,
+ pipe: { x: 980, y: 640, r: 46 },
+ // perRow 7 keeps the heap clear of the footing at x=430; at 10 the last
+ // column landed 9px from a foot and the two were ejected apart on frame one.
+ pile: pile('common', 20, 150, 700, { perRow: 7 }),
+ required: 5, ocdTarget: 12,
+ });
+}
+
+// ── 3 ───────────────────────────────────────────────────────────────────────
+{
+ // The footing sits UNDER the overhang, so the only way up is to walk the
+ // structure out sideways first. Keep the run short -- this is level 3, and
+ // an earlier draft asked for a 677px diagonal that nothing could build.
+ const f = footing(560, GROUND - 14);
+ LEVELS.push({
+ level: 3, name: 'Headroom', chapter: 1,
+ tip: 'You cannot build through the slab. Come out from under it first.',
+ terrain: [ground(), rect(300, 540, 820, 610)],
+ ...f,
+ pipe: { x: 980, y: 470, r: 46 },
+ pile: pile('common', 24, 1020, GROUND, { perRow: 8 }),
+ required: 5, ocdTarget: 12,
+ });
+}
+
+// ── 4 ───────────────────────────────────────────────────────────────────────
+{
+ const f = footing(331, GROUND - 14, 'common');
+ LEVELS.push({
+ level: 4, name: 'Second Thoughts', chapter: 1,
+ tip: 'Green ivy goo can be pulled back off and used again. Grey goo is there for good.',
+ terrain: [ground()],
+ ...f,
+ // The heap must comfortably outlast the climb. An earlier draft gave
+ // exactly enough goo to REACH the pipe and nothing to feed it, so the
+ // level opened the pipe and was then unwinnable.
+ pipe: { x: 660, y: 560, r: 46 },
+ pile: [
+ ...pile('ivy', 12, 540, GROUND, { perRow: 6 }),
+ ...pile('common', 8, 860, GROUND, { perRow: 8 }),
+ ],
+ required: 4, ocdTarget: 9,
+ });
+}
+
+// ── 5 ───────────────────────────────────────────────────────────────────────
+{
+ const f = footing(791, GROUND - 14);
+ LEVELS.push({
+ level: 5, name: 'Overhead', chapter: 1,
+ tip: 'The ceiling bites. Stop short of it.',
+ terrain: [ground(), rect(400, 180, 1200, 240, 'spike')],
+ ...f,
+ pipe: { x: 791, y: 340, r: 46 },
+ pile: pile('common', 18, 1000, GROUND, { perRow: 9 }),
+ required: 5, ocdTarget: 11,
+ });
+}
+
+// ── 6 ───────────────────────────────────────────────────────────────────────
+{
+ const f = footing(791, GROUND - 14);
+ LEVELS.push({
+ level: 6, name: 'Tall Order', chapter: 1,
+ tip: 'A tower stands on compression and falls on sway. Brace every level.',
+ terrain: [ground()],
+ ...f,
+ pipe: { x: 791, y: 180, r: 46 },
+ pile: pile('common', 26, 1020, GROUND, { perRow: 9 }),
+ required: 6, ocdTarget: 14,
+ });
+}
+
+// ── Sandbox tier (7-12) ─────────────────────────────────────────────────────
+// One level per Wave 3 mechanic, so each can be played and judged on its own.
+// This is THROWAWAY content, explicitly not the final curriculum -- Wave 4
+// replaces it with a designed 5-chapter ramp.
+
+// ── 7 · anchors ─────────────────────────────────────────────────────────────
+{
+ const f = footing(780, GROUND - 14);
+ LEVELS.push({
+ level: 7, name: 'Anchors Aweigh', chapter: 2, sandbox: true,
+ tip: 'Brown anchor goo sticks straight to rock and needs no friends. Start a span from a wall.',
+ // Pillars must not stand BETWEEN the heap and the structure: loose goo
+ // walks along the ground, and a 550px wall is a wall.
+ terrain: [ground(), rect(400, 300, 460, GROUND), rect(1120, 300, 1180, GROUND)],
+ ...f,
+ pipe: { x: 780, y: 400, r: 46 },
+ pile: [
+ ...pile('anchor', 6, 860, GROUND, { perRow: 6 }),
+ ...pile('common', 20, 860, GROUND - 30, { perRow: 6 }),
+ ],
+ required: 5, ocdTarget: 12,
+ });
+}
+
+// ── 8 · pokey (wall starts) ─────────────────────────────────────────────────
+{
+ LEVELS.push({
+ level: 8, name: 'Sticking Point', chapter: 2, sandbox: true,
+ tip: 'Pokey goo grips rock. There is no ground here worth building from.',
+ terrain: [ground(), rect(240, 200, 320, 760), rect(1120, 200, 1200, 760)],
+ balls: [
+ { x: 340, y: 560, type: 'pokey', pinned: true },
+ { x: 340, y: 498, type: 'pokey', pinned: true },
+ { x: 398, y: 530, type: 'common' },
+ ],
+ strands: [[2, 0], [2, 1], [0, 1]],
+ pipe: { x: 1080, y: 430, r: 46 },
+ pile: [
+ ...pile('pokey', 6, 620, GROUND, { perRow: 6 }),
+ ...pile('common', 22, 620, GROUND - 30, { perRow: 8 }),
+ ],
+ required: 5, ocdTarget: 12,
+ });
+}
+
+// ── 9 · bombs, fire and destructible rock ───────────────────────────────────
+{
+ const f = footing(400, GROUND - 14);
+ LEVELS.push({
+ level: 9, name: 'Demolition', chapter: 2, sandbox: true,
+ tip: 'Drop a bomb by the fire and the soft rock goes with it. Or take the long way over.',
+ terrain: [
+ ground(),
+ // Soft rock in the way -- blow it, or climb over. The fire sits BESIDE
+ // the rock at ground level, not on the climbing route: an earlier draft
+ // had it capping the rock, which made the bomb the only survivable way
+ // through and left the level unwinnable by ordinary building.
+ { ...rect(700, 660, 780, GROUND), destructible: true },
+ rect(600, 806, 680, GROUND, 'fire'),
+ ],
+ ...f,
+ pipe: { x: 980, y: 700, r: 46 },
+ // Greedy will not climb a wall unprompted -- that means building away from
+ // the pipe first -- so route it over the top.
+ waypoints: [{ x: 740, y: 590 }],
+ pile: [
+ ...pile('bomb', 4, 160, GROUND, { perRow: 4 }),
+ ...pile('common', 24, 160, GROUND - 30, { perRow: 7 }),
+ ],
+ required: 5, ocdTarget: 12,
+ });
+}
+
+// ── 10 · fans ───────────────────────────────────────────────────────────────
+{
+ const f = footing(360, 686);
+ LEVELS.push({
+ level: 10, name: 'Updraft', chapter: 2, sandbox: true,
+ tip: 'The updraft takes weight off everything inside it. Spans reach further in the wind.',
+ terrain: [
+ rect(0, 700, 600, H),
+ rect(940, 700, W, H),
+ rect(600, 930, 940, H, 'spike'),
+ rect(0, 620, 40, 700),
+ ],
+ fans: [{ x: 600, y: 300, w: 340, h: 630, dx: 0, dy: -1, force: 1150 }],
+ ...f,
+ pipe: { x: 1060, y: 630, r: 46 },
+ pile: pile('common', 26, 120, 700, { perRow: 6 }),
+ required: 4, ocdTarget: 12,
+ });
+}
+
+// ── 11 · gears ──────────────────────────────────────────────────────────────
+{
+ const f = footing(300, GROUND - 14);
+ LEVELS.push({
+ level: 11, name: 'Cogs', chapter: 2, sandbox: true,
+ tip: 'The cog is solid and it is turning. Anything it touches gets dragged along.',
+ terrain: [ground()],
+ gears: [{ x: 800, y: 470, r: 110, teeth: 9, omega: 1.4 }],
+ ...f,
+ pipe: { x: 1240, y: 560, r: 46 },
+ pile: pile('common', 28, 1000, GROUND, { perRow: 9 }),
+ required: 5, ocdTarget: 13,
+ });
+}
+
+// ── 12 · balloons, blocks and bits ──────────────────────────────────────────
+{
+ const f = footing(400, GROUND - 14);
+ LEVELS.push({
+ level: 12, name: 'Lighter Than Air', chapter: 2, sandbox: true,
+ tip: 'Balloons pull up, blocks hold rigid, bits are cheap and weak. Mix them.',
+ terrain: [ground()],
+ ...f,
+ pipe: { x: 400, y: 300, r: 46 },
+ pile: [
+ ...pile('balloon', 6, 700, GROUND, { perRow: 6 }),
+ ...pile('block', 8, 700, GROUND - 34, { perRow: 8 }),
+ ...pile('bit', 6, 700, GROUND - 68, { perRow: 6 }),
+ ...pile('common', 18, 700, GROUND - 100, { perRow: 9 }),
+ ],
+ required: 5, ocdTarget: 12,
+ });
+}
+
+mkdirSync(outDir, { recursive: true });
+
+const manifest = { version: 1, generatedAt: new Date(0).toISOString(), levels: [] };
+for (const lv of LEVELS) {
+ const file = `level-${String(lv.level).padStart(3, '0')}.json`;
+ const body = { world: { w: W, h: H }, ...lv };
+ writeFileSync(join(outDir, file), `${JSON.stringify(body, null, 2)}\n`);
+ manifest.levels.push({
+ level: lv.level, name: lv.name, chapter: lv.chapter, file,
+ required: lv.required, ocdTarget: lv.ocdTarget,
+ sandbox: lv.sandbox || undefined,
+ });
+}
+manifest.chapters = [
+ { id: 1, name: 'The Goo Filled Hills', blurb: 'Learn to build.', from: 1, to: 6 },
+ { id: 2, name: 'Sandbox', blurb: 'One level per mechanic — throwaway, replaced in Wave 4.', from: 7, to: LEVELS.length },
+];
+writeFileSync(join(outDir, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`);
+
+console.log(`[gen] wrote ${LEVELS.length} levels + manifest to ${outDir}`);
diff --git a/tools/verifyGooTower.js b/tools/verifyGooTower.js
new file mode 100644
index 0000000..b689149
--- /dev/null
+++ b/tools/verifyGooTower.js
@@ -0,0 +1,1173 @@
+// Goo Tower — regression harness. Run after every change to the sim.
+//
+// The physics is the game. A subtly wrong solver produces a structure that
+// looks plausible and is unbuildable, and there is no way to tell by reading
+// the code — so everything the sim promises gets asserted here.
+//
+// 1. Geometry primitives (point-in-poly, closest point, segment crossing,
+// line-of-sight blocking)
+// 2. Solver invariants (chain hangs at rest length, truss beats chain,
+// strands snap past BREAK_RATIO, orphaned chunks stop being rooted)
+// 3. Terrain (balls rest ON the ground, never inside it; slopes; friction)
+// 4. Hazards (spikes kill, spikeProof survives, falling out of the world)
+// 5. Stability (no NaN, no launch, stiff lattices stay bounded)
+// 6. Determinism (same seed replays bit-identically; frame-rate independent)
+//
+// Usage: node tools/verifyGooTower.js
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ TUNING, GOO_TYPES, gooType, mulberry32,
+ pointInPoly, closestPointOnPoly, segmentsIntersect, lineBlocked,
+ createState, addBall, addStrand, breakStrand, strandById, ballById,
+ stepSim, settle, settlePhysics, isSettled, refreshStructure, isRooted,
+ strandLength, strandStress, ballSpeed, cloneState, hashState, liveStrands,
+ chooseAttachments, canPlace, placeBall, pickBallAt, beginDrag, dragTo, endDrag,
+ insideSolid, isWon, hasOCD, availableGoo,
+ distanceToSolid, canStickToTerrain, requiredStrands, ignite, detonate,
+} from '../src/games/gootower/GooTowerLogic.js';
+import { autoPlay } from '../src/games/gootower/GooTowerAuto.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+let passes = 0;
+let failures = 0;
+function check(name, cond, detail = '') {
+ if (cond) { passes += 1; console.log(` ok ${name}`); }
+ else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); }
+}
+function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 60 - title.length))}`); }
+
+const GROUND_Y = 800;
+const ground = () => ({ kind: 'solid', poly: [[0, GROUND_Y], [1600, GROUND_Y], [1600, 1000], [0, 1000]] });
+const baseLevel = (over = {}) => ({
+ world: { w: 1600, h: 1000 },
+ terrain: [ground()],
+ balls: [], strands: [], pile: [],
+ required: 1, ocdTarget: 1,
+ ...over,
+});
+
+// ── 1. Geometry ─────────────────────────────────────────────────────────────
+section('1. Geometry primitives');
+{
+ const sq = [[0, 0], [100, 0], [100, 100], [0, 100]];
+ check('pointInPoly centre', pointInPoly(50, 50, sq));
+ check('pointInPoly outside', !pointInPoly(150, 50, sq));
+ check('pointInPoly outside (above)', !pointInPoly(50, -10, sq));
+
+ const cp = closestPointOnPoly(50, 20, sq);
+ check('closestPointOnPoly picks nearest edge', Math.abs(cp.y - 0) < 1e-9 && Math.abs(cp.x - 50) < 1e-9,
+ `got ${cp.x},${cp.y}`);
+ const cp2 = closestPointOnPoly(-30, 50, sq);
+ check('closestPointOnPoly from outside', Math.abs(cp2.x) < 1e-9 && Math.abs(cp2.d2 - 900) < 1e-6,
+ `got ${cp2.x},${cp2.y} d2=${cp2.d2}`);
+
+ check('segmentsIntersect crossing', segmentsIntersect(0, 0, 10, 10, 0, 10, 10, 0));
+ check('segmentsIntersect parallel', !segmentsIntersect(0, 0, 10, 0, 0, 5, 10, 5));
+ check('segmentsIntersect disjoint', !segmentsIntersect(0, 0, 1, 1, 5, 5, 6, 6));
+}
+{
+ const st = createState(baseLevel({
+ terrain: [ground(), { kind: 'solid', poly: [[400, 300], [420, 300], [420, 600], [400, 600]] }],
+ }));
+ check('lineBlocked through a wall', lineBlocked(st, 300, 450, 500, 450));
+ check('lineBlocked clear path', !lineBlocked(st, 300, 200, 500, 200));
+ check('lineBlocked ignores non-solid', !lineBlocked(
+ createState(baseLevel({ terrain: [{ kind: 'spike', poly: [[400, 300], [420, 300], [420, 600], [400, 600]] }] })),
+ 300, 450, 500, 450));
+}
+
+// ── 2. Solver invariants ────────────────────────────────────────────────────
+section('2. Solver invariants');
+{
+ // A chain hung from a pinned node must settle near its rest length, not
+ // stretch away to nothing and not collapse to zero.
+ const N = 6;
+ const balls = [{ x: 400, y: 200, type: 'common', pinned: true }];
+ for (let i = 1; i <= N; i += 1) balls.push({ x: 400, y: 200 + i * TUNING.STRAND_REST, type: 'common' });
+ const strands = [];
+ for (let i = 0; i < N; i += 1) strands.push([i, i + 1]);
+ const st = createState(baseLevel({ balls, strands }));
+ settle(st, 12);
+
+ check('hanging chain settles', isSettled(st));
+ let allInBand = true;
+ let worst = 0;
+ for (const s of liveStrands(st)) {
+ const ratio = strandLength(st, s) / s.rest;
+ worst = Math.max(worst, ratio);
+ if (ratio < 0.9 || ratio > TUNING.BREAK_RATIO) allInBand = false;
+ }
+ check('chain strands hang between 0.9x and break ratio', allInBand, `worst ratio ${worst.toFixed(3)}`);
+ check('chain does not snap under its own weight', liveStrands(st).length === N,
+ `${liveStrands(st).length}/${N} survived`);
+ check('pinned node never moves', st.balls[0].x === 400 && st.balls[0].y === 200);
+ check('chain hangs downward', st.balls[N].y > st.balls[0].y + N * TUNING.STRAND_REST * 0.8);
+}
+{
+ // THE core claim of the whole design: rigidity is emergent from
+ // triangulation. A triangulated cantilever must sag dramatically less than
+ // an untriangulated chain of the same span and mass.
+ //
+ // Proportions matter and are load-bearing in the literal sense: chord force
+ // in a cantilever goes as W*L/(2*d), so a shallow truss tears its own root
+ // strands off. This one is 3 bays at full depth, which common goo can
+ // actually carry -- the over-long case is asserted separately below.
+ const SPAN = 3;
+ const DX = TUNING.STRAND_REST;
+
+ const chainBalls = [{ x: 300, y: 300, type: 'common', pinned: true }];
+ const chainStrands = [];
+ for (let i = 1; i <= SPAN; i += 1) {
+ chainBalls.push({ x: 300 + i * DX, y: 300, type: 'common' });
+ chainStrands.push([i - 1, i]);
+ }
+ const chain = createState(baseLevel({ balls: chainBalls, strands: chainStrands }));
+ settle(chain, 12);
+ const chainSag = chain.balls[SPAN].y - 300;
+
+ // Same span, two rows, fully triangulated.
+ const H = 62;
+ const trussBalls = [];
+ const idx = (row, col) => row * (SPAN + 1) + col;
+ for (let row = 0; row < 2; row += 1) {
+ for (let col = 0; col <= SPAN; col += 1) {
+ trussBalls.push({ x: 300 + col * DX, y: 300 - row * H, type: 'common', pinned: col === 0 });
+ }
+ }
+ const trussStrands = [];
+ for (let col = 0; col < SPAN; col += 1) {
+ trussStrands.push([idx(0, col), idx(0, col + 1)]); // bottom chord
+ trussStrands.push([idx(1, col), idx(1, col + 1)]); // top chord
+ trussStrands.push([idx(0, col), idx(1, col + 1)]); // diagonal
+ trussStrands.push([idx(1, col), idx(0, col + 1)]); // counter-diagonal
+ }
+ for (let col = 0; col <= SPAN; col += 1) trussStrands.push([idx(0, col), idx(1, col)]);
+ const truss = createState(baseLevel({ balls: trussBalls, strands: trussStrands }));
+ settle(truss, 12);
+ const trussSag = truss.balls[idx(0, SPAN)].y - 300;
+
+ check('triangulated truss sags far less than a bare chain',
+ trussSag < chainSag * 0.5,
+ `truss ${trussSag.toFixed(1)}px vs chain ${chainSag.toFixed(1)}px`);
+ check('truss holds itself up', trussSag < DX,
+ `sag ${trussSag.toFixed(1)}px`);
+ check('truss keeps all its strands', liveStrands(truss).length === trussStrands.length,
+ `${liveStrands(truss).length}/${trussStrands.length}`);
+}
+{
+ // ...and the converse, which is just as important for the game to have any
+ // drama in it: overreach and the structure tears itself apart. A cantilever
+ // long enough to drive root chord force past the strand rating MUST fail.
+ const SPAN = 8, DX = TUNING.STRAND_REST, H = 54;
+ const balls = [];
+ const idx = (r, c) => r * (SPAN + 1) + c;
+ for (let r = 0; r < 2; r += 1)
+ for (let c = 0; c <= SPAN; c += 1)
+ balls.push({ x: 200 + c * DX, y: 300 - r * H, type: 'common', pinned: c === 0 });
+ const strands = [];
+ for (let c = 0; c < SPAN; c += 1) {
+ strands.push([idx(0, c), idx(0, c + 1)]);
+ strands.push([idx(1, c), idx(1, c + 1)]);
+ strands.push([idx(0, c), idx(1, c + 1)]);
+ strands.push([idx(1, c), idx(0, c + 1)]);
+ }
+ for (let c = 0; c <= SPAN; c += 1) strands.push([idx(0, c), idx(1, c)]);
+ const st = createState(baseLevel({ balls, strands }));
+ settle(st, 14);
+ check('an over-long cantilever collapses', liveStrands(st).length < strands.length,
+ `${liveStrands(st).length}/${strands.length} survived`);
+}
+{
+ // Tension must be the real, physical constraint force -- the whole break
+ // model is calibrated in ball-weights, so if this drifts, every level's
+ // difficulty drifts with it.
+ const weight = 1500; // GOO_TYPES.common.mass * TUNING.GRAVITY
+ const readings = [];
+ for (const n of [1, 2, 4]) {
+ const balls = [{ x: 400, y: 100, type: 'common', pinned: true }];
+ const strands = [];
+ for (let i = 1; i <= n; i += 1) {
+ balls.push({ x: 400, y: 100 + i * TUNING.STRAND_REST, type: 'common' });
+ strands.push([i - 1, i]);
+ }
+ const st = createState(baseLevel({ terrain: [], balls, strands }));
+ settle(st, 25);
+ readings.push(st.strands[0].tension / weight);
+ }
+ check('strand tension reads true static load in ball-weights',
+ readings.every((v, i) => Math.abs(v - (i === 0 ? 1 : i === 1 ? 2 : 4)) < 0.2),
+ readings.map((v) => v.toFixed(2)).join(', '));
+ check('strandStress is 0..1 and rises with load',
+ (() => {
+ const st = createState(baseLevel({
+ terrain: [],
+ balls: [{ x: 400, y: 100, type: 'common', pinned: true }, { x: 400, y: 162, type: 'common' }],
+ strands: [[0, 1]],
+ }));
+ settle(st, 20);
+ const s = strandStress(st.strands[0]);
+ return s > 0 && s < 1;
+ })());
+}
+{
+ // A single-frame numerical spike must not snap a strand; sustained overload
+ // must. This is the LOAD_TAU fatigue window.
+ const mk = () => createState(baseLevel({
+ terrain: [],
+ balls: [{ x: 400, y: 100, type: 'common', pinned: true }, { x: 400, y: 162, type: 'common' }],
+ strands: [[0, 1]],
+ }));
+ // Drive these through real positions, not by poking `tension`: substep()
+ // recomputes tension from lambda before applyStress ever reads it, so an
+ // injected value is overwritten and the test would prove nothing.
+ const k = 1 / TUNING.COMPLIANCE; // strand stiffness
+ const cap = TUNING.BREAK_FORCE; // common strand, strength 1
+ const stretchFor = (force) => force / k; // Hooke
+ const restLen = mk().strands[0].rest;
+
+ // Modest overload for a single substep: over cap, under SHOCK_FACTOR * cap.
+ const spike = mk();
+ spike.balls[1].y = spike.balls[0].y + restLen + stretchFor(cap * 1.6);
+ spike.balls[1].py = spike.balls[1].y;
+ stepSim(spike, TUNING.SUBSTEP_DT);
+ check('a one-frame spike below SHOCK_FACTOR does not snap a strand',
+ !spike.strands[0].broken, `stress ${strandStress(spike.strands[0]).toFixed(2)}`);
+
+ // Same strand, same single substep, but genuinely violent.
+ const shock = mk();
+ const violent = stretchFor(cap * (TUNING.SHOCK_FACTOR + 1));
+ check('the shock test stays under the length backstop', violent < restLen * (TUNING.BREAK_RATIO - 1),
+ `${violent.toFixed(1)}px vs ${(restLen * (TUNING.BREAK_RATIO - 1)).toFixed(1)}px`);
+ shock.balls[1].y = shock.balls[0].y + restLen + violent;
+ shock.balls[1].py = shock.balls[1].y;
+ stepSim(shock, TUNING.SUBSTEP_DT);
+ check('a violent shock cuts straight through the fatigue window', shock.strands[0].broken);
+
+ // And sustained-but-moderate overload eventually fatigues through.
+ const fatigue = mk();
+ fatigue.balls[1].y = fatigue.balls[0].y + restLen + stretchFor(cap * 1.6);
+ for (let i = 0; i < 200 && !fatigue.strands[0].broken; i += 1) {
+ fatigue.balls[1].y = fatigue.balls[0].y + restLen + stretchFor(cap * 1.6);
+ fatigue.balls[1].py = fatigue.balls[1].y;
+ stepSim(fatigue, TUNING.SUBSTEP_DT);
+ }
+ check('sustained overload fatigues a strand through', fatigue.strands[0].broken);
+}
+{
+ // A strand stretched past BREAK_RATIO must snap.
+ const st = createState(baseLevel({
+ balls: [{ x: 400, y: 200, type: 'common', pinned: true }, { x: 400, y: 260, type: 'common' }],
+ strands: [[0, 1]],
+ }));
+ const s = st.strands[0];
+ check('strand starts intact', !s.broken);
+ st.balls[1].y = 200 + s.rest * (TUNING.BREAK_RATIO + 0.3);
+ st.balls[1].py = st.balls[1].y;
+ stepSim(st, 1 / 60);
+ check('an extreme stretch snaps a strand', s.broken, `len/rest = ${(strandLength(st, s) / s.rest).toFixed(2)}`);
+}
+{
+ // Slack strands pull but never push.
+ const st = createState(baseLevel({
+ balls: [{ x: 400, y: 200, type: 'common', pinned: true }, { x: 400, y: 210, type: 'common', pinned: true }],
+ strands: [[0, 1]],
+ }));
+ const s = st.strands[0];
+ check('short strand is treated as slack', strandLength(st, s) < s.rest * TUNING.SLACK_RATIO,
+ `len ${strandLength(st, s).toFixed(1)} rest ${s.rest.toFixed(1)}`);
+}
+{
+ // Cut a structure and the orphaned half must stop counting as rooted.
+ const st = createState(baseLevel({
+ balls: [
+ { x: 400, y: 200, type: 'common', pinned: true },
+ { x: 400, y: 262, type: 'common' },
+ { x: 400, y: 324, type: 'common' },
+ ],
+ strands: [[0, 1], [1, 2]],
+ }));
+ refreshStructure(st);
+ check('whole structure is rooted via its anchor', isRooted(st, 2));
+ breakStrand(st, st.strands[0], null);
+ refreshStructure(st);
+ check('orphaned chunk is no longer rooted', !isRooted(st, 2));
+ check('orphaned chunk stays one component (chunks fall as chunks)',
+ st.components.get(1) === st.components.get(2));
+ const before = st.balls[2].y;
+ settle(st, 4);
+ check('orphaned chunk falls', st.balls[2].y > before + 50, `moved ${(st.balls[2].y - before).toFixed(1)}px`);
+}
+
+// ── 3. Terrain ──────────────────────────────────────────────────────────────
+section('3. Terrain collision');
+{
+ const st = createState(baseLevel({ balls: [{ x: 400, y: 300, type: 'common' }] }));
+ settle(st, 8);
+ const b = st.balls[0];
+ check('ball rests on the ground surface', Math.abs(b.y - (GROUND_Y - b.r)) < 2.5,
+ `y=${b.y.toFixed(2)} expected ${(GROUND_Y - b.r).toFixed(2)}`);
+ check('ball is never inside solid terrain', !pointInPoly(b.x, b.y, st.terrain[0].poly));
+ check('resting ball is marked grounded', b.grounded);
+ check('resting ball counts as settled', isSettled(st));
+}
+{
+ // Dropped into the middle of a solid block, a ball must be ejected, not stuck.
+ const st = createState(baseLevel({
+ terrain: [ground(), { kind: 'solid', poly: [[300, 400], [700, 400], [700, 600], [300, 600]] }],
+ balls: [{ x: 500, y: 500, type: 'common' }],
+ }));
+ stepSim(st, 1 / 60);
+ const b = st.balls[0];
+ check('ball ejected from inside a block', !pointInPoly(b.x, b.y, st.terrain[1].poly),
+ `at ${b.x.toFixed(1)},${b.y.toFixed(1)}`);
+}
+{
+ // A ball on a slope slides downhill rather than sticking or sinking.
+ const st = createState(baseLevel({
+ terrain: [{ kind: 'solid', poly: [[0, 400], [1600, 800], [1600, 1000], [0, 1000]] }],
+ balls: [{ x: 400, y: 300, type: 'common' }],
+ }));
+ const x0 = st.balls[0].x;
+ settle(st, 10);
+ check('ball slides down a slope', st.balls[0].x > x0 + 10, `moved ${(st.balls[0].x - x0).toFixed(1)}px`);
+ check('ball stays on top of the slope', !pointInPoly(st.balls[0].x, st.balls[0].y, st.terrain[0].poly));
+}
+
+section('3b. Ball-ball collision');
+{
+ // Loose goo must pile, not merge into a single point.
+ const pile = [];
+ for (let i = 0; i < 12; i += 1) pile.push({ x: 400 + (i % 3) * 4, y: 300 - i * 6, type: 'common' });
+ const st = createState(baseLevel({ pile }));
+ settle(st, 14);
+
+ let minGap = Infinity;
+ for (let i = 0; i < st.balls.length; i += 1) {
+ for (let j = i + 1; j < st.balls.length; j += 1) {
+ const a = st.balls[i], b = st.balls[j];
+ minGap = Math.min(minGap, Math.hypot(b.x - a.x, b.y - a.y) - (a.r + b.r));
+ }
+ }
+ check('loose goo piles instead of collapsing to a point', minGap > -2.5,
+ `closest overlap ${minGap.toFixed(2)}px`);
+ check('nothing in the pile is inside the terrain',
+ st.balls.every((b) => !pointInPoly(b.x, b.y, st.terrain[0].poly)));
+ // On an open floor, spreading into a single layer is the CORRECT outcome --
+ // round balls on flat ground roll apart. Stacking only has to happen when
+ // something contains them, so test that in a pit.
+ const spread = Math.max(...st.balls.map((b) => b.x)) - Math.min(...st.balls.map((b) => b.x));
+ check('goo on an open floor spreads out', spread > 100, `spread ${spread.toFixed(0)}px`);
+}
+{
+ const pit = [
+ ground(),
+ { kind: 'solid', poly: [[330, 600], [350, 600], [350, GROUND_Y], [330, GROUND_Y]] },
+ { kind: 'solid', poly: [[470, 600], [490, 600], [490, GROUND_Y], [470, GROUND_Y]] },
+ ];
+ const pile = [];
+ for (let i = 0; i < 12; i += 1) pile.push({ x: 370 + (i % 3) * 30, y: 560 - i * 8, type: 'common' });
+ const st = createState(baseLevel({ terrain: pit, pile }));
+ settle(st, 16);
+ const highest = Math.min(...st.balls.map((b) => b.y));
+ check('contained goo stacks up', highest < GROUND_Y - 3 * 14,
+ `highest ball at y=${highest.toFixed(0)}, floor ${GROUND_Y}`);
+ check('stacked goo does not interpenetrate', (() => {
+ for (let i = 0; i < st.balls.length; i += 1) {
+ for (let j = i + 1; j < st.balls.length; j += 1) {
+ const a = st.balls[i], b = st.balls[j];
+ if (Math.hypot(b.x - a.x, b.y - a.y) < a.r + b.r - 2.5) return false;
+ }
+ }
+ return true;
+ })());
+
+ // Two balls dropped at exactly the same spot must still separate.
+ const twin = createState(baseLevel({ pile: [{ x: 500, y: 300 }, { x: 500, y: 300 }] }));
+ settle(twin, 10);
+ const gap = Math.hypot(twin.balls[1].x - twin.balls[0].x, twin.balls[1].y - twin.balls[0].y);
+ check('coincident balls separate rather than dividing by zero',
+ Number.isFinite(gap) && gap > twin.balls[0].r, `gap ${gap.toFixed(2)}px`);
+}
+
+// ── 4. Hazards ──────────────────────────────────────────────────────────────
+section('4. Hazards');
+{
+ const spikes = { kind: 'spike', poly: [[300, 600], [700, 600], [700, 660], [300, 660]] };
+ const st = createState(baseLevel({ terrain: [ground(), spikes], balls: [{ x: 400, y: 300, type: 'common' }] }));
+ let died = null;
+ for (let i = 0; i < 240 && !died; i += 1) {
+ for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') died = e;
+ }
+ check('spikes kill common goo', !!died && died.cause === 'spike', died ? died.cause : 'survived');
+
+ const st2 = createState(baseLevel({ terrain: [ground(), spikes], balls: [{ x: 400, y: 300, type: 'skull' }] }));
+ let died2 = false;
+ for (let i = 0; i < 240; i += 1) {
+ for (const e of stepSim(st2, 1 / 60)) if (e.type === 'ballDie') died2 = true;
+ }
+ check('skull goo is spike-proof', !died2);
+ check('spike-proof flag matches the type table', GOO_TYPES.skull.spikeProof && !GOO_TYPES.common.spikeProof);
+}
+{
+ // Killing a ball must take its strands with it.
+ const st = createState(baseLevel({
+ balls: [
+ { x: 400, y: 200, type: 'common', pinned: true },
+ { x: 400, y: 262, type: 'common' },
+ { x: 400, y: 324, type: 'common' },
+ ],
+ strands: [[0, 1], [1, 2]],
+ }));
+ const spikes = { kind: 'spike', poly: [[350, 330], [450, 330], [450, 380], [350, 380]] };
+ st.terrain.push({ ...spikes, bounds: { minX: 350, minY: 330, maxX: 450, maxY: 380 } });
+ let died = false;
+ for (let i = 0; i < 240 && !died; i += 1) {
+ for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') died = true;
+ }
+ check('a dying ball drops its strands', died && liveStrands(st).length <= 1,
+ `${liveStrands(st).length} strands left`);
+}
+{
+ const st = createState(baseLevel({ terrain: [], balls: [{ x: 400, y: 300, type: 'common' }] }));
+ let cause = null;
+ for (let i = 0; i < 600 && !cause; i += 1) {
+ for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') cause = e.cause;
+ }
+ check('falling out of the world is fatal', cause === 'fell', cause || 'survived');
+}
+
+// ── 4b. Attachment rules ────────────────────────────────────────────────────
+section('4b. Attachment');
+{
+ // Two anchors 62px apart; a ball dropped above their midpoint should reach
+ // both and form a triangle.
+ const mk = () => createState(baseLevel({
+ balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common', pinned: true }],
+ pile: [{ x: 800, y: 700, type: 'common' }],
+ }));
+
+ const st = mk();
+ const picks = chooseAttachments(st, 431, 350, 'common');
+ check('a ball between two anchors attaches to both', picks.length === 2, `got ${picks.length}`);
+ check('canPlace agrees', canPlace(st, 431, 350, 'common'));
+
+ check('out of reach attaches to nothing',
+ chooseAttachments(st, 431, 100, 'common').length === 0);
+ check('canPlace refuses out of reach', !canPlace(st, 431, 100, 'common'));
+
+ // MIN_ANGLE: the rule that forces triangles. Sitting directly in line with
+ // two anchors, the far one is within MIN_ANGLE of the near one and must be
+ // rejected -- leaving one strand, which is below common goo's minStrands.
+ const inline = chooseAttachments(st, 369, 400, 'common');
+ check('MIN_ANGLE rejects a collinear second candidate', inline.length === 1,
+ `got ${inline.length}`);
+ check('a placement below minStrands is refused', !canPlace(st, 369, 400, 'common'));
+
+ // Line of sight.
+ const walled = createState(baseLevel({
+ terrain: [ground(), { kind: 'solid', poly: [[430, 200], [440, 200], [440, 600], [430, 600]] }],
+ balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 470, y: 400, type: 'common', pinned: true }],
+ }));
+ const seen = chooseAttachments(walled, 400, 350, 'common');
+ check('a wall blocks attachment through it', !seen.includes(1), `got [${seen}]`);
+ check('cannot place inside solid terrain', !canPlace(walled, 435, 400, 'common'));
+
+ // Type limits.
+ const many = createState(baseLevel({
+ balls: [
+ { x: 400, y: 400, type: 'common', pinned: true },
+ { x: 462, y: 400, type: 'common', pinned: true },
+ { x: 431, y: 458, type: 'common', pinned: true },
+ ],
+ }));
+ check('common goo takes at most 2 strands',
+ chooseAttachments(many, 431, 400, 'common').length <= 2);
+ check('balloon goo takes exactly 1 strand',
+ chooseAttachments(many, 431, 400, 'balloon').length <= 1);
+ check('attachment is deterministic',
+ JSON.stringify(chooseAttachments(many, 431, 400, 'common')) ===
+ JSON.stringify(chooseAttachments(many, 431, 400, 'common')));
+}
+{
+ // Full drag round-trip.
+ const st = createState(baseLevel({
+ balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common', pinned: true }],
+ pile: [{ x: 800, y: 700, type: 'common' }],
+ }));
+ const ball = st.balls[2];
+ check('pickBallAt finds the loose ball', pickBallAt(st, 800, 700) === ball);
+ check('pickBallAt ignores non-detachable attached goo', pickBallAt(st, 400, 400) === null);
+
+ const ev = [];
+ check('beginDrag takes the ball', beginDrag(st, ball, ev) && ball.held);
+ dragTo(st, ball, 431, 350);
+ check('endDrag sticks it to the structure', endDrag(st, ball, 431, 350, ev));
+ check('the placed ball is attached', ball.attached && !ball.held);
+ check('it gained two strands', ball.strands.length === 2, `${ball.strands.length}`);
+ check('it left the pile', !st.pile.includes(ball.id));
+ check('a place event was emitted', ev.some((e) => e.type === 'place'));
+
+ // A failed placement returns the ball to the pile rather than eating it.
+ const st2 = createState(baseLevel({
+ balls: [{ x: 400, y: 400, type: 'common', pinned: true }],
+ pile: [{ x: 800, y: 700, type: 'common' }],
+ }));
+ const b2 = st2.balls[1];
+ const ev2 = [];
+ beginDrag(st2, b2, ev2);
+ check('a placement with too few strands fails', !endDrag(st2, b2, 400, 340, ev2));
+ check('the failed ball returns to the pile', st2.pile.includes(b2.id) && !b2.attached);
+ check('a placeFailed event was emitted', ev2.some((e) => e.type === 'placeFailed'));
+
+ // Detachable goo can be lifted back off; common goo cannot.
+ const st3 = createState(baseLevel({
+ balls: [
+ { x: 400, y: 400, type: 'common', pinned: true },
+ { x: 462, y: 400, type: 'common', pinned: true },
+ { x: 431, y: 350, type: 'ivy' },
+ ],
+ strands: [[2, 0], [2, 1]],
+ }));
+ check('ivy goo is detachable', beginDrag(st3, st3.balls[2]));
+ check('detaching drops its strands', liveStrands(st3).length === 0);
+ const st4 = createState(baseLevel({
+ balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common' }],
+ strands: [[0, 1]],
+ }));
+ check('common goo is not detachable', !beginDrag(st4, st4.balls[1]));
+}
+
+// ── 4c. Crawling and the pipe ───────────────────────────────────────────────
+section('4c. Crawlers and the pipe');
+{
+ // A structure that reaches the pipe, with loose goo at its foot.
+ const build = (pileN) => {
+ const balls = [
+ { x: 400, y: 700, type: 'common', pinned: true },
+ { x: 462, y: 700, type: 'common', pinned: true },
+ { x: 431, y: 645, type: 'common' },
+ { x: 431, y: 590, type: 'common' },
+ ];
+ const strands = [[2, 0], [2, 1], [3, 2], [3, 0], [3, 1]];
+ const pile = [];
+ for (let i = 0; i < pileN; i += 1) pile.push({ x: 420 + i * 6, y: 690 - i * 4, type: 'common' });
+ return createState(baseLevel({
+ terrain: [ground()],
+ balls, strands, pile,
+ pipe: { x: 431, y: 570, r: 46 },
+ required: 2, ocdTarget: 3,
+ }));
+ };
+
+ const st = build(0);
+ for (let i = 0; i < 30; i += 1) stepSim(st, 1 / 60);
+ check('the pipe opens when the structure reaches it', st.pipe.open);
+
+ const closed = createState(baseLevel({
+ balls: [{ x: 400, y: 700, type: 'common', pinned: true }],
+ pipe: { x: 431, y: 200, r: 46 },
+ required: 1,
+ }));
+ for (let i = 0; i < 30; i += 1) stepSim(closed, 1 / 60);
+ check('a pipe out of reach stays shut', !closed.pipe.open);
+
+ // Loose goo climbs the structure and gets drunk.
+ const run = build(4);
+ let collected = 0;
+ let climbed = false;
+ for (let i = 0; i < 60 * 25; i += 1) {
+ for (const e of stepSim(run, 1 / 60)) {
+ if (e.type === 'collect') collected += 1;
+ if (e.type === 'climb') climbed = true;
+ }
+ if (collected >= 4) break;
+ }
+ check('loose goo climbs onto the structure', climbed);
+ check('crawlers reach the pipe and are collected', collected > 0, `${collected} collected`);
+ check('state.collected agrees with the events', run.collected === collected);
+ check('the level is won once required is met', isWon(run), `${run.collected}/${run.required}`);
+ check('OCD tracks separately from the win',
+ hasOCD(run) === (run.collected >= run.ocdTarget));
+ check('collected goo leaves the pile', run.pile.length + run.collected >= 4);
+
+ // Cutting the structure strands a crawler rather than teleporting it.
+ const cut = build(3);
+ for (let i = 0; i < 60 * 4; i += 1) stepSim(cut, 1 / 60);
+ const walker = cut.balls.find((b) => b.walking);
+ if (walker) {
+ const s = strandById(cut, walker.onStrand);
+ breakStrand(cut, s, null);
+ stepSim(cut, 1 / 60);
+ check('a crawler whose strand breaks falls off', !walker.walking);
+ } else {
+ check('a crawler whose strand breaks falls off', true, 'no crawler active; skipped');
+ }
+}
+
+// ── 4d. Terrain-sticking goo (anchor, pokey) ────────────────────────────────
+section('4d. Anchors and wall-sticking goo');
+{
+ const wall = { kind: 'solid', poly: [[600, 200], [640, 200], [640, 700], [600, 700]] };
+ const st = createState(baseLevel({ terrain: [ground(), wall], pile: [{ x: 900, y: 700, type: 'anchor' }] }));
+
+ check('distanceToSolid measures the nearest surface',
+ Math.abs(distanceToSolid(st, 660, 400) - 20) < 1e-6, `${distanceToSolid(st, 660, 400)}`);
+ check('anchor goo sticks beside a wall', canStickToTerrain(st, 660, 400, 'anchor'));
+ check('anchor goo does not stick in mid-air', !canStickToTerrain(st, 900, 400, 'anchor'));
+ check('common goo never sticks to terrain', !canStickToTerrain(st, 660, 400, 'common'));
+
+ check('a stuck anchor needs no strands', requiredStrands(st, 660, 400, 'anchor') === 0);
+ check('an unstuck anchor still needs none by type', gooType('anchor').minStrands === 0);
+ check('pokey beside a wall needs no strands', requiredStrands(st, 660, 400, 'pokey') === 0);
+ check('pokey in mid-air needs its full quota',
+ requiredStrands(st, 900, 400, 'pokey') === GOO_TYPES.pokey.minStrands);
+ check('common goo beside a wall still needs two',
+ requiredStrands(st, 660, 400, 'common') === 2);
+
+ check('an anchor can be placed on a wall', canPlace(st, 660, 400, 'anchor'));
+ check('an anchor cannot be placed in mid-air', !canPlace(st, 900, 400, 'anchor'));
+
+ // Placing one pins it, which is what makes it an anchor point.
+ const ball = st.balls.find((b) => b.type === 'anchor');
+ const ev = [];
+ beginDrag(st, ball, ev);
+ check('placing an anchor on a wall succeeds', endDrag(st, ball, 660, 400, ev));
+ check('a stuck anchor is pinned', ball.pinned && ball.invMass === 0);
+ check('a stick event was emitted', ev.some((e) => e.type === 'stick'));
+ settle(st, 4);
+ check('a stuck anchor never moves', Math.abs(ball.x - 660) < 1e-9 && Math.abs(ball.y - 400) < 1e-9);
+ check('a stuck anchor roots the structure', isRooted(st, ball.id));
+}
+
+// ── 4e. Bombs ───────────────────────────────────────────────────────────────
+section('4e. Bombs and fire');
+{
+ const fire = { kind: 'fire', poly: [[380, 500], [520, 500], [520, 560], [380, 560]] };
+ const mk = (over = {}) => createState(baseLevel({
+ terrain: [ground(), fire],
+ balls: [
+ { x: 400, y: 300, type: 'common', pinned: true },
+ { x: 462, y: 300, type: 'common', pinned: true },
+ { x: 431, y: 356, type: 'common' },
+ ],
+ strands: [[2, 0], [2, 1], [0, 1]],
+ pile: [{ x: 450, y: 450, type: 'bomb' }],
+ ...over,
+ }));
+
+ const st = mk();
+ const bomb = st.balls.find((b) => b.type === 'bomb');
+ let lit = false;
+ let blew = false;
+ for (let i = 0; i < 60 * 8 && !blew; i += 1) {
+ for (const e of stepSim(st, 1 / 60)) {
+ if (e.type === 'ignite') lit = true;
+ if (e.type === 'explode') blew = true;
+ }
+ }
+ check('fire lights a bomb rather than killing it', lit);
+ check('a lit bomb detonates', blew);
+ check('the fuse is not instant', TUNING.FUSE_TIME > 0);
+ check('the bomb is consumed by its own blast', bomb.dead);
+
+ // Blast radius is finite: goo well clear of it survives.
+ const far = createState(baseLevel({
+ terrain: [ground(), fire],
+ balls: [{ x: 1400, y: 300, type: 'common', pinned: true }],
+ pile: [{ x: 450, y: 450, type: 'bomb' }],
+ }));
+ for (let i = 0; i < 60 * 8; i += 1) stepSim(far, 1 / 60);
+ check('goo outside the blast radius survives', !far.balls[0].dead);
+
+ // Blast effects, driven directly so the bomb is where we put it.
+ const blast = createState(baseLevel({
+ terrain: [
+ ground(),
+ { kind: 'solid', poly: [[470, 300], [560, 300], [560, 340], [470, 340]], destructible: true },
+ { kind: 'solid', poly: [[1300, 300], [1390, 300], [1390, 340], [1300, 340]], destructible: true },
+ ],
+ balls: [
+ { x: 400, y: 300, type: 'common', pinned: true },
+ { x: 462, y: 300, type: 'common', pinned: true },
+ { x: 431, y: 356, type: 'common' },
+ { x: 1500, y: 300, type: 'common', pinned: true },
+ ],
+ strands: [[2, 0], [2, 1], [0, 1]],
+ pile: [{ x: 431, y: 400, type: 'bomb' }],
+ }));
+ const charge = blast.balls.find((b) => b.type === 'bomb');
+ const terrainBefore = blast.terrain.length;
+ const evb = [];
+ detonate(blast, charge, evb);
+
+ check('a blast shears strands within BLAST_R', liveStrands(blast).length < 3,
+ `${liveStrands(blast).length}/3 left`);
+ check('a blast kills goo within BLAST_KILL_R', blast.balls[2].dead);
+ check('a blast spares goo well outside it', !blast.balls[3].dead);
+ check('a blast destroys destructible terrain in range', blast.terrain.length < terrainBefore,
+ `${terrainBefore} -> ${blast.terrain.length}`);
+ check('an explode event carries the blast position',
+ evb.some((e) => e.type === 'explode' && e.x === 431));
+
+ const soft = blast;
+ check('destructible terrain out of range survives',
+ soft.terrain.some((t) => t.destructible), 'all destructible terrain went');
+ check('indestructible terrain always survives',
+ soft.terrain.some((t) => t.kind === 'solid' && !t.destructible));
+
+ // Chain reaction.
+ const chain = createState(baseLevel({
+ terrain: [ground(), fire],
+ pile: [
+ { x: 450, y: 450, type: 'bomb' },
+ { x: 450, y: 390, type: 'bomb' },
+ { x: 450, y: 330, type: 'bomb' },
+ ],
+ }));
+ let blasts = 0;
+ for (let i = 0; i < 60 * 12; i += 1) {
+ for (const e of stepSim(chain, 1 / 60)) if (e.type === 'explode') blasts += 1;
+ }
+ check('bombs chain-react', blasts >= 2, `${blasts} explosions`);
+
+ // Anchors are bolted to the world.
+ const anchored = createState(baseLevel({
+ terrain: [ground(), fire],
+ balls: [{ x: 460, y: 430, type: 'anchor', pinned: true }],
+ pile: [{ x: 450, y: 450, type: 'bomb' }],
+ }));
+ for (let i = 0; i < 60 * 8; i += 1) stepSim(anchored, 1 / 60);
+ check('anchors survive a blast', !anchored.balls[0].dead);
+}
+
+// ── 4f. Fans and gears ──────────────────────────────────────────────────────
+section('4f. Fans and gears');
+{
+ // A fan strong enough to beat gravity lifts goo inside its volume.
+ const st = createState(baseLevel({
+ fans: [{ x: 300, y: 200, w: 300, h: 600, dx: 0, dy: -1, force: 2600 }],
+ pile: [{ x: 450, y: 700, type: 'common' }, { x: 1200, y: 700, type: 'common' }],
+ }));
+ const inFan = st.balls[0];
+ const outside = st.balls[1];
+ const y0 = inFan.y;
+ const y1 = outside.y;
+ for (let i = 0; i < 90; i += 1) stepSim(st, 1 / 60);
+ check('a fan lifts goo inside its volume', inFan.y < y0 - 30,
+ `moved ${(inFan.y - y0).toFixed(1)}px`);
+ check('goo outside the fan is unaffected', outside.y >= y1 - 2,
+ `moved ${(outside.y - y1).toFixed(1)}px`);
+ check('a sideways fan pushes sideways', (() => {
+ const s2 = createState(baseLevel({
+ fans: [{ x: 300, y: 600, w: 600, h: 260, dx: 1, dy: 0, force: 2600 }],
+ pile: [{ x: 400, y: 800, type: 'common' }],
+ }));
+ const x0 = s2.balls[0].x;
+ for (let i = 0; i < 90; i += 1) stepSim(s2, 1 / 60);
+ return s2.balls[0].x > x0 + 30;
+ })());
+}
+{
+ // A gear is solid, spins, and drags what touches it.
+ const mkGear = (omega) => createState(baseLevel({
+ terrain: [ground()],
+ gears: [{ x: 500, y: 600, r: 90, teeth: 8, omega }],
+ pile: [{ x: 500, y: 460, type: 'common' }],
+ }));
+
+ const spun = mkGear(3);
+ const gearT = spun.terrain.find((t) => t.gear);
+ check('a gear joins the terrain as a solid', !!gearT && gearT.kind === 'solid');
+ const a0 = gearT.gear.angle;
+ for (let i = 0; i < 60; i += 1) stepSim(spun, 1 / 60);
+ check('a gear rotates', Math.abs(gearT.gear.angle - a0) > 1,
+ `angle moved ${(gearT.gear.angle - a0).toFixed(2)}rad`);
+ check('a spinning gear stays solid (goo never sinks into it)',
+ !pointInPoly(spun.balls[0].x, spun.balls[0].y, gearT.poly));
+
+ // Positive omega drags the top of the wheel to the right (screen y is down).
+ const right = mkGear(4);
+ const left = mkGear(-4);
+ const rx = right.balls[0].x;
+ const lx = left.balls[0].x;
+ for (let i = 0; i < 150; i += 1) { stepSim(right, 1 / 60); stepSim(left, 1 / 60); }
+ check('a gear drags goo along its rim',
+ right.balls[0].x > rx + 5 && left.balls[0].x < lx - 5,
+ `cw ${(right.balls[0].x - rx).toFixed(1)}px, ccw ${(left.balls[0].x - lx).toFixed(1)}px`);
+
+ // Time-varying terrain must not cost determinism.
+ const d1 = mkGear(3);
+ const d2 = mkGear(3);
+ for (let i = 0; i < 200; i += 1) { stepSim(d1, 1 / 60); stepSim(d2, 1 / 60); }
+ check('gears stay deterministic', hashState(d1) === hashState(d2));
+}
+
+// ── 4g. The remaining goo types ─────────────────────────────────────────────
+section('4g. Balloon, block, bit');
+{
+ // A balloon tied to a hanging structure must visibly hold it up.
+ const build = (tip) => {
+ const balls = [
+ { x: 400, y: 200, type: 'common', pinned: true },
+ { x: 462, y: 200, type: 'common', pinned: true },
+ { x: 431, y: 256, type: 'common' },
+ { x: 431, y: 318, type: 'common' },
+ ];
+ const strands = [[2, 0], [2, 1], [3, 2]];
+ if (tip) {
+ // Clear of every other ball: goo that starts overlapping is ejected hard
+ // by the collision solver and snaps its own strand.
+ balls.push({ x: 493, y: 256, type: 'balloon' });
+ strands.push([4, 3]);
+ }
+ return createState(baseLevel({ terrain: [], balls, strands }));
+ };
+ const plain = build(false);
+ const lifted = build(true);
+ settle(plain, 12);
+ settle(lifted, 12);
+
+ // Position is a weak way to measure lift: the structure is held by strands
+ // stiff enough that ~1.3 ball-weights of buoyancy only moves it a few px.
+ // The load it takes OFF the supporting strand is the real signal.
+ const plainLoad = plain.strands[2].tension;
+ const liftedLoad = lifted.strands[2].tension;
+ check('a balloon takes load off the strand holding the structure',
+ liftedLoad < plainLoad * 0.7,
+ `${liftedLoad.toFixed(0)} vs ${plainLoad.toFixed(0)}`);
+ check('a balloon lifts the structure it is tied to',
+ lifted.balls[3].y < plain.balls[3].y - 2,
+ `${lifted.balls[3].y.toFixed(2)} vs ${plain.balls[3].y.toFixed(2)}`);
+ check('one balloon is worth about one goo of lift',
+ Math.abs(GOO_TYPES.balloon.mass * GOO_TYPES.balloon.buoyancy) > 1
+ && Math.abs(GOO_TYPES.balloon.mass * GOO_TYPES.balloon.buoyancy) < 3,
+ `${(GOO_TYPES.balloon.mass * -GOO_TYPES.balloon.buoyancy).toFixed(2)} ball-weights`);
+ check('balloon goo takes a single strand', GOO_TYPES.balloon.maxStrands === 1);
+
+ // Block goo is stiffer, so the same span sags less.
+ const span = (type) => {
+ const balls = [{ x: 300, y: 300, type, pinned: true }];
+ const strands = [];
+ for (let i = 1; i <= 3; i += 1) {
+ balls.push({ x: 300 + i * TUNING.STRAND_REST, y: 300, type });
+ strands.push([i - 1, i]);
+ }
+ const s = createState(baseLevel({ terrain: [], balls, strands }));
+ settle(s, 12);
+ return s.balls[3].y - 300;
+ };
+ const blockSag = span('block');
+ const commonSag = span('common');
+ check('block goo is stiffer than common goo', blockSag < commonSag,
+ `block ${blockSag.toFixed(1)}px vs common ${commonSag.toFixed(1)}px`);
+
+ check('bit goo is small and light',
+ GOO_TYPES.bit.r < GOO_TYPES.common.r && GOO_TYPES.bit.mass < GOO_TYPES.common.mass);
+ check('bit goo hangs off a single strand', GOO_TYPES.bit.minStrands === 1);
+ check('every goo type is reachable from gooType()',
+ Object.keys(GOO_TYPES).every((k) => gooType(k) === GOO_TYPES[k]));
+}
+
+// ── 5. Stability ────────────────────────────────────────────────────────────
+section('5. Stability');
+{
+ // A dense, stiff, over-constrained lattice is the worst case for a naive
+ // spring integrator. It must stay finite and bounded.
+ const COLS = 8, ROWS = 5, DX = TUNING.STRAND_REST;
+ const balls = [], strands = [];
+ const at = (r, c) => r * COLS + c;
+ for (let r = 0; r < ROWS; r += 1) {
+ for (let c = 0; c < COLS; c += 1) {
+ balls.push({ x: 300 + c * DX, y: 300 + r * DX, type: 'block', pinned: r === 0 });
+ }
+ }
+ for (let r = 0; r < ROWS; r += 1) {
+ for (let c = 0; c < COLS; c += 1) {
+ if (c + 1 < COLS) strands.push([at(r, c), at(r, c + 1)]);
+ if (r + 1 < ROWS) strands.push([at(r, c), at(r + 1, c)]);
+ if (r + 1 < ROWS && c + 1 < COLS) strands.push([at(r, c), at(r + 1, c + 1)]);
+ if (r + 1 < ROWS && c > 0) strands.push([at(r, c), at(r + 1, c - 1)]);
+ }
+ }
+ const st = createState(baseLevel({ balls, strands }));
+ settle(st, 12);
+
+ let finite = true, bounded = true, maxSpeed = 0;
+ for (const b of st.balls) {
+ if (!Number.isFinite(b.x) || !Number.isFinite(b.y)) finite = false;
+ if (Math.abs(b.x) > 1e5 || Math.abs(b.y) > 1e5) bounded = false;
+ maxSpeed = Math.max(maxSpeed, ballSpeed(st, b));
+ }
+ check('stiff lattice stays finite (no NaN)', finite);
+ check('stiff lattice stays bounded (no explosion)', bounded);
+ check('stiff lattice comes to rest', maxSpeed < TUNING.SETTLE_SPEED, `max speed ${maxSpeed.toFixed(2)}`);
+ check('stiff lattice keeps most of its strands', liveStrands(st).length > strands.length * 0.9,
+ `${liveStrands(st).length}/${strands.length}`);
+ check('per-substep travel is capped', maxSpeed <= TUNING.MAX_SPEED + 1e-6);
+}
+{
+ // Buoyant goo must rise, not sink.
+ const st = createState(baseLevel({ balls: [{ x: 400, y: 500, type: 'balloon' }] }));
+ const y0 = st.balls[0].y;
+ for (let i = 0; i < 60; i += 1) stepSim(st, 1 / 60);
+ check('balloon goo rises', st.balls[0].y < y0 - 20, `moved ${(st.balls[0].y - y0).toFixed(1)}px`);
+ check('common goo falls', (() => {
+ const s2 = createState(baseLevel({ balls: [{ x: 400, y: 300, type: 'common' }] }));
+ const start = s2.balls[0].y;
+ for (let i = 0; i < 20; i += 1) stepSim(s2, 1 / 60);
+ return s2.balls[0].y > start + 5;
+ })());
+}
+
+// ── 6. Determinism ──────────────────────────────────────────────────────────
+section('6. Determinism');
+{
+ const mk = () => createState(baseLevel({
+ balls: [
+ { x: 400, y: 200, type: 'common', pinned: true },
+ { x: 462, y: 200, type: 'common' },
+ { x: 431, y: 254, type: 'common' },
+ { x: 493, y: 254, type: 'common' },
+ ],
+ strands: [[0, 1], [0, 2], [1, 2], [1, 3], [2, 3]],
+ }), 12345);
+
+ const a = mk(), b = mk();
+ for (let i = 0; i < 300; i += 1) { stepSim(a, 1 / 60); stepSim(b, 1 / 60); }
+ check('identical states replay bit-identically', hashState(a) === hashState(b),
+ `${hashState(a)} vs ${hashState(b)}`);
+
+ // Frame-rate independence: one 1/60 step must equal four 1/240 steps.
+ const c = mk(), d = mk();
+ for (let i = 0; i < 300; i += 1) {
+ stepSim(c, 1 / 60);
+ for (let k = 0; k < 4; k += 1) stepSim(d, TUNING.SUBSTEP_DT);
+ }
+ check('1/60 step == 4x substep', hashState(c) === hashState(d),
+ `${hashState(c)} vs ${hashState(d)}`);
+
+ // A ragged frame budget must not change the outcome either.
+ const e = mk(), f = mk();
+ const raggedRng = mulberry32(999);
+ let tE = 0, tF = 0;
+ const TOTAL = 5;
+ while (tE < TOTAL) { const dt = TUNING.SUBSTEP_DT * 4; stepSim(e, dt); tE += dt; }
+ while (tF < TOTAL) {
+ const n = 1 + Math.floor(raggedRng() * 6);
+ const dt = TUNING.SUBSTEP_DT * n;
+ if (tF + dt > tE) break;
+ stepSim(f, dt); tF += dt;
+ }
+ while (tF < tE - 1e-9) { stepSim(f, TUNING.SUBSTEP_DT); tF += TUNING.SUBSTEP_DT; }
+ check('ragged frame pacing reaches the same state', hashState(e) === hashState(f),
+ `${hashState(e)} vs ${hashState(f)}`);
+
+ const g = mk();
+ const h = cloneState(g);
+ for (let i = 0; i < 120; i += 1) { stepSim(g, 1 / 60); stepSim(h, 1 / 60); }
+ check('cloneState produces an independent, identical sim', hashState(g) === hashState(h));
+ const g2 = cloneState(g);
+ for (let i = 0; i < 30; i += 1) stepSim(g2, 1 / 60);
+ check('mutating a clone does not touch the original', hashState(g) !== hashState(g2));
+}
+{
+ const rng = mulberry32(42);
+ const first = [rng(), rng(), rng()];
+ const rng2 = mulberry32(42);
+ const second = [rng2(), rng2(), rng2()];
+ check('seeded rng is reproducible', first.every((v, i) => v === second[i]));
+ check('rng stays in [0,1)', first.every((v) => v >= 0 && v < 1));
+}
+
+// ── 7. Type table sanity ────────────────────────────────────────────────────
+section('7. Goo type table');
+{
+ let ok = true, why = '';
+ for (const [name, t] of Object.entries(GOO_TYPES)) {
+ if (t.minStrands > t.maxStrands) { ok = false; why = `${name} min>max`; }
+ if (t.r <= 0) { ok = false; why = `${name} bad radius`; }
+ if (t.mass < 0) { ok = false; why = `${name} negative mass`; }
+ }
+ check('every goo type is coherent', ok, why);
+ check('gooType falls back to common', gooType('nonexistent') === GOO_TYPES.common);
+ check('anchor is massless and pinned in practice', GOO_TYPES.anchor.mass === 0);
+ check('balloon is the only single-strand lifter', GOO_TYPES.balloon.buoyancy < 0);
+}
+
+// ── 8. The shipped level bank ───────────────────────────────────────────────
+section('8. Level bank');
+{
+ const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower');
+ let manifest = null;
+ try {
+ manifest = JSON.parse(readFileSync(join(bankDir, 'levels.json'), 'utf8'));
+ } catch (e) {
+ check('levels.json loads', false, e.message);
+ }
+
+ if (manifest) {
+ check('manifest has levels', Array.isArray(manifest.levels) && manifest.levels.length > 0);
+ check('manifest levels are numbered 1..N with no gaps',
+ manifest.levels.every((m, i) => m.level === i + 1),
+ manifest.levels.map((m) => m.level).join(','));
+ check('every manifest entry names a chapter that exists',
+ manifest.levels.every((m) => (manifest.chapters || []).some((c) => c.id === m.chapter)));
+
+ for (const entry of manifest.levels) {
+ const tag = `L${entry.level} ${entry.name}`;
+ let def = null;
+ try {
+ def = JSON.parse(readFileSync(join(bankDir, entry.file), 'utf8'));
+ } catch (e) {
+ check(`${tag}: file loads`, false, e.message);
+ continue;
+ }
+
+ check(`${tag}: manifest matches the level file`,
+ def.level === entry.level && def.required === entry.required && def.ocdTarget === entry.ocdTarget);
+ check(`${tag}: has a pipe`, !!def.pipe);
+ check(`${tag}: OCD target is at least the requirement`, def.ocdTarget >= def.required);
+
+ const st = createState(def);
+ const pileSize = st.pile.length;
+ check(`${tag}: enough goo exists to meet OCD`, pileSize >= def.ocdTarget,
+ `${pileSize} loose vs OCD ${def.ocdTarget}`);
+ check(`${tag}: the starting structure is rooted`,
+ st.balls.some((b) => b.attached && isRooted(st, b.id)));
+ check(`${tag}: nothing starts inside solid terrain`,
+ st.balls.every((b) => !insideSolid(st, b.x, b.y)));
+ // Overlapping goo is ejected violently by the collision solver and can
+ // snap its own strands before the player touches anything.
+ check(`${tag}: no goo starts overlapping`, (() => {
+ for (let i = 0; i < st.balls.length; i += 1) {
+ for (let j = i + 1; j < st.balls.length; j += 1) {
+ const a = st.balls[i];
+ const b = st.balls[j];
+ if (Math.hypot(b.x - a.x, b.y - a.y) < (a.r + b.r) * 0.6) return false;
+ }
+ }
+ return true;
+ })());
+ check(`${tag}: the pipe does not start open`, !st.pipe.open);
+
+ // The level must be quiet before the player touches it. A bank level that
+ // spends its first seconds collapsing, shedding strands or losing goo off
+ // the rim is a bug in the level, not in the physics.
+ settle(st, 10);
+ check(`${tag}: settles without breaking a strand`,
+ liveStrands(st).length === st.strands.length,
+ `${liveStrands(st).length}/${st.strands.length}`);
+ check(`${tag}: settles without losing goo`,
+ st.balls.every((b) => !b.dead),
+ `${st.balls.filter((b) => b.dead).length} lost`);
+ check(`${tag}: still has OCD-many goo after settling`,
+ st.pile.length >= def.ocdTarget, `${st.pile.length} left`);
+ }
+ }
+}
+
+// ── 9. Winnability ──────────────────────────────────────────────────────────
+// The gate that matters. Everything above proves the physics is sane; this
+// proves the levels can actually be beaten. It caught two real level bugs on
+// the first run (a level that handed out exactly enough goo to reach the pipe
+// and none to feed it, and a level asking for a 677px traverse at level 3).
+section('9. Winnability (greedy reference player)');
+{
+ const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower');
+ let manifest = null;
+ try {
+ manifest = JSON.parse(readFileSync(join(bankDir, 'levels.json'), 'utf8'));
+ } catch (_) { manifest = null; }
+
+ if (manifest) {
+ for (const entry of manifest.levels) {
+ const def = JSON.parse(readFileSync(join(bankDir, entry.file), 'utf8'));
+ const r = autoPlay(def);
+ check(`L${entry.level} ${entry.name}: a naive builder can win it`,
+ r.won,
+ `placed ${r.placed}, pipe ${r.pipeOpen ? 'open' : 'SHUT'}, collected ${r.collected}/${def.required}`);
+ }
+ }
+}
+
+// ── 10. Editor contract ─────────────────────────────────────────────────────
+// GooTowerEditor.js imports Phaser, so it cannot run here. What CAN be pinned
+// down are the assumptions it makes about this module — the ones that would
+// break it silently.
+section('10. Editor contract');
+{
+ // placeStructureBall() maps the ball ids returned by chooseAttachments
+ // straight onto indices of its own `balls` array. That is only valid because
+ // createState adds structure balls first, in order, before the pile. If that
+ // order ever changes, the editor would wire strands to the wrong goo.
+ const st = createState(baseLevel({
+ balls: [
+ { x: 400, y: 400, type: 'common', pinned: true },
+ { x: 462, y: 400, type: 'common', pinned: true },
+ { x: 431, y: 350, type: 'ivy' },
+ ],
+ pile: [{ x: 900, y: 700, type: 'common' }, { x: 940, y: 700, type: 'common' }],
+ }));
+ check('structure ball index === ball id',
+ st.balls.slice(0, 3).every((b, i) => b.id === i));
+ check('pile goo comes after the structure, in order',
+ st.pile.length === 2 && st.pile[0] === 3 && st.pile[1] === 4, `pile=[${st.pile}]`);
+ check('structure goo is attached, pile goo is not',
+ st.balls.slice(0, 3).every((b) => b.attached) && st.balls.slice(3).every((b) => !b.attached));
+}
+{
+ // The editor's Settle button writes settled positions back into the level.
+ // That is only worth doing if a settled level STAYS settled when reloaded --
+ // otherwise every export would drift.
+ const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower');
+ const def = JSON.parse(readFileSync(join(bankDir, 'level-001.json'), 'utf8'));
+
+ const first = createState(def);
+ settlePhysics(first, 14);
+ // Replicate the editor's write-back: structure balls, then pile, in order.
+ const settled = JSON.parse(JSON.stringify(def));
+ settled.balls.forEach((b, i) => { b.x = Math.round(first.balls[i].x); b.y = Math.round(first.balls[i].y); });
+ const offset = settled.balls.length;
+ settled.pile.forEach((p, i) => { p.x = Math.round(first.balls[offset + i].x); p.y = Math.round(first.balls[offset + i].y); });
+
+ const second = createState(settled);
+ const before = second.balls.map((b) => ({ x: b.x, y: b.y }));
+ settlePhysics(second, 14);
+ let maxDrift = 0;
+ second.balls.forEach((b, i) => {
+ maxDrift = Math.max(maxDrift, Math.hypot(b.x - before[i].x, b.y - before[i].y));
+ });
+ check('a settled level stays settled when reloaded', maxDrift < 12,
+ `max drift ${maxDrift.toFixed(1)}px`);
+ check('settling never breaks a strand on a good level',
+ liveStrands(second).length === second.strands.length);
+ check('settling never loses goo on a good level', second.balls.every((b) => !b.dead));
+}
+{
+ // The editor exports exactly the shape createState consumes. Round-trip a
+ // shipped level through that shape and it must still behave identically.
+ const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower');
+ const def = JSON.parse(readFileSync(join(bankDir, 'level-002.json'), 'utf8'));
+ const roundTripped = {
+ world: { w: 1600, h: 1000 },
+ level: def.level, name: def.name, chapter: def.chapter, tip: def.tip,
+ terrain: def.terrain.map((t) => ({ kind: t.kind, poly: t.poly.map((p) => [p[0], p[1]]) })),
+ balls: def.balls.map((b) => ({ x: b.x, y: b.y, type: b.type, pinned: !!b.pinned })),
+ strands: def.strands.map((s) => [s[0], s[1]]),
+ pile: def.pile.map((p) => ({ x: p.x, y: p.y, type: p.type })),
+ pipe: { x: def.pipe.x, y: def.pipe.y, r: def.pipe.r },
+ required: def.required, ocdTarget: def.ocdTarget,
+ };
+ const a = createState(def);
+ const b = createState(roundTripped);
+ for (let i = 0; i < 120; i += 1) { stepSim(a, 1 / 60); stepSim(b, 1 / 60); }
+ check('an editor round-trip produces an identical level', hashState(a) === hashState(b),
+ `${hashState(a)} vs ${hashState(b)}`);
+}
+
+console.log(`\n[verify] ${passes} passed, ${failures} failed`);
+if (failures > 0) process.exit(1);