@maccesar/titools 4.15.0 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -177,7 +177,7 @@ The knowledge index is based on the latest Titanium SDK documentation. If your p
177
177
  | ti-expert | Architecture and implementation | Starting point for most tasks |
178
178
  | purgetss | Utility-first styling | UI styling and animations |
179
179
  | ti-ui | UI/UX patterns | Complex layouts, ListViews, platform UI |
180
- | ti-game | 2D games with the `ti.game` module | Sprites, physics, shaped solids, collisions, raycasts, pathfinding, text HUDs, sprite attachment, particles, camera |
180
+ | ti-game | 2D games with the `ti.game` module | Sprites, physics, tile layers, collisions, pathfinding, text/performance HUDs, particles, camera |
181
181
  | ti-api | Complete Titanium API reference | Looking up properties, methods, events |
182
182
  | ti-guides | SDK fundamentals | tiapp.xml, Hyperloop, distribution, JDK/Xcode compatibility, release notes |
183
183
  | ti-howtos | Native feature integration | Location, push, media, platform APIs |
@@ -443,6 +443,8 @@ When it activates:
443
443
  - Named anchors and percentage strings (`anchor: 'bottom'`, `hitboxScaleY: '55%'`) wherever the engine takes a ratio
444
444
  - Particle emitters, Verlet ropes, camera follow/zoom/shake/effects, `scrollFactor` parallax, low-latency sound
445
445
  - Game-clock timers that obey pause and slow motion, texture `unload()` for level streaming, and what LiveView reloads clean up on their own
446
+ - Native `TileLayer` maps with visible-cell rendering, Tiled GIDs, solid/one-way cells, live edits and A* integration
447
+ - Collision-shape overlays plus the expandable performance HUD and opt-in `performance` telemetry event
446
448
  - Drag & drop, pinch, rotate, multitouch controls
447
449
 
448
450
  Example prompts:
@@ -455,6 +457,8 @@ Example prompts:
455
457
  "Add a score HUD that stays put while the camera follows the player."
456
458
  "Make the guard walk a patrol route and turn around before the ledge."
457
459
  "Tap to walk, but the player should go around the tree instead of through it."
