Package: tinyecs
tinyecs TutorialThis tutorial is also available by runningpydoctinyecs.tutorialThe demo developed within this document can be run upfront withpython-mtinyecs.tutorialAboutECS stands for Entity Component System, and it is a programming paradigm that
differs from the well known OO.During my research I stumbled overthis article seriesand after reading part 2 and 3, I decided to implement an ECS myself, well
aware thatesperis a solid and long existing implementation, but I wanted
to see how to implement it myself.I'm not trying to sell you an ECS by explaining the problems of multiple
inheritance in game programming.  There are articles out there that do this in
much detail.  If you're here, you're already interested in the concept, so
this will be a tutorial, implementing a bunch of bouncing sprites, which
should show you all the API you need to know.Setting up the environmentI assume, you have a current version of python up & running on your computer
already, and you have a basic understanding of installing packages withpip.I suggest to create a new project in a virtual environment, so your main
install stays clean from random dependency packages.  There are tutorials on
the web and on youtube how to do that on your platform, if you're unfamiliar
with the concept.tinyecsitself is just a library to manage abstract entities.  It doesn't
rely on pygame-ce and could equally be used with other libs likepygletor
even console applications like roguelikes.  It comes with a few preconfigured
components though, which were developed with pygame-ce, so this tutorial will
require to install that.pipinstallpygame-ceIf you're runnning pygame already, I recommend to switch through new pygame
community edition, since most core developers migrated to this project.  This
tutorial will work with bothpygame-ceandpygame, since it doesn't make
use of any updated features.Note: if anybody can tell me, how to depend on either pygame-ce or pygame,
but be happy with whatever is already there in pyproject.toml, please tell
me.Next, of course you need tinyecs.  You're probably reading this tutorial after
cloning the project from github, or reading it directly on the site.For completeness, the github project page ishttps://github.com/dickerdackel/tinyecsThe latest development version (unstable) can be installed from there withpipinstallgit+https://github.com/dickerdackel/tinyecsOr you can install the stable version directly from pypipipinstalltinyecsThe codeNo, not yet.  First......Some conceptsTo ease the code, I usuallyimport tinyecs as ecs.  If you like to import it
in its normal namespace, please adapt all examples here accordingly.EntitiesAn entity is just an identifyer for a "thing" in your world.  The examples
here will always call thiseid.  It's role is about the same as an object in
game written in an object oriented style, e.g. a bonus coin.In contrast to OO development, the entity is really just an ID.  Callingecs.create_entitywill generate a random uuid4, but since entities are
actually keys into a dict internally, any hashable type will work too.  It
makes e.g. sense for the player entity to be quickly accessible by using
'player' as ID.Entities are created and removed witheid=ecs.create_entity()# gives you a uuid4 IDplayer=ecs.create_entity('player')# gives you 'player' as IDecs.remove_entity(eid)If an entity is removed, all references to its components are also dropped, but...  See "IMPORTANT" below at "Components"ComponentsTo give life to this entity, "components" can be attached to it.  A component
is in its most basic form any data record.  Like a sprite, a position vector,
a record containing statistics like hitpoints or armor.Components are addressed by a tag which is calledcid(short for component
ID) during this tutorial.  Note, that there is also a component ID for the
actual object, but I have yet to find a use for that, and sincetagis a
very general term,cidstuck with me and I always use it for this purpose.Components are added and removed to and from an entity like this:ecs.add_component(eid,'tag',object)ecs.remove_component(eid,'tag')IMPORTANT:An entity that e.g. has a sprite in a global sprite group cannot be released
by the ECS, since it doesn't know anything about the interface of that sprite.
The ECS and the sprite group are two different systems.To deal with that issue, you can add a methodshutdown_to your component,
which will be called when the component is removed from the ECS.# Remove a instance of a pygame.sprite.Sprite subclass# from all sprite groupsdefshutdown_(self):self.kill()SystemsDifferent from writing OO, the component itself doesn't have any code (in the
purest form of ECS), which is wheresystemscome into play.A system is a function that works on a fixed set of components from a single
entity.  system functions are not called directly, but with the help of
tinyecs'run_systemfunction.Imagine a saucer entity, with the componentssprite,position, andmomentum. It should appear on the screen at position (50, 50) and should fly
diagonally across the screen until it disappears off-screen.eid=ecs.create_entity()ecs.add_component(eid,'sprite',Sprite('saucer.jpg'))ecs.add_component(eid,'position',pygame.Vector2(50,50))ecs.add_component(eid,'momentum',pygame.Vector2(1024,768).normalized()*100)To apply the momentum to the position, a functionmomentum_systemis needed.
To apply the position to the rect of the sprite, a functionsprite_systemwill be used.  To run e.g. themomentum_system, put the following into the
game loop:ecs.run_system(dt,momentum_system,'momentum','position')When the game loop passes this call,run_systemwill find all entities that
have both componentsmomentumandpositionand pass these together with
deltatimedtand the entity ID into themomentum_systemfunction.Writing themomentum_systemis easy:defmomentum_system(dt,eid,momentum,position):position+=momentum*dtThat's it.  Now every time run system is called for themomentum_system, all
objects with these two components will have their position updated.Note:If you need to put additional arguments from the game loop into the system,
use**kwargs.run_systemwill pass allcidsas*argsinto your custom
function, and all additional keyword args from therun_systemcall will
passed in after the components.  The following will be explained in the actual
script later.ecs.run_system(dt,deadzone_system,'position',deadzone=WORLD)So the system is basically theupdate(dt)function in an OO driven game.At this point you might get the feeling, that you will have a very large list
of systems in a big block in your game loop, and that's exactly right.  You
either hate that, which is fine, so the option is either to go back to an OO
development model, or chose a more OO driven ECS implementation like e.g. the
long establishedesper.There are some alternatives still.you can register systems with the required cids at the start of your
program and call a single function in your game loop:ecs.run_all_systems(dt)To give you a more fine grained control over what systems run together, the
concept ofdomainswas introduced.  A system domain is simply a group of
systems that are bundled under a common name.  We won't make use of that in
this tutorial, please consult the embedded docs for more detail.pydoctinyecs.add_system_to_domainpydoctinyecs.run_domainpydoctinyecs.remove_system_from_domainif you prefer to have normal classes as components and have the
functionality in there, just create a short system that calls the update
method of your component.  You still need that block ofrun_systemor above
shortcuts in your game loop though.defcall_update_system(dt,eid,component):component.update(dt)Finally code!We now write a simple demo of colorful flying boxes.If the user holds the mouse, a bunch of rectangular sprites of random size are
released at mouse position, drifting in random directions until the mouse is
released again.Boxes that drift off screen will be automatically removed from the system.tinyecs comes with a sprite that has a shutdown method, and also systems that
handle motion and screen boundaries, but we'll write these here ourselves, so
you get a feel for how things work.A basic pygame game loopThis is a basic pygame boilerplate game loop.  It could be shortened, but this
is a good start for stateless test scripts and experiments.  It already has a
sprite group added.importpygameTITLE='pygame minimal template'SCREEN=pygame.Rect(0,0,1024,768)FPS=60DT_MAX=3/FPSpygame.init()pygame.display.set_caption(TITLE)screen=pygame.display.set_mode(SCREEN.size)clock=pygame.time.Clock()group=pygame.sprite.Group()running=Truewhilerunning:dt=min(clock.tick(FPS)/1000.0,DT_MAX)foreinpygame.event.get():matche.type:casepygame.QUIT:running=Falsecasepygame.KEYDOWNife.key==pygame.K_ESCAPE:running=Falsescreen.fill('black')...group.update(dt)...group.draw(screen)pygame.display.flip()runtime=pygame.time.get_ticks()/1000fps=clock.get_fps()pygame.display.set_caption(f'{TITLE}-{runtime=:.2f}{fps=:.2f}')pygame.quit()The sprite class:fromrandomimportrandom,choicefrompygame.colordictimportTHECOLORSclassDemoSprite(pygame.sprite.Sprite):def__init__(self,*groups):# Make sure, the sprite is properly initialized for sprite groupssuper().__init__(*groups)# Size is random between 8 and 32 pixels in both dimensionsw,h=random()*24+8,random()*24+8# Just set up a basic pygame sprite instanceself.image=pygame.Surface((w,h))self.image.fill(choice(list(THECOLORS)))# Note that we don't set the position!self.rect=self.image.get_rect()defshutdown_(self):print(f'{self}removed from sprite groups')self.kill()Creating an entityWe define a function that creates the full entity.  If you're coming from OO,
this mostly resembles the__init__of an entity class.frompygameimportVector2defcreate_box_entity(position):# Give sprites a random speed between 0 and +/-50px/sdx,dy=random()*100-50,random()*100-50e=ecs.create_entity()ecs.add_component(e,'position',Vector2(position))ecs.add_component(e,'momentum',Vector2(dx,dy))ecs.add_component(e,'sprite',DemoSprite)This function will be called if the mouse button is pressed to generate a
spray of new sprites at the given mouse position.The systemsWe already wrote themomentum_systemabove, but here again for completeness:# Make the world rect a bit larger than the screen, so sprites don't suddenly# disappear at the screen edge.  Note: rect.scale_by is a pygame-ce# addition.WORLD=SCREEN.scale_by(1.25)defmomentum_system(dt,eid,momentum,position):"""Add a delta time scaled momentum to the position."""position+=momentum*dtdefsprite_position_system(dt,eid,sprite,position):"""Apply the position to the rect of the sprite for the sprite group"""sprite.rect.center=positiondefdeadzone_system(dt,eid,position,*,world):"""Kill sprites that move off screen"""ifworld.collidepoint(position):returnecs.remove_entity(eid)Releasing entities on clickThe lines marked with>are additions to the game loop template all above.>emitting=Falserunning=Truewhilerunning:dt=min(clock.tick(FPS)/1000.0,DT_MAX)foreinpygame.event.get():matche.type:casepygame.QUIT:running=False>casepygame.MOUSEBUTTONDOWNife.button==1:>emitting=True>casepygame.MOUSEBUTTONUPife.button==1:>emitting=Falsecasepygame.KEYDOWNife.key==pygame.K_ESCAPE:running=False>ifemitting:>for_inrange(10):>create_box_entity(pygame.mouse.get_pos())screen.fill('black')Running the systems>ecs.run_system(dt,momentum_system,'momentum','position')>ecs.run_system(dt,deadzone_system,'position',world=WORLD)>ecs.run_system(dt,sprite_position_system,'sprite','position')screen.fill('black')># group.update(dt)  # Not neededgroup.draw(screen)pygame.display.flip()That's it.Here's the full script with the functions, classes, imports above also merged
in.  Additionally, a sprite counter was added to the title bar and the print
from theshutdown_of the sprite class was commented out.importpygameimporttinyecsasecsfromrandomimportrandom,choicefrompygameimportVector2frompygame.colordictimportTHECOLORSTITLE='pygame minimal template'SCREEN=pygame.Rect(0,0,1024,768)FPS=60DT_MAX=3/FPS# Make the world rect a bit larger than the screen, so sprites don't suddenly# disappear at the screen edge.  Note: rect.scale_by is a pygame-ce# addition.WORLD=SCREEN.scale_by(1.25)classDemoSprite(pygame.sprite.Sprite):def__init__(self,*groups):# Make sure, the sprite is properly initialized for sprite groupssuper().__init__(*groups)# Size is random between 8 and 32 pixels in both dimensionsw,h=random()*24+8,random()*24+8# Just set up a basic pygame sprite instanceself.image=pygame.Surface((w,h))self.image.fill(choice(list(THECOLORS)))# Note that we don't set the position!self.rect=self.image.get_rect()defshutdown_(self):# print(f'{self} removed from sprite groups')self.kill()defcreate_box_entity(position,sprite_group):# Give sprites a random speed between 0 and +/-50px/sdx,dy=random()*100-50,random()*100-50e=ecs.create_entity()ecs.add_component(e,'position',Vector2(position))ecs.add_component(e,'momentum',Vector2(dx,dy))ecs.add_component(e,'sprite',DemoSprite(sprite_group))defmomentum_system(dt,eid,momentum,position):"""Add a delta time scaled momentum to the position."""position+=momentum*dtdefsprite_position_system(dt,eid,sprite,position):"""Apply the position to the rect of the sprite for the sprite group"""sprite.rect.center=positiondefdeadzone_system(dt,eid,position,*,world):"""Kill sprites that move off screen"""ifworld.collidepoint(position):returnecs.remove_entity(eid)pygame.init()pygame.display.set_caption(TITLE)screen=pygame.display.set_mode(SCREEN.size)clock=pygame.time.Clock()group=pygame.sprite.Group()emitting=Falserunning=Truewhilerunning:dt=min(clock.tick(FPS)/1000.0,DT_MAX)foreinpygame.event.get():matche.type:casepygame.QUIT:running=Falsecasepygame.MOUSEBUTTONDOWNife.button==1:emitting=Truecasepygame.MOUSEBUTTONUPife.button==1:emitting=Falsecasepygame.KEYDOWNife.key==pygame.K_ESCAPE:running=Falseifemitting:for_inrange(10):create_box_entity(pygame.mouse.get_pos(),group)ecs.run_system(dt,momentum_system,'momentum','position')ecs.run_system(dt,deadzone_system,'position',world=WORLD)ecs.run_system(dt,sprite_position_system,'sprite','position')screen.fill('black')group.draw(screen)pygame.display.flip()runtime=pygame.time.get_ticks()/1000fps=clock.get_fps()sprites=len(group)pygame.display.set_caption(f'{TITLE}-{runtime=:.2f}{fps=:.2f}{sprites=}')pygame.quit()Available components and systemsNow that we've written 3 basic systems ourself, let's have a look at the
pygame(-ce) components that are currently included with tinyecs.Please note that while tinyecs should be API stable by now, the bundled
components intinyecs.compsysarenot.  I'm still trying to get a feel for
some of the features I want and how I want to access them.Components and systems are only listed here.  Please check the embedded docs
for details and look at the code to decide if you want to make use of these
systems or if you'd rather roll your own.I usually import these together with tinyecs like this:importtinyecsasecsimporttinyecs.compsysasecscclass ESprite(pygame.sprite.Sprite)A sprite class that already has ashutdown_methodclass EVSprite(pygame.sprite.Sprite)A sprite class where the image attribute is a property.  You can pass animage_factoryfunction to the init that will generate images when thegroup.drawfunctions runs over it.class RSAImageARotated,Scaled andAlpha transparent image.UNSTABLE!  DON'T RELY ON THIS YET!def dead_system(dt, eid, dead)Sometimes it is useful to not remove a sprite immediately from the system.
Instead, you can add a component tagged e.g. 'dead', and later reap all
entities marked with that tag.def deadzone_system(dt, eid, world, position, *, container)Basically the function created in this tutorial, with one addition.Not every sprite is run through this system, only sprites that have aworldcomponent.  That way, e.g. enemy sprites waiting off screen to be activated
will not get removed.worldcan be anything, I usually make it a boolean, but the existence of
that component alone is sufficient.ecs.add_component(e,'world',True)def extension_system(dt, eid, extension)Will be removed.  tinyecs installs pgcooldown as a requirement and that comes
with theCronDclass which does the same and more.def force_system(dt, eid, force, momentum)Applies (adds) a constant force to a momentum.def lifetime_system(dt, eid, lifetime)Kills the entity once lifetime has run out.  Expectslifetimeto be an
instance ofpgcooldown.Cooldowndef momentum_system(dt, eid, momentum, position)The same as we wrote in the tutorial above.def mouse_system(dt, eid, mouse, position)Update a position component with the position of the mouse cursor.def scale_system(dt, eid, scale, momentum) / def friction_system(dt, eid, scale, momentum)Apply friction to a momentum.In contrast to a force_system, which adds a directional vector to the
momentum, the friction scales the momentum by a factor.  It can also be
greater 1.friction_systemis an alias toscale_system.def sprite_system(dt, eid, sprite, position)The same as we wrote above, apply the position tosprite.rect.center.def wsad_system(dt, eid, wsad, position)An example system to control a playear with thewsadkeys. This was just a
proof of concept, but I'm currently using it, so perhaps it will stay.