kaypy 0.1.0__tar.gz

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.
Files changed (107) hide show
  1. kaypy-0.1.0/DEVELOPING.md +333 -0
  2. kaypy-0.1.0/GUIDE.md +1524 -0
  3. kaypy-0.1.0/LICENSE +33 -0
  4. kaypy-0.1.0/MANIFEST.in +13 -0
  5. kaypy-0.1.0/PKG-INFO +437 -0
  6. kaypy-0.1.0/README.md +406 -0
  7. kaypy-0.1.0/examples/CREDITS.md +134 -0
  8. kaypy-0.1.0/examples/dungeon/elf_m.png +0 -0
  9. kaypy-0.1.0/examples/dungeon.png +0 -0
  10. kaypy-0.1.0/examples/gen_assets.py +60 -0
  11. kaypy-0.1.0/examples/images/bean.png +0 -0
  12. kaypy-0.1.0/examples/images/coin.png +0 -0
  13. kaypy-0.1.0/examples/images/dino_0.png +0 -0
  14. kaypy-0.1.0/examples/images/dino_1.png +0 -0
  15. kaypy-0.1.0/examples/images/dino_2.png +0 -0
  16. kaypy-0.1.0/examples/images/dino_3.png +0 -0
  17. kaypy-0.1.0/examples/images/dino_4.png +0 -0
  18. kaypy-0.1.0/examples/images/dino_5.png +0 -0
  19. kaypy-0.1.0/examples/images/dino_6.png +0 -0
  20. kaypy-0.1.0/examples/images/dino_7.png +0 -0
  21. kaypy-0.1.0/examples/images/dino_8.png +0 -0
  22. kaypy-0.1.0/examples/images/ghosty.png +0 -0
  23. kaypy-0.1.0/examples/images/grass.png +0 -0
  24. kaypy-0.1.0/examples/images/portal.png +0 -0
  25. kaypy-0.1.0/examples/images/spike.png +0 -0
  26. kaypy-0.1.0/examples/images/steel.png +0 -0
  27. kaypy-0.1.0/examples/lesson10_levels.py +43 -0
  28. kaypy-0.1.0/examples/lesson11_camera.py +58 -0
  29. kaypy-0.1.0/examples/lesson12_sprite_atlas.py +100 -0
  30. kaypy-0.1.0/examples/lesson13_state_ai.py +61 -0
  31. kaypy-0.1.0/examples/lesson1_adding_object.py +12 -0
  32. kaypy-0.1.0/examples/lesson2_player_movement.py +25 -0
  33. kaypy-0.1.0/examples/lesson3_collision.py +48 -0
  34. kaypy-0.1.0/examples/lesson5_gravity.py +39 -0
  35. kaypy-0.1.0/examples/lesson6_sprite_animation.py +67 -0
  36. kaypy-0.1.0/examples/lesson7_scenes.py +91 -0
  37. kaypy-0.1.0/examples/lesson8_audio_buttons.py +38 -0
  38. kaypy-0.1.0/examples/lesson9_timer_loop.py +17 -0
  39. kaypy-0.1.0/examples/sounds/background.wav +0 -0
  40. kaypy-0.1.0/examples/sounds/ding.wav +0 -0
  41. kaypy-0.1.0/examples/sounds/screech.wav +0 -0
  42. kaypy-0.1.0/kaplay/__init__.py +192 -0
  43. kaypy-0.1.0/kaplay/assets.py +127 -0
  44. kaypy-0.1.0/kaplay/callutil.py +54 -0
  45. kaypy-0.1.0/kaplay/camera.py +44 -0
  46. kaypy-0.1.0/kaplay/cli.py +86 -0
  47. kaypy-0.1.0/kaplay/comps/__init__.py +0 -0
  48. kaypy-0.1.0/kaplay/comps/area.py +24 -0
  49. kaypy-0.1.0/kaplay/comps/body.py +50 -0
  50. kaypy-0.1.0/kaplay/comps/move.py +67 -0
  51. kaypy-0.1.0/kaplay/comps/pos.py +52 -0
  52. kaypy-0.1.0/kaplay/comps/shapes.py +96 -0
  53. kaypy-0.1.0/kaplay/comps/sprite.py +86 -0
  54. kaypy-0.1.0/kaplay/comps/state.py +66 -0
  55. kaypy-0.1.0/kaplay/comps/transform.py +104 -0
  56. kaypy-0.1.0/kaplay/debugmod.py +10 -0
  57. kaypy-0.1.0/kaplay/engine.py +311 -0
  58. kaypy-0.1.0/kaplay/events.py +128 -0
  59. kaypy-0.1.0/kaplay/gameobj.py +192 -0
  60. kaypy-0.1.0/kaplay/geometry.py +53 -0
  61. kaypy-0.1.0/kaplay/kaboom.py +42 -0
  62. kaypy-0.1.0/kaplay/level.py +44 -0
  63. kaypy-0.1.0/kaplay/physics.py +208 -0
  64. kaypy-0.1.0/kaplay/render.py +119 -0
  65. kaypy-0.1.0/kaplay/starter/CREDITS.md +134 -0
  66. kaypy-0.1.0/kaplay/starter/dungeon/elf_m.png +0 -0
  67. kaypy-0.1.0/kaplay/starter/dungeon.png +0 -0
  68. kaypy-0.1.0/kaplay/starter/game.py +81 -0
  69. kaypy-0.1.0/kaplay/starter/images/bean.png +0 -0
  70. kaypy-0.1.0/kaplay/starter/images/coin.png +0 -0
  71. kaypy-0.1.0/kaplay/starter/images/dino_0.png +0 -0
  72. kaypy-0.1.0/kaplay/starter/images/dino_1.png +0 -0
  73. kaypy-0.1.0/kaplay/starter/images/dino_2.png +0 -0
  74. kaypy-0.1.0/kaplay/starter/images/dino_3.png +0 -0
  75. kaypy-0.1.0/kaplay/starter/images/dino_4.png +0 -0
  76. kaypy-0.1.0/kaplay/starter/images/dino_5.png +0 -0
  77. kaypy-0.1.0/kaplay/starter/images/dino_6.png +0 -0
  78. kaypy-0.1.0/kaplay/starter/images/dino_7.png +0 -0
  79. kaypy-0.1.0/kaplay/starter/images/dino_8.png +0 -0
  80. kaypy-0.1.0/kaplay/starter/images/ghosty.png +0 -0
  81. kaypy-0.1.0/kaplay/starter/images/grass.png +0 -0
  82. kaypy-0.1.0/kaplay/starter/images/portal.png +0 -0
  83. kaypy-0.1.0/kaplay/starter/images/spike.png +0 -0
  84. kaypy-0.1.0/kaplay/starter/images/steel.png +0 -0
  85. kaypy-0.1.0/kaplay/starter/sounds/background.wav +0 -0
  86. kaypy-0.1.0/kaplay/starter/sounds/ding.wav +0 -0
  87. kaypy-0.1.0/kaplay/starter/sounds/screech.wav +0 -0
  88. kaypy-0.1.0/kaplay/timers.py +57 -0
  89. kaypy-0.1.0/kaplay/vec2.py +114 -0
  90. kaypy-0.1.0/kaplay/webbuild.py +361 -0
  91. kaypy-0.1.0/kaypy.egg-info/PKG-INFO +437 -0
  92. kaypy-0.1.0/kaypy.egg-info/SOURCES.txt +105 -0
  93. kaypy-0.1.0/kaypy.egg-info/dependency_links.txt +1 -0
  94. kaypy-0.1.0/kaypy.egg-info/entry_points.txt +2 -0
  95. kaypy-0.1.0/kaypy.egg-info/requires.txt +5 -0
  96. kaypy-0.1.0/kaypy.egg-info/top_level.txt +1 -0
  97. kaypy-0.1.0/pyproject.toml +60 -0
  98. kaypy-0.1.0/setup.cfg +4 -0
  99. kaypy-0.1.0/tests/test_core.py +86 -0
  100. kaypy-0.1.0/tests/test_decorators.py +174 -0
  101. kaypy-0.1.0/tests/test_guide_code.py +98 -0
  102. kaypy-0.1.0/tests/test_lazy_key_map.py +49 -0
  103. kaypy-0.1.0/tests/test_packaging.py +108 -0
  104. kaypy-0.1.0/tests/test_physics.py +77 -0
  105. kaypy-0.1.0/tests/test_tile_floor_landing.py +110 -0
  106. kaypy-0.1.0/tests/test_web_platform_guard.py +76 -0
  107. kaypy-0.1.0/webbuild.py +19 -0
