Skip to content

Commit e712ef7

Browse files
fix(ecs): never generate, recycle or delete a renderer-reserved entity id (#1544)
* fix(ecs): never generate, recycle or delete a renderer-reserved entity id `createEntityContainer` could hand a scene an entity id inside the renderer-reserved range, where it collides exactly with a live remote player's avatar entity. Two defects combine: 1. The CRDT receive path calls `updateRemovedEntity` for every inbound DELETE_ENTITY (systems/crdt/index.ts:153), including the renderer's own tombstones for departed remote players. That records a reserved entity NUMBER in `removedEntities`. Note the DELETE_ENTITY branch never consults entity state at all, so `EntityState.Reserved` cannot skip it. 2. `generateEntity`'s recycling loop scans `removedEntities` with no reserved-range filter, so it returns `toEntityId(reservedNumber, version + 1)`. The result is not merely an overlapping range but the SAME id: the avatar-communication system reissues a vacated slot as `toEntityId(number, storedVersion + 1)`, and this loop recycles it with the same expression off the same stored version. The version bits that were meant to separate the two allocators are what synchronise them. `removeEntity` then compounds it by comparing the PACKED id against `reservedStaticEntities`. The version occupies the high 16 bits, so that only catches version 0 -- number 32 version 1 packs to 65568 and passes. A scene could therefore delete a live remote player's entity, and each such removal re-armed the slot at version+1, making every poisoned slot a renewable source of collisions. `getEntityState` already decomposed correctly; these now agree. Fixes: - `generateEntity`: skip free-list entries whose number is reserved. - `removeEntity`: decompose before comparing, via a new `isReservedEntityNumber` helper used by every site. - `updateRemovedEntity` / `updateUsedEntity`: refuse reserved numbers, so the free list only ever holds numbers the container owns. Reserved numbers need no tombstone -- `getEntityState` reports them as `Reserved` before consulting `removedEntities`, so `Removed` is unreachable. - `Engine.removeEntity`: ask the container FIRST and purge components only if it accepts. This is what actually stops the damage. Purging a refused removal desynchronizes the scene from the renderer with no path back, because the outgoing DELETE ops are dropped by the scene write guard, so the renderer keeps the entity alive and never re-sends. A streamed component (Transform) recovers on the next packet; a one-shot one (PlayerIdentityData) does not, leaving a moving Transform with no identity that is invisible to every getEntitiesWith(PlayerIdentityData, ...) query the scene makes. Field impact, from a 3h world-server log: of 223 player-left events, 16 had no peer disconnect within 3s and 15 of those shared a millisecond with a blocked scene CRDT op, with none occurring without one. 13 identifiable players were silently removed from the authoritative scene's world model while still connected and playing; none recovered, over windows up to 24 minutes. Adds test/ecs/reserved-entity-range.spec.ts (11 tests), which fails 5/11 without these changes. Imports avoid the package barrel deliberately: it eagerly instantiates extended components and cannot be required from a spec without a prior codegen build. * test(ecs): cover the reserved-range guard in updateUsedEntity The `isReservedEntityNumber` guard added to `updateUsedEntity` had no test: it is unreachable from the CRDT receive path, because `getEntityState` returns `Reserved` rather than `Unknown` for those numbers and only `Unknown` calls `updateUsedEntity`. With this repo gating on 100% branch coverage, an unreachable-but-present guard fails CI. Asserted directly instead of dropping the guard, because `updateUsedEntity` is public on `IEntityContainer` and its `v > 0` branch would otherwise seed `removedEntities` with a reserved entity number — the same defect through a different door. Measured on packages/@dcl/ecs/src/engine/entity.ts: origin/main leaves 4 branches uncovered by the runnable specs; with this change it is 2, and every branch the fix introduces is covered. * fix(ecs): mask inline in the reserved check; refresh bundle snapshots CI's `test` job failed on the bundle-size and VM-allocation snapshots. The guard adds code to @dcl/ecs, so SCENE_COMPILED_JS_SIZE_PROD, MALLOC_COUNT, ALIVE_OBJS_DELTA and MEMORY_USAGE_COUNT all shift and the committed snapshots have to be regenerated. Only those metric lines changed across the 12 snapshot files — no CRDT output differs. While regenerating, dropped an avoidable allocation the guard introduced: isReservedEntityNumber called EntityUtils.fromEntityId, which builds a [number, number] tuple, purely to read the entity number — on every removeEntity / updateRemovedEntity / updateUsedEntity. Masks inline instead. `entity & MAX_U16` already lands in [0, 65535], so fromEntityId's `>>> 0` is a no-op for this comparison; verified equivalent across 104 (number, version) pairs spanning the reserved boundary and both 16-bit extremes. That saved ~0.1k of bundle size. It did NOT move MALLOC_COUNT, which stays at 17430: the boot path these snapshots measure never calls the guarded functions, so the remaining delta is the closure itself, not per-call allocation. Keeping the inline mask on its own merits. Regenerated with `UPDATE_SNAPSHOTS=true` after `make build`. Note that `make build` regenerates packages/@dcl/ecs/src/components/generated/, which is untracked — a stale copy there makes 94 of 104 test/ecs suites fail to load locally with an unrelated TouchScreenControls error. * fix(ecs): type IEngine.removeEntity as boolean, matching what it returns Review follow-up. The tests assert that removeEntity returns false for a refused reserved-entity removal, but IEngine declared it `void`, so no caller could act on that without a cast — `if (!engine.removeEntity(e))` does not compile against a void return. The mismatch predates this branch: main already ended removeEntity with `return entityContainer.removeEntity(entity)`, and IEntityContainer has always typed that as boolean. The API report shows both side by side, one `void` and one `boolean`. What changed here is that the value became meaningful — false now specifically means "refused, components untouched" — so this is the right place to stop the type lying about it. Non-breaking: widening a return type does not affect callers that ignore it, and nothing implements IEngine. The only `Pick<IEngine, ... 'removeEntity'>` consumers are removeEntityWithChildren / removeNetworkEntityChildrens in tree.ts, both fed the internal removeEntity, which already returns boolean. removeEntityWithChildren is deliberately left `void`: it delegates to removeNetworkEntityChildrens, which is genuinely void, and iterates a tree where a per-entity result has no single meaning. Public API surface changes by exactly one line, regenerated via `make build`: - removeEntity(entity: Entity): void; + removeEntity(entity: Entity): boolean; Bundle snapshots are unchanged, as expected for a types-only edit. Full suite green: 160 suites, 1216 tests. * fix(ecs): keep purging the named static entities; make the regression test real Two defects found in review of this branch. 1. Skipping the component purge for the WHOLE reserved range broke a working API. `engine.removeEntity(engine.PlayerEntity)` previously purged the scene's local components and emitted DELETE_COMPONENT on the wire; after the first version of this change it did nothing at all — no purge, no frame, no notification, and a `false` return every existing caller discards. A scene clearing player state that way would leave the player permanently locked by a stale InputModifier with nothing in the log. The justification was wrong, not just the bound: the renderer denies scene component ops only on the AVATAR range, so deletes on RootEntity/ PlayerEntity/CameraEntity do reach it and are applied — the frame is byte-identical to InputModifier.deleteFrom(engine.PlayerEntity), which demonstrably works. RESERVED_STATIC_ENTITIES was serving two different ownership regimes. Now split: the container still refuses to release ANY reserved id (that is what caused the cross-wire), while the purge is skipped only for entities the renderer streams — `isRendererStreamedNumber`, i.e. numbers 3 up to the bound. Verified both directions: removeEntity(PlayerEntity) purges again, removeEntity(#32 v1) still leaves a live avatar's components intact. 2. The headline regression test passed against the unfixed container, so it pinned nothing. `removedEntities` is a Map iterated in insertion order and the recycling loop returns the FIRST eligible entry — the old setup released scene entity 512 before recording the avatar tombstone, so 512 was handed out and the avatar key was never reached. The composite case was worse: it only marked entities used, which leaves entityCounter at 512, so `usedSize + 512 >= entityCounter` held and the recycling loop was never entered at all. Both rewritten to establish the two real preconditions — a standing allocation deficit AND the avatar key leading the insertion order — with the composite case now setting DCL_MAX_COMPOSITE_ENTITY for real rather than simulating it. Added two tests that assert the preconditions themselves, so the setup cannot silently stop reproducing. The spec now fails 8 of 16 against origin/main and passes 16/16 here. Also hoists the reserved-range predicate to module scope so there is no closure per container. Note this does NOT reduce bundle size — the second predicate needed for (1) costs more than the closure saved, so SCENE_COMPILED_JS_SIZE_PROD rises to 621.1k. Snapshots regenerated. Public API surface unchanged (the new helper is @internal). * fix(ecs): take the renderer-streamed bound from the container, not the default Review follow-up (P2). Engine.removeEntity called isRendererStreamedNumber with the module default of RESERVED_STATIC_ENTITIES, so the purge decision and the id-release decision could disagree whenever a custom container is injected via the public IEngineOptions.entityContainer seam. They are two halves of one question and have to use the same bound. Confirmed reachable, and it breaks in BOTH directions — the more damaging one being the opposite of what you might expect: createEntityContainer({ reservedStaticEntities: 64 }), entity #100 v0 container released the id? true components purged? false getEntitiesWith(C) after removal: 1 entry, still yields 100 = {"v":111} The id goes back into the recycling pool while the entity stays visible to component queries with its data intact — engine.removeEntity returns true and the entity is not removed. A caller cannot detect that. The other direction is the narrower one: with reservedStaticEntities: 1024, entity #700 is renderer-owned per the container (release refused) yet the purge ran anyway, reopening for 512..1024 exactly the corruption this branch fixes. Fix: IEntityContainer exposes the bound it enforces, and Engine.removeEntity uses it. The member is OPTIONAL, so a third-party IEntityContainer stays source-compatible; when absent the engine falls back to the module default, which is correct for any container that also uses it. Public API grows by one line: + readonly reservedStaticEntities?: number; Tests cover both directions and fail 3/21 without the change. Note the named static entities stay at numbers 0-2 regardless of the bound — root, player and camera are fixed by the protocol, not by the reserved range — and a bound below 3 degenerates safely to "always purge", matching a container that reserves nothing. Snapshots regenerated. Full suite: 160 suites, 1223 tests. * refactor(ecs): classify via getEntityState instead of a new public member Review sweep. The previous commit answered "is this entity renderer-owned?" by adding `IEntityContainer.reservedStaticEntities` and an `@internal` `isRendererStreamedNumber`. The container already answers it, on a non-optional member every implementation must provide: `getEntityState` returns `EntityState.Reserved` for exactly `(entity & MAX_U16) < reservedStaticEntities`. Using that instead removes, in order of value: - The new public API member. The api-report diff for this branch is now one line — `removeEntity(entity: Entity): boolean` — which is what a bugfix should cost. - The silent-wrong-answer path. The optional member let a third-party container that simply does not know about it inherit the 512 policy with no error; its own doc comment admitted as much. `getEntityState` cannot be got wrong, because implementing it is not optional. - The `@internal` export and its never-exercised default parameter. - ~25 lines of comment, and the re-indentation of the pre-existing purge loop. Equivalence verified over 570 (bound, number, version) combinations spanning bounds {3,64,512,1024,65535} and both 16-bit extremes: zero mismatches. Both custom-bound regression blocks from the previous commit pass unchanged, so the behaviour they pin is preserved. `getEntityState` now routes through the same `isReservedEntity` helper as the container's three guards, so the engine's classification and the container's release decision are literally the same expression rather than two that agree by convention. `NAMED_STATIC_ENTITIES` moves to engine/index.ts, next to the RootEntity/PlayerEntity/CameraEntity fields it is derived from. Tests: the named-static boundary had NO coverage — collapsing NAMED_STATIC_ENTITIES to 0 left the entire suite green, so the distinction this change spends its comments justifying was load-bearing prose. Added four cases covering both sides of it, including number 3 as the first non-named reserved number. Mutation-checked: `<` -> `<=`, 3 -> 0, 3 -> 4, inverting the purge guard, dropping the lower bound, and always returning true are all killed now. The one surviving mutant is the reserved-skip in generateEntity, which is unreachable by construction — every writer into `removedEntities` refuses reserved numbers — and the comment now says so instead of narrating history. Comments cut roughly threefold against the package's actual conventions (its longest existing prose block is 3 lines). The rationale is stated once, at the decision site in engine/index.ts, rather than three times. Fixed a wrong claim on the old NAMED_STATIC_ENTITIES docblock — "the three entity numbers a scene may legitimately author components on"; a scene authors on all its own entities, the point is that these three are reserved. Dropped the spec's barrel-import justification, which was false: test/ecs/engine.spec.ts imports the barrel and passes. Full suite: 160 suites, 1227 tests. Snapshots regenerated. * test(ecs): pass the required onChangeFunction, and pin it with an inbound message Correctness review of this branch found the only certain defects on it were inside its own regression test. The two `Engine({ entityContainer })` calls omitted `onChangeFunction`, which IEngineOptions declares as required. That is not cosmetic: engine/index.ts:310 is `options?.onChangeFunction(...)` — the optional chain guards `options`, not the member — so with a partial options object the call is `undefined(...)`. Those engines threw on their first inbound message. Every test in both blocks passed because none of them ticked. Verified directly: WITH onChangeFunction -> update() resolved, component landed WITHOUT onChangeFunction -> update() THREW: options?.onChangeFunction is not a function no options at all -> update() resolved (the optional chain short-circuits) Added a test per block that feeds a real inbound PUT through a transport, because `onChangeFunction` is only invoked from the CRDT receive path — a bare update() with no transport never reaches it, so my first two attempts at this test were vacuous. Negative control: removing the option now fails 1 of 26 with that TypeError. Also rewrote the setup comment above the recycling block. It claimed the avatar tombstone had to be recorded before any recyclable scene number "or the avatar key is never reached" — true against an unfixed container, but on this branch `updateRemovedEntity` refuses the tombstone outright, so `removedEntities` never holds that key and the loop guard is never consulted. The tests were passing for a different reason than the comment gave. Now says what the setup is actually for: making them discriminate against unfixed source. * fix(ecs): delegate engine.getEntityState instead of aliasing the container's Two findings from the review of this branch that belong with it, because the branch is what made them matter. engine/index.ts exposed `getEntityState: partialEngine.entityContainer.getEntityState` — a detached method reference, so calling `engine.getEntityState(e)` binds `this` to the engine object rather than the container. Harmless for the built-in container, whose implementation is a closure, but a custom IEntityContainer that uses `this` silently reports the WRONG state through the public API while the engine itself reports the right one, because removeEntity calls the container directly. That divergence was cosmetic before; this branch made removeEntity classify through getEntityState, so the public API would now contradict the engine's own behaviour. Delegated instead of aliased. Pinned with a class-based container that reads `this.bound`. Negative control: restoring the aliased form fails 1 of 29. Also documents on IEngine.removeEntityWithChildren that it can complete only partially and does not report it. `removeEntity` refuses renderer-reserved nodes, so a reserved node anywhere in a Transform tree survives while its descendants are removed, leaving its `Transform.parent` pointing at a removed entity. That is reachable only if a scene parents a reserved entity under a scene entity, and fixing it properly means either skipping such subtrees or returning a result — a public API change this PR should not carry. Documented rather than silently left as a surprise. Deliberately NOT fixed here: systems/crdt/index.ts purges every component for any inbound DELETE_ENTITY with no reserved-range guard, which is reachable from the network transport and lets a peer erase a live remote player's identity and transform from a scene, which the scene then forwards onward. An unconditional guard there would be wrong — the renderer's tombstone for a departed player MUST purge — so the fix is transport-gating, a separate change to the CRDT receive semantics that deserves its own review. Filing separately. Public API surface unchanged: still one line for this branch. Full suite: 160 suites, 1231 tests.
1 parent 07a0182 commit e712ef7

17 files changed

Lines changed: 509 additions & 52 deletions

packages/@dcl/ecs/src/engine/entity.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ export type IEntityContainer = {
101101
updateUsedEntity(entity: Entity): boolean
102102
}
103103

104+
/**
105+
* True when `entity`'s NUMBER falls in the renderer-reserved range, at any version.
106+
*
107+
* Masks rather than calling `EntityUtils.fromEntityId`, which allocates a tuple per call to
108+
* read one number; this runs on every inbound CRDT message. `entity & MAX_U16` already lands
109+
* in [0, 65535], so the `>>> 0` fromEntityId applies is a no-op here.
110+
*/
111+
function isReservedEntity(entity: Entity, reservedStaticEntities: number): boolean {
112+
return (entity & MAX_U16) < reservedStaticEntities
113+
}
114+
104115
/**
105116
* @public
106117
*/
@@ -143,7 +154,11 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
143154
}
144155

145156
for (const [number, version] of removedEntities.getMap()) {
146-
if (version < MAX_U16) {
157+
// Never recycle a renderer-reserved number: the renderer reissues those slots as
158+
// toEntityId(number, version + 1) too, from the same stored version, so it would hand
159+
// the scene an id belonging to a live remote player. Belt-and-braces — every writer
160+
// into `removedEntities` refuses reserved numbers, so this cannot fire today.
161+
if (number >= reservedStaticEntities && version < MAX_U16) {
147162
const entity = EntityUtils.toEntityId(number, version + 1)
148163
// If the entity is not being used, we can re-use it
149164
// If the entity was removed in this tick, we're not counting for the usedEntities, but we have it in the toRemoveEntityArray
@@ -158,7 +173,7 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
158173
}
159174

160175
function removeEntity(entity: Entity) {
161-
if (entity < reservedStaticEntities) return false
176+
if (isReservedEntity(entity, reservedStaticEntities)) return false
162177

163178
if (usedEntities.has(entity)) {
164179
usedEntities.delete(entity)
@@ -185,6 +200,12 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
185200
}
186201

187202
function updateRemovedEntity(entity: Entity) {
203+
// Called for EVERY inbound DELETE_ENTITY, including the renderer's tombstones for
204+
// departed remote players — so this is the door reserved numbers would otherwise enter
205+
// the free list through. They need no tombstone: getEntityState reports them Reserved
206+
// before it consults `removedEntities`, so `Removed` is unreachable for them anyway.
207+
if (isReservedEntity(entity, reservedStaticEntities)) return false
208+
188209
const [n, v] = EntityUtils.fromEntityId(entity)
189210

190211
// Update the removed entities map
@@ -199,6 +220,11 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
199220
}
200221

201222
function updateUsedEntity(entity: Entity) {
223+
// Same invariant as updateRemovedEntity. Unreachable from the CRDT path today
224+
// (getEntityState returns `Reserved`, never `Unknown`), but the `v > 0` branch below
225+
// would seed `removedEntities` with a reserved number.
226+
if (isReservedEntity(entity, reservedStaticEntities)) return false
227+
202228
const [n, v] = EntityUtils.fromEntityId(entity)
203229

204230
// if the entity was removed then abort fast
@@ -216,11 +242,14 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
216242
}
217243

218244
function getEntityState(entity: Entity): EntityState {
219-
const [n, v] = EntityUtils.fromEntityId(entity)
220-
if (n < reservedStaticEntities) {
245+
// Same guard as the three above, so `Engine.removeEntity` — which classifies via
246+
// getEntityState — cannot disagree with whether this container releases the id.
247+
if (isReservedEntity(entity, reservedStaticEntities)) {
221248
return EntityState.Reserved
222249
}
223250

251+
const [n, v] = EntityUtils.fromEntityId(entity)
252+
224253
if (usedEntities.has(entity)) {
225254
return EntityState.UsedEntity
226255
}

packages/@dcl/ecs/src/engine/index.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { ByteBuffer } from '../serialization/ByteBuffer'
88
import { crdtSceneSystem, OnChangeFunction } from '../systems/crdt'
99
import { ComponentDefinition } from './component'
1010
import { createComponentDefinitionFromSchema } from './lww-element-set-component-definition'
11-
import { Entity, createEntityContainer } from './entity'
11+
import { Entity, EntityState, EntityUtils, createEntityContainer } from './entity'
1212
import { ReadonlyComponentSchema } from './readonly'
1313
import { SystemItem, SystemContainer, SystemFn, SYSTEMS_REGULAR_PRIORITY } from './systems'
1414
import type {
@@ -29,6 +29,9 @@ export * from './readonly'
2929
export * from './types'
3030
export { Entity, ByteBuffer, SystemItem, OnChangeFunction }
3131

32+
/** RootEntity 0, PlayerEntity 1, CameraEntity 2 — see the engineInstance fields below. */
33+
const NAMED_STATIC_ENTITIES = 3
34+
3235
function preEngine(options?: IEngineOptions): PreEngine {
3336
const entityContainer = options?.entityContainer ?? createEntityContainer()
3437
const componentsDefinition = new Map<number, ComponentDefinition<unknown>>()
@@ -49,6 +52,20 @@ function preEngine(options?: IEngineOptions): PreEngine {
4952
return entity
5053
}
5154
function removeEntity(entity: Entity) {
55+
// The renderer streams the avatar range and drops the scene's deletes there, so purging
56+
// locally is permanent: a one-shot component like PlayerIdentityData is never re-sent, and
57+
// the entity is left as a moving Transform with no identity. The three named static
58+
// entities are reserved too, but the renderer DOES apply scene deletes on them — that is
59+
// how InputModifier.deleteFrom(engine.PlayerEntity) clears an input lock — so they must
60+
// still be purged. Asking the container keeps this in step with whether it releases the
61+
// id, including for a custom container with a different reserved range.
62+
const [entityNumber] = EntityUtils.fromEntityId(entity)
63+
const isAvatarEntity =
64+
entityNumber >= NAMED_STATIC_ENTITIES && entityContainer.getEntityState(entity) === EntityState.Reserved
65+
66+
const released = entityContainer.removeEntity(entity)
67+
if (isAvatarEntity) return released
68+
5269
for (const [, component] of componentsDefinition) {
5370
// TODO: hack for the moment.
5471
// We still need the NetworkEntity to forward this message to the SyncTransport.
@@ -57,7 +74,7 @@ function preEngine(options?: IEngineOptions): PreEngine {
5774
component.entityDeleted(entity, true)
5875
}
5976

60-
return entityContainer.removeEntity(entity)
77+
return released
6178
}
6279

6380
function removeEntityWithChildren(entity: Entity) {
@@ -337,7 +354,12 @@ export function Engine(options?: IEngineOptions): IEngine {
337354
PlayerEntity: 1 as Entity,
338355
CameraEntity: 2 as Entity,
339356

340-
getEntityState: partialEngine.entityContainer.getEntityState,
357+
// Delegated, not aliased. A detached `entityContainer.getEntityState` reference binds
358+
// `this` to the engine, so a custom IEntityContainer that uses `this` silently reports the
359+
// wrong state through the public API while the engine itself, which calls the container
360+
// directly, reports the right one. Harmless for the built-in closure-based container, but
361+
// removeEntity now classifies through getEntityState, so the two must never diverge.
362+
getEntityState: (entity: Entity) => partialEngine.entityContainer.getEntityState(entity),
341363
addTransport: crdtSystem.addTransport,
342364

343365
entityContainer: partialEngine.entityContainer

packages/@dcl/ecs/src/engine/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,19 @@ export interface IEngine {
9292
* @public
9393
* Remove all components of an entity
9494
* @param entity - entity
95+
* @returns whether the entity id was released for reuse. Ids in the renderer-reserved range
96+
* are never released, at any version. Components are still purged for
97+
* RootEntity/PlayerEntity/CameraEntity, but not for the avatar range.
9598
*/
96-
removeEntity(entity: Entity): void
99+
removeEntity(entity: Entity): boolean
97100

98101
/**
99102
* Remove all components of each entity in the tree made with Transform parenting
100103
* @param entity - the root entity of the tree
104+
*
105+
* May complete only partially and does not report it: nodes in the renderer-reserved range
106+
* are refused by `removeEntity`, so a reserved node anywhere in the tree survives while its
107+
* descendants are removed — leaving its `Transform.parent` pointing at a removed entity.
101108
*/
102109
removeEntityWithChildren(entity: Entity): void
103110

packages/@dcl/playground-assets/etc/playground-assets.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1461,7 +1461,7 @@ export interface IEngine {
14611461
registerComponentDefinition<T>(componentName: string, componentDefinition: ComponentDefinition<T>): ComponentDefinition<T>;
14621462
// (undocumented)
14631463
removeComponentDefinition(componentId: number | string): void;
1464-
removeEntity(entity: Entity): void;
1464+
removeEntity(entity: Entity): boolean;
14651465
removeEntityWithChildren(entity: Entity): void;
14661466
removeSystem(selector: string | SystemFn): boolean;
14671467
readonly RootEntity: Entity;

0 commit comments

Comments
 (0)