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