Skip to content

Project Layout

This page documents the recommended directory layout for a YAGE game. It’s the layout the create-yage starter templates embody and the layout that’s worked well on real projects. Not a hard rule — adapt as your game grows — but starting here will save you refactors later.

src/
├── main.ts # entry: create Engine, register plugins, push initial scene
├── scenes/
│ └── <SceneName>.ts # one file per scene
├── entities/
│ ├── <SimpleEntity>.ts # single-file class for entities that fit in ~50 lines
│ └── <ComplexEntity>/ # folder for entities that need supporting files
│ ├── index.ts # the Entity subclass
│ ├── <Entity>Controller.ts
│ └── <other entity-specific components>.ts
├── components/ # ONLY for components reused across multiple entities
│ └── <SharedComponent>.ts
└── traits/ # optional, advanced
└── <TraitToken>.ts
public/
└── assets/ # images, audio, data files — served at site root

A scene class assembles the scene: it preloads assets, sets up the camera and spawns entities. It holds no game state and no rules. Geometry belongs in entity classes. Movement, collision callbacks, event handlers and game state such as the score belong in those entities’ components.

Rule of thumb: if a scene grows past ~150 lines, that’s a signal you’re inlining entity logic. Extract entity classes and components until onEnter is a list of spawns.

import { Entity, Scene, defineEvent } from "@yagejs/core";
import { texture } from "@yagejs/renderer";
import { sound } from "@yagejs/audio";
// Good — the scene is a spawn list
class GameScene extends Scene {
readonly name = "game";
readonly preload = [playerTexture, jumpSfx];
onEnter() {
this.spawn(Hud, { key: HUD_KEY }); // hosts the score
this.spawn(Player, { x: 120, y: 400 }); // respawns itself on PlayerHit
this.spawn(Platform, { x: 800, y: 560, width: 1600, height: 60 });
this.spawn(Coin, { x: 380, y: 410 });
this.spawn(Hazard, { x: 620, y: 340, amplitude: 60, period: 2.2 });
}
}
// Asset handles and the event, declared with the scene:
const playerTexture = texture("/assets/player.png");
const jumpSfx = sound("/assets/jump.wav");
const PlayerHit = defineEvent("game:player-hit");
// Defined in entities/ and elsewhere in the game:
declare class Hud extends Entity {}
declare const HUD_KEY: string;
declare class Player extends Entity { setup(params: { x: number; y: number }): void }
declare class Platform extends Entity { setup(params: { x: number; y: number; width: number; height: number }): void }
declare class Coin extends Entity { setup(params: { x: number; y: number }): void }
declare class Hazard extends Entity { setup(params: { x: number; y: number; amplitude: number; period: number }): void }

The player’s controller component reacts to PlayerHit itself, with this.listenScene(PlayerHit, () => this.respawn()). When a component should not listen for itself, onEnter may connect the event to a component method in one line, such as this.on(PlayerHit, () => player.get(Respawn).respawn()). State Management shows where the score lives.

2. Simple entity → single file; complex entity → folder

Section titled “2. Simple entity → single file; complex entity → folder”

A simple entity is one Entity subclass that fits comfortably in one file: entities/<Name>.ts.

entities/
├── Platform.ts # 20 lines — just Transform + Graphics + physics
├── Wall.ts # 15 lines — no graphics, just a collider
└── Coin.ts # 35 lines — single trigger callback

A complex entity is one that needs supporting files next to it (a dedicated controller, multiple components, an asset module). Promote to a folder named after the entity, with an index.ts exporting the class:

entities/
├── Player/
│ ├── index.ts # the Player Entity subclass
│ ├── PlayerController.ts # reads input, drives the rigid body
│ └── PlayerInventory.ts # another player-specific component
└── Enemy/
├── index.ts
├── EnemyAI.ts
└── EnemyAnimations.ts

Start simple — promote to a folder only when you actually need the second file. Don’t pre-create folders for entities that fit in one file.

3. Entity-specific components live next to the entity

Section titled “3. Entity-specific components live next to the entity”

src/components/ is reserved for components that are used by multiple entities. A PlayerController that only the Player uses belongs in entities/Player/, not components/.

If a second entity starts using the same component, move it to components/ at that point. That’s exactly the threshold the starter’s Oscillate component crosses — it’s used by both Coin (vertical bob) and Hazard (horizontal slide), so it lives in components/Oscillate.ts:

import { Component, Entity } from "@yagejs/core";
// components/Oscillate.ts — shared between Coin and Hazard
export class Oscillate extends Component {
constructor(readonly options: { axis: "x" | "y"; amplitude: number; period: number }) {
super();
}
// ... sine-wave motion along an axis ...
}
// entities/Coin.ts
export class Coin extends Entity {
setup() {
this.add(new Oscillate({ axis: "y", amplitude: 4, period: 1.2 }));
}
}
// entities/Hazard.ts
export class Hazard extends Entity {
setup() {
this.add(new Oscillate({ axis: "x", amplitude: 60, period: 2.2 }));
}
}

If the starter had only Coin using Oscillate, the component would live at entities/Coin/Oscillate.ts instead (and Coin.ts would be promoted to a folder).

Engine creation, plugin registration, scene push. No game logic. If main.ts grows, the overflow belongs in a scene or a plugin.

// main.ts — entire file
import { Engine } from "@yagejs/core";
import { RendererPlugin } from "@yagejs/renderer";
import { PhysicsPlugin } from "@yagejs/physics";
import { InputPlugin } from "@yagejs/input";
import { GameScene } from "./scenes/GameScene.js";
const engine = new Engine({ debug: true });
engine.use(new RendererPlugin({ width: 800, height: 600, container: document.getElementById("game")! }));
engine.use(new PhysicsPlugin({ gravity: { x: 0, y: 980 } }));
engine.use(new InputPlugin({ actions: { jump: ["Space"] } }));
await engine.start();
engine.scenes.push(new GameScene());

Traits (defineTrait / trait()) are capability markers that let you query across entity types without depending on specific classes — “all Damageable things”, “all Interactable things”. They pay off once you have several entity types sharing the same capability. For simple games, skip traits/ entirely; you don’t need it until you do.

Vite serves everything in public/ from the site root. A handle declared as texture("/assets/player.png") resolves to public/assets/player.png at runtime.

Declare handles with texture() / sound() at module scope in the scene that uses them, and list them in Scene.preload — YAGE guarantees they’re loaded before onEnter runs.

scenes/GameScene.ts
import { Scene } from "@yagejs/core";
import { texture } from "@yagejs/renderer";
import { sound } from "@yagejs/audio";
export const playerTexture = texture("/assets/player.png");
export const jumpSfx = sound("/assets/jump.wav");
export class GameScene extends Scene {
readonly name = "game";
readonly preload = [playerTexture, jumpSfx];
// ...
}

Exporting the handles means entity files in entities/ can import them for use in their own setup() methods — no string duplication.

These rules describe a starting point, not a prescription. Promote when you feel friction:

SignalRefactor
Scene file > 150 lines with inline spawn helpersExtract entity classes
Entity file > 100 lines with a dedicated controller componentPromote to entities/<Name>/ folder
Same component code appearing in two entity filesMove to src/components/
main.ts contains game logicMove it into a scene or plugin
Two scenes need the same asset path constantsExport them from a shared module

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/patterns.md.