460
+ "Render a 200x200 Tiled map without creating one sprite per tile."
461
+ "Show FPS, p95 frame time, draw calls and texture switches while I tune this scene."
458
462
  ```
459
463
 
460
464
  Key rules:
@@ -463,7 +467,9 @@ Key rules:
463
467
  - `solidWith` blocks, `collidesWith` reports — they are independent
464
468
  - A sprite with size but no sheet is an invisible trigger (scores, goals, walls)
465
469
  - Add a whole level with one `gameView.add([...])` call
470
+ - Use `Game.createTileLayer()` for large/static grids; keep sprites for interactive cells and actors
466
471
  - HUDs are `Game.createText` sprites with `screenFixed: true`, not `Ti.UI.Label` overlays
472
+ - `debug: true` means collision shapes; use `debug: { hud: true }` or `performance` for telemetry
467
473
  - Timers that drive game logic belong on the game clock (`gameView.every`), so a paused game stops spawning
468
474
 
469
475
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maccesar/titools",
3
- "version": "4.15.0",
3
+ "version": "4.16.0",
4
4
  "description": "Titanium SDK skills and agents for AI coding assistants (Claude Code, Gemini CLI, Codex CLI)",
5
5
  "main": "lib/index.js",
6
6
  "type": "module",
@@ -1,56 +1,60 @@
1
1
  ---
2
2
  name: ti-game
3
- description: "Use when building or reviewing 2D games with the ti.game module for Titanium SDK (Android/iOS) — sprites, sheets, animations, tweens, physics (gravity, gravityX, solids, solidMode, linearDamping, carMode, thrust), collision groups, trigger zones, hitbox shapes (circle, rotatedRect), swept, raycast, paths, bitmap text, particles, ropes, camera, parallax, sound, drag & drop, multitouch, attachments. AUTO-DETECT: if tiapp.xml declares ti.game or any file calls require('ti.game'), invoke BEFORE writing ANY game code. Also trigger on: game, juego, spritesheet, platformer, endless runner, top-down, shooter, racer, flappy, breakout, rhythm, cards, puzzle, hitbox, attachTo, slope, ramp, pool, plinko, bounce, friction, pathfinding, maze, patrol, HUD, score, game loop, GameView. ti.game is NOT Phaser, Godot or Box2D: you never write a game loop nor move a sprite from setInterval, and features like tilemaps, parent/child transforms and gamepads do not exist yet — check references/roadmap.md before inventing an API."
4
- argument-hint: "[genre or feature]"
3
+ description: "Use when a Titanium app declares ti.game in tiapp.xml, imports require('ti.game'), or needs a 2D game built or reviewed with that native engine: sprites, native movement, collisions, tile layers, camera, HUD, audio, particles, or touch. Covers Alloy and Classic on Android/iOS. Do not use for unrelated game engines or ordinary Titanium UI."
5
4
  allowed-tools: Read, Grep, Glob, Edit, Write, Bash(node *)
6
5
  ---
7
6
 
8
- # ti.game — 2D game engine for Titanium SDK
9
-
10
- `ti.game` is a native OpenGL ES 2.0 sprite engine exposed to JavaScript, written and maintained by **Michael Gangolf (m1ga)** — upstream is https://github.com/m1ga/ti.game. This skill tracks upstream `main` as of **2026-08-27**, manifest **`0.5.0`** (`ee69056`) — and that number has meant different things at different times. Under `0.3.0` the engine gained the text engine, `screenFixed`, `swept` collisions, `collisionend`, `followPath`, animation chaining, `raycast`, `findPath`, game-clock timers, `scrollFactor` parallax and the `multiply`/`screen` blend modes, so anything still calling itself 0.3.0 has none of them. Under `0.4.0` it gained per-axis hitboxes, text word wrap, `unload()` on sheets and fonts, the LiveView renderer lifecycle (2026-08-23), then `attachTo`/`detach`, inherited opacity on attachments and named/percentage values (2026-08-26). `0.5.0` is the first bump that carries a feature set of its own: shape-aware solids and lightweight circle physics — `hitboxShape: 'rotatedRect'`, circular solids, `solidMode`, `gravityX`, `linearDamping` and two-sided `restitution`. Date the build rather than trusting the version. When upstream and any fork disagree, upstream wins. Same JS API on Android and iOS — the iOS side is a class-per-class Obj-C twin of the Android engine.
11
-
12
- Works in **both Classic and Alloy** projects. Nothing here depends on PurgeTSS.
13
-
14
- ## The one rule everything follows
15
-
16
- **JS describes the scene and reacts to events. The engine runs every frame.**
17
-
18
- The Kroll bridge (JS ↔ native) is far too slow for per-frame work, so the API is a *scene description*: setting `sprite.x`, `sprite.velocityY` or `car.throttle` writes a volatile field the render thread reads on the next tick. Events flow back only when something discrete happens — `tap`, `collision`, `land`, `complete`. Nothing fires per frame by design.
19
-
20
- The practical consequence, and the single biggest mistake to avoid:
21
-
22
- > If you are about to move a sprite from a `setInterval` or a `requestAnimationFrame`, stop and find the native property that does it. Coarse timers for *decisions* (an AI picking a direction every 80–150 ms, an autofire cadence, a spawn schedule) are fine and the official demos use them. Timers for *motion* are not.
23
-
24
- | You want | Do NOT write | Use |
25
- | --- | --- | --- |
26
- | A sprite that falls | `setInterval(() => s.y += 5)` | `s.gravity = 2000` |
27
- | A sprite that moves at constant speed | timer updating `x` | `s.velocityX = 300` |
28
- | Move from A to B over time | manual easing loop | `s.animate({ x, y, duration, easing })` |
29
- | A car that drives and drifts | your own physics | `carMode: true` + `throttle`/`steering` |
30
- | A ship with inertia | vector math per frame | `thrust` + `angularVelocity` |
31
- | Drag a sprite with the finger | `touchmove` handler | `draggable: true` |
32
- | Scrolling background under a fixed camera | timer repositioning copies | `wrapX` / `wrapShift` + `velocityX` |
33
- | Parallax layers under a moving camera | hand-scrolled layers | `scrollFactor` on each layer |
34
- | Camera that follows the player | timer writing `cameraX` | `gameView.follow(sprite, options)` |
35
- | A score, a title, a label | `Ti.UI.Label` overlays | `Game.createText({ text, screenFixed: true })` |
36
- | A name tag, health bar or turret that rides a sprite | a timer copying `owner.x`/`y` onto it | `tag.attachTo(owner, { offsetY: -40 })` |
37
- | A dialog box that fits the screen | breaking the lines with `\n` after measuring in JS | `maxWidth` on the text sprite |
38
- | A bullet fast enough to skip past a thin wall | shrinking the time step | `swept: true` on the bullet |
39
- | To know the player *left* a zone | polling on a timer | the `collisionend` event |
40
- | An enemy on a fixed patrol route | chained `animate()` legs | `sprite.followPath(points, { loop })` |
41
- | Play an attack, then go back to idle | an `animationcomplete` handler | `play('attack', { then: 'idle' })` |
42
- | Line of sight, a ledge probe, a hitscan shot | an invisible probe sprite | `gameView.raycast(x0, y0, x1, y1, ['group'])` |
43
- | Walking around obstacles to a tapped point | your own A* in JS, or a straight line through the wall | `gameView.findPath(from, to, opts)` → `sprite.followPath(path)` |
44
- | A spawn wave or an AI tick that respects pause | `setInterval` | `gameView.every(ms, cb)` |
45
- | Smoke, sparks, explosions | pooled sprites in JS | `Game.createEmitter(...)` |
46
- | A swinging chain or cape | verlet solver in JS | `Game.createRope(...)` |
47
- | A tilted ramp a crate slides down | a staircase of small rectangles | `rotation` + `hitboxShape: 'rotatedRect'` on the ramp |
48
- | A round post or peg that deflects | a box solid and a JS correction | `hitboxShape: 'circle'` on the **solid**, not just on the ball |
49
- | Balls that knock each other around | separating pairs by hand in a loop | `solidMode: 'push'` on both, each listing the other's group |
50
- | A drum, bowl or cage holding balls in | a ring of wall sprites around the rim | one circle solid with `solidMode: 'contain'` |
51
- | A ball that rolls to a stop | decrementing `velocityX` on a timer | `linearDamping` |
52
- | Wind, or a conveyor pull | `velocityX -= k` every tick | `gravityX` |
53
- | Slow motion or a pause that still draws | pausing your own timers | `gameView.timeScale = 0.5` / `0` |
7
+ # ti.game — native 2D games for Titanium SDK
8
+
9
+ `ti.game` is Michael Gangolf's OpenGL ES 2.0 sprite engine for Titanium. JavaScript describes the scene and reacts to discrete events; Android and iOS run rendering, animation, physics, collision, gestures, audio, particles, ropes, tile layers, camera work, and telemetry natively.
10
+
11
+ This skill is verified against upstream `main` at **`d587081` (2026-08-28)**. Both manifests still say **`0.5.0`**, although important features landed after that version was introduced. Treat the commit or build date as the capability marker; feature-detect an installed build when the exact artifact is unknown.
12
+
13
+ The skill applies to **Classic and Alloy** projects. Nothing here requires PurgeTSS.
14
+
15
+ ## Required workflow
16
+
17
+ Open the references relevant to the task before proposing code. Do not answer from familiarity with Phaser, Godot, Box2D, or an older `ti.game` build.
18
+
19
+ | Task | Read first |
20
+ | --- | --- |
21
+ | Exact methods, properties, defaults, event payloads, percentages | [references/api.md](references/api.md) |
22
+ | Install, Classic/Alloy structure, assets, cleanup, lifecycle | [references/project-setup.md](references/project-setup.md) |
23
+ | Choose a runnable upstream example | [references/examples.md](references/examples.md) |
24
+ | Reusable level, pooling, HUD, collision, path, timer patterns | [references/patterns.md](references/patterns.md) |
25
+ | Platformer, racer, shooter, runner, rhythm, ball-game recipes | [references/recipes-action.md](references/recipes-action.md) |
26
+ | Cards, point-and-click, particles, ropes, camera, effects | [references/recipes-adventure-effects.md](references/recipes-adventure-effects.md) |
27
+ | Large maps, Tiled data, tile collision, live edits, pathfinding | [references/tilemaps.md](references/tilemaps.md) |
28
+ | Hitbox overlays, performance HUD, telemetry, tuning | [references/debugging-performance.md](references/debugging-performance.md) |
29
+ | Missing, partial, and deliberately out-of-scope features | [references/roadmap.md](references/roadmap.md) |
30
+
31
+ In the response, cite factual guidance with `[source: references/<file>.md]`. If a relevant reference cannot be consulted, prefix the claim with `FROM_MEMORY (unverified):` instead of silently filling the gap.
32
+
33
+ The module's demos are integration evidence, not production templates. Preserve the API combination they prove, but remove launcher plumbing, repeated navigation controls, and avoidable global state when adapting them.
34
+
35
+ ## The engine rule
36
+
37
+ **JS describes and reacts. The engine runs every frame.**
38
+
39
+ Do not move sprites from `setInterval`, `requestAnimationFrame`, or a JS loop. Property writes cross the Kroll bridge; native properties and methods keep frame-by-frame work on the render thread. Coarse timers are appropriate for decisions such as enemy targeting or spawn cadence, preferably through `gameView.after()` / `every()` when they must obey pause and `timeScale`.
40
+
41
+ | Intent | Native mechanism |
42
+ | --- | --- |
43
+ | Fall or move continuously | `gravity`, `gravityX`, `velocityX`, `velocityY` |
44
+ | Move to a point | `animate()` or `followPath()` |
45
+ | Drive or fly | `carMode` + `throttle`/`steering`, or `thrust` + `angularVelocity` |
46
+ | Drag, pinch, rotate | `draggable`, `pinchable`, `rotatable` |
47
+ | Follow the player | `gameView.follow()` |
48
+ | Walk around obstacles | `findPath()` → `followPath()` |
49
+ | Test line of sight or hitscan | `raycast()` |
50
+ | Fast projectile collision | `swept: true` on the mover |
51
+ | Repeat a large map efficiently | `Game.createTileLayer()` |
52
+ | Attach a label, health bar, or turret | `attachTo()` |
53
+ | Score or in-game label | `Game.createText()`; usually `screenFixed: true` for a HUD |
54
+ | Smoke, sparks, explosions | `Game.createEmitter()` |
55
+ | Chain, tether, cape | `Game.createRope()` |
56
+ | Pause or slow the scene while still drawing | `gameView.timeScale` |
57
+ | Measure FPS, frame time, and draw calls | `debug: { hud: true }` or the `performance` event |
54
58
 
55
59
  ## Minimal scene
56
60
 
@@ -59,34 +63,31 @@ const Game = require('ti.game');
59
63
 
60
64
  const win = Ti.UI.createWindow({
61
65
  backgroundColor: '#000',
62
- navBarHidden: true, // iOS: no nav bar
63
- theme: 'Theme.Titanium.DayNight.NoTitleBar' // Android: no title bar
66
+ navBarHidden: true,
67
+ theme: 'Theme.Titanium.DayNight.NoTitleBar'
68
+ });
69
+ const gameView = Game.createGameView({
70
+ backgroundColor: '#8ed8f8',
71
+ maxFps: 60
64
72
  });
65
- const gameView = Game.createGameView({ backgroundColor: '#8ed8f8' });
66
-
67
73
  const sheet = Game.createSpriteSheet({
68
74
  image: 'assets/hero.png',
69
75
  frameWidth: 32,
70
76
  frameHeight: 48,
71
- smoothing: false // nearest-neighbor — crisp pixel art
77
+ smoothing: false
72
78
  });
73
79
 
74
80
  let initialized = false;
75
- gameView.addEventListener('resize', (e) => {
81
+ gameView.addEventListener('resize', ({ width, height }) => {
76
82
  if (initialized) {
77
83
  return;
78
84
  }
79
85
  initialized = true;
80
- buildLevel(e.width, e.height);
81
- });
82
86
 
83
- function buildLevel(W, H) {
84
87
  const hero = Game.createSprite({
85
- sheet: sheet,
86
- x: W / 2,
87
- y: H / 2,
88
- width: 96,
89
- height: 144,
88
+ sheet,
89
+ x: width / 2,
90
+ y: height / 2,
90
91
  animations: {
91
92
  idle: { frames: [0], fps: 1, loop: true },
92
93
  walk: { frames: [1, 2], fps: 6, loop: true }
@@ -94,150 +95,40 @@ function buildLevel(W, H) {
94
95
  });
95
96
  hero.play('idle');
96
97
  gameView.add(hero);
97
- }
98
+ });
98
99
 
99
100
  win.add(gameView);
100
101
  win.open();
101
102
  ```
