Skip to content

Instantly share code, notes, and snippets.

@1bardesign
Last active June 2, 2020 13:42
Show Gist options
  • Save 1bardesign/e9ce518611ff25ff8e969115028d53fc to your computer and use it in GitHub Desktop.
Save 1bardesign/e9ce518611ff25ff8e969115028d53fc to your computer and use it in GitHub Desktop.
Hopefully straightforward writeup on the design of my lua-side "ecs", which I should find a better name for.

Components:

  • Managed by systems, self-contained as much as possible
  • May have references to components in other systems that they're dependent on (eg a sprite might want a position, though it may accept any vector (shared or unshared) for that position)
  • Generally take those references in their constructor
  • Generally designed to have a nice oop-y method interface, rather than be "just" data - eg sprite:set_frame(fx, fy), animation:reset() etc

Systems:

  • Do stuff with their set of components
  • Have "final say" when it comes to those components, including jettisoning them into space (most important for stuff like sorting lists of sprites on z, keeping invisible in a separate list, etc)
  • Are where component addition/removal happens - no entity required
  • There's a base system with support for deferred removal (uses push/pop/with(f) style and removes everything once we hit the base level) to prevent anything funny happening when removing components mid-iteration

Entities:

  • used for managing the setup of components
  • entity:new() takes a list of systems so you can have multiple worlds going at once
  • entity:add_component(system, name, args) does what you'd hopefully expect, creating a new component in system, naming it name as far as this entity is concerned, and passing args through to the system component constructor (should probably use varargs but all of them take a table anyway)
  • entity:c(n) and entity:get_component(name) are aliases
  • also have an event system built in, as it's handy to be able to say player:e("hurt") rather than have a dedicated component to bother with

Kernel:

  • have systems and tasks
  • can have "other global stuff" attached as part of its table, as it's visible to all systems
  • tasks are single functions in an ordered collection by name
  • systems have an ordering (implicitly the order they were added if not defined)
  • when a system is added, it gets a register callback with the kernel
  • systems generally add tasks for update/draw here as well as listening to any global pubsub stuff
  • base system also has a default "register" implementation that adds update/draw tasks to the kernel if those functions exist in the system

Example:

Deep Sky's game state kernel setup looks like this (m is the state machine for the game state)

m.k = kernel:new({
		camera = world.cam,
	})
	:add_system("sprites", sprite_system:new(render_to_world), 3)
	:add_system("tilemap_sprites", sprite_system:new(render_to_world_shader), 3)
	:add_system("sprites_overlay", sprite_system:new(render_to_world), 5)
	:add_system("tilemap_sprites_overlay", sprite_system:new(render_to_world_shader), 5)
	:add_system("hud_sprites", sprite_system:new(render_to_screen), 20)
	:add_system("physics", physics_system:new())
	:add_system("beat", beat_system:new(0.75))
	:add_system("behaviour", behaviour_system:new())
	:add_system("behaviour_timeless", behaviour_system:new())
	:add_system("animation", animation_system:new())

Update for the entire thing:

m.k:update(dt)

Parallax foreground entity constructor, has an example of the behaviour system being a cheeky catch-all

return function(systems, args)
	local asset = args.asset
	if not asset then
		return nil
	end

	local e = entity:new(systems)

	--set up the sprite
	local s = e:add_component("tilemap_sprites_overlay", "sprite", asset)
	local f = args.frame or love.math.random(6)
	s.pos:vset(args.pos)
	s.frame:sset(
		math.floor(f % 2),
		math.floor(f / 2)
	)
	s.framesize:sset(192/512, 128/512)
	s.size:sset(6, 4)
	if args.flip then
		s.x_flipped = true
	end
	s.z = 10 + args.parallax_factor * 10

	--add a behaviour component
	--this just gets its update method called each frame
	--we use it to hold a reference to the sprite created above,
	--and modify its offset based on distance from the camera
	e:add_component("behaviour", "parallax_thing", {
		sprite = s,
		cam = systems.sprites_overlay.camera,
		_tv = vec2:zero(),
		mw = args.world.mw,
		fac = args.parallax_factor,
		update = function(self, dt)
			self._tv:vset(self.sprite.pos):vsubi(self.cam.pos)
			self._tv.x = math.wrap(self._tv.x, -self.mw * 0.5, self.mw * 0.5)
			self.sprite.offset:vset(self._tv):smuli(self.fac)
		end
	})

	return e
end

Benefits:

  • can have more than one of a single component type per entity
  • can have free-floating components without need for an entity (eg a static sprite, and maybe an animation managing the sprite, or a static collider)
  • systems can manage their own data for performance
  • 99% of systems can do a nice dumb loop over their components to update
  • those that need it can do threading or store components as ffi objects or be implemented in c++ under the hood or whatever
  • all your throwaway game code can go in the behaviour system while being very easy to port to a dedicated system - animations began their lives here for example
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment