Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Interest culling

Interest culling sends each player’s page only what stands near them. A room with fifty players spread over a 300 m map sends each page the players and props within the radius, not all of them. Each page then downloads, decodes and draws less. Culling is off by default, and the engine never turns it on for a game that did not ask, in any version.

Turn it on when the world is larger than the radius players care about: an open world, a survival map, a building game of chunks. Leave it off for an arena where every player sees the whole map, and for a card table.

interest on defineRoom turns culling on for the whole game or for one kind of room:

packages/room/test/outside/islands.ts
import { defineGame, defineRoom } from "@spawnite/engine/core";
// Islands cull at 80 m, since each is bigger than a player sees; the
// plaza holds every player in one square and culls nothing. A game with
// one kind of room sets interest beside maxPlayers instead.
export const room = defineRoom({
maxPlayers: 50,
kinds: { island: { interest: { radius: 80 } }, plaza: {} },
});
export const game = defineGame({ name: "islands", start: "island", room });

radius is in metres, from leastInterestRadius, 8, to mostInterestRadius, 1,000. A kind’s own interest wins over the game’s, and a kind with none takes the game’s.

A page holds an entity once the room sent it whole, and until it leaves the page’s view. The page’s origins are the entity the player’s camera follows, the player’s view target, and every entity the player controls: useViewTarget(), useCharacter() and readControlled(world, player) read them. An entity enters the player’s view once it stands within 1.2 times the radius of an origin, and leaves past 1.4 times, so one pacing on the edge does not flicker. So a page may hold an entity up to two fifths past the radius. Distance is measured to the surface of a sphere that holds the entity’s colliders, so a 256 m terrain stays under a player standing on its edge.

The following entities are on every page whatever the distance:

  • Every player entity, so a scoreboard lists everyone.
  • An entity with no pose, such as a match’s record or a ledger.
  • An entity marked Relevance.Always.
  • Each of the player’s own origins.
  • An entity attached to one the player’s page holds, which arrives with it.

What enters the player’s view is paced. The ground around each entity the player’s page predicts goes at once. The rest goes nearest first, a few tens of kilobytes a send, so a join or a long teleport never floods the player’s connection.

The radius is a promise: an entity within it is on the player’s page once nothing waits. The room recomputes interest 20 times a second, and at once for a spawn, a teleport or a change of what the player controls. So the promise holds for anything closing on the player’s character slower than a fifth of the radius in the time a recompute, a send and the player’s latency take. That is about 70 m/s at a 60 m radius with 100 ms of latency, and twice that at twice the radius. A development room warns of an entity that entered a page’s view already inside the radius, naming the entity, the player and the radius.

A page’s world holds a part of the room’s, whatever the setting: privacy already hides another player’s hand of cards. Under culling, a query on the page finds what stands near the player’s character alone. The following table gives the pattern for each need that would otherwise assume the whole world:

Need on the page Wrong under culling Right
A count: enemies left, players alive A query’s length on the page A count a system on the room writes into a trait every page holds
A minimap Every held entity of a kind Relevance.Always on what the map shows, such as a flag, or a list of blips the room keeps
The nearest enemy or pickup The nearest held entity, at any distance The nearest held one within readInterest(world).radius, an enemy by isTeammate
A scoreboard A query over characters The player entities, which every page holds
A reaction to something appearing A spawn event onAdd or useQuery: an entity also arrives when it enters the player’s view
A reference to another entity Assuming it resolves Handling null, as a destroy already makes necessary

A reference to an entity the player’s page does not hold reads null, as a destroyed one does. Once that entity enters the player’s view, the room sends the trait that names it again, and it reads the entity. An event names an entity the player’s page does not hold the way its WhenAbsent says: null, left out of a list, or not sent to the player’s page at all.

An entity that enters the player’s view carries CaughtUpTrait on the player’s page, as one a welcome brought does, so a view plays no spawn effect for something that stood there all along. An entity that leaves the player’s view goes as a destroy does. A view plays a death from the event that means it, never from its unmount.

The minimap marks each flag Relevance.Always, so every page holds every flag whatever the distance. It reads where each flag stands with useWorldRead, which reads again after every step, as the Hud page says:

packages/room/test/outside/Minimap.tsx
import {
definePrefab,
defineTrait,
Relevance,
Text,
TransformTrait,
useWorldRead,
type World,
} from "@spawnite/engine";
// A minimap of the capture flags in a room that culls by interest. Each
// flag is on every page whatever the distance, so the map lists every
// flag, where a query over what the page holds would list the near ones
// alone.
/** Which team a flag belongs to. */
export const FlagTrait = defineTrait("minimapFlag", { team: "" });
/** A capture flag, held by every page. */
export const flag = definePrefab("minimapFlag", {
traits: [FlagTrait()],
relevance: Relevance.Always,
});
/** One mark on the map: the flag, its team, and where it stands. */
interface FlagMark {
id: number;
team: string;
x: number;
z: number;
}
/** Every flag's place, as plain values, read again after each step, so a
* carried flag moves on the map. */
export function readFlags(world: World): FlagMark[] {
return world.query(FlagTrait, TransformTrait).map((entity) => ({
id: entity.id(),
team: entity.get(FlagTrait)?.team ?? "",
x: Math.round(entity.get(TransformTrait)?.position.x ?? 0),
z: Math.round(entity.get(TransformTrait)?.position.z ?? 0),
}));
}
/** The map, as a list a game's HUD lays out over its picture. */
export function Minimap() {
const flags = useWorldRead(readFlags);
return flags.map(({ id, team, x, z }) => (
<Text key={id}>{`${team} flag at ${x}, ${z}`}</Text>
));
}

