adjective physically concerning land or its inhabitants.
Geotic is an ECS library focused on performance, features, and non-intrusive design. Geotic is consistantly one of the fastest js ecs libraries, especially when it comes to large numbers of entities and queries.
This library is heavily inspired by ECS in Caves of Qud. Watch these talks to get inspired!
Python user? Check out the Python port of this library, ecstremity!
npm install geotic
Below is a contrived example which shows the basics of geotic:
import { Engine, Component } from 'geotic';
// define some simple components
class Position extends Component {
static properties {
x: 0,
y: 0,
};
}
class Velocity extends Component {
static properties {
x: 0,
y: 0,
};
}
class IsFrozen extends Component {}
const engine = new Engine();
// all Components and Prefabs must be `registered` by the engine
engine.registerComponent(Position);
engine.registerComponent(Velocity);
engine.registerComponent(IsFrozen);
...
// create a world to hold and create entities and queries
const world = engine.createWorld();
// Create an empty entity. Call `entity.id` to get the unique ID.
const entity = world.createEntity();
// add some components to the entity
entity.addComponent(Position, { x: 4, y: 10 });
entity.addComponent(Velocity, { x: 1, y: .25 });
// create a query that tracks all components that have both a `Position`
// and `Velocity` component but not a `IsFrozen` component. A query can
// have any combination of `all`, `none` and `any`
const kinematics = world.createQuery({
all: [Position, Velocity],
none: [IsFrozen]
});
...
// geotic does not dictate how your game loop should behave
const loop = (dt) => {
// loop over the result set to update the position for all entities
// in the query. The query will always return an up-to-date array
// containing entities that match
kinematics.get().forEach((entity) => {
entity.position.x += entity.velocity.x * dt;
entity.position.y += entity.velocity.y * dt;
});
};
...
// serialize all world entities into a JS object
const data = world.serialize();
...
// convert the serialized data back into entities and components
world.deserialize(data);
The Engine
class is used to register all components and prefabs, and create new Worlds.
import { Engine } from 'geotic';
const engine = new Engine();
engine.registerComponent(clazz);
engine.registerPrefab({ ... });
engine.destroyWorld(world);
Engine properties and methods:
The World
class is a container for entities. Usually only one instance is needed,
but it can be useful to spin up more for offscreen work.
import { Engine } from 'geotic';
const engine = new Engine();
const world = engine.createWorld();
// create/destroy entities
world.createEntity();
world.getEntity(entityId);
world.getEntities();
world.destroyEntity(entityId);
world.destroyEntities();
// create queries
world.createQuery({ ... });
// create entity from prefab
world.createPrefab('PrefabName', { ... });
// serialize/deserialize entities
world.serialize();
world.serialize(entities);
world.deserialize(data);
// generate unique entity id
world.createId();
// destroy all entities and queries
world.destroy();
World properties and methods:
Entity
. optionally provide an IDEntity
by IDentity.destroy()
A unique id and a collection of components.
const zombie = world.createEntity();
zombie.add(Name, { value: 'Donnie' });
zombie.add(Position, { x: 2, y: 0, z: 3 });
zombie.add(Velocity, { x: 0, y: 0, z: 1 });
zombie.add(Health, { value: 200 });
zombie.add(Enemy);
zombie.name.value = 'George';
zombie.velocity.x += 12;
zombie.fireEvent('hit', { damage: 12 });
if (zombie.health.value <= 0) {
zombie.destroy();
}
Entity properties and methods:
true
if this entity is destroyedtrue
if the specified component belongs to this entityComponents hold entity data. A component must be defined and then registered with the Engine. This example defines a simple Health
component:
import { Component } from 'geotic';
class Health extends Component {
// these props are defaulting to 10
// anything defined here will be serialized
static properties {
current: 10,
maximum: 10,
};
// arbitrary helper methods and properties can be declared on
// components. Note that these will NOT be serialized
get isAlive() {
return this.current > 0;
}
reduce(amount) {
this.current = Math.max(this.current - amount, 0);
}
heal(amount) {
this.current = Math.min(this.current + amount, this.maximum);
}
// This is automatically invoked when a `damage-taken` event is fired
// on the entity: `entity.fireEvent('damage-taken', { damage: 12 })`
// the `camelcase` library is used to map event names to methods
onDamageTaken(evt) {
// event `data` is an arbitray object passed as the second parameter
// to entity.fireEvent(...)
this.reduce(evt.data.damage);
// handling the event will prevent it from continuing
// to any other components on the entity
evt.handle();
}
}
Component properties and methods:
<Entity>
and <EntityArray>
respectively!keyProperty
.allowMultiple
is false, this has no effect. If this property is omitted, it will be stored as an array on the component.true
if this component is destroyedThis example shows how allowMultiple
and keyProperty
work:
class Impulse extends Component {
static properties = {
x: 0,
y: 0,
};
static allowMultiple = true;
}
ecs.registerComponent(Impulse);
...
// add multiple `Impulse` components to the player
player.add(Impulse, { x: 3, y: 2 });
player.add(Impulse, { x: 1, y: 0 });
player.add(Impulse, { x: 5, y: 6 });
...
// returns the array of Impulse components
player.impulse;
// returns the Impulse at position `2`
player.impulse[2];
// returns `true` if the component has an `Impulse` component
player.has(Impulse);
// the `player.impulse` property is an array
player.impulse.forEach((impulse) => {
console.log(impulse.x, impulse.y);
});
// remove and destroy the first impulse
player.impulse[0].destroy();
...
class EquipmentSlot extends Component {
static properties = {
name: 'hand',
itemId: 0,
};
static allowMultiple = true;
static keyProperty = 'name';
get item() {
return this.world.getEntity(this.itemId);
}
set item(entity) {
return this.itemId = entity.id;
}
}
ecs.registerComponent(EquipmentSlot);
...
const player = ecs.createEntity();
const helmet = ecs.createEntity();
const sword = ecs.createEntity();
// add multiple equipment slot components to the player
player.add(EquipmentSlot, { name: 'rightHand' });
player.add(EquipmentSlot, { name: 'leftHand', itemId: sword.id });
player.add(EquipmentSlot, { name: 'head', itemId: helmet.id });
...
// since the `EquipmentSlot` had a `keyProperty=name`, the `name`
// is used to access them
player.equipmentSlot.head;
player.equipmentSlot.rightHand;
// this will `destroy` the `sword` entity and automatically
// set the `rightHand.item` property to `null`
player.equipmentSlot.rightHand.item.destroy();
// remove and destroy the `rightHand` equipment slot
player.equipmentSlot.rightHand.destroy();
Queries keep track of sets of entities defined by component types. They are limited to the world they're created in.
const query = world.createQuery({
any: [A, B], // exclude any entity that does not have at least one of A OR B.
all: [C, D], // exclude entities that don't have both C AND D
none: [E, F], // exclude entities that have E OR F
});
query.get().forEach((entity) => ...); // loop over the latest set (array) of entites that match
// alternatively, listen for when an individual entity is created/updated that matches
query.onEntityAdded((entity) => {
console.log('an entity was updated or created that matches the query!', entity);
});
query.onEntityRemoved((entity) => {
console.log('an entity was updated or destroyed that previously matched the query!', entity);
});
true
if the given entity
is being tracked by the query. Mostly used internallyexample Save game state by serializing all entities and components
const saveGame = () => {
const data = world.serialize();
localStorage.setItem('savegame', data);
};
...
const loadGame = () => {
const data = localStorage.getItem('savegame');
world.deserialize(data);
};
Events are used to send a message to all components on an entity. Components can attach data to the event and prevent it from continuing to other entities.
The geotic event system is modelled aver this talk by Brian Bucklew - AI in Qud and Sproggiwood.
// a `Health` component which listens for a `take damage` event
class Health extends Component {
...
// event names are mapped to methods using the `camelcase` library.
onTakeDamage(evt) {
console.log(evt);
this.value -= evt.data.amount;
// the event gets passed to all components the `entity` unless a component
// invokes `evt.prevent()` or `evt.handle()`
evt.handle();
}
// watch ALL events coming to component
onEvent(evt) {
console.log(evt.name);
console.log(evt.is('take-damage'));
}
}
...
entity.add(Health);
const evt = entity.sendEvent('take-damage', { amount: 12 });
console.log(evt.name); // return the name of the event. "take-damage"
console.log(evt.data); // return the arbitrary data object attached. { amount: 12 }
console.log(evt.handled); // was `handle()` called?
console.log(evt.prevented); // was `prevent()` or `handle()` called?
console.log(evt.handle()); // handle and prevent the event from continuing
console.log(evt.prevent()); // prevent the event from continuing without marking `handled`
console.log(evt.is('take-damage')); // simple name check
Prefabs are a pre-defined template of components.
The prefab system is modelled after this talk by Thomas Biskup - There be dragons: Entity Component Systems for Roguelikes.
// prefabs must be registered before they can be instantiated
engine.registerPrefab({
name: 'Being',
components: [
{
type: 'Position',
properties: {
x: 4,
y: 10,
},
},
{
type: 'Material',
properties: {
name: 'flesh',
},
},
],
});
ecs.registerPrefab({
// name used when creating the prefab
name: 'HumanWarrior',
// an array of other prefabs of which this one derives. Note they must be registered in order.
inherit: ['Being', 'Warrior'],
// an array of components to attach
components: [
{
// this should be a component constructor name
type: 'EquipmentSlot',
// what properties should be assigned to the component
properties: {
name: 'head',
},
},
{
// components that allow multiple can easily be added in
type: 'EquipmentSlot',
properties: {
name: 'legs',
},
},
{
type: 'Material',
// if a parent prefab already defines a `Material` component, this flag
// will say how to treat it. Defaults to overwrite=true
overwrite: true,
properties: {
name: 'silver',
},
},
],
});
...
const warrior1 = world.createPrefab('HumanWarrior');
// property overrides can be provided as the second argument
const warrior2 = world.createPrefab('HumanWarrior', {
equipmentSlot: {
head: {
itemId: world.createPrefab('Helmet').id
},
},
position: {
x: 12,
y: 24,
},
});