Entities

Entities

This docs page is under construction. Expect more information coming to this page soon!
If you have questions, please ask them in our Discord server and we'll answer them promptly! 😊

In Dreamlab, an entity is a distinct object that can be created, updated, or destroyed. They can respond to events such as physics ticks or network packets and run logic in the game world.

To implement objects in the world, you inherit from the SpawnableEntity class.

Spawnable Entities

Spawnable Entities are an abstraction on top of Entities that have stricter requirements. They must have a position in the world, they must be able to be created and destroyed at runtime, and they must be able to be synced over the network.

Defining

Spawnable Entities are defined as classes that extend from SpawnableEntity. They can define any required arguments using a Zod schema, which will ensure any arguments passed in are valid, as well as providing type-safety inside your entity class.

💡

The export z from @dreamlab.gg/core/sdk is a re-export of Zod. Refer to their documentation for more info on how to define arguments.

TypeScript
import { SpawnableEntity } from '@dreamlab.gg/core'
import { z } from '@dreamlab.gg/core/sdk'
 
// Define the arguments for this entity
// Arguments must be a ZodObject
type Args = typeof ArgsSchema
const ArgsSchema = z.object({})
 
// Export the args schema with a representative name
export { ArgsSchema as ExampleEntityArgs }
 
// Define a class that inherits from SpawnableEntity, passing in the Args type
export class ExampleEntity extends SpawnableEntity<Args> {
  // ... implement all required members
}

Args

You can think of entity args as analogous to constructor parameters, as they represent input data for your entity. You must define a “schema” for your args using Zod (see above for more info) which describes all your args and their types. This schema is used to ensure any args passed in to game.spawn({ ... }) are valid, and is also used to generate the inspector in Dreamlab’s edit mode.

TypeScript
import { SpawnableEntity, SpawnableContext } from '@dreamlab.gg/core'
import { z } from '@dreamlab.gg/core/sdk'
 
// Define a type alias for your args based on the schema below
// This will be passed to SpawnableEntity classes to enable correct typing in your IDE
type Args = typeof ArgsSchema
 
// Define an object schema using Zod that describes your args
const ArgsSchema = z.object({
  // Define an arg named `width` that is a number between 1 and 100
  width: z.number().min(1).max(100),
})
 
// We pass in the `Args` type as a generic to tell the class about our args
export class ExampleEntity extends SpawnableEntity<Args> {
  public constructor(ctx: SpawnableContext<Args>) {
    super(ctx)
 
    // You can access the args in your class and they will be strongly typed
    const 
const width: number
width
= this.args.width
} }

Registering

Spawnable Entities must be registered with the game instance in order for them to be created by name. This is most commonly done inside of a sharedInit() function, which is a convention that world scripts use to run initialization code on both client and server.

💡

Refer to your world scripts for how sharedInit() relates to the initialization of a world.

Entity names registered with the game instance must be unique. To avoid collisions, you should namespace your entity names as shown below. Although we recommend namespacing using the @project/entity format, this is just a convention and you are free to solve uniqueness issues however you like.

Registering an entity requires you pass in a class constructor that inherits from SpawnableEntity, and a valid Zod schema for its args.

shared.ts
export const sharedInit = async game => {
  // register your entity with the game
  game.register('@example/example-entity', ExampleEntity, ExampleEntityArgs)
}

Spawning

required

  • entity
  • args
  • transform.position

optional

  • transform.rotation / 0
  • transform.zIndex / 0
  • uid / random cuid
  • label
  • tags / []
server.ts
import type { InitServer } from '@dreamlab.gg/core/sdk'
 
export const init: InitServer = game => {
  // ... server-side initialization
 
  game.spawn({
    // Reference the entity we registered by name
    entity: '@example/example-entity',
 
    // TODO
    args: {},
 
    // TODO
    transform: {
      position: [],
      rotation: 0,
      zIndex: 0,
    },
 
    // TODO
    tags: [],
  })
}

Lifecycle

Spawnable entities have a number of methods related to their lifecycle that will be called by Dreamlab that you are required to implement.

Initialization

Entity initialization is done in the constructor. You should check the current platform to ensure you only perform graphics and other client-only initialization only on the client.

Updates

  • onArgsUpdate -> called when args change, client & server
  • onResize -> called from game.resize()

Tick Loops

  • onPhysicsStep -> called every physics step, client & server
  • onRenderFrame -> called every frame, client only

Destruction

  • teardown -> required, client & server

Other Methods

  • bounds -> required, return undefined if an entity is boundless (ie: entities that have no physical representation in the world)
  • isPointInside -> required, return false if an entity is boundless

bounds represents the AABB of the entity, and is used to render editor selection highlights, as well as to do quick and dirty intersection tests. isPointInside is used for precise intersection tests, such as mouse events.


Example 1 - Bouncing Ball

This script defines a spawnable entity that represents a simple bouncing ball. Refer to Physics and Graphics for more in-depth detail on how the physics and rendering work.

ball.ts
import type {
  PreviousArgs,
  RenderTime,
  SpawnableContext,
} from '@dreamlab.gg/core'
import { SpawnableEntity } from '@dreamlab.gg/core'
import { camera, game, physics, stage } from '@dreamlab.gg/core/labs'
import type { Bounds } from '@dreamlab.gg/core/math'
import { toRadians, Vec } from '@dreamlab.gg/core/math'
import { z } from '@dreamlab.gg/core/sdk'
import type { CircleGraphics } from '@dreamlab.gg/core/utils'
import { drawCircle } from '@dreamlab.gg/core/utils'
import Matter from 'matter-js'
import { Container } from 'pixi.js'
 
