Topics

    Systems are typically pure, as they only read/modify the components of queried entities. However, as your game grows, you may want a system to trigger behavior in a different system. For example, you may write a physics system that wraps a third-party library whose methods you'd like to expose to other physics-interested systems.

    Topics facilitate a way to do this without resorting to global state, unlike global effects.

    Inter-System Communication#

    Let's say you want to apply an impulse to a physics body when a player jumps so it gains some momentum in a direction. One way of doing this is to model the operation as a component.

    const Impulse = {
      x: number,
      y: number,
    }
    

    When you need to apply a impulse to an entity, you insert an Impulse component on the current tick, and remove it on the following tick.

    const sysInput = ({ attach, detach }: World) => {
      qryJumping(entity => attach(entity, component(Impulse)))
      qryWithImpulse((entity, [impulse]) => detach(entity, impulse))
    }
    
    const sysPhysics = () => {
      qryWithImpulse((entity, [impulse]) => {
        const body = getBodyByEntity(entity)
        physicsEngine.applyImpulseLocal(body, impulse)
      })
    }
    

    This will work fine for a small game; however, there are a couple of problems with this approach as you scale to more complex games:

    1. Adding and removing components in an archetypal ECS is slow
    2. Your physics system must wait until the next tick to detect the newly attached impluse component

    Topics#

    Topics are simple FIFO buffers that hold on to messages between ticks that can be used to signal events or expose an RPC-like API to a system.

    Topics are created using the createTopic<T>() function, where T is the type (e.g. a union type) of message managed by the topic. The createTopic function is defined in topic.ts.

    import { createTopic, Entity } from "@javelin/ecs"
    
    type ImpulseCommand = [type: "impulse", entity: Entity, force: [number, number]]
    
    const physicsTopic = createTopic<ImpulseCommand>()
    

    Messages are enqueued using the topic.push() method.

    const message: ImpulseCommand = ["impulse", 23, [0, 2]]
    physicsTopic.push(message)
    

    Messages are unavailable until the topic.flush() method is called. You can call flush() manually, or you can configure your world to do it automatically with the topics option:

    createWorld({
      topics: [physicsTopic],
      ...
    })
    

    Messages can then be read using a for..of loop.

    import { physicsTopic } from "./physics_topic"
    
    const sysPhysics = () => {
      for (const command of physicsTopic) {
        if (command[0] === "impulse") {
          const body = getBodyByEntity(command[1])
          physicsEngine.applyImpulseLocal(body, command[2])
        }
      }
    }
    

    Immediate Processing#

    Sometimes messages should be handled as quickly as possible, like when processing user input. topic.pushImmediate will push a message into the topic for immediate processing.

    physicsTopic.pushImmediate(["impulse", 24, [0, 2]])