Skip to content

Levels and Worlds

A build declares its levels once, on the game definition the engine is created with. Each level names the game mode that runs it, lists the actors it places, and loads the assets it needs. The engine opens startLevel when it initializes, and the game travels afterwards by naming another level.

src/game.ts
import type { GameDefinition } from "@clockwyrks/structured-3d";
import { Rally } from "./instance";
import { arena, title } from "./levels";
export const game: GameDefinition = {
instance: Rally,
levels: { title, arena },
startLevel: "title",
};

The keys of levels are the names a build travels by, so a case fixes them in its constants module and every build of the case agrees on them. startLevel must be one of those keys, and instance is optional: a game that keeps nothing across a transition omits it and takes the base game instance. Every member of LevelDefinition and World is listed under worlds and levels.

actors lists the actors the level places. Each entry names a class and optionally a transform, a set of tags, and a configure step that runs on the constructed actor before it begins play.

src/levels.ts
import type { LevelDefinition } from "@clockwyrks/structured-3d";
import { UP, quatFromAxisAngle, vec3 } from "@clockwyrks/structured-3d";
import { ARENA } from "./constants";
import { ArenaMode } from "./arena-mode";
import { Goal } from "./goal";
import { PauseMenu } from "./pause-menu";
import { TitleMode } from "./title-mode";
export const title: LevelDefinition = { mode: TitleMode };
export const arena: LevelDefinition = {
mode: ArenaMode,
actors: [
{
type: Goal,
transform: {
position: vec3(-ARENA.halfWidth, 0, 0),
rotation: quatFromAxisAngle(UP, Math.PI / 2),
},
tags: ["goal"],
configure: (goal: Goal) => {
goal.side = "left";
},
},
{
type: Goal,
transform: {
position: vec3(ARENA.halfWidth, 0, 0),
rotation: quatFromAxisAngle(UP, -Math.PI / 2),
},
tags: ["goal"],
configure: (goal: Goal) => {
goal.side = "right";
},
},
{ type: PauseMenu },
],
};

A transform written here is applied field by field over the actor’s own, so an entry sets the fields that matter and leaves the rest at the identity: position at { x: 0, y: 0, z: 0 }, rotation at { x: 0, y: 0, z: 0, w: 1 }, and scale at { x: 1, y: 1, z: 1 }. A field given is given whole, so an entry that rotates an actor supplies the whole quaternion, built with quatFromAxisAngle or quatFromEuler from the math helpers rather than written by hand.

Place the scenery here and leave the pawns to the game mode. A mode’s restart spawns its pawnClass at its own spawn point, so a paddle, a ship, or a character arrives through possession rather than through the level’s list.

Every declared actor exists before any of their beginPlay runs, so an actor that needs a peer looks it up in beginPlay rather than in configure.

src/goal.ts
import { Actor, ColliderComponent } from "@clockwyrks/structured-3d";
import { ARENA } from "./constants";
export class Goal extends Actor {
side: "left" | "right" = "left";
constructor() {
super();
this.attach(
new ColliderComponent({
shape: {
kind: "box",
width: 0.5,
height: ARENA.halfHeight * 2,
depth: ARENA.depth,
},
channel: "goal",
responses: { ball: "overlap" },
}),
);
}
}

A constructor attaches components and sets defaults. Work that reads the world belongs in beginPlay, which runs once every declared actor of the level exists. The collider’s box is stated in the actor’s own axes, and the rotation the level wrote turns it with the actor.

load runs before the world is built and the engine awaits it, so every actor of the level finds its assets already decoded. Hold what it produced in the level’s own module and read it as a plain value.

src/arena-assets.ts
import * as THREE from "three";
import type { Model } from "@clockwyrks/structured-3d";
export interface ArenaAssets {
court: THREE.Texture;
paddle: Model;
}
let assets: ArenaAssets | null = null;
export function setArenaAssets(loaded: ArenaAssets): void {
assets = loaded;
}
export function arenaAssets(): ArenaAssets {
if (assets === null) throw new Error("arena assets are not loaded");
return assets;
}
export const arena: LevelDefinition = {
mode: ArenaMode,
actors: [
/* ... */
],
async load(api) {
const [court, paddle] = await Promise.all([
api.assets.loadTexture("textures/court.png"),
api.assets.loadModel("models/paddle.glb"),
]);
await api.audio.load("bounce", "audio/bounce.ogg");
setArenaAssets({ court, paddle });
},
};

A texture is what a material declaration’s map takes, and a model is what a ModelComponent clones, so an actor constructed for this level hands either to its component as a plain value. Load here what this level alone needs. An asset every level needs is loaded in the game instance’s initialize and held on the instance, where it survives every transition, and so does a cue: a cue bound in one level’s load stays bound for the rest of the run.

world.spawn constructs an actor, applies the spec, attaches it, and runs its beginPlay and each component’s beginPlay before returning. The actor is fully live when the call returns, and its first tick is the next frame.