@@ -0,0 +1,333 @@
1
+ # Developer notes
2
+
3
+ How kaypy is put together, what's been verified and how, where it's still
4
+ thin, and the bugs that were expensive enough to be worth writing down.
5
+
6
+ For how to *use* the engine, see [README.md](README.md).
7
+
8
+ ---
9
+
10
+ ## Architecture
11
+
12
+ - **`kaplay/gameobj.py`** — `GameObj` merges each component's attributes onto
13
+ itself via `__getattr__`/`__setattr__` delegation, the same way KAPLAY's JS
14
+ does `Object.assign`. Calling `.jump()` on an object without `body()` raises
15
+ exactly the `AttributeError` the guide's troubleshooting section describes.
16
+ - **`kaplay/comps/`** — one file per component family. Components are data
17
+ holders with optional `add`/`update`/`destroy` hooks; nothing draws itself.
18
+ - **`kaplay/geometry.py`** — the one place that turns `pos()` + `anchor()` +
19
+ `scale()` + parent offsets into a world-space rect. Both collision
20
+ (`area.py`) and rendering (`render.py`) call into it, so "does this touch
21
+ that" and "where does this draw" can never disagree.
22
+ - **`kaplay/physics.py`** — gravity and integration, then AABB overlap and
23
+ resolution. Physical pushback only happens between two objects that **both**
24
+ have `body()`; a coin or enemy with only `area()` is a pass-through trigger,
25
+ per Lesson 3. Collision rects are `pygame.FRect` (float precision) rather
26
+ than `pygame.Rect` — a resting object's sub-pixel gravity drift would
27
+ otherwise round away and make `isGrounded()` flicker.
28
+ - **`kaplay/engine.py`** — the `Engine` singleton: window, clock, asset/event/
29
+ timer managers, camera, and the frame loop. The loop is `asyncio`-shaped so
30
+ the same body drives both the native run and the pygbag web build.
31
+ - **`kaplay/render.py`** — draws sprite/rect/circle/text in `z()` order,
32
+ through the camera unless `fixed()`.
33
+
34
+ ### How the loop starts
35
+
36
+ There is no `run()` call anywhere in the KAPLAY guide, so there is none here
37
+ either. Natively, `kaplay()` registers the frame loop with `atexit`, so it
38
+ starts the instant your script's top level finishes. `run()` is still exposed
39
+ for tooling that wants to be explicit, and is a harmless no-op the second time.
40
+
41
+ The web build can't use that trick (see *pygbag incompatibilities* below), so
42
+ `webbuild.py` generates a `main.py` that imports your script as a module —
43
+ which runs its whole top level exactly once, same as atexit firing after it —
44
+ and then awaits `run_async()` directly. Your game script is byte-for-byte the
45
+ same on both targets.
46
+
47
+ ---
48
+
49
+ ## Tests
50
+
51
+ Standalone scripts, run directly, using plain `assert`. They set
52
+ `SDL_VIDEODRIVER=dummy` themselves so they run headless.
53
+
54
+ ```bash
55
+ python tests/test_core.py # collision edge-triggering, child objects, scenes, state()
56
+ python tests/test_physics.py # gravity, grounding, mass-based pushing
57
+ python tests/test_tile_floor_landing.py # landing on a tiled floor, at uneven frame rates
58
+ python tests/test_web_platform_guard.py # kaplay() never touches pygbag's broken atexit
59
+ python tests/test_lazy_key_map.py # pygame.K_* is never read before pygame.init()
60
+ python tests/test_decorators.py # every event works as a callback AND as a decorator
61
+ python tests/test_guide_code.py # every program printed in GUIDE.md actually runs
62
+ ```
63
+
64
+ `test_web_platform_guard`, `test_lazy_key_map` and `test_tile_floor_landing`
65
+ all exist because of bugs that only ever showed up in a browser.
66
+
67
+ `KAYPY_TEST_MAX_FRAMES=n` makes any game script run `n` frames and exit, which
68
+ is how the lessons get smoke-tested. `KAYPY_SCREENSHOT_AT=n` plus
69
+ `KAYPY_SCREENSHOT_PATH=out.png` dumps a frame to disk, which is how they get
70
+ checked by eye.
71
+
72
+ **No pytest wiring yet** — worth converting once the API settles.
73
+
74
+ > A caution learned the hard way: "the lesson script exits 0" is not the same
75
+ > as "the lesson works." Lesson 10's player fell through the floor for a long
76
+ > time while the script exited 0 every single run. Render a frame and look at
77
+ > it.
78
+
79
+ `test_guide_code.py` applies the weaker half of that lesson to GUIDE.md: it pulls
80
+ every complete program out of the guide and runs it, so a sample can't rot into
81
+ something that doesn't start. Knowing it is *correct* still means rendering it
82
+ and looking.
83
+
84
+ ---
85
+
86
+ ## Physics: three real bugs
87
+
88
+ All three were found by watching Lesson 10 actually run, not by reading code.
89
+ `tests/test_tile_floor_landing.py` pins down all three.
90
+
91
+ **1. Push direction was read off `pos()`, which is anchor-dependent.**
92
+ `_resolve` decided which object was on top by comparing the two objects' raw
93
+ `pos` values. But `pos` means different things to different objects: a plain
94
+ tile's `pos.y` is its *top edge*, while `anchor("bot")` — what a platformer
95
+ character wants, so it can be placed by its feet — makes `pos.y` the *bottom
96
+ edge*. A bean standing on a tile therefore had the larger `pos.y` of the two,
97
+ so the engine concluded the tile was above the bean and pushed the bean
98
+ **downward**, deeper in, every frame, until it was through the floor and
99
+ falling forever. Any object combining `anchor("bot")` with `body()` hit this,
100
+ on every platform, at every frame rate. Now read off the collision rects, which
101
+ `get_world_rect()` has already folded the anchor into.
102
+
103
+ **2. The resolution axis came from whichever overlap was smaller.**
104
+ That's the usual shortcut and it fails on the case a platformer hits
105
+ constantly: a floor built of separate tiles. A player landing on the seam
106
+ between two tiles overlaps each by only half its width; sink deeper than that
107
+ in a single frame and both tiles push *sideways*, in opposite directions, so
108
+ they cancel and nothing holds the player up. Now the axis comes from where the
109
+ two were before the step — if they already lined up horizontally and have only
110
+ now begun overlapping vertically, it's a landing, however deep it got.
111
+
112
+ **3. Fast bodies tunneled through walls.**
113
+ Movement and collision now run in as many substeps as needed to keep any body
114
+ under `MAX_STEP_PX` (16px) of travel per step, up to `MAX_SUBSTEPS` (4).
115
+ Normal frames need exactly one substep, so this costs nothing until something
116
+ is genuinely moving fast. Frame times aren't ours to control — a backgrounded
117
+ tab, a slow machine, a breakpoint — so the fix has to be in how far one step
118
+ may move things.
119
+
120
+ Still not stress-tested: simultaneous multi-body stacking.
121
+
122
+ ---
123
+
124
+ ## Events: two forms, one registration path
125
+
126
+ Every event accepts a function, or acts as a decorator over one. Both go
127
+ through `callutil.register_or_decorate(fn, register)`: given a function it
128
+ registers immediately, given `None` it hands back a decorator that registers
129
+ and then returns the function unchanged — so the decorated name stays bound to
130
+ the function rather than to `None` or a wrapper.
131
+
132
+ The callback form is Kaplay's and is not going anywhere; it's what makes
133
+ Kaplay's docs translate line for line. The decorator form exists because
134
+ Python's lambdas hold a single *expression*, and the workarounds teach things
135
+ you would not choose to teach:
136
+
137
+ ```python
138
+ # what the lesson had to write
139
+ onKeyPress("space", lambda: player.jump(1000) if player.isGrounded() else None)
140
+ player.onCollide("danger", lambda d: setattr(player, "pos", level.tile2Pos(2, 5)))
141
+ ```
142
+
143
+ The `else None` is inert — it exists only because a conditional expression
144
+ demands an else. `setattr` appears only because a lambda cannot assign. Neither
145
+ is game logic.
146
+
147
+ Adding it cost one helper and an `fn=None` default on each event. Bare
148
+ decorators (`@onUpdate`, `@player.onGround`) already worked before any of this,
149
+ because the registration functions returned `fn`.
150
+
151
+ `tests/test_decorators.py` checks each event twice, once per form, asserting
152
+ the same registration and the same firing — plus that decorated handlers stay
153
+ callable and keep their `__name__`.
154
+
155
+ **Naming stays camelCase.** `onKeyPress`, not `on_key_press`. snake_case would
156
+ be more idiomatic Python in isolation, but the entire premise of this project
157
+ is that Kaplay's documentation and examples apply as written; two spellings for
158
+ every event would cost that and hand students two names for one idea. The
159
+ decorator gain doesn't require giving it up.
160
+
161
+ ---
162
+
163
+ ## Web export (pygbag)
164
+
165
+ `webbuild.py` assembles a folder, converts audio, runs pygbag, and serves the
166
+ result. Four separate incompatibilities had to be fixed before a page would run
167
+ at all. Each one is cheap to re-break, so they're documented here with the
168
+ evidence that found them.
169
+
170
+ ### 1. pygbag's `atexit` replacement is broken
171
+
172
+ pygbag replaces the stdlib `atexit` module outright
173
+ (`pygbag/support/cross/aio/atexit.py`), and that replacement has a genuine bug:
174
+ its `register()` closes over an undefined `arg` instead of `args`, so calling
175
+ it raises `NameError` immediately. kaypy's atexit-based "no `run()` call" trick
176
+ would have crashed instantly under pygbag.
177
+
178
+ Fixed by detecting `sys.platform == "emscripten"` (the real marker pygbag and
179
+ CPython both use) and skipping atexit registration there entirely. The web
180
+ build's generated `main.py` calls `run_async()` explicitly instead. Pinned by
181
+ `tests/test_web_platform_guard.py`, which recreates pygbag's actual broken
182
+ module and confirms `kaplay()` never touches it once that platform is detected.
183
+
184
+ ### 2. pygame was a stub, because nothing declared it
185
+
186
+ pygbag's in-browser bootstrap only links in the *real*, compiled pygame WASM
187
+ extension when it finds a PEP 723 dependency block naming `pygame.base` in the
188
+ entry script it runs:
189
+
190
+ ```python
191
+ # /// script
192
+ # dependencies = [
193
+ # "pygame.base",
194
+ # ]
195
+ # ///
196
+ ```
197
+
198
+ Without it, `import pygame` still silently succeeds, but you get a stub with
199
+ the constants (`pygame.K_LEFT` and friends) and **none of the real functions**.
200
+
201
+ Ordinary pygbag games never hit this, because they write `import pygame` at the
202
+ top of their own `main.py`. A kaypy game never writes it at all — `kaplay` does
203
+ that internally, several imports removed from anything pygbag's scanner reads —
204
+ so that block could only ever come from the generated `main.py`, and now does.
205
+
206
+ Symptom: the page loaded, the click did nothing, and the crash was
207
+ `AttributeError: module 'pygame' has no attribute 'init'` at the exact line
208
+ `Engine.__init__` calls `pygame.init()` — with pygbag's own boot log printing
209
+ `"# 696: no pep 723 block found"` immediately before it.
210
+
211
+ ### 3. `pygame.K_*` read at import time
212
+
213
+ `kaplay/events.py` used to build its key-name lookup table at module level.
214
+ Native pygame-ce doesn't care — those constants exist the moment you
215
+ `import pygame`, before `pygame.init()`. Under pygbag's WASM pygame they don't
216
+ exist until after `init()`, and `import kaplay` reaches `events.py` (via
217
+ `engine.py`'s `from .events import EventManager`) well before a script's own
218
+ `kaplay()` call runs `pygame.init()`. Every web export died with
219
+ `AttributeError: module 'pygame' has no attribute 'K_LEFT'` before a single
220
+ line of the game ran.
221
+
222
+ The table is now built lazily on first use — `onKeyDown`/`onKeyPress`/
223
+ `onKeyRelease` or the frame loop, all of which only happen after `kaplay()`.
224
+ Pinned by `tests/test_lazy_key_map.py`.
225
+
226
+ ### 4. A reboot loop on the very first click
227
+
228
+ pygbag's default (`--can_close 0`) registers a `beforeunload` handler that
229
+ calls `confirm("Are you sure you want to navigate away from this page ?")`.
230
+ Modern Chrome and Safari flatly refuse to run a synchronous `confirm()` during
231
+ `beforeunload` — and rather than the site just failing to prompt, that sends
232
+ pygbag's runtime into a loop: fetch the game archive, wait for the click it
233
+ demands to unlock audio, hit the blocked `confirm()`, reboot from scratch,
234
+ forever, before the first frame ever runs. From outside it looks exactly like
235
+ "I clicked, saw a border, then nothing."
236
+
237
+ `webbuild.py` always passes `--can_close 1`, which skips the handler. Confirmed
238
+ against a real built site by driving a browser through the click and watching
239
+ the console log the fetch → focus → blocked-confirm → reboot cycle fire
240
+ repeatedly with the flag unset, then stop once it was set.
241
+
242
+ **This is why `webbuild.py` must be the thing that runs pygbag.** The flags
243
+ above only get added when it's the one invoking it. Running
244
+ `python -m pygbag --build <dir>` yourself afterwards skips them and puts you
245
+ straight back on the broken defaults.
246
+
247
+ ### Audio: `.wav` → `.ogg`
248
+
249
+ Browsers' WASM audio can't reliably decode most `.wav` files, and pygbag's
250
+ build step refuses to package one at all ("has a common unsupported format. Use
251
+ OGG format instead."). `webbuild.py` converts every `.wav` it copies into a
252
+ same-named `.ogg`, and `AssetManager.loadSound` prefers that sibling `.ogg`
253
+ automatically whenever it detects the browser platform — so the game script
254
+ still just says `loadSound("ding", "sounds/ding.wav")` with no if-web branch.
255
+
256
+ The conversion uses `ffmpeg` if it's on PATH, otherwise the `imageio-ffmpeg`
257
+ package (pulled in by `pip install -e ".[web]"`), whose wheel bundles an actual
258
+ static ffmpeg binary per platform — so no Homebrew or system package manager is
259
+ ever required. Only if neither is available does it fall back to
260
+ `--disable-sound-format-error` and warn.
261
+
262
+ > A `soundfile`-only pure-Python fallback was tried first and rejected: its
263
+ > bundled libsndfile OGG/Vorbis encoder **segfaulted silently** on a real
264
+ > 22.05kHz mono `.wav`, writing a valid-looking but completely empty `.ogg`.
265
+ > Every conversion now runs in a subprocess so an encoder crash can't take the
266
+ > build down with it.
267
+
268
+ ### Frame pacing on the web
269
+
270
+ Natively, `clock.tick(60)` sleeps to pace the loop. In the browser that sleep
271
+ can't yield, so it just burns the main thread and makes frame times *less*
272
+ even — measured in a real build, `dt` alternated between 16ms and the engine's
273
+ 50ms ceiling. On web the loop now only measures, and lets the browser's
274
+ animation frame do the pacing.
275
+
276
+ ### Debugging a running web build
277
+
278
+ Worth knowing, because it turns a rebuild-and-squint loop into a live one:
279
+
280
+ - pygbag prints Python tracebacks to its **own in-page terminal**, not the
281
+ browser console. `read_page` on the accessibility tree shows them; the
282
+ devtools console will not.
283
+ - `window.python.PyRun_SimpleString("...")` runs arbitrary Python **inside the
284
+ live game**. That's how the falling-bean bug was diagnosed: reset the player,
285
+ record its rect each frame from an injected `onUpdate`, and read the numbers
286
+ back out. The physics fix was verified the same way — monkeypatched into the
287
+ running build and confirmed landing before anything was rebuilt.
288
+
289
+ ---
290
+
291
+ ## Why pygame-ce is pinned, not vendored
292
+
293
+ It's pinned to an exact version (`pygame-ce==2.5.8`), not vendored:
294
+
295
+ - **It's a compiled C extension**, shipped as separate binary wheels per OS and
296
+ CPU. There's no single file to vendor — one wheel's contents only work on the
297
+ platform it was built for, and shipping all of them is what PyPI already does
298
+ for you, at its hosting cost rather than this repo's.
299
+ - **Vendoring the source** means bundling SDL2's source and a build toolchain,
300
+ and compiling on every machine it installs to. That trades "pip might break
301
+ someday" for "the install needs a C compiler and SDL2 headers today" — a
302
+ bigger, earlier cost for a smaller, later risk.
303
+ - **A pin already buys the thing vendoring was for.** `pygame-ce==2.5.8`
304
+ resolves to the same wheel every time, forever; PyPI doesn't let a published
305
+ version change underneath you. Bumping it is a deliberate one-line change you
306
+ test against, not something that happens to you.
307
+
308
+ If the real worry is installing with no internet at all, that's a different and
309
+ solvable problem (a local wheel cache, or an offline `pip download` bundle) and
310
+ worth deciding on separately.
311
+
312
+ Note that the web build runs a *different* pygame — pygbag supplies
313
+ `pygame-ce 2.5.7` compiled for WASM, and you don't get to choose it. Anything
314
+ that depends on 2.5.8-specific behaviour will diverge in the browser.
315
+
316
+ ---
317
+
318
+ ## Known limitations
319
+
320
+ - **No tile-based pathfinding.** `tile(isObstacle=True)` is stored (Lesson 12)
321
+ but nothing reads it to route around obstacles the way KAPLAY's own
322
+ pathfinding helpers do.
323
+ - **Multi-body stacking** is untested — see *Physics* above.
324
+ - **No pytest suite** — `tests/` are standalone scripts.
325
+ - **Audio on a headless machine** degrades to silent no-ops rather than
326
+ crashing (`pygame.mixer.init()` and `loadSound()` are both wrapped), which is
327
+ useful for CI. On a real desktop, `examples/lesson8_audio_buttons.py` plays
328
+ real sound.
329
+ - **The native build step for the web** has only ever been exercised up to the
330
+ network wall in this project's sandbox, which blocks pygbag's CDN
331
+ (`pygame-web.github.io`). Folder assembly and file gathering are verified
332
+ there; the actual WASM packaging is verified on a normal machine. The browser
333
+ side has been driven end-to-end against a real local server.