Skip to content
Draft
22 changes: 18 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# TeEngine Architecture

TeEngine is a **simple 2D TypeScript game engine** with **WebGPU** rendering, **systems-based ECS**, and **Rapier physics**. It ships as the **`teengine` npm package** — see [PACKAGE.md](./PACKAGE.md) for layout.
TeEngine is a **simple 2D TypeScript game engine** with **WebGPU** rendering, **systems-based ECS**, and **Rapier physics**. It ships as the **`teengine` npm package**.

See [MODULES.md](./MODULES.md) for what belongs in the engine vs your game vs examples.

## Layer stack

Expand All @@ -26,15 +28,15 @@ PhysicsBridge (Rapier 2D) ←→ World.fixedUpdate()

## ECS + Systems

Entities are component bags. Behavior lives in **systems**:
Entities are component bags. Behavior lives in **systems** you write:

```ts
world.addFixedSystem(new SpinSystem());
world.addRenderSystem(new WorldEntityRenderSystem(graphics));
world.addRenderSystem(new CameraFollowSystem(worldCamera));
```

Game-specific systems (e.g. `PlayerControllerSystem`) live in your app, not the package.
Built-in systems (`SpinSystem`, `CameraFollowSystem`, `WorldEntityRenderSystem`) are small rendering/utility helpers. **Movement, AI, game rules** — your systems. See `examples/demo/PlayerControllerSystem.ts` for one approach.

## Game loop

Expand Down Expand Up @@ -72,6 +74,8 @@ packages/teengine/src/

## Roadmap

### Done

- [x] WebGPU + cameras + layers + sprites
- [x] Entity system + fixed timestep + systems
- [x] Input system
Expand All @@ -81,4 +85,14 @@ packages/teengine/src/
- [x] JSON atlas loader
- [x] npm package layout
- [x] Collision events / sensors
- [ ] Kinematic character controller

### Engine quality (next)

- [x] Remove demo tags from core entity types (`tags: Set<string>` + demo constants in examples)
- [x] Seal public API — no demo atlas types, game-named collision presets, or coord internals
- [ ] ECS query helpers
- [ ] Small math exports (`Vec2`, utilities)
- [ ] Asset load cache + GPU release
- [ ] Physics stepping performance

See [MODULES.md](./MODULES.md) for scope boundaries.
122 changes: 122 additions & 0 deletions docs/MODULES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# What belongs in TeEngine

TeEngine is the **simplest useful 2D game engine** — not a platformer kit, not a grab bag of game recipes.

**One rule:** the package gives you primitives to **run, draw, simulate, and input**. Everything that decides *how a game plays* is yours to build on top.

---

## In the engine (`packages/teengine`)

These are the only subsystems. If it does not fit here, it does not ship in the package.

| Module | Responsibility |
|--------|----------------|
| **engine** | Fixed timestep loop, pause, resize |
| **graphics** | Cameras, layers, sprites, shapes |
| **gpu** | WebGPU batching (internal, not exported) |
| **ecs** | Entities, components, systems, render interpolation |
| **input** | Keyboard, mouse, action map |
| **physics** | Rapier bridge — bodies, colliders, sensors, collision events, sync |
| **assets** | Load atlases onto the GPU |
| **math** | Small shared types (`Color`; grow only when the engine itself needs them) |

### What physics gives you (and nothing more)

- Create bodies: `dynamic`, `fixed`, `kinematicPosition`
- Set velocity, apply impulse, step the world
- Collision layers (`layers()`, `COLLIDE_ALL`) — you define your own bit constants
- Enter/exit events, sensors
- Engine Y-down ↔ Rapier Y-up handled for you (not exported)

That is enough to build **any** movement style — platformer, top-down, vehicle, point-and-click — in your own systems. The engine does not pick one.

### What the engine deliberately omits

| Not in engine | Why |
|---------------|-----|
| Character controller | Gameplay. You write a `FixedSystem`. |
| Animation / state machines | Gameplay / content pipeline. |
| Scene manager | App structure. You wire `Engine.setLoop`. |
| Tilemaps | Content format + renderer. Add when a game needs it. |
| Audio | Separate concern. |
| UI / text | Use DOM or your UI library. |
| Game-specific tags / atlas shapes | Demo defines its own in `examples/demo/` |

---

## Public API (`teengine`)

Single entry point — `packages/teengine/src/index.ts` is the only contract:

