Skip to content

Instantly share code, notes, and snippets.

@jonschoning
Forked from mvanga/ecs-annotated.py
Created November 8, 2018 21:32
Show Gist options
  • Save jonschoning/b6750e0edbae0f4abf9857f8f5b67e22 to your computer and use it in GitHub Desktop.
Save jonschoning/b6750e0edbae0f4abf9857f8f5b67e22 to your computer and use it in GitHub Desktop.
A Python3 implementation of an entity-component-system in under 50 lines code.
import uuid
import json
# Returns a python dictionary given a file containing a JSON-based
# component definition. Every definition *must* contain a 'type'
# and 'schema' field inside a top-level dictionary. Here is an
# example of a simple schema file that defines a 'meta' component
# containing a 'name' field.
#
# // Contents of meta.json
# {
# "type": "meta",
# "schema": {
# "name": ""
# }
# }
def Component(filename):
with open(filename + '.json', 'r') as f:
return json.load(f)
# The Entity class defines a single entity in our system composed
# of multiple components. At its heart, an Entity object is simply
# an empty bucket associated with a unique ID.
#
# This class provides exactly one relevant function: a way to
# attach a component dictionary to the entity. This function
# simply takes the dictionary loaded using the Component()
# function above, instantiates a new class on-the-fly, and
# sets a class attribute. Here is an example of how to attach
# our 'meta' object from above to an entity:
#
# >>> e = Entity()
# >>> e.__dict__
# {'id': '5afec678-4c4e-44a9-be74-8764f62b61fd', 'components': []}
# >>>
# >>> e.attach(Component('meta'))
# >>> pprint.pprint(e.__dict__)
# {'components': ['meta'],
# 'id': '5afec678-4c4e-44a9-be74-8764f62b61fd',
# 'meta': <ecs.Component object at 0x108a1e400>}
# >>> e.meta.name = 'Player'
#
# The attach() function also takes a namespace argument for
# renaming longer component names when creating the attribute:
#
# >> e = Entity()
# >> e.attach(Component('meta'), namespace='m')
# >> e.m.name = 'Player'
#
# We also do some housekeeping: the 'components' object variable
# keeps track of all components attached to this entity:
#
# >>> e = Entity()
# >>> e.attach(Component('meta')
# >>> e.components
# ['meta']
#
# Note that namespacing maintains the original class name inside
# the components array:
#
# >>> e = Entity()
# >>> e.attach(Component('meta'), namespace='m')
# >>> e.components
# ['meta']
#
# Finally, we also track two reverse mappings: (i) to go from a
# given entity ID to an entity object, and (ii) to go from a
# component type to a list of entity objects. Both these are
# implemented as class methods so no instantiated object is needed
# to retrieve them.
#
# The first is useful in many cases where you want to reference a
# particular entity and use it in a system. For example, an attack
# component can simply store the ID of the entity being attacked.
# The damage-calculation system simply looks up the entity using
# this reverse index.
#
# target = Entity.get('47d78b7e-8c5c-417b-8f46-be0de7c0b62d')
#
# The second index is used in implementing systems that deal with
# all entities having a particular component attached. For example,
# a MovementSystem can easily grab all entities containing a
# movement component:
#
# entities = Entity.filter('movement')
#
class Entity(object):
eindex = {} # Index mapping entity IDs to entity objects
cindex = {} # Index mapping component names to entity objects
def __init__(self):
self.id = str(uuid.uuid4())
self.components = []
self.eindex[self.id] = self # Assumes ID's never collide
def attach(self, component, namespace=None):
# Append component name to list of components
self.components.append(component['type'])
# Create a raw 'Component' object based on the JSON schema
key = namespace if namespace else component['type']
self.__dict__[key] = type('Component', (), component['schema'])()
# Add to component index
if component['type'] not in self.cindex:
self.cindex[component['type']] = []
self.cindex[component['type']].append(self)
@classmethod
def filter(cls, component):
entities = cls.cindex.get(component)
return entities if entities is not None else []
@classmethod
def get(cls, eid):
return cls.eindex.get(eid)
# The final class that completes our ECS implementation is the System
# class for defining systems that update the world.
#
# The class is designed as a simple pub-sub system where each system
# decides which game events it wants to be notified of. This is done
# using the `subscribe()` method, which takes an event type (string)
# as a parameter.
#
# Each System object contains its own list of pending events and
# a class method, inject(), allows for injecting game events into the
# entire set of systems. This function it basically looks up all
# subscribers of that given event and simply appends the event into
# each of their event lists. Note that at this point, the event has
# not yet been handled, but simply registered as pending by appending
# into each subscriber's events list.
#
# The reason the inject() function is a class method and not an object
# level method is so that external parts of the game, such as the
# input system, can freely inject events into systems.
#
# Finally, the update() function can be overridden by subclasses to
# define their own custom game loop logic. The `pending()` function
# can be used here to retrieve (and clear) all pending events in the
# system, and the Entity.filter() function can be called to get a
# filtered list of entities relevant to this system. Here is a very
# simple example of how to implement a system:
#
# class MovementSystem(System):
# def __init__(self):
# super().__init__()
# self.subscribe('move')
#
# def update(self):
# # Get list of pending events and clear current event queue
# events = self.pending()
# # Filter entities by type. Fast because we use component_index.
# entities = Entity.filter('movement')
#
# # Do something here that modifies state or generates events
#
# # Inject any new events at the end
# self.inject({'type': 'move', 'data': randstr(10)})
#
# Note that above, the update() function first gets all pending events,
# runs some processing code, and finally, if needed, emits a new set of
# events that get picked up in the next round by other systems.
#
# Note that there is also no restriction on which entities you access in
# the update() function inside a System object. Above, I show an example
# of picking entities having a single component ('movement'), but you can
# instead easily choose to pick out different sets, apply any kind of
# set operators, etc. before arriving at the entities you need and
# continuing with the loop.
#
# Here's a made-up example that illustrates how to do this using Python's
# built-in set operations:
#
# def update(self):
# ent_with_move = set(Entity.filter('movement'))
# ent_with_pos = set(Entity.filter('position'))
# ent_with_ai = set(Entity.filter('ai'))
# entities = list((ent_with_move& ent_with_pos) | ent_with_ai)
#
# # Rest of the loop goes here and uses 'entities'
#
class System(object):
systems = []
subscriptions = {}
def __init__(self):
self.events = []
self.systems.append(self)
def subscribe(self, event_type):
if event_type not in self.subscriptions:
self.subscriptions[event_type] = []
self.subscriptions[event_type].append(self)
def pending(self):
# Get pending events and clear queue
ret = self.events
self.events = []
return ret
@classmethod
def inject(cls, event):
# All events must be dicts with a 'type' field
event_type = event['type']
if event_type not in cls.subscriptions:
return
for subscriber in cls.subscriptions[event_type]:
subscriber.events.append(event)
def update(self):
pass
@classmethod
def update_all(cls):
for system in cls.systems:
system.update()
{
"type": "meta",
"schema": {
"name": ""
}
}
@jonschoning
Copy link
Author


It only supports 1-component systems though, right? No proper entity composition?

I'm not sure this would actually work as an ECS, then. Most systems will need to work on at least a couple of components in my experience. The one I use handles full expressions of composition, eg HAS(Position AND Speed), NOT_HAS(Dead OR Offline)


Sure it does! There's no restriction on which entities you access in the update() function inside a System object. In my annotations I show an example of picking entities having a single component ('movement'), but you can instead easily choose to pick out different sets, apply any kind of set operators, etc. before arriving at the entities you need and continuing with the loop.

Here's a totally random example of how you can do this with Python's built-in set operations:

def update(self):
    entities_with_movement = set(Entity.filter('movement'))
    entities_with_position = set(Entity.filter('position'))
    entities_with_ai_control = set(Entity.filter('ai'))
    entities = list((entities_with_movement & entities_with_position) | entities_with_ai_control)

    # Rest of the loop

And of course you can factor this out into separate methods to make things easier, but I wanted to keep things as minimal as possible and re-use as much language-level functionality as possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment