ludos 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 (41) hide show
  1. ludos-0.1.0/.github/workflows/publish.yml +27 -0
  2. ludos-0.1.0/.gitignore +20 -0
  3. ludos-0.1.0/LICENSE +21 -0
  4. ludos-0.1.0/PKG-INFO +85 -0
  5. ludos-0.1.0/README.md +59 -0
  6. ludos-0.1.0/docs/guide.md +491 -0
  7. ludos-0.1.0/pyproject.toml +46 -0
  8. ludos-0.1.0/src/ludos/__init__.py +43 -0
  9. ludos-0.1.0/src/ludos/display/__init__.py +0 -0
  10. ludos-0.1.0/src/ludos/display/window.py +58 -0
  11. ludos-0.1.0/src/ludos/engine.py +143 -0
  12. ludos-0.1.0/src/ludos/errors.py +25 -0
  13. ludos-0.1.0/src/ludos/input/__init__.py +0 -0
  14. ludos-0.1.0/src/ludos/input/bindings.py +62 -0
  15. ludos-0.1.0/src/ludos/input/events.py +34 -0
  16. ludos-0.1.0/src/ludos/input/handler.py +85 -0
  17. ludos-0.1.0/src/ludos/scenes/__init__.py +0 -0
  18. ludos-0.1.0/src/ludos/scenes/base.py +34 -0
  19. ludos-0.1.0/src/ludos/scenes/manager.py +62 -0
  20. ludos-0.1.0/src/ludos/scenes/menu.py +120 -0
  21. ludos-0.1.0/src/ludos/state/__init__.py +0 -0
  22. ludos-0.1.0/src/ludos/state/base.py +22 -0
  23. ludos-0.1.0/src/ludos/state/manager.py +50 -0
  24. ludos-0.1.0/tests/__init__.py +0 -0
  25. ludos-0.1.0/tests/conftest.py +38 -0
  26. ludos-0.1.0/tests/test_display/__init__.py +0 -0
  27. ludos-0.1.0/tests/test_display/test_window.py +69 -0
  28. ludos-0.1.0/tests/test_docs_sync.py +266 -0
  29. ludos-0.1.0/tests/test_engine.py +195 -0
  30. ludos-0.1.0/tests/test_errors.py +29 -0
  31. ludos-0.1.0/tests/test_input/__init__.py +0 -0
  32. ludos-0.1.0/tests/test_input/test_bindings.py +72 -0
  33. ludos-0.1.0/tests/test_input/test_events.py +39 -0
  34. ludos-0.1.0/tests/test_input/test_handler.py +109 -0
  35. ludos-0.1.0/tests/test_scenes/__init__.py +0 -0
  36. ludos-0.1.0/tests/test_scenes/test_base.py +58 -0
  37. ludos-0.1.0/tests/test_scenes/test_manager.py +93 -0
  38. ludos-0.1.0/tests/test_scenes/test_menu.py +111 -0
  39. ludos-0.1.0/tests/test_state/__init__.py +0 -0
  40. ludos-0.1.0/tests/test_state/test_base.py +40 -0
  41. ludos-0.1.0/tests/test_state/test_manager.py +59 -0