102
103
 
103
- ### Build the level on `resize`, never from `displayCaps`
104
-
105
- `Ti.Platform.displayCaps` includes the system bars and reports **points** on iOS, while the scene lives in surface **pixels**. Sizing a level from it puts bottom-anchored sprites below the visible area. The GameView fires `resize` with the real surface size — that is the coordinate space. The guard flag matters: `resize` fires again on rotation and on some lifecycle transitions, and rebuilding the level there would duplicate every sprite.
106
-
107
- When you genuinely need dp→scene units (to keep a floor above 80dp on-screen buttons), measure the surface rather than trusting the density factor, because the iOS Simulator renders at 1x while still reporting the device scale:
108
-
109
- ```javascript
110
- const density = Ti.Platform.osname === 'android'
111
- ? Ti.Platform.displayCaps.logicalDensityFactor
112
- : H / Ti.Platform.displayCaps.platformHeight;
113
- const buttonZone = Math.round(130 * density);
114
- const groundTop = H - buttonZone;
115
- ```
116
-
117
- ### Coordinates
118
-
119
- Top-left origin, y-down, surface pixels. Touch coordinates map 1:1. `(x, y)` is the sprite's **anchor** (default `0.5/0.5` = center, so `y` is the middle of the sprite, not its top). Rotation in degrees, positive clockwise. All JS durations are milliseconds.
120
-
121
- ## Pick a movement model
122
-
123
- | Model | Properties | Fits |
124
- | --- | --- | --- |
125
- | Plain velocity | `velocityX/Y` (px/s), `gravity` (px/s²) | Flappy, projectiles, falling objects, notes |
126
- | Platformer | velocity + `solidWith`, read-only `onGround`, `land` event | Mario-style run & jump |
127
- | Bouncing body | `restitution` (0..1) on **either** side of the contact, on top of `solidWith` | Balls, pinball, breakout |
128
- | Rolling body | `hitboxShape: 'circle'` + `linearDamping`, and `solidMode: 'push'` when they hit each other | Pool, plinko, a bingo drum |
129
- | Car | `carMode: true`, `throttle`, `steering` (-1..1) | Top-down racer with emergent drift |
130
- | Newtonian flight | `thrust` (px/s² along heading), `angularVelocity` (deg/s), `maxSpeed` | Asteroids, lunar lander |
131
- | Tween | `animate({ x, y, duration, easing })` | Point-and-click walking, UI cards, patrols |
132
-
133
- Gravity is per sprite, not global — set it on each body that should fall. A sprite with no `gravity` and no velocity simply stays put.
134
-
135
- ## Block versus react: `solidWith` and `collidesWith` are independent
136
-
137
- - `solidWith: ['ground']` **blocks** movement against sprites tagged `collisionGroup: 'ground'`. The engine pushes the sprite out along the axis of least penetration, sets read-only `onGround` and fires `land`.
138
- - `collidesWith: ['coin', 'enemy']` **reports** overlap. Fires `collision` once per overlap-enter and `collisionend` once on separation (both carry `group`, `other`, `x`, `y`). Nothing is blocked.
139
-
140
- A sprite can use both — a player that stands on platforms (`solidWith`) and dies on spikes (`collidesWith`).
141
-
142
- The enter/exit pair is the trigger lifecycle other engines call `OnTriggerEnter`/`Exit`: water that tints the hero while he is in it, a pressure plate holding a door open exactly while something rests on it. Removing or hiding the partner mid-contact also counts as separation, so a despawned object still closes its door. There is deliberately **no** per-frame "stay" event — keep that state in JS.
143
-
144
- A fast mover can slip through a thin target between frames: it never overlaps on any single frame, so the discrete test misses. `swept: true` on the *mover* tests its movement as a path — `collision` fires for whatever the path crossed, and `solidWith` walls stop it at the impact point instead of letting it teleport through.
145
-
146
- When the question is not "did we touch?" but "what is over there?", the answer is `gameView.raycast(x0, y0, x1, y1, ['group'])` — line of sight, a ledge probe ahead of a walker's feet, a hitscan shot. It returns `null` or the nearest hit (`{ x, y, distance, group, sprite, normal }`), needs nothing but a `collisionGroup` on the targets, and replaces the old trick of parking an invisible probe sprite where the ray would land. It is a discrete query: call it from a decision timer or a tap handler, never every frame.
147
-
148
- And when the question is "how do I get over there?", `gameView.findPath(from, to, { cellSize, groups, clearance, bounds })` runs grid A* around the same tagged sprites and hands back waypoints for `followPath` — tap-to-walk that rounds a tree, a hound re-routing to the player on an 800 ms timer. Also discrete: the walk itself runs natively between queries, so pathfind on taps and AI ticks, never per frame. Pass `clearance` of about half the walker's width, since the route is a line for the sprite's **center**.
149
-
150
- **A sprite with `width`/`height` but no `sheet` renders nothing and works as an invisible trigger.** Score zones, goals, checkpoints, ceilings, kill floors and walls are all built this way in the official demos. This is the idiomatic way to add logic to a scene without art.
151
-
152
- Tune fairness with `hitboxScale` (art rarely fills its frame; slightly small hitboxes feel better), `hitboxScaleX`/`hitboxScaleY` when the drawing fills its frame by a different fraction on each axis (a 20×44 hero in a 32×48 frame wants `0.62` wide and `0.92` tall — the two multiply `hitboxScale` rather than replace it), and `hitboxShape: 'circle'` for balls and rocks — circles also resolve against solids along the contact normal, so they bounce off corners diagonally instead of like a box. Since 2026-08-27 the **solid's** shape counts too: a `'circle'` on the post makes the normal run centre to centre, and `'rotatedRect'` keeps a tilted platform's box turned with its art instead of re-boxing it square to the screen — which is what lets a crate slide down a ramp rather than stand on an invisible ledge. Two more properties live on the solid: `solidMode: 'contain'` holds matched circles *inside* a circular boundary with no ring of wall sprites, and `solidMode: 'push'` makes a pair of circles bodies that split the separation and trade momentum. `'push'` needs both sides to agree — both circles, both `'push'`, and each listing the other's `collisionGroup` — or it silently degrades to one ball shoving an immovable one. Turn on `debug: true` per sprite, or on the GameView for everything, to see green collision AABBs, blue touch bounds and the orange anchor dot.
153
-
154
- The hitbox shrinks **around the anchor**, which is why the anchor is half of that tuning and why both now have readable forms: `anchor: 'bottom'` with `hitboxScaleY: '55%'` is the same sprite as `anchorY: 1` with `hitboxScaleY: 0.55`, and says what it is doing. Every ratio the engine exposes takes a percentage string, and the anchors take names (`'left'`, `'bottom-right'`, `'center'`); numbers keep working untouched. [api.md](references/api.md) lists exactly which properties are ratios — coordinates, sizes, degrees and speeds are not, and neither is the car model's `grip`/`drag`.
155
-
156
- ## Performance rules that actually matter
157
-
158
- - **One texture = one draw call.** The batcher accumulates up to 1000 quads and flushes on texture switch. A scene whose sprites all share one sheet renders in a single draw call regardless of sprite count. Pack art into as few sheets as possible; reuse one white particle texture and tint it at runtime.
159
- - **Know what else breaks the batch**, since none of it is visible on screen: every blend-mode transition in draw order flushes — there are four (`'normal'`, `'add'`, `'multiply'`, `'screen'`), so group same-blend sprites into their own `zIndex` band and the frame switches a handful of times instead of constantly; each glowing sprite costs 2 extra draw calls per frame, plus 2 more while a `flash()` runs; ropes, skid marks and debug overlays always draw with normal blending, so interleaving them with non-normal content flushes too.
160
- - **Bulk-add a level.** `gameView.add([spriteA, spriteB, emitter, rope])` crosses the bridge once and commits under one scene lock. Collect a level's objects in an array and add them in one call; `add(object)` still works for single objects.
161
- - **Keep collision group lists targeted.** Cost is O(colliders × candidates) per frame. A bullet checking `['asteroid']` tests 5 sprites, not the whole scene.
162
- - **Pool, don't create.** Bullets, notes, obstacles and enemies are created once (usually 3–10 of them), then recycled by setting `visible = false` and zeroing velocity. Creating sprites mid-game costs a bridge hop and a scene lock.
163
- - **Read properties in handlers, not in loops.** `ball.x` is a synchronous snapshot of the live native value — perfect inside a `collision` handler or an 80 ms AI tick, wasteful in a tight loop.
164
- - **`maxFps: 60`** on the GameView keeps 120 Hz ProMotion displays from doubling render work. `0` (default) follows the display refresh rate.
165
- - **Judge performance on device.** The iOS Simulator's OpenGL translation layer is disproportionately slow and renders at a 1x drawable; real devices keep native screen scale and run far smoother.
166
-
167
- ## Text is a sprite
168
-
169
- Text draws **inside** the scene: `Game.createText({ text: 'SCORE 0' })` returns a sprite made of glyph quads, so it z-sorts, tweens, tints, flashes, glows, wobbles on `idleAnimation` and takes `tap` events like any other sprite — a text button needs no overlay view. With no `font` it uses a pixel font embedded in the module (nothing to ship); `Game.createFont({...})` loads a BMFont/AngelCode atlas or a monospace grid image. There is no font size — scale the sprite, with integer values for crisp pixels.
170
-
171
- ```javascript
172
- const score = Game.createText({ text: 'SCORE 0', screenFixed: true, x: W / 2, y: H * 0.08, scale: 3, zIndex: 100 });
173
- gameView.add(score);
174
- score.text = 'SCORE 10'; // re-lays out natively
175
- ```
176
-
177
- One long string wraps itself: `maxWidth` breaks lines on word boundaries and re-wraps every time `text` changes, so a dialog box needs no hand-broken `\n`. It is measured in font-space px, *before* `scale` — divide the screen width you want by the scale you are drawing at.
178
-
179
- `screenFixed: true` works on **any** sprite: `x`/`y` become surface coordinates and camera position, zoom and shake are ignored, while touch still maps back. That is the HUD pattern — the score stays put while the camera follows the player. Without it, text is world-space and scrolls past like scenery (signposts, floating damage numbers).
180
-
181
- A label that belongs *on* a moving sprite is neither: `tag.attachTo(hero, { offsetY: -40 })` pins it natively, resolved every frame after physics and before collisions, so it never lags by a frame and needs no JS. It works on any sprite, not just text — health bars, shadows, an invisible hitbox on a body, and with `rotate: true` a turret whose offset swings with the tank. Removing the owner removes what is attached to it, and fading the owner fades them with it: position, rotation and opacity are inherited. Scale, visibility and tint are not — set those per sprite.
182
-
183
- Overlaid `Ti.UI.Label` views still work and are still right for anything the engine cannot draw: native inputs, system fonts, accented text the built-in font lacks, scrollable menus. Just do not reach for them for a score any more.
184
-
185
- Standard Titanium touch events do not reach the GameView, but ordinary Titanium buttons and views layered on top work normally, and sibling views receive simultaneous pointers — which is how the demos build multitouch d-pads (hold ▶ and press jump at the same time). One caveat from the demos: Android's `touchFeedback` ripple cannot animate over this canvas and spams `RippleDrawable` errors, so on-screen buttons set their own `backgroundColor` on `touchstart`/`touchend`.
186
-
187
- ## Clean up what you created
104
+ ## Non-negotiable invariants
188
105
 
