
A addon for MCBE that despawns mobs no player can currently see (out
of FOV *and* behind a block) and restores them - position, rotation, health,
name tag - once a player can see that spot again. This is the same idea as
client-side "Entity Culling" mods, reimplemented server-side with the
`@minecraft/server` scripting API, since Bedrock add-ons can't hook the
renderer directly.
Built against **Minecraft Bedrock 1.26.40**, using `@minecraft/server` v2.9.0
(stable - no experimental/cheats toggles required).


XP clumps (New!)
A separate subsystem from culling (`xpClumps.js`), with its own loop, its
own enable flag, and its own stats. It reduces XP orb counts near players
without ever destroying or creating XP.
**Why it can't just merge orbs directly.** An orb's XP value isn't readable
through the stable script API, so the obvious approach read N orbs, remove
them, spawn one worth the total - would be guessing at the amount.
(`Dimension.spawnXp()` exists but is pre-release, so it isn't relied on
either.) Instead the system only ever *moves* orbs and lets the engine do
the rest:
- Orbs within `instantCollectRadius` (default 0.8 blocks) of a player get
nudged into that player, so vanilla's own pickup code awards the exact XP.
- Remaining orbs are grouped and moved onto a shared anchor, so Bedrock's
native orb-merging combines their values.
**Nothing in `xpClumps.js` calls `remove()` on an orb.** The engine is the
only thing that ever deletes one - which is what makes XP loss or
duplication structurally impossible here rather than just unlikely. If a
merge doesn't happen for some reason, the orbs are still sitting there and
the next pass retries; the cost is a wasted teleport, not lost XP.
Other design notes:
- **XP orbs are never culled.** `filters.js` hard-blocks
`minecraft:xp_orb` with no config flag, alongside players. Culling one
would save its type and position while silently losing its value.
- **Groups are anchor-based, not chained.** Every member must be within
`mergeRadius` of the anchor itself - otherwise a chain of 2-block hops
would collapse a 6-block-wide spread into one point.
- **The anchor is the orb with the most neighbours**, so it sits near the
middle of a cluster and the orbs that move travel less.
- **A spatial hash** keeps neighbour lookups local instead of comparing
every orb against every other one.
- **`maxProcessedPerTick`** (default 20) caps how many orbs move per pass,
so a big farm dumping hundreds of orbs at once can't spike a tick - the
count still drops quickly, just over several passes.
- **A short cooldown** after moving an orb gives the engine time to
actually merge it before the next pass looks at it again.
- **Merge success is verified, not assumed.** Moved groups are rechecked a
few ticks later; a member that no longer exists was absorbed. That's why
`/cull:stats` reports merge *attempts* and *successful* merges
separately.
| Command | What it does |
| --- | --- |
| `/cull:xpclumps on` / `off` | Enable/disable XP clumping |
| `/cull:xpclumps status` | Show current state and the active radii/interval |
`/cull:off` disables culling only - XP clumping keeps running. Likewise
`/cull:xpclumps off` leaves culling alone. The tuning values
(`mergeRadius`, `scanRadius`, `checkIntervalTicks`, `maxProcessedPerTick`,
`minOrbsToMerge`, `mergeCooldownTicks`) live in `config.js` under
`xpClumps` and aren't exposed as commands yet.


