rgame 0.2.0 → 0.3.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.
Files changed (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +3 -5
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +167 -12
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
data/docs/api/toolbox.md CHANGED
@@ -1,61 +1,170 @@
1
- # Utilities
1
+ # Toolbox
2
2
 
3
- Engine classes a **game author reaches for directly** that don't belong to the scene
4
- graph, components, signals, or systems chapters pooling, localization, audio facts,
5
- flat grids, the camera, collision boxes. All are pure Ruby (none `require "gosu"`), so
6
- they stay headless-testable.
3
+ This page covers engine classes **a game author uses directly** that belong to no
4
+ other chapter: pooling, the text a node draws, audio facts, the camera, collision boxes.
5
+ All are pure Ruby, so they stay testable headless. One section is a recipe, not a
6
+ class: [making a character that collides](#making-a-character-that-collides). No
7
+ single class answers that question.
7
8
 
8
- For the low-level classes that sit *behind* components and are rarely constructed by
9
- hand (collision maths, the spatial index, animation playback), see
10
- [Internal building blocks](internals.md).
9
+ [Internal building blocks](internals.md) covers the low-level classes *behind*
10
+ components, which you rarely construct by hand: collision maths, the spatial
11
+ index, animation playback.
11
12
 
12
- ## `Matrix` — a flat fixed-size grid
13
+ ## Grids
13
14
 
14
- `RGame::Engine::Matrix` is a fixed-size grid addressed as `[x, y]` but backed by a
15
- **single flat (row-major) array**, not an array-of-arrays. One contiguous
16
- allocation is cheaper than nested arrays, and it is the shape a C-level buffer
17
- takes which is not hypothetical: its 3-D sibling made exactly that move.
15
+ **The engine has no grid class of its own.** A fixed-size grid is a
16
+ [`RGame::Util::Tensor`](values.md#rgameutiltensor): three-dimensional, backed by
17
+ one flat C array. A flat 2-D grid is a `Tensor` with a depth of 1. `TileMap`
18
+ stacks its tile layers in one `Tensor(width, height, layer_count)`. It shows the
19
+ rule that the engine layer may hold `RGame::Util` values: a grid is a value, so
20
+ the engine owns one outright.
21
+
22
+ [`NavGrid`](#navgrid--routes-over-a-tile-grid) searches over a grid's solidity; it
23
+ does not store a grid. The solidity lives in a
24
+ [`Util::SolidGrid`](values.md#rgameutilsolidgrid), one byte per cell in C. The C
25
+ search must read it, and a `Tensor`'s cells are Ruby objects.
26
+
27
+ ## `Text` — the string a node draws
28
+
29
+ **`RGame::Engine::Text` (`rgame/engine/text`) holds a translation key and keeps
30
+ the rendered String until an input changes.** The inputs are its variables and
31
+ `I18n.generation`. Build a `Text` once, off the per-frame path, and read it in
32
+ `on_draw`. A read with unchanged inputs returns the same frozen String and
33
+ allocates nothing. [Localization](localization.md) covers the tables it reads,
34
+ plurals, and the player's language.
18
35
 
19
36
  ```ruby
20
- grid = RGame::Engine::Matrix.new(width, height, initial: 0)
21
- grid[col, row] = gid
22
- grid[col, row] # row-major: index = y * width + x
23
- ```
37
+ require 'rgame'
24
38
 
25
- It does no bounds checking on the hot path (callers stay in range).
39
+ i18n = RGame::Engine::I18n
40
+ i18n.load_hash(en: { hud: { score: 'Score: %{score}', title: 'Apples' } },
41
+ de: { hud: { score: 'Punkte: %{score}', title: 'Äpfel' } })
26
42
 
27
- For three dimensions, reach for [`RGame::Util::Tensor`](values.md#rgameutiltensor) the C
28
- one. `TileMap` stacks its tile layers in a single
29
- `Tensor(width, height, layer_count)`, and that is the worked example of the rule
30
- that the engine layer may hold `RGame::Util` values: a grid is a value, so the
31
- layer above owns one outright rather than being handed it.
43
+ score = RGame::Engine::Text.new('hud.score', :score) # built once
44
+ title = RGame::Engine::Text.new('title', scope: 'hud')
32
45
 
33
- ## `CachedLabel` a display string rebuilt only on change
46
+ score.with(score: 7) # => "Score: 7"
47
+ score.with(score: 7).equal?(score.with(score: 7)) # => true — nothing changed, nothing rendered
48
+ title.to_s # => "Apples"
49
+ i18n.locale = :de
50
+ score.with(score: 7) # => "Punkte: 7" — the switch re-renders
51
+ title.to_s # => "Äpfel"
52
+ ```
34
53
 
35
- `RGame::Engine::CachedLabel` (`rgame/engine/cached_label`) holds a label string and rebuilds it only when its
36
- source value changes, so a per-frame draw shows the cached copy without interpolating (and
37
- allocating) a `String` every frame. Construct it — and its format block — outside the per-frame
38
- path (e.g. in `on_add`), then read it by value in `on_draw`:
54
+ In a node, the draw reads the `Text` with the current value, or passes a `Text`
55
+ with no names as it is:
39
56
 
40
57
  ```ruby
41
- @score_label = RGame::Engine::CachedLabel.new { |score| "Score: #{score}" } # built once
42
-
43
58
  def on_draw(renderer, _view)
44
- renderer.text(@score_label[@score], 12, 10) # cached; rebuilds only when @score changes
59
+ renderer.text(@score.with(score: @points), 12, 10)
60
+ renderer.text(@title, 12, 30)
45
61
  end
46
62
  ```
47
63
 
48
- `@score_label[value]` returns the same `String` object while `value` is unchanged. This is the
49
- sanctioned home for build-on-change interpolation, so the per-frame allocation cops
50
- (`rubocop/cop/game/`) exempt it. For a value that changes *every* frame (an FPS or allocation counter) a cached string can't
51
- help draw the digits individually from cached glyph strings instead, as `RGame::Engine::DebugOverlay` does.
64
+ **A `Text` goes where a String goes.** It answers `to_str` with the same String
65
+ as `to_s`, so `Renderer#text` and `Renderer#text_width` take one directly. A
66
+ `Text` is also `==` to a String that reads the same, from either side. A spec's
67
+ spy that recorded a `Text` therefore matches `with('Apples', 12, 30)`. Two `Text`s
68
+ compare by identity, and `hash` stays the object's own, so a `Text` is not a
69
+ String-keyed Hash key.
70
+
71
+ ```ruby
72
+ require 'rgame'
73
+
74
+ RGame::Engine::I18n.load_hash(en: { hud: { title: 'Apples' } })
75
+ title = RGame::Engine::Text.new('hud.title')
76
+
77
+ title == 'Apples' # => true
78
+ 'Apples' == title # => true
79
+ title.to_str.equal?(title.to_s) # => true — the same String, no copy
80
+ ```
81
+
82
+ ### Variables are keywords
83
+
84
+ `Text.new(key, *names, scope: nil)` gives the `Text` a `with` whose keywords are
85
+ `names`. A missing or unknown keyword raises Ruby's own `ArgumentError`, on the
86
+ first frame that reads it. Every `Text` with the same names shares one generated
87
+ `with`, whatever order the names came in. `names` returns them sorted.
88
+
89
+ A `Text` with no names is read with `to_s`, and `with` with no keywords returns
90
+ the same. On a `Text` that has names, `to_s` reads it with the values its last
91
+ `with` was given, and renders them again after a locale switch. So one node can
92
+ set the values in `update`, and whatever draws the `Text` passes it on without
93
+ knowing them: a [button's label](ui.md#labels-are-translation-keys) works this
94
+ way. Before the first `with`, `to_s` and `to_str` raise `ArgumentError`, naming
95
+ the keywords `with` needs, and a renderer handed the `Text` lets that error
96
+ through.
97
+
98
+ The values belong to the `Text`, not to whoever set them. Two nodes sharing one
99
+ `Text` with names show whatever the last `with` gave, so give each node its own. A name must be usable as a Ruby local variable:
100
+ `Text.new('x', :Name)` and `Text.new('x', :end)` raise `ArgumentError`.
101
+
102
+ A key whose translation is a plural needs `:count` among the names, and picks its
103
+ form by `count` as [`I18n.t` does](localization.md#plurals).
104
+
105
+ ### Scope
106
+
107
+ `scope: 'hud'` with key `'title'` resolves `'hud.title'`. `key` returns the key
108
+ without its scope. `scope=` changes the scope, and the next read resolves again.
109
+ A [`UI::Menu`'s `scope:`](ui.md#labels-are-translation-keys) sets the scope of
110
+ the labels its buttons build from keys.
111
+
112
+ ### When it renders again
113
+
114
+ A read renders again when any keyword differs from the last read, by `==`, or
115
+ when `I18n.generation` has moved. The generation moves on every `load`, every
116
+ switch of locale or default, every `plural_rule`, and `reset`. A `Text` compares that one Integer and
117
+ never subscribes to `I18n`, so nothing holds on to it.
118
+
119
+ A `Text` built before any table loads shows the [missing-key](localization.md#missing-keys)
120
+ answer. After a `load`, its next read shows the translation.
121
+
122
+ A translation whose placeholders differ from the declared names also goes to the
123
+ missing policy. Under `:key` the `Text` shows its key, and a callable receives
124
+ the key and the chain. Under `:raise` it raises `I18n::VariableMismatch` in
125
+ either direction: for a `%{name}` the `Text` does not declare, and for a
126
+ declared name the translation never prints. A plural whose `Text` lacks `:count`
127
+ raises it too.
128
+
129
+ ### `Text.literal` and `Text.computed`
130
+
131
+ `Text.literal(string)` shows `string` in every locale and never consults `I18n`.
132
+ `Text.computed(*names) { |**keywords| ... }` shows what its block returns. The
133
+ block runs when a keyword or `I18n.generation` changes, and never on an unchanged
134
+ read. A block that calls `I18n.t` therefore follows the language:
135
+ `examples/pathfinding` assembles its status line this way from four keys, two of
136
+ them plurals. Both answer
137
+ `with`, `to_s` and `to_str` like any `Text`, and both ignore `scope=`.
138
+
139
+ ```ruby
140
+ require 'rgame'
141
+
142
+ lives = RGame::Engine::Text.computed(:lives) { |lives:| "Lives: #{lives}" }
143
+ lives.with(lives: 3) # => "Lives: 3"
144
+ RGame::Engine::Text.literal('Ada').to_s # => "Ada"
145
+ ```
146
+
147
+ **Use a `Text` instead of working around the cop.** `Game/NoInterpolationInHotPath`
148
+ refuses `"Score: #{score}"` in a draw, and a `Text` built in `initialize` is the
149
+ answer. `examples/sound` shows it: a play counter on screen costs one render per
150
+ press and none in the frames between. A value that changes *every* frame, such
151
+ as an FPS counter, gains nothing from a cache. `RGame::Engine::DebugOverlay`
152
+ draws its digits one by one from cached single-character strings.
153
+
154
+ **Text chosen by state is a table of `Text`s**, such as
155
+ `{ true => Text.new('state.fullscreen'), false => Text.new('state.windowed') }.freeze`.
156
+ It selects a `Text` and builds nothing. A `Text` holds no reference to a game, so
157
+ it can live in a constant. A `Text.computed` made at the top level of a file
158
+ cannot: its block keeps that file's local variables alive, a `game` among them. A
159
+ value that **never changes and is not words**, such as an
160
+ id, belongs in an ivar built in `initialize`.
52
161
 
53
162
  ## `Pool` — reuse, don't allocate
54
163
 
55
- `RGame::Engine::Pool` (`rgame/engine/pool`) recycles many short-lived, homogeneous objects
56
- bullets, particles, transient enemies — so steady-state spawning allocates nothing.
57
- Acquired objects come from a free list, falling back to a factory block only when the
58
- list is empty.
164
+ **`RGame::Engine::Pool` (`rgame/engine/pool`) recycles short-lived objects of one
165
+ kind**, so steady-state spawning allocates nothing. Bullets, particles and
166
+ transient enemies are typical. `acquire` takes an object from a free list, and
167
+ calls the factory block only when the list is empty.
59
168
 
60
169
  ```ruby
61
170
  pool = RGame::Engine::Pool.new { Bullet.new } # factory builds a blank object
@@ -65,20 +174,21 @@ pool.each { |bullet| bullet.update(dt) }
65
174
  pool.reclaim_if(&:dead?) # sweep dead → free list, once per frame
66
175
  ```
67
176
 
68
- The factory builds a *blank* object; the caller re-initialises it after `acquire`
69
- (typically via a `reset` from an [`RGame::Engine::Resettable`](#resettable--mutable-only-where-a-pool-needs-it)
70
- value object). `reclaim_if` is the deferred-removal seam: it sweeps the active list
71
- once, moving every object the block marks dead onto the free list. Call it *after*
72
- iterating with `each` never mutate the active list mid-iteration. `active`, `size`,
73
- and `each` expose the live set for update/draw traversal.
177
+ The factory builds a *blank* object. The caller re-initialises it after
178
+ `acquire`, usually through a `reset` method on the pooled class.
179
+ `examples/pooling` shows this on a node. `reclaim_if` sweeps the active list once
180
+ and moves every object the block marks dead onto the free list. Call it *after*
181
+ iterating with `each`; never change the active list mid-iteration. `active`,
182
+ `size`, `empty?` and `each` expose the live set for update and draw.
74
183
 
75
184
  ## `Path` — a walkable polyline
76
185
 
77
- `RGame::Engine::Path` (`rgame/engine/path`) is an ordered polyline of waypoints an entity walks along —
78
- the "road" of a tower-defense level. Pure data: it holds the waypoints and the precomputed
79
- per-segment lengths, so a follower walking it at runtime allocates nothing. Waypoints are
80
- stored flat (`x0, y0, x1, y1, …`) in one contiguous array and read back through scalar
81
- accessors, so neither construction nor traversal leaks a pair-object per waypoint.
186
+ **`RGame::Engine::Path` (`rgame/engine/path`) is an ordered polyline** that an
187
+ entity walks along: a road, a patrol route, a track. It is pure data. It holds the
188
+ waypoints and precomputed segment lengths, so a follower allocates nothing at
189
+ runtime. It stores waypoints flat (`x0, y0, x1, y1, …`) in one array and returns
190
+ them through scalar accessors. Neither construction nor traversal creates an
191
+ object per waypoint.
82
192
 
83
193
  ```ruby
84
194
  path = RGame::Engine::Path.new([[0, 0], [100, 0], [100, 100]]) # ≥ 2 waypoints, in walk order
@@ -90,21 +200,115 @@ path.length # total length
90
200
  path.distance_to(x, y) # shortest distance from a point to the polyline
91
201
  ```
92
202
 
93
- A follower ([`Components::PathFollow`](components.md#pathfollow)) reads segments by index
94
- and interpolates itself; Path never returns a coordinate pair. `distance_to` answers "how
95
- far is this point from the road" (allocation-free scalar maths) — e.g. to mask the
96
- tower-placement cells that sit on or hug the road.
203
+ A follower ([`Components::PathFollow`](components.md#pathfollow)) reads segments
204
+ by index and interpolates itself; `Path` never returns a coordinate pair.
205
+ `distance_to` answers "how far is this point from the road" with scalar maths and
206
+ no allocation. A level can use it to keep objects off or away from the road.
207
+
208
+ ## `NavGrid` — routes over a tile grid
209
+
210
+ **`RGame::Engine::NavGrid` (`rgame/engine/nav_grid`) finds the cheapest route from
211
+ one cell to another.** The search runs in C, as a
212
+ [`Util::RouteSearch`](values.md#rgameutilroutesearch). In a tile scene you do not
213
+ build one: [`TileWorld#nav_grid`](components.md#tileworld) returns one over the
214
+ map's solid tiles.
215
+
216
+ You build a `NavGrid` in one of two ways. Passing both or neither raises
217
+ `ArgumentError`.
218
+
219
+ - **From a callable**: `NavGrid.new(width:, height:, solid:)`.
220
+ `solid.call(col, row)` returns true for a solid cell. `NavGrid` calls it once per
221
+ cell, copies the answers into its own grid, and never calls it again. A later
222
+ change behind the callable stays invisible.
223
+ - **Over a shared grid**: `NavGrid.new(grid:)`, with a
224
+ [`Util::SolidGrid`](values.md#rgameutilsolidgrid). Nothing is copied, so a cell
225
+ changed in that grid changes for the `NavGrid` at once. `TileWorld` builds its
226
+ grid this way. Use this form for solidity that will change. A
227
+ [`TileBlockers`](internals.md#tileblockers--the-tile-grid-as-a-blocker-source)
228
+ is built over the same grid, so routing and collision read one store.
229
+
230
+ ```ruby
231
+ require 'rgame'
232
+
233
+ rows = [
234
+ '........',
235
+ '###..###',
236
+ '........'
237
+ ]
238
+ grid = RGame::Engine::NavGrid.new(width: 8, height: 3,
239
+ solid: ->(col, row) { rows[row][col] == '#' })
240
+
241
+ grid.walkable?(3, 1) # => true
242
+ grid.find(0, 0, 7, 2) # => [[0, 0], [1, 0], [2, 0], [3, 0], [4, 1], [4, 2], [5, 2], [6, 2], [7, 2]]
243
+ grid.find(0, 0, 0, 1) # => nil — the goal is solid
244
+ grid.reachable?(0, 0, 7, 2) # => true
245
+ grid.region(0, 0) # => 0 — an Integer label; nil for a solid or off-grid cell
246
+ ```
247
+
248
+ - **A route is a list of cells, both ends included**, as `[[col, row], ...]`. When
249
+ start equals goal, the route has one cell. Turning a cell into a point to walk to
250
+ depends on the node's collider, which the grid knows nothing about. So `NavGrid`
251
+ never speaks pixels. [`Components::Navigator`](components.md#navigator) does that
252
+ and walks the result.
253
+ - **An unreachable goal returns `nil`.** That covers a solid or off-grid end, and a
254
+ goal in another region. `nil` is an ordinary answer, not an error.
255
+ - **Moves go 8 ways at octile cost**: 1 straight, √2 diagonal. A diagonal move
256
+ requires both orthogonal neighbours to be open, so a route never cuts the corner
257
+ of a solid cell. The search returns a cheapest route. It breaks ties
258
+ deterministically, so the same query always returns the same route.
259
+ - **`NavGrid` relabels connected regions whenever the grid has changed.** A goal
260
+ in another region is the slowest answer for a search, which must exhaust the
261
+ start's region first. Region labels turn it into a lookup. Relabelling a 120x90
262
+ map takes about 45 µs, on the first query after a change.
263
+ - **Regions and routes follow a shared grid.** Wall a region in two through the
264
+ `SolidGrid`, and the next `find` across the wall returns `nil`. Open it again and
265
+ the route goes through, with nothing rebuilt:
266
+
267
+ ```ruby
268
+ store = RGame::Util::SolidGrid.build(8, 3) { |col, row| rows[row][col] == '#' }
269
+ shared = RGame::Engine::NavGrid.new(grid: store)
270
+ store.set_solid(3, 1, true)
271
+ store.set_solid(4, 1, true)
272
+ shared.find(0, 0, 7, 2) # => nil — the gap is closed
273
+ shared.reachable?(0, 0, 7, 2) # => false
274
+ ```
275
+
276
+ - **Coordinates are Integers.** A `Float` or `nil` names no cell and raises
277
+ `TypeError`. An Integer outside the grid, however large, is treated like any
278
+ other cell outside it.
279
+ - **Search on demand, never per frame.** A search allocates its result. It keeps
280
+ its working buffers between searches, so never search one `NavGrid` from two
281
+ threads at once. A corner-to-corner route across a 60x40 town takes under
282
+ 0.1 ms. The slowest of 200 random routes on a 120x90 map takes about 1 ms.
283
+
284
+ ### What routing does not cover
285
+
286
+ - **Maps that change at runtime, in a tile scene.** A shared `SolidGrid` changes
287
+ under a `NavGrid` and a `TileBlockers` at once, as above. `TileWorld` keeps its
288
+ grid private, though, and a
289
+ [`Navigator`](components.md#navigator) already walking learns nothing of a change.
290
+ - **Replanning around moving actors.** `find` knows only the grid. It cannot treat a
291
+ cell as blocked for one query, and a `Navigator` held by another actor waits
292
+ instead of replanning.
293
+ - **Crowds.** Many navigators can share one `TileWorld#nav_grid`. Each `go_to` costs
294
+ a search plus smoothing, well under a millisecond on a 60x40 map. Nothing
295
+ coordinates the walkers.
296
+ - **Avoidance.** Nothing steers a walker around another. Of the blocker sources,
297
+ only `TileBlockers` answers `travel?`.
298
+ - **Flow fields.** A search answers one start and one goal. Nothing computes the
299
+ distance from every cell to a shared goal.
300
+ - **Weighted terrain.** Every open cell costs the same.
301
+ - **Colliders larger than a tile.** `Navigator#go_to` raises for one.
97
302
 
98
303
  ## `Timer` — paced periodic events
99
304
 
100
- `RGame::Engine::Timer` (`rgame/engine/timer`) is a repeating interval timer for periodic events that
101
- aren't driven by input a spawner emitting an enemy every N seconds, a tower's fire
102
- rate, a wave clock. It only **accumulates** time; the owner decides what each elapsed
103
- interval means. That split is deliberate: the same primitive serves both "act
104
- automatically" (consume every ready interval) and "stay loaded until conditions allow"
105
- (check `ready?`, but `consume` only when actually acting) so a tower with no target
106
- keeps its shot ready instead of wasting it. Pure and allocation-free, so it ticks on the
107
- per-frame path.
305
+ **`RGame::Engine::Timer` (`rgame/engine/timer`) accumulates time; its owner decides
306
+ what each interval means.** Use it for periodic events that no input drives: a
307
+ spawner that emits an enemy every N seconds, a turret's fire rate, a wave clock.
308
+ The split lets one class serve two patterns. "Act automatically" consumes every
309
+ ready interval. "Stay loaded until allowed" checks `ready?` but calls `consume`
310
+ only when acting, so a turret without a target keeps its shot ready. `Timer` is
311
+ pure and allocates nothing, so it runs on the per-frame path.
108
312
 
109
313
  ```ruby
110
314
  @spawn_timer = RGame::Engine::Timer.new(0.8) # built once, off the hot path
@@ -118,19 +322,20 @@ def on_update(dt)
118
322
  end
119
323
  ```
120
324
 
121
- `consume` carries the overshoot forward (rather than zeroing), so a long-running cadence
122
- doesn't drift; `reset` drops accumulated time after retuning `interval`. When a step might
123
- span several intervals, loop: `while timer.ready? do …; timer.consume end`.
325
+ `consume` carries the overshoot forward instead of zeroing it, so a long-running
326
+ cadence does not drift. `reset` drops accumulated time, for example after you
327
+ change `interval`. When one step may span several intervals, loop:
328
+ `while timer.ready? do …; timer.consume end`.
124
329
 
125
- For a node that should tick automatically, reach for
126
- [`Components::Timer`](components.md#timer) instead — it owns one of these, rides the node's
127
- update tick (so nothing can forget to drive it), and emits `on_timeout` rather than making
128
- you poll `ready?`/`consume`.
330
+ For a node that should tick on its own, use
331
+ [`Components::Timer`](components.md#timer). It owns a `Timer`, runs in the node's
332
+ update tick so nothing can forget to drive it, and emits `on_timeout` instead of
333
+ making you poll `ready?` and `consume`.
129
334
 
130
335
  ## `Camera` — follow a point, clamp to the world
131
336
 
132
- `RGame::Engine::Camera` (`rgame/engine/camera`) is the pure follow-and-clamp maths for a
133
- scrolling view. It splits into two calls, and the split is the whole design:
337
+ **`RGame::Engine::Camera` (`rgame/engine/camera`) holds the follow-and-clamp maths
338
+ for a scrolling view.** It splits the work into two calls:
134
339
 
135
340
  ```ruby
136
341
  camera = RGame::Engine::Camera.new(world_width: map.pixel_width, world_height: map.pixel_height)
@@ -139,26 +344,92 @@ camera.resolve(view_width, view_height) # at draw: the offset for *this* viewpor
139
344
  camera.x, camera.y # the resolved offset
140
345
  ```
141
346
 
142
- `center_on` records the target; `resolve` works out the offset, clamped so the view never
143
- shows past the world's edges (near a corner the target drifts off-centre instead).
144
- **The viewport size is an argument rather than state** because the same camera is drawn
145
- through viewports of different sizes a half-width one clamps differently from a
146
- full-width one, and the difference is visible near a world edge.
347
+ `center_on` records the target, and `target_x` and `target_y` read it back.
348
+ `resolve` computes the offset, clamped so the
349
+ view never shows past the world's edges. `world_width` and `world_height` are
350
+ optional and writable: a scene sets them when it loads a map. Left `nil`, the
351
+ camera is unbounded and follows its target exactly. With bounds, the target drifts
352
+ off centre near a corner.
353
+
354
+ **The viewport size is an argument, not state**, because one camera can be drawn
355
+ through viewports of different sizes. A half-width viewport clamps
356
+ differently from a full-width one, and the difference shows near a world edge.
357
+
358
+ **A camera belongs to a player**
359
+ ([`RGame::Engine::Player#camera`](input.md#players-seats-and-joining)), not to a
360
+ scene, because a scene may have any number of viewers. You never call `resolve`
361
+ yourself. `Viewports` resolves each view's camera against that view's size before
362
+ the frame is drawn. A
363
+ [`CameraFollow`](components.md#camerafollow) component on the followed node points
364
+ the camera, and a [`WorldView`](scene_graph.md#view-transforms-and-the-camera)
365
+ applies it. See `examples/scroll_map` for one camera and `examples/split_screen`
366
+ for two.
367
+
368
+ ## Making a character that collides
369
+
370
+ **A character stopped by the world *and* by other characters is four components
371
+ on a plain node.** Nothing connects them by hand:
147
372
 
148
- **A camera belongs to a player** ([`RGame::Engine::Player#camera`](input.md#players-seats-and-joining)),
149
- not to a scene: a scene may have any number of viewers. Nothing calls `resolve` by hand —
150
- the platform resolves each camera against the viewport it is about to draw. Pointing one
151
- is a [`CameraFollow`](components.md#camerafollow) component on the node being followed,
152
- and applying it is a [`WorldView`](scene_graph.md#view-transforms-and-the-camera). See
153
- `examples/15_tiled_world`.
373
+ ```ruby
374
+ require 'rgame'
375
+
376
+ node = RGame::Engine::Node2D.new(x: 240, y: 320)
377
+ node.add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
378
+ node.add_component(RGame::Engine::Components::FeetCollider.new(width: 10, height: 8, layer: :hero))
379
+ node.add_component(RGame::Engine::Components::CharacterBody.new(speed: 60, blocked_by: %i[tiles hero npc]))
380
+ node.add_component(RGame::Engine::Components::PlayerController.new)
381
+ ```
382
+
383
+ Each line does one job:
384
+
385
+ - The [sprite](components.md#animatedsprite) gives the node its dimensions, and
386
+ reads the body's intent back as a facing.
387
+ - The [`FeetCollider`](components.md#feetcollider) derives a small box at the
388
+ bottom of those dimensions. It is the node's **only** shape.
389
+ - The [`CharacterBody`](components.md#characterbody) names what that shape may not
390
+ pass through.
391
+ - The [controller](components.md#playercontroller) writes an intent each step, and
392
+ the body turns it into a move.
393
+
394
+ An NPC is the same four lines, with a
395
+ [`WanderController`](components.md#wandercontroller) and `layer: :npc`. The hero's
396
+ `blocked_by` names `:npc`, and the NPC's names `:hero`. The two stop each other
397
+ without knowing what the other is. A crowd of NPCs that all declare `:npc` works,
398
+ because a body is never stopped by its own collider.
399
+
400
+ The scene must mount what those names refer to: a
401
+ [`TileWorld`](components.md#tileworld) for `:tiles`, and a
402
+ [`CollisionWorld`](components.md#collisionworld) for the two layer names. **A name
403
+ with nothing behind it raises at attach**, naming what is missing. An actor that
404
+ silently walked through walls would look like a collision bug, with its cause in a
405
+ scene file three directories away.
406
+
407
+ Three things need no remembering:
408
+
409
+ - **Component order does not matter.** The feet box needs the size the sprite
410
+ gives the node. So the collider builds it on first read, not at construction.
411
+ - **The node has one shape, not two.** The body resolves the collider's box. The
412
+ rectangle a fence stops is the rectangle that reports contacts, and
413
+ `collider.box =` retunes both.
414
+ - **Reacting needs no second mechanism.** `body.on_blocked { |by| ... }` fires once
415
+ when something starts stopping the body: a villager, a solid tile or the world's
416
+ edge. A blocked pair ends up *touching*, not overlapping, so `on_hit` is the wrong
417
+ signal. See
418
+ [Blocking and overlapping](systems.md#blocking-and-overlapping-are-two-reports-and-a-pair-gets-one-of-them).
419
+
420
+ [Collision: two indexes, one resolver](systems.md#collision-two-indexes-one-resolver)
421
+ describes the architecture behind those four lines.
154
422
 
155
423
  ## `CollisionBox` — an actor's feet box
156
424
 
157
- `RGame::Engine::CollisionBox` (`rgame/engine/collision_box`) is a character's collision rectangle,
158
- expressed as an offset + size **relative to the sprite's top-left origin** — decoupled
159
- from the sprite size, so a 32×32 sprite can carry a small box at its feet. A
160
- [`CharacterBody`](components.md#characterbody) holds one and the collision code resolves
161
- *it* (not the sprite) against the tiles.
425
+ **`RGame::Engine::CollisionBox` (`rgame/engine/collision_box`) is a character's
426
+ collision rectangle.** It stores an offset and a size **relative to the sprite's
427
+ top-left origin**, independent of the sprite's size. A 32×32 sprite can therefore
428
+ carry a small box at its feet. A [`BoxCollider`](components.md#boxcollider) holds
429
+ one. [`FeetCollider`](components.md#feetcollider) builds this shape from the node's
430
+ dimensions, so a character rarely constructs one by hand. The collision code
431
+ resolves the box, not the sprite, against whatever the body declared: solid tiles,
432
+ other actors, the world's edge.
162
433
 
163
434
  ```ruby
164
435
  box = RGame::Engine::CollisionBox.bottom_anchored(
@@ -167,43 +438,39 @@ box = RGame::Engine::CollisionBox.bottom_anchored(
167
438
  box.aabb(x, y) # => [x + offset_x, y + offset_y, width, height]
168
439
  ```
169
440
 
170
- `bottom_anchored` is the common case (feet box); the raw constructor takes explicit
171
- `offset_x:`/`offset_y:`/`width:`/`height:` for anything else.
172
-
173
- ## `RGame::Engine::I18n` — localization
441
+ `bottom_anchored` covers the common feet box. For anything else, the constructor
442
+ takes `width:` and `height:`, plus optional `offset_x:` and `offset_y:`, both
443
+ defaulting to 0.
174
444
 
175
- `RGame::Engine::I18n` (`engine/i18n`) is minimal localization: per-locale translation tables
176
- (loaded from YAML or an inline Hash), `t(key)` lookup with `%{var}` interpolation and a
177
- fallback locale, and pluralization. It is a **global module** (like the signal
178
- dispatcher), so `t` is reachable anywhere without wiring. YAML is its only dependency.
445
+ `CollisionBox` also holds rectangle geometry, as class methods over plain numbers.
446
+ A per-frame path can call them without building a box:
179
447
 
180
448
  ```ruby
181
- RGame::Engine::I18n.load_file(:en, "locales/en.yml")
182
- RGame::Engine::I18n.load(:de, menu: { title: "Hauptmenü" }) # nested Hashes allowed
183
- RGame::Engine::I18n.default = :en # fallback when the current locale lacks a key
184
- RGame::Engine::I18n.locale = :de
185
-
186
- RGame::Engine::I18n.t("menu.title") # dotted key, resolved in :de then :en
187
- RGame::Engine::I18n.t(:greeting, name: "Ada") # => "Hello, Ada" from %{name}
188
- RGame::Engine::I18n.t(:apples, count: 3) # pluralized: { one:, other:, zero? }
449
+ RGame::Engine::CollisionBox.overlap?(x1, y1, w1, h1, x2, y2, w2, h2) # rect vs rect
450
+ RGame::Engine::CollisionBox.overlap_circle?(x, y, w, h, cx, cy, r) # rect vs circle
189
451
  ```
190
452
 
191
- Keys are symbolized on load, so YAML's string keys and inline symbol keys look the same
192
- to `t`. `t` resolves a dotted key in the current locale, then the fallback, then returns
193
- the key itself as a last resort. Pass `count:` to pluralize the key's value is then a
194
- `{ one:, other:, optionally zero: }` table, and `count` is also exposed to interpolation
195
- as `%{count}` (English/German use the one/other rule).
453
+ **Both tests are half-open**: a shape spans `[x, x + w)`. Shapes that share only
454
+ an edge, or a circle that exactly grazes a box, do not overlap. That is also why
455
+ blocking and contact are separate reports. A blocked pair ends up exactly
456
+ touching, so it does not overlap, and `on_hit` does not fire.
457
+ `CircleCollider.overlap?` agrees, so contact means the same for every pair of
458
+ shapes. A grid needs this convention. Pieces on neighbouring squares border each
459
+ other constantly, and an inclusive test would report each as a contact. The
460
+ broadphase buckets by the same convention, so the two never disagree.
196
461
 
197
- The **`generation` counter** is the headless-friendly change seam: it ticks whenever the
198
- locale changes, so cached UI text can re-resolve only when `generation` moves rather than
199
- re-running `t` every frame keeping with the engine's no-per-frame-allocation rule.
462
+ A [`BoxCollider`](components.md#boxcollider) component is a `CollisionBox` plus a
463
+ registration in the scene's [`CollisionWorld`](components.md#collisionworld). These
464
+ two methods are its narrowphase. [`FeetCollider`](components.md#feetcollider) adds
465
+ the `bottom_anchored` arithmetic, computed from the node's own dimensions.
200
466
 
201
467
  ## `AudioBus` — decoupled audio facts
202
468
 
203
- `RGame::Engine::AudioBus` (`rgame/engine/audio_bus`) is a global, always-present audio bus: gameplay
204
- emits audio *facts* (`play this sound`, `play this music`) here, decoupled from playback,
205
- and an `AudioDirector` subscribes and turns them into actual
206
- sound. A module rather than an instance so any node can reach it without wiring.
469
+ **`RGame::Engine::AudioBus` (`rgame/engine/audio_bus`) is a global audio bus.**
470
+ Gameplay emits audio *facts* on it, such as "play this sound" or "play this
471
+ music", separate from playback. An `AudioDirector` subscribes and turns the facts
472
+ into sound. The bus is a module, not an instance, so any node reaches it without
473
+ wiring.
207
474
 
208
475
  ```ruby
209
476
  RGame::Engine::AudioBus.play_sound(:boom)
@@ -211,34 +478,16 @@ RGame::Engine::AudioBus.play_music(:theme)
211
478
  RGame::Engine::AudioBus.stop_music
212
479
  ```
213
480
 
214
- The emit shims (`play_sound`, `play_music`, `stop_music`) are the gameplay-facing API;
215
- underneath each is an [`RGame::Engine::Signal`](signals.md) (`on_play_sound`, `on_play_music`,
216
- `on_stop_music`) that is the actual subscription seam the director listens on. Because
217
- the engine only emits facts and never names an audio device, the bus stays in the engine
218
- layer and playback stays in `RGame::Core`.
219
-
220
- ## `Resettable` mutable only where a pool needs it
221
-
222
- `RGame::Engine::Resettable` (`rgame/engine/resettable`) builds value-object classes for pooling. Like
223
- `Data.define`, instances expose read-only accessors and carry their fields as a unit —
224
- but where a `Data` value is fully immutable (every change is a fresh allocation), these
225
- add exactly one mutation: `reset`, which overwrites all fields at once and returns self.
226
-
227
- ```ruby
228
- Point = RGame::Engine::Resettable.define(:x, :y)
229
- p = Point.new(3, 4)
230
- p.x # => 3 (read-only; no x= setter)
231
- p.reset(5, 6) # overwrite in place, allocation-free → self
232
-
233
- Vel = RGame::Engine::Resettable.define(:dx, :dy, keyword_init: true)
234
- Vel.new(dx: 1, dy: 0).reset(dx: 2, dy: 0)
235
- ```
236
-
237
- That single in-place `reset` is the only mutability a [`Pool`](#pool--reuse-dont-allocate)
238
- needs: acquire a recycled instance and `reset` it, without exposing the per-field setters
239
- a `Struct` would. Methods are generated fixed-arity with direct ivar assignment (as
240
- Struct/Data do), so `reset` is allocation-free and recycling stays zero-allocation in
241
- steady state — including the `keyword_init: true` form, which generates named parameters
242
- (`reset(x:, y:)`) rather than a `**kwargs` splat (the one form that would build a Hash per
243
- call). Reach for this over a mutable `Struct` whenever a value object is pool-recycled; see
244
- the Style notes in `CLAUDE.md`.
481
+ **`RGame::Game` subscribes a director when it starts, and calls `unsubscribe`
482
+ when the loop ends.** A game does neither. The release matters. The bus is a
483
+ module, so it holds its listeners until someone removes them. A listener holds the
484
+ director, the audio device, and the asset manager the device resolves paths
485
+ through. That manager holds the `App` it loads images for, window included. A
486
+ single game never notices, because its `App` lives as long as the process. A
487
+ process that runs two games does notice.
488
+
489
+ `play_sound`, `play_music` and `stop_music` form the gameplay API. Under each sits
490
+ an [`RGame::Engine::Signal`](signals.md) (`on_play_sound`, `on_play_music`,
491
+ `on_stop_music`), which the director subscribes to. The engine only emits facts
492
+ and never names an audio device. The bus therefore stays in the engine layer, and
493
+ playback stays in `RGame::Core`.