189
- The render loop follows the activity lifecycle automatically, but **your JS timers and sounds do not**. Every demo that starts an interval or plays looping music stops it on window close. In Alloy, wire this into the controller's `cleanup()`.
106
+ - Build surface-dependent content from the GameView's guarded `resize` event. `Ti.Platform.displayCaps` is not the scene surface and can include system bars or different units.
107
+ - Coordinates use a top-left origin, y increases downward, and `(x, y)` is the sprite anchor. Durations crossing the JS boundary are milliseconds.
108
+ - `solidWith` blocks; `collidesWith` reports `collision`/`collisionend`. They are independent. A sized sprite without a sheet is a valid invisible solid or trigger.
109
+ - Put the hitbox shape on the object whose geometry matters. A round peg needs `hitboxShape: 'circle'`; a ramp needs `'rotatedRect'`.
110
+ - A `TileLayer` replaces one-sprite-per-tile maps when the map is large. Its visible-cell renderer, cell collision, live edits, and pathfinding integration are native.
111
+ - Collect initial sprites, emitters, ropes, and tile layers and add them in one `gameView.add([...])` call.
112
+ - Use `Game.createText()` for scene text. Use Titanium views only for UI the engine cannot draw, such as native input controls or system-font interfaces.
113
+ - Stop app-owned JS timers, looping sounds, and listeners on close/controller cleanup. `removeAllSprites()` clears Sprite/Text objects only; explicitly remove tracked emitters, ropes, and tile layers. `SpriteSheet.unload()` and `Font.unload()` are permanent level-streaming operations, not cache eviction.
114
+ - Check [references/roadmap.md](references/roadmap.md) before naming an API that merely exists in another engine.
190
115
 