The nearest enemy searches no further than the radius. Past it, the player’s page may still hold an enemy that is leaving, and may not yet hold a nearer one. It answers null while pending is above 0, since a nearer enemy may still be on its way. isTeammate leaves out the player’s own team, and findTeam names a team, as Reading the teams says:

packages/room/test/outside/nearestEnemy.ts
import {
HealthTrait,
isTeammate,
readInterest,
TransformTrait,
type Entity,
type World,
} from "@spawnite/engine/core";
// The nearest enemy of a character, read on the player's page in a room
// that may cull by interest. Past the radius the page may hold an enemy
// a moment longer, or not yet hold a nearer one, so the search stops at
// the radius, where every enemy is on the page once nothing waits to
// arrive.
/** The nearest entity with health that is not on `character`'s team,
* within the interest radius. Null where none stands that near, and
* while entities are still arriving after a join or a teleport, when a
* nearer one may not be on the page yet. */
export function findNearestEnemy(
world: World,
character: Entity,
): Entity | null {
const from = character.get(TransformTrait)?.position;
if (!from) return null;
const interest = readInterest(world);
if (interest.culling && interest.pending > 0) return null;
let nearest: Entity | null = null;
let nearestDistance = interest.culling ? interest.radius : Infinity;
for (const other of world.query(HealthTrait, TransformTrait)) {
if (isTeammate(world, character, other)) continue;
const distance =
other.get(TransformTrait)?.position.distanceTo(from) ?? Infinity;
if (distance > nearestDistance) continue;
nearest = other;
nearestDistance = distance;
}
return nearest;
}

The following reads answer what culling means for the code that asks:

  • readInterest(world) answers { culling: false }, or { culling: true, radius, pending }. On a page, pending counts the entities still on their way, and the radius’s promise holds once it reads 0. On the room’s world it reads the room’s setting, and in a game played alone culling is off. Code shared by every case reads its reach as interest.culling ? interest.radius : Infinity.
  • useInterest() is the same read as a hook, for a HUD.
  • isRelevant(world, entity) answers whether this world holds the entity for its game to read. On a page it is false for an entity the room removed that stands a moment longer for the readers of an event. On the room’s world, it is true for every living entity.
  • isRelevantTo(step, entity, player) answers, on the room, whether a player’s page holds an entity. It takes an authority, so a page cannot call it.
  • Relevance.Always on a prefab’s or a spawn’s relevance, or on <Entity relevance>, keeps an entity on every page. setTrait on RelevanceTrait changes it at run time, as a boss whose fight starts. The trait lives on the room alone, so a page cannot read it.

After a join or a teleport, the ground under the character arrives first and the rest follows over the next sends. A game that wants nothing drawn half-loaded shows an overlay while pending is above 0:

packages/room/test/outside/LoadingOverlay.tsx
import { Text, useInterest, type InterestStatus } from "@spawnite/engine";
// A line over the screen while the area around the character is still
// arriving, after a join or a teleport into a room that culls by
// interest. The ground under the character arrives first; the rest
// follows over the next sends.
/** What the overlay says, or null once nothing waits to reach
* the player's page. */
export function describeLoading(interest: InterestStatus): string | null {
if (!interest.culling || interest.pending === 0) return null;
return `Loading the area: ${interest.pending} to go`;
}
/** The overlay, which the game's HUD mounts once. */
export function LoadingOverlay() {
const line = describeLoading(useInterest());
return line === null ? null : <Text>{line}</Text>;
}

The room does not pause the player’s character while the area loads: the ground around the character goes at once, so the character never stands on nothing.

A drill runs your game’s room with culling forced on, without changing the game’s setting:

Terminal window
spawnite play start --interest-drill 30
spawnite play room --bots 4 --interest-drill 30

The page and the bots play with what stands within 30 m alone. A counter that undercounts or a minimap that empties shows what to change before the game turns culling on. The drill never runs in a room the platform hosts. A test that opens a room with startRoom takes interestDrill the same way.

Culling is one control here, the radius, per game or per kind of room. The following table compares it with the five engines:

What Here Roblox Unreal Unity Netcode Godot
Turn culling on defineRoom({ interest }), off by default StreamingEnabled, on for new places Always on, per actor Off; the game hides objects Off; visibility filters
The distance radius, one per kind of room StreamingTargetRadius, StreamingMinRadius NetCullDistanceSquared The game’s own test The game’s own filter
Always held Relevance.Always, changeable at run time ModelStreamingMode.Persistent bAlwaysRelevant Visible unless hidden public_visibility
Arriving in a new area The ground at once, the rest paced; no pause StreamingIntegrityMode can pause the character None general None None

PlayCanvas ships no networking of its own. A voxel or tile world built from chunks, each an entity with a grid field, sends far chunks to no page.

Culling saves bandwidth and each page’s memory for what the room streams. It does not do the following:

  • It keeps every entity in the room’s memory, and the room steps all of them but the NPCs no page wants, which stop or slow down.
  • It takes one radius per kind of room: no distance per prefab, and none per player.
  • It measures from entities: a page’s origins are what the player controls and looks through.
  • It never pauses the player’s character while an area loads.
  • It judges a shot against the room’s whole world, so a gun that reaches past the radius hits what the player’s page never drew.