microecs 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.
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Meehai
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.1
2
+ Name: microecs
3
+ Version: 0.1.0
4
+ Summary: MicroECS: Minimal Entity Component System (ECS) in python and numpy
5
+ Home-page: https://gitlab.com/meehai/microecs
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE.TXT
10
+ Requires-Dist: numpy>=2.2.0
11
+ Requires-Dist: loggez>=0.8
12
+
13
+ # MicroECS
14
+
15
+ Minimal (<200 LoC) Entity Component System in python and numpy. Examples also use raylib for rendering.
16
+
17
+ Usage:
18
+
19
+ - `pip install -r requirements.txt`
20
+ - Sandbox: `python main.py`
21
+ - Tests: `pytest test/`
22
+
23
+ There are only four primitives (bottom up): `Component`,`Pool`, `World` and `System`.
24
+
25
+ - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 4 dtypes only: `int32`, `float32`, `str` and `bool`.
26
+ - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
27
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other.
28
+ - `System` is an abstract class that queries the `World` for a subset of `Pools` matching some components. It updates the entities in these pools given some logic (e.g. collisions, motion physics or simply calls the drawing functions).
29
+
30
+ Few relevant concepts:
31
+
32
+ - `Pool` operates on array indices, while `World` operates on entity IDs (also integers). This allows seamless movement between pools while the high-level systems still working as intended.
33
+ - All mutable operations on `World` are lazy. These are: `add_entity`, `remove_entity`, `add_component`, `remove_component`. They are added to a command buffer which is only executed when calling `world.update()`.
34
+
35
+ Super simplified main loop structure:
36
+
37
+ ```python
38
+ import numpy as np
39
+ import raylib as rl
40
+ from microecs import World, Component, TickSystem
41
+
42
+ # components
43
+ class HasPosition(Component):
44
+ position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
45
+ class HasColor(Component):
46
+ color: np.ndarray = field(metadata={"shape": (4, ), "dtype": "int32"})
47
+
48
+ # systems
49
+ class RenderSystem(TickSystem):
50
+ def on_tick(self, world: World): # must override
51
+ for pool in world.query_and((HasPosition, HasColor)): # get all pools of entities having both
52
+ for position, radius, color in zip(pool.position, pool.color): # for each entity in this pool
53
+ DrawEntity(position, color)
54
+ class CollisionSystem(TickSystem)
55
+ def on_tick(self, world: World): # must override
56
+ ...
57
+
58
+ def main():
59
+ render_system = RenderSystem()
60
+ update_systems: list[TickSystem] = [CollisionSystem()]
61
+
62
+ world = World(components=[HasPosition, HasColor])
63
+ for _ in range(n_objects):
64
+ # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
65
+ world.add_entity(components=(HasPosition, HasColor), # tuple of components (types)
66
+ position=np.array((x, y), "float32"), # data as kwargs
67
+ color=np.array("black", dtype="int32"))
68
+
69
+ while not rl.WindowShouldClose():
70
+ world.update() # must be called at each tick so the lazy methods are processed and entities are updated
71
+ # update stuff...
72
+ _ = [system.on_tick(world=world) for system in update_systems]
73
+ # draw stuff, e.g. using raylib
74
+ rl.BeginDrawing()
75
+ rl.ClearBackground(rl.RAYWHITE)
76
+ rl.DrawFPS(rl.GetScreenWidth() - 100, 0)
77
+ render_system.on_tick(world=world)
78
+ rl.EndDrawing()
79
+ ```
@@ -0,0 +1,67 @@
1
+ # MicroECS
2
+
3
+ Minimal (<200 LoC) Entity Component System in python and numpy. Examples also use raylib for rendering.
4
+
5
+ Usage:
6
+
7
+ - `pip install -r requirements.txt`
8
+ - Sandbox: `python main.py`
9
+ - Tests: `pytest test/`
10
+
11
+ There are only four primitives (bottom up): `Component`,`Pool`, `World` and `System`.
12
+
13
+ - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 4 dtypes only: `int32`, `float32`, `str` and `bool`.
14
+ - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
15
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other.
16
+ - `System` is an abstract class that queries the `World` for a subset of `Pools` matching some components. It updates the entities in these pools given some logic (e.g. collisions, motion physics or simply calls the drawing functions).
17
+
18
+ Few relevant concepts:
19
+
20
+ - `Pool` operates on array indices, while `World` operates on entity IDs (also integers). This allows seamless movement between pools while the high-level systems still working as intended.
21
+ - All mutable operations on `World` are lazy. These are: `add_entity`, `remove_entity`, `add_component`, `remove_component`. They are added to a command buffer which is only executed when calling `world.update()`.
22
+
23
+ Super simplified main loop structure:
24
+
25
+ ```python
26
+ import numpy as np
27
+ import raylib as rl
28
+ from microecs import World, Component, TickSystem
29
+
30
+ # components
31
+ class HasPosition(Component):
32
+ position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
33
+ class HasColor(Component):
34
+ color: np.ndarray = field(metadata={"shape": (4, ), "dtype": "int32"})
35
+
36
+ # systems
37
+ class RenderSystem(TickSystem):
38
+ def on_tick(self, world: World): # must override
39
+ for pool in world.query_and((HasPosition, HasColor)): # get all pools of entities having both
40
+ for position, radius, color in zip(pool.position, pool.color): # for each entity in this pool
41
+ DrawEntity(position, color)
42
+ class CollisionSystem(TickSystem)
43
+ def on_tick(self, world: World): # must override
44
+ ...
45
+
46
+ def main():
47
+ render_system = RenderSystem()
48
+ update_systems: list[TickSystem] = [CollisionSystem()]
49
+
50
+ world = World(components=[HasPosition, HasColor])
51
+ for _ in range(n_objects):
52
+ # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
53
+ world.add_entity(components=(HasPosition, HasColor), # tuple of components (types)
54
+ position=np.array((x, y), "float32"), # data as kwargs
55
+ color=np.array("black", dtype="int32"))
56
+
57
+ while not rl.WindowShouldClose():
58
+ world.update() # must be called at each tick so the lazy methods are processed and entities are updated
59
+ # update stuff...
60
+ _ = [system.on_tick(world=world) for system in update_systems]
61
+ # draw stuff, e.g. using raylib
62
+ rl.BeginDrawing()
63
+ rl.ClearBackground(rl.RAYWHITE)
64
+ rl.DrawFPS(rl.GetScreenWidth() - 100, 0)
65
+ render_system.on_tick(world=world)
66
+ rl.EndDrawing()
67
+ ```
@@ -0,0 +1,8 @@
1
+ """init file"""
2
+
3
+ from .pool import Pool
4
+ from .world import World
5
+ from .system import TickSystem
6
+ from .component import Component
7
+
8
+ __all__ = ["Pool", "World", "TickSystem", "Component"]
@@ -0,0 +1,8 @@
1
+ """component.py - submodule holind the base class for all components, which is just a dataclass wrapper"""
2
+ from dataclasses import dataclass
3
+
4
+ class Component:
5
+ """Base for ECS components: subclassing auto-applies @dataclass(kw_only=True)."""
6
+ def __init_subclass__(cls, **kwargs):
7
+ super().__init_subclass__(**kwargs)
8
+ dataclass(kw_only=True)(cls)
@@ -0,0 +1,74 @@
1
+ """pool.py - A pool of entities of the same type (same list of components). Basically a dynamic array with numpy"""
2
+ import numpy as np
3
+ from .utils import Shape, logger
4
+
5
+ class Pool:
6
+ """
7
+ Pool is a dynamic array of entities data given a list of fields, shapes and dtypes (from traits).
8
+ Pool has no concept of entity ids.
9
+ """
10
+ INITIAL_CAPACITY = 100
11
+ RESERVED_NAMES = {"size", "capacity", "fields", "shapes", "dtypes", "data"}
12
+
13
+ def __init__(self, fields: list[str], shapes: list[Shape], dtypes: list[np.dtype]):
14
+ assert len(fields) == len(shapes) == len(dtypes), (len(fields), len(shapes), len(dtypes))
15
+ assert not (set(fields) & Pool.RESERVED_NAMES), f"One of {fields=} in {Pool.RESERVED_NAMES}"
16
+ self.fields = fields
17
+ self.shapes = shapes
18
+ self.dtypes = dtypes
19
+
20
+ self.data: dict[str, np.ndarray] = {} # the actual data is stored in a dynamic arrray, one per field
21
+ self.size = 0
22
+ self.capacity = Pool.INITIAL_CAPACITY
23
+ for _field, shape, dtype in zip(fields, shapes, dtypes):
24
+ self.data[_field] = np.empty(shape=(self.capacity, *shape), dtype=dtype)
25
+
26
+ def add_entity(self, **entity_fields) -> int:
27
+ """Adds an entity to the pool. All the fields required by this pool must be provided as kwargs"""
28
+ if self.size == self.capacity:
29
+ self._realloc(self.capacity * 2)
30
+ logger.debug(f"Capacity extended from {self.capacity // 2} to {self.capacity}")
31
+
32
+ for _field, field_shape, field_dtype in zip(self.fields, self.shapes, self.dtypes):
33
+ new_item: np.ndarray = entity_fields[_field] # checked in World._get_entity_pool(entity).
34
+ assert new_item.shape == field_shape, f"{_field=} {new_item=}, {new_item.shape=}, {field_shape=}"
35
+ assert np.issubdtype(new_item.dtype, field_dtype), f"{_field=} {new_item=} {new_item.dtype=} {field_dtype=}"
36
+ self.data[_field][self.size] = new_item
37
+ self.size += 1
38
+ return self.size - 1
39
+
40
+ def remove_entity(self, entity_index: int):
41
+ """removes an entity given an index (NOT ID) inside this pool"""
42
+ assert entity_index < self.size, f"OOB: {entity_index=}, {self.size=}"
43
+ for _field in self.fields:
44
+ self.data[_field][entity_index] = self.data[_field][self.size - 1]
45
+ self.size -= 1
46
+
47
+ if self.size < self.capacity / 4 and self.capacity > Pool.INITIAL_CAPACITY:
48
+ self._realloc(self.capacity // 2)
49
+
50
+ def pop_entity(self, entity_index: int) -> dict[str, np.ndarray]:
51
+ """pops an entity given an index (NOT ID) inside this pool and returns the data"""
52
+ res = {_field: self.data[_field][entity_index].copy() for _field in self.fields}
53
+ self.remove_entity(entity_index)
54
+ return res
55
+
56
+ def _realloc(self, new_capacity: int):
57
+ for _field, shape, dtype in zip(self.fields, self.shapes, self.dtypes):
58
+ old_data = self.data[_field]
59
+ self.data[_field] = np.empty(shape=(new_capacity, *shape), dtype=dtype)
60
+ self.data[_field][0:self.size] = old_data[0:self.size]
61
+ self.capacity = new_capacity
62
+
63
+ def __getattr__(self, name):
64
+ if (data := self.__dict__.get("data")) is not None and name in data:
65
+ return data[name][0: self.size]
66
+ raise AttributeError(name)
67
+
68
+ def __setattr__(self, name, value):
69
+ if (data := self.__dict__.get("data")) is not None and name in data:
70
+ raise ValueError(f"Cannot explicitly set anything to Pool. Use `pool.component[:] = ...` ({name=})")
71
+ super().__setattr__(name, value)
72
+
73
+ def __len__(self):
74
+ return self.size
@@ -0,0 +1,10 @@
1
+ """systems.py - Interfaces for various types of systems in ECS"""
2
+ from abc import ABC, abstractmethod
3
+
4
+ from .world import World
5
+
6
+ class TickSystem(ABC):
7
+ """Generic tick-level system. Called inside the hot main engine loop on every tick"""
8
+ @abstractmethod
9
+ def on_tick(self, scene: World):
10
+ """callback called on each tick"""
@@ -0,0 +1,8 @@
1
+ """utils.py - common utilities used by microecs. Mostly logger and types used across the code."""
2
+ from loggez import make_logger
3
+
4
+ PoolKey = int
5
+ EntityId = int
6
+ Shape = tuple[int, ...]
7
+
8
+ logger = make_logger("MICROECS")
@@ -0,0 +1,155 @@
1
+ """world.py - The world container for ECS. It manages all the pools (one per archetype). Entities are id-based."""
2
+ from typing import Callable
3
+ from functools import partial
4
+ import numpy as np
5
+
6
+ from .pool import Pool
7
+ from .utils import Shape, EntityId, PoolKey, logger
8
+ from .component import Component
9
+
10
+ class World:
11
+ """Generic container for pools of components. Newly added components are assigned a unique id and go in a pool"""
12
+ def __init__(self, components: list[type[Component]]):
13
+ self._check_components(components)
14
+ self.pools: dict[PoolKey, Pool] = {}
15
+ self.pool_to_components: dict[Pool, list[type[Component]]] = {}
16
+ # components management
17
+ self.component_names = [x.__name__ for x in components]
18
+ self.component_types = list(components)
19
+ self.component_to_bit: dict[type, int] = {t: 2**i for i, t in enumerate(components)} # unique bit for querying
20
+ self.component_to_shapes: dict[type, list[Shape]] = {
21
+ t: [f.metadata["shape"] for f in t.__dataclass_fields__.values()] for t in components}
22
+ self.component_to_dtypes: dict[type, list[str]] = {
23
+ t: [f.metadata["dtype"] for f in t.__dataclass_fields__.values()] for t in components}
24
+ self.component_to_field_names: dict[type, list[str]] = {t: list(t.__dataclass_fields__) for t in components}
25
+ # entity id management
26
+ self._eid_to_pool_ix: dict[EntityId, tuple[Pool, int]] = {}
27
+ self._pool_ix_to_eid: dict[tuple[Pool, int], EntityId] = {}
28
+ self._last_id: EntityId = -1
29
+ self._live_ids: set[EntityId] = set() # 'eager' mode ids so e.g. calling remove_entity twice before update fails
30
+ # command buffer management. {add/remove}_{entity/component} are lazy. Taken into account after update().
31
+ self._command_buffer: list[Callable] = []
32
+ logger.debug(f"Created scene with components: {self.component_names}")
33
+
34
+ # public api
35
+
36
+ def update(self):
37
+ """commits the underlying pool changes from the systems between two updates. Should be called in main loop."""
38
+ for fn in self._command_buffer:
39
+ fn()
40
+ self._command_buffer.clear()
41
+
42
+ def add_entity(self, components: list[type[Component]], **kwargs) -> EntityId:
43
+ """Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()"""
44
+ self._check_components_against_pool(components, **kwargs)
45
+ self._last_id += 1
46
+ self._live_ids.add(self._last_id)
47
+ self._command_buffer.append(partial(self._add_to_pool, entity_id=self._last_id,
48
+ components=components, **kwargs))
49
+ return self._last_id
50
+
51
+ def remove_entity(self, entity_id: EntityId):
52
+ """Removes an entity based on its unique entity id. Lazy; call update()"""
53
+ assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
54
+ self._live_ids.remove(entity_id)
55
+ self._command_buffer.append(partial(self._pop_from_pool, entity_id=entity_id))
56
+
57
+ def add_component(self, entity_id: EntityId, component: type[Component], **kwargs):
58
+ """Adds a component to an entity given its id. Component data is sent to kwargs. Lazy; call update()"""
59
+ assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
60
+ assert component in self.component_types, f"Component '{component}' not in {self.component_types}"
61
+ self._command_buffer.append(partial(self._do_add_component, entity_id=entity_id, component=component, **kwargs))
62
+
63
+ def remove_component(self, entity_id: EntityId, component: type[Component]):
64
+ """Removes a component from an entity given its id. Lazy; call update()"""
65
+ assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
66
+ assert component in self.component_types, f"Component '{component}' not in {self.component_types}"
67
+ self._command_buffer.append(partial(self._do_remove_component, entity_id=entity_id, component=component))
68
+
69
+ def query_and(self, component_types: list[type]) -> list[Pool]:
70
+ """returns all the entities that have all the components"""
71
+ key = self._make_key(component_types)
72
+ res = []
73
+ for archetype_key, archetype_pool in self.pools.items():
74
+ if (archetype_key & key) == key: # key is subset of archetype_key
75
+ res.append(archetype_pool)
76
+ return res
77
+
78
+ # private stuff
79
+
80
+ # eager mode methods equivalent to add/remove entities and add/remove_components
81
+
82
+ def _add_to_pool(self, entity_id: EntityId, components: list[type[Component]], **kwargs):
83
+ """adds the item to the pool"""
84
+ pool = self._get_entity_pool(components)
85
+ pool_index = pool.add_entity(**kwargs)
86
+ self._eid_to_pool_ix[entity_id] = (pool, pool_index)
87
+ self._pool_ix_to_eid[(pool, pool_index)] = entity_id
88
+
89
+ def _pop_from_pool(self, entity_id: EntityId) -> tuple[dict[str, np.ndarray], list[type]]:
90
+ """common function that updates the entities inside a pool (after popswap) and removes them if they get empty"""
91
+ old_pool, pool_ix = self._eid_to_pool_ix.pop(entity_id)
92
+ entity = old_pool.pop_entity(pool_ix)
93
+ components = self.pool_to_components[old_pool]
94
+ id_which_was_last_in_pool = self._pool_ix_to_eid.pop((old_pool, len(old_pool)))
95
+ if entity_id != id_which_was_last_in_pool:
96
+ self._eid_to_pool_ix[id_which_was_last_in_pool] = (old_pool, pool_ix) # we re-use the popped id (swapped)
97
+ self._pool_ix_to_eid[(old_pool, pool_ix)] = id_which_was_last_in_pool
98
+ if len(old_pool) == 0:
99
+ del self.pools[self._make_key(components)]
100
+ del self.pool_to_components[old_pool]
101
+ return entity, components
102
+
103
+ def _do_add_component(self, entity_id: EntityId, component: type[Component], **kwargs):
104
+ entity, components = self._pop_from_pool(entity_id)
105
+ new_components = [*components, component]
106
+ assert entity.keys().isdisjoint(kwargs), f"Duplicate keys: {entity.keys()} vs {kwargs.keys()}"
107
+ self._check_components_against_pool(new_components, **entity, **kwargs)
108
+ self._add_to_pool(entity_id, new_components, **entity, **kwargs)
109
+
110
+ def _do_remove_component(self, entity_id: EntityId, component: type[Component]):
111
+ entity, components = self._pop_from_pool(entity_id)
112
+ for _field in self.component_to_field_names[component]:
113
+ assert _field in entity.keys(), f"Field {component}/{_field} not in components: {components} ({entity_id=})"
114
+ entity.pop(_field)
115
+ new_components = [c for c in components if c != component]
116
+ self._check_components_against_pool(new_components, **entity)
117
+ self._add_to_pool(entity_id, new_components, **entity)
118
+
119
+ # other low-level methods
120
+
121
+ def _check_components_against_pool(self, components: list[type[Component]], **entity_fields):
122
+ assert len(cs := components) > 0, f"Entity has no components: {self.component_names}"
123
+ assert all(c in self.component_types for c in cs), f"Components '{cs}' not in {self.component_types}"
124
+ expected_fields = set()
125
+ for component in components:
126
+ for _field in self.component_to_field_names[component]:
127
+ expected_fields.add(_field)
128
+ assert _field in entity_fields, f"Entity doesn't have '{component}/{_field}'"
129
+ assert (extra := set(entity_fields) - expected_fields) == set(), f"Extra fields: {extra}; {expected_fields=}"
130
+
131
+ def _get_entity_pool(self, components: list[type]) -> Pool:
132
+ if (key := self._make_key(components)) not in self.pools:
133
+ fields = sum([self.component_to_field_names[component] for component in components], []) # merge fields
134
+ shapes = sum([self.component_to_shapes[component] for component in components], []) # merge shapes
135
+ dtypes = sum([self.component_to_dtypes[component] for component in components], []) # merge dtypes
136
+ self.pools[key] = Pool(fields, shapes, dtypes)
137
+ self.pool_to_components[self.pools[key]] = components
138
+ return self.pools[key]
139
+
140
+ def _make_key(self, components: list[type]) -> PoolKey:
141
+ key = 0
142
+ for component in components:
143
+ assert component in self.component_types, f"Component '{component.__name__}' not in {self.component_names}"
144
+ key |= self.component_to_bit[component]
145
+ return key
146
+
147
+ def _check_components(self, components: list[type]):
148
+ _dtypes = {"float32", "int32", "bool", "str"}
149
+ for component in components:
150
+ assert hasattr(component, "__dataclass_fields__"), f"Component '{component}' is not a dataclass"
151
+ for field_name, _field in component.__dataclass_fields__.items():
152
+ assert _field.type == np.ndarray, f"Field '{field_name}' of '{component=}' not an array: {_field}"
153
+ assert _field.metadata.keys() == {"shape", "dtype"}, _field.metadata
154
+ assert isinstance(_field.metadata["shape"], tuple), _field.metadata["shape"]
155
+ assert isinstance(fmd := _field.metadata["dtype"], str) and fmd in _dtypes, f"{fmd} not in {_dtypes}"
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.1
2
+ Name: microecs
3
+ Version: 0.1.0
4
+ Summary: MicroECS: Minimal Entity Component System (ECS) in python and numpy
5
+ Home-page: https://gitlab.com/meehai/microecs
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE.TXT
10
+ Requires-Dist: numpy>=2.2.0
11
+ Requires-Dist: loggez>=0.8
12
+
13
+ # MicroECS
14
+
15
+ Minimal (<200 LoC) Entity Component System in python and numpy. Examples also use raylib for rendering.
16
+
17
+ Usage:
18
+
19
+ - `pip install -r requirements.txt`
20
+ - Sandbox: `python main.py`
21
+ - Tests: `pytest test/`
22
+
23
+ There are only four primitives (bottom up): `Component`,`Pool`, `World` and `System`.
24
+
25
+ - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 4 dtypes only: `int32`, `float32`, `str` and `bool`.
26
+ - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
27
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other.
28
+ - `System` is an abstract class that queries the `World` for a subset of `Pools` matching some components. It updates the entities in these pools given some logic (e.g. collisions, motion physics or simply calls the drawing functions).
29
+
30
+ Few relevant concepts:
31
+
32
+ - `Pool` operates on array indices, while `World` operates on entity IDs (also integers). This allows seamless movement between pools while the high-level systems still working as intended.
33
+ - All mutable operations on `World` are lazy. These are: `add_entity`, `remove_entity`, `add_component`, `remove_component`. They are added to a command buffer which is only executed when calling `world.update()`.
34
+
35
+ Super simplified main loop structure:
36
+
37
+ ```python
38
+ import numpy as np
39
+ import raylib as rl
40
+ from microecs import World, Component, TickSystem
41
+
42
+ # components
43
+ class HasPosition(Component):
44
+ position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
45
+ class HasColor(Component):
46
+ color: np.ndarray = field(metadata={"shape": (4, ), "dtype": "int32"})
47
+
48
+ # systems
49
+ class RenderSystem(TickSystem):
50
+ def on_tick(self, world: World): # must override
51
+ for pool in world.query_and((HasPosition, HasColor)): # get all pools of entities having both
52
+ for position, radius, color in zip(pool.position, pool.color): # for each entity in this pool
53
+ DrawEntity(position, color)
54
+ class CollisionSystem(TickSystem)
55
+ def on_tick(self, world: World): # must override
56
+ ...
57
+
58
+ def main():
59
+ render_system = RenderSystem()
60
+ update_systems: list[TickSystem] = [CollisionSystem()]
61
+
62
+ world = World(components=[HasPosition, HasColor])
63
+ for _ in range(n_objects):
64
+ # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
65
+ world.add_entity(components=(HasPosition, HasColor), # tuple of components (types)
66
+ position=np.array((x, y), "float32"), # data as kwargs
67
+ color=np.array("black", dtype="int32"))
68
+
69
+ while not rl.WindowShouldClose():
70
+ world.update() # must be called at each tick so the lazy methods are processed and entities are updated
71
+ # update stuff...
72
+ _ = [system.on_tick(world=world) for system in update_systems]
73
+ # draw stuff, e.g. using raylib
74
+ rl.BeginDrawing()
75
+ rl.ClearBackground(rl.RAYWHITE)
76
+ rl.DrawFPS(rl.GetScreenWidth() - 100, 0)
77
+ render_system.on_tick(world=world)
78
+ rl.EndDrawing()
79
+ ```
@@ -0,0 +1,14 @@
1
+ LICENSE.TXT
2
+ README.md
3
+ setup.py
4
+ microecs/__init__.py
5
+ microecs/component.py
6
+ microecs/pool.py
7
+ microecs/system.py
8
+ microecs/utils.py
9
+ microecs/world.py
10
+ microecs.egg-info/PKG-INFO
11
+ microecs.egg-info/SOURCES.txt
12
+ microecs.egg-info/dependency_links.txt
13
+ microecs.egg-info/requires.txt
14
+ microecs.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ numpy>=2.2.0
2
+ loggez>=0.8
@@ -0,0 +1 @@
1
+ microecs
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ """setup.py -- note use setuptools==73.0.1; older versions fuck up the data files, newer versions include resources."""
2
+ from pathlib import Path
3
+ from setuptools import setup, find_packages
4
+
5
+ NAME = "microecs"
6
+ VERSION = "0.1.0"
7
+ DESCRIPTION = "MicroECS: Minimal Entity Component System (ECS) in python and numpy"
8
+ URL = "https://gitlab.com/meehai/microecs"
9
+
10
+ CWD = Path(__file__).absolute().parent
11
+ with open(CWD/"README.md", "r", encoding="utf-8") as fh:
12
+ long_description = fh.read()
13
+
14
+ REQUIRED_CORE = [
15
+ "numpy>=2.2.0",
16
+ "loggez>=0.8",
17
+ ]
18
+
19
+ setup(
20
+ name=NAME,
21
+ version=VERSION,
22
+ description=DESCRIPTION,
23
+ long_description=long_description,
24
+ long_description_content_type="text/markdown",
25
+ url=URL,
26
+ packages=find_packages(),
27
+ install_requires=REQUIRED_CORE,
28
+ extras_require={},
29
+ dependency_links=[],
30
+ license="MIT",
31
+ python_requires=">=3.12",
32
+ scripts=[], # cli/xxx in the future
33
+ )