191
- Two things the engine now cleans up on its own, so do not hand-roll them: a **LiveView reload** retires the previous runtime's render loops, `SoundPool`, `MediaPlayer`s and audio proxies, so reloads stop stacking game loops; and releasing a sheet or font frees its GL texture. What you *can* now do explicitly is `spriteSheet.unload()` / `font.unload()` when streaming levels — permanent, so only after nothing draws from them.
116
+ ## Source priority
192
117
 
193
- Game-clock timers (`gameView.after` / `every`) are the exception that proves the rule: they run on the engine's clock, so they freeze at `timeScale: 0` and pause with the render loop instead of firing into a dead scene. That is why spawn waves and AI ticks belong there. A `setInterval` keeps running through a pause, which is right for a real-time countdown and wrong for everything else.
118
+ When sources disagree, use this order:
194
119
 
195
- ```javascript
196
- win.addEventListener('close', () => {
197
- clearInterval(aiTimer);
198
- clearTimeout(spawnTimer);
199
- music.stop(); // a looping track outlives the window otherwise
200
- gameView.pause();
201
- gameView.removeAllSprites();
202
- });
203
- ```
120
+ 1. Android and iOS native proxy/engine code for actual behavior and parity.
121
+ 2. `example/*.js` for combinations exercised by the module, not code-quality conventions.
122
+ 3. Upstream `README.md` and `TUTORIAL.md` for intended public usage.
123
+ 4. Upstream `TODO.md` for what remains partial or absent.
204
124
 
205
- ## Reference files
125
+ Known upstream prose drift at the pinned commit: the README says 26 demos although there are 33; its camera row names the wrong HUD corner; and it says every `TileLayer` property is live although Android treats `legend`, `firstGid`, `cols`, and `rows` as creation-time configuration. The references already normalize those differences.
206
126
 
207
- Read the one that matches the task instead of guessing — the API surface below was verified against the module source, not reconstructed from memory.
127
+ ## Before returning code
208
128
 
209
- | Need | Reference |
210
- | --- | --- |
211
- | Exact properties, methods, events, defaults for every object | [api.md](references/api.md) |
212
- | Working code per genre: flappy, platformer, top-down, racer, asteroids, endless runner, rhythm, cards, point & click, particles, ropes, camera — plus patrol paths, raycast probes, pathfinding, parallax and game-clock timers | [recipes.md](references/recipes.md) |
213
- | Install the module, tiapp.xml, Alloy vs Classic wiring, assets, sprite sheets, atlases, lifecycle, testing | [project-setup.md](references/project-setup.md) |
214
- | What does NOT exist yet, what is coming, and how to work around it today | [roadmap.md](references/roadmap.md) |
215
-
216
- When this skill runs out — a brand-new feature, a demo's exact code, a build question — read upstream itself at https://github.com/m1ga/ti.game. Each file there is authoritative for something different:
217
-
218
- | Upstream file | What it is authoritative for |
219
- | --- | --- |
220
- | `README.md` | The module's own API documentation, and the demo index. Canonical, but it has lagged the code: verify a defaulted value against the source before relying on it |
221
- | `example/` | 32 runnable demos, one per feature area — the reference implementations this skill's recipes are distilled from |
222
- | `TODO.md` | What the maintainer has planned, with priorities. The source of [roadmap.md](references/roadmap.md) |
223
- | `tutorial.md` | A step-by-step first scene (sprite, animation, tap-to-move). For a human learning the module, not for looking things up |
224
- | `ios/README.md` | The iOS port's threading, coordinates and touch constants. Mostly for module developers; the app-facing facts are already here |
225
- | `AGENTS.md` | Rules for changing the module itself — irrelevant to building a game with it |
226
- | `android/src/ti/game/`, `ios/Classes/` | The truth. Defaults, clamping and edge cases in this skill were read here, not from prose |
227
-
228
- ## Before writing game code, check these
229
-
230
- 1. Is the level built inside a guarded `resize` handler, using `e.width`/`e.height`?
231
- 2. Does any timer move a sprite? Replace it with velocity, gravity, a tween, `carMode` or `thrust`.
232
- 3. Are all the level's objects added in one `gameView.add([...])` call?
233
- 4. Does every moving body have the right model — `solidWith` to be blocked, `collidesWith` to be notified?
234
- 5. Is `hitboxScale` set on sprites whose art does not fill the frame — with `hitboxScaleX`/`hitboxScaleY` when the fit differs per axis, and the `anchor` the box shrinks around named rather than left as a bare `1`?
235
- 6. Do triggers (scores, goals, kill zones) use sheet-less sprites instead of visible art?
236
- 7. Is every `createSound`, `setInterval` and `setTimeout` stopped when the window closes?
237
- 8. Is the HUD `Game.createText` with `screenFixed: true`, rather than a stack of overlaid labels?
238
- 9. Does anything travel far enough per frame to tunnel? Give it `swept: true`.
239
- 10. Does a timer drive game logic (spawn waves, AI ticks, respawns)? Move it to `gameView.every`/`after` so it obeys pause and slow motion.
240
- 11. Does anything ride on top of a moving sprite — a name tag, a health bar, a turret, a hitbox? `attachTo` pins it natively; a timer copying coordinates is the old workaround.
241
- 12. Is a solid's own **shape** doing the work, or is JS correcting for a box that is not what the art shows? A round post wants `hitboxShape: 'circle'` on itself; a tilted one wants `'rotatedRect'`.
242
- 13. Did anything get `restitution` on a *surface*? Every rider on it now bounces, and a hard landing on it stops setting `onGround` — check whatever gates jumps.
243
- 14. Are you using an API that actually exists? Anything involving tilemaps, full parent/child transforms, joysticks, gamepads, or the platformer *feel* on a slope (step-up, friction along the contact) — check [roadmap.md](references/roadmap.md) first.
129
+ - Confirm the installed module build exposes every API used; the manifest version alone is insufficient.
130
+ - Keep per-frame work native and JS event-driven.
131
+ - Prefer a demo-backed combination, then refactor it into focused, maintainable code.
132
+ - Preserve Android/iOS parity; do not rely on an Android-only permissive argument shape.
133
+ - State partial engine behavior honestly, especially slopes, tile triggers/raycast, parenting, audio polish, and input devices.
134
+ - Cite the references that support the result.
@@ -1,8 +1,8 @@
1
1
  # ti.game API reference
2
2
 
