Entity Pooling
A game that fires twenty bullets a second builds and throws away twenty
entities a second, each with a physics body, a display object and a set of
components. EntityPool builds a small group of them once and cycles that
group: a spent bullet goes dormant and comes back on the next shot, with
nothing reallocated in between.
The pooling example runs the same fountain of physics sparks both ways —
pooled and spawn-and-destroy — with live counters, so you can see what changes.
A pooled entity
Section titled “A pooled entity”A pooled class looks like any other entity subclass, plus one required hook:
import { Entity, EntityPool, Scene, Transform, Vec2 } from "@yagejs/core";import { GraphicsComponent } from "@yagejs/renderer";import { RigidBodyComponent, ColliderComponent } from "@yagejs/physics";
class Bullet extends Entity { damage = 1;
setup() { this.add(new Transform()); this.add( new GraphicsComponent().draw((g) => { g.circle(0, 0, 4).fill({ color: 0xfbbf24 }); }), ); this.add(new RigidBodyComponent({ type: "dynamic" })); this.add(new ColliderComponent({ shape: { type: "circle", radius: 4 } })); }
// Runs every time the pool hands this bullet out. onAcquire(x: number, y: number, dir: Vec2, damage: number) { this.damage = damage; const rb = this.get(RigidBodyComponent); rb.setPosition(x, y); rb.setVelocity({ x: dir.x * 900, y: dir.y * 900 }); }}setup() runs once per member, when the pool builds it. onAcquire runs on
every acquisition, and it is where the entity returns to a known state.
Creating the pool
Section titled “Creating the pool”Create pools in onEnter(), so the members’ components can resolve the scene’s
services — the physics world and the render tree are registered by then.
class ArenaScene extends Scene { readonly name = "arena"; private bullets!: EntityPool<Bullet>;
onEnter() { this.bullets = new EntityPool(this, Bullet, { prewarm: 32 }); }}prewarm builds members up front and parks them dormant. They run their
constructor and setup(), not onAcquire — prewarming is not an acquisition.
If the entity’s setup() takes parameters, pass them as setup and the
constructor demands them, the same way scene.spawn does:
new EntityPool(this, Spark, { setup: { color: 0x38bdf8 }, prewarm: 16 });Firing and recycling
Section titled “Firing and recycling”const bullet = this.bullets.acquire(muzzleX, muzzleY, aimDir, 1);// ...on impact or when it leaves the screenthis.bullets.release(bullet);acquire’s arguments are the entity’s own onAcquire parameters, so the
compiler checks the call against the hook. release runs the optional
onRelease hook, puts the entity to sleep, and returns it to the pool.
Releasing from a collision handler is safe. The physics drain reads every collision pair before it runs any handler, so a collision queued for a member’s previous life is dropped rather than delivered to whoever acquires it next.
By default the pool is elastic: it grows whenever every member is out, and
acquire always returns a bullet.
Capping the pool
Section titled “Capping the pool”Pass maxSize to bound the total number of members. A saturated acquire
then returns undefined, and the return type says so:
const bullets = new EntityPool(this, Bullet, { maxSize: 64 });
const bullet = bullets.acquire(x, y, dir, 1);if (!bullet) return; // 64 already in flightforceAcquire always returns a bullet. On a saturated capped pool it reclaims
one: the lowest-priority member in flight is released and handed straight back,
running onRelease and then onAcquire in the same call. The default victim
is the member acquired longest ago; reclaimPriority picks another:
const sparks = new EntityPool(this, Spark, { maxSize: 200, reclaimPriority: (spark) => spark.remainingLife, // steal the faintest});
const spark = sparks.forceAcquire(x, y); // never undefinedOn an elastic pool forceAcquire just grows the pool, so it is also the call
to use when you never want to handle an empty result.
Reading the pool
Section titled “Reading the pool”size is the total member count, leased how many are out, and free how
many are ready. They are the numbers to put on a debug overlay when tuning
prewarm and maxSize.
What reuse does not reset
Section titled “What reuse does not reset”Two consequences worth planning for:
- Register entity event listeners in
setup(), not inonAcquire— otherwise every acquisition adds another copy. A listener you do add per life has to be removed again inonRelease. - Components that own a live resource use
onEnable()/onDisable()to put it to sleep and bring it back. The engine’s own components already do: physics bodies leave the simulation, visuals hide, sounds stop.
Keeping a reference to a pooled entity
Section titled “Keeping a reference to a pooled entity”entity.handle() gives you a reference that expires with the life you took it
in. Read it back through .current:
import { Entity, type EntityHandle } from "@yagejs/core";
class Turret extends Entity { private target?: EntityHandle<Enemy>;
onSpotted(enemy: Enemy) { this.target = enemy.handle(); }
update() { const enemy = this.target?.current; // undefined once that enemy is gone if (enemy) this.aimAt(enemy); }}The rule for when to reach for one:
- Handle whenever pooled entities are involved. A member can be retired
from anywhere — its own collision handler calling
destroy()releases it — so a stored plain reference can go stale without you hearing about it. - Plain reference for entities that live as long as the scene (the player, a manager), or when the same piece of code stores the reference and retires the entity, so the lifecycle is controlled right where the reference lives.
.current means “the same life”, not “active right now”: an entity switched
off with setActive(false) still resolves. A life ends when the entity is
destroyed, when its scene tears down, on every path that returns a member to
its pool — release, releaseAll, and a forceAcquire reclaim — and when
the pool is disposed, which destroys its members.
Children end their lives with their parent, so a handle on a member’s hitbox
expires when the member is released.
Handles work on any entity, pooled or not, and a handle on a non-pooled entity survives a save. Pool members are left out of snapshots, so a handle on one restores empty. See Snapshot quicksave for the serialize and restore pattern.
Timing and visibility
Section titled “Timing and visibility”A member is active, in its queries and past onEnable before onAcquire
runs, so the hook can reach its own components and its siblings. Acquire
during Update and the entity renders on the same frame; acquire during Render
or EndOfFrame and it first draws on the next one.
While a member sits in the pool it is dormant: hidden, out of the simulation,
out of every query, and absent from scene.findEntity and
scene.findEntitiesByTag. It still appears in scene.getEntities().
Lifetime and saves
Section titled “Lifetime and saves”Pools belong to the scene that created them. Scene exit disposes the pool and
destroys its members, and acquire on a disposed pool throws. Calling
dispose() yourself does the same thing early.
Mistakes the pool reports
Section titled “Mistakes the pool reports”destroy() on a member is a release. Retirement is usually decided
somewhere that holds a plain entity and no pool reference — a collision
handler, an update, an event listener — so destroy() sends a pool member
back to its pool instead of tearing it down:
// The player is hit. `event.other` is an Entity; it may or may not be pooled.onCollision((event) => { this.takeDamage(10); event.other.destroy(); // pooled -> released; not pooled -> destroyed});The same game code therefore works whether or not the bullet came from a pool,
which is what lets you add pooling without hunting down every retire site. Two
consequences to know: isDestroyed stays false for a member you “destroyed”,
and destroying an entity that has a member below it detaches that member and
returns it rather than destroying it. Only dispose() destroys members, which
the scene does on exit.
Releasing an entity the pool has not leased is a no-op with a warning: a double
release, or a member of another pool. Calling setActive on a leased member
from outside does not return it to the pool either — only release does.
A throwing onAcquire leaves the member leased and active; a throwing
onRelease still parks it. Both are attributed to the entity and then
propagate, exactly like a throw from update().