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 exits | Can be used in a nested function | Lifetime | |
---|---|---|---|
world | No | Yes | Valid for the duration of the experience |
eid | Yes | Yes, if extracted to a variable (not accessed later as component.eid ) | Represents an entity. No longer valid after the entity is deleted. |
schema/data | Yes | No | Only valid during the initial call of the callback (not in nested functions) |
schemaAttribute/dataAttribute | No | Yes | Always 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.