- **Run:** `Engine`, loop callbacks, fixed timestep constants
- **Draw:** `Graphics`, `Camera2D`, `Layers`, `Color`, shape/sprite draw types
- **Simulate:** `World`, `Entity`, `Transform`, `hasPhysics`, `isSimulatedBody`, physics bridge + collision helpers
- **Input:** `Input`, `ActionMap`
- **Assets:** `loadAtlasFromJson(engine, …)`, `uploadRgbaTexture(engine, …)`, `AtlasRegion`

Not exported: GPU device accessor, coordinate conversion helpers, demo-specific types, preset collision group names.

---

## In examples (`examples/*`)

Reference games and **copy-paste starting points** — not second-class engine modules.

```
examples/demo/
demoConstants.ts ← DemoTags, DemoAtlas, DemoCollisionGroups
PlayerControllerSystem.ts ← velocity + jump: one way to move a dynamic body
CoinPickupSystem.ts ← sensor collision handling
DemoScene.ts ← how to wire engine + world + systems
```

A default working player controller belongs **here** (or in docs as a snippet), so developers can read it, fork it, or ignore it. It is never imported from `"teengine"`.

---

## Internal (`src/gpu/`, unexported helpers)

Implementation details. No stability guarantee. Not part of the engine's promise.

---

## The boundary test

Before adding anything to `packages/teengine`, ask:

> **Does every 2D game need this, regardless of genre?**

| Answer | Verdict |
|--------|---------|
| Yes — e.g. draw a sprite, step physics, poll input | **Engine** |
| No — e.g. platformer jump, walk cycle, inventory | **Developer's code or examples** |

When unsure, leave it out. The engine stays smaller; games stay flexible.

---

## Roadmap (engine only)

| Item | Status |
|------|--------|
| Remove demo tags from `Entity` | ✅ `tags: Set<string>` |
| Seal public API | ✅ Phase 2 |
| ECS query helpers | Planned |
| Export `Vec2` / small math helpers | Planned |
| Asset cache / lifecycle | Planned |
| Physics perf (buffer reuse) | Planned |

**Not on engine roadmap:** character controller, animation module, scene stack, tilemaps.

---

## Summary

| Layer | Location | Role |
|-------|----------|------|
| Engine | `teengine` npm package | Run, draw, simulate, input |
| Examples | `examples/*` | Working reference implementations you own |
| Your game | Your repo | Systems, scenes, content, feel |

The best simplest 2D engine is one that **does less, clearly** — and gets out of the way.
34 changes: 16 additions & 18 deletions docs/PACKAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ teengine-js/

## Design principles

See [MODULES.md](./MODULES.md) for the engine boundary.

| Concern | Where it lives |
|---------|----------------|
| **Reusable engine** | `packages/teengine` |
| **Game-specific logic** | `examples/*` (PlayerController, demo atlas, scenes) |
| **Engine** | `packages/teengine` — run, draw, simulate, input |
| **Reference implementations** | `examples/*` (player controller, pickup, scenes) — copy and adapt |
| **Your game** | Your app — systems, content, feel |
| **GPU internals** | `packages/teengine/src/gpu` — private, not exported |
| **Editor / UI** | Out of scope — use your own UI framework in the app |
| **Editor / UI** | Out of scope — use your own UI framework |

## Public API (`teengine`)

Expand All @@ -46,32 +49,27 @@ Single entry point today:
import {
Engine,
World,
Graphics,
PhysicsBridge,
PhysicsWorld,
Layers,
layers,
loadAtlasFromJson,
SpinSystem,
uploadRgbaTexture,
CameraFollowSystem,
WorldEntityRenderSystem,
} from "teengine";
```

### Future subpath exports (optional)
// Tags and collision layer bits are game-defined — see examples/demo/src/demoConstants.ts
world.spawn({
tags: ["player"],
collision: { response: "solid", layers: layers(MY_PLAYER_LAYER, MY_GROUND_LAYER) },
});

If the API grows, add without breaking the main entry:

```json
{
"exports": {
".": "./dist/index.js",
"./systems": "./dist/systems/index.js",
"./physics": "./dist/physics/index.js"
}
}
const atlas = await loadAtlasFromJson(engine, "/assets/sprites.json");
const procedural = uploadRgbaTexture(engine, pixels, width, height);
```

Only split when consumers need tree-shaking or clearer boundaries — not required yet.
Single entry point. No subpath exports unless the core API genuinely outgrows one bundle.

## Consuming the package

Expand Down
60 changes: 39 additions & 21 deletions docs/PHYSICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,49 @@ All conversion happens in `PhysicsWorld` — game code stays in engine coordinat

## Usage

`PhysicsWorld` wraps Rapier; `PhysicsBridge` is the entity-facing layer `World` uses.
`World` takes the bridge in its constructor — there is no `attachPhysics()` call.

```ts
const physics = await PhysicsWorld.create({ gravityY: 980 });
world.attachPhysics(physics);
const physicsWorld = await PhysicsWorld.create({ gravityY: 980 });
const physics = new PhysicsBridge(physicsWorld);
const world = new World(physics);

