Hello World (raylib)
examples/01-hello-world.py is the smallest complete microecs program: static balls in a box that turn red when they overlap, plus click-to-add-a-ball. Run it:
python examples/01-hello-world.py --n_objects 10
It has every moving part of a microecs app — components, systems, a World, and a main loop — and nothing else.
Components (data only)
Three data-only components. Each field is a numpy array declared with shape + dtype metadata; a default lets you omit it at spawn time.
from dataclasses import field
import numpy as np
import raylib as rl
from microecs import World, Component
class HasRadius(Component):
radius: np.ndarray = field(metadata={"shape": (1, ), "dtype": "float32", "default": None})
class HasPosition2D(Component):
position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32", "default": None})
class HasColor(Component):
color: np.ndarray = field(metadata={"shape": (4, ), "dtype": "int32", "default": np.array(rl.BLACK, "int32")})
Systems (behaviour)
A render system (per-entity, because raylib draws one circle at a time) and a collision system (fully vectorized). Both are just callables over the World — see Systems & Per-Entity Iteration for the patterns.
class RenderSystem:
def __call__(self, world: World):
qr = world.query(HasRadius, HasPosition2D, HasColor)
for position, radius, color in zip(qr.position, qr.radius, qr.color): # per-entity draw
rl.DrawCircle(int(position[0].item()), int(position[1].item()), int(radius.item()), color.tolist())
class CollisionSystem:
def __call__(self, world: World):
qr = world.query(HasPosition2D, HasRadius, HasColor)
collisions = self._get_collisions(qr.position.numpy(), qr.radius.numpy()) # (N, 1) bool
_red = np.array(rl.RED, "int32")[None].repeat(len(qr), axis=0)
_black = np.array(rl.BLACK, "int32")[None].repeat(len(qr), axis=0)
qr.color = np.where(collisions, _red, _black) # recolor all entities at once
RenderSystem must loop — there is no "draw all circles" call — so it zips the fields. CollisionSystem never loops over entities: the overlap check is one broadcasted numpy expression ((N,1,2) - (1,N,2) → (N,N) pairwise distances) and the recolor is a single np.where written back through qr.color.
World + main loop
Build the World with its component set, spawn some entities, then loop:
world = World(components=[HasRadius, HasPosition2D, HasColor])
for _ in range(args.n_objects):
world.add_entity(components=(HasRadius, HasPosition2D, HasColor),
position=np.array(position, "float32"), radius=np.array([radius], "float32"))
render_system, update_systems = RenderSystem(), [CollisionSystem()]
while not rl.WindowShouldClose():
world.update() # 1. flush last tick's spawns/despawns
if rl.IsMouseButtonPressed(rl.MOUSE_BUTTON_LEFT):
world.add_entity(...) # 2. lazy -> the new ball appears next tick
_ = [system(world=world) for system in update_systems] # 3. run systems (collision recolor)
rl.BeginDrawing()
rl.ClearBackground(rl.RAYWHITE)
render_system(world=world) # 4. draw
rl.EndDrawing()
The order that matters: world.update() first, so entities added last tick (or on click) are committed before the systems and the renderer read them. add_entity is lazy — a ball clicked this frame appears next frame. That deferral is exactly what keeps a query stable while a system runs over it (see Primitives — Mutation timing).
Full code
Everything above, in one file. Copy it, run it, click in the window.
#!/usr/bin/env python3
"""01-hello-world.py The basic hello world for ECS. Creates some static balls. You can add some with the mouse."""
from dataclasses import field
from typing import Callable
from argparse import ArgumentParser, Namespace
import random
import numpy as np
import raylib as rl
from loggez import loggez_logger as logger
from microecs import World, Component
Point2D = tuple[float, float]
DT = 1 / 100
MAX_SUBTICKS_PER_RENDER_TICK = 3
# components
class HasRadius(Component):
radius: np.ndarray = field(metadata={"shape": (1, ), "dtype": "float32", "default": None})
class HasPosition2D(Component):
position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32", "default": None})
class HasColor(Component):
color: np.ndarray = field(metadata={"shape": (4, ), "dtype": "int32", "default": np.array(rl.BLACK, dtype="int32")})
# systems
class RenderSystem:
def __call__(self, world: World):
qr = world.query(HasRadius, HasPosition2D, HasColor)
for position, radius, color in zip(qr.position, qr.radius, qr.color):
rl.DrawCircle(int(position[0].item()), int(position[1].item()), int(radius.item()), color.tolist())
class CollisionSystem:
def __call__(self, world: World):
qr = world.query(HasPosition2D, HasRadius, HasColor)
_red = np.array(rl.RED, dtype="int32")[None].repeat(len(qr), axis=0)
_black = np.array(rl.BLACK, dtype="int32")[None].repeat(len(qr), axis=0)
collisions = self._get_collisions(qr.position.numpy(), qr.radius.numpy())
qr.color = np.where(collisions, _red, _black)
def _get_collisions(self, positions: np.ndarray, radii: np.ndarray) -> np.ndarray:
dists = np.sqrt(((positions[:, None] - positions[None])**2).sum(-1)) # (N, 1, 2) - (1, N, 2) -> ... -> (N, N)
radii_sum = (radii[None] + radii[:, None])[..., 0] # (N, N)
collisions_nn = (dists < radii_sum) - np.eye(len(positions)) # (N, N)
res = (collisions_nn.sum(axis=1) > 0)[..., None] # (N, 1)
return res
def main(args: Namespace):
rl.InitWindow(800, 800, b"Entity Component Style + SoA (batched)")
scene_size = (600, 600)
render_system = RenderSystem()
update_systems: list[Callable] = [CollisionSystem()]
world = World(components=[HasRadius, HasPosition2D, HasColor])
for _ in range(args.n_objects):
radius = random.randint(5, 20)
position = random.randint(radius, scene_size[0] - radius), random.randint(radius, scene_size[1] - radius)
world.add_entity(components=(HasRadius, HasPosition2D, HasColor), position=np.array(position, "float32"),
radius=np.array([radius], "float32"),)
while not rl.WindowShouldClose():
world.update()
if rl.IsMouseButtonPressed(rl.MOUSE_BUTTON_LEFT):
radius = random.randint(5, 20)
position = rl.GetMousePosition().x, rl.GetMousePosition().y
world.add_entity(components=(HasRadius, HasPosition2D, HasColor), position=np.array(position, "float32"),
radius=np.array([radius], "float32"),)
_ = [system(world=world) for system in update_systems]
rl.BeginDrawing()
rl.ClearBackground(rl.RAYWHITE)
rl.DrawFPS(rl.GetScreenWidth() - 100, 0)
rl.DrawRectangleLinesEx((0, 0, *scene_size), 2, rl.BLACK)
render_system(world=world)
rl.EndDrawing()
logger.log_every_s(f"FPS: {rl.GetFPS()}", "DEBUG")
if __name__ == "__main__":
parser = ArgumentParser()
parser.add_argument("--n_objects", type=int, default=10)
main(parser.parse_args())
Next
- Moving & Colliding Balls — adds velocity, wall bounce, and a fixed-
dtphysics clock. - Serialization (save & load) — save/load the whole world to JSON via
to_dict.