type Args = typeof ArgsSchema
const ArgsSchema = z.object({
  // Radius must be a positive number greater than 1
  // Defaults to 60 if not passed
  radius: z.number().positive().min(1).default(60),
})
 
export { ArgsSchema as TestBallArgs }
export class TestBall extends SpawnableEntity<Args> {
  private static MASS = 20
 
  private readonly body: Matter.Body
  private readonly container: Container | undefined
  private readonly gfx: CircleGraphics | undefined
 
  public constructor(ctx: SpawnableContext<Args>) {
    super(ctx)
 
    // Create a circular Matter.js physics body
    this.body = Matter.Bodies.circle(
      this.transform.position.x,
      this.transform.position.y,
      this.args.radius,
      {
        label: 'testBall',
        render: { visible: false },
        // Matter.js rotations are in radians, Dreamlab rotations are in degrees
        angle: toRadians(this.transform.rotation),
 
        // The `preview` context variable is set to `true` if the editor is spawning the entity in "preview mode"
        // This should disable physics, collisions, and other runtime code
        isStatic: this.preview,
        isSensor: this.preview,
 
        mass: TestBall.MASS,
        inverseMass: 1 / TestBall.MASS,
        restitution: 0.95, // Bounciness
      },
    )
 
    // Register the physics body with the Matter.js physics engine
    physics().register(this, this.body)
 
    // Link the transform to the physics body
    // Whenever the transform (position / rotation) changes, automatically update the body
    physics().linkTransform(this.body, this.transform)
 
    // Check if we're in a client context
    const $game = game('client')
    if ($game) {
      const { radius } = this.args
 
      // Create a pixi container to house our circle
      // We can use this to group many pixi elements together should we want to draw complex shapes
      this.container = new Container()
      this.container.sortableChildren = true
      this.container.zIndex = this.transform.zIndex
 
      // Create a new Pixi graphics primitive and draw a circle with it
      this.gfx = drawCircle({ radius })
      this.gfx.zIndex = 100
 
      // Add the circle graphics to the container, and add the container to the main Pixi stage
      this.container.addChild(this.gfx)
      stage().addChild(this.container)
 
      // Update the container's z-index when the transform changes
      this.transform.addZIndexListener(() => {
        if (this.container) this.container.zIndex = this.transform.zIndex
      })
    }
  }
 
  public override bounds(): Bounds | undefined {
    // Return the correct bounds based on the radius
    const { radius } = this.args
    return { width: radius * 2, height: radius * 2 }
  }
 
  public override isPointInside(point: Matter.Vector): boolean {
    // Use Matter.js to query if a point is inside the physics body
    return Matter.Query.point([this.body], point).length > 0
  }
 
  public override onArgsUpdate(
    path: string,
    previousArgs: PreviousArgs<Args>,
  ): void {
    if (path === 'radius') {
      // If we are running on the client, re-draw with new radius
      if (this.gfx) this.gfx.redraw(this.args)
 
      // Store rotation and calculate new scale
      const angle = this.body.angle
      const scale = this.args.radius / previousArgs.radius
 
      // Update the physics body with new radius
      // We need to undo rotation before scaling to prevent weird issues
      Matter.Body.setAngle(this.body, 0)
      Matter.Body.scale(this.body, scale, scale)
      Matter.Body.setAngle(this.body, angle)
      Matter.Body.setMass(this.body, TestBall.MASS)
    }
  }
 
  public override onResize({ width, height }: Bounds): void {
    // Update the radius when the width or height changes
    this.args.radius = Math.max(width / 2, height / 2)
  }
 
  public override teardown(): void {
    // Remove the physics body from the physics system
    physics().unregister(this, this.body)
    // Unlink the transform from the physics body
    physics().unlinkTransform(this.body, this.transform)
 
    // Remove the container and any children
    this.container?.destroy({ children: true })
  }
 
  public override onRenderFrame({ smooth }: RenderTime): void {
    if (this.container) {
      // Get the interpolated position from the physics body
      const smoothed = Vec.add(
        this.body.position,
        Vec.mult(this.body.velocity, smooth),
      )
 
      // Get the position of the body relative to the camera
      // This gives us a screen-space position
      const pos = Vec.add(smoothed, camera().offset)
 
      // Update the screen-space position and rotation of the container
      this.container.position = pos
      this.container.rotation = this.body.angle
    }
  }
}

Spawning Entities

For this example, we want our bouncy ball to be synced between the client and server and also spawn over time.

shared.ts
import { TestBall, TestBallArgs } from './ball.ts'
 
export const sharedInit = async game => {
  game.register('@example/test-ball', TestBall, TestBallArgs)
}
server.ts
import type { InitServer } from '@dreamlab.gg/core/sdk'
import { sharedInit } from './shared.ts'
 
/**
 * Return a random whole number between `min` and `max`
 */
const randomInt = (min: number, max: number): number => {
  return Math.floor(Math.random() * (max - min)) + min
}
 
export const init: InitServer = async game => {
  await sharedInit(game)
 
  // Spawn 50 balls, one per second
  for (let i = 0; i < 50; i++) {
    setTimeout(() => {
      game.spawn({
        // Reference the spawnable entity by name
        entity: '@example/test-ball',
 
        // Give the ball a random radius between 20 and 150
        args: { radius: randomInt(20, 150) },
 
        // Spawn the ball at a random x coordinate between -600 and 600
        transform: { position: [randomInt(-600, 600), -700] },
 
        // Give the ball a 'net/replicated' tag to automatically sync it between clients
        tags: ['net/replicated'],
      })
    }, i * 1000)
  }
}

These are the results when connecting on two clients. Notice the physics simulation is seamlessly synced:

Example 2 - Mob with Health Bar

Suppose we want to create a mob which players using the default character controller can attack.