// Static ground (engine coords: x,y = top-left)
physics.createStaticBox(0, 300, 2000, 40);

world.spawn({
const playerId = world.spawn({
transform: { x: 400, y: 286 },
rigidBody: {
type: "dynamic",
collider: { kind: "box", width: 28, height: 28 },
lockRotation: true,
},
// collider, collision, and rigidBody are separate sibling components —
// collider is never nested inside rigidBody.
collider: { shape: { kind: "box", width: 28, height: 28 } },
collision: { response: "solid" },
rigidBody: { type: "dynamic", lockRotation: true },
});

fixedUpdate: ({ dt, input }) => {
physics.setLinearVelocity(handle, dx * speed, vy);
physics.applyImpulse(handle, 0, jumpImpulse);
physics.step(dt);
world.syncFromPhysics();
};
// Movement reads/writes velocity by entity id, through the bridge —
// never through a raw Rapier handle.
engine.setLoop({
fixedUpdate: (ctx) => {
const dx = ctx.input.actionAxis("move_left", "move_right");
const vel = physics.getLinearVelocity(playerId);
physics.setLinearVelocity(playerId, dx * 220, vel.y);
if (ctx.input.actionPressed("jump")) physics.applyImpulse(playerId, 0, 280);

// World.fixedUpdate runs snapshot → your fixed systems → physics.step()
// → sync → your post-physics systems. Do not call physics.step() yourself.
world.fixedUpdate({ ...ctx, physics });
},
render: (ctx) => {
engine.graphics.beginFrame(Color.hex("#0d1117"));
world.render({ ...ctx, physics });
engine.graphics.endFrame();
},
});
```

See `examples/demo/src/PlayerControllerSystem.ts` and `DemoScene.ts` for the same pattern as a reusable `FixedSystem`.

## RigidBodyComponent

| Field | Purpose |
Expand All @@ -60,18 +79,17 @@ Collider and collision policy live on separate components (`collider`, `collisio

## Next phases

### Phase 2 — Events
- `PhysicsWorld.onCollisionEnter` / `Exit` via Rapier event queue
### Events
- Collision enter/exit via Rapier event queue
- Sensor colliders for triggers

### Phase 3 — Character controller
- Rapier `KinematicCharacterController` for platformer movement
- Replace impulse-based jump with controller API

### Phase 4 — Performance
### Performance
- Reuse translation buffers (avoid alloc per body per frame)
- Optional Web Worker for `world.step()`

### Movement (not engine scope)
Platformer / character movement is built in **your** `FixedSystem` using the physics API above (`setLinearVelocity`, `applyImpulse`, `kinematicPosition`, etc.). See `examples/demo/PlayerControllerSystem.ts` for a starting point.

## Vite config

```ts
Expand Down
7 changes: 4 additions & 3 deletions examples/demo/src/CoinPickupSystem.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { FixedSystem } from "teengine";
import type { FixedSystem, FixedSystemContext } from "teengine";
import { DemoTags } from "./demoConstants.js";

export class CoinPickupSystem implements FixedSystem {
readonly name = "CoinPickupSystem";

fixedUpdate(ctx: import("teengine").FixedSystemContext): void {
fixedUpdate(ctx: FixedSystemContext): void {
const { world, physics } = ctx;
if (!physics) return;

Expand All @@ -12,7 +13,7 @@ export class CoinPickupSystem implements FixedSystem {

const self = world.get(event.self);
const other = world.get(event.other);
if (self?.coin && other?.player) {
if (self?.tags.has(DemoTags.coin) && other?.tags.has(DemoTags.player)) {
world.remove(event.self);
}
}
Expand Down
Loading
Loading