Skip to content

fix(ecs): never generate, recycle or delete a renderer-reserved entity id - #1544

Merged
LautaroPetaccio merged 9 commits into
mainfrom
fix/ecs-reserved-entity-range
Aug 18, 2026
Merged

fix(ecs): never generate, recycle or delete a renderer-reserved entity id#1544
LautaroPetaccio merged 9 commits into
mainfrom
fix/ecs-reserved-entity-range

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

createEntityContainer can hand a scene an entity id inside the renderer-reserved range, where it collides exactly with a live remote player's avatar entity. Engine.removeEntity then purges that player's components from the scene's local ECS.

Mechanism

Two defects combine.

The free list accepts reserved entity numbers. systems/crdt/index.ts calls entityContainer.updateRemovedEntity for every inbound DELETE_ENTITY, including the renderer's tombstones for departed peers. That records a reserved entity number in removedEntities. The DELETE_ENTITY branch never consults entity state, so EntityState.Reserved cannot skip it.

The recycling loop doesn't filter them. generateEntity scans removedEntities with only a version < MAX_U16 guard and returns toEntityId(number, version + 1).

The result is not an overlapping range but the same id, because the renderer computes the same recurrence from the same stored version:

Renderer reissues a vacated slot as toEntityId(number, storedVersion + 1)
This loop recycles it as toEntityId(number, storedVersion + 1)

Reproduced against the default container — no injected container, nothing custom:

scene creates an entity, a peer disconnects (inbound DELETE_ENTITY for #40 v0),
scene releases its own entity:

engine.addEntity() -> 65576 (#40 v1)
  entity number 40 is RESERVED (renderer-owned)
  in the avatar range [32,256)? true

removeEntity compounds it by comparing the packed id against reservedStaticEntities. The version occupies the high 16 bits, so that only catches version 0 — number 32 at version 1 packs to 65568 and passes:

removeEntity(196640 (n=32, v=3)) -> true
   guard is `packed < 512`; packed = 196640 -> guard PASSES
>>> generateEntity() -> 262176 (n=32, v=4) | RESERVED? true

So a scene could 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. Over 5000 randomized ops, 7 seeded reserved numbers produced 244 reserved-range allocations, versions climbing to 24–45. getEntityState decomposed correctly all along; these now agree.

Why the recycling branch is reachable

generateEntity only scans the free list when usedEntities.size + reservedStaticEntities < entityCounter. A composite build puts scenes there permanently: DCL_MAX_COMPOSITE_ENTITY starts entityCounter past the composite's max entity while main.crdt marks fewer entities used. For one production scene that is a standing deficit of 4 with zero scene-side removals, plus one slot per removal after that:

phase 1 (no scene removals): 4 reserved of 20 allocations
phase 2 (10 removals, 30 allocations): 7 reserved of 30

What it breaks

Engine.removeEntity purged components off any entity, including renderer-owned ones. The renderer drops the scene's outgoing deletes for the avatar range, so it keeps the entity alive and never re-sends:

scene calls engine.removeEntity( 65571 (#35 v1) ) on a LIVE player
   outbound: type=2 entity=65571 component=1, type=2 entity=65571 component=1089, type=3 entity=65571
   players visible to getEntitiesWith(PlayerIdentityData, Transform): 0
   after 8 more renderer Transform packets:
     Transform          -> back (y=82)
     PlayerIdentityData -> STILL GONE

Transform returns because it is restreamed; PlayerIdentityData is sent once per peer, so it does not. The entity is left as a moving Transform with no identity, invisible to every getEntitiesWith(PlayerIdentityData, …) query. Recovery needs the peer reconnecting or the scene's subscription being recreated — neither happens on its own mid-session.

Which component is lost depends on the interleaving. entityDeleted clears data and lastSentData but never timestamps, so if the scene held the id first, the renderer's Transform PUTs lose until its own timestamp passes the retained one — roughly one packet per scene write:

scene allocated 655395 (#35 v10)   <- 7 writes, retained timestamp = 7
  renderer Transform ts=1 -> ABSENT
  renderer Transform ts=9 -> 59        <- recovers only here

Either ordering surfaces as a player with no resolvable position.

Field evidence

From a 3 h authoritative-server log where all proximity decisions route through one getEntitiesWith(PlayerIdentityData, Transform) lookup:

  • Direction of causation. The scene wrote a Transform to #32 v21 5.7 s before the renderer minted that id (Reused entity 32 version 20 → blocked scene op on 1376288Reused entity 32 version 21 (id: 1376288)). The scene's allocator invented it; the renderer collided with it afterwards.
  • Silent player removal. Of 223 player-left events, 16 had no peer disconnect within 3 s. 15 of those 16 share a millisecond with a blocked scene CRDT op; none occur without one.
  • Permanence. 13 identifiable players were removed from the authoritative scene's world model while still connected and playing. None recovered, over windows up to 24 minutes. Of the 7 who kept playing, 7/7 were broken for the rest of the session.

Host scope

Worth stating precisely, because the four hosts differ and the fix has to hold for all of them:

Host Avatar entity range Sends DELETE_ENTITY for avatars Bumps version on reuse Exact collision
headless runtime [32, 256) every departure yes yes, always
godot-explorer [32, 256) only for peers live at the scene's tick 0 yes yes, in that window
bevy-explorer 6..=405 yes, for every census.died yes yes
unity-explorer [32, 256) never — no entity-level delete in its outgoing API no, always v0 no

unity emits five per-component DELETE_COMPONENTs on disconnect instead, and since updateRemovedEntity is reachable only from DELETE_ENTITY, it never poisons the free list. It also has no reserved-range guard on scene-authored ops, so a scene write to an avatar id is applied there rather than dropped — and deletedEntities is keyed by entity number while entitiesMap is keyed by packed id, so a scene-authored DELETE_ENTITY on an avatar number would permanently tombstone that number for the scene's lifetime.

bevy is worth calling out because it picks an entirely different range — foreign players start at 6, and it reserves WORLD_ORIGIN = 5 on top of root/player/camera. Its kill bumps the generation and forwards a DeleteEntity to the scene for every death, so the collision arithmetic lands identically: a peer leaving slot 6 makes the renderer reissue new(6, 1) = 65542, which is exactly what the scene's recycling loop produces from toEntityId(6, 0 + 1).

None of this is specified. @dcl/protocol pins neither the avatar range nor the id packing, and no CRDT wire-protocol document ships with it — yet five codebases hardcode these constants independently (@dcl/ecs, the headless runtime, and the three explorers), they disagree on the range, on whether reuse bumps the version, and on whether reserved writes are rejected. bevy even disagrees with itself: dcl_component declares 6..=405 while comms declares 6..=406, and the allocator uses the latter.

The container is therefore the only place this invariant can hold regardless of which host is attached.

The fix

entity.ts — one helper, isReservedEntity(entity, reservedStaticEntities), masking the low 16 bits so it holds at every version. Used to refuse reserved numbers in removeEntity, updateRemovedEntity and updateUsedEntity, to skip them in generateEntity's recycling loop, and to short-circuit getEntityState — so the engine's classification and the container's release decision are literally the same expression.

index.tsEngine.removeEntity classifies before acting and skips the component purge only for entities the renderer streams:

const [entityNumber] = EntityUtils.fromEntityId(entity)
const isAvatarEntity =
  entityNumber >= NAMED_STATIC_ENTITIES && entityContainer.getEntityState(entity) === EntityState.Reserved

const released = entityContainer.removeEntity(entity)
if (isAvatarEntity) return released

Deliberately not the whole reserved range. The renderer denies scene component ops only on the avatar range; ops on RootEntity/PlayerEntity/CameraEntity reach it and are applied — that is how InputModifier.deleteFrom(engine.PlayerEntity) clears an input lock, and its wire frame is byte-identical to the one a removal emits. Treating those three like avatars would turn a working removal into a silent no-op.

Asking the container rather than a module constant means a custom container injected through IEngineOptions.entityContainer is handled correctly too, on a member every implementation already provides.

getEntityState on the engine is delegated rather than aliased: a detached entityContainer.getEntityState reference binds this to the engine, so a container that uses this would report a state contradicting the engine's own behaviour.

types.tsIEngine.removeEntity returns boolean: whether the id was released for reuse. Public API surface changes by exactly one line.

-    removeEntity(entity: Entity): void;
+    removeEntity(entity: Entity): boolean;

Non-breaking — widening a return type doesn't affect callers that ignore it, nothing implements IEngine, and the only Pick<IEngine, …'removeEntity'> consumers are removeEntityWithChildren / removeNetworkEntityChildrens, both fed the internal function.

Tests

test/ecs/reserved-entity-range.spec.ts, 29 tests. Full suite: 160 suites, 1231 tests.

Mutation-checked — every mutation of the changed logic is killed:

Mutation Result
isReservedEntity <<= killed (6 tests)
NAMED_STATIC_ENTITIES 3 → 0 killed (2)
NAMED_STATIC_ENTITIES 3 → 4 killed (1)
invert the purge guard killed (9)
drop the named-static lower bound killed (2)
bound from the module default, not the container killed (3)
removeEntity always returns true killed (2)
drop the mask (entity < bound) killed (5)
getEntityState aliased instead of delegated killed (1)
generateEntity reserved-skip removed survives — unreachable by construction

That last one survives because every writer into removedEntities refuses reserved numbers, so the loop can never see one; the guard is kept as a second enforcement point and the comment says so.

The CRDT output is byte-identical to main for non-reserved entities across both the renderer and network transports — outgoing bytes, onProcessEntityComponentChange sequence, and onChange callbacks. The only diff in that scenario is that main emitted DELETE_ENTITY to the renderer for a live remote player and this branch does not.

Known limitations

  • removeEntityWithChildren can complete partially and does not report it. A reserved node anywhere in a Transform tree survives while its descendants are removed, leaving its Transform.parent pointing at a removed entity. It returns void, so a caller cannot detect this. Reachable only if a scene parents a reserved entity under a scene entity; documented on the interface rather than changed, since fixing it means either skipping such subtrees or altering the public signature.
  • A non-integer reservedStaticEntities makes generateEntity's >= and isReservedEntity's < non-complementary, so a generated entity can be classified Reserved. Nothing passes one.

Not in this change

systems/crdt/index.ts purges every component for any inbound DELETE_ENTITY with no reserved-range guard. That is reachable from the network transport, which lets a peer erase a live remote player's identity and transform from a scene — which the scene then forwards onward:

after inbound DELETE_ENTITY on the NETWORK transport:
  identity=null  transform=null  container state=Reserved
  forwarded to renderer: DELETE_ENTITY e=0x00010020,
    DELETE_COMPONENT e=0x00010020 c=1, DELETE_COMPONENT e=0x00010020 c=1089

An unconditional guard there would be wrong — the renderer's tombstone for a departed player must purge — so the fix is transport-gating, a change to CRDT receive semantics that deserves its own review. Filed separately.

🤖 Generated with Claude Code

…y 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.
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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 17, 2026

Copy link
Copy Markdown

Deploying js-sdk-toolchain with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9b06596
Status: ✅  Deploy successful!
Preview URL: https://3b9d6502.js-sdk-toolchain.pages.dev
Branch Preview URL: https://fix-ecs-reserved-entity-rang.js-sdk-toolchain.pages.dev

View logs

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Test this pull request

  • The @dcl/sdk package can be tested in scenes by running

    npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/fix/ecs-reserved-entity-range/dcl-sdk-7.26.1-32144224411.commit-890c64a.tgz"
  • The @dcl/js-runtime package can be tested in scenes by running

    npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/fix/ecs-reserved-entity-range/@dcl/js-runtime/dcl-js-runtime-7.26.1-32144224411.commit-890c64a.tgz"
  • To test with npx init

    export SDK_COMMANDS="https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/fix/ecs-reserved-entity-range/dcl-sdk-commands-7.26.1-32144224411.commit-890c64a.tgz"
    npx $SDK_COMMANDS init
  • The /changerealm command to test test in-world

    /changerealm https://sdk-team-cdn.decentraland.org/ipfs/fix/ecs-reserved-entity-range-e2e
    
  • You can preview this build entering:
    https://playground.decentraland.org/?sdk-branch=fix/ecs-reserved-entity-range

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.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete — approved.

I did not find any P0/P1 issues. The changes consistently enforce the renderer-owned entity-number boundary in the allocator, entity removal, inbound tombstone handling, and component purge ordering. The regression coverage exercises the collision path, reserved removal refusal, and ordinary scene-owned removal behavior.

Security review: No security issues found.

Public API / consumer impact: This is a corrective behavior change for invalid scene attempts to remove renderer-owned entities. Existing scene-owned entity removal remains covered and unchanged.

Non-blocking follow-up: Engine.removeEntity() now has tests that assert its boolean return, but the public IEngine.removeEntity type is still void. If callers are expected to observe false for refused reserved-entity removals, consider updating that type and release notes in a follow-up.

CI status: all reported checks are passing.


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

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.
… 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).

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep re-review complete — approved.

I re-read the current head (ee5c3c9) and focused on the allocator invariant, inbound CRDT tombstone paths, component purge ordering, public API impact, tests, and security. I did not find any P0/P1 issues.

Findings:

  • P2: left one non-blocking inline comment about Engine.removeEntity using the default renderer-streamed bound even when an injected/custom entity container owns a different reserved range. This is unlikely to affect the default production path, but it is worth tightening because IEngineOptions.entityContainer is an extension seam.

Security review: No security issues found.

Public API / consumer impact: IEngine.removeEntity is now typed as returning boolean, matching runtime behavior and the API report. Callers that ignore the return remain compatible; custom implementations/mocks may need to return a boolean. I did not find a blocking downstream usage issue.

CI status: all reported checks are passing.


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

Comment thread packages/@dcl/ecs/src/engine/index.ts Outdated
// range, so deletes on RootEntity/PlayerEntity/CameraEntity DO reach it and are applied.
// Skipping those would silently break a working removal — the frame is byte-identical to
// InputModifier.deleteFrom(engine.PlayerEntity).
if (!isRendererStreamedNumber(entity)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Non-blocking: this purge decision uses isRendererStreamedNumber(entity) with the default RESERVED_STATIC_ENTITIES bound, while Engine can be constructed with an injected entityContainer whose reserved/owned range differs. In that case the container may return released === true for an entity number that the default helper still classifies as renderer-streamed, so components would be left attached to an entity the container already released. Consider tying the purge decision to the container result as well, e.g. purge when released || !isRendererStreamedNumber(entity), and add a small regression test with Engine({ entityContainer: createEntityContainer({ reservedStaticEntities: 32 }) }).

…e 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.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep re-review complete — approved.

I reviewed the current head (d8e093e) across allocator behavior, packed entity-number/version handling, CRDT tombstone paths, Engine.removeEntity purge ordering, custom entityContainer bounds, public API impact, snapshots, and the new regression coverage.

Findings:

  • No P0/P1 issues found.
  • No security issues found.
  • The previous custom-container bound concern is covered by the latest change and regression tests.
  • Remaining P2/non-blocking consideration: IEngine.removeEntity is now typed as returning boolean. That matches the implementation behavior and the new tests, and callers that ignore the result remain compatible; custom mocks/implementations may need to return a boolean after upgrading.

Consumer impact: this is a corrective behavior change for renderer-reserved entity ownership. I did not find blocking downstream usage in org search for custom entityContainer injection or removeEntity(entity implementers.

CI status: all reported checks are passing.


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

…mber

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.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep re-review complete — approved.

I reviewed the current head (3d4c50d) with a fresh pass over the allocator invariant, packed entity-number/version handling, inbound CRDT tombstones, Engine.removeEntity purge ordering, named-static vs avatar-range behavior, custom entityContainer bounds, public API impact, snapshots, and the expanded regression tests. I also ran parallel TypeScript/security/architecture/pattern/simplicity review agents and deduplicated their findings.

Findings:

  • No P0/P1 issues found.
  • No security issues found.
  • The latest change removes the optional public IEntityContainer.reservedStaticEntities surface and keeps the purge decision aligned with the container by classifying through getEntityState before removal.
  • Remaining P2/non-blocking consideration: IEngine.removeEntity is now typed as returning boolean. That matches the implementation and tests, and callers that ignore the result remain compatible; custom mocks/implementations typed as IEngine may need to return a boolean after upgrading.

Consumer impact: I re-checked org usage for IEngine / removeEntity. I found call sites that ignore the return, but no external implements IEngine implementation that would block this change. The API report is updated.

CI status: all reported checks are passing (lint, docs, test, build, CLI E2E, dependency check, Cloudflare Pages).


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

…ound 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.
…ainer'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.

@pravusjif pravusjif left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@LautaroPetaccio
LautaroPetaccio merged commit e712ef7 into main Aug 18, 2026
8 checks passed
@LautaroPetaccio
LautaroPetaccio deleted the fix/ecs-reserved-entity-range branch August 18, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants