Goal - Let’s make a wave spawner
This importable script creates a spawner in Horizon Worlds that generates enemies at a fixed interval (every second by default). Yes, I know the moral repercussions of generating more enemies in the world, but the corporation required it…
I’ve also put together a simple video talking through the tutorial if just reading through all of this freaks you out a little bit.

1. Understanding the Script Basics
Importing Dependencies
import { Component, PropTypes, Asset } from 'horizon/core';
Depending on your editor settings, this could also have an “AT” symbol like:
import { Component, PropTypes, Asset } from '@horizon/core';
Component: lets us define custom behavior for an entity.PropTypes: allows us to define editable properties (e.g., assigning a prefab in the editor).Asset: represents a prefab or saved object in Horizon Worlds. It’s a saved template that can be referenced by scripts.
Defining the Component
export class WaveSpawner extends Component<typeof WaveSpawner> {
static propsDefinition = {
enemyPrefab: { type: PropTypes.Asset },
};
- Creates a new component in your project called
WaveSpawner. - Adds one property:
enemyPrefab, which must be an Asset (your enemy prefab). This is essentially telling the spawner thing - what is the monster you want me to spawn. Suggested monster name = Charles.
Start Method
override start() {
// checks to ensure that the component was setup with an enemy to spawn
if (!this.props.enemyPrefab) {
console.error("WaveSpawner: 'enemyPrefab' property is not set.");
return;
}
// This is where we define the interval of spawn rate in millisecons adjustments are totally cool
this.async.setInterval(() => {
this.spawnEnemy();
}, 1000);
}
- This method runs when the component starts. Hence, the “override start()” bit at the beginning.
- Checks if
enemyPrefabhas been assigned. It’ll get mad at you if you forget to tell the enemy spawner what enemy–Charles–to spawn. - If yes, sets up a timer to call
spawnEnemy()every 1000 ms (1 sec). - Also, don’t forget, if the spawner is somehow despawned, then it won’t make more monsters.
Still tracking? Great!!! Let’s get into the spawning logic next, and don’t worry, we don’t need a doula.
Spawn Logic
private spawnEnemy() {
// where do we create this beautiful enemy of yours?
const spawnPosition = this.entity.position.get();
const spawnRotation = this.entity.rotation.get();
this.world.spawnAsset(
this.props.enemyPrefab!,
spawnPosition,
spawnRotation
).then(entities => {
if (entities && entities.length > 0) {
// console.log(`Spawned entity with ID: ${entities[0].id}`);
}
}).catch(error => {
console.error("Failed to spawn enemy prefab:", error);
});
}
- Uses the entity’s position and rotation as the spawn point. And by entity we mean the “spawner asset thing.”
- Spawns the assigned enemy asset prefab in the world. I.E. Charles is born where his mama “the Charles Spawner” was placed.
- Logs errors if spawning fails.
Register the Component
Component.register(WaveSpawner);
- Makes the component available in Horizon Worlds’ editor. Meaning this becomes a reusable building block in the editor and in your game!
- Next time you go to the “Add Component” menu, you’ll see a new, custom component, and if you’re smart, you’ll rename it to “Charlie’s Mama.”
2. Using the Spawner
- Create an enemy prefab
- Build your enemy (model + scripts).
- Save it as a Template Asset. Otherwise referred to as “Charles Prime.”
- Create a spawner entity
- Add an empty object (or marker) where you want enemies to appear.
- To recap - this is Charles’s mama’s location and where Charles Prime’s clones will be born.
- Attach the WaveSpawner script
- Select your spawner entity.
- Add the
WaveSpawnercomponent.
- Assign the prefab
- In the inspector, set enemyPrefab to your enemy Asset input in the spawner.
- Test
- Press Play.
- An enemy spawns every second at the spawner’s location.
- And you can change that timing in the main script as needed.
3. Key Notes
- Like we mentioned, if no enemy prefab asset is assigned, the script safely stops and prints an error. Essentially, you’re telling Charles’s Mama to spawn a Charles, but you never told her that her child should be named “Charles” or even what he looks like.
- Enemies will spawn forever until you stop the world or remove the spawner. Translation: Charle’s mom is boundless.
- Be mindful of performance since spawning a sea of infinite Charlies in someone’s headset or phone will definitely melt down those tiny GPUs.
Here’s the breakdown of the script we’ve been writing so far:
//to recap, the import might need an at symbol '@horizon/core'
import { Component, PropTypes, Asset } from 'horizon/core';
export class WaveSpawner extends Component<typeof WaveSpawner> {
// This will let the Horizon Inspector display an assignable spot for the enemy asset
static propsDefinition = {
enemyPrefab: { type: PropTypes.Asset },
};
override start() {
// Check if the enemyPrefab property has been assigned in the editor.
if (!this.props.enemyPrefab) {
console.error("WaveSpawner: 'enemyPrefab' property is not set.");
return;
}
// Set up a timer that calls the spawnEnemy function every 1000 milliseconds (1 second).
this.async.setInterval(() => {
this.spawnEnemy();
}, 1000);
}
private spawnEnemy() {
// Get the position and rotation of the spawner entity.
const spawnPosition = this.entity.position.get();
const spawnRotation = this.entity.rotation.get();
// Spawn the asset from the prefab property.
this.world.spawnAsset(
this.props.enemyPrefab!,
spawnPosition,
spawnRotation
).then(entities => {
if (entities && entities.length > 0) {
// console.log(`Spawned entity with ID: ${entities[0].id}`);
}
}).catch(error => {
console.error("Failed to spawn enemy prefab:", error);
});
}
}
Component.register(WaveSpawner);
Okay, so that’s the basics of spawning Charlies. But there’s ever-so-much-more we can do together… Take my hand, sweet developer, and let’s dive into the advanced tutorial.
Afraid? No problem, we can stop here. But do you want to shake the foundations of the Charlieverse? Let’s do it.
ADVANCED: WaveSpawnerOnePoint — Next Level Tutorial
One spawn point, three mobs, adjustable spread
This step builds on the basic WaveSpawner by letting you spawn up to three enemy prefabs at once (yay triplets) from a single spawn point, with an adjustable side‑to‑side spread so they don’t overlap (perfect for “The Tale of Three Little Charlies”).
What the Multi-spawner Does
- Spawn 1–3 prefabs simultaneously on a fixed interval.
- Use a single spawn point (either the entity this script is on, or a separate marker via
spawnPoint). Meaning lots of simultaneous Charlies, and only one mama. - Control spacing with a
spreadvalue (meters): left / center / right. This is especially handy for tower defence style projects.
The Code (drops into horizon as AdvWaveSpawnerOnePoint.ts)
import { Component, PropTypes, Asset, Entity } from 'horizon/core';
export class AdvWaveSpawnerOnePoint extends Component<typeof AdvWaveSpawnerOnePoint> {
static propsDefinition = {
// Up to three prefabs; assign 1–3
// You can make different enemies come from a "mama" for example - Charles, Harold, and Tina are spawned
// in the same moment by Charlie's mama.
// This will also be visible in the horizon editor since we're making them static props.
enemyPrefab1: { type: PropTypes.Asset },
enemyPrefab2: { type: PropTypes.Asset },
enemyPrefab3: { type: PropTypes.Asset },
// Optional explicit spawn point; if not set, uses this.entity
spawnPoint: { type: PropTypes.Entity },
// Interval between spawns (ms)
spawnMs: { type: PropTypes.Number, default: 1000 },
// Horizontal spread (meters) to avoid overlap; 0 = same position
// Usually 1 is a good starting point.
spread: { type: PropTypes.Number, default: 0 },
};
private timerId: number | null = null;
override start() {
const anyPrefab =
this.props.enemyPrefab1 || this.props.enemyPrefab2 || this.props.enemyPrefab3;
if (!anyPrefab) {
console.error("AdvWaveSpawnerOnePoint: No enemy prefab assigned.");
return;
}
this.timerId = this.async.setInterval(() => {
this.spawnGroup();
}, this.props.spawnMs);
}
private spawnGroup() {
const point = this.props.spawnPoint ?? this.entity;
const basePos = point.position.get();
const baseRot = point.rotation.get();
const slots: Array<{ prefab?: Asset; offsetX: number }> = [
{ prefab: this.props.enemyPrefab1, offsetX: -this.props.spread }, // left
{ prefab: this.props.enemyPrefab2, offsetX: 0 }, // center
{ prefab: this.props.enemyPrefab3, offsetX: +this.props.spread }, // right
];
for (const slot of slots) {
if (!slot.prefab) continue;
// Apply a simple world-space X offset to reduce overlap
const pos = new (basePos as any).constructor(
basePos.x + (slot.offsetX || 0),
basePos.y,
basePos.z
);
this.world.spawnAsset(slot.prefab, pos, baseRot).catch((err) => {
console.error("AdvWaveSpawnerOnePoint: Failed to spawn prefab:", err);
});
}
}
}
Component.register(AdvWaveSpawnerOnePoint);
Note: The
(basePos as any).constructor(...)pattern builds a new position in the same vector type thatHorizonuses.
How to set it up Multi-Spawn
- Create or choose a spawn point
- Option A: Place this component on the entity that marks your spawn location.
- Option B: Create an empty marker entity and assign it to the
spawnPointproperty.
- Assign prefabs
- Set
enemyPrefab1,enemyPrefab2,enemyPrefab3to your mob Assets. - You can assign one, two, or three prefabs. Empty slots are ignored. But you know you want to name them Charles, Harold, and Tina
- Set
- Tune cadence and spacing
spawnMs: interval between spawns (e.g.,1000for 1 second).spread: horizontal separation in meters. Try0.5to1.0for visible spacing.
- Press Play
- Watch your trio spawn in formation at the chosen point on each tick. Hurray triplets!
Tips
- Adjust Monster Variety: During runtime, you can change what asset is fed to the spawner, or just mix it up on each spawner to have a dynamic game.
- Marching behavior: Give each prefab its own movement/AI to walk forward after spawning. #GiveCharlesPurpose
- Performance: Add despawn logic to enemies (on death or after N seconds) to avoid buildup, and don’t forget to ponder briefly on the transience of all life… We’re not so different from Charles…
Troubleshooting
- Nothing spawns: Ensure at least one
enemyPrefabXis assigned and going in the right direction. If nothing is assigned you’ll see a console log. - Spawning at wrong place: Verify
spawnPointor the host entity transform. You can also go into the code and change the spawn point to whatever you prefer. - Overlap too tight: Increase
spreador add slight Z offsets inside the loop if needed.
🎵 SUPER ADVANCED: BPM Wave Spawner Tutorial
Goal
This component lets you spawn up to 3 mobs at a spawn point, but instead of a fixed interval it synchronizes spawning to musical tempo (BPM).
It supports:
- Changing BPM live
- Tap-tempo input
- Swing feel (jazz shuffle)
- Subdivisions (quarter, eighths, sixteenths, etc.)
- Spawn gating (every N beats)
- Smooth BPM transitions
Perfect for rhythm‑based gameplay synced to music. Or if you’re listening to Thriller by MJ!
The Code
import { Component, PropTypes, Asset, Entity } from 'horizon/core';
class BPMWaveSpawner extends Component<typeof BPMWaveSpawner> {
static propsDefinition = {
enemyPrefab1: { type: PropTypes.Asset },
enemyPrefab2: { type: PropTypes.Asset },
enemyPrefab3: { type: PropTypes.Asset },
spawnPoint: { type: PropTypes.Entity },
spread: { type: PropTypes.Number, default: 0.6 },
bpm: { type: PropTypes.Number, default: 120 },
initialSubdivision: { type: PropTypes.Number, default: 1 },
spawnEveryNBeats: { type: PropTypes.Number, default: 1 },
swingPercent: { type: PropTypes.Number, default: 0 },
smoothing: { type: PropTypes.Number, default: 0.2 },
minIntervalMs: { type: PropTypes.Number, default: 40 },
};
private timerRunning = false;
private beatTimeoutId: number | null = null;
private currentBpm!: number;
private targetBpm!: number;
private beatIndex = 0;
private swingToggle = false;
private subdivisionRt!: number;
private spawnEveryNBeatsRt!: number;
private tapTimes: number[] = [];
private maxTaps = 6;
private tapTimeoutMs = 2500;
override start() {
const anyPrefab = this.props.enemyPrefab1 || this.props.enemyPrefab2 || this.props.enemyPrefab3;
if (!anyPrefab) {
console.error("BPMWaveSpawner: assign at least one enemy prefab.");
return;
}
const startBpm = Math.max(1, this.props.bpm || 120);
this.currentBpm = startBpm;
this.targetBpm = startBpm;
this.subdivisionRt = Math.max(1, Math.floor(this.props.initialSubdivision || 1));
this.spawnEveryNBeatsRt = Math.max(1, Math.floor(this.props.spawnEveryNBeats || 1));
this.timerRunning = true;
this.scheduleNextBeat();
}
override onDestroy() {
this.timerRunning = false;
if (this.beatTimeoutId !== null) {
this.async.clearTimeout
? this.async.clearTimeout(this.beatTimeoutId)
: this.async.clearInterval(this.beatTimeoutId);
this.beatTimeoutId = null;
}
}
// Public API
public setBpm(newBpm: number) { if (newBpm > 0) this.targetBpm = newBpm; }
public setSubdivision(n: number) { if (n > 0) this.subdivisionRt = Math.floor(n); }
public setSpawnEveryNBeats(n: number) { if (n > 0) this.spawnEveryNBeatsRt = Math.floor(n); }
public resetPhase() { this.beatIndex = 0; this.swingToggle = false; }
public tapTempo() {
const now = Date.now();
if (this.tapTimes.length && now - this.tapTimes[this.tapTimes.length - 1] > this.tapTimeoutMs) {
this.tapTimes = [];
}
this.tapTimes.push(now);
if (this.tapTimes.length > this.maxTaps) this.tapTimes.shift();
if (this.tapTimes.length >= 2) {
const intervals = [];
for (let i = 1; i < this.tapTimes.length; i++) {
intervals.push(this.tapTimes[i] - this.tapTimes[i - 1]);
}
const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const tappedBpm = 60000 / Math.max(avgMs, 1);
this.setBpm(tappedBpm);
}
}
private scheduleNextBeat() {
if (!this.timerRunning) return;
this.currentBpm = this.lerp(this.currentBpm, this.targetBpm, this.clamp01(this.props.smoothing));
const baseBeatMs = 60000 / Math.max(this.currentBpm, 1);
const subdivMs = baseBeatMs / Math.max(this.subdivisionRt, 1);
const swing = this.clamp(this.props.swingPercent, 0, 0.9);
const intervalMs = swing > 0
? (this.swingToggle ? subdivMs * (1 - swing) : subdivMs * (1 + swing))
: subdivMs;
this.swingToggle = !this.swingToggle;
const waitMs = Math.max(intervalMs, this.props.minIntervalMs);
this.beatTimeoutId = this.async.setTimeout
? this.async.setTimeout(() => this.onBeat(), waitMs)
: this.async.setInterval(() => this.onBeat(true), waitMs);
}
private onBeat(clearIntervalFallback = false) {
if (!this.timerRunning) return;
const shouldSpawn = (this.beatIndex % Math.max(this.spawnEveryNBeatsRt, 1)) === 0;
if (shouldSpawn) this.spawnTrio();
this.beatIndex++;
if (clearIntervalFallback && this.beatTimeoutId !== null) {
this.async.clearInterval(this.beatTimeoutId);
this.beatTimeoutId = null;
}
this.scheduleNextBeat();
}
private spawnTrio() {
const point = this.props.spawnPoint ?? this.entity;
const basePos = point.position.get();
const baseRot = point.rotation.get();
const slots = [
{ prefab: this.props.enemyPrefab1, offsetX: -this.props.spread },
{ prefab: this.props.enemyPrefab2, offsetX: 0 },
{ prefab: this.props.enemyPrefab3, offsetX: +this.props.spread },
];
for (const s of slots) {
if (!s.prefab) continue;
const pos = new (basePos as any).constructor(basePos.x + (s.offsetX || 0), basePos.y, basePos.z);
this.world.spawnAsset(s.prefab, pos, baseRot).catch((err) => {
console.error("BPMWaveSpawner: spawn failed:", err);
});
}
}
private clamp01(v: number) { return Math.max(0, Math.min(1, v)); }
private clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); }
private lerp(a: number, b: number, t: number) { return a + (b - a) * this.clamp01(t); }
}
Component.register(BPMWaveSpawner);
Setup Guide
- Create your spawn point
- Place an empty entity in your world.
- Add
BPMWaveSpawnerto it (or assign it viaspawnPoint). - Just dance.
- Assign prefabs
- Set
enemyPrefab1–3to any prefabs (same or different). - Leave empty slots blank.
- Tune musical properties
bpm: starting tempoinitialSubdivision: 1=quarter, 2=eighths, 4=sixteenths…spawnEveryNBeats: e.g., 4 → only spawn once per bar (at 4/4)swingPercent: 0.15–0.25 for a swung feelspread: offset mobs left/center/right
Runtime Controls
setBpm(140)→ smoothly shift toward 140 BPMsetSubdivision(4)→ spawn on 16th notessetSpawnEveryNBeats(2)→ spawn only on every 2nd beatresetPhase()→ line up to the start of a songtapTempo()→ call this on user taps to detect BPM
Example Use Cases
- Rhythm shooter: spawn waves synced to music beats
- Dance game: enemies appear on downbeats / offbeats
- Boss fights: tempo ramps up as phase progresses (More music? More Charlies.)
- Interactive music: let players tap tempo to drive enemy waves
Tips
- To sync with a song, call
resetPhase()on the first downbeat. - Use tap tempo to align to live DJ/Band performance and discover if you actually have rhythm.
- Use smoothing for gentle tempo drifts, or set it to
1.0for instant BPM jumps. - Keep
minIntervalMs≥ 40 to avoid stutter at very high BPMs.
Enjoy creating BPM‑driven rhythm gameplay! May Charles live on and reign supreme!