Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/remove-component-set-undefined.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"miniplex": patch
---

`World.removeComponent` now assigns `undefined` to the removed component instead of using the `delete` operator.

Queries already treat a component as absent when its value is `undefined`, so this is fully transparent to `with`/`without`/`where` queries. Assigning `undefined` keeps the entity's object shape stable (avoiding V8 deoptimizations — measurably faster component removal and post-removal iteration) and routes the change through any property setters/proxies, making miniplex compatible with reactive/observed objects such as [Colyseus](https://colyseus.io) schemas.

Note: the property key is now retained on the entity (with value `undefined`) rather than removed, so checks that rely on key presence (e.g. `"foo" in entity` or `Object.keys(entity)`) will continue to see it.
67 changes: 67 additions & 0 deletions packages/core/benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,73 @@ profile("clear (with archetypes)", () => {
}
})

heading("Component Removal")

profile("removeComponent", () => {
const world = new World<Entity>()

for (let i = 0; i < entityCount; i++)
world.add({
position: { x: 0, y: i, z: 0 },
velocity: { x: 0, y: 0, z: 0 }
})

return () => {
for (const entity of world.entities) {
world.removeComponent(entity, "velocity")
}

return () => world.entities.every((e) => e.velocity === undefined)
}
})

profile("removeComponent (with archetypes)", () => {
const world = new World<Entity>()
const withVelocity = world.with("velocity").connect()

for (let i = 0; i < entityCount; i++)
world.add({
position: { x: 0, y: i, z: 0 },
velocity: { x: 0, y: 0, z: 0 }
})

return () => {
for (const entity of world.entities) {
world.removeComponent(entity, "velocity")
}

return () => withVelocity.size === 0
}
})

/* Iterate entities after a component was removed, surfacing any object-shape
deoptimization from the removal. */
profile("simulate after removeComponent", () => {
const world = new World<Entity>()

for (let i = 0; i < entityCount; i++)
world.add({
position: { x: Math.random() * 200 - 100, y: i, z: 0 },
velocity: { x: 1, y: 2, z: 3 }
})

for (const entity of world.entities) {
world.removeComponent(entity, "velocity")
}

return () => {
let i = 0
for (const { position } of world.entities) {
i++
position.x += 1
position.y += 1
position.z += 1
}

return () => i === entityCount
}
})

heading("Iteration")

profile("simulate (iterator, world)", () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export class World<E extends {} = any>
* @param entity The entity to modify.
* @param component The name of the component to remove.
*/
removeComponent(entity: E, component: keyof E) {
removeComponent<C extends keyof E>(entity: E, component: C) {
/* Return early if the entity doesn't even have the component. */
if (entity[component] === undefined) return

Expand All @@ -158,8 +158,10 @@ export class World<E extends {} = any>
this.reindex(entity, future)
}

/* Remove the component. */
delete entity[component]
/* Assign `undefined` rather than `delete`: queries treat both as absent, but
this keeps the object shape stable and works with proxied property access
(e.g. Colyseus schemas). */
entity[component] = undefined as E[C]
}

/* QUERIES */
Expand Down
8 changes: 8 additions & 0 deletions packages/core/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,14 @@ describe(World, () => {
expect(entity).toEqual({ name: "John" })
})

it("sets the component to `undefined` instead of `delete`-ing the property", () => {
const world = new World<Entity>()
const entity = world.add({ name: "John", age: 30 })
world.removeComponent(entity, "age")
expect(entity.age).toBeUndefined()
expect("age" in entity).toBe(true)
})

it("removes the entity from any relevant archetypes", () => {
const world = new World<Entity>()
const query = world.with("age").without("height")
Expand Down