@maccesar/titools 4.14.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.
@@ -1,1574 +0,0 @@
1
- # ti.game recipes by genre
2
-
3
- Working patterns distilled from the 26 official demos in `example/` and from a shipped Alloy game (Titanium Lander). Each recipe shows the part that is specific to the genre; the shared scaffolding is in [The scaffolding](#the-scaffolding) and is not repeated.
4
-
5
- Every snippet uses only API that exists in upstream `main` at `83b7863`, 2026-08-26 (manifest `0.4.0`, unchanged since 2026-08-20 — the version number stopped tracking the code). Check [roadmap.md](roadmap.md) before reaching for anything not shown here.
6
-
7
- <!-- TOC-START -->
8
- ## Contents
9
-
10
- - [The 26 demos, and what each one is the reference for](#the-26-demos-and-what-each-one-is-the-reference-for)
11
- - [The scaffolding](#the-scaffolding)
12
- - [Cross-cutting patterns](#cross-cutting-patterns)
13
- - [Tap-to-flap](#tap-to-flap)
14
- - [Platformer](#platformer)
15
- - [Top-down / Zelda](#top-down--zelda)
16
- - [Top-down racer](#top-down-racer)
17
- - [Asteroids / space shooter](#asteroids--space-shooter)
18
- - [Lunar lander](#lunar-lander)
19
- - [Endless runner](#endless-runner)
20
- - [Rhythm game](#rhythm-game)
21
- - [Ball sports / breakout physics](#ball-sports--breakout-physics)
22
- - [Cards and board games](#cards-and-board-games)
23
- - [Point & click adventure](#point--click-adventure)
24
- - [Particles](#particles)
25
- - [Ropes and chains](#ropes-and-chains)
26
- - [Camera work](#camera-work)
27
- - [Visual effects](#visual-effects)
28
-
29
- <!-- TOC-END -->
30
-
31
- ## The 26 demos, and what each one is the reference for
32
-
33
- `example/` is the module's real documentation: every demo is a runnable app, and upstream keeps them current with the engine. When a recipe here is not enough, open the demo — it compiles today.
34
-
35
- | Demo | The reference implementation of | Covered here |
36
- | --- | --- | --- |
37
- | `basic.js` | Sheets, animations, drag/pinch/rotate, tween chaining | [The scaffolding](#the-scaffolding) |
38
- | `puzzle.js` | Drag & drop with snapping, press-to-lift, one piece per finger | [Cards and board games](#cards-and-board-games) |
39
- | `flappy.js` | Gravity + tap impulse, score zones, wrapping parallax | [Tap-to-flap](#tap-to-flap) |
40
- | `platformer.js` | `solidWith`, `land`, `oneWay` stairs, a moving platform that carries you, d-pad | [Platformer](#platformer) |
41
- | `volley.js` | `restitution`, JS hit response, an AI timer | [Ball sports / breakout physics](#ball-sports--breakout-physics) |
42
- | `racing.js` | `carMode` drift, skid marks, lap and checkpoint logic | [Top-down racer](#top-down-racer) |
43
- | `cards.js` | Deck dealing, fanned hand, selection tweens, idle wobble | [Cards and board games](#cards-and-board-games) |
44
- | `asteroids.js` | `thrust`/`angularVelocity`, `wrapAround`, bullet pooling, additive bolts | [Asteroids / space shooter](#asteroids--space-shooter) |
45
- | `topdown.js` | Tile map from a string array, `ySort` depth, follower NPC | [Top-down / Zelda](#top-down--zelda) |
46
- | `skate.js` | Endless runner: pooled obstacles, jump button, pixel-art parallax | [Endless runner](#endless-runner) |
47
- | `pointclick.js` | Tap-to-walk with `findPath` + `followPath`, verb coin, hotspots | [Point & click adventure](#point--click-adventure) |
48
- | `particles.js` | Fountain, tap bursts, a smoke trail following a dragged sprite | [Particles](#particles) |
49
- | `rhythm.js` | Pooled notes on native velocity, `press` pads, timing windows | [Rhythm game](#rhythm-game) |
50
- | `camera.js` | Dead-zone follow, `cameraBounds`, zoom, shake, `cameraEffect`, `scrollFactor` | [Camera work](#camera-work) |
51
- | `rope.js` | Verlet ropes from a dragged head and from a fixed anchor | [Ropes and chains](#ropes-and-chains) |
52
- | `flip.js` | `flipX`/`flipY` driven by movement; tap inverts gravity | [Visual effects](#visual-effects) |
53
- | `hitbox.js` | `debug: true` overlays, and why the per-axis hitbox scales exist | [Seeing the hitbox](#seeing-the-hitbox--hitboxjs) |
54
- | `blend.js` | The four blend modes side by side, `flash()` variants | [Visual effects](#visual-effects) |
55
- | `text.js` | Screen-fixed HUD, world-space labels, text buttons, wrapped dialog, a name tag pinned with `attachTo` | [HUD](#hud--textjs-flappyjs), [Labels pinned to a sprite](#labels-bars-and-turrets-pinned-to-a-sprite--attachto-textjs) |
56
- | `swept.js` | `swept: true` vs tunneling, same bullets, rising speed | [Object pooling](#object-pooling) |
57
- | `path.js` | Smoothed circuits, sharp patrols, `play(name, { then })` chains | [Patrol routes and animation chains](#patrol-routes-and-animation-chains--pathjs) |
58
- | `raycast.js` | Line of sight, ledge probes, tap-fired hitscan | [Line of sight, ledge probes and hitscan](#line-of-sight-ledge-probes-and-hitscan--raycastjs) |
59
- | `zones.js` | The `collision`/`collisionend` lifecycle, including despawned partners | [Zones you can be inside of](#zones-you-can-be-inside-of-enter--exit--zonesjs) |
60
- | `demoscene.js` | Sine scrollers and copper bars built from `followPath` circles | [Visual effects](#visual-effects) |
61
- | `maze.js` | A* over a tile maze, `simplify: false` route visualization, a re-pathing hound | [Pathfinding around obstacles](#pathfinding-around-obstacles--mazejs-pointclickjs) |
62
- | `timescale.js` | `timeScale` slow motion and freeze; game clock vs `setInterval` | [Timers on the game clock](#timers-on-the-game-clock) |
63
-
64
- ## The scaffolding
65
-
66
- Every game repeats this shape. Sheets and sounds are created once, outside `resize`, because they are texture handles, not scene objects.
67
-
68
- ```javascript
69
- const Game = require('ti.game');
70
-
71
- module.exports = function () {
72
-
73
- const win = Ti.UI.createWindow({
74
- backgroundColor: '#000',
75
- theme: 'Theme.Titanium.DayNight.NoTitleBar'
76
- });
77
- const gameView = Game.createGameView({
78
- backgroundColor: '#8ed8f8',
79
- maxFps: 60
80
- // debug: true // draw every collision shape while tuning hitboxes
81
- });
82
-
83
- const heroSheet = Game.createSpriteSheet({ image: 'assets/hero.png', frameWidth: 64, frameHeight: 64 });
84
- const jumpSound = Game.createSound({ url: 'assets/jump.wav', volume: 0.8 });
85
-
86
- let timer = null;
87
- win.addEventListener('close', () => {
88
- if (timer !== null) {
89
- clearInterval(timer);
90
- timer = null;
91
- }
92
- });
93
-
94
- let initialized = false;
95
- gameView.addEventListener('resize', (e) => {
96
- if (initialized) {
97
- return;
98
- }
99
- initialized = true;
100
- init(e.width, e.height);
101
- });
102
-
103
- function init(W, H) {
104
- // The whole game lives here. Derive every size from W and H so the
105
- // layout holds on any screen: SPEED = W * 0.35, GRAVITY = H * 2.2.
106
- }
107
-
108
- win.add(gameView);
109
- win.open();
110
- };
111
- ```
112
-
113
- Sizing everything as a fraction of `W`/`H` is not cosmetic — a jump tuned in absolute pixels becomes unplayable on a different device. The demos consistently express speeds as `W * f`, accelerations as `H * f` and sprite sizes as `Math.min(W, H) * f`.
114
-
115
- ## Cross-cutting patterns
116
-
117
- The pieces every genre below reuses: pooling, freeing a level's textures, parallax, multitouch controls, HUD text, wrapped dialog, labels and bars attached to a sprite, invisible triggers, seeing the hitbox, enter/exit zones, patrol routes, line of sight, pathfinding, game-clock timers and hit-stop.
118
-
119
- ### Object pooling
120
-
121
- Create a fixed set once, recycle forever. `visible = false` also removes the sprite from collision, which is what makes this work.
122
-
123
- ```javascript
124
- const bullets = [];
125
- for (let i = 0; i < 10; i++) {
126
- const sprite = Game.createSprite({
127
- sheet: shipSheet,
128
- frame: 3,
129
- width: 8,
130
- height: 16,
131
- visible: false,
132
- wrapAround: true,
133
- swept: true, // path-tested: a fast bolt cannot skip past a rock
134
- collidesWith: ['asteroid']
135
- });
136
- const state = { sprite: sprite, active: false, generation: 0 };
137
-
138
- sprite.addEventListener('collision', (e) => {
139
- if (!state.active) {
140
- return;
141
- }
142
- deactivate(state);
143
- e.other.visible = false; // the rock is gone: no render, no collision
144
- });
145
-
146
- gameView.add(sprite);
147
- bullets.push(state);
148
- }
149
-
150
- function deactivate(state) {
151
- state.active = false;
152
- state.sprite.visible = false;
153
- state.sprite.velocityX = 0;
154
- state.sprite.velocityY = 0;
155
- }
156
-
157
- function fire(x, y, vx, vy) {
158
- const state = bullets.find((b) => !b.active);
159
- if (!state) {
160
- return; // pool exhausted — drop the shot
161
- }
162
- state.active = true;
163
- state.generation++;
164
- const generation = state.generation;
165
- Object.assign(state.sprite, { x: x, y: y, velocityX: vx, velocityY: vy, visible: true });
166
- setTimeout(() => {
167
- // burn out — unless this slot was already recycled by a newer shot
168
- if (state.active && state.generation === generation) {
169
- deactivate(state);
170
- }
171
- }, 1200);
172
- }
173
- ```
174
-
175
- The `generation` counter matters: without it, a timeout fired for an old shot deactivates the *new* bullet occupying that slot.
176
-
177
- `swept: true` (compared side by side in `swept.js`) is what keeps a fast bolt from crossing a rock between two frames without ever overlapping it. The asteroids demo predates it and inflates the hitbox instead (`hitboxScale: 1.4`) — that works, at the price of counting near misses as hits. Prefer `swept`, and reserve `hitboxScale` — with `hitboxScaleX`/`hitboxScaleY` when the art's fit differs per axis — for fairness tuning.
178
-
179
- Recycling a sprite with `visible = false` fires `collisionend` on anything it was touching. Handlers that treat separation as "the player walked away" need to tolerate a despawn.
180
-
181
- ### Freeing a level's textures
182
-
183
- Pooling recycles sprites; it does not free the **texture** behind them. A game that streams worlds accumulates every atlas it has ever loaded until the GL context dies. `unload()` releases one on the next rendered frame.
184
-
185
- ```javascript
186
- function unloadLevel(level) {
187
- level.sprites.forEach((s) => gameView.remove(s));
188
- level.sheets.forEach((sheet) => sheet.unload()); // only once nothing draws from them
189
- }
190
- ```
191
-
192
- It is permanent — not an eviction that reloads on demand. A sprite still pointing at an unloaded sheet stops drawing, silently. A single-level game should not call it: the textures die with the window.
193
-
194
- ### Parallax
195
-
196
- Two ways, and the choice is decided by whether the **camera** moves or the **world** does.
197
-
198
- **A camera travelling through a static world** — platformer, top-down, anything using `follow()` — wants `scrollFactor`, one property per layer:
199
-
200
- ```javascript
201
- const clouds = Game.createSprite({ sheet: cloudSheet, x: W / 2, y: 120, zIndex: 0, scrollFactor: 0.25 });
202
- const hills = Game.createSprite({ sheet: hillSheet, x: W / 2, y: 260, zIndex: 1, scrollFactor: 0.6 });
203
- const sun = Game.createSprite({ sheet: sunSheet, x: 60, y: 60, zIndex: 0, scrollFactor: 0 });
204
- ```
205
-
206
- `0` pins the sun to the view while still zooming with `cameraScale` — that is the difference from `screenFixed`, which ignores zoom as well. Values above `1` overtake the camera for foreground grass. The sprite's `x`/`y` never change: `scrollFactor` shifts where it is *drawn* and maps touch back to match, so collisions still happen at the world coordinates you wrote.
207
-
208
- **A world scrolling under a fixed camera** — endless runner, flappy — cannot use `scrollFactor`, because the camera never travels. Two screen-wide copies per layer, scrolled by native velocity; `wrapX`/`wrapShift` teleport a copy that left the screen back behind its sibling. No JS runs in the loop.
209
-
210
- ```javascript
211
- function makeLayer(sheet, y, height, speed, z) {
212
- const copies = [];
213
- for (let i = 0; i < 2; i++) {
214
- const s = Game.createSprite({
215
- sheet: sheet,
216
- x: W / 2 + i * W,
217
- y: y,
218
- width: W,
219
- height: height,
220
- zIndex: z,
221
- tileRepeat: 'x', // keeps texture density; needs repeat: true on the sheet
222
- wrapX: -W / 2,
223
- wrapShift: 2 * W
224
- });
225
- gameView.add(s);
226
- copies.push(s);
227
- }
228
- return {
229
- setSpeed: (v) => copies.forEach((copy) => {
230
- copy.velocityX = -v;
231
- })
232
- };
233
- }
234
-
235
- const skyline = makeLayer(skylineSheet, groundTop - SKY_H / 2, SKY_H, SPEED * 0.12, 1);
236
- const street = makeLayer(streetSheet, (groundTop + H) / 2, H - groundTop, SPEED, 8);
237
- ```
238
-
239
- Slower layers read as further away. Three layers (clouds at 0.15×, hills at 0.4×, ground at 1×) is the flappy demo's recipe. `camera.js` demos the other half: `scrollFactor` cloud shadows at 1.35× and a `scrollFactor: 0` sun.
240
-
241
- ### Multitouch on-screen controls
242
-
243
- Each button must be **its own Titanium view** — sibling views receive simultaneous pointers, so holding a direction while pressing jump works. A single container splitting touches itself does not.
244
-
245
- ```javascript
246
- function makeButton(title, position, fontSize) {
247
- const button = Ti.UI.createLabel({
248
- text: title,
249
- textAlign: 'center',
250
- color: '#fff',
251
- font: { fontSize: fontSize || 30, fontWeight: 'bold' },
252
- backgroundColor: '#59000000',
253
- borderRadius: 40,
254
- width: '80dp',
255
- height: '80dp',
256
- bottom: '24dp'
257
- });
258
- // Manual press feedback: touchFeedback's ripple cannot animate over this
259
- // canvas and spams RippleDrawable errors on Android.
260
- button.addEventListener('touchstart', () => {
261
- button.backgroundColor = '#8c000000';
262
- });
263
- ['touchend', 'touchcancel'].forEach((event) => {
264
- button.addEventListener(event, () => {
265
- button.backgroundColor = '#59000000';
266
- });
267
- });
268
- Object.assign(button, position);
269
- return button;
270
- }
271
-
272
- function bindHold(button, apply, release) {
273
- button.addEventListener('touchstart', apply);
274
- ['touchend', 'touchcancel'].forEach((event) => {
275
- button.addEventListener(event, release);
276
- });
277
- }
278
-
279
- const leftButton = makeButton('◀', { left: '24dp' });
280
- bindHold(leftButton, () => {
281
- car.steering = -1;
282
- }, () => {
283
- if (car.steering === -1) { // don't cancel the other button's input
284
- car.steering = 0;
285
- }
286
- });
287
- ```
288
-
289
- The guard in the release handler is what keeps two opposite buttons from fighting: releasing left only zeroes steering if left is still the active direction.
290
-
291
- ### HUD — `text.js`, `flappy.js`
292
-
293
- Text sprites with `screenFixed: true`. They live in the scene — one batch, no overlay views — but ignore the camera, so a score stays in the corner while the world scrolls under it.
294
-
295
- ```javascript
296
- // Created outside resize (no size known yet), positioned once it is
297
- const scoreLabel = Game.createText({ text: '0', screenFixed: true, zIndex: 100 });
298
- const statusLabel = Game.createText({ text: 'Tap to start!', screenFixed: true, zIndex: 100 });
299
-
300
- function buildLevel(W, H) {
301
- const TEXT_SCALE = Math.max(1, Math.round(W / 240)); // integer: crisp pixels
302
- Object.assign(scoreLabel, { scale: TEXT_SCALE, x: W / 2, y: H * 0.08 });
303
- Object.assign(statusLabel, { scale: TEXT_SCALE, x: W / 2, y: H * 0.45 });
304
- gameView.add([scoreLabel, statusLabel]);
305
- // ...
306
- }
307
-
308
- function score() {
309
- points += 1;
310
- scoreLabel.text = points + ' / ' + GATE_COUNT;
311
- scoreLabel.flash('#4dff88', 250); // text flashes like any sprite
312
- }
313
- ```
314
-
315
- Scaling from `W` is what replaces `fontSize`: a bitmap font has one native size, and an integer multiplier is the only way to keep texels square. `Math.max(1, ...)` guards the smallest screens.
316
-
317
- Because a label is a sprite, the HUD gets the whole toolbox for free — `animate()` to pop a score on change, `tintColor` per state, `glowColor` on a title, `idleAnimation` for a gentle wobble, and `tap` handlers so a `[ RESET ]` label works as a button with no Titanium view behind it.
318
-
319
- Drop `screenFixed` and the same label becomes world furniture: signposts, floating damage numbers, a name over an NPC — it scrolls with everything else.
320
-
321
- A dialog box is one long string with `maxWidth`: the engine breaks the lines on word boundaries and re-wraps every time `text` is written, so the source keeps no hand-placed `\n`.
322
-
323
- ```javascript
324
- const UNIT = Math.max(1, Math.round(W / 240));
325
- const dialog = Game.createText({
326
- text: 'TAP FOR POND WISDOM',
327
- maxWidth: Math.round(W * 0.55 / UNIT), // font-space px: divide by the scale
328
- align: 'center',
329
- lineSpacing: 1.3,
330
- scale: UNIT,
331
- x: W / 2,
332
- y: H * 0.78,
333
- zIndex: 3
334
- });
335
- dialog.addEventListener('tap', () => {
336
- dialog.text = lines[i++ % lines.length]; // re-wraps natively
337
- dialog.flash('#fff', 150);
338
- });
339
- ```
340
-
341
- `maxWidth` is measured before `scale`, which is the only trap here — pass screen pixels straight in and the block comes out `scale` times too wide. `align` positions the lines against each other (the block's own width), not inside the wrap column, so anchor the sprite where the text should sit. A word longer than `maxWidth` overflows instead of breaking mid-word.
342
-
343
- Overlaid `Ti.UI.Label` views remain the answer for text the engine's font cannot draw (accents, system fonts, right-to-left) and for real UI: menus, dialogs, text inputs.
344
-
345
- ### Labels, bars and turrets pinned to a sprite — `attachTo`, `text.js`
346
-
347
- A HUD is `screenFixed`; a name tag, a health bar or a shadow is the opposite — it belongs *on* a sprite that moves. `attachTo` is the native version of the JS everyone wrote before it: a timer copying `owner.x` into `tag.x`, one bridge round trip per tag per tick, and always a frame behind.
348
-
349
- ```javascript
350
- const tag = Game.createText({ text: 'THE BALL', scale: UNIT, tintColor: '#7fd4ff', zIndex: 6 });
351
- tag.attachTo(ball, { offsetY: -(W * 0.09) }); // offset in the tag's own units
352
- gameView.add(tag); // the tag is still a scene sprite
353
- ```
354
-
355
- The tag now tracks the ball through gravity, tweens, a moving platform and a finger drag, with no JS in the loop, and disappears with it — `gameView.remove(ball)` removes everything attached to the ball, recursively.
356
-
357
- A health bar is the same idea with two sprites, and shows where inheritance stops:
358
-
359
- ```javascript
360
- // blockSheet is any small opaque texture stretched and tinted — a sheet-less
361
- // sprite draws nothing at all, that is the invisible-trigger idiom below
362
- const barBack = Game.createSprite({ sheet: blockSheet, width: 40, height: 5, zIndex: 5, tintColor: '#222' });
363
- const barFill = Game.createSprite({ sheet: blockSheet, width: 40, height: 5, zIndex: 6, anchor: 'left', tintColor: '#3f6' });
364
-
365
- barBack.attachTo(hero, { offsetY: -34 });
366
- barFill.attachTo(hero, { offsetX: -20, offsetY: -34 }); // anchored left → grows to the right
367
- gameView.add([barBack, barFill]);
368
-
369
- // on damage: the width is yours to set — scale, visibility and tint are not inherited
370
- barFill.width = 40 * (hp / hpMax);
371
-
372
- // on death: one tween fades the hero AND both bars, because opacity does cascade
373
- hero.animate({ opacity: 0, duration: 400 });
374
- ```
375
-
376
- `rotate: true` is the other half: the offset swings around the target and the sprite copies its rotation, which is what a turret on a tank or a hat on a tumbling hero needs.
377
-
378
- ```javascript
379
- turret.attachTo(tank, { offsetY: -12, rotate: true });
380
- ```
381
-
382
- Four things to keep in mind:
383
-
384
- - **The attached sprite has to be added to the scene**, and so does its target. Neither pins otherwise — the sprite just sits at whatever `x`/`y` it was created with.
385
- - **Its position stops being yours.** Velocity, gravity and `animate({ x })` still run and are overwritten the same frame. To fling a tag off a dying enemy, `detach()` first, then give it velocity.
386
- - **Match `scrollFactor` between the two.** Attachment pins world coordinates; parallax changes only where a sprite is *drawn*. A tag at `scrollFactor: 1` on a background sprite at `0.5` stays pinned in world space and drifts across the screen anyway.
387
- - **A faded owner also stops its tags taking taps.** The inherited opacity runs through the hit test as well as the draw, so an owner at `opacity: 0` leaves an attached text button untouchable — while that button's own `opacity` still reads 1. Hide an owner with `visible = false` if the tags should keep working, or detach them.
388
-
389
- ### Invisible triggers
390
-
391
- A sprite with `width`/`height` and no `sheet` renders nothing but still collides. This is how scores, goals, ceilings, kill floors, walls and checkpoints are built.
392
-
393
- ```javascript
394
- // score zone in the gap between two pipes
395
- gameView.add(Game.createSprite({ x: gateX, y: gapCenter, width: 20, height: GAP, collisionGroup: 'score' }));
396
-
397
- // invisible ceiling so the player cannot escape upward
398
- gameView.add(Game.createSprite({ x: W / 2, y: -20, width: W * 3, height: 40, collisionGroup: 'ceiling' }));
399
-
400
- // side walls
401
- [-20, W + 20].forEach((x) => {
402
- gameView.add(Game.createSprite({ x: x, y: H / 2, width: 40, height: H * 3, collisionGroup: 'wall' }));
403
- });
404
- ```
405
-
406
- Attach your own metadata to a sprite and read it back from `e.other` in the handler:
407
-
408
- ```javascript
409
- const zone = Game.createSprite({ x: cp.x, y: cp.y, width: cp.w, height: cp.h, collisionGroup: 'checkpoint' });
410
- zone.cpIndex = index;
411
-
412
- car.addEventListener('collision', (e) => {
413
- if (e.group === 'checkpoint') {
414
- visited[e.other.cpIndex] = true;
415
- }
416
- });
417
- ```
418
-
419
- ### Seeing the hitbox — `hitbox.js`
420
-
421
- `debug: true` on the GameView is the whole tuning workflow, and the demo exists to make the overlays legible: two identical adventurers walk into the same wall, one untuned and one with `hitboxScaleX: 0.62` / `hitboxScaleY: 0.92`. The untuned one stops a body's width short and hovers above the floor on his frame's padding; the tuned one goes flush and lands his feet. Tapping toggles the tuning live, so the green box snaps between frame and drawing.
422
-
423
- ```javascript
424
- const gameView = Game.createGameView({ debug: true }); // green = collision AABB, blue = drawn frame, orange = anchor
425
-
426
- hero.hitboxScaleX = 0.62; // both are live: write them and watch the box move
427
- hero.hitboxScaleY = 0.92;
428
- ```
429
-
430
- Read the two boxes as a pair: green is what collides, blue is what draws **and what takes taps**. Green much smaller than blue on one axis only is exactly the case the per-axis scales exist for; green and blue identical on a sprite whose art has padding is the bug the demo dramatizes.
431
-
432
- The orange dot is the third variable, not decoration: the box shrinks **around the anchor**, so where the anchor sits decides which edge stays put. A prop that should collide from mid-height down while still resting on the floor is the anchor and the scale together, and both have a readable form now:
433
-
434
- ```javascript
435
- // the same thing, twice — the second one says what it is doing
436
- Game.createSprite({ sheet, anchorY: 1, hitboxScaleY: 0.55 });
437
- Game.createSprite({ sheet, anchor: 'bottom', hitboxScaleY: '55%' });
438
- ```
439
-
440
- Every ratio takes a percentage this way (`opacity`, `scale`, `scrollFactor`, `restitution`, `volume`, the `follow` margins…) and both anchors take names. Coordinates, sizes, degrees and speeds do not — they were never fractions.
441
-
442
- ### Zones you can be inside of (enter / exit) — `zones.js`
443
-
444
- `collision` and `collisionend` are the two halves of a trigger: entering water, standing on a pressure plate, holding a capture point. Nothing fires while you stay inside — the state lives in JS between the two events, and that is deliberate, since a per-frame "stay" would be bridge traffic every frame.
445
-
446
- ```javascript
447
- const pool = Game.createSprite({
448
- x: W * 0.32, y: H * 0.3, width: W * 0.44, height: H * 0.18,
449
- sheet: waterSheet, tintColor: '#3d6fd4', opacity: 0.35,
450
- touchEnabled: false, // drags pass through to whatever is inside
451
- collisionGroup: 'water'
452
- });
453
-
454
- hero.collidesWith = ['water'];
455
- hero.addEventListener('collision', (e) => {
456
- if (e.group === 'water') {
457
- hero.tintColor = '#7fb2ff';
458
- swimming = true;
459
- }
460
- });
461
- hero.addEventListener('collisionend', (e) => {
462
- if (e.group === 'water') {
463
- hero.tintColor = null;
464
- swimming = false;
465
- }
466
- });
467
- ```
468
-
469
- The listener goes on the sprite that declares `collidesWith`. For a pressure plate holding a door open exactly while a crate rests on it, that is the **plate** (`collidesWith: ['crate']`), not the crate — then `collision` opens the door and `collisionend` closes it, with no polling anywhere.
470
-
471
- Removing the partner from the scene, hiding it (`visible = false`) or changing its `collisionGroup` mid-contact all count as separation, so the exit still fires and the door cannot stick open when an object despawns.
472
-
473
- ### Patrol routes and animation chains — `path.js`
474
-
475
- A fixed route is `followPath`, not a chain of tween legs. The points are walked natively at `speed` px/s; `rotate: true` turns the sprite to face along the path (heading `0` = up, same convention as `rotation`), and `smoothing` rounds the corners with a radius in px.
476
-
477
- ```javascript
478
- guard.followPath([
479
- { x: W * 0.22, y: H * 0.46 },
480
- { x: W * 0.78, y: H * 0.46 },
481
- { x: W * 0.78, y: H * 0.60 },
482
- { x: W * 0.22, y: H * 0.60 }
483
- ], { speed: W * 0.16, loop: true }); // sharp rectangle, walks forever
484
-
485
- ship.followPath(diamond, {
486
- speed: W * 0.35,
487
- loop: true,
488
- rotate: true, // nose follows the heading
489
- smoothing: W * 0.12 // rounds the diamond into an oval
490
- });
491
- ```
492
-
493
- A non-looping run fires `pathcomplete` and clears itself; `followPath(null)` stops in place. Two points is the minimum — one point is ignored with a warning, not treated as "go there".
494
-
495
- The path writes `x`/`y` outright, after velocity and gravity have been integrated, so **a sprite on a path ignores its own physics for placement**. It still feeds the frame delta, though, which means a platform on a looping circuit carries whatever is standing on it — a moving platform is now a path, not a tween ping-pong.
496
-
497
- Animation chains are native in the same spirit:
498
-
499
- ```javascript
500
- guard.play('hop', { then: 'walk' }); // one call, no animationcomplete handler
501
- bird.play('flap', { then: ['glide', 'idle'] });
502
- ```
503
-
504
- Each queued animation starts as the previous non-looping one finishes, and a looping animation ends the chain. `animationcomplete` still fires per step if you want to hook one. `stop()` drops the queue along with the current animation.
505
-
506
- ### Line of sight, ledge probes and hitscan — `raycast.js`
507
-
508
- `gameView.raycast(x0, y0, x1, y1, groups)` answers "what is the first thing on this line?" with `null` or `{ x, y, distance, group, sprite, normal }`. The targets need nothing but a `collisionGroup` — no `collidesWith` on the asking side.
509
-
510
- ```javascript
511
- const hit = gameView.raycast(eyeX, eyeY, dog.x, dog.y, ['blocker']);
512
- if (hit) {
513
- aimBeam(sight, eyeX, eyeY, hit.x, hit.y, hit.distance);
514
- sight.tintColor = '#f44'; // view blocked at the impact point
515
- }
516
- ```
517
-
518
- Pass the groups as an **array**. Android also accepts loose arguments, iOS does not, so the array is the portable form. Only `visible`, non-`screenFixed` sprites carrying a `collisionGroup` are candidates — a pooled sprite parked at `visible = false` is correctly invisible to the ray, an untagged wall is invisible too (the usual mistake), and HUD sprites are skipped by design so a screen-fixed score never blocks a world-space sight line.
519
-
520
- A ledge probe is the same call pointed straight down, one step ahead of the feet:
521
-
522
- ```javascript
523
- const dir = walker.velocityX >= 0 ? 1 : -1;
524
- const probeX = walker.x + dir * W * 0.07;
525
- if (!gameView.raycast(probeX, walker.y, probeX, walker.y + W * 0.12, ['ground'])) {
526
- walker.velocityX = -walker.velocityX; // nothing underfoot: turn before falling
527
- walker.flipX = walker.velocityX < 0;
528
- }
529
- ```
530
-
531
- Run these from a coarse timer or a tap handler — the demo probes at 150 ms — never once per frame. A per-frame `raycast` from JS is exactly the bridge traffic the engine is built to avoid. Note that `raycast.js` drives its probes with `setInterval`: it was written two commits before game-clock timers existed, and `gameView.every` is the better home for an AI tick today.
532
-
533
- ### Pathfinding around obstacles — `maze.js`, `pointclick.js`
534
-
535
- `gameView.findPath(from, to, options)` is grid A\* over the same sprites `raycast` sees: visible, tagged with a `collisionGroup`, nothing else to set up. It returns `{ x, y }` waypoints — or `null` — and those waypoints go straight into `followPath`, so the walk itself runs natively.
536
-
537
- ```javascript
538
- gameView.addEventListener('tap', (e) => {
539
- const path = gameView.findPath(
540
- { x: player.x, y: player.y },
541
- { x: e.x, y: e.y },
542
- { cellSize: TILE, groups: ['wall'], bounds: MAZE_BOUNDS });
543
- if (!path || path.length < 2) {
544
- return; // tapped outside the walkable rect
545
- }
546
- player.scaleX = path[1].x < player.x ? -1 : 1; // face the first leg
547
- player.play('walk');
548
- player.followPath(path, { speed: SPEED });
549
- });
550
- ```
551
-
552
- `cellSize` is the one option that decides everything else. On a tile map, pass the tile size and the grid lines up with the walls exactly; on a free-form scene, something near the walker's width. Too coarse and doorways disappear, too fine and the query gets expensive (over ~1M cells it returns `null` rather than allocating).
553
-
554
- **The path is a line for the sprite's *center*.** A body wider than a cell scrapes corners unless you pass `clearance` — about half the walker's width:
555
-
556
- ```javascript
557
- const path = gameView.findPath(from, to, {
558
- cellSize: Math.round(W * 0.04),
559
- groups: ['obstacle'],
560
- clearance: PLAYER_W * 0.35, // keep half a body off the trunk
561
- bounds: WALK_BOUNDS // the walkable floor, in center coordinates
562
- });
563
- ```
564
-
565
- `bounds` doubles as the walkable area, which is how the point-&-click floor clamp survives: `WALK_BOUNDS` is the old `WALK_TOP`/margin clamp expressed as a rect, in **sprite-center coordinates** (tap points are feet positions, so subtract half the sprite height before searching).
566
-
567
- **Obstacles do not have to be the art.** `pointclick.js` blocks only the oak's trunk with an invisible sprite — `opacity: 0`, `touchEnabled: false`, no collision wiring — because `findPath` reads the hitbox, not the pixels. The canopy stays walkable, so the player still passes behind the tree with `ySort` doing the depth:
568
-
569
- ```javascript
570
- gameView.add(Game.createSprite({
571
- sheet: treeSheet, opacity: 0, touchEnabled: false,
572
- x: tree.x, y: groundY, width: TREE_W * 0.35, height: PLAYER_H * 0.8,
573
- collisionGroup: 'obstacle'
574
- }));
575
- ```
576
-
577
- A blocked goal snaps to the nearest free cell a few rings out, so tapping a wall walks to its edge instead of doing nothing. Tapping outside `bounds` is the case worth guarding — that is what `path.length < 2` catches.
578
-
579
- **Chasing AI is a timer, not a frame loop.** Re-path on a coarse game-clock tick and let `followPath` cover the gap:
580
-
581
- ```javascript
582
- const chase = gameView.every(800, () => {
583
- const path = gameView.findPath({ x: hound.x, y: hound.y }, { x: player.x, y: player.y }, PATH_OPTIONS);
584
- if (path && path.length > 1) {
585
- hound.followPath(path, { speed: TILE * 2.6 });
586
- }
587
- });
588
- win.addEventListener('close', () => gameView.cancelTimer(chase));
589
- ```
590
-
591
- `followPath(null)` cancels an in-flight route — `maze.js` uses it to yank the hound back to its den after it catches you.
592
-
593
- To *see* what A\* did, ask twice: `simplify: false` returns every grid cell it walked, `simplify: true` (the default) the line-of-sight-reduced corners the sprite actually gets. Dropping a dot on each is the whole route visualization in the maze demo.
594
-
595
- ### Timers on the game clock
596
-
597
- `gameView.after(ms, cb)` and `every(ms, cb)` run on the engine's clock: they stretch under `timeScale` slow motion, freeze at `0`, and pause with the render loop. Spawn waves, AI decision ticks and respawn delays belong here — a paused game that keeps spawning is the bug this fixes.
598
-
599
- ```javascript
600
- const wave = gameView.every(2000, () => spawnEnemy());
601
- gameView.after(500, () => player.play('idle'));
602
- gameView.cancelTimer(wave); // both return an int id
603
- ```
604
-
605
- The callback receives `{ id }`. Called without a callback, the view fires a `timer` event carrying the same id instead — pick one, not both. A repeating timer fires at most once per frame and restarts its interval after a stall rather than bursting to catch up.
606
-
607
- What still belongs in `setTimeout`: anything that must keep running while the game is frozen. A pause menu counting down in real seconds is not on the game clock.
608
-
609
- ### Hit-stop on impact
610
-
611
- Freezing the scene for a few frames makes an impact read before the explosion starts. `timeScale = 0` stops the engine from advancing while it keeps drawing and accepting touches — no render loop to restart, no state to save. (Pattern from Titanium Lander: 140 ms on a crash, 90 ms when a shield absorbs a hit.)
612
-
613
- ```javascript
614
- let hitStopTimer = null;
615
-
616
- function hitStop(ms) {
617
- clearHitStop();
618
- gameView.timeScale = 0;
619
- hitStopTimer = setTimeout(() => {
620
- hitStopTimer = null;
621
- gameView.timeScale = 1;
622
- }, ms);
623
- }
624
-
625
- function clearHitStop() {
626
- if (hitStopTimer !== null) {
627
- clearTimeout(hitStopTimer);
628
- hitStopTimer = null;
629
- }
630
- gameView.timeScale = 1;
631
- }
632
- ```
633
-
634
- Restoring the scale must be unconditional: run `clearHitStop()` from reset, from window close and from every teardown path, because a freeze that outlives a retry starts the next round stopped.
635
-
636
- ## Tap-to-flap
637
-
638
- Gravity plus an impulse per tap. Scoring and death are collisions with invisible zones. The rotation tween is what sells the arc.
639
-
640
- ```javascript
641
- const pig = Game.createSprite({
642
- sheet: pigSheet,
643
- x: W * 0.28,
644
- y: H * 0.42,
645
- width: PIG_SIZE,
646
- height: PIG_SIZE,
647
- zIndex: 10,
648
- hitboxScale: 0.7, // the art does not fill the frame
649
- hitboxScaleY: 0.85, // ...and the pig is wider than he is tall
650
- collidesWith: ['pipe', 'ground', 'score', 'ceiling'],
651
- animations: { fly: { frames: [0, 1, 2, 1], fps: 10, loop: true } }
652
- });
653
- pig.play('fly');
654
-
655
- gameView.addEventListener('press', () => {
656
- if (over) {
657
- reset();
658
- return;
659
- }
660
- if (!started) {
661
- start(); // first tap turns gravity on
662
- }
663
- pig.velocityY = FLAP; // e.g. -H * 0.6
664
- pig.clearTweens();
665
- pig.rotation = -15; // snap the nose up...
666
- pig.animate({ rotation: 25, duration: 900, easing: Game.EASE_IN }); // ...then tip forward
667
- });
668
-
669
- pig.addEventListener('collision', (e) => {
670
- if (over) {
671
- return;
672
- }
673
- if (e.group === 'score') {
674
- score++;
675
- } else if (e.group === 'ceiling') {
676
- pig.velocityY = H * 0.1; // gentle push back down instead of a hard stop
677
- } else {
678
- gameOver();
679
- }
680
- });
681
- ```
682
-
683
- Obstacles are pooled gates: three sprites each (top pipe, bottom pipe, invisible score zone) that scroll left on `velocityX` and get repositioned when they leave the screen. Freezing the game means setting every scroller's `velocityX = 0` and the player's `gravity = 0` — the engine has no global "stop", but `gameView.timeScale = 0` is one if you want the scene frozen mid-flight.
684
-
685
- ## Platformer
686
-
687
- `solidWith` handles the blocking; you only decide when to jump.
688
-
689
- ```javascript
690
- const player = Game.createSprite({
691
- sheet: playerSheet,
692
- x: W * 0.15,
693
- y: groundTop - PLAYER_SIZE / 2,
694
- width: PLAYER_SIZE,
695
- height: PLAYER_SIZE,
696
- zIndex: 10,
697
- gravity: GRAVITY, // H * 2.2
698
- hitboxScale: 0.85,
699
- hitboxScaleX: 0.66, // the blob is 38px wide in a 64px frame, but nearly full height
700
- solidWith: ['solid', 'trampoline'],
701
- animations: {
702
- idle: { frames: [0], fps: 1, loop: true },
703
- walk: { frames: [1, 2], fps: 8, loop: true }
704
- }
705
- });
706
-
707
- gameView.follow(player, { topMargin: 0.33, bottomMargin: 0.7, maxY: 0 });
708
-
709
- let moveDir = 0;
710
-
711
- function applyMovement() {
712
- player.velocityX = moveDir * RUN_SPEED;
713
- if (moveDir !== 0) {
714
- player.scaleX = moveDir; // face the running direction
715
- }
716
- if (player.onGround) {
717
- player.play(moveDir !== 0 ? 'walk' : 'idle');
718
- }
719
- }
720
-
721
- function jump() {
722
- if (player.onGround) { // gate on the engine's own flag
723
- player.velocityY = -JUMP; // H * 0.95 ≈ 0.2 * H of height
724
- player.stop();
725
- player.frame = 3; // hold the jump pose
726
- }
727
- }
728
-
729
- player.addEventListener('land', (e) => {
730
- if (e.group === 'trampoline') {
731
- player.velocityY = -JUMP * 1.1; // bounce higher than a jump
732
- player.stop();
733
- player.frame = 3;
734
- e.other.frame = 1; // squash the mat you landed on
735
- setTimeout(() => {
736
- e.other.frame = 0;
737
- }, 150);
738
- return;
739
- }
740
- applyMovement(); // back from the jump pose
741
- });
742
- ```
743
-
744
- Jump height is `JUMP² / (2 × GRAVITY)`. Space your platforms comfortably below it — the demo uses steps of `0.14 * H` against a `0.2 * H` jump.
745
-
746
- **One-way platforms**: `oneWay: true` on the solid lets riders jump up through it and land on top, never blocking sideways or from below. This is what makes a staircase climbable.
747
-
748
- **Moving platforms carry riders for free** — a solid moved by `velocityX` or a tween drags whoever stands on it, with no re-landing jitter:
749
-
750
- ```javascript
751
- const mover = Game.createSprite({
752
- sheet: moverSheet, x: W * 0.18, y: groundTop - STEP * 2,
753
- width: W * 0.26, height: PLAT_H, collisionGroup: 'solid'
754
- });
755
-
756
- function patrol() {
757
- mover.animate({
758
- x: (mover.x < W * 0.35) ? W * 0.5 : W * 0.18,
759
- duration: 2600,
760
- easing: Game.EASE_IN_OUT
761
- });
762
- }
763
- mover.addEventListener('complete', patrol); // ping-pong forever
764
- patrol();
765
- ```
766
-
767
- Set `carryRiders: false` on terrain that scrolls under a player who should stay put (endless runners).
768
-
769
- ## Top-down / Zelda
770
-
771
- A tile map from a string array, plus `ySort` for depth. Only water is solid; trees are pure decoration you can walk behind.
772
-
773
- ```javascript
774
- const MAP = [
775
- 'WWWWWWWWWWWW',
776
- 'WGGGGGGGGGGW',
777
- 'WGGFGGGPGGGW',
778
- 'WWWWWWWWWWWW'
779
- ];
780
- const FRAMES = { G: 0, F: 1, P: 2, W: 3 };
781
- const COLS = MAP[0].length;
782
- const ROWS = MAP.length;
783
- const TILE = Math.floor(Math.min(W / COLS, (H - buttonZone) / ROWS));
784
- const ox = (W - COLS * TILE) / 2;
785
- const oy = (H - buttonZone - ROWS * TILE) / 2;
786
-
787
- const level = [];
788
- for (let row = 0; row < ROWS; row++) {
789
- for (let col = 0; col < COLS; col++) {
790
- const kind = MAP[row].charAt(col);
791
- level.push(Game.createSprite({
792
- sheet: tileSheet,
793
- frame: FRAMES[kind],
794
- x: ox + (col + 0.5) * TILE,
795
- y: oy + (row + 0.5) * TILE,
796
- width: TILE,
797
- height: TILE,
798
- zIndex: 0,
799
- collisionGroup: (kind === 'W') ? 'solid' : null // only water blocks
800
- }));
801
- }
802
- }
803
- gameView.add(level); // one bridge crossing
804
- ```
805
-
806
- This is fine at 12×15 tiles and dead at 200×200 — one sprite per tile. A native tilemap layer is on the roadmap; until then, build large worlds from a few big `tileRepeat` sprites plus sparse solids.
807
-
808
- Depth sorting: give the player, trees and buildings the **same `zIndex`** with `ySort: true`, and keep ground tiles on a lower `zIndex`. The engine then orders them by bottom edge.
809
-
810
- ```javascript
811
- const player = Game.createSprite({
812
- sheet: walkerSheet,
813
- x: start.x, y: start.y, width: TILE, height: TILE,
814
- zIndex: 5,
815
- ySort: true,
816
- hitboxScale: 0.6, // top-down games feel best with a small body box
817
- solidWith: ['solid'],
818
- animations: {
819
- down: { frames: [0, 1], fps: 6, loop: true },
820
- up: { frames: [2, 3], fps: 6, loop: true },
821
- side: { frames: [4, 5], fps: 6, loop: true }
822
- }
823
- });
824
- ```
825
-
826
- 8-way movement from four buttons, tracking facing yourself because `animation` keeps its value after `stop()`:
827
-
828
- ```javascript
829
- let moveX = 0;
830
- let moveY = 0;
831
- let lastFacing = 'down';
832
- let walking = false;
833
-
834
- function applyMovement() {
835
- player.velocityX = moveX * SPEED;
836
- player.velocityY = moveY * SPEED;
837
- if (moveX === 0 && moveY === 0) {
838
- player.stop();
839
- walking = false;
840
- player.frame = { down: 0, up: 2, side: 4 }[lastFacing];
841
- return;
842
- }
843
- const facing = (moveX !== 0) ? 'side' : (moveY < 0 ? 'up' : 'down');
844
- if (moveX !== 0) {
845
- player.scaleX = moveX; // side frames face right; mirror for left
846
- }
847
- if (!walking || facing !== lastFacing) {
848
- player.play(facing);
849
- walking = true;
850
- }
851
- lastFacing = facing;
852
- }
853
- ```
854
-
855
- **NPC on a decision timer.** The AI thinks every 100 ms; the walking itself is native velocity between ticks. This is the sanctioned use of a timer — decisions, not motion.
856
-
857
- ```javascript
858
- timer = setInterval(() => {
859
- const dx = player.x - dog.x;
860
- const dy = player.y - dog.y;
861
- const dist = Math.sqrt(dx * dx + dy * dy);
862
- if (dist > FOLLOW_DISTANCE) {
863
- dog.velocityX = (dx / dist) * DOG_SPEED;
864
- dog.velocityY = (dy / dist) * DOG_SPEED;
865
- if (Math.abs(dx) > 2) {
866
- dog.scaleX = (dx > 0) ? 1 : -1;
867
- }
868
- setDogState('walk');
869
- } else {
870
- dog.velocityX = 0;
871
- dog.velocityY = 0;
872
- setDogState('sit');
873
- }
874
- }, 100);
875
- ```
876
-
877
- ## Top-down racer
878
-
879
- `carMode` gives you throttle, steering and emergent drift. You supply the track and the lap logic.
880
-
881
- ```javascript
882
- const car = Game.createSprite({
883
- sheet: carSheet,
884
- x: W * 0.38,
885
- y: laneY,
886
- width: CAR_SIZE,
887
- height: CAR_SIZE,
888
- pixelSnap: true, // stable texel phase for pixel art in motion
889
- rotation: 90, // facing right along the bottom straight
890
- zIndex: 10,
891
- hitboxScale: 0.75,
892
- carMode: true,
893
- enginePower: MAX_SPEED * 1.1,
894
- maxSpeed: MAX_SPEED,
895
- turnRate: 210,
896
- grip: 3.2, // below the default 4 → drifts in fast corners
897
- drag: 0.8,
898
- skidMarks: true,
899
- restitution: 0.3, // bump off the barriers instead of sticking
900
- solidWith: ['wall'],
901
- collidesWith: ['checkpoint', 'goal']
902
- });
903
- ```
904
-
905
- Controls write `throttle` and `steering` once per touch — never per frame:
906
-
907
- ```javascript
908
- bindHold(gasButton, () => {
909
- car.throttle = 1;
910
- }, () => {
911
- if (car.throttle === 1) {
912
- car.throttle = 0;
913
- }
914
- });
915
- bindHold(brakeButton, () => {
916
- car.throttle = -1;
917
- }, () => {
918
- if (car.throttle === -1) {
919
- car.throttle = 0;
920
- }
921
- });
922
- ```
923
-
924
- Track and laps: the visible track is one sprite; the collision geometry is invisible rectangles (outer border, inner island). Checkpoints are strips across each lane, and the goal only counts a lap once all of them were visited — that is what stops a player from reversing over the finish line.
925
-
926
- ```javascript
927
- car.addEventListener('collision', (e) => {
928
- if (e.group === 'checkpoint') {
929
- visited[e.other.cpIndex] = true;
930
- } else if (e.group === 'goal' && Object.keys(visited).length === 3) {
931
- laps++;
932
- visited = {};
933
- }
934
- });
935
- ```
936
-
937
- Tuning: lower `grip` = more drift; `turnRate` is degrees per second at full steering and full speed; `drag` decides how fast the car coasts to a stop. Read-only `drifting` is the hook for a tire-squeal sound.
938
-
939
- ## Asteroids / space shooter
940
-
941
- Newtonian flight: `thrust` accelerates along the heading, `angularVelocity` turns, `wrapAround` re-enters from the opposite edge, momentum does the rest.
942
-
943
- ```javascript
944
- const ship = Game.createSprite({
945
- sheet: shipSheet,
946
- x: W / 2, y: H / 2, width: SHIP_SIZE, height: SHIP_SIZE,
947
- zIndex: 10,
948
- maxSpeed: MAX_SPEED,
949
- wrapAround: true,
950
- hitboxScale: 0.6,
951
- hitboxShape: 'circle',
952
- collidesWith: ['asteroid'],
953
- animations: { thrust: { frames: [1, 2], fps: 14, loop: true } } // flickering flame
954
- });
955
-
956
- bindHold(thrustButton, () => {
957
- ship.thrust = THRUST; // px/s² along the heading
958
- ship.play('thrust');
959
- thrustSound.play(); // created with loop: true
960
- }, () => {
961
- ship.thrust = 0;
962
- ship.stop();
963
- ship.frame = 0;
964
- thrustSound.stop();
965
- });
966
-
967
- bindHold(leftButton, () => {
968
- ship.angularVelocity = -TURN_SPEED;
969
- }, () => {
970
- if (ship.angularVelocity < 0) {
971
- ship.angularVelocity = 0;
972
- }
973
- });
974
- ```
975
-
976
- Firing from the nose, inheriting the ship's momentum — with `rotation` in degrees clockwise and y pointing down, the heading vector is `(sin θ, -cos θ)`:
977
-
978
- ```javascript
979
- const rad = ship.rotation * Math.PI / 180;
980
- const dirX = Math.sin(rad);
981
- const dirY = -Math.cos(rad);
982
- bullet.x = ship.x + dirX * SHIP_SIZE * 0.55;
983
- bullet.y = ship.y + dirY * SHIP_SIZE * 0.55;
984
- bullet.rotation = ship.rotation;
985
- bullet.velocityX = ship.velocityX + dirX * BULLET_SPEED;
986
- bullet.velocityY = ship.velocityY + dirY * BULLET_SPEED;
987
- bullet.visible = true;
988
- ```
989
-
990
- Give bolts `blend: 'add'` so they glow over the starfield. Autofire is a `setInterval` on the fire button — a cadence decision, not motion.
991
-
992
- Damage feedback and invulnerability:
993
-
994
- ```javascript
995
- let invulnerableUntil = 0;
996
-
997
- ship.addEventListener('collision', (e) => {
998
- if (e.group !== 'asteroid' || Date.now() < invulnerableUntil) {
999
- return;
1000
- }
1001
- lives--;
1002
- explodeSound.play();
1003
- ship.flash('#ff5252', 400); // native red damage flash
1004
- invulnerableUntil = Date.now() + 1500;
1005
- });
1006
-
1007
- // last-life warning: one JS timer, the fade itself runs in the engine
1008
- warnTimer = setInterval(() => {
1009
- ship.flash('#ff5252', 350);
1010
- }, 700);
1011
- ```
1012
-
1013
- Rocks are round (`hitboxShape: 'circle'`), drift on random velocities, spin on `angularVelocity`, and are recycled by `visible = false` rather than removed.
1014
-
1015
- ## Lunar lander
1016
-
1017
- Same flight model as Asteroids, with gravity on and a landing test. There is no "landing" event — you judge the touchdown yourself in the `land` handler, where velocities are still readable.
1018
-
1019
- ```javascript
1020
- const lander = Game.createSprite({
1021
- sheet: shipSheet,
1022
- x: W / 2, y: H * 0.2, width: SHIP, height: SHIP,
1023
- zIndex: 10,
1024
- gravity: world.gravity, // per-world, e.g. H * 0.35 for the Moon
1025
- maxSpeed: MAX_SPEED,
1026
- hitboxScale: 0.8,
1027
- solidWith: ['terrain', 'pad'],
1028
- collidesWith: ['hazard']
1029
- });
1030
-
1031
- const MAX_LANDING_SPEED = H * 0.18;
1032
- const MAX_LANDING_TILT = 12; // degrees
1033
-
1034
- lander.addEventListener('land', (e) => {
1035
- const speed = Math.abs(lander.velocityY);
1036
- const tilt = Math.abs(((lander.rotation % 360) + 540) % 360 - 180);
1037
- const onPad = e.group === 'pad';
1038
-
1039
- if (onPad && speed <= MAX_LANDING_SPEED && tilt <= MAX_LANDING_TILT) {
1040
- succeed();
1041
- return;
1042
- }
1043
- crash();
1044
- });
1045
-
1046
- function crash() {
1047
- hitStop(140); // see the hit-stop pattern
1048
- lander.flash('#ff5252', 400);
1049
- explosion.x = lander.x;
1050
- explosion.y = lander.y;
1051
- explosion.emit(30);
1052
- gameView.shake({ strength: SHIP * 0.5, duration: 450 });
1053
- crashSound.play();
1054
- }
1055
- ```
1056
-
1057
- Thrust burns fuel on the same coarse tick that reads the controls — 80 ms is fast enough to feel responsive and slow enough to stay off the bridge:
1058
-
1059
- ```javascript
1060
- timer = setInterval(() => {
1061
- if (mainThrusterHeld && fuel > 0) {
1062
- fuel -= BURN_RATE;
1063
- lander.thrust = THRUST;
1064
- exhaust.emitting = true;
1065
- } else {
1066
- lander.thrust = 0;
1067
- exhaust.emitting = false;
1068
- }
1069
- fuelLabel.text = Math.max(0, Math.round(fuel)) + '%';
1070
- }, 80);
1071
- ```
1072
-
1073
- Wind is just a horizontal acceleration you re-apply on that same tick by nudging `velocityX`; per-world gravity, drag and wind are plain numbers in a catalog object.
1074
-
1075
- ## Endless runner
1076
-
1077
- The player never moves horizontally. Gravity plus an invisible ground solid handle the jump arc; the world scrolls past at the same speed as the obstacles, so they look glued to the street.
1078
-
1079
- ```javascript
1080
- const player = Game.createSprite({
1081
- sheet: skaterSheet,
1082
- x: W * 0.24,
1083
- y: groundTop - PLAYER / 2,
1084
- width: PLAYER, height: PLAYER,
1085
- zIndex: 10,
1086
- gravity: GRAVITY,
1087
- hitboxScale: 0.9,
1088
- solidWith: ['ground', 'platform'],
1089
- collidesWith: ['wall', 'pit'],
1090
- animations: { roll: { frames: [0, 1], fps: 6, loop: true } }
1091
- });
1092
-
1093
- function jump() {
1094
- if (!over && player.onGround) {
1095
- jumpSound.play();
1096
- dust.emitting = false; // no dust while airborne
1097
- player.velocityY = -JUMP;
1098
- player.stop();
1099
- player.frame = 2; // tuck pose
1100
- }
1101
- }
1102
-
1103
- player.addEventListener('land', () => {
1104
- if (!over) {
1105
- player.play('roll');
1106
- dust.emitting = true;
1107
- }
1108
- });
1109
- ```
1110
-
1111
- Spawning is a self-rescheduling timeout, so the gap can vary and a long obstacle can push the next spawn further out:
1112
-
1113
- ```javascript
1114
- function scheduleSpawn() {
1115
- spawnTimer = setTimeout(() => {
1116
- spawnObstacle();
1117
- scheduleSpawn();
1118
- }, 1100 + Math.random() * 800 + extraDelay);
1119
- }
1120
- ```
1121
-
1122
- Raised road sections are solids the player jumps onto, with `carryRiders: false` (the road scrolls, the skater must not be dragged with it) plus an invisible trigger on the front face so rolling into the step is a crash rather than the solid shoving the player off screen.
1123
-
1124
- Crash sequence: stop the timers, zero gravity, freeze every scroller, switch to the wipeout frame, fire a spark burst, shake the camera.
1125
-
1126
- ```javascript
1127
- function crash(group) {
1128
- over = true;
1129
- music.stop();
1130
- crashSound.play();
1131
- clearTimers();
1132
- player.gravity = 0;
1133
- player.velocityY = 0;
1134
- player.stop();
1135
- player.frame = 3;
1136
- layers.forEach((layer) => layer.setSpeed(0));
1137
- setObstacleSpeed(0);
1138
- dust.emitting = false;
1139
- sparks.x = player.x;
1140
- sparks.y = player.y;
1141
- sparks.emit(26);
1142
- gameView.shake({ strength: PLAYER * 0.12, duration: 450 });
1143
- }
1144
- ```
1145
-
1146
- ## Rhythm game
1147
-
1148
- Notes fall on native velocity; JS only handles presses and a spawn timer. Pads use `press`, not `tap` — a rhythm game cannot wait for the finger to lift.
1149
-
1150
- ```javascript
1151
- const pads = LANES.map((x, lane) => {
1152
- const pad = Game.createSprite({ sheet: padSheet, x: x, y: PAD_Y, width: PAD, height: PAD, zIndex: 5 });
1153
- pad.addEventListener('press', () => hitLane(lane, pad));
1154
- gameView.add(pad);
1155
- return pad;
1156
- });
1157
-
1158
- // gems must not swallow pad presses
1159
- const sprite = Game.createSprite({
1160
- sheet: noteSheet, width: NOTE, height: NOTE, zIndex: 10,
1161
- visible: false,
1162
- touchEnabled: false,
1163
- collidesWith: ['misszone']
1164
- });
1165
-
1166
- function hitLane(lane, pad) {
1167
- pad.frame = 1;
1168
- setTimeout(() => {
1169
- pad.frame = 0;
1170
- }, 120);
1171
-
1172
- let best = null;
1173
- let bestDistance = Infinity;
1174
- notes.forEach((note) => {
1175
- if (note.active && note.lane === lane) {
1176
- const distance = Math.abs(note.sprite.y - PAD_Y); // live native read
1177
- if (distance < bestDistance) {
1178
- bestDistance = distance;
1179
- best = note;
1180
- }
1181
- }
1182
- });
1183
-
1184
- if (best !== null && bestDistance <= HIT_RANGE) {
1185
- recycle(best);
1186
- goodSound.play();
1187
- burst.x = LANES[lane];
1188
- burst.y = PAD_Y;
1189
- burst.tint = LANE_TINTS[lane];
1190
- burst.emit(18);
1191
- score += (bestDistance <= PERFECT) ? 3 : 1;
1192
- } else {
1193
- badSound.play();
1194
- }
1195
- }
1196
- ```
1197
-
1198
- An invisible `misszone` trigger below the pads catches gems that got away and counts the miss. Derive the fall speed from the beat: `FALL_SPEED = (PAD_Y + NOTE) / 1.6` with a 400 ms spawn interval puts four eighth notes between spawn and pad at 150 BPM.
1199
-
1200
- ## Ball sports / breakout physics
1201
-
1202
- `restitution` on a circle-hitbox ball gives real reflection off walls and obstacles. Player contact is handled in JS so you control the feel.
1203
-
1204
- ```javascript
1205
- const ball = Game.createSprite({
1206
- sheet: ballSheet,
1207
- x: W / 2, y: H * 0.3, width: BALL_SIZE, height: BALL_SIZE,
1208
- zIndex: 12,
1209
- hitboxScale: 0.9,
1210
- hitboxShape: 'circle', // bounces off the net's corner along the normal
1211
- restitution: 0.75,
1212
- solidWith: ['wall', 'net'], // blocked and bounced by these
1213
- collidesWith: ['player', 'floor'] // only reported for these
1214
- });
1215
-
1216
- ball.addEventListener('collision', (e) => {
1217
- if (e.group === 'player') {
1218
- // reflect away from the blob's center, biased upward
1219
- const blob = e.other;
1220
- const dx = ball.x - blob.x;
1221
- const dy = ball.y - blob.y;
1222
- const len = Math.sqrt(dx * dx + dy * dy) || 1;
1223
- ball.velocityX = (dx / len) * HIT_SPEED;
1224
- ball.velocityY = Math.min((dy / len) * HIT_SPEED, -HIT_SPEED * 0.45);
1225
- } else if (e.group === 'floor') {
1226
- roundOver(ball.x > W / 2);
1227
- }
1228
- });
1229
- ```
1230
-
1231
- Note the split: walls and the net are `solidWith` (the engine bounces the ball off them for free), while the players and the floor are `collidesWith` (you decide what happens). For breakout, bricks go in `collidesWith` and are removed with `visible = false` on hit while the paddle and walls stay solid.
1232
-
1233
- A `restitution: 1` ball on an invisible floor bounces forever with no JS at all.
1234
-
1235
- **Opponent AI** on an 80 ms timer, reading a handful of live properties per tick:
1236
-
1237
- ```javascript
1238
- timer = setInterval(() => {
1239
- const onCpuSide = ball.x > W / 2;
1240
- const target = (running && onCpuSide) ? ball.x + PLAYER_SIZE * 0.2 : cpuHomeX;
1241
- const dx = target - cpu.x;
1242
- cpu.velocityX = (Math.abs(dx) > PLAYER_SIZE * 0.15) ? (dx > 0 ? RUN_SPEED : -RUN_SPEED) : 0;
1243
- if (running && onCpuSide && cpu.onGround
1244
- && Math.abs(ball.x - cpu.x) < PLAYER_SIZE * 1.2
1245
- && ball.y < cpu.y && ball.velocityY > 0) {
1246
- cpu.velocityY = -JUMP;
1247
- }
1248
- }, 80);
1249
- ```
1250
-
1251
- ## Cards and board games
1252
-
1253
- No physics at all — sprite taps, native tweens, glow and idle wobble.
1254
-
1255
- **Dealing** staggers one tween per card with `delay`, and flips the art on arrival:
1256
-
1257
- ```javascript
1258
- cards.forEach((card, index) => {
1259
- card.state = 'dealing';
1260
- card.sprite.idleAnimation = false; // still cards land exactly on target
1261
- card.sprite.frame = BACK_FRAME;
1262
- card.sprite.x = DECK_X;
1263
- card.sprite.y = DECK_Y;
1264
- card.sprite.zIndex = 30 + index; // above the deck while flying
1265
- card.sprite.animate({
1266
- x: card.home.x,
1267
- y: card.home.y,
1268
- rotation: card.home.rotation,
1269
- duration: 380,
1270
- delay: index * 140, // one card after another
1271
- easing: Game.EASE_IN_OUT
1272
- });
1273
- });
1274
-
1275
- sprite.addEventListener('complete', () => {
1276
- if (card.state === 'dealing') {
1277
- card.state = 'hand';
1278
- sprite.frame = card.face; // flip face up on arrival
1279
- sprite.zIndex = card.index;
1280
- sprite.idleAnimation = true; // wobble only once it has settled
1281
- }
1282
- });
1283
- ```
1284
-
1285
- **Selection** lifts the card and fades a glow in — `glowOpacity` is tweenable, so the halo appears with the movement instead of popping:
1286
-
1287
- ```javascript
1288
- sprite.addEventListener('tap', () => {
1289
- if (card.selected) {
1290
- card.selected = false;
1291
- sprite.animate({ x: home.x, y: home.y, rotation: home.rotation, scale: 1, glowOpacity: 0, duration: 150, easing: Game.EASE_OUT });
1292
- } else if (selectedCards().length < MAX_SELECTED) {
1293
- card.selected = true;
1294
- sprite.glowColor = '#ffc94d';
1295
- sprite.glowBlur = CARD_W * 0.14;
1296
- sprite.glowOpacity = 0;
1297
- sprite.animate({ y: home.y - LIFT, scale: 1.12, glowOpacity: 1, duration: 150, easing: Game.EASE_OUT });
1298
- }
1299
- });
1300
- ```
1301
-
1302
- **Drag & drop with snapping** (puzzle pieces, board tiles). The drag itself is native; you only decide where it lands:
1303
-
1304
- ```javascript
1305
- const piece = Game.createSprite({ sheet: sheet, frame: index, x: homeX, y: homeY, width: PIECE, height: PIECE, draggable: true, zIndex: 10 });
1306
-
1307
- piece.addEventListener('press', () => {
1308
- piece.zIndex = 100; // draw above everything while held
1309
- piece.animate({ scale: 1.15, duration: 120, easing: Game.EASE_OUT });
1310
- });
1311
-
1312
- piece.addEventListener('release', () => {
1313
- piece.animate({ scale: 1, duration: 120, easing: Game.EASE_OUT });
1314
- });
1315
-
1316
- piece.addEventListener('dragend', (e) => {
1317
- let best = null;
1318
- let bestDistance = SNAP_DISTANCE;
1319
- cells.forEach((cell) => {
1320
- if (cell.piece) {
1321
- return;
1322
- }
1323
- const d = Math.sqrt(Math.pow(e.x - cell.x, 2) + Math.pow(e.y - cell.y, 2));
1324
- if (d < bestDistance) {
1325
- bestDistance = d;
1326
- best = cell;
1327
- }
1328
- });
1329
- piece.zIndex = 10;
1330
- if (best) {
1331
- best.piece = piece;
1332
- piece.animate({ x: best.x, y: best.y, duration: 150, easing: Game.EASE_OUT });
1333
- } else {
1334
- piece.animate({ x: homeX, y: homeY, duration: 400, easing: Game.EASE_IN_OUT });
1335
- }
1336
- });
1337
- ```
1338
-
1339
- Because every handler is per sprite, this is multi-touch for free: each finger grabs its own piece.
1340
-
1341
- ## Point & click adventure
1342
-
1343
- Tap-to-walk routes around the scenery with `findPath` and walks the waypoints natively:
1344
-
1345
- ```javascript
1346
- const WALK_BOUNDS = { // the floor, in sprite-CENTER coordinates
1347
- minX: W * 0.06, maxX: W * 0.94,
1348
- minY: WALK_TOP - PLAYER_H / 2, maxY: H * 0.95 - PLAYER_H / 2
1349
- };
1350
-
1351
- function walkTo(x, y) {
1352
- const targetX = Math.min(Math.max(x, WALK_BOUNDS.minX), WALK_BOUNDS.maxX);
1353
- const targetY = Math.min(Math.max(y - PLAYER_H / 2, WALK_BOUNDS.minY), WALK_BOUNDS.maxY);
1354
- const path = gameView.findPath({ x: player.x, y: player.y }, { x: targetX, y: targetY }, {
1355
- cellSize: Math.round(W * 0.04),
1356
- groups: ['obstacle'],
1357
- clearance: PLAYER_W * 0.35, // keep half a body off the trunk
1358
- bounds: WALK_BOUNDS
1359
- });
1360
- if (!path || path.length < 2) {
1361
- return; // tapped outside the floor
1362
- }
1363
- player.scaleX = path[1].x < player.x ? -1 : 1; // face the first leg
1364
- player.play('walk');
1365
- player.followPath(path, { speed: WALK_SPEED }); // constant px/s, cancels the previous route
1366
- }
1367
-
1368
- player.addEventListener('pathcomplete', () => player.play('idle'));
1369
- ```
1370
-
1371
- The walkable floor is still a clamp, not geometry — but it is now expressed as the `bounds` rect the search runs in. Obstacles are separate: `pointclick.js` adds an invisible box over the oak's *trunk* only (`opacity: 0`, `touchEnabled: false`, `collisionGroup: 'obstacle'`), so the player circles the trunk and still walks behind the canopy with `ySort`. See [Pathfinding around obstacles](#pathfinding-around-obstacles--mazejs-pointclickjs) for the options.
1372
-
1373
- With nothing in the scene to route around, `findPath` returns `[start, goal]` and `followPath` walks the straight line — the same result as the distance-sized `animate()` tween this demo used before, with one less speed calculation to get wrong.
1374
-
1375
- **Hotspots and the tap conflict.** The GameView fires `tap` for *every* touch, including ones a sprite already handled, so a naive walk handler also walks when you click the bird. Hit-test the interactive sprites in JS and bail out:
1376
-
1377
- ```javascript
1378
- function over(sprite, x, y) {
1379
- return sprite.visible !== false
1380
- && Math.abs(x - sprite.x) <= sprite.width / 2 + PAD
1381
- && Math.abs(y - sprite.y) <= sprite.height / 2 + PAD;
1382
- }
1383
-
1384
- gameView.addEventListener('tap', (e) => {
1385
- if (over(bird, e.x, e.y) || over(handIcon, e.x, e.y) || over(talkIcon, e.x, e.y)) {
1386
- return;
1387
- }
1388
- hideIcons();
1389
- walkTo(e.x, e.y);
1390
- });
1391
- ```
1392
-
1393
- A verb coin is two icon sprites with `visible` toggled and `idleAnimation: true` for a bit of life while open.
1394
-
1395
- ## Particles
1396
-
1397
- Three modes, all native. Tint white art at runtime so one 16×16 texture covers every effect in the game.
1398
-
1399
- **Continuous fountain**:
1400
-
1401
- ```javascript
1402
- const fountain = Game.createEmitter({
1403
- sheet: sparkSheet, frame: 1,
1404
- x: W / 2, y: H - UNIT * 0.05,
1405
- rate: 90,
1406
- lifetime: 1600,
1407
- speed: H * 0.75, // launch up...
1408
- angle: 0, // 0 = up, clockwise degrees
1409
- spread: 25,
1410
- gravity: H * 0.45, // ...and fall back
1411
- size: UNIT * 0.03,
1412
- startScale: 1, endScale: 0.4,
1413
- startOpacity: 1, endOpacity: 0,
1414
- tint: '#ffcc44',
1415
- maxParticles: 400
1416
- });
1417
- gameView.add(fountain);
1418
- ```
1419
-
1420
- **Burst on demand** — `rate: 0`, position it and call `emit(n)`:
1421
-
1422
- ```javascript
1423
- const firework = Game.createEmitter({
1424
- sheet: sparkSheet, frame: 1,
1425
- rate: 0,
1426
- lifetime: 900,
1427
- speed: UNIT * 0.6,
1428
- spread: 360,
1429
- gravity: H * 0.25,
1430
- startScale: 1, endScale: 0.3,
1431
- maxParticles: 500
1432
- });
1433
- gameView.add(firework);
1434
-
1435
- gameView.addEventListener('tap', (e) => {
1436
- firework.x = e.x;
1437
- firework.y = e.y;
1438
- firework.tint = COLORS[colorIndex++ % COLORS.length];
1439
- firework.emit(45);
1440
- });
1441
- ```
1442
-
1443
- **Trail following a sprite** — `target` keeps up even mid-drag, with no bridge traffic:
1444
-
1445
- ```javascript
1446
- const dust = Game.createEmitter({
1447
- sheet: sparkSheet, frame: 0,
1448
- target: player,
1449
- offsetX: -PLAYER * 0.32, // behind the rear wheels
1450
- offsetY: PLAYER * 0.42,
1451
- rate: 22,
1452
- lifetime: 450,
1453
- speed: W * 0.08,
1454
- angle: -35, // up and back
1455
- spread: 50,
1456
- startOpacity: 0.45, endOpacity: 0,
1457
- tint: '#8a8580',
1458
- zIndex: 9 // behind the player
1459
- });
1460
- gameView.add(dust);
1461
-
1462
- dust.emitting = false; // the one property JS toggles at runtime
1463
- ```
1464
-
1465
- Add `blend: 'add'` for fire, magic and sparks — overlaps bloom instead of covering.
1466
-
1467
- ## Ropes and chains
1468
-
1469
- ```javascript
1470
- // hangs from a draggable ball
1471
- const ball = Game.createSprite({ sheet: ballSheet, x: W / 2, y: H * 0.22, width: BALL, height: BALL, zIndex: 10, draggable: true, hitboxShape: 'circle' });
1472
- gameView.add(ball);
1473
-
1474
- gameView.add(Game.createRope({
1475
- sheet: ropeSheet,
1476
- segments: 14,
1477
- segmentLength: H * 0.04,
1478
- thickness: W * 0.03,
1479
- gravity: H * 1.6,
1480
- damping: 0.985,
1481
- iterations: 3,
1482
- head: ball, // pinned to the sprite — drag it and the rope follows
1483
- zIndex: 5
1484
- }));
1485
-
1486
- // fixed anchor + a weight on a tether: drag it too far and it is pulled back
1487
- gameView.add(Game.createRope({
1488
- sheet: ropeSheet,
1489
- segments: 10,
1490
- segmentLength: SEG_LEN,
1491
- gravity: H * 1.6,
1492
- x: W * 0.15, y: H * 0.1, // fixed head anchor
1493
- tail: weight,
1494
- maxLength: 10 * SEG_LEN, // wrecking balls, leashes, yo-yos
1495
- zIndex: 5
1496
- }));
1497
- ```
1498
-
1499
- With sprites on both ends plus `maxLength`, dragging either one tows the other once the rope goes taut — carts, chained crates, a grappling hook whose tip you read from `rope.endX` / `rope.endY`.
1500
-
1501
- ## Camera work
1502
-
1503
- ```javascript
1504
- gameView.follow(player, {
1505
- leftMargin: 0.4, rightMargin: 0.6, // horizontal follow is off without these
1506
- topMargin: 0.4, bottomMargin: 0.6,
1507
- smoothing: 0.12, // 0 snaps; higher eases
1508
- maxY: WORLD_H // default 0 clamps to the top of the world
1509
- });
1510
- gameView.cameraBounds = { minX: 0, minY: 0, maxX: WORLD_W, maxY: WORLD_H };
1511
- ```
1512
-
1513
- Sprites live in world coordinates and touch input is mapped back to world space automatically, zoom included — taps and drags keep working while scrolled. Overlaid Titanium controls are screen-fixed and unaffected.
1514
-
1515
- ```javascript
1516
- gameView.cameraScale = Math.min(2.5, Math.max(0.5, gameView.cameraScale * 1.25)); // zoom
1517
- gameView.shake({ strength: W * 0.02, duration: 500 }); // impact
1518
- gameView.cameraEffect = 'tint'; // night vision
1519
- gameView.cameraTint = '#4f8';
1520
- gameView.cameraEffectIntensity = 0.7;
1521
- gameView.cameraEffect = 'glitch'; // broken signal
1522
- gameView.cameraEffect = 'none'; // skips the pass entirely
1523
- ```
1524
-
1525
- For a big ground plane, one `tileRepeat` sprite covering the whole world beats a grid of tile sprites — the texture keeps its native density and the scene stays one draw call.
1526
-
1527
- ## Visual effects
1528
-
1529
- **Blend modes** — four, on sprites and emitters alike. `blend.js` shows all of them side by side over a bright meadow strip:
1530
-
1531
- | Mode | Does | Use for |
1532
- | --- | --- | --- |
1533
- | `'normal'` | Covers what is behind it | Everything else |
1534
- | `'add'` | Sums toward white | Glows, fire, lasers, light shafts, sparks |
1535
- | `'multiply'` | Darkens the backdrop | Contact shadows under sprites, stains, grime, smoke that dims |
1536
- | `'screen'` | Lightens without blowing out | Fog, haze, god rays, soft light |
1537
-
1538
- Group same-blend sprites on their own `zIndex` band: every mode change in draw order costs a batch flush, so alternating them sprite by sprite degrades toward one draw call each. Unknown strings fall back to `'normal'` in silence and the names are case-sensitive — `blend: 'Add'` renders as normal with no warning.
1539
-
1540
- **`flash(color, duration)`** — a solid-color silhouette that fades out natively. This is the damage flash a multiplicative `tintColor` cannot produce (white tint = no change). Retrigger it on a timer for invincibility blinking.
1541
-
1542
- **`flipX` / `flipY` from movement** — mirror the drawn frame without touching physics:
1543
-
1544
- ```javascript
1545
- // tween patrol: flip on each turn-around
1546
- function patrol() {
1547
- const goingRight = bird.x < W / 2;
1548
- bird.flipX = !goingRight;
1549
- bird.animate({ x: goingRight ? W - MARGIN : MARGIN, duration: 3000, easing: Game.EASE_IN_OUT });
1550
- }
1551
- bird.addEventListener('complete', patrol);
1552
-
1553
- // velocity runner: a slow watchdog turns it around, never moves it
1554
- timer = setInterval(() => {
1555
- [dog, player].forEach((runner) => {
1556
- if (runner.velocityX > 0 && runner.x > W - MARGIN) {
1557
- runner.velocityX = -runner.velocityX;
1558
- } else if (runner.velocityX < 0 && runner.x < MARGIN) {
1559
- runner.velocityX = -runner.velocityX;
1560
- }
1561
- runner.flipX = runner.velocityX < 0;
1562
- });
1563
- }, 150);
1564
-
1565
- // VVVVVV-style gravity inversion
1566
- gameView.addEventListener('tap', () => {
1567
- player.gravity = -player.gravity;
1568
- player.flipY = player.gravity < 0;
1569
- });
1570
- ```
1571
-
1572
- **`timeScale`** — one property slows or freezes physics, sheet animations, tweens, particles, the camera and the game-clock timers together, while rendering and touch keep running. `0.5` for slow motion, `0` for a pause that still draws, short bursts of `0` for hit-stop. `timescale.js` makes the timer half visible: a clock on `gameView.every(1000, ...)` freezes with the scene while a `setInterval` clock beside it keeps ticking.
1573
-
1574
- **`idleAnimation`** — a per-sprite organic sway that composes with tweens and drags. Cheap life for cards, icons, floating pickups and menu art. Disable it before a tween that must land precisely.