Skip to content

Pawns and Controllers

A build with two paddles: one driven by a player through the action registry and one driven by a computer opponent that reads the world. Both paddles are the same class, and the side that differs is which controller holds the pawn.

import type { ActionBinding } from "@clockwyrks/structured-3d";
export const WIDTH = 640;
export const HEIGHT = 360;
export const COURT_HALF_WIDTH = 8;
export const COURT_HALF_HEIGHT = 4;
export const PADDLE_WIDTH = 0.4;
export const PADDLE_HEIGHT = 2.4;
export const PADDLE_DEPTH = 0.4;
export const PADDLE_SPEED = 9;
export const DEAD_ZONE = 0.1;
export const LEVEL = "duel";
export const TAGS = {
paddle: "paddle",
player: "player-paddle",
opponent: "opponent-paddle",
} as const;
export const ACTIONS = {
up: "move-up",
down: "move-down",
restart: "confirm",
} as const;
export const BINDINGS: Record<string, ActionBinding> = {
[ACTIONS.up]: { keys: ["KeyW", "ArrowUp"], kind: "analog" },
[ACTIONS.down]: { keys: ["KeyS", "ArrowDown"], kind: "analog" },
[ACTIONS.restart]: { keys: ["Enter", "Space"] },
};

move-up and move-down belong to the single-stick vocabulary and confirm is a menu action, so all three carry the layout once the engine is built with it. The court and the paddles are measured in world units, and the design size alone is logical.

import { createEngine } from "@clockwyrks/structured-3d";
import { HEIGHT, WIDTH } from "./constants";
import { duel } from "./game";
const canvas = document.querySelector<HTMLCanvasElement>("#game");
if (canvas === null) throw new Error("missing canvas #game");
const engine = createEngine({
canvas,
width: WIDTH,
height: HEIGHT,
background: "#0b0f16",
layout: "single-stick",
game: duel,
});
await engine.initialize();
await engine.run();

layout selects the touch layout before anything is registered, so each action in that layout’s vocabulary is tagged with it as the instance registers it.

import { GameInstance } from "@clockwyrks/structured-3d";
import type { GameDefinition, InitApi } from "@clockwyrks/structured-3d";
import { BINDINGS, LEVEL } from "./constants";
import { Lamp } from "./actors/lamp";
import { DuelMode } from "./levels/duel-mode";
export class DuelGame extends GameInstance<null> {
override initialize(api: InitApi): null {
for (const [action, binding] of Object.entries(BINDINGS)) {
api.input.register(action, binding);
}
return null;
}
}
export const duel: GameDefinition<null> = {
instance: DuelGame,
levels: { [LEVEL]: { mode: DuelMode, actors: [{ type: Lamp }] } },
startLevel: LEVEL,
};

The instance registers the bindings once, before the start level opens. The level declares one actor, the lamp that lights the court; the mode spawns both paddles as it adds its players.

import {
Actor,
LightComponent,
quatFromEuler,
} from "@clockwyrks/structured-3d";
export class Lamp extends Actor {
constructor() {
super();
this.attach(
new LightComponent({ light: { kind: "hemisphere", intensity: 0.6 } }),
);
const sun = this.attach(
new LightComponent({ light: { kind: "directional", intensity: 1.2 } }),
);
sun.offset.rotation = quatFromEuler(-Math.PI / 4, Math.PI / 4, 0);
}
}
import {
MeshComponent,
Pawn,
PlayerController,
vec3,
} from "@clockwyrks/structured-3d";
import type { Controller } from "@clockwyrks/structured-3d";
import {
COURT_HALF_HEIGHT,
PADDLE_DEPTH,
PADDLE_HEIGHT,
PADDLE_SPEED,
PADDLE_WIDTH,
TAGS,
} from "../constants";
export class Paddle extends Pawn {
private direction = 0;
constructor() {
super();
this.addTag(TAGS.paddle);
this.attach(
new MeshComponent({
geometry: {
kind: "box",
width: PADDLE_WIDTH,
height: PADDLE_HEIGHT,
depth: PADDLE_DEPTH,
},
material: { color: "#e6edf6" },
}),
);
}
drive(direction: number): void {
this.direction = Math.min(Math.max(direction, -1), 1);
}
override possessedBy(controller: Controller): void {
const side =
controller instanceof PlayerController ? TAGS.player : TAGS.opponent;
this.addTag(side);
}
override tick(dt: number): void {
const limit = COURT_HALF_HEIGHT - PADDLE_HEIGHT / 2;
const current = this.transform.position;
const y = current.y + this.direction * PADDLE_SPEED * dt;
this.transform.position = vec3(
current.x,
Math.min(Math.max(y, -limit), limit),
current.z,
);
this.direction = 0;
}
}

