Skip to content

Actors and Components

A build writes one class per thing in the world. The class extends Actor, attaches its components in the constructor, keeps a field for each one, and overrides the lifecycle methods it needs. Everything else about the actor is ordinary TypeScript.

Attach in the constructor, hold the result, and set the fields the actor starts with. attach returns the component it was given, so the field is assigned and attached in one statement.

import {
Actor,
ColliderComponent,
ShapeComponent,
} from "@clockwyrks/structured-2d";
import { LAYER, PADDLE, PALETTE, TAGS } from "./constants";
export class Paddle extends Actor {
readonly body: ShapeComponent;
readonly collider: ColliderComponent;
velocityY = 0;
constructor() {
super();
this.body = this.attach(
new ShapeComponent({
shape: { kind: "rect", width: PADDLE.width, height: PADDLE.height },
fill: PALETTE.paddle,
}),
);
this.body.layer = LAYER.actors;
this.collider = this.attach(
new ColliderComponent({
shape: { kind: "rect", width: PADDLE.width, height: PADDLE.height },
channel: "paddle",
responses: { ball: "block" },
}),
);
this.addTag(TAGS.paddle);
}
}

The constructor runs before the actor has a world, so it confines itself to the actor’s own contents. Anything that reads this.world or another actor belongs in beginPlay.

A level declares the actors it places, and the world spawns anything that appears later.

import type { LevelDefinition } from "@clockwyrks/structured-2d";
import { Match } from "./mode";
import { Ball } from "./ball";
import { Paddle } from "./paddle";
import { FIELD, TAGS } from "./constants";
export const court: LevelDefinition = {
mode: Match,
actors: [
{
type: Paddle,
transform: { x: 24, y: FIELD.height / 2 },
tags: [TAGS.left],
},
{ type: Paddle, transform: { x: FIELD.width - 24, y: FIELD.height / 2 } },
{ type: Ball, transform: { x: FIELD.width / 2, y: FIELD.height / 2 } },
],
};

world.spawn returns a live actor that has already begun play, so the caller configures it, stores it, or reads it back immediately.

const shard = this.world.spawn(Shard, {
transform: { x: this.transform.x, y: this.transform.y },
tags: [TAGS.debris],
configure: (s) => {
s.velocity = { x: Math.cos(angle) * 90, y: Math.sin(angle) * 90 };
},
});

configure runs before the actor begins play, so a field the actor’s own beginPlay reads is set through it rather than after spawn returns.

Every actor a level declares exists before any beginPlay runs, so this is where an actor looks up the rest of the world. Find by tag when a case fixed the vocabulary, and by type when the class is the build’s own.

import { Actor } from "@clockwyrks/structured-2d";
import { Ball } from "./ball";
import { TAGS } from "./constants";
export class Goal extends Actor {
private ball: Ball | null = null;
private posts: readonly Actor[] = [];
beginPlay(): void {
this.ball = this.world.find(Ball);
this.posts = this.world.byTag(TAGS.post);
}
}

find returns the first match or null. ofType returns every live instance of a class and byTag every live actor carrying a tag, both in spawn order and as a copy the caller owns.

tick receives the frame’s delta in seconds. Write every speed, acceleration, and cooldown per second, and multiply by the delta.

import { Actor } from "@clockwyrks/structured-2d";
import { FIELD, BALL_SPEED } from "./constants";
export class Ball extends Actor {
velocity = { x: BALL_SPEED, y: BALL_SPEED * 0.4 };
tick(dt: number): void {
this.transform.x += this.velocity.x * dt;
this.transform.y += this.velocity.y * dt;
if (this.transform.y < 0 || this.transform.y > FIELD.height) {
this.velocity.y = -this.velocity.y;
this.world.audio.play("bounce");
}
}
}

The transform is mutable in place, so movement is an assignment. Set tickEnabled to false to park an actor and its components without removing them, and tickWhenPaused to true for an actor that must keep running while the world is paused.

Behavior that belongs to a piece rather than to the whole actor goes in a component. A component extends Component, takes whatever constructor arguments it needs, and reads its actor through this.actor.

import { Component } from "@clockwyrks/structured-2d";
export class Bob extends Component {
private elapsed = 0;
constructor(
private readonly amplitude: number,
private readonly period: number,
) {
super();
}
tick(dt: number): void {
this.elapsed += dt;
const phase = (this.elapsed / this.period) * Math.PI * 2;
this.offset.y = Math.sin(phase) * this.amplitude;
}
}

Writing offset moves the component relative to its actor, so a pickup bobs while its actor stays where the level placed it. Attach it like any other component:

import { Actor, SpriteComponent } from "@clockwyrks/structured-2d";
import { Bob } from "./bob";
import { textures } from "./textures";
export class Pickup extends Actor {
constructor() {
super();
this.attach(new SpriteComponent({ image: textures.coin }));
this.attach(new Bob(4, 1.2));
}
}

Components tick in attachment order, immediately after their actor’s own tick. Set enabled to false on a component to drop its tick, its drawing, and its collision together.

Tags are how a case names things, so take the strings from the case’s constants module rather than writing them inline.

import { Actor } from "@clockwyrks/structured-2d";
import { TAGS } from "./constants";
export class Brick extends Actor {
constructor() {
super();
this.addTag(TAGS.brick);
}
weaken(): void {
this.addTag(TAGS.cracked);
}
}
const remaining = this.world.byTag(TAGS.brick).length;
const cracked = this.world
.byTag(TAGS.brick)
.filter((actor) => actor.hasTag(TAGS.cracked));

destroy marks the actor. It stops ticking, drawing, and colliding at once, and leaves the world at the end of the frame.

import { Actor, type EndPlayReason } from "@clockwyrks/structured-2d";
import { TAGS } from "./constants";
export class Brick extends Actor {
hits = 2;
hit(): void {
this.hits -= 1;
if (this.hits <= 0) this.destroy();
}
endPlay(reason: EndPlayReason): void {
if (reason === "destroyed") this.world.audio.play("brick-break");
}
}

endPlay names why the actor is leaving, so cleanup that belongs to being destroyed is separated from cleanup that belongs to the level closing. Read alive before acting on an actor a handler or a query returned earlier in the same frame.