3
- Complete JS surface of `ti.game` as of upstream `main` at `ee69056`, 2026-08-27, verified against the module source (`android/src/ti/game/*.java` and `engine/*.java`, mirrored by `ios/Classes/TG*`). Defaults come from the engine fields, not from prose. The manifest reads **`0.5.0`** since 2026-08-27, and that bump is the shape-aware solids: `hitboxShape: 'rotatedRect'`, circular solids, `solidMode`, `gravityX`, `linearDamping` and two-sided `restitution`. Read the number as a floor, never as a feature list. Under `0.3.0` the engine gained the text engine (`createFont`/`createText`), `screenFixed`, `swept`, `collisionend`, `followPath`, animation chaining, `raycast`, `findPath`, the game-clock timers, `scrollFactor` and the `multiply`/`screen` blend modes; under `0.4.0` it gained `hitboxScaleX`/`hitboxScaleY`, text `maxWidth`, `SpriteSheet.unload()`/`Font.unload()`, the LiveView renderer lifecycle (all 2026-08-23), `attachTo`/`detach`, inherited opacity on attachments and the named/percentage values (all 2026-08-26). Two builds calling themselves 0.4.0 therefore differ by more than two builds calling themselves 0.4.0 and 0.5.0 do — check the build date, or feature-detect by reading the property **before** ever writing it (`typeof sprite.linearDamping === 'undefined'` means the build predates it). Writing first proves nothing: an unknown property is stored on the Kroll proxy and reads back exactly as it was set. Methods are the reliable probe — `typeof sprite.attachTo === 'function'` cannot be faked by a previous write. So is a property the engine **normalizes**: writing `sprite.solidMode = 'nonsense'` reads back `'block'` on a build that implements it and `'nonsense'` on one that only stored it on the proxy, which makes it the one probe that survives having been written to.
3
+ Complete JS surface of `ti.game` as of upstream `main` at `d587081`, 2026-08-28, verified against every Android proxy/engine and its iOS twin. Defaults come from native fields, not prose. The manifests still read **`0.5.0`**, but the performance HUD/telemetry and native `TileLayer` landed afterward without another bump. Read the number as a floor, never as a feature list. Older `0.3.0`/`0.4.0` artifacts also vary by build date; feature-detect when the artifact is unknown. Read a property before writing it, because Kroll can retain an unknown write on the proxy. Methods are safer probes (`typeof Game.createTileLayer === 'function'`), as are normalized properties (`solidMode` reads an unknown name back as `'block'` only when the engine implements it).
4
4
 
5
- Every property listed is **live**: reading returns the current native value, even mid-drag or mid-tween. Every property can also be passed to its `create*` factory. All durations crossing the JS boundary are **milliseconds** (the engine converts to seconds internally).
5
+ Properties are live unless their table says creation-time or read-only. Every writable property can be passed to its `create*` factory. `TileLayer` has four cross-platform creation-time inputs (`legend`, `firstGid`, `cols`, `rows`) because Android does not expose the live setters that iOS has. All durations crossing the JS boundary are **milliseconds**.
6
6
 
7
7
  <!-- TOC-START -->
8
8
  ## Contents
