Usage

What is it? (And an Example)

Instead of requiring inheritance, one can specify which attributes to operate on and any object that has those attributes is then operated on. That is, if a Player has an x and y attribute and an (unrelated) Enemy class has an x and y attribute you can have them both influenced by a World object. This avoids complicated inheritance hierarchies.

Here is a small example to illustrate the above:

from punyecs import World, requirements, Trait, give_traits

w = World()
Pos = Trait(x=0.0, y = 0.0)

@give_traits(Pos)
class Player:
    pass

@give_traits(Pos, override={"x": 1.0, "y": 1.0)
class Enemy:
    pass

@requirements(w, Pos)
def move(e, dt):
    e.x += 0.1 * dt
    e.y += 0.1 * dt

player = Player()
enemy = Enemy()
w.add(player)
w.add(enemy)

w.update(1)
print(player.x) # Prints 0.1
print(player.y) # Prints 0.1

print(enemy.x) # Prints 1.1
print(enemy.y) # Prints 1.1

Be sure to read the comments! Observe the move function operates on both Player and Enemy even though they are unrelated.

Note

We pass 1 to w.update(...) because in the video game context we virtually always want to pass some change in time per frame of the object. Passing dt is crucial in the context of game development. For example, in Pygame, a game loop might look something like:

...
dt = 0
...
while True:
    w.update(dt)
    ...
    dt = clock.tick(60) / 1000

Fine Grained Control: Querying

Returning to the example above, we may want various enemies to move like above but instead want to allow controller input for the player object. We can avoid influencing the player object by using the c “constraint former” and exclude objects that are the player. The function move becomes:

@requirements(w, Pos, subject_to=c.isnot(player))
def move(e, dt):
    e.x += 0.1 * dt
    e.y += 0.1 * dt

Then after every w.update(1) the player object will still remain at x=0.0, y=0.0.

An Alternative: Exclude Based on a Value

It could be that you have multiple characters that are controllable that are not the player. You could give them an attribute controller: bool = True. Then exclude an object if it has the controller attribute and the controller attribute is True with the following subject_to constraint:

An Extension: Exclude Based on Attribute Predicates

Excluding based on the singular value of an attribute still might not be enough control. Suppose we have high level entities and low level entities and that most entities (except high level entities) are affected by gravity. More precisely, suppose high level enemies are those that have an attribute level > 50. Gravity typically operates on the y attribute so we may write something like:

@requirements(w, YAxis, subject_to=c.level > 50)
def gravity(e):
    e.y -= GRAVITY

Excluding Based on Attributes

We may not care what value the attribute is and simply want to exclude the object if it has that attribute. For instance, we may have many different kinds of creatures. Most can follow the usual movement update function, but some creatures have a wiggle attribute. wiggle could be a Boolean, or even something more sophisticated like a function that describes how the creature wiggles. We can use the ex_attr function to create a subject_to constraint as follows:

from punyecs import World, requirements, Trait, give_traits, c, ex_attr

w = World()
Pos = Trait(x=0.0, y=0.0)

@give_traits(Pos)
class Player:
    pass

@give_traits(Pos, override={"x": 1.0, "y": 1.0})
class Enemy:
    pass

@give_traits(Pos, override={"x": 3.0, "y": 3.0})
class Wiggler:
    wiggle: lambda x: x + 2

@requirements(w, Pos, subject_to=ex_attr(c, "wiggle"))
def move(e, dt):
    e.x += 0.1 * dt
    e.y += 0.1 * dt

@requirements(w, Pos, subject_to=has_attr(c, "wiggle"))
def wiggle(e, dt):
    e.x = wiggle(e.x) * dt
    e.y = wiggle(e.y) * dt


player = Player()
enemy = Enemy()
wiggler = Wiggle()
w.add(player)
w.add(enemy)
w.add(wiggler)

w.update(1)
print(player.x) # Prints 0.1
print(player.y) # Prints 0.1

print(enemy.x) # Prints 1.1
print(enemy.y) # Prints 1.1

print(wiggler.x) # Prints 5.0
print(wiggler.y) # Prints 5.0

Refinements on Updating: one_shot

update is often used in ECS, but what if you do not want to update every group? Furthermore, update is often in the gameloop, but what if you need to invoke a function on a bunch of objects outside the gameloop? This is where the one_shot decorator comes into play.

Consider this simple setup:

from punyecs import World, one_shot, Trait, give_traits, c

Pos = Trait(x=0, y=0, z=0)

@give_traits(Pos, exclude={"z"})
class Player:
    pass

@give_traits(Pos)
class Enemy:
    pass

@one_shot(w, Pos)
def inc_x(e):
    e.x += 1

e1 = Enemy()
e2 = Enemy()
p1 = Player()

w.add(e1)
w.add(e2)
w.add(p1)

What have we done? We’ve added one player and two enemies to a world w. Furthermore, we’ve added a @one_shot decorator. Thus, whenever we call inc_x without arguments it updates every enemy character (more precisely any object with x, y, and z attributes). Player remains the same because it lacks the z component. Thus, inc_x() can be placed anywhere and it will update a specific group of entities. So

>>> e1.x, e2.x, p1.x
(0, 0, 0)
>>> inc_x()
>>> e1.x, e2.x, p1.x
(1, 1, 0)