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.
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.
Placing a level’s actors
Section titled “Placing a level’s actors”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.
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.
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.
Loading a level’s assets
Section titled “Loading a level’s assets”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.
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.
Spawning at run time
Section titled “Spawning at run time”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.
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.
Finding actors
Section titled “Finding actors”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.
Scheduling with after and every
Section titled “Scheduling with after and every”Timers count simulated world time. after runs a callback once and every
repeats it, and both return a handle clearTimer cancels.
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.
Pausing
Section titled “Pausing”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.
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.
Opening the next level
Section titled “Opening the next level”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.
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.