drive records an intent and tick integrates it against the delta, so the pawn holds the movement rule and the controller holds the decision. The direction returns to rest each tick, so a paddle stops when its controller stops driving. possessedBy is where the pawn learns which side it is on.

import { PlayerController } from "@clockwyrks/structured-3d";
import { Paddle } from "../actors/paddle";
import { ACTIONS } from "../constants";
export class PaddlePlayer extends PlayerController {
override tick(): void {
const pawn = this.pawn;
if (!(pawn instanceof Paddle)) return;
pawn.drive(this.input.value(ACTIONS.up) - this.input.value(ACTIONS.down));
if (this.input.pressed(ACTIONS.restart)) {
this.world.mode.restart(this);
}
}
}

value is the held read: both directions are analog, so a key reports 1 while it is down and a stick reports its deflection along that direction, and taking the axis as the difference makes holding both report 0. Up is +Y in the world, so the axis is up minus down. pressed is the edge read, true once per press and consumed by the call, so one press costs one restart, which respawns the paddle at the mode’s spawn point and possesses it.

import { AIController } from "@clockwyrks/structured-3d";
import { Paddle } from "../actors/paddle";
import { DEAD_ZONE, TAGS } from "../constants";
export class PaddleAI extends AIController {
override tick(): void {
const pawn = this.pawn;
if (!(pawn instanceof Paddle)) return;
const target = this.world.byTag(TAGS.player)[0];
if (target === undefined) return;
const delta = target.transform.position.y - pawn.transform.position.y;
if (Math.abs(delta) < DEAD_ZONE) return;
pawn.drive(Math.sign(delta));
}
}

The opponent reaches the same drive call from the world: it finds the player’s paddle by tag and chases it, and the dead zone keeps it still once aligned.

import {
GameMode,
PlayerController,
QUAT_IDENTITY,
VEC3_ONE,
vec3,
} from "@clockwyrks/structured-3d";
import type { Controller, Transform } from "@clockwyrks/structured-3d";
import { Paddle } from "../actors/paddle";
import { COURT_HALF_WIDTH } from "../constants";
import { PaddleAI } from "../controllers/paddle-ai";
import { PaddlePlayer } from "../controllers/paddle-player";
export class DuelMode extends GameMode {
override playerControllerClass = PaddlePlayer;
override pawnClass = Paddle;
override beginPlay(): void {
this.addPlayer({ name: "Player" });
this.addBot(PaddleAI, { name: "Opponent" });
this.setPhase("playing");
}
override spawnPoint(controller: Controller): Transform {
const x =
controller instanceof PlayerController
? -COURT_HALF_WIDTH
: COURT_HALF_WIDTH;
return {
position: vec3(x, 0, 0),
rotation: QUAT_IDENTITY,
scale: VEC3_ONE,
};
}
}

addPlayer builds the player state, builds a PaddlePlayer because the options name no controller, spawns a Paddle at spawnPoint, and possesses it. addBot does the same with the PaddleAI it is handed. The world’s camera starts at (0, 0, 10) looking at the origin, so both paddles, eight units to either side on the z = 0 plane, are in view without the mode posing it.

Paddle exposes drive and integrates what it is given, so a controller is free to compute that number however it likes. PaddlePlayer computes it from two actions and PaddleAI computes it from another actor’s transform, and the pawn behaves the same way under either.

Controllers tick before any actor, in the order they were added, so both paddles observe a direction set this frame. Swapping the class handed to addBot, or handing addPlayer a different controller, changes who decides.

A check drives the game the way a player does, with a controller of its own:

class HoldUp extends AIController {
override tick(): void {
(this.pawn as Paddle).drive(1);
}
}
const scripted = world.mode.addBot(HoldUp, { pawn: null });
scripted.possess(world.byTag(TAGS.player)[0] as Paddle);
await engine.advance(60);

possess unpossesses whatever controller already held that pawn, so the player’s paddle answers to the scripted controller from the next frame. The movement rule, the frame order, and the clamp against the court are the ones a player exercises, so the check measures the build rather than a path beside it.