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 and Color 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 a Loot component to create a new type, like type(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.

idformatsupported values
number(alias of f64)
u88-bit unsigned integer0 to 255
u1616-bit unsigned integer0 to 65,535
u3232-bit unsigned integer0 to 4,294,967,295
i88-bit signed integer-128 to 127
i1616-bit signed integer-32,768 to 32,767
i3232-bit signed integer-2,147,483,648 to 2,147,483,647
f3232-bit float
f6464-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:

  1. 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.
  2. 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.
  3. Group.Update can be used for core game logic, like handling entity collision events, applying damage-over-time effects, spawning entities, etc.
  4. Group.LateUpdate can be used to spawn and destroy entities because entity operations are deferred until the end of a step anyways.
  5. 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.

Javelin architecture diagram

Server

Server

Client

Prediction