How it works:
1. Every few ticks, each player's nearby entities are scanned
(`scanner.js`), out to `maxDistance` (default 64 blocks).
2. Entities are filtered down to ones that are safe to touch
(`filters.js`), following a fixed precedence: protected entities (no
players, no XP orbs, no paintings/armor stands/cushions, no bosses, no
villagers/traders, no leashed/ridden/inventoried mobs, and by default no
custom-named entities) are never cullable, no matter what; then the
blacklist; then the whitelist (if you've set one); and finally the
hostile/passive/items category toggles. See "Filters
& protection" below.
3. For each remaining candidate, we check whether *any* player can see it
(`visibility.js`, combining a view-cone test and a block-occlusion
test). An entity only counts as hidden if nobody can see it - this is
deliberately multiplayer-safe.
4. If an entity stays hidden for `cullDelayTicks` in a row (default 15
ticks, ~0.75s) AND is within `cullMaxDistance` (default 32 blocks) of
the nearest player, its state is captured (`entityState.js`) into a
small persistent database (`database.js`, stored in world dynamic
properties) and the real entity is removed (`culling.js`). Hidden
entities farther than `cullMaxDistance` are left alone entirely - see
"Playing nicely with vanilla despawning" below. `cullDelayTicks` is the
"recently visible" grace period: it's what stops a quick look-away/
look-back from despawning and immediately re-summoning the same entity,
and it's live-tunable with `/cull:grace <ticks>`. While that timer is
running the entity is rechecked on *every* scan pass (skipping distance
tiering and load throttling), so any moment it becomes visible resets
the timer. Because visibility is sampled once per scan pass, the grace
period is a *minimum* hidden time in whole passes (with the default
5-tick scan, 15 ticks culls ~15-20 ticks after the entity is first seen
hidden), not an exact duration.
5. Every culled record's nearest-player distance is checked on *every*
restore pass, for either of two reasons: the moment nobody is within
`cullMaxDistance` of it anymore (while someone is still within
`restoreDistance`, default 64 blocks), it's restored immediately,
regardless of visibility and regardless of its normal recheck timer -
see "Avoiding mob duplication" below for why that one can't afford to
wait. Otherwise, while it's still within `cullMaxDistance`, it falls
back to the normal timer: current player visibility is checked, and the
moment a player could see that spot again, a fresh entity is
`/summon`-ed there and the saved state is reapplied (`restore.js`).
6. `main.js` just wires all of the above together and registers the
`/cull:*` commands (`commands.js`).

Architecture:
┌─────────────────────┐
│ Player(s) │
└──────────┬──────────┘
│
every 2–5 ticks
│
▼
┌────────────────────────┐
│ Entity Scanner │
│ nearby entities │
└───────────┬────────────┘
│
┌─────────▼──────────┐
│ FOV Check │
│ Is entity inside │
│ player's FOV? │
└───────┬─────┬──────┘
YES NO
│ │
keep it ▼
┌───────────────┐
│ Block Raycast │
│ Is wall in │
│ front of mob? │
└───────┬───────┘
│
┌───────┴────────┐
│ │
NO wall WALL
│ │
keep mob SAVE STATE
│
▼
DESPAWN MOB
│
▼
Culling Database
│
player looks toward area
│
▼
RESTORE ENTITY
│
▼
restore
**Note this architecture is based of an old version of the pack.

Monster population reservation (v1.17.0)
Hostile entities that are culled into the persistent database now reserve an
estimated monster-population slot. This prevents Bedrock from immediately
refilling the slots of hidden hostile mobs and then restoring the hidden mobs
on top of those new spawns.
The stable Script API does not expose Bedrock's internal population counter,
so this is an approximation:
`effective population = live monsters + reserved culled monsters`
Natural `EntityInitializationCause.Spawned` monster spawns are observed after
they appear and are removed only when the estimated local population is already
at the configured cap. Born/event/loaded/transformed entities are ignored, and
restore spawns are explicitly marked as internal.
Defaults:
- monster cap: 70
- population radius: 128 blocks
- herd overflow allowance: 3
- maximum blocked natural spawns per tick: 20
- reservation reconciliation: every 200 ticks
Reservations are rebuilt from the existing culling database on startup, so no
additional persistent database is required. `/cull:stats` and `/cull:status`
show reservation information.

Config migration:
Saved settings are versioned (`config.js`'s `CONFIG_VERSION`). On load, an
older save is automatically migrated forward - old field names are mapped
to their new equivalents and any newly-added settings (like `protection`
and `filters`) are filled in with defaults - then immediately re-saved in
the current shape, so migration only ever runs once per world.

Commands:
(These commands are FULLY optional and for debugging purposes only also the culling starts automatically at world startup so you do NOT need to run /cull:on)
All of these are optional and require operator permission and cheats enabled:
| Command | Effect |
|---|---|
| `/cull:on` | Enable the system |
| `/cull:off` | Disable it and immediately restore everything currently culled |
| `/cull:toggle` | Flip on/off in one command - off also restores everything, same as `/cull:off` |
| `/cull:debug` | Toggle chat messages on every cull/restore |
| `/cull:xpclumps <on\|off\|status>` | XP orb clumping - separate subsystem, unaffected by `/cull:off` (see "XP clumps") |
| `/cull:stats [reset]` | Show culling statistics (currently culled, lifetime culled/restored by category, raycasts vs. skipped, failed captures/restores), or reset the lifetime counters |
| `/cull:status` | Detailed status panel: enabled state, culled/pending counts, active settings, filter counts, and a full database integrity breakdown (records, shards, failed/missing/orphaned shards, duplicate ids, invalid records, metadata health) |
| `/cull:distance <blocks>` | Set scan/restore range (8-128) |
| `/cull:cullrange <blocks>` | Set how close an entity must be to actually get culled (4-`maxDistance`) |
| `/cull:fov <degrees>` | Set the view-cone angle (30-180) |
| `/cull:grace <ticks>` | Set the "recently visible" grace period - how long an entity must stay continuously hidden before it's culled (0-100 ticks, default 15) |
| `/cull:blacklist <add\|remove\|clear\|list> [type]` | Entity types that are never culled, on top of the built-in protections |
| `/cull:whitelist <add\|remove\|clear\|list> [type]` | If set, *only* these entity types may be culled (still subject to the hostile/passive/items toggles) |
| `/cull:combatevents <anger\|calm> <add\|remove\|clear\|list> [eventId]` | Extra `dataDrivenEntityTrigger` event ids treated as bonus anger/calm signals for combat protection, on top of the built-in guessed list (see "Combat-aware culling protection") |
| `/cull:cleanup` | Clean up the culling database: compact it, reclaim orphaned shards, and drop unrestorable records |
| `/cull:reload` | Reset all settings to defaults |
| `/cull:help` | List these commands in-game |
Custom entities outside the `minecraft:` namespace are `unknown`. Use `/cull:category <category> <on|off>` to change these at runtime. The older `/cull:unknown <protect|passive|all>` command remains as a compatibility alias: `protect` disables the unknown category, while `passive` and `all` enable it (use /cull:unkown passive or /cull:unkown all to cull entities from other mods).
`/cull:blacklist`/`/cull:whitelist` accept either a bare id (`zombie`) or a
full type id (`minecraft:zombie`) - a bare id is assumed to be
`minecraft:<id>`. Example: `/cull:blacklist add armor_stand`.
Settings persist across restarts. Scan/restore timing (`scanIntervalTicks`,
`restoreCheckIntervalTicks`, `lowLoadThreshold`, `highLoadThreshold`) aren't
exposed as commands - edit the defaults in `config.js` before packaging if
you want to change those. `cullDelayTicks` (the "recently visible" grace
period) is the exception - it's live-tunable with `/cull:grace <ticks>`.

inspired by tr7zw's Entity Culling mod <3.