Skip to main content

Common issues and best practices

Common issues and best practices to follow when scripting custom components.

Stale references to component

When your component is passed (world, component) to add, tick, or remove callbacks, it is not always safe to reference "component" in a nested function and use it after the callback has returned. For example:

Incorrect Example

// UNSAFE CODE

ecs.registerComponent({
name: 'age-counter',
data: {
age: ecs.i32,
interval: ecs.i32,
},
add: (world, component) => {
const interval = world.time.setInterval(() => {
// This is not safe because we're accessing data after some amount of time
// has passed, it's not guaranteed to still be valid.
component.data.age += 1
}, 1000)

// This is safe because we're assigning to data within the add function
component.data.interval = interval
},
tick: (world, component) => {
console.log('I am', component.data.age, 'seconds old')
},
remove: (world, component) => {
world.time.clearTimeout(component.data.interval)
}
})

While the above code might work for a component that's used on only a single entity, since the cursor reference is reused between instances, the data.age value will not be written correctly when multiple entities are using the component.

Correct Example

ecs.registerComponent({
name: 'age-counter',
data: {
age: ecs.i32,
interval: ecs.i32,
},
add: (world, component) => {
const {eid, dataAttribute} = component
const interval = world.time.setInterval(() => {
// This is safe because we're re-aquiring a cursor at the time we need it,
// instead of using a stale cursor from before.
const data = dataAttribute.cursor(eid)
data.age += 1
}, 1000)

component.data.interval = interval
},
tick: (world, component) => {
console.log('I am', component.data.age, 'seconds old')
},
remove: (world, component) => {
world.time.clearTimeout(component.data.interval)
}
})

Above, dataAttribute is used as a stable way to access the data for a component in a nested function.

Reference the Physics Playground example project for correct state machine boolean example.

Also, an eid variable is declared instead of using component.eid because component.eid is rewritten depending on which entity is receiving a callback.

Of the arguments passed to component callbacks, these are the validity of the values:

Changes after callback exitsCan be used in a nested functionLifetime
worldNoYesValid for the duration of the experience
eidYesYes, if extracted to a variable (not accessed later as component.eid)Represents an entity. No longer valid after the entity is deleted.
schema/dataYesNoOnly valid during the initial call of the callback (not in nested functions)
schemaAttribute/dataAttributeNoYesAlways valid

Invalidated Cursors

Cursor objects are interfaces to read and write data to the ECS state. For each component, the same cursor is reused each time a cursor is requested; the same instance is simply pointed to a different location in memory. Because of this, there are times where a cursor reference can be invalidated, and no longer point to the data you expect.

// UNSAFE CODE

const cursor1 = MyComponent.get(world, entity1)
console.log(cursor1.name) // 'entity1'

const cursor2 = MyComponent.get(world, entity2)
console.log(cursor2.name) // 'entity2'


// Unexpected bugs may occur if using cursor1 after another access of the component
console.log(cursor1.name) // 'entity2'
console.log(cursor1 === cursor2) // 'true' - it's the same object, just initialized differently each time

Dangling Listeners

Try to avoid assuming that any specific object or component will exist forever. As your project evolves, or as additional studio features are released, you'll want to make sure all of your component logic is well-behaved, which includes proper cleanup of event listeners.

State machines make it very easy to clean up listeners.

Here is a good example using a state machine:

ecs.registerComponent({
name: 'Game Manager',
schema: {
// Add data that can be configured on the component.
scoreDisplay: ecs.eid, // how many coins you've collected
},
schemaDefaults: {
// Add defaults for the schema fields.
},
data: {
// Add data that cannot be configured outside of the component.
score: ecs.i32, // The integer value of the score
},
stateMachine: ({world, eid, schemaAttribute, dataAttribute}) => {
// Add score event
const coinCollect = () => {
const data = dataAttribute.cursor(eid)
data.score += 1
ecs.Ui.set(world, schemaAttribute.get(eid).scoreDisplay, {
text: data.score.toString(),
})
}

ecs.defineState('default').initial().onEnter(() => {
world.events.addListener(world.events.globalId, 'coinCollect', coinCollect)
}).onExit(() => {
world.events.removeListener(world.events.globalId, 'coinCollect', coinCollect)
})
},
})

Please also refer to the documentation on cleaning up listeners.