@@ -17,6 +17,7 @@ Every property listed is **live**: reading returns the current native value, eve
17
17
  - [Sound](#sound)
18
18
  - [Emitter](#emitter)
19
19
  - [Rope](#rope)
20
+ - [TileLayer](#tilelayer)
20
21
  - [Events at a glance](#events-at-a-glance)
21
22
  - [Gotchas the property tables do not show](#gotchas-the-property-tables-do-not-show)
22
23
 
@@ -38,6 +39,7 @@ const Game = require('ti.game');
38
39
  | `Game.createSound(options)` | Sound |
39
40
  | `Game.createEmitter(options)` | Emitter — particle system |
40
41
  | `Game.createRope(options)` | Rope — Verlet chain |
42
+ | `Game.createTileLayer(options)` | TileLayer — visible-cell tile renderer and collision grid |
41
43
 
42
44
  Easing constants (for `sprite.animate({ easing })`): `Game.EASE_LINEAR`, `Game.EASE_IN`, `Game.EASE_OUT`, `Game.EASE_IN_OUT`, `Game.EASE_BOUNCE`, `Game.EASE_ELASTIC`.
43
45
 
@@ -60,6 +62,7 @@ Game.createSprite({ anchor: 'bottom-left', hitboxScaleY: '55%', opacity: '80%' }
60
62
  | Emitter | `startScale`, `endScale`, `startOpacity`, `endOpacity` |
61
63
  | Rope | `damping` |
62
64
  | Sound | `volume` |
65
+ | TileLayer | `opacity`, `scrollFactor`, `restitution` |
63
66
 
64
67
  Over 100% is meaningful where the ratio is a multiplier — `scaleX: '200%'` is twice as wide. Text sprites are sprites, so they take all of it (`Game.createText({ anchor: 'top-left', opacity: '60%' })`).
65
68
 
@@ -92,9 +95,9 @@ A normal Titanium view — add it to a window or any container, size it with the
92
95
 
93
96
  | Member | Type | Default | Notes |
94
97
  | --- | --- | --- | --- |
95
- | `add(object)` / `add([objects])` | method | — | Accepts Sprite, Emitter or Rope. An array crosses the bridge once and is committed under a single native scene lock — build a level into an array and add it in one call |
98
+ | `add(object)` / `add([objects])` | method | — | Accepts Sprite/Text, Emitter, Rope, or TileLayer. An array crosses the bridge once and is committed under native scene locks — build a level into an array and add it in one call |
96
99
  | `remove(object)` | method | — | Remove one object from the scene — **and, recursively, every sprite attached to it** (see [Attachment](#attachment)) |
97
- | `removeAllSprites()` | method | — | Clear the whole scene, dropping every attachment with it |
100
+ | `removeAllSprites()` | method | — | Clears Sprite/Text objects and their attachments. It does **not** remove emitters, ropes, or tile layers; track and remove those separately |
98
101
  | `pause()` / `resume()` | method | — | Render loop control. The activity/app lifecycle already pauses and resumes automatically |
99
102
  | `backgroundColor` | string | — | GL clear color |
100
103
  | `maxFps` | int | `0` | Frame cap. `60` stops 120 Hz ProMotion displays from doubling render work; `0` = display refresh rate |
@@ -107,16 +110,16 @@ A normal Titanium view — add it to a window or any container, size it with the
107
110
  | `stopFollow()` | method | — | Stop following; the camera stays where it is |
108
111
  | `shake({ strength, duration })` | method | `12` px, `400` ms | Detuned-sine rumble on the projection only — follow, bounds and touch mapping are unaffected |
109
112
  | `raycast(x0, y0, x1, y1, groups)` | method | — | One-shot nearest-hit query along the segment against **visible, non-`screenFixed`** sprites carrying a `collisionGroup` in `groups` (omit for any tagged sprite). Returns `null` for a clear ray, else `{ x, y, distance, group, sprite, normal: { x, y } }`. Rect hitboxes are tested as their AABB, circle hitboxes exactly; a ray starting inside a hitbox reports it at distance 0 |
110
- | `findPath(from, to, options)` | method | — | Grid A\* from `from` to `to` (`{ x, y }` world points) around the same sprites `raycast` sees — **visible, non-`screenFixed`**, carrying a `collisionGroup` in `options.groups` (omit for any tagged sprite). Returns an array of `{ x, y }` waypoints ready for `sprite.followPath()`, or `null` when no route exists. Options below |
113
+ | `findPath(from, to, options)` | method | — | Grid A\* from `from` to `to` (`{ x, y }` world points) around visible, non-`screenFixed` sprites and fully solid TileLayer cells carrying a `collisionGroup` in `options.groups` (omit for any tagged obstacle). Returns `{ x, y }` waypoints ready for `sprite.followPath()`, or `null`. Options below |
111
114
  | `after(ms, callback)` | method | — | Runs `callback` once after `ms` of **game time** — the delay stretches under `timeScale` slow motion, freezes at `0` and pauses with the render loop, unlike `setTimeout`. Returns an int id. The callback receives `{ id }` |
112
115
  | `every(ms, callback)` | method | — | Like `after()`, repeating until cancelled. Fires at most once per frame, and restarts its interval after a stall instead of bursting to catch up |
113
116
  | `cancelTimer(id)` | method | — | Cancels a timer from `after()` / `every()` |
114
117
  | `cameraEffect` | string | `'none'` | Fullscreen shader pass: `'none'`, `'tint'`, `'glitch'`. With `'none'` the extra pass is skipped entirely |
115
118
  | `cameraTint` | string | — | Color for the `'tint'` effect, e.g. `'#4f8'` (night vision, poison, flashback) |
116
119
  | `cameraEffectIntensity` | float | `1` | 0..1 — tint mix or glitch amount |
117
- | `debug` | bool | `false` | Draw collision shapes for every sprite in the scene |
120
+ | `debug` | bool / dict | `false` | `true` draws all collision shapes. Object form: `{ hitbox, hud, hudFont }`; `hud` is `true` or a corner name. Readback is normalized to `{ hitbox, hud: false | corner }`. See [debugging-performance.md](debugging-performance.md) |
118
121
 
119
- Events: `press`, `tap`, `release` — fired for **every** touch anywhere on the view, with payload `x`, `y` in scene coordinates (tap-anywhere controls, flappy-style). And `resize` with payload `width`, `height` — the real surface size in pixels.
122
+ Events: `press`, `tap`, `release` — fired for **every** touch anywhere on the view, with payload `x`, `y` in scene coordinates (tap-anywhere controls, flappy-style). `resize` carries the real surface `width`, `height` in pixels. `performance` carries a one-second telemetry snapshot only while a listener exists; see [debugging-performance.md](debugging-performance.md).
120
123
 
121
124
  Plus `timer` with payload `id`, fired only for `after()` / `every()` calls made **without** a callback — pass a callback or listen for the event, not both.
122
125
 
@@ -142,7 +145,7 @@ Anything that is not a sprite — `null`, a plain object, a number — clears th
142
145
  | Option | Default | Effect |
143
146
  | --- | --- | --- |
144
147
  | `cellSize` | `32` | Grid resolution in px. Match it to the tile size on a tile map so the grid lines up with the walls; a coarser grid is faster and blockier |
145
- | `groups` | all tagged sprites | Array of `collisionGroup` names to treat as obstacles |
148
+ | `groups` | all tagged obstacles | Array of sprite or TileLayer `collisionGroup` names to treat as obstacles |
146
149
  | `clearance` | `0` | Extra obstacle inflation in px. About half the walker's width keeps it from scraping corners, since the path is a line for the sprite's **center** |
147
150
  | `bounds` | the whole surface | `{ minX, minY, maxX, maxY }` search rect — also the walkable area, so a floor clamp goes here |
148
151
  | `diagonals` | `true` | Octile moves. Diagonals never cut a blocked corner |
@@ -150,7 +153,7 @@ Anything that is not a sprite — `null`, a plain object, a number — clears th
150
153
 
151
154
  Waypoints come back with the **exact** start and goal as first and last point, so the walk begins under the sprite's feet and ends on the tapped pixel. A blocked start or goal snaps outward to the nearest free cell (up to 4 rings), which is why tapping an obstacle walks to its edge instead of returning `null`. `null` means: no route, degenerate `bounds`, `cellSize <= 0`, or a grid over ~1M cells (a runaway `bounds`/`cellSize` combination fails the query rather than allocating).
152
155
 
153
- Everything is built per call on the calling thread — no persistent grid, no GL work. Like `raycast` it is a **discrete query**: run it on taps and AI timers (`gameView.every`), never per frame.
156
+ Everything is built per call on the calling thread — no persistent grid, no GL work. Fully solid TileLayer cells are rasterized; one-way cells are intentionally walkable. Like `raycast` it is a **discrete query**: run it on taps and AI timers (`gameView.every`), never per frame.
154
157
 
155
158
  ```javascript
156
159
  const path = gameView.findPath(
@@ -367,7 +370,7 @@ The rest of the contract, all read from the engine:
367
370
  - **A drag outranks the attachment — for position only.** While a finger holds the sprite (`draggable: true`, past the touch slop) the pinning is skipped, exactly as it is for a rider on a moving platform, and resumes when the finger lifts. The inherited opacity keeps applying throughout.
368
371
  - **Chains work**: attach a hat to a tag attached to a hero. Parents resolve first through recursion, with a depth cap of 8 that breaks accidental cycles — beyond eight links the far ancestor is not re-resolved that frame and the chain lags instead of hanging. Attaching a sprite to itself is ignored.
369
372
  - **Cross-space attach converts automatically.** A `screenFixed` sprite attached to a world sprite (or the reverse) has the target's position mapped through the camera first, so the offset stays in the sprite's own units.
370
- - **Removing the target removes everything attached to it**, recursively — a tag never outlives its owner, and a chain goes with it. `detach()` first to keep one alive. `removeAllSprites()` clears every attachment as it empties the scene.
373
+ - **Removing the target removes everything attached to it**, recursively — a tag never outlives its owner, and a chain goes with it. `detach()` first to keep one alive. `removeAllSprites()` clears every attachment as it empties the Sprite/Text list; emitters, ropes, and tile layers remain.
371
374
  - **A non-sprite target detaches** instead of crashing (Android logs a warning; iOS is silent). Like `follow()`, the binding takes an untyped argument on purpose — a typed proxy parameter aborts the app on the JNI type check when JS sends a plain object.
372
375
 
373
376
  ### Sprite events
@@ -387,7 +390,7 @@ The rest of the contract, all read from the engine:
387
390
  | `pathcomplete` | `x`, `y` | A non-looping `followPath` run reached the end |
388
391
  | `collision` | `group`, `other`, `x`, `y` | Overlap with a `collidesWith` group began |
389
392
  | `collisionend` | `group`, `other`, `x`, `y` | That overlap ended: the shapes separated, **or** the partner was removed from the scene, hidden (`visible = false`) or stopped matching the group filter |
390
- | `land` | `x`, `y`, `other`, `group` | Landed on top of a `solidWith` solid. `other` is the solid — read it to react to what you landed on (trampolines, hazards) |
393
+ | `land` | `x`, `y`, optional `other`, `group` | Landed on top of a `solidWith` solid. `other` is the solid sprite when one exists; TileLayer cells have no proxy and omit it |
391
394
 
392
395
  ## Font
393
396
 
@@ -535,13 +538,39 @@ A native Verlet chain: integration and distance constraints run in the game loop
535
538
 
536
539
  The tether yields at the end no finger owns: with a fixed head anchor the tail sprite is simply leashed, but with sprites on both ends you can drag either one and the other is towed once the rope goes taut.
537
540
 
541
+ ## TileLayer
542
+
543
+ One native grid replaces a sprite per tile. Only cells inside the camera are drawn, collision checks visit cells under the mover, and fully solid cells can feed `findPath`. Add/remove a layer through the GameView like other scene objects. It draws below sprites at the same `zIndex`.
544
+
545
+ | Group | Members | Notes |
546
+ | --- | --- | --- |
547
+ | Grid input | `data` | Nested numeric rows, string rows decoded through `legend`, or flat row-major ids sized by `cols`/`rows`. Live on both platforms |
548
+ | Creation-time grid configuration | `legend`, `firstGid`, `cols`, `rows` | Set in `createTileLayer()` for portability. `cols`/`rows` read back from the parsed grid; Android has no live setters for the four configuration inputs |
549
+ | Derived size | `width`, `height` | Read-only world dimensions |
550
+ | Placement | `x`, `y`, `tileWidth`, `tileHeight`, `zIndex`, `scrollFactor` | Tile sizes default to the sheet frame dimensions |
551
+ | Look | `sheet`, `visible`, `opacity`, `tintColor` | `opacity` and `scrollFactor` accept percentage strings |
552
+ | Collision | `collisionGroup`, `solid`, `oneWay`, `restitution`, `debug` | `solid`/`oneWay` accept tile ids or legend characters. `restitution` accepts a percentage |
553
+
554
+ Tiled data uses `firstGid`: gid 0 becomes empty when an offset is active, and flip bits are stripped before the frame id is calculated. The flip transform itself is not rendered.
555
+
556
+ | Method | Result |
557
+ | --- | --- |
558
+ | `getTile(col, row)` | Frame id, `-1` when empty or outside |
559
+ | `setTile(col, row, id)` | Replaces art and recalculates its flag from `solid`/`oneWay` |
560
+ | `isBlocked(col, row)` | Fully solid status; one-way cells answer `false` |
561
+ | `setBlocked(col, row, bool)` | Per-cell full-solid override without changing art |
562
+ | `tileAt(x, y)` | World point → `{ col, row, tile, solid, x, y }`, or `null` outside |
563
+ | `cellAt(col, row)` | Cell → the same structure; `x`/`y` are the center |
564
+
565
+ Movers list the layer's `collisionGroup` in `solidWith`. Rect, circle, swept, restitution, grounding, and `land` are supported, but the tile cell is not a proxy: a tile landing has no `other` sprite. Tile cells do not produce `collidesWith` trigger events and are not included in `raycast()` yet. Full workflow, Tiled input, live edits, and bounded A* are in [tilemaps.md](tilemaps.md).
566
+
538
567
  ## Events at a glance
539
568
 
540
569
  Only these events exist. Nothing fires per frame, and events are only fired if a listener is registered.
541
570
 
542
- - **GameView**: `press`, `tap`, `release`, `resize`, `timer`
571
+ - **GameView**: `press`, `tap`, `release`, `resize`, `timer`, `performance`
543
572
  - **Sprite** (and therefore **Text**): `press`, `release`, `tap`, `dragstart`, `drag`, `dragend`, `pinch`, `rotate`, `animationcomplete`, `complete`, `pathcomplete`, `collision`, `collisionend`, `land`
544
- - **SpriteSheet, Font, Sound, Emitter, Rope**: none
573
+ - **SpriteSheet, Font, Sound, Emitter, Rope, TileLayer**: none
545
574
 
546
575
  There is `collision` (enter) and `collisionend` (exit), but deliberately **no** per-frame "stay" event — that would be bridge traffic every frame. Hold the in-between state in JS: you heard the enter, you will hear the end.
547
576
 
@@ -589,6 +618,7 @@ There is `collision` (enter) and `collisionend` (exit), but deliberately **no**
589
618
  - **`raycast` only sees `visible`, non-`screenFixed` sprites carrying a `collisionGroup`.** A hidden pooled sprite is invisible to the ray, which is usually what you want; an untagged wall is invisible too, which usually is not. HUD sprites are skipped by design, so a screen-fixed score never blocks a world-space line of sight.
590
619
  - **`raycast` is a discrete query, not a sensor.** Calling it from a game-clock timer or a tap handler is the intended use. Calling it every frame from JS puts exactly the bridge traffic in the loop that the whole engine exists to avoid.
591
620
  - **`findPath` walks the sprite's center, not its silhouette.** The waypoints are a line through free grid cells, so a body wider than a cell clips corners unless you pass `clearance` (roughly half the walker's width) or shrink `cellSize`. Obstacles are read from the **hitbox**, so `hitboxScale`, the per-axis scales and `hitboxShape: 'circle'` shape the walkable space too.
621
+ - **`findPath` sees fully solid TileLayer cells, not one-way cells.** One-way platforms answer non-solid to the path grid so a top-down route may cross them. If that is not correct for the game's navigation rules, maintain a separate fully solid navigation layer or cell overrides.
592
622
  - **`findPath` returning `null` is not always "no route".** Degenerate `bounds`, `cellSize <= 0` and a grid over ~1M cells all return `null` as well — a `bounds` rect of the whole world at `cellSize: 4` fails silently that way. Check the numbers before blaming the maze.
593
623
  - **A path of one or two points is the straight-line case, not a bug.** With nothing in the way `findPath` returns just `[start, goal]`; `path.length < 2` is the guard the demos use to detect a tap outside `bounds`.
594
624
  - **Game-clock timers are not `setTimeout`.** `after`/`every` freeze at `timeScale: 0` and pause with the render loop — which is why spawn waves belong there — but a pause menu that needs a real countdown still needs `setTimeout`.
@@ -603,5 +633,7 @@ There is `collision` (enter) and `collisionend` (exit), but deliberately **no**
603
633
  - **A faded owner stops its tags taking taps.** The inherited opacity goes through the same hit test as a sprite's own, so a tag on an owner at `opacity: 0` is untouchable even though its own `opacity` still says 1 — the pooling idiom of hiding an owner now silently disarms its attached buttons too.
604
634
  - **`animate()` takes a percentage only on `scale`.** Inside the tween options `scaleX`, `scaleY` and `opacity` are read as plain numbers on both platforms, so `animate({ opacity: '50%' })` does not do what the property setter would. Percentages are for property writes; keep tween targets numeric.
605
635
  - **A bad ratio or anchor name fails differently per platform.** Android keeps the value the property already held; iOS resets it to the property's default. Neither throws, both log a warning, and at creation time they agree — the split only shows on a write to a property already set to something else.
636
+ - **Four TileLayer inputs are creation-time for portable code.** iOS rebuilds after live writes to `legend`, `firstGid`, `cols`, and `rows`; Android exposes only creation-dict handling (plus `cols`/`rows` getters). Put all four in `createTileLayer()` and replace `data` for a live rebuild.
637
+ - **`removeAllSprites()` does not clear every scene list.** It removes Sprite/Text objects and resets their attachments; emitters, ropes, and TileLayers remain until individually removed.
606
638
  - **`anchor` beats `anchorX`/`anchorY` in the same `createSprite` call**, whatever the key order in the object, because the engine applies the preset after the two axes. Pass one or the other, not both.
607
639
  - **`scrollFactor` does not move the sprite.** It shifts where the sprite is *drawn* and maps touch back to match. `x`, `y`, physics and collisions stay in world coordinates, so a `scrollFactor: 0.5` platform still collides where its world `x` says, not where it looks.