What is Javelin?
Javelin is an Entity Component System (ECS) framework for TypeScript and JavaScript. It draws inspiration from other ECS frameworks like Bevy and Flecs to provide an ergonomic, performant, and interoperable way of creating games in JavaScript.
Features
Systems with reactive entity queries.
let lootSystem = () => {
world.query(Player).each((player, playerBox) => {
world.query(Loot).each((loot, lootBox) => {
// (pick up loot bag)
})
})
}
A scheduler with run criteria, ordering constraints, and system groups.
let weatherEnabled = (world: j.World) => {
return world.getResource(Config).weatherEnabled
}
app
.addSystem(shootSystem)
.addSystem(lootSystem, j.after(shootSystem))
.addSystem(weatherSystem, null, weatherEnabled)
.addSystemToGroup(j.Group.Late, renderSystem)
A type system used to create, add, and remove sets of components from entities.
let Transform = j.type(Position, Rotation, Scale)
let Mesh = j.type(Geometry, Material)
let Player = j.type(Transform, Mesh)
world.create(Player)
Enum components used to safeguard entity composition and implement state machines.
let PlanetType = j.slot(Gas, Rock)
// Error: An entity can have at most one component for a given slot
world.create(j.type(PlanetType(Gas), PlanetType(Rock)))
Entity relationships with built-in support for heirarchies.
let parent = world.create()
let child = world.create(j.ChildOf(parent))
world.delete(parent) // also deletes `child`
And much more! Move to the next chapter to learn how to install Javelin.
Installation
Javelin is available on the NPM package registry and can be installed with your JS package manager of choice:
npm install @javelin/ecs
You can optionally install the authoritative server networking plugin if you plan on building a multiplayer game:
npm install @javelin/net
Keep reading to learn how to build a simple game with Javelin.
Hello World
The complete source produced in this tutorial can be found in the Javelin repo.
To get started, we’ll load Javelin into a document with a <canvas>
element:
<html>
<body>
<canvas></canvas>
<script type="module">
import {App} from "node_modules/@javelin/core/dist/index.mjs"
</script>
</body>
</html>
Create an App
At the core of any Javelin game is an app.
import * as j from "@javelin/ecs"
let app = j.app()
Apps are responsible for running systems—functions that implement game logic, against a world—game state.
Most Javelin projects will need just one app.
Create a Box
An app has a single world by default. A world manages all game state, primarily entities and their components. Our game has a single entity: a box.
Entities are created using a world’s create()
method. In order to create our box, we need to get a reference to the app’s world. We can do this using a startup system, a function that is executed once when the app is initialized.
let createBoxSystem = (world: j.World) => {
let box = world.create()
}
app.addInitSystem(createBoxSystem)
app.step()
A box will be created when app.step()
is called, only once, before any other game logic is run.
Our entity doesn’t have any box-like qualities yet. Entities don’t have any intrinsic state. In fact, they’re just integers that identify a unique set of components.
Components can play many roles. They can function as simple labels, add component data to entities, or even represent relationships between entities. Components that add data to entities are called value components.
In this exercise, we’ll define two value components: one for the position of the box, and another for its color. Value components are created using the value
function:
let Position = j.value<{x: number; y: number}>()
let Color = j.value<string>()
Position
andColor
are called value components, because they add values to entities. Objects that conform their shape (e.g.{x:0, y:0}
) are called component data or component values.
Components are added to entities using a world’s add
method. Let’s give our entity some position and color data:
let createBoxSystem = (world: j.World) => {
let box = world.create()
world.add(box, Position, {x: 0, y: 0})
world.add(box, Color, "#ff0000")
}
We can condense the two add calls into a single statement using a type. A type is an alias for a set of components. Let’s create a Box
type that will come in handy whenever we need to reference an entity with both a position and a color.
let Box = j.type(Position, Color)
We could then rewrite the two world.add
statements with a single statement like so:
world.add(box, Box, {x: 0, y: 0}, "#ff0000")
Types are composable with components and other types. For example, the
Box
type could be combined with aLoot
component to create a new type, liketype(Box, Loot)
.
Move the Box
We’ll hook up our box to user input in a new system. Unlike the startup system we created, this system will execute continuously so the game can respond to keyboard input.
But before it can move anything, the system will first need to locate the box. Requesting information about a world is the most common task an ECS does. Sometimes the requests are simple, like “find all boxes”. But occasionally more nuanced requests like “find all hungry hippos that aren’t on fire” are required. In Javelin, these requests are expressed using queries.
A system is a function that recieves a world as its sole argument. Typically a system will:
- Request resources (global state that won’t fit plainly into entities)
- Run queries against a world
- Read/write component data
This system will need to perform all three of these operations: get the input resource, find the box using a query, and update the box’s position.
We’ll first get a reference to the device’s keyboard state using world.getResource
:
let moveBoxSystem = (world: j.World) => {
let {key} = world.getResource(Input)
}
Then we’ll find and update the box using a query. world.query
returns an iterable collection of entities that match a list of types and components to a callback function:
world.query(Box).each((box, boxPos) => {
boxPos.x += Number(key("ArrowRight")) - Number(key("ArrowLeft"))
boxPos.y += Number(key("ArrowDown")) - Number(key("ArrowUp"))
})
Draw the Box
The next step is to draw the box to the screen. We’ll use the document’s sole canvas element as our rendering medium. To draw to the canvas we need a reference to its 2d rendering context.
Javelin’s API encourages code reuse and portability. Systems are more portable when they have fewer global or module-level dependencies, which is especially useful when sharing systems between apps (like a client and server). All a system receives is an instance of World
—so how can we provide the drawing context to our render system(s) without resorting to a global variable or singleton?
We can define a resource for it. Resources let us provide arbitrary values to our systems. Let’s create a resource for a CanvasRenderingContext2D
:
let Context2D = j.resource<CanvasRenderingContext2D>()
Next, we’ll provide the app a value for the Context2D
resource using its addResource
method.
let context = document.querySelector("canvas")!.getContext("2d")
app.addResource(Context2D, context)
Resources can provide any value to systems. This includes third party library objects, singleton entities, and any other game state that doesn’t clearly fit into entities and components.
Image data is not automatically cleared from canvas elements, so we should write a system that erases the canvas so we don’t draw our box on top of old pixels. We’ll get the draw context using the useResource
effect (which simply calls world.getResource
), and call its clearRect()
method:
let clearCanvasSystem = (world: j.World) => {
let context = world.getResource(Context2D)
context.clearRect(0, 0, 300, 150) // default canvas width/height
}
Taking everything we’ve learned so far about systems, queries, and resources, we can write a system that draws our box to the canvas:
let drawBoxSystem = (world: j.World) => {
let context = world.getResource(Context2D)
world.query(Box).each((box, boxPos, boxColor) => {
context.fillStyle = boxColor
context.fillRect(poxPos.x, boxPos.y, 50, 50)
})
}
Hook it Up
Our movement and rendering systems are fully implemented! We just need to register them with our app. We’ll use the app’s addSystem
method to instruct the app to execute the system each time the app’s step
method is called.
Systems are executed in the order in which they are added. So we could simply add them sequentially:
app
// Add our systems in order:
.addSystem(moveBoxSystem)
.addSystem(clearCanvasSystem)
.addSystem(drawBoxSystem)
This practice doesn’t work well for larger games with dozens of systems. At scale, adding and reordering systems becomes impractical because systems must be ordered just right for the app to function predictably.
We want to ensure that our render systems are executed after our movement system so our players see the most up-to-date game state at the end of each frame. Javelin splits each step into a pipeline of system groups. We can ensure that our render systems execute after our behavior systems by moving them to a group that executes later in the pipeline.
Systems are added to the Group.Update
group by default. So we can add our rendering systems to a system group that follows, like Group.LateUpdate
, to ensure they run after our game behavior. A system can be added to a group other than App.Update
via an app’s addSystemToGroup
method:
app
.addSystem(moveBoxSystem)
.addSystemToGroup(j.Group.LateUpdate, clearCanvasSystem)
.addSystemToGroup(j.Group.LateUpdate, drawBoxSystem)
Now, regardless of the order the systems are added in, moveBoxSystem
will always run before the box is drawn to the canvas.
We can also add ordering constraints to systems to ensure they execute in a deterministic order within a group. Each system registration method accepts a constraint builder that defines the ordering of systems within a group.
We want to ensure our box is drawn to the canvas Only after_ the canvas is cleared, otherwise the user may see nothing each frame. We can accomplish this like so:
app.addSystemToGroup(
j.Group.LateUpdate,
drawBoxSystem,
j.after(clearCanvasSystem),
)
Hello, Box!
Our final app initialization statement should look like this:
app
.addResource(Context2D, context)
.addInitSystem(createBoxSystem)
.addSystem(moveBoxSystem)
.addSystemToGroup(j.Group.LateUpdate, clearCanvasSystem)
.addSystemToGroup(
j.Group.LateUpdate,
drawBoxSystem,
j.after(clearCanvasSystem),
)
We can execute all of our app’s registered systems using the app’s step
method. If we call step
at a regular interval, the box should move in response to arrow key presses.
let loop = () => {
app.step()
requestAnimationFrame(loop)
}
loop()
Move on to the next chapter to see some examples of other games made with Javelin.
Entities
An entity identifies a discrete game unit. You can think of an entity as a pointer to a collection of components that can grow and shrink during gameplay.
An entity can represent anything from a player or enemy, to a spawn point, or even a remotely connected client.
Javelin supports up to around one-million (2^20) active entities, and around four-billion (2^32) total entities over the lifetime of the game. Entities are technically unsigned integers, but they should be treated as opaque values to keep your code robust to API changes.
Entity Creation
Entities are created using world.create
.
world.create()
Entities can be created with a single component.
let Position = j.value<Vector2>()
world.create(Position, {x: 0, y: 0})
But most often you will need to create entities from a set of components. This is accomplished using types:
let Position = j.value<Vector2>()
let Velocity = j.value<Vector2>()
let Kinetic = j.type(Position, Velocity)
world.create(Kinetic, {x: 0, y: 0}, {x: 1, y: -1})
Component values cannot be provided to tag components during entity creation, since tags are stateless.
let Burning = j.tag()
world.create(j.type(Burning, Position), {x: 0, y: 0})
Components defined with a schema are auto-initialized if a value is not provided.
let Position = j.value({x: "f32", y: "f32"})
world.create(Position) // automatically adds {x: 0, y: 0}
Entity Reconfiguration
Components are added to entities using world.add
.
world.add(entity, Velocity, {x: 1, y: -1})
world.add(entity, j.type(Burning, Position))
Components are removed from entities using world.remove
.
world.remove(entity, Kinetic)
Entity Deletion
Entities are deleted using world.delete
.
world.delete(entity)
Entity Transaction
Entity operations are deferred until the end of each step. Take the following example where a systemB
downstream of systemA
fails to locate a newly created entity within a single step.
app
// systemA
.addSystem(world => {
world.create(Hippo)
})
// systemB
.addSystem(world => {
world.query(Hippo).each(hippo => {
// (not called, even though a hippo was created)
})
})
.step()
All changes made to entities are accumulated into a transction that is applied after the last system executes. This allows Javelin to performanly move changed entities within it’s internal data structures at most one time per step. This behavior also reduces the potential for bugs where systems that occur early in the pipeline can “miss” entities that are created and deleted within the same step.
Components
If entities are the bread of ECS then components are the butter. Components provide state to entities that persists between game steps.
You can read about how components are added to entities in the Entities chapter.
Tag Components
Because systems resolve entities based on their composition, the simple addition of a component to an entity has meaning in Javelin. It stands to reason then that systems could execute per-entity logic solely based on the presence of a component.
Tags are the simplest kind of component. They are stateless, and consequentially are performantly added and removed from entities.
Tags are created with the tag
function:
let PurpleTeam = j.tag()
let YellowTeam = j.tag()
Tags also happen to take up minimal space in network messages because they have no corresponding value to serialize.
Value Components
Value components define entity state that should be represented with a value, like a string, array, or object. They are created with the value
function:
let Mass = j.value()
value
accepts a generic type parameter that defines the value the component represents. Value components that aren’t provided a value type are represented as unknown
.
let Mass = j.value<number>()
Schema
Value components may optionally be defined with a schema. Schemas are component blueprints that make a component’s values eligible for auto-initialization, serialization, pooling, and validation.
A value component can be defined with a schema by providing the schema as the first parameter to value
:
let Mass = j.value("f32")
Schemas can take the form of scalars or records.
let Quaternion = j.value({
x: "f32",
y: "f32",
z: "f32",
w: "f32",
})
Deeply-nested schema are planned, but not supported at the current point in Javelin’s development.
Below is a table of all schema-supported formats.
id | format | supported values |
---|---|---|
number | (alias of f64) | |
u8 | 8-bit unsigned integer | 0 to 255 |
u16 | 16-bit unsigned integer | 0 to 65,535 |
u32 | 32-bit unsigned integer | 0 to 4,294,967,295 |
i8 | 8-bit signed integer | -128 to 127 |
i16 | 16-bit signed integer | -32,768 to 32,767 |
i32 | 32-bit signed integer | -2,147,483,648 to 2,147,483,647 |
f32 | 32-bit float | |
f64 | 64-bit float |
Relationships
Relationships are a special kind of component created by pairing a relation component with an entity id.
A relation component is created using the relation
function.
let GravitatingTo = j.relation()
The value returned by relation
is a function that builds relationships on a per-entity basis. Relationships can be added to entities like any other component.
let planet = world.create(Planet)
let spaceship = world.create(j.type(Spaceship, GravitatingTo(planet)))
Relationships can be used as query terms to resolve related entities.
world.query(Planet).each(planet => {
world.query(GravitatingTo(planet), Velocity).each((entity, velocity) => {
// (apply gravity)
})
})
Behind the scenes, relation
creates a hidden tag component that is also attached to any entities with a GravitatingTo(entity)
relationship component. The relation builder will be subsituted with this hidden tag component when included in a list of query terms.
The following example query finds all entities with relationships of the GravitatingTo
variety.
world.query(GravitatingTo).each(entity => {
// `entity` is affected by gravity
})
Entity Heirarchies
Javelin comes with a special relation component called ChildOf
that provides the means to create tree-like entity hierarchies.
let bag = world.create(Bag)
let sword = world.create(j.type(Sword, j.ChildOf(bag)))
ChildOf
can be used with queries to find all children of an entity.
world.query(j.ChildOf(bag)).each(item => {
// `item` is a child of `bag`
})
Deleting a parent entity will also delete its children.
world.delete(bag) // also deletes `sword`
An entity may have only one parent.
world.create(type(j.ChildOf(spaceship), j.ChildOf(planet)))
// Error: a type may have only one ChildOf relationship
The parent of an entity can be resolved using world.parentOf
:
world.parentOf(sword) // `bag`
Enums
Enum-like behavior can be achieved using slots. Slots are useful when defining mutually exclusive states, like when implementing character movement systems.
Slots are created using the slot
function. They are defined with one or more value or tag components.
let MovementState = j.slot(Running, Walking, Crouching)
Slot components are added to entities like any other component. To create a slot component, call the slot (in this case MovementState
) with one of its components.
world.create(MovementState(Walking))
Slots cannot be defined with relationships. For example,
ChildOf(*)
is not a valid slot component.
A slot guarantees that at most one of the components included in its defintion may be attached to an entity.
let character = world.create(MovementState(Walking))
world.add(character, MovementState(Running))
// Error: A type may have at most one component for a given slot
You can find entities with a given slot by including the slot in a query’s terms.
world.query(MovementState).each(entity => {
// `entity` has a `MovementState`
})
Slots are implemented as relation components, so they follow the same semantics. This also means that a slot produces unique components that are independent of those it was defined with. Below is an example of a type that uses a slot to ensure the entity has at most one element (e.g. water, fire, poison), while also integrating another Poison
component value for a separate use.
j.type(Element(Poison), Poison)
Systems
Systems are functions that modify a world. All game logic is implemented by systems.
An app executes each of it’s systems each step. Systems may be optionally configured to run in a specific order through ordering constraints and system groups. They may also be conditionally disabled (and enabled) with run criteria.
Systems are added to an app using the app’s addSystem
method.
let plantGrowthSystem = (world: j.World) => {
world.query(Plot).each((plot, plotWater) => {
world.query(Plant, j.ChildOf(plot)).each((plant, plantMass) => {
// (grow plant using plotWater)
})
})
}
app.addSystem(plantGrowthSystem)
By default, an app will execute it’s systems in the order they are added.
Systems are removed via the removeSystem
method.
app.removeSystem(plantGrowthSystem)
Ordering Constraints
The order in which an app executes systems can be configured using explicit ordering constraints. Ordering constraints are established using a constraint builder object passed to addSystem
’s optional second callback argument.
game
.addSystem(plantGrowthSystem, j.after(weatherSystem))
.addSystem(weatherSystem)
The after
constraint ensures a system will be run some time following a given system, while before
ensures a system is executed earlier in the pipeline.
Ordering constraints can be chained.
app.addSystem(pestSystem, j.after(weatherSystem).before(plantGrowthSystem))
Run Criteria
Systems can also be run conditionally based on the boolean result of a callback function. This predicate function is provided as the third argument to addSystem
.
let eachHundredthTick = (world: World) => {
return world.getResource(Clock).tick % 100 === 0
}
app.addSystem(plantGrowthSystem, j.after(weatherSystem), eachHundredthTick)
System Groups
Systems may be organized into groups. Javelin has six built-in groups:
enum Group {
Early,
EarlyUpdate,
Update,
LateUpdate,
Late,
}
Groups are run in the order they appear in the above enum. There are no rules around how built-in groups should be used, but here are some ideas:
Group.Early
can be used for detecting device input, processing incoming network messages, and any additional housekeeping that doesn’t touch the primary entities in your world.Group.EarlyUpdate
might be used for behaviors that have important implications for most entities in your game, like applying player input and updating a physics simulation.Group.Update
can be used for core game logic, like handling entity collision events, applying damage-over-time effects, spawning entities, etc.Group.LateUpdate
can be used to spawn and destroy entities because entity operations are deferred until the end of a step anyways.Group.Late
might be used to render the scene, send outgoing network messages, serialize game state, etc.
Systems are grouped using an app’s addSystemToGroup
method:
app.addSystemToGroup(j.Group.Late, renderPlotSystem)
Like addSystem
, addSystemToGroup
also accepts ordering constraints and run criteria through it’s third and fourth arguments.
app.addSystemToGroup(j.Group.Late, renderPlotSystem, _ =>
_.before(renderGrassSystem),
)
Custom system groups can be created with an app’s addGroup
method:
app.addGroup("plot_sim")
Like systems, system groups can be ordered and toggled using ordering constraints and run criteria, respectively.
app.addGroup(
"plot_sim",
j.before(j.Group.LateUpdate).after(j.Group.Update),
eachHundredthTick,
Initialization Systems
Javelin has a sixth built-in system group: Group.Init
. This group has run criteria and ordering constraints that ensure it is executed only once at the beginning of the app’s first step. Group.Init
is useful when performing one-off initialization logic, like loading a map or spawning a player.
Apps have a small convenience method for adding systems to Group.Init
: addInitSystem
.
app.addInitSystem(loadLevelSystem)
Of course, like each of the aformentioned system-related methods, addInitSystem
also accepts ordering constraints and run criteria.
Queries
An entity is defined by the components associated with it. Components grant the data (component values) to entities that are needed to model a specific behavior. This leaves systems with the implementation of that behavior.
In order for a system to implement entity behavior, it must first find all entities of interest. This is done using queries.
A system may query entities using it’s world’s of
method.
let Planet = j.type(PlanetGeometry, PlanetType, ...)
let orbitPlanetsSystem = (world: j.World) => {
let planets = world.query(Planet)
}
Entities that match the query’s terms are iterated using the query’s each
method.
planets.each(planet => {
// `planet` has all components defined in the `Planet` type
})
Query terms may include types, components (including relation tags and relationship components, slot tags and slot components), and filters, discussed later in the chapter.
Query Views
Systems are often highly specific about the entities they resolve while only utilizing a small subset of the entities’ component values. The component values a query iteratee recieves can be narrowed using the query’s as
method.
world
.query(Hippo, Element(Lightning), StandingIn(Water), Health)
.as(Health)
.each((hippo, hippoHealth) => {
// (do something with just the hippo's health)
})
Query Filters
The results of a query can be further narrowed using query filters. Javelin currently has two query filters: Not
and Changed
.
Not
The Not
filter excludes entities that match a given component.
world.query(Planet, j.Not(Atmosphere)).each(planet => {
// `planet` does not have an atmosphere component
})
Without
can be used with more complex component types like relationships and slots. The following query could be expressed in plain terms as “all gas planets that are not in the Sol system”:
world.query(Planet, PlanetType(Gas), j.Not(j.ChildOf(solSystem)))
Another example: “all non-gas planets”:
world.query(Planet, j.Not(PlanetType(Gas)))
Changed
The
Changed
filter is under development.
Monitors
Some systems must be notified of entities that match or no longer match a set of query terms. This is necessary when implementing side-effects like spawning new entities when other entities are created or destroyed, updating a third-party library, or displaying information in a UI.
Systems can react to changes in entity composition using monitors. Below a simple system that utilizes monitors to log all created and deleted entities:
let logEntitySystem = (world: j.World) => {
world
.monitor()
.eachIncluded(entity => {
console.log("created", entity)
})
.eachExcluded(entity => {
console.log("deleted", entity)
})
}
The entities a monitor recieves can be narrowed using query terms.
world.monitor(Client).eachIncluded((client) => {
let clientSocket = world.get(client, Socket)
clientSocket.send("ping")
})
In plain terms, monitors execute the callback provided to eachIncluded
for each entity that began to match the provided query terms at the end of the last step. It executes the eachExcluded
callback for entities that no longer match the terms. They can be used to:
- mutate the world in response to entity changes
- update list or aggregate views in a UI
- broadcast events to a third-party library, service, or client
Data Transience
Because entity operations are deferred to the end of a step, component values are no longer available to a system by the time a monitor executes it’s eachExcluded
callback.
If you wish to access component values of an entity that no longer matches a monitor’s terms, you can use a world’s monitorImmediate
method. monitorImmediate
returns a monitor that is configured to run within the current step.
world.monitorImmediate(Enemy).eachExcluded((enemy) => {
let enemyPos = world.get(enemy, Position)
world.create(LootBag, enemyPos)
})
An immediate monitor must be executed downstream of its causal systems in the app’s system execution pipeline. In the above example, the system should be configured using ordering constraints to occur after the system that deletes Enemy
entities.
app
.addSystem(deleteDeadEntitiesSystem)
.addSystem(
world => world.monitorImmediate(Enemy).eachExcluded(/* ... */),
j.after(deleteDeadEntitiesSystem),
)
Resources
You may find yourself struggling to represent some game state as an entity, or you may be integrating a third-party library like Three.js or Rapier. Javelin provides resources as an alternative to one-off entities or singleton components.
Resources are useful for:
- Run criteria dependencies, e.g. a promise that must resolve before a system can run
- Plugin configuration values
- Third-party library objects like a Three.js scene or Rapier physics world
- Global state that applies to all entities, like a game clock
- Abstractions over external dependencies like device input or Web Audio
Resource Registration
Resources are identifiers for arbitrary values. A resource identifier is created with the resource
function. resource
accepts a single type parameter that defines the resource’s value.
let Scene = j.resource<Three.Scene>()
Resources are added to an app with the addResource
method.
app.addResource(Scene, new Three.Scene())
Resource Retrieval
The value of a given resource can be read using an app’s getResource
method.
let scene = app.getResource(Scene)
getResource
will throw an error if the resource had not been previously added.
Resources are stored in an app’s world so that systems can request resources.
app.addSystem(world => {
let scene = world.getResource(Scene)
})
Systems can overwrite resources using a world’s setResource
method.
app.addSystem(world => {
world.setResource(Now, performance.now())
})
Plugins
Composing a game as a series of modules increases the portability of your code. The same module can be used by multiple apps, like client and server apps, or different builds of your game.
Javelin uses the term plugin for a reusable module. A plugin is a function that modifies an app in some way.
Here is a plugin that adds a resource to an app:
export let pausePlugin = (app: j.App) => {
app.addResource(Paused, false)
}
Plugins are added to an app via the use
method.
app.use(pausePlugin)
Plugin Example
Plugins help organize game code. Take the following snippet from an example in the Javelin repo, where each logical sub-unit of the sample game is arranged into its own plugin:
let app = j
.app()
.addGroup("render", j.after(j.Group.LateUpdate).before(j.Group.Late))
.use(timePlugin)
.use(clockPlugin)
.use(disposePlugin)
.use(bulletPlugin)
// ...
Let’s open up the timePlugin
plugin. It modifies the app by adding a Time
resource and a system that advances the clock each step.
export let timePlugin = (app: j.App) =>
app
.addResource(Time, {previous: 0, current: 0, delta: 0})
.addSystemToGroup(j.Group.Early, advanceTimeSystem)
This plugin isn’t game-specific and could be easily shared with other Javelin projects. That’s all there is to plugins!
Glossary
Entity
An entity is and identifier for a discrete “thing” in a game. Each entity has its own unique set of components called a type.
Component
A component is an identifier that can be added to or removed from an entity’s type. An entity is fully defined by its type and its component values.
Type
A type is a set of components that defines an entity’s composition.
Value component
A value component is a component that adds a value (called component data) to an entity.
Component data
Component data is any value attached to an entity through a component.
Tag component
A tag component is a component that does not add data to entities.
World
A world is the container for all ECS data, including entities, entity types and component data, and resources.
Resource
A resource a key that is used to get and set an arbitrary (non-entity related) value within a world. Used synonymously with Resource value.
Resource value
A resource value is a non-entity, non-component piece of game state added to the world through a resource.
System
A system is a function that updates a world’s entities and resources. Systems commonly use queries and monitors to implement game logic and react to entity composition changes.
Query
A query is an iterable object that yields entities matching a set of query terms.
Query term
A query term is any component, relation, or slot.
Monitor
A monitor is a a query-like object that yields entities that began matching or no longer match an array of query terms.
Relation
A relation is a function that returns per-entity relationship components. Relations are subsituted in types with their underlying relation tag.
Relation tag
A relation tag is a tag component automatically given to relations. They provide the means to find all entities with associated relationships.
For example, world.query(ChildOf)
would find all entities that are a child of another entity.
Relationship component
A relationship component is the product of calling a relation with an entity. For example, ChildOf(parent)
creates a relationship component for the entity parent
. When added to an entity, a relation component asserts that it’s entity and the entity to which it is being added are related in some way.
Slot
A slot is a function that builds slot components. Slots are defined with a fixed set of components. A slot can only produce slot components for the components it was defined with.
Slot tag
A slot tag is a tag component automatically given to slots. Slots are subsituted in types with their underlying slot tags. For example, world.query(MovementState)
would find all entities with a MovementState
.
Slot component
A slot component is the product of calling a slot with a component. For example, MovementState(Running)
creates a slot component for the component Running
. When added to an entity, a slot component asserts that it’s entity may have only one slot component of the given slot.
Fixed Timestep
Fixed Timestep
Design Overview
Entities in Javelin are stored in a graph separate from their component values. Entities are sorted into nodes based on their current component makeup (or composition).
Each value component is allocated an array where its values are stored.
Systems create queries that search the entity graph for matching components. Queries then fetch component values of interest by accessing the entity’s index in the corresponding storage array.
Architecture Diagram
Below is a diagram that illustrates the storage and retrieval of entities and components by systems.