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

Scene classes should be orchestrators: preload assets, set up the camera, spawn entities, wire up event listeners. Geometry, movement logic, and collision callbacks belong on the entities themselves.

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

// Good — the scene is a spawn list
class GameScene extends Scene {
readonly name = "game";
readonly preload = [playerTexture, jumpSfx];
onEnter() {
this.spawn(Player, { x: 120, y: 400 });
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 });
this.on(PlayerHit, () => respawnPlayer(this));
}
}

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:

// components/Oscillate.ts — shared between Coin and Hazard
export class Oscillate extends Component {
// ... sine-wave motion along an axis ...
}
// entities/Coin.ts
this.add(new Oscillate({ axis: "y", amplitude: 4, period: 1.2 }));
// entities/Hazard.ts
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
export const playerTexture = texture("/assets/player.png");
export const jumpSfx = sound("/assets/jump.wav");
export class GameScene extends Scene {
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