@@ -0,0 +1,27 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ - run: uv sync --all-extras
15
+ - run: uv run pytest
16
+
17
+ publish:
18
+ needs: test
19
+ runs-on: ubuntu-latest
20
+ environment: pypi
21
+ permissions:
22
+ id-token: write
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: astral-sh/setup-uv@v5
26
+ - run: uv build
27
+ - uses: pypa/gh-action-pypi-publish@release/v1
ludos-0.1.0/.gitignore ADDED
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ .pytest_cache/
12
+ .coverage
13
+ htmlcov/
14
+ *.so
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .uv/
18
+ uv.lock
19
+ CLAUDE.md
20
+ DESIGN.md
ludos-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Clarke-Lauer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ludos-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: ludos
3
+ Version: 0.1.0
4
+ Summary: Pygame abstraction framework for rapid game development
5
+ Project-URL: Homepage, https://github.com/mclarkelauer/ludos
6
+ Project-URL: Repository, https://github.com/mclarkelauer/ludos
7
+ Project-URL: Issues, https://github.com/mclarkelauer/ludos/issues
8
+ Author-email: Matt Clarke-Lauer <mdlauer@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Games/Entertainment
19
+ Classifier: Topic :: Software Development :: Libraries :: pygame
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: pygame-ce>=2.5.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Ludos
28
+
29
+ Pygame-ce abstraction framework that eliminates boilerplate for rapid game development. Clean separation between input, state, and rendering with extensibility at every layer.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install ludos
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```python
40
+ from dataclasses import dataclass
41
+ import pygame
42
+ from ludos import (
43
+ BaseGameState, BaseScene, EngineConfig, GameEngine,
44
+ KeyBindings, SceneManager, StateManager,
45
+ )
46
+
47
+ @dataclass
48
+ class MyState(BaseGameState):
49
+ score: int = 0
50
+
51
+ class PlayScene(BaseScene):
52
+ def handle_input(self, events, state_manager):
53
+ pass
54
+
55
+ def update(self, dt, state_manager):
56
+ pass
57
+
58
+ def render(self, surface, state_manager):
59
+ surface.fill((0, 0, 0))
60
+
61
+ config = EngineConfig(title="My Game", width=800, height=600)
62
+ state_manager = StateManager(MyState())
63
+ scene_manager = SceneManager()
64
+ scene_manager.push(PlayScene())
65
+
66
+ engine = GameEngine(config)
67
+ engine.run(scene_manager, state_manager)
68
+ ```
69
+
70
+ ## Features
71
+
72
+ - **State management** — Subclass `BaseGameState` as a dataclass, mutate through `StateManager.update()`
73
+ - **Scene stack** — Push/pop/replace scenes with `on_enter`/`on_exit` lifecycle hooks
74
+ - **Input mapping** — Map raw pygame keys to semantic actions via `KeyBindings`
75
+ - **Display wrapper** — `Window` handles pygame display init and surface management
76
+ - **Built-in menu** — `MenuScene` with configurable `MenuConfig` for quick prototyping
77
+ - **Error hierarchy** — All errors inherit `LudosError` for unified catching
78
+
79
+ ## Documentation
80
+
81
+ See the [user guide](docs/guide.md) for full documentation.
82
+
83
+ ## License
84
+
85
+ MIT
ludos-0.1.0/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # Ludos
2
+
3
+ Pygame-ce abstraction framework that eliminates boilerplate for rapid game development. Clean separation between input, state, and rendering with extensibility at every layer.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install ludos
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from dataclasses import dataclass
15
+ import pygame
16
+ from ludos import (
17
+ BaseGameState, BaseScene, EngineConfig, GameEngine,
18
+ KeyBindings, SceneManager, StateManager,
19
+ )
20
+
21
+ @dataclass
22
+ class MyState(BaseGameState):
23
+ score: int = 0
24
+
25
+ class PlayScene(BaseScene):
26
+ def handle_input(self, events, state_manager):
27
+ pass
28
+
29
+ def update(self, dt, state_manager):
30
+ pass
31
+
32
+ def render(self, surface, state_manager):
33
+ surface.fill((0, 0, 0))
34
+
35
+ config = EngineConfig(title="My Game", width=800, height=600)
36
+ state_manager = StateManager(MyState())
37
+ scene_manager = SceneManager()
38
+ scene_manager.push(PlayScene())
39
+
40
+ engine = GameEngine(config)
41
+ engine.run(scene_manager, state_manager)
42
+ ```
43
+
44
+ ## Features
45
+
46
+ - **State management** — Subclass `BaseGameState` as a dataclass, mutate through `StateManager.update()`
47
+ - **Scene stack** — Push/pop/replace scenes with `on_enter`/`on_exit` lifecycle hooks
48
+ - **Input mapping** — Map raw pygame keys to semantic actions via `KeyBindings`
49
+ - **Display wrapper** — `Window` handles pygame display init and surface management
50
+ - **Built-in menu** — `MenuScene` with configurable `MenuConfig` for quick prototyping
51
+ - **Error hierarchy** — All errors inherit `LudosError` for unified catching
52
+
53
+ ## Documentation
54
+
55
+ See the [user guide](docs/guide.md) for full documentation.
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,491 @@
1
+ # Ludos User Guide
2
+
3
+ Ludos is a pygame-ce abstraction framework that eliminates boilerplate and lets you build games fast. You define your state, scenes, and input bindings — Ludos handles the game loop, event conversion, and display management.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Clone and install with uv
9
+ uv sync --all-extras
10
+ ```
11
+
12
+ **Requirements**: Python >= 3.12, pygame-ce >= 2.5.0
13
+
14
+ ## Quickstart
15
+
16
+ Here is the smallest possible Ludos program — a window with a menu:
17
+
18
+ ```python
19
+ from ludos import GameEngine, EngineConfig, MenuScene, MenuItem, MenuConfig
20
+
21
+
22
+ def on_start():
23
+ print("Starting!")
24
+
25
+
26
+ def on_quit():
27
+ print("Goodbye!")
28
+
29
+
30
+ engine = GameEngine(
31
+ config=EngineConfig(title="My Game", width=800, height=600, fps=60),
32
+ initial_scene=MenuScene(
33
+ items=[
34
+ MenuItem("Start Game", on_start),
35
+ MenuItem("Quit", on_quit),
36
+ ],
37
+ config=MenuConfig(title="Main Menu"),
38
+ ),
39
+ )
40
+ engine.run()
41
+ ```
42
+
43
+ This opens an 800x600 window with a navigable menu. Arrow keys move the selection, Enter confirms. The window closes when you press the X button.
44
+
45
+ ## Core Concepts
46
+
47
+ Ludos's architecture separates concerns into four systems:
48
+
49
+ ```
50
+ GameEngine (composition root)
51
+ ├── Window — pygame display surface
52
+ ├── InputHandler — polls events, resolves semantic actions
53
+ │ └── KeyBindings — key/button → action mapping
54
+ ├── SceneManager — stack-based scene switching
55
+ │ └── BaseScene — your game screens (ABC)
56
+ └── StateManager[T] — generic over your state dataclass
57
+ ```
58
+
59
+ The game loop runs **Input → Update → Render** every frame with delta time.
60
+
61
+ ## State
62
+
63
+ ### Defining State
64
+
65
+ Extend `BaseGameState` with a `@dataclass`. Your fields sit alongside the built-in ones:
66
+
67
+ ```python
68
+ from dataclasses import dataclass
69
+ from ludos import BaseGameState
70
+
71
+
72
+ @dataclass
73
+ class MyState(BaseGameState):
74
+ score: int = 0
75
+ player_x: float = 400.0
76
+ player_y: float = 300.0
77
+ lives: int = 3
78
+ ```
79
+
80
+ **Built-in fields** (managed by the engine):
81
+
82
+ | Field | Type | Default | Description |
83
+ |-------|------|---------|-------------|
84
+ | `is_running` | `bool` | `True` | Set to `False` to stop the engine |
85
+ | `frame_count` | `int` | `0` | Incremented each frame by the engine |
86
+ | `elapsed_time` | `float` | `0.0` | Total seconds elapsed |
87
+ | `metadata` | `dict[str, Any]` | `{}` | Arbitrary storage for your own use |
88
+
89
+ ### Mutating State
90
+
91
+ State is managed through `StateManager[T]`. Mutations use an explicit mutator pattern:
92
+
93
+ ```python
94
+ # Inside a scene's handle_input or update:
95
+ engine.state_manager.update(lambda s: setattr(s, "score", s.score + 10))
96
+ ```
97
+
98
+ The `StateManager` tracks whether state has changed via a `dirty` flag, which the engine resets after each render frame with `mark_clean()`.
99
+
100
+ ### StateManager API
101
+
102
+ | Member | Type | Description |
103
+ |--------|------|-------------|
104
+ | `state` | property → `T` | The current state instance |
105
+ | `dirty` | property → `bool` | `True` if state was mutated since last `mark_clean()` |
106
+ | `update(mutator)` | method | Apply a `Callable[[T], None]` to mutate state |
107
+ | `mark_clean()` | method | Reset the dirty flag |
108
+
109
+ If the mutator raises, it is wrapped in a `StateError`.
110
+
111
+ ## Input
112
+
113
+ ### InputEvent
114
+
115
+ Raw pygame events are converted to `InputEvent` — a frozen dataclass:
116
+
117
+ | Field | Type | Description |
118
+ |-------|------|-------------|
119
+ | `type` | `InputType` | Event category (see below) |
120
+ | `key` | `int \| None` | Pygame key constant (keyboard events) |
121
+ | `button` | `int \| None` | Mouse button number (mouse events) |
122
+ | `pos` | `tuple[int, int] \| None` | Mouse position (mouse events) |
123
+ | `action` | `str \| None` | Semantic action resolved from KeyBindings |
124
+
125
+ ### InputType
126
+
127
+ ```python
128
+ from ludos import InputType
129
+
130
+ InputType.KEY_DOWN # Key pressed
131
+ InputType.KEY_UP # Key released
132
+ InputType.MOUSE_DOWN # Mouse button pressed
133
+ InputType.MOUSE_UP # Mouse button released
134
+ InputType.MOUSE_MOTION # Mouse moved
135
+ InputType.QUIT # Window close
136
+ ```
137
+
138
+ ### KeyBindings
139
+
140
+ Maps raw pygame keys/buttons to semantic action strings. The engine uses these to populate `InputEvent.action`.
141
+
142
+ ```python
143
+ from ludos import KeyBindings
144
+ import pygame
145
+
146
+ # Start from defaults or empty
147
+ bindings = KeyBindings.defaults()
148
+
149
+ # Add/override bindings
150
+ bindings.bind_key(pygame.K_w, "move_up")
151
+ bindings.bind_key(pygame.K_s, "move_down")
152
+ bindings.bind_mouse(2, "middle_click")
153
+
154
+ # Remove a binding
155
+ bindings.unbind_key(pygame.K_SPACE)
156
+
157
+ # Look up
158
+ bindings.get_key_action(pygame.K_w) # "move_up"
159
+ bindings.get_mouse_action(1) # "click"
160
+ ```
161
+
162
+ **Default bindings** (`KeyBindings.defaults()`):
163
+
164
+ | Key/Button | Action |
165
+ |------------|--------|
166
+ | `K_UP` | `"move_up"` |
167
+ | `K_DOWN` | `"move_down"` |
168
+ | `K_LEFT` | `"move_left"` |
169
+ | `K_RIGHT` | `"move_right"` |
170
+ | `K_RETURN` | `"confirm"` |
171
+ | `K_ESCAPE` | `"cancel"` |
172
+ | `K_SPACE` | `"action"` |
173
+ | Mouse button 1 | `"click"` |
174
+ | Mouse button 3 | `"right_click"` |
175
+
176
+ ### InputHandler
177
+
178
+ Polls pygame events, converts them to `InputEvent`, and dispatches registered callbacks.
179
+
180
+ ```python
181
+ from ludos import InputHandler, KeyBindings
182
+
183
+ handler = InputHandler(KeyBindings.defaults())
184
+
185
+ # Register callbacks for semantic actions
186
+ handler.on("confirm", lambda event: print("Confirmed!"))
187
+ handler.on("cancel", lambda event: print("Cancelled!"))
188
+
189
+ # Unregister
190
+ handler.off("confirm", my_callback)
191
+ ```
192
+
193
+ You typically don't call `handler.poll()` yourself — the engine does it. But in your scenes, you receive the already-converted `InputEvent` objects.
194
+
195
+ ## Scenes
196
+
197
+ ### BaseScene
198
+
199
+ All game screens extend `BaseScene` and implement three required methods:
200
+
201
+ ```python
202
+ import pygame
203
+ from ludos import BaseScene, BaseGameState, InputEvent, InputType
204
+
205
+
206
+ class GameplayScene(BaseScene):
207
+ def handle_input(self, event: InputEvent, state: BaseGameState) -> None:
208
+ """Called once per input event per frame."""
209
+ if event.action == "move_up":
210
+ state.metadata["player_y"] = state.metadata.get("player_y", 300) - 5
211
+
212
+ def update(self, dt: float, state: BaseGameState) -> None:
213
+ """Called once per frame. dt = seconds since last frame."""
214
+ pass
215
+
216
+ def render(self, surface: pygame.Surface, state: BaseGameState) -> None:
217
+ """Draw to the surface each frame."""
218
+ surface.fill((30, 30, 60))
219
+
220
+ # Optional lifecycle hooks:
221
+ def on_enter(self, state: BaseGameState) -> None:
222
+ """Called when this scene becomes active (pushed or uncovered)."""
223
+ print("Entered gameplay!")
224
+
225
+ def on_exit(self, state: BaseGameState) -> None:
226
+ """Called when this scene is deactivated (popped or covered)."""
227
+ print("Left gameplay!")
228
+ ```
229
+
230
+ ### SceneManager
231
+
232
+ Scenes live on a stack. The topmost scene is the **active** scene that receives input, updates, and renders.
233
+
234
+ ```python
235
+ from ludos import SceneManager, BaseGameState
236
+
237
+ manager = SceneManager()
238
+ state = BaseGameState()
239
+
240
+ manager.push(gameplay_scene, state) # gameplay is active, on_enter called
241
+ manager.push(pause_scene, state) # gameplay.on_exit → pause.on_enter
242
+ manager.pop(state) # pause.on_exit → gameplay.on_enter
243
+ manager.replace(menu_scene, state) # gameplay.on_exit → menu.on_enter
244
+
245
+ manager.active # The topmost scene (or None)
246
+ manager.depth # Number of scenes on stack
247
+ manager.clear(state) # Pop all scenes, calling on_exit for each
248
+ ```
249
+
250
+ **Lifecycle flow**:
251
+ - `push(scene)`: old scene's `on_exit` → new scene's `on_enter`
252
+ - `pop()`: active scene's `on_exit` → uncovered scene's `on_enter`
253
+ - `replace(scene)`: old scene's `on_exit` → new scene's `on_enter`
254
+ - `clear()`: all scenes get `on_exit` called, stack empties
255
+
256
+ ### MenuScene
257
+
258
+ A ready-made scene with keyboard-navigable menu items:
259
+
260
+ ```python
261
+ from ludos import MenuScene, MenuItem, MenuConfig
262
+
263
+
264
+ def start_game():
265
+ print("Starting!")
266
+
267
+
268
+ menu = MenuScene(
269
+ items=[
270
+ MenuItem("New Game", start_game),
271
+ MenuItem("Options", lambda: print("Options")),
272
+ MenuItem("Quit", lambda: print("Quit")),
273
+ ],
274
+ config=MenuConfig(
275
+ title="My Game",
276
+ font_size=36,
277
+ font_name=None, # None = pygame default font
278
+ text_color=(255, 255, 255),
279
+ highlight_color=(255, 255, 0),
280
+ bg_color=(0, 0, 0),
281
+ title_font_size=48,
282
+ item_spacing=10,
283
+ ),
284
+ )
285
+ ```
286
+
287
+ Navigation uses the `"move_up"`, `"move_down"`, and `"confirm"` actions from your key bindings. Selection wraps around.
288
+
289
+ **MenuConfig fields**:
290
+
291
+ | Field | Type | Default |
292
+ |-------|------|---------|
293
+ | `font_size` | `int` | `36` |
294
+ | `font_name` | `str \| None` | `None` (system default) |
295
+ | `text_color` | `tuple[int, int, int]` | `(255, 255, 255)` |
296
+ | `highlight_color` | `tuple[int, int, int]` | `(255, 255, 0)` |
297
+ | `bg_color` | `tuple[int, int, int]` | `(0, 0, 0)` |
298
+ | `title` | `str` | `""` |
299
+ | `title_font_size` | `int` | `48` |
300
+ | `item_spacing` | `int` | `10` |
301
+
302
+ ## Engine
303
+
304
+ ### EngineConfig
305
+
306
+ ```python
307
+ from ludos import EngineConfig
308
+
309
+ config = EngineConfig(
310
+ width=800, # Window width (default: 800)
311
+ height=600, # Window height (default: 600)
312
+ title="Ludos", # Window title (default: "Ludos")
313
+ fps=60, # Target frames per second (default: 60)
314
+ bg_color=(0, 0, 0), # Background clear color (default: black)
315
+ display_flags=0, # Pygame display flags (default: 0)
316
+ )
317
+ ```
318
+
319
+ ### GameEngine
320
+
321
+ The composition root that ties everything together:
322
+
323
+ ```python
324
+ from ludos import GameEngine, EngineConfig, KeyBindings
325
+
326
+ engine = GameEngine(
327
+ config=EngineConfig(title="My Game"),
328
+ initial_state=MyState(),
329
+ initial_scene=my_scene,
330
+ bindings=KeyBindings.defaults(), # optional, defaults are used if omitted
331
+ )
332
+
333
+ # Access subsystems
334
+ engine.state_manager # StateManager[T]
335
+ engine.scene_manager # SceneManager
336
+ engine.input_handler # InputHandler
337
+ engine.window # Window (None until run() is called)
338
+
339
+ # Run the game
340
+ engine.run() # Blocks until game ends
341
+
342
+ # Stop from inside a scene or callback
343
+ engine.stop() # Sets is_running = False, loop exits after current frame
344
+ ```
345
+
346
+ **Game loop order** (each frame):
347
+ 1. Tick clock, compute delta time
348
+ 2. Increment `frame_count` and `elapsed_time`
349
+ 3. **Input**: poll events, dispatch to active scene's `handle_input`
350
+ 4. `QUIT` events automatically call `engine.stop()`
351
+ 5. **Update**: call active scene's `update(dt, state)`
352
+ 6. **Render**: clear window, call active scene's `render(surface, state)`, flip display
353
+ 7. Mark state clean
354
+
355
+ ## Error Handling
356
+
357
+ All framework errors inherit from `LudosError`:
358
+
359
+ ```python
360
+ from ludos import LudosError
361
+
362
+ try:
363
+ engine.run()
364
+ except LudosError as e:
365
+ print(f"Framework error: {e}")
366
+ ```
367
+
368
+ | Error | Raised when |
369
+ |-------|-------------|
370
+ | `LudosError` | Base class for all errors |
371
+ | `InitializationError` | Pygame init or window creation fails |
372
+ | `StateError` | State mutator raises or invalid state passed |
373
+ | `SceneError` | Invalid scene operation (pop empty stack, push non-scene) |
374
+ | `InputError` | Invalid key binding (empty action string) |
375
+ | `RenderError` | Display flip fails |
376
+
377
+ ## Complete Example
378
+
379
+ A game with a main menu that transitions to a gameplay scene:
380
+
381
+ ```python
382
+ from dataclasses import dataclass
383
+
384
+ import pygame
385
+
386
+ from ludos import (
387
+ BaseGameState,
388
+ BaseScene,
389
+ EngineConfig,
390
+ GameEngine,
391
+ InputEvent,
392
+ KeyBindings,
393
+ MenuConfig,
394
+ MenuItem,
395
+ MenuScene,
396
+ )
397
+
398
+
399
+ @dataclass
400
+ class MyState(BaseGameState):
401
+ score: int = 0
402
+ player_x: float = 400.0
403
+ player_y: float = 300.0
404
+
405
+
406
+ class GameplayScene(BaseScene):
407
+ def __init__(self, engine: GameEngine) -> None:
408
+ self._engine = engine
409
+
410
+ def handle_input(self, event: InputEvent, state: BaseGameState) -> None:
411
+ if event.action == "cancel":
412
+ # Return to menu
413
+ self._engine.scene_manager.pop(state)
414
+ elif event.action == "move_up":
415
+ state.metadata["player_y"] = state.metadata.get("player_y", 300.0) - 5
416
+ elif event.action == "move_down":
417
+ state.metadata["player_y"] = state.metadata.get("player_y", 300.0) + 5
418
+ elif event.action == "move_left":
419
+ state.metadata["player_x"] = state.metadata.get("player_x", 400.0) - 5
420
+ elif event.action == "move_right":
421
+ state.metadata["player_x"] = state.metadata.get("player_x", 400.0) + 5
422
+
423
+ def update(self, dt: float, state: BaseGameState) -> None:
424
+ pass
425
+
426
+ def render(self, surface: pygame.Surface, state: BaseGameState) -> None:
427
+ surface.fill((20, 20, 40))
428
+ x = int(state.metadata.get("player_x", 400.0))
429
+ y = int(state.metadata.get("player_y", 300.0))
430
+ pygame.draw.circle(surface, (0, 200, 255), (x, y), 20)
431
+
432
+
433
+ def main():
434
+ bindings = KeyBindings.defaults()
435
+ bindings.bind_key(pygame.K_w, "move_up")
436
+ bindings.bind_key(pygame.K_s, "move_down")
437
+ bindings.bind_key(pygame.K_a, "move_left")
438
+ bindings.bind_key(pygame.K_d, "move_right")
439
+
440
+ engine = GameEngine(
441
+ config=EngineConfig(title="My Game", width=800, height=600),
442
+ initial_state=MyState(),
443
+ bindings=bindings,
444
+ )
445
+
446
+ gameplay = GameplayScene(engine)
447
+
448
+ menu = MenuScene(
449
+ items=[
450
+ MenuItem("Play", lambda: engine.scene_manager.push(gameplay, engine.state_manager.state)),
451
+ MenuItem("Quit", engine.stop),
452
+ ],
453
+ config=MenuConfig(title="My Game"),
454
+ )
455
+
456
+ engine._initial_scene = menu
457
+ engine.run()
458
+
459
+
460
+ if __name__ == "__main__":
461
+ main()
462
+ ```
463
+
464
+ ## Testing Your Game
465
+
466
+ Ludos is designed for testability. Mock pygame to test scenes without a display:
467
+
468
+ ```python
469
+ from unittest.mock import MagicMock
470
+
471
+ from ludos import InputEvent, InputType
472
+ from your_game import GameplayScene, MyState
473
+
474
+
475
+ def test_move_up():
476
+ scene = GameplayScene(engine=MagicMock())
477
+ state = MyState()
478
+ state.metadata["player_y"] = 300.0
479
+
480
+ event = InputEvent(type=InputType.KEY_DOWN, action="move_up")
481
+ scene.handle_input(event, state)
482
+
483
+ assert state.metadata["player_y"] == 295.0
484
+ ```
485
+
486
+ Run tests with:
487
+
488
+ ```bash
489
+ uv run pytest
490
+ uv run pytest --cov=ludos
491
+ ```