@lagless/create 0.0.36 → 0.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/dist/index.js +96 -16
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/templates/pixi-react/AGENTS.md +57 -27
- package/templates/pixi-react/CLAUDE.md +225 -49
- package/templates/pixi-react/README.md +16 -6
- package/templates/pixi-react/__packageName__-backend/package.json +1 -0
- package/templates/pixi-react/__packageName__-backend/src/main.ts +2 -0
- package/templates/pixi-react/__packageName__-frontend/package.json +8 -1
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/grid-background.tsx +4 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/player-view.tsx +68 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/runner-provider.tsx +57 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/hooks/use-start-multiplayer-match.ts +5 -5
- package/templates/pixi-react/__packageName__-frontend/src/app/screens/title.screen.tsx +18 -1
- package/templates/pixi-react/__packageName__-frontend/{vite.config.ts → vite.config.ts.ejs} +0 -2
- package/templates/pixi-react/__packageName__-simulation/package.json +7 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/arena.ts +12 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/schema/ecs.yaml +90 -6
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/apply-move-input.system.ts +73 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/boundary.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/damping.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/index.ts +8 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/integrate.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/physics-step.system.ts +65 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-connection.system.ts +158 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-leave.system.ts +70 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/save-prev-transform.system.ts +46 -0
- package/templates/pixi-react/docs/01-schema-and-codegen.md +244 -0
- package/templates/pixi-react/docs/02-ecs-systems.md +293 -0
- package/templates/pixi-react/docs/03-determinism.md +204 -0
- package/templates/pixi-react/docs/04-input-system.md +255 -0
- package/templates/pixi-react/docs/05-signals.md +175 -0
- package/templates/pixi-react/docs/06-rendering.md +256 -0
- package/templates/pixi-react/docs/07-multiplayer.md +277 -0
- package/templates/pixi-react/docs/08-physics2d.md +266 -0
- package/templates/pixi-react/docs/08-physics3d.md +312 -0
- package/templates/pixi-react/docs/09-recipes.md +362 -0
- package/templates/pixi-react/docs/10-common-mistakes.md +224 -0
- package/templates/pixi-react/docs/api-quick-reference.md +254 -0
- package/templates/pixi-react/package.json +6 -0
- /package/templates/pixi-react/__packageName__-backend/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-frontend/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-simulation/{.swcrc → .swcrc.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-simulation/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/{tsconfig.base.json → tsconfig.base.json.ejs} +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Schema & Codegen
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
All ECS structure is defined in a single YAML file: `<game>-simulation/src/lib/schema/ecs.yaml`. After editing, run `pnpm codegen` to regenerate TypeScript code into `src/lib/code-gen/`.
|
|
6
|
+
|
|
7
|
+
**Never edit files in `code-gen/` manually** — they are overwritten on every codegen run.
|
|
8
|
+
|
|
9
|
+
## Codegen Command
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm codegen
|
|
13
|
+
# Or directly:
|
|
14
|
+
npx @lagless/codegen -c <game>-simulation/src/lib/schema/ecs.yaml
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Field Types
|
|
18
|
+
|
|
19
|
+
| YAML Type | TypedArray | Bytes | Range |
|
|
20
|
+
|-----------|-----------|-------|-------|
|
|
21
|
+
| `uint8` | Uint8Array | 1 | 0 to 255 |
|
|
22
|
+
| `uint16` | Uint16Array | 2 | 0 to 65,535 |
|
|
23
|
+
| `uint32` | Uint32Array | 4 | 0 to 4,294,967,295 |
|
|
24
|
+
| `int8` | Int8Array | 1 | -128 to 127 |
|
|
25
|
+
| `int16` | Int16Array | 2 | -32,768 to 32,767 |
|
|
26
|
+
| `int32` | Int32Array | 4 | -2,147,483,648 to 2,147,483,647 |
|
|
27
|
+
| `float32` | Float32Array | 4 | ~7 significant digits |
|
|
28
|
+
| `float64` | Float64Array | 8 | ~15 significant digits |
|
|
29
|
+
| `uint8[N]` | Uint8Array | N | Fixed-size byte array (e.g., UUIDs) |
|
|
30
|
+
|
|
31
|
+
## Components
|
|
32
|
+
|
|
33
|
+
Components define per-entity data stored in Structure-of-Arrays layout.
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
components:
|
|
37
|
+
# Regular component with fields
|
|
38
|
+
Transform2d:
|
|
39
|
+
positionX: float32
|
|
40
|
+
positionY: float32
|
|
41
|
+
prevPositionX: float32
|
|
42
|
+
prevPositionY: float32
|
|
43
|
+
|
|
44
|
+
# Component with mixed types
|
|
45
|
+
PlayerBody:
|
|
46
|
+
playerSlot: uint8
|
|
47
|
+
radius: float32
|
|
48
|
+
health: uint16
|
|
49
|
+
|
|
50
|
+
# Tag component — no fields, zero memory, bitmask-only
|
|
51
|
+
Frozen: {}
|
|
52
|
+
|
|
53
|
+
# Also a tag (equivalent to {})
|
|
54
|
+
Dead:
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Tag Components
|
|
58
|
+
|
|
59
|
+
Components with no fields (`{}` or empty body) are automatically detected as **tags**. They:
|
|
60
|
+
- Occupy zero memory per entity (only a bitmask bit)
|
|
61
|
+
- Work in filters and prefabs like normal components
|
|
62
|
+
- Used for state flags: `Frozen`, `Dead`, `Invincible`, etc.
|
|
63
|
+
|
|
64
|
+
### Component IDs
|
|
65
|
+
|
|
66
|
+
Component IDs are sequential bit indices (0, 1, 2, ...) assigned in YAML declaration order. The framework supports up to **64 component types** (auto-detected: 1 Uint32 mask word for ≤32, 2 words for 33-64).
|
|
67
|
+
|
|
68
|
+
## Singletons
|
|
69
|
+
|
|
70
|
+
Global typed fields — one copy per simulation (not per entity).
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
singletons:
|
|
74
|
+
GameState:
|
|
75
|
+
gamePhase: uint8 # 0=lobby, 1=playing, 2=gameover
|
|
76
|
+
roundTimer: uint32
|
|
77
|
+
arenaRadius: float32
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Access in systems: `this._gameState.gamePhase` (read/write directly).
|
|
81
|
+
|
|
82
|
+
## Player Resources
|
|
83
|
+
|
|
84
|
+
Per-player data indexed by player slot (0 to maxPlayers-1).
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
playerResources:
|
|
88
|
+
PlayerResource:
|
|
89
|
+
id: uint8[16] # Player UUID
|
|
90
|
+
entity: uint32 # Player's entity ID
|
|
91
|
+
connected: uint8 # Boolean: is connected
|
|
92
|
+
score: uint32
|
|
93
|
+
lastReportedHash: uint32 # Required for hash verification
|
|
94
|
+
lastReportedHashTick: uint32 # Required for hash verification
|
|
95
|
+
hashMismatchCount: uint16 # Required for hash verification
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Access in systems: `this._playerResource.score[slot]`.
|
|
99
|
+
|
|
100
|
+
**Hash verification fields** (`lastReportedHash`, `lastReportedHashTick`, `hashMismatchCount`) are required if you use hash-based divergence detection.
|
|
101
|
+
|
|
102
|
+
## Inputs (RPCs)
|
|
103
|
+
|
|
104
|
+
Inputs define the data structure for client→server→client messages.
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
inputs:
|
|
108
|
+
# Server event — emitted by server hooks (onPlayerJoin, etc.)
|
|
109
|
+
PlayerJoined:
|
|
110
|
+
slot: uint8
|
|
111
|
+
playerId: uint8[16]
|
|
112
|
+
|
|
113
|
+
PlayerLeft:
|
|
114
|
+
slot: uint8
|
|
115
|
+
reason: uint8
|
|
116
|
+
|
|
117
|
+
# Player input — sent by client via drainInputs/addRPC
|
|
118
|
+
MoveInput:
|
|
119
|
+
directionX: float32
|
|
120
|
+
directionY: float32
|
|
121
|
+
|
|
122
|
+
# Hash reporting — required for divergence detection
|
|
123
|
+
ReportHash:
|
|
124
|
+
hash: uint32
|
|
125
|
+
atTick: uint32
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Input Conventions
|
|
129
|
+
- **Server events** (PlayerJoined, PlayerLeft): emitted by `ctx.emitServerEvent()` in RoomHooks
|
|
130
|
+
- **Player inputs** (MoveInput, etc.): sent by client via `addRPC()` in `drainInputs`
|
|
131
|
+
- **ReportHash**: automatically sent by `createHashReporter()` — don't send manually
|
|
132
|
+
|
|
133
|
+
## Filters
|
|
134
|
+
|
|
135
|
+
Filters maintain live entity lists matching include/exclude component masks.
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
filters:
|
|
139
|
+
PlayerFilter:
|
|
140
|
+
include: [Transform2d, PlayerBody]
|
|
141
|
+
|
|
142
|
+
MovingFilter:
|
|
143
|
+
include: [Transform2d, Velocity2d]
|
|
144
|
+
|
|
145
|
+
FrozenPlayerFilter:
|
|
146
|
+
include: [Transform2d, PlayerBody, Frozen]
|
|
147
|
+
|
|
148
|
+
ActivePlayerFilter:
|
|
149
|
+
include: [Transform2d, PlayerBody]
|
|
150
|
+
exclude: [Frozen, Dead]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Filters are iterable in systems: `for (const entity of this._filter) { ... }`.
|
|
154
|
+
Filter data lives in the shared ArrayBuffer and is restored on rollback.
|
|
155
|
+
|
|
156
|
+
## Simulation Type
|
|
157
|
+
|
|
158
|
+
The `simulationType` field enables physics engine integration:
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
# No physics — manual velocity/position management
|
|
162
|
+
# (default, no simulationType field needed)
|
|
163
|
+
|
|
164
|
+
# Rapier 2D physics
|
|
165
|
+
simulationType: physics2d
|
|
166
|
+
|
|
167
|
+
# Rapier 3D physics
|
|
168
|
+
simulationType: physics3d
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Auto-Prepended Components
|
|
172
|
+
|
|
173
|
+
When `simulationType: physics2d`:
|
|
174
|
+
- **Transform2d** (6 fields): `positionX`, `positionY`, `rotation`, `prevPositionX`, `prevPositionY`, `prevRotation`
|
|
175
|
+
- **PhysicsRefs**: `bodyHandle: float64`, `colliderHandle: float64`, `bodyType: uint8`, `collisionLayer: uint16`
|
|
176
|
+
- **PhysicsRefsFilter** automatically created
|
|
177
|
+
|
|
178
|
+
When `simulationType: physics3d`:
|
|
179
|
+
- **Transform3d** (14 fields): `positionX/Y/Z`, `rotationX/Y/Z/W`, `prevPositionX/Y/Z`, `prevRotationX/Y/Z/W`
|
|
180
|
+
- **PhysicsRefs**: same as 2D
|
|
181
|
+
- **PhysicsRefsFilter** automatically created
|
|
182
|
+
|
|
183
|
+
**Do NOT declare Transform2d/Transform3d or PhysicsRefs manually** when using a simulationType — they are auto-prepended.
|
|
184
|
+
|
|
185
|
+
## Generated Files
|
|
186
|
+
|
|
187
|
+
After running `pnpm codegen`, the following files are generated in `src/lib/code-gen/`:
|
|
188
|
+
|
|
189
|
+
| File | Contents |
|
|
190
|
+
|------|----------|
|
|
191
|
+
| `core.ts` | All component classes, singleton classes, filter classes, player resource classes |
|
|
192
|
+
| `runner.ts` | `<ProjectName>Runner` class extending `ECSRunner` with typed `Core` accessor |
|
|
193
|
+
| `input-registry.ts` | `<ProjectName>InputRegistry` with all input types registered |
|
|
194
|
+
| `prefabs.ts` | Helper prefab builders for common entity archetypes |
|
|
195
|
+
|
|
196
|
+
The runner class provides typed access to all ECS objects:
|
|
197
|
+
```typescript
|
|
198
|
+
runner.Core.Transform2d // component
|
|
199
|
+
runner.Core.PlayerFilter // filter
|
|
200
|
+
runner.Core.GameState // singleton
|
|
201
|
+
runner.Core.PlayerResource // player resource
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Common Schema Patterns
|
|
205
|
+
|
|
206
|
+
### Player Entity
|
|
207
|
+
```yaml
|
|
208
|
+
components:
|
|
209
|
+
PlayerBody:
|
|
210
|
+
playerSlot: uint8
|
|
211
|
+
radius: float32
|
|
212
|
+
health: uint16
|
|
213
|
+
maxHealth: uint16
|
|
214
|
+
|
|
215
|
+
filters:
|
|
216
|
+
PlayerFilter:
|
|
217
|
+
include: [Transform2d, PlayerBody]
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Projectile with Lifetime
|
|
221
|
+
```yaml
|
|
222
|
+
components:
|
|
223
|
+
Projectile:
|
|
224
|
+
ownerSlot: uint8
|
|
225
|
+
damage: uint16
|
|
226
|
+
spawnTick: uint32
|
|
227
|
+
lifetimeTicks: uint16
|
|
228
|
+
Velocity2d:
|
|
229
|
+
velocityX: float32
|
|
230
|
+
velocityY: float32
|
|
231
|
+
|
|
232
|
+
filters:
|
|
233
|
+
ProjectileFilter:
|
|
234
|
+
include: [Transform2d, Projectile, Velocity2d]
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Game Phases via Singleton
|
|
238
|
+
```yaml
|
|
239
|
+
singletons:
|
|
240
|
+
GameState:
|
|
241
|
+
gamePhase: uint8 # enum: 0=waiting, 1=countdown, 2=playing, 3=gameover
|
|
242
|
+
phaseStartTick: uint32
|
|
243
|
+
roundNumber: uint8
|
|
244
|
+
```
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# ECS Systems
|
|
2
|
+
|
|
3
|
+
## Anatomy of a System
|
|
4
|
+
|
|
5
|
+
Systems are classes decorated with `@ECSSystem()` that implement `IECSSystem`. Dependencies are injected via constructor.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { ECSSystem, IECSSystem } from '@lagless/core';
|
|
9
|
+
import { Transform2d, Velocity2d, MovingFilter } from '../code-gen/core.js';
|
|
10
|
+
|
|
11
|
+
@ECSSystem()
|
|
12
|
+
export class IntegrateSystem implements IECSSystem {
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly _transform: Transform2d,
|
|
15
|
+
private readonly _velocity: Velocity2d,
|
|
16
|
+
private readonly _filter: MovingFilter,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
update(tick: number): void {
|
|
20
|
+
for (const entity of this._filter) {
|
|
21
|
+
this._transform.unsafe.positionX[entity] += this._velocity.unsafe.velocityX[entity];
|
|
22
|
+
this._transform.unsafe.positionY[entity] += this._velocity.unsafe.velocityY[entity];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## DI Injectable Tokens
|
|
29
|
+
|
|
30
|
+
Any of these types can be requested in a system's constructor:
|
|
31
|
+
|
|
32
|
+
| Type | What You Get |
|
|
33
|
+
|------|-------------|
|
|
34
|
+
| `Transform2d`, `PlayerBody`, etc. | Component class — access entity data |
|
|
35
|
+
| `PlayerFilter`, `MovingFilter`, etc. | Filter class — iterate matching entities |
|
|
36
|
+
| `GameState`, etc. | Singleton — global data fields |
|
|
37
|
+
| `PlayerResource` | Per-player data indexed by slot |
|
|
38
|
+
| `EntitiesManager` | Create/remove entities, add/remove components |
|
|
39
|
+
| `PRNG` | Deterministic random number generator |
|
|
40
|
+
| `ECSConfig` | Simulation configuration (maxEntities, tickRate, etc.) |
|
|
41
|
+
| `AbstractInputProvider` | Read RPCs via `collectTickRPCs()` |
|
|
42
|
+
| `ScoreSignal`, etc. | Signal classes — emit rollback-aware events |
|
|
43
|
+
|
|
44
|
+
## Data Access Patterns
|
|
45
|
+
|
|
46
|
+
### Hot Path — Unsafe Typed Arrays (Fastest)
|
|
47
|
+
|
|
48
|
+
Direct typed array access. No bounds checking. Use in system `update()` loops.
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// Read
|
|
52
|
+
const x = this._transform.unsafe.positionX[entity];
|
|
53
|
+
|
|
54
|
+
// Write
|
|
55
|
+
this._transform.unsafe.positionX[entity] = 100;
|
|
56
|
+
this._transform.unsafe.positionY[entity] = 200;
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Convenient — Cursor (Single Entity)
|
|
60
|
+
|
|
61
|
+
Object-like access for setup/initialization code. Slower than unsafe.
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
const cursor = this._transform.getCursor(entity);
|
|
65
|
+
cursor.positionX = 100;
|
|
66
|
+
cursor.positionY = 200;
|
|
67
|
+
// cursor.positionX also readable
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Component Set (Bulk Init)
|
|
71
|
+
|
|
72
|
+
Set multiple fields at once:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
this._transform.set(entity, {
|
|
76
|
+
positionX: 100,
|
|
77
|
+
positionY: 200,
|
|
78
|
+
prevPositionX: 100, // ALWAYS set prev = current on spawn!
|
|
79
|
+
prevPositionY: 200,
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Entity Lifecycle
|
|
84
|
+
|
|
85
|
+
### Creating Entities
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
const entity = this._entities.createEntity();
|
|
89
|
+
this._entities.addComponent(entity, Transform2d);
|
|
90
|
+
this._entities.addComponent(entity, PlayerBody);
|
|
91
|
+
|
|
92
|
+
// Set initial data
|
|
93
|
+
this._transform.set(entity, {
|
|
94
|
+
positionX: spawnX,
|
|
95
|
+
positionY: spawnY,
|
|
96
|
+
prevPositionX: spawnX, // MUST match position to avoid interpolation jump
|
|
97
|
+
prevPositionY: spawnY,
|
|
98
|
+
});
|
|
99
|
+
this._playerBody.set(entity, { playerSlot: slot, radius: 20 });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Removing Entities
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
this._entities.removeEntity(entity);
|
|
106
|
+
// Entity ID goes to recycling stack, will be reused
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Adding/Removing Components
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// Add component to existing entity
|
|
113
|
+
this._entities.addComponent(entity, Frozen); // tag component
|
|
114
|
+
|
|
115
|
+
// Remove component
|
|
116
|
+
this._entities.removeComponent(entity, Frozen);
|
|
117
|
+
|
|
118
|
+
// Check if entity has component
|
|
119
|
+
if (this._entities.hasComponent(entity, Frozen)) { ... }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Component Masks
|
|
123
|
+
|
|
124
|
+
Entity presence in filters is determined by component bitmasks. When you add/remove a component, the entity automatically enters/leaves matching filters.
|
|
125
|
+
|
|
126
|
+
## Prefabs
|
|
127
|
+
|
|
128
|
+
Prefabs provide a fluent API for entity creation with multiple components:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { Prefab } from '@lagless/core';
|
|
132
|
+
|
|
133
|
+
// In a system:
|
|
134
|
+
const entity = Prefab.create(this._entities)
|
|
135
|
+
.with(Transform2d, { positionX: 0, positionY: 0, prevPositionX: 0, prevPositionY: 0 })
|
|
136
|
+
.with(PlayerBody, { playerSlot: slot, radius: 20 })
|
|
137
|
+
.with(Velocity2d, { velocityX: 0, velocityY: 0 })
|
|
138
|
+
.build();
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Filter Iteration
|
|
142
|
+
|
|
143
|
+
Filters are iterable — they yield entity IDs matching their include/exclude masks.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// Basic iteration
|
|
147
|
+
for (const entity of this._filter) {
|
|
148
|
+
const x = this._transform.unsafe.positionX[entity];
|
|
149
|
+
// ...
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check filter length
|
|
153
|
+
if (this._filter.length === 0) return;
|
|
154
|
+
|
|
155
|
+
// Access underlying array (advanced)
|
|
156
|
+
const entities = this._filter.entities; // number[]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Filter data lives in the ArrayBuffer** — it's automatically restored on rollback.
|
|
160
|
+
|
|
161
|
+
## PRNG (Deterministic Random)
|
|
162
|
+
|
|
163
|
+
The PRNG state is stored in the ArrayBuffer, so it's restored on rollback. **Never use `Math.random()`.**
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
@ECSSystem()
|
|
167
|
+
export class SpawnSystem implements IECSSystem {
|
|
168
|
+
constructor(private readonly _prng: PRNG) {}
|
|
169
|
+
|
|
170
|
+
update(tick: number): void {
|
|
171
|
+
// Random float in [0, 1)
|
|
172
|
+
const f = this._prng.getFloat();
|
|
173
|
+
|
|
174
|
+
// Random integer in [from, to) — exclusive upper bound
|
|
175
|
+
const x = this._prng.getRandomInt(-500, 500);
|
|
176
|
+
|
|
177
|
+
// Random integer in [from, to] — inclusive upper bound
|
|
178
|
+
const y = this._prng.getRandomIntInclusive(1, 6);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Player Resources
|
|
184
|
+
|
|
185
|
+
Per-player data indexed by slot (0 to maxPlayers-1). Stored in the ArrayBuffer.
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
@ECSSystem()
|
|
189
|
+
export class ScoreSystem implements IECSSystem {
|
|
190
|
+
constructor(private readonly _playerResource: PlayerResource) {}
|
|
191
|
+
|
|
192
|
+
update(tick: number): void {
|
|
193
|
+
// Read
|
|
194
|
+
const score = this._playerResource.score[slot];
|
|
195
|
+
|
|
196
|
+
// Write
|
|
197
|
+
this._playerResource.score[slot] += 100;
|
|
198
|
+
|
|
199
|
+
// Check connection
|
|
200
|
+
if (this._playerResource.connected[slot]) { ... }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## ECSConfig
|
|
206
|
+
|
|
207
|
+
Access simulation configuration:
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
@ECSSystem()
|
|
211
|
+
export class MySystem implements IECSSystem {
|
|
212
|
+
constructor(private readonly _config: ECSConfig) {}
|
|
213
|
+
|
|
214
|
+
update(tick: number): void {
|
|
215
|
+
const maxE = this._config.maxEntities; // default 1024
|
|
216
|
+
const maxP = this._config.maxPlayers; // default 4
|
|
217
|
+
const dt = this._config.frameLength; // seconds per tick (e.g., 1/20)
|
|
218
|
+
const rate = this._config.tickRate; // ticks per second (e.g., 20)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## System Registration
|
|
224
|
+
|
|
225
|
+
Systems must be registered in the systems array in `systems/index.ts`. **Order matters** — systems execute sequentially in array order every tick.
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
import { IECSSystemClass } from '@lagless/core';
|
|
229
|
+
import { SavePrevTransformSystem } from './save-prev-transform.system.js';
|
|
230
|
+
import { PlayerConnectionSystem } from './player-connection.system.js';
|
|
231
|
+
import { ApplyMoveInputSystem } from './apply-move-input.system.js';
|
|
232
|
+
import { IntegrateSystem } from './integrate.system.js';
|
|
233
|
+
import { BoundarySystem } from './boundary.system.js';
|
|
234
|
+
import { PlayerLeaveSystem } from './player-leave.system.js';
|
|
235
|
+
import { HashVerificationSystem } from './hash-verification.system.js';
|
|
236
|
+
|
|
237
|
+
export const systems: IECSSystemClass[] = [
|
|
238
|
+
SavePrevTransformSystem, // 1. Store prev positions for interpolation
|
|
239
|
+
PlayerConnectionSystem, // 2. Handle join/leave events
|
|
240
|
+
ApplyMoveInputSystem, // 3. Read player inputs
|
|
241
|
+
IntegrateSystem, // 4. Apply velocities
|
|
242
|
+
BoundarySystem, // 5. Enforce boundaries
|
|
243
|
+
PlayerLeaveSystem, // 6. Cleanup disconnected entities
|
|
244
|
+
HashVerificationSystem, // 7. ALWAYS LAST — divergence detection
|
|
245
|
+
];
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Complete System Example
|
|
249
|
+
|
|
250
|
+
A system that reads move inputs, sanitizes them, and applies velocity:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { ECSSystem, IECSSystem, AbstractInputProvider, ECSConfig } from '@lagless/core';
|
|
254
|
+
import { MathOps } from '@lagless/math';
|
|
255
|
+
import { Transform2d, Velocity2d, PlayerBody, PlayerFilter, MoveInput } from '../code-gen/core.js';
|
|
256
|
+
|
|
257
|
+
const finite = (v: number): number => Number.isFinite(v) ? v : 0;
|
|
258
|
+
|
|
259
|
+
@ECSSystem()
|
|
260
|
+
export class ApplyMoveInputSystem implements IECSSystem {
|
|
261
|
+
constructor(
|
|
262
|
+
private readonly _input: AbstractInputProvider,
|
|
263
|
+
private readonly _transform: Transform2d,
|
|
264
|
+
private readonly _velocity: Velocity2d,
|
|
265
|
+
private readonly _playerBody: PlayerBody,
|
|
266
|
+
private readonly _filter: PlayerFilter,
|
|
267
|
+
private readonly _config: ECSConfig,
|
|
268
|
+
) {}
|
|
269
|
+
|
|
270
|
+
update(tick: number): void {
|
|
271
|
+
const rpcs = this._input.collectTickRPCs(tick, MoveInput);
|
|
272
|
+
for (const rpc of rpcs) {
|
|
273
|
+
const slot = rpc.meta.playerSlot;
|
|
274
|
+
|
|
275
|
+
// ALWAYS sanitize RPC data
|
|
276
|
+
let dirX = finite(rpc.data.directionX);
|
|
277
|
+
let dirY = finite(rpc.data.directionY);
|
|
278
|
+
dirX = MathOps.clamp(dirX, -1, 1);
|
|
279
|
+
dirY = MathOps.clamp(dirY, -1, 1);
|
|
280
|
+
|
|
281
|
+
// Find entity for this player slot
|
|
282
|
+
for (const entity of this._filter) {
|
|
283
|
+
if (this._playerBody.unsafe.playerSlot[entity] !== slot) continue;
|
|
284
|
+
|
|
285
|
+
const speed = 200 * this._config.frameLength;
|
|
286
|
+
this._velocity.unsafe.velocityX[entity] = dirX * speed;
|
|
287
|
+
this._velocity.unsafe.velocityY[entity] = dirY * speed;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
```
|