src/arena-mode.ts
import { GameMode, vec3 } from "@clockwyrks/structured-3d";
import { BALL_SPEED } from "./constants";
import { Ball } from "./ball";
export class ArenaMode extends GameMode {
serve(): Ball {
return this.world.spawn(Ball, {
transform: { position: vec3(0, 0, 0) },
tags: ["ball"],
configure: (ball) => {
ball.velocity = vec3(BALL_SPEED, 0, 0);
},
});
}
}

spec carries the same three fields a placed actor’s entry carries, and configure receives the actor at its own type, so a spawn site sets the fields the class declares without a cast.

destroy is the other half. It marks the actor, which stops ticking and rendering at once, and the actor leaves the world at the end of the frame.

Three lookups cover what a frame asks for. Each returns live actors in spawn order, as a copy the caller owns.

const goals = this.world.byTag("goal");
const balls = this.world.ofType(Ball);
const ball = this.world.find(Ball);

byTag is the lookup a case’s validators use, so fix the tag vocabulary in the constants module and tag every actor a check needs to name. ofType and find are the typed lookups a build uses on its own classes, and find returns null when nothing matches.

tick(dt: number): void {
const ball = this.world.find(Ball);
if (ball === null) this.serve();
}

world.actors() returns everything, and world.players() returns the player controllers in index order, which is how a mode reaches the input side of the world.

Timers count simulated world time. after runs a callback once and every repeats it, and both return a handle clearTimer cancels.

src/arena-mode.ts
import { GameMode, type TimerHandle } from "@clockwyrks/structured-3d";
import { ArenaState } from "./arena-state";
export class ArenaMode extends GameMode {
declare readonly state: ArenaState;
gameStateClass = ArenaState;
private countdown: TimerHandle | null = null;
beginPlay(): void {
this.addPlayer({ name: "P1" });
this.setPhase("playing");
this.world.after(1.5, () => this.serve());
this.countdown = this.world.every(1, () => {
this.state.remaining -= 1;
if (this.state.remaining === 0) this.setPhase("over");
});
}
endPlay(): void {
if (this.countdown !== null) this.world.clearTimer(this.countdown);
}
}

ArenaState is this game’s GameState subclass, named by gameStateClass and redeclared on state so the mode reads its own match figures at their own type.

Clearing on the way out is optional, because a world clears its own timers when it closes. Clearing explicitly is what stops a repeating timer that has finished its job while the world stays open.

A paused world runs no timer, so a countdown scheduled with every holds its place across a pause without the game tracking the remainder itself.

world.setPaused suspends the controllers, the actors, their components, the timers, the collision pass, and the game mode. The world keeps rendering, so a pause screen draws over the world it suspended.

An actor that must keep running sets tickWhenPaused, and reads its action through a player controller’s input, which is the only place a game reads one.

src/pause-menu.ts
import {
Actor,
PlayerController,
TextComponent,
vec3,
} from "@clockwyrks/structured-3d";
import { HEIGHT, WIDTH } from "./constants";
export class PauseMenu extends Actor {
private player: PlayerController | null = null;
constructor() {
super();
this.tickWhenPaused = true;
const label = this.attach(new TextComponent({ text: "paused" }));
label.layer = 10;
label.offset.position = vec3(WIDTH / 2, HEIGHT / 2, 0);
}
beginPlay(): void {
this.player = this.world.players()[0] ?? null;
}
tick(): void {
if (this.player?.input.pressed("pause") === true) {
this.world.setPaused(!this.world.paused);
}
}
}

A TextComponent is a screen-space component, so its composed transform is read in logical units from the top-left of the design field, and the offset above centers the label on the canvas whatever the camera is doing.

Read the pause action in one place. Each player controller consumes an edge independently, so a second reader of the same controller’s pressed("pause") takes the edge this one is waiting for.

world.open requests a transition and returns. The engine performs it after the frame’s ticks and collision are finished and before the frame renders, so the caller returns into a world that is still whole.

tick(dt: number): void {
if (this.phase !== "over") return;
this.world.open("arena", {
round: this.round + 1,
carried: this.state.players[0].score,
});
}

The options object reaches the incoming game mode as this.options, before its beginPlay runs. It is Readonly<Record<string, unknown>>, so a mode narrows each value it reads and supplies a default for the start level, which receives an empty object.

src/arena-mode.ts
export class ArenaMode extends GameMode {
round = 1;
beginPlay(): void {
this.round =
typeof this.options.round === "number" ? this.options.round : 1;
const player = this.addPlayer({ name: "P1" });
if (typeof this.options.carried === "number") {
player.playerState.score = this.options.carried;
}
this.setPhase("playing");
}
}

One call per frame is honored, and a second call in the same frame replaces the first, so two systems that both decide to travel produce one transition.

Options carry a value into the next match. A value that must survive many transitions lives on the game instance instead, which is the one framework object a transition leaves standing. The camera is part of the world, so the incoming level starts with a camera at the defaults, and a mode that wants it elsewhere poses it or gives it a target in beginPlay.