@maccesar/titools 4.15.0 → 4.16.1

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.
@@ -0,0 +1,653 @@
1
+ # Action-game recipes
2
+
3
+ Production-oriented patterns distilled from the upstream action demos at `ti.game@c216e7f`. Use [examples.md](examples.md) to locate the full runnable demo and [api.md](api.md) for exact defaults and payloads. The wall-contact API is source/README-backed because upstream has not added a wall-jump demo yet.
4
+
5
+ <!-- TOC-START -->
6
+ ## Contents
7
+
8
+ - [Tap-to-flap](#tap-to-flap)
9
+ - [Platformer](#platformer)
10
+ - [Top-down / Zelda](#top-down--zelda)
11
+ - [Top-down racer](#top-down-racer)
12
+ - [Asteroids / space shooter](#asteroids--space-shooter)
13
+ - [Lunar lander](#lunar-lander)
14
+ - [Endless runner](#endless-runner)
15
+ - [Rhythm game](#rhythm-game)
16
+ - [Ball sports / breakout physics](#ball-sports--breakout-physics)
17
+
18
+ <!-- TOC-END -->
19
+
20
+ ## Tap-to-flap
21
+
22
+ Gravity plus an impulse per tap. Scoring and death are collisions with invisible zones. The rotation tween is what sells the arc.
23
+
24
+ ```javascript
25
+ const pig = Game.createSprite({
26
+ sheet: pigSheet,
27
+ x: W * 0.28,
28
+ y: H * 0.42,
29
+ width: PIG_SIZE,
30
+ height: PIG_SIZE,
31
+ zIndex: 10,
32
+ hitboxScale: 0.7, // the art does not fill the frame
33
+ hitboxScaleY: 0.85, // ...and the pig is wider than he is tall
34
+ collidesWith: ['pipe', 'ground', 'score', 'ceiling'],
35
+ animations: { fly: { frames: [0, 1, 2, 1], fps: 10, loop: true } }
36
+ });
37
+ pig.play('fly');
38
+
39
+ gameView.addEventListener('press', () => {
40
+ if (over) {
41
+ reset();
42
+ return;
43
+ }
44
+ if (!started) {
45
+ start(); // first tap turns gravity on
46
+ }
47
+ pig.velocityY = FLAP; // e.g. -H * 0.6
48
+ pig.clearTweens();
49
+ pig.rotation = -15; // snap the nose up...
50
+ pig.animate({ rotation: 25, duration: 900, easing: Game.EASE_IN }); // ...then tip forward
51
+ });
52
+
53
+ pig.addEventListener('collision', (e) => {
54
+ if (over) {
55
+ return;
56
+ }
57
+ if (e.group === 'score') {
58
+ score++;
59
+ } else if (e.group === 'ceiling') {
60
+ pig.velocityY = H * 0.1; // gentle push back down instead of a hard stop
61
+ } else {
62
+ gameOver();
63
+ }
64
+ });
65
+ ```
66
+
67
+ 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.
68
+
69
+ ## Platformer
70
+
71
+ `solidWith` handles the blocking; you only decide when to jump.
72
+
73
+ ```javascript
74
+ const WALL_KICK_X = RUN_SPEED * 1.35;
75
+ const WALL_KICK_Y = JUMP * 0.9;
76
+
77
+ const player = Game.createSprite({
78
+ sheet: playerSheet,
79
+ x: W * 0.15,
80
+ y: groundTop - PLAYER_SIZE / 2,
81
+ width: PLAYER_SIZE,
82
+ height: PLAYER_SIZE,
83
+ zIndex: 10,
84
+ gravity: GRAVITY, // H * 2.2
85
+ hitboxScale: 0.85,
86
+ hitboxScaleX: 0.66, // the blob is 38px wide in a 64px frame, but nearly full height
87
+ solidWith: ['solid', 'trampoline'],
88
+ wallSlideSpeed: H * 0.18, // 0 disables the native downward cap
89
+ animations: {
90
+ idle: { frames: [0], fps: 1, loop: true },
91
+ walk: { frames: [1, 2], fps: 8, loop: true }
92
+ }
93
+ });
94
+
95
+ gameView.follow(player, { topMargin: 0.33, bottomMargin: 0.7, maxY: 0 });
96
+
97
+ let moveDir = 0;
98
+
99
+ function applyMovement() {
100
+ player.velocityX = moveDir * RUN_SPEED;
101
+ if (moveDir !== 0) {
102
+ player.scaleX = moveDir; // face the running direction
103
+ }
104
+ if (player.onGround) {
105
+ player.play(moveDir !== 0 ? 'walk' : 'idle');
106
+ }
107
+ }
108
+
109
+ function jump() {
110
+ if (player.onGround) { // gate on the engine's own flag
111
+ player.velocityY = -JUMP; // H * 0.95 ≈ 0.2 * H of height
112
+ player.stop();
113
+ player.frame = 3; // hold the jump pose
114
+ return;
115
+ }
116
+
117
+ const away = player.onWallLeft ? 1 : player.onWallRight ? -1 : 0;
118
+ if (away !== 0) { // read state when the jump is requested
119
+ player.velocityX = away * WALL_KICK_X;
120
+ player.velocityY = -WALL_KICK_Y;
121
+ player.scaleX = away;
122
+ player.stop();
123
+ player.frame = 3;
124
+ }
125
+ }
126
+
127
+ player.addEventListener('land', (e) => {
128
+ if (e.group === 'trampoline') {
129
+ player.velocityY = -JUMP * 1.1; // bounce higher than a jump
130
+ player.stop();
131
+ player.frame = 3;
132
+ e.other.frame = 1; // squash the mat you landed on
133
+ setTimeout(() => {
134
+ e.other.frame = 0;
135
+ }, 150);
136
+ return;
137
+ }
138
+ applyMovement(); // back from the jump pose
139
+ });
140
+
141
+ player.addEventListener('wallhit', () => {
142
+ player.flash('#d7f4ff', 80); // transition effect, not jump gating
143
+ });
144
+ ```
145
+
146
+ 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.
147
+
148
+ **Wall jumps and slides**: `onWallLeft` / `onWallRight` are read-only contact state and only stay true while the resolver is pushing the player sideways. Read them in `jump()`; do not poll `wallhit` or make it sticky in JS. `wallhit` fires on first contact or a side switch and is ideal for sound/dust. `wallSlideSpeed` caps downward velocity only, so the upward kick remains intact. With touch controls that continuously reapply horizontal movement, let the wall-jump impulse win briefly before writing the held direction again. TileLayer walls set the same flags, but their `wallhit` payload has no `other` or `group`.
149
+
150
+ **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.
151
+
152
+ **Moving platforms carry riders for free** — a solid moved by `velocityX` or a tween drags whoever stands on it, with no re-landing jitter:
153
+
154
+ ```javascript
155
+ const mover = Game.createSprite({
156
+ sheet: moverSheet, x: W * 0.18, y: groundTop - STEP * 2,
157
+ width: W * 0.26, height: PLAT_H, collisionGroup: 'solid'
158
+ });
159
+
160
+ function patrol() {
161
+ mover.animate({
162
+ x: (mover.x < W * 0.35) ? W * 0.5 : W * 0.18,
163
+ duration: 2600,
164
+ easing: Game.EASE_IN_OUT
165
+ });
166
+ }
167
+ mover.addEventListener('complete', patrol); // ping-pong forever
168
+ patrol();
169
+ ```
170
+
171
+ Set `carryRiders: false` on terrain that scrolls under a player who should stay put (endless runners).
172
+
173
+ ## Top-down / Zelda
174
+
175
+ A tile map from a string array, plus `ySort` for depth. Only water is solid; trees are pure decoration you can walk behind.
176
+
177
+ ```javascript
178
+ const MAP = [
179
+ 'WWWWWWWWWWWW',
180
+ 'WGGGGGGGGGGW',
181
+ 'WGGFGGGPGGGW',
182
+ 'WWWWWWWWWWWW'
183
+ ];
184
+ const FRAMES = { G: 0, F: 1, P: 2, W: 3 };
185
+ const COLS = MAP[0].length;
186
+ const ROWS = MAP.length;
187
+ const TILE = Math.floor(Math.min(W / COLS, (H - buttonZone) / ROWS));
188
+ const ox = (W - COLS * TILE) / 2;
189
+ const oy = (H - buttonZone - ROWS * TILE) / 2;
190
+
191
+ const level = [];
192
+ for (let row = 0; row < ROWS; row++) {
193
+ for (let col = 0; col < COLS; col++) {
194
+ const kind = MAP[row].charAt(col);
195
+ level.push(Game.createSprite({
196
+ sheet: tileSheet,
197
+ frame: FRAMES[kind],
198
+ x: ox + (col + 0.5) * TILE,
199
+ y: oy + (row + 0.5) * TILE,
200
+ width: TILE,
201
+ height: TILE,
202
+ zIndex: 0,
203
+ collisionGroup: (kind === 'W') ? 'solid' : null // only water blocks
204
+ }));
205
+ }
206
+ }
207
+ gameView.add(level); // one bridge crossing
208
+ ```
209
+
210
+ This sprite-per-tile pattern is still reasonable at 12×15 when trees and buildings need independent sprite behavior. At 200×200 it ticks, sorts, draws, and collision-scans far too many objects. Use one or more native `Game.createTileLayer()` grids for large/static terrain, then keep only interactive actors and props as sprites. See [tilemaps.md](tilemaps.md).
211
+
212
+ 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.
213
+
214
+ ```javascript
215
+ const player = Game.createSprite({
216
+ sheet: walkerSheet,
217
+ x: start.x, y: start.y, width: TILE, height: TILE,
218
+ zIndex: 5,
219
+ ySort: true,
220
+ hitboxScale: 0.6, // top-down games feel best with a small body box
221
+ solidWith: ['solid'],
222
+ animations: {
223
+ down: { frames: [0, 1], fps: 6, loop: true },
224
+ up: { frames: [2, 3], fps: 6, loop: true },
225
+ side: { frames: [4, 5], fps: 6, loop: true }
226
+ }
227
+ });
228
+ ```
229
+
230
+ 8-way movement from four buttons, tracking facing yourself because `animation` keeps its value after `stop()`:
231
+
232
+ ```javascript
233
+ let moveX = 0;
234
+ let moveY = 0;
235
+ let lastFacing = 'down';
236
+ let walking = false;
237
+
238
+ function applyMovement() {
239
+ player.velocityX = moveX * SPEED;
240
+ player.velocityY = moveY * SPEED;
241
+ if (moveX === 0 && moveY === 0) {
242
+ player.stop();
243
+ walking = false;
244
+ player.frame = { down: 0, up: 2, side: 4 }[lastFacing];
245
+ return;
246
+ }
247
+ const facing = (moveX !== 0) ? 'side' : (moveY < 0 ? 'up' : 'down');
248
+ if (moveX !== 0) {
249
+ player.scaleX = moveX; // side frames face right; mirror for left
250
+ }
251
+ if (!walking || facing !== lastFacing) {
252
+ player.play(facing);
253
+ walking = true;
254
+ }
255
+ lastFacing = facing;
256
+ }
257
+ ```
258
+
259
+ **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.
260
+
261
+ ```javascript
262
+ timer = setInterval(() => {
263
+ const dx = player.x - dog.x;
264
+ const dy = player.y - dog.y;
265
+ const dist = Math.sqrt(dx * dx + dy * dy);
266
+ if (dist > FOLLOW_DISTANCE) {
267
+ dog.velocityX = (dx / dist) * DOG_SPEED;
268
+ dog.velocityY = (dy / dist) * DOG_SPEED;
269
+ if (Math.abs(dx) > 2) {
270
+ dog.scaleX = (dx > 0) ? 1 : -1;
271
+ }
272
+ setDogState('walk');
273
+ } else {
274
+ dog.velocityX = 0;
275
+ dog.velocityY = 0;
276
+ setDogState('sit');
277
+ }
278
+ }, 100);
279
+ ```
280
+
281
+ ## Top-down racer
282
+
283
+ `carMode` gives you throttle, steering and emergent drift. You supply the track and the lap logic.
284
+
285
+ ```javascript
286
+ const car = Game.createSprite({
287
+ sheet: carSheet,
288
+ x: W * 0.38,
289
+ y: laneY,
290
+ width: CAR_SIZE,
291
+ height: CAR_SIZE,
292
+ pixelSnap: true, // stable texel phase for pixel art in motion
293
+ rotation: 90, // facing right along the bottom straight
294
+ zIndex: 10,
295
+ hitboxScale: 0.75,
296
+ carMode: true,
297
+ enginePower: MAX_SPEED * 1.1,
298
+ maxSpeed: MAX_SPEED,
299
+ turnRate: 210,
300
+ grip: 3.2, // below the default 4 → drifts in fast corners
301
+ drag: 0.8,
302
+ skidMarks: true,
303
+ restitution: 0.3, // bump off the barriers instead of sticking
304
+ solidWith: ['wall'],
305
+ collidesWith: ['checkpoint', 'goal']
306
+ });
307
+ ```
308
+
309
+ Controls write `throttle` and `steering` once per touch — never per frame:
310
+
311
+ ```javascript
312
+ bindHold(gasButton, () => {
313
+ car.throttle = 1;
314
+ }, () => {
315
+ if (car.throttle === 1) {
316
+ car.throttle = 0;
317
+ }
318
+ });
319
+ bindHold(brakeButton, () => {
320
+ car.throttle = -1;
321
+ }, () => {
322
+ if (car.throttle === -1) {
323
+ car.throttle = 0;
324
+ }
325
+ });
326
+ ```
327
+
328
+ 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.
329
+
330
+ ```javascript
331
+ car.addEventListener('collision', (e) => {
332
+ if (e.group === 'checkpoint') {
333
+ visited[e.other.cpIndex] = true;
334
+ } else if (e.group === 'goal' && Object.keys(visited).length === 3) {
335
+ laps++;
336
+ visited = {};
337
+ }
338
+ });
339
+ ```
340
+
341
+ 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.
342
+
343
+ ## Asteroids / space shooter
344
+
345
+ Newtonian flight: `thrust` accelerates along the heading, `angularVelocity` turns, `wrapAround` re-enters from the opposite edge, momentum does the rest.
346
+
347
+ ```javascript
348
+ const ship = Game.createSprite({
349
+ sheet: shipSheet,
350
+ x: W / 2, y: H / 2, width: SHIP_SIZE, height: SHIP_SIZE,
351
+ zIndex: 10,
352
+ maxSpeed: MAX_SPEED,
353
+ wrapAround: true,
354
+ hitboxScale: 0.6,
355
+ hitboxShape: 'circle',
356
+ collidesWith: ['asteroid'],
357
+ animations: { thrust: { frames: [1, 2], fps: 14, loop: true } } // flickering flame
358
+ });
359
+
360
+ bindHold(thrustButton, () => {
361
+ ship.thrust = THRUST; // px/s² along the heading
362
+ ship.play('thrust');
363
+ thrustSound.play(); // created with loop: true
364
+ }, () => {
365
+ ship.thrust = 0;
366
+ ship.stop();
367
+ ship.frame = 0;
368
+ thrustSound.stop();
369
+ });
370
+
371
+ bindHold(leftButton, () => {
372
+ ship.angularVelocity = -TURN_SPEED;
373
+ }, () => {
374
+ if (ship.angularVelocity < 0) {
375
+ ship.angularVelocity = 0;
376
+ }
377
+ });
378
+ ```
379
+
380
+ Firing from the nose, inheriting the ship's momentum — with `rotation` in degrees clockwise and y pointing down, the heading vector is `(sin θ, -cos θ)`:
381
+
382
+ ```javascript
383
+ const rad = ship.rotation * Math.PI / 180;
384
+ const dirX = Math.sin(rad);
385
+ const dirY = -Math.cos(rad);
386
+ bullet.x = ship.x + dirX * SHIP_SIZE * 0.55;
387
+ bullet.y = ship.y + dirY * SHIP_SIZE * 0.55;
388
+ bullet.rotation = ship.rotation;
389
+ bullet.velocityX = ship.velocityX + dirX * BULLET_SPEED;
390
+ bullet.velocityY = ship.velocityY + dirY * BULLET_SPEED;
391
+ bullet.visible = true;
392
+ ```
393
+
394
+ Give bolts `blend: 'add'` so they glow over the starfield. Autofire is a `setInterval` on the fire button — a cadence decision, not motion.
395
+
396
+ Damage feedback and invulnerability:
397
+
398
+ ```javascript
399
+ let invulnerableUntil = 0;
400
+
401
+ ship.addEventListener('collision', (e) => {
402
+ if (e.group !== 'asteroid' || Date.now() < invulnerableUntil) {
403
+ return;
404
+ }
405
+ lives--;
406
+ explodeSound.play();
407
+ ship.flash('#ff5252', 400); // native red damage flash
408
+ invulnerableUntil = Date.now() + 1500;
409
+ });
410
+
411
+ // last-life warning: one JS timer, the fade itself runs in the engine
412
+ warnTimer = setInterval(() => {
413
+ ship.flash('#ff5252', 350);
414
+ }, 700);
415
+ ```
416
+
417
+ Rocks are round (`hitboxShape: 'circle'`), drift on random velocities, spin on `angularVelocity`, and are recycled by `visible = false` rather than removed.
418
+
419
+ ## Lunar lander
420
+
421
+ 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.
422
+
423
+ ```javascript
424
+ const lander = Game.createSprite({
425
+ sheet: shipSheet,
426
+ x: W / 2, y: H * 0.2, width: SHIP, height: SHIP,
427
+ zIndex: 10,
428
+ gravity: world.gravity, // per-world, e.g. H * 0.35 for the Moon
429
+ maxSpeed: MAX_SPEED,
430
+ hitboxScale: 0.8,
431
+ solidWith: ['terrain', 'pad'],
432
+ collidesWith: ['hazard']
433
+ });
434
+
435
+ const MAX_LANDING_SPEED = H * 0.18;
436
+ const MAX_LANDING_TILT = 12; // degrees
437
+
438
+ lander.addEventListener('land', (e) => {
439
+ const speed = Math.abs(lander.velocityY);
440
+ const tilt = Math.abs(((lander.rotation % 360) + 540) % 360 - 180);
441
+ const onPad = e.group === 'pad';
442
+
443
+ if (onPad && speed <= MAX_LANDING_SPEED && tilt <= MAX_LANDING_TILT) {
444
+ succeed();
445
+ return;
446
+ }
447
+ crash();
448
+ });
449
+
450
+ function crash() {
451
+ hitStop(140); // see the hit-stop pattern
452
+ lander.flash('#ff5252', 400);
453
+ explosion.x = lander.x;
454
+ explosion.y = lander.y;
455
+ explosion.emit(30);
456
+ gameView.shake({ strength: SHIP * 0.5, duration: 450 });
457
+ crashSound.play();
458
+ }
459
+ ```
460
+
461
+ 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:
462
+
463
+ ```javascript
464
+ timer = setInterval(() => {
465
+ if (mainThrusterHeld && fuel > 0) {
466
+ fuel -= BURN_RATE;
467
+ lander.thrust = THRUST;
468
+ exhaust.emitting = true;
469
+ } else {
470
+ lander.thrust = 0;
471
+ exhaust.emitting = false;
472
+ }
473
+ fuelLabel.text = Math.max(0, Math.round(fuel)) + '%';
474
+ }, 80);
475
+ ```
476
+
477
+ 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.
478
+
479
+ ## Endless runner
480
+
481
+ 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.
482
+
483
+ ```javascript
484
+ const player = Game.createSprite({
485
+ sheet: skaterSheet,
486
+ x: W * 0.24,
487
+ y: groundTop - PLAYER / 2,
488
+ width: PLAYER, height: PLAYER,
489
+ zIndex: 10,
490
+ gravity: GRAVITY,
491
+ hitboxScale: 0.9,
492
+ solidWith: ['ground', 'platform'],
493
+ collidesWith: ['wall', 'pit'],
494
+ animations: { roll: { frames: [0, 1], fps: 6, loop: true } }
495
+ });
496
+
497
+ function jump() {
498
+ if (!over && player.onGround) {
499
+ jumpSound.play();
500
+ dust.emitting = false; // no dust while airborne
501
+ player.velocityY = -JUMP;
502
+ player.stop();
503
+ player.frame = 2; // tuck pose
504
+ }
505
+ }
506
+
507
+ player.addEventListener('land', () => {
508
+ if (!over) {
509
+ player.play('roll');
510
+ dust.emitting = true;
511
+ }
512
+ });
513
+ ```
514
+
515
+ Spawning is a self-rescheduling timeout, so the gap can vary and a long obstacle can push the next spawn further out:
516
+
517
+ ```javascript
518
+ function scheduleSpawn() {
519
+ spawnTimer = setTimeout(() => {
520
+ spawnObstacle();
521
+ scheduleSpawn();
522
+ }, 1100 + Math.random() * 800 + extraDelay);
523
+ }
524
+ ```
525
+
526
+ 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.
527
+
528
+ Crash sequence: stop the timers, zero gravity, freeze every scroller, switch to the wipeout frame, fire a spark burst, shake the camera.
529
+
530
+ ```javascript
531
+ function crash(group) {
532
+ over = true;
533
+ music.stop();
534
+ crashSound.play();
535
+ clearTimers();
536
+ player.gravity = 0;
537
+ player.velocityY = 0;
538
+ player.stop();
539
+ player.frame = 3;
540
+ layers.forEach((layer) => layer.setSpeed(0));
541
+ setObstacleSpeed(0);
542
+ dust.emitting = false;
543
+ sparks.x = player.x;
544
+ sparks.y = player.y;
545
+ sparks.emit(26);
546
+ gameView.shake({ strength: PLAYER * 0.12, duration: 450 });
547
+ }
548
+ ```
549
+
550
+ ## Rhythm game
551
+
552
+ 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.
553
+
554
+ ```javascript
555
+ const pads = LANES.map((x, lane) => {
556
+ const pad = Game.createSprite({ sheet: padSheet, x: x, y: PAD_Y, width: PAD, height: PAD, zIndex: 5 });
557
+ pad.addEventListener('press', () => hitLane(lane, pad));
558
+ gameView.add(pad);
559
+ return pad;
560
+ });
561
+
562
+ // gems must not swallow pad presses
563
+ const sprite = Game.createSprite({
564
+ sheet: noteSheet, width: NOTE, height: NOTE, zIndex: 10,
565
+ visible: false,
566
+ touchEnabled: false,
567
+ collidesWith: ['misszone']
568
+ });
569
+
570
+ function hitLane(lane, pad) {
571
+ pad.frame = 1;
572
+ setTimeout(() => {
573
+ pad.frame = 0;
574
+ }, 120);
575
+
576
+ let best = null;
577
+ let bestDistance = Infinity;
578
+ notes.forEach((note) => {
579
+ if (note.active && note.lane === lane) {
580
+ const distance = Math.abs(note.sprite.y - PAD_Y); // live native read
581
+ if (distance < bestDistance) {
582
+ bestDistance = distance;
583
+ best = note;
584
+ }
585
+ }
586
+ });
587
+
588
+ if (best !== null && bestDistance <= HIT_RANGE) {
589
+ recycle(best);
590
+ goodSound.play();
591
+ burst.x = LANES[lane];
592
+ burst.y = PAD_Y;
593
+ burst.tint = LANE_TINTS[lane];
594
+ burst.emit(18);
595
+ score += (bestDistance <= PERFECT) ? 3 : 1;
596
+ } else {
597
+ badSound.play();
598
+ }
599
+ }
600
+ ```
601
+
602
+ 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.
603
+
604
+ ## Ball sports / breakout physics
605
+
606
+ `restitution` on a circle-hitbox ball gives real reflection off walls and obstacles. Player contact is handled in JS so you control the feel.
607
+
608
+ ```javascript
609
+ const ball = Game.createSprite({
610
+ sheet: ballSheet,
611
+ x: W / 2, y: H * 0.3, width: BALL_SIZE, height: BALL_SIZE,
612
+ zIndex: 12,
613
+ hitboxScale: 0.9,
614
+ hitboxShape: 'circle', // bounces off the net's corner along the normal
615
+ restitution: 0.75,
616
+ solidWith: ['wall', 'net'], // blocked and bounced by these
617
+ collidesWith: ['player', 'floor'] // only reported for these
618
+ });
619
+
620
+ ball.addEventListener('collision', (e) => {
621
+ if (e.group === 'player') {
622
+ // reflect away from the blob's center, biased upward
623
+ const blob = e.other;
624
+ const dx = ball.x - blob.x;
625
+ const dy = ball.y - blob.y;
626
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
627
+ ball.velocityX = (dx / len) * HIT_SPEED;
628
+ ball.velocityY = Math.min((dy / len) * HIT_SPEED, -HIT_SPEED * 0.45);
629
+ } else if (e.group === 'floor') {
630
+ roundOver(ball.x > W / 2);
631
+ }
632
+ });
633
+ ```
634
+
635
+ 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.
636
+
637
+ A `restitution: 1` ball on an invisible floor bounces forever with no JS at all — and since 2026-08-27 the bounce can live on the *floor* instead, which is what makes one springy platform in an otherwise dead level a one-property change. Round obstacles, bumpers that knock each other around and a table that brings everything to rest are all in [Shaped solids](patterns.md#shaped-solids-ramps-round-posts-and-bodies-that-push-back--slopesjs-circlesjs-pooljs-drumjs).
638
+
639
+ **Opponent AI** on an 80 ms timer, reading a handful of live properties per tick:
640
+
641
+ ```javascript
642
+ timer = setInterval(() => {
643
+ const onCpuSide = ball.x > W / 2;
644
+ const target = (running && onCpuSide) ? ball.x + PLAYER_SIZE * 0.2 : cpuHomeX;
645
+ const dx = target - cpu.x;
646
+ cpu.velocityX = (Math.abs(dx) > PLAYER_SIZE * 0.15) ? (dx > 0 ? RUN_SPEED : -RUN_SPEED) : 0;
647
+ if (running && onCpuSide && cpu.onGround
648
+ && Math.abs(ball.x - cpu.x) < PLAYER_SIZE * 1.2
649
+ && ball.y < cpu.y && ball.velocityY > 0) {
650
+ cpu.velocityY = -JUMP;
651
+ }
652
+ }, 80);
653
+ ```