Entities
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.
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.
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.
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
/[]
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 & serveronResize
-> called fromgame.resize()
Tick Loops
onPhysicsStep
-> called every physics step, client & serveronRenderFrame
-> called every frame, client only
Destruction
teardown
-> required, client & server
Other Methods
bounds
-> required, returnundefined
if an entity is boundless (ie: entities that have no physical representation in the world)isPointInside
-> required, returnfalse
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.
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.
import { TestBall, TestBallArgs } from './ball.ts'
export const sharedInit = async game => {
game.register('@example/test-ball', TestBall, TestBallArgs)
}
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.