Component-Based
Build game objects by composing reusable components — movement, physics, animation, and more.
Self-playing Pong. This is an embedded game not a gif.
import { Engine, Scene, Vec2 } from "@yagejs/core";import { RendererPlugin, CameraEntity } from "@yagejs/renderer";import { PhysicsPlugin } from "@yagejs/physics";import { UIPlugin } from "@yagejs/ui";import { Ball } from "./Ball";import { Paddle } from "./Paddle";import { Wall, Goal, Scoreboard } from "./GameArea";
class PongScene extends Scene { readonly name = "pong";
onEnter() { this.spawn(CameraEntity, { position: new Vec2(300, 200) });
this.spawn(Scoreboard); const ball = this.spawn(Ball, { w: 600, h: 400 }); this.spawn(Paddle, { x: 30, y: 200, ball, side: "left" }); this.spawn(Paddle, { x: 570, y: 200, ball, side: "right" }); this.spawn(Wall, { x: 300, y: -10, w: 600, h: 20 }); // top this.spawn(Wall, { x: 300, y: 410, w: 600, h: 20 }); // bottom this.spawn(Goal, { x: -20, y: 200, w: 20, h: 400, side: "left" }); this.spawn(Goal, { x: 620, y: 200, w: 20, h: 400, side: "right" }); }}
const engine = new Engine();engine.use(new RendererPlugin({ width: 600, height: 400, backgroundColor: 0x0a0a0a }));engine.use(new PhysicsPlugin({ gravity: { x: 0, y: 0 } }));engine.use(new UIPlugin());await engine.start();engine.scenes.push(new PongScene());import { defineEvent } from "@yagejs/core";
export type Side = "left" | "right";
// Typed event — any entity can emit it, any listener can subscribeexport const GoalEvent = defineEvent<{ side: Side }>("goal");import { Component, Entity, RandomKey, Transform, Vec2 } from "@yagejs/core";import { GraphicsComponent } from "@yagejs/renderer";import { RigidBodyComponent, ColliderComponent } from "@yagejs/physics";import { GoalEvent, type Side } from "./types";
// listenScene hears an event that any entity emits. It stops with the component.class Serve extends Component { private rb = this.sibling(RigidBodyComponent); private random = this.service(RandomKey); // the scene's seeded generator
constructor(private center: Vec2) { super(); }
onAdd() { // After a goal, serve again from the centre this.listenScene(GoalEvent, ({ side }) => this.launch(side === "right" ? "left" : "right")); this.launch(this.random.pick(["left", "right"] as const)); }
private launch(toward: Side) { this.rb.setPosition(this.center.x, this.center.y); this.rb.setVelocity({ x: (toward === "right" ? 1 : -1) * 250, y: this.random.range(-100, 100) }); }}
// Entities are game objects. setup() receives typed params from scene.spawn().export class Ball extends Entity { setup({ w, h }: { w: number; h: number }) { const center = new Vec2(w / 2, h / 2); this.add(new Transform({ position: center })); this.add(new GraphicsComponent().draw((g) => g.circle(0, 0, 8).fill(0xffffff))); // Dynamic body — moved by physics forces this.add(new RigidBodyComponent({ type: "dynamic", fixedRotation: true })); this.add(new ColliderComponent({ shape: { type: "circle", radius: 8 }, restitution: 1, // fully elastic bounce friction: 0, })); this.add(new Serve(center)); }}import { Entity, Component, Transform, Vec2, ProcessComponent, ProcessSlot } from "@yagejs/core";import { GraphicsComponent } from "@yagejs/renderer";import { RigidBodyComponent, ColliderComponent } from "@yagejs/physics";import type { Side } from "./types";
// Components hold game logic. sibling() grabs other components on the same entity.class PaddleAI extends Component { private rb = this.sibling(RigidBodyComponent); private transform = this.sibling(Transform); private proc = this.sibling(ProcessComponent); private slot!: ProcessSlot;
constructor(private ball: Entity, private side: Side) { super(); }
// onAdd runs once when the component is attached to an entity onAdd() { // ProcessSlot is a timer — fires onComplete every 0.35s this.slot = this.proc.slot({ duration: 0.35, onComplete: () => { this.react(); this.slot.restart(); }, }); this.slot.start(); }
private react() { const ballVel = this.ball.get(RigidBodyComponent).getVelocity(); // Only move if ball is heading toward us const approaching = this.side === "left" ? ballVel.x < 0 : ballVel.x > 0; if (!approaching) return; const diff = this.ball.get(Transform).position.y - this.transform.position.y; this.rb.applyImpulse(new Vec2(0, Math.sign(diff) * 100)); }}
export class Paddle extends Entity { setup({ x, y, ball, side }: { x: number; y: number; ball: Entity; side: Side }) { this.add(new Transform({ position: new Vec2(x, y) })); this.add(new GraphicsComponent().draw((g) => g.roundRect(-6, -36, 12, 72, 4).fill(0xffffff))); // Dynamic but locked horizontally — physics handles vertical movement this.add(new RigidBodyComponent({ type: "dynamic", fixedRotation: true, lockTranslationX: true, linearDamping: 2, })); this.add(new ColliderComponent({ shape: { type: "box", width: 12, height: 72 }, restitution: 1, friction: 0, })); this.add(new ProcessComponent()); this.add(new PaddleAI(ball, side)); }}import { Component, Entity, Transform, Vec2 } from "@yagejs/core";import { RigidBodyComponent, ColliderComponent } from "@yagejs/physics";import { UISurface, Anchor } from "@yagejs/ui";import type { UIText } from "@yagejs/ui";import { GoalEvent, type Side } from "./types";
// Static bodies — immovable, used for boundariesexport class Wall extends Entity { setup({ x, y, w, h }: { x: number; y: number; w: number; h: number }) { this.add(new Transform({ position: new Vec2(x, y) })); this.add(new RigidBodyComponent({ type: "static" })); this.add(new ColliderComponent({ shape: { type: "box", width: w, height: h }, restitution: 1, friction: 0, })); }}
// Sensor — detects overlaps without blocking movementexport class Goal extends Entity { setup({ x, y, w, h, side }: { x: number; y: number; w: number; h: number; side: Side }) { this.add(new Transform({ position: new Vec2(x, y) })); this.add(new RigidBodyComponent({ type: "static" })); const collider = new ColliderComponent({ shape: { type: "box", width: w, height: h }, sensor: true, }); this.add(collider); // When the ball enters this zone, emit a goal event collider.onTrigger((ev) => { if (ev.entered) this.emit(GoalEvent, { side }); }); }}
// Game state lives in a component: it hears every goal and updates the textclass Score extends Component { private points = { left: 0, right: 0 };
constructor(private left: UIText, private right: UIText) { super(); }
onAdd() { this.listenScene(GoalEvent, ({ side }) => { this.points[side]++; this.left.setText(String(this.points.left)); this.right.setText(String(this.points.right)); }); }}
// Screen-space UI — anchored to viewport, not affected by cameraexport class Scoreboard extends Entity { setup() { const style = { fontSize: 48, fill: 0xffffff, fontFamily: "monospace" }; const panel = this.add( new UISurface({ anchor: Anchor.TopCenter, offset: { x: 0, y: 16 }, direction: "row", gap: 60 }), ); this.add(new Score(panel.text("0", style), panel.text("0", style))); }}Component-Based
Build game objects by composing reusable components — movement, physics, animation, and more.
Plugin Architecture
Rendering, physics, input, audio, particles, tilemaps, UI — pick only what you need.
TypeScript First
Strict TypeScript with typed DI, typed events, and zero any. Full autocomplete everywhere.
Battle-Tested Foundations
PixiJS v8 for rendering, Rapier2D for physics. Production-grade libraries under the hood.
Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages.