@lagless/create 0.0.38 → 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 -4
- 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 -0
- 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__-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__-frontend/{vite.config.ts → vite.config.ts.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,362 @@
|
|
|
1
|
+
# Recipes — Step-by-Step Cookbook
|
|
2
|
+
|
|
3
|
+
## Add a New Component
|
|
4
|
+
|
|
5
|
+
1. Edit `ecs.yaml`:
|
|
6
|
+
```yaml
|
|
7
|
+
components:
|
|
8
|
+
Health:
|
|
9
|
+
current: uint16
|
|
10
|
+
max: uint16
|
|
11
|
+
```
|
|
12
|
+
2. Run `pnpm codegen`
|
|
13
|
+
3. Import in systems: `import { Health } from '../code-gen/core.js';`
|
|
14
|
+
4. Add to constructor DI: `private readonly _health: Health`
|
|
15
|
+
5. Access data: `this._health.unsafe.current[entity]`
|
|
16
|
+
|
|
17
|
+
## Add a Tag Component
|
|
18
|
+
|
|
19
|
+
Tags have no fields — they only occupy a bitmask bit (zero memory per entity).
|
|
20
|
+
|
|
21
|
+
1. Edit `ecs.yaml`:
|
|
22
|
+
```yaml
|
|
23
|
+
components:
|
|
24
|
+
Frozen: {}
|
|
25
|
+
Dead: {}
|
|
26
|
+
```
|
|
27
|
+
2. Run `pnpm codegen`
|
|
28
|
+
3. Use in systems:
|
|
29
|
+
```typescript
|
|
30
|
+
// Add tag
|
|
31
|
+
this._entities.addComponent(entity, Frozen);
|
|
32
|
+
// Remove tag
|
|
33
|
+
this._entities.removeComponent(entity, Frozen);
|
|
34
|
+
// Check tag
|
|
35
|
+
if (this._entities.hasComponent(entity, Frozen)) { ... }
|
|
36
|
+
```
|
|
37
|
+
4. Use in filters:
|
|
38
|
+
```yaml
|
|
39
|
+
filters:
|
|
40
|
+
ActivePlayerFilter:
|
|
41
|
+
include: [Transform2d, PlayerBody]
|
|
42
|
+
exclude: [Frozen, Dead]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Add a New System
|
|
46
|
+
|
|
47
|
+
1. Create `systems/my-feature.system.ts`:
|
|
48
|
+
```typescript
|
|
49
|
+
import { ECSSystem, IECSSystem } from '@lagless/core';
|
|
50
|
+
import { Transform2d, PlayerFilter } from '../code-gen/core.js';
|
|
51
|
+
|
|
52
|
+
@ECSSystem()
|
|
53
|
+
export class MyFeatureSystem implements IECSSystem {
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly _transform: Transform2d,
|
|
56
|
+
private readonly _filter: PlayerFilter,
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
update(tick: number): void {
|
|
60
|
+
for (const entity of this._filter) {
|
|
61
|
+
// your logic here
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
2. Add to `systems/index.ts`:
|
|
67
|
+
```typescript
|
|
68
|
+
import { MyFeatureSystem } from './my-feature.system.js';
|
|
69
|
+
|
|
70
|
+
export const systems = [
|
|
71
|
+
// ... existing systems
|
|
72
|
+
MyFeatureSystem, // add in correct execution order
|
|
73
|
+
// ... hash verification last
|
|
74
|
+
];
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Add a New Input Type
|
|
78
|
+
|
|
79
|
+
1. Edit `ecs.yaml`:
|
|
80
|
+
```yaml
|
|
81
|
+
inputs:
|
|
82
|
+
ShootInput:
|
|
83
|
+
targetX: float32
|
|
84
|
+
targetY: float32
|
|
85
|
+
```
|
|
86
|
+
2. Run `pnpm codegen`
|
|
87
|
+
3. Send from client (in `runner-provider.tsx` drainInputs):
|
|
88
|
+
```typescript
|
|
89
|
+
if (mouseClicked) {
|
|
90
|
+
addRPC(ShootInput, { targetX: mouseWorldX, targetY: mouseWorldY });
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
4. Read in system:
|
|
94
|
+
```typescript
|
|
95
|
+
const rpcs = this._input.collectTickRPCs(tick, ShootInput);
|
|
96
|
+
for (const rpc of rpcs) {
|
|
97
|
+
const targetX = MathOps.clamp(finite(rpc.data.targetX), -1000, 1000);
|
|
98
|
+
const targetY = MathOps.clamp(finite(rpc.data.targetY), -1000, 1000);
|
|
99
|
+
// spawn projectile, etc.
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Add a New Entity Type
|
|
104
|
+
|
|
105
|
+
1. Define components and filter in `ecs.yaml`:
|
|
106
|
+
```yaml
|
|
107
|
+
components:
|
|
108
|
+
Projectile:
|
|
109
|
+
ownerSlot: uint8
|
|
110
|
+
damage: uint16
|
|
111
|
+
spawnTick: uint32
|
|
112
|
+
lifetimeTicks: uint16
|
|
113
|
+
Velocity2d:
|
|
114
|
+
velocityX: float32
|
|
115
|
+
velocityY: float32
|
|
116
|
+
|
|
117
|
+
filters:
|
|
118
|
+
ProjectileFilter:
|
|
119
|
+
include: [Transform2d, Projectile, Velocity2d]
|
|
120
|
+
```
|
|
121
|
+
2. Run `pnpm codegen`
|
|
122
|
+
3. Create spawn logic in a system:
|
|
123
|
+
```typescript
|
|
124
|
+
const entity = this._entities.createEntity();
|
|
125
|
+
this._entities.addComponent(entity, Transform2d);
|
|
126
|
+
this._entities.addComponent(entity, Projectile);
|
|
127
|
+
this._entities.addComponent(entity, Velocity2d);
|
|
128
|
+
|
|
129
|
+
this._transform.set(entity, {
|
|
130
|
+
positionX: startX, positionY: startY,
|
|
131
|
+
prevPositionX: startX, prevPositionY: startY,
|
|
132
|
+
});
|
|
133
|
+
this._projectile.set(entity, {
|
|
134
|
+
ownerSlot: slot, damage: 10, spawnTick: tick, lifetimeTicks: 60,
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
4. Create view component (see [06-rendering.md](06-rendering.md))
|
|
138
|
+
5. Add `<FilterViews filter={runner.Core.ProjectileFilter} View={ProjectileView} />`
|
|
139
|
+
|
|
140
|
+
## Add a Signal
|
|
141
|
+
|
|
142
|
+
1. Define in `signals/index.ts`:
|
|
143
|
+
```typescript
|
|
144
|
+
@ECSSignal()
|
|
145
|
+
export class ExplosionSignal extends Signal<{ x: number; y: number }> {}
|
|
146
|
+
```
|
|
147
|
+
2. Register in `arena.ts` signals array
|
|
148
|
+
3. Emit in system:
|
|
149
|
+
```typescript
|
|
150
|
+
this._explosionSignal.emit(tick, { x: posX, y: posY });
|
|
151
|
+
```
|
|
152
|
+
4. Subscribe in view:
|
|
153
|
+
```typescript
|
|
154
|
+
explosionSignal.Predicted.subscribe(e => spawnExplosionParticles(e.data.x, e.data.y));
|
|
155
|
+
explosionSignal.Cancelled.subscribe(e => removeExplosionParticles());
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Add a New Screen
|
|
159
|
+
|
|
160
|
+
1. Create `screens/lobby.screen.tsx`:
|
|
161
|
+
```tsx
|
|
162
|
+
export function LobbyScreen() {
|
|
163
|
+
const navigate = useNavigate();
|
|
164
|
+
return (
|
|
165
|
+
<div>
|
|
166
|
+
<h1>Lobby</h1>
|
|
167
|
+
<button onClick={() => navigate('/game')}>Start</button>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
2. Add route in `router.tsx`:
|
|
173
|
+
```tsx
|
|
174
|
+
<Route path="/lobby" element={<LobbyScreen />} />
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Add Bot AI
|
|
178
|
+
|
|
179
|
+
Bots are simulated as if they were players sending RPCs via server events.
|
|
180
|
+
|
|
181
|
+
1. Add bot management to server hooks:
|
|
182
|
+
```typescript
|
|
183
|
+
// In game-hooks.ts:
|
|
184
|
+
onRoomCreated: (ctx) => {
|
|
185
|
+
// Schedule bot input every tick
|
|
186
|
+
setInterval(() => {
|
|
187
|
+
const botSlot = 1; // reserved for bot
|
|
188
|
+
ctx.emitServerEvent(MoveInput, {
|
|
189
|
+
directionX: calculateBotDirX(),
|
|
190
|
+
directionY: calculateBotDirY(),
|
|
191
|
+
}, botSlot);
|
|
192
|
+
}, 50); // 20 ticks/sec
|
|
193
|
+
},
|
|
194
|
+
```
|
|
195
|
+
2. Bot inputs flow through the same RPC system — no special handling in simulation
|
|
196
|
+
|
|
197
|
+
## Add Game Phases (Lobby → Playing → GameOver)
|
|
198
|
+
|
|
199
|
+
1. Add singleton in `ecs.yaml`:
|
|
200
|
+
```yaml
|
|
201
|
+
singletons:
|
|
202
|
+
GameState:
|
|
203
|
+
gamePhase: uint8 # 0=lobby, 1=countdown, 2=playing, 3=gameover
|
|
204
|
+
phaseStartTick: uint32
|
|
205
|
+
```
|
|
206
|
+
2. Create `game-phase.system.ts`:
|
|
207
|
+
```typescript
|
|
208
|
+
@ECSSystem()
|
|
209
|
+
export class GamePhaseSystem implements IECSSystem {
|
|
210
|
+
constructor(
|
|
211
|
+
private readonly _gameState: GameState,
|
|
212
|
+
private readonly _config: ECSConfig,
|
|
213
|
+
) {}
|
|
214
|
+
|
|
215
|
+
update(tick: number): void {
|
|
216
|
+
const phase = this._gameState.gamePhase;
|
|
217
|
+
const elapsed = tick - this._gameState.phaseStartTick;
|
|
218
|
+
|
|
219
|
+
if (phase === 1 && elapsed > 60) { // 3 seconds at 20 tps
|
|
220
|
+
this._gameState.gamePhase = 2;
|
|
221
|
+
this._gameState.phaseStartTick = tick;
|
|
222
|
+
}
|
|
223
|
+
// ... more phase transitions
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Add Timer / Countdown
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
@ECSSystem()
|
|
232
|
+
export class TimerSystem implements IECSSystem {
|
|
233
|
+
constructor(
|
|
234
|
+
private readonly _gameState: GameState,
|
|
235
|
+
private readonly _config: ECSConfig,
|
|
236
|
+
) {}
|
|
237
|
+
|
|
238
|
+
update(tick: number): void {
|
|
239
|
+
const elapsed = tick - this._gameState.phaseStartTick;
|
|
240
|
+
const elapsedSeconds = elapsed * this._config.frameLength;
|
|
241
|
+
const remainingSeconds = Math.max(0, 120 - elapsedSeconds); // 2 minute timer
|
|
242
|
+
|
|
243
|
+
if (remainingSeconds <= 0) {
|
|
244
|
+
this._gameState.gamePhase = 3; // gameover
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Display in React:
|
|
251
|
+
```tsx
|
|
252
|
+
const elapsed = runner.Simulation.tick - gameState.phaseStartTick;
|
|
253
|
+
const remaining = Math.max(0, 120 - elapsed / runner.Config.tickRate);
|
|
254
|
+
return <div>{Math.ceil(remaining)}s</div>;
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Add Score Tracking
|
|
258
|
+
|
|
259
|
+
1. Add to playerResources in `ecs.yaml`:
|
|
260
|
+
```yaml
|
|
261
|
+
playerResources:
|
|
262
|
+
PlayerResource:
|
|
263
|
+
score: uint32
|
|
264
|
+
kills: uint16
|
|
265
|
+
deaths: uint16
|
|
266
|
+
```
|
|
267
|
+
2. Update in system:
|
|
268
|
+
```typescript
|
|
269
|
+
this._playerResource.score[killerSlot] += 100;
|
|
270
|
+
this._playerResource.kills[killerSlot] += 1;
|
|
271
|
+
this._playerResource.deaths[victimSlot] += 1;
|
|
272
|
+
```
|
|
273
|
+
3. Read in view:
|
|
274
|
+
```tsx
|
|
275
|
+
const score = runner.Core.PlayerResource.score[localSlot];
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Add Death / Respawn
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
@ECSSystem()
|
|
282
|
+
export class DeathRespawnSystem implements IECSSystem {
|
|
283
|
+
constructor(
|
|
284
|
+
private readonly _entities: EntitiesManager,
|
|
285
|
+
private readonly _transform: Transform2d,
|
|
286
|
+
private readonly _playerBody: PlayerBody,
|
|
287
|
+
private readonly _filter: PlayerFilter,
|
|
288
|
+
private readonly _prng: PRNG,
|
|
289
|
+
) {}
|
|
290
|
+
|
|
291
|
+
update(tick: number): void {
|
|
292
|
+
for (const entity of this._filter) {
|
|
293
|
+
if (this._playerBody.unsafe.health[entity] <= 0) {
|
|
294
|
+
// Death: add Dead tag
|
|
295
|
+
this._entities.addComponent(entity, Dead);
|
|
296
|
+
|
|
297
|
+
// Schedule respawn: store death tick
|
|
298
|
+
this._playerBody.unsafe.deathTick[entity] = tick;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Check for respawn
|
|
303
|
+
for (const entity of this._deadFilter) {
|
|
304
|
+
const deathTick = this._playerBody.unsafe.deathTick[entity];
|
|
305
|
+
if (tick - deathTick >= 60) { // 3 seconds at 20 tps
|
|
306
|
+
this._entities.removeComponent(entity, Dead);
|
|
307
|
+
// Respawn at random position
|
|
308
|
+
const x = this._prng.getRandomInt(-400, 400);
|
|
309
|
+
const y = this._prng.getRandomInt(-300, 300);
|
|
310
|
+
this._transform.set(entity, {
|
|
311
|
+
positionX: x, positionY: y,
|
|
312
|
+
prevPositionX: x, prevPositionY: y,
|
|
313
|
+
});
|
|
314
|
+
this._playerBody.unsafe.health[entity] = this._playerBody.unsafe.maxHealth[entity];
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Add Projectile with Lifetime
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
@ECSSystem()
|
|
325
|
+
export class ProjectileLifetimeSystem implements IECSSystem {
|
|
326
|
+
constructor(
|
|
327
|
+
private readonly _entities: EntitiesManager,
|
|
328
|
+
private readonly _projectile: Projectile,
|
|
329
|
+
private readonly _filter: ProjectileFilter,
|
|
330
|
+
) {}
|
|
331
|
+
|
|
332
|
+
update(tick: number): void {
|
|
333
|
+
for (const entity of this._filter) {
|
|
334
|
+
const spawnTick = this._projectile.unsafe.spawnTick[entity];
|
|
335
|
+
const lifetime = this._projectile.unsafe.lifetimeTicks[entity];
|
|
336
|
+
|
|
337
|
+
if (tick - spawnTick >= lifetime) {
|
|
338
|
+
this._entities.removeEntity(entity);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Add a Singleton
|
|
346
|
+
|
|
347
|
+
1. Edit `ecs.yaml`:
|
|
348
|
+
```yaml
|
|
349
|
+
singletons:
|
|
350
|
+
ArenaConfig:
|
|
351
|
+
radius: float32
|
|
352
|
+
shrinkRate: float32
|
|
353
|
+
```
|
|
354
|
+
2. Run `pnpm codegen`
|
|
355
|
+
3. Access in systems:
|
|
356
|
+
```typescript
|
|
357
|
+
constructor(private readonly _arenaConfig: ArenaConfig) {}
|
|
358
|
+
update(tick: number): void {
|
|
359
|
+
const r = this._arenaConfig.radius;
|
|
360
|
+
this._arenaConfig.radius -= this._arenaConfig.shrinkRate;
|
|
361
|
+
}
|
|
362
|
+
```
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Common Mistakes
|
|
2
|
+
|
|
3
|
+
## Determinism
|
|
4
|
+
|
|
5
|
+
### Using Math.sin/cos/atan2/sqrt
|
|
6
|
+
|
|
7
|
+
**Wrong:**
|
|
8
|
+
```typescript
|
|
9
|
+
const angle = Math.atan2(dy, dx);
|
|
10
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
11
|
+
```
|
|
12
|
+
**Correct:**
|
|
13
|
+
```typescript
|
|
14
|
+
const angle = MathOps.atan2(dy, dx);
|
|
15
|
+
const dist = MathOps.sqrt(dx * dx + dy * dy);
|
|
16
|
+
```
|
|
17
|
+
**Why:** `Math.*` trig/sqrt results differ between browsers and platforms. MathOps uses WASM for cross-platform determinism.
|
|
18
|
+
|
|
19
|
+
### Using Math.random()
|
|
20
|
+
|
|
21
|
+
**Wrong:** `const r = Math.random();`
|
|
22
|
+
**Correct:** `const r = this._prng.getFloat();`
|
|
23
|
+
**Why:** Math.random() produces different values on each client. PRNG state is in the ArrayBuffer and deterministic.
|
|
24
|
+
|
|
25
|
+
### Forgetting prevPosition on Spawn
|
|
26
|
+
|
|
27
|
+
**Wrong:**
|
|
28
|
+
```typescript
|
|
29
|
+
this._transform.set(entity, { positionX: 100, positionY: 200 });
|
|
30
|
+
```
|
|
31
|
+
**Correct:**
|
|
32
|
+
```typescript
|
|
33
|
+
this._transform.set(entity, {
|
|
34
|
+
positionX: 100, positionY: 200,
|
|
35
|
+
prevPositionX: 100, prevPositionY: 200,
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
**Why:** prevPosition defaults to 0. Interpolation between (0,0) and (100,200) causes a one-frame visual jump.
|
|
39
|
+
|
|
40
|
+
### Array.sort() without Comparator
|
|
41
|
+
|
|
42
|
+
**Wrong:** `entities.sort();`
|
|
43
|
+
**Correct:** `entities.sort((a, b) => a - b);`
|
|
44
|
+
**Why:** Default sort is lexicographic and engine-dependent. Explicit comparator ensures deterministic order.
|
|
45
|
+
|
|
46
|
+
### Using Date.now() in Simulation
|
|
47
|
+
|
|
48
|
+
**Wrong:** `if (Date.now() > deadline) { ... }`
|
|
49
|
+
**Correct:** `if (tick > deadlineTick) { ... }`
|
|
50
|
+
**Why:** Date.now() differs between clients. Use tick count for all time-based logic.
|
|
51
|
+
|
|
52
|
+
## Input
|
|
53
|
+
|
|
54
|
+
### Not Sanitizing RPC Data
|
|
55
|
+
|
|
56
|
+
**Wrong:**
|
|
57
|
+
```typescript
|
|
58
|
+
const dirX = rpc.data.directionX; // Could be NaN, Infinity
|
|
59
|
+
this._velocity.unsafe.velocityX[entity] = dirX * speed;
|
|
60
|
+
```
|
|
61
|
+
**Correct:**
|
|
62
|
+
```typescript
|
|
63
|
+
const finite = (v: number): number => Number.isFinite(v) ? v : 0;
|
|
64
|
+
let dirX = MathOps.clamp(finite(rpc.data.directionX), -1, 1);
|
|
65
|
+
this._velocity.unsafe.velocityX[entity] = dirX * speed;
|
|
66
|
+
```
|
|
67
|
+
**Why:** Network messages can contain NaN/Infinity. NaN propagates through all math and corrupts state permanently.
|
|
68
|
+
|
|
69
|
+
### Clamping Before Finite Check
|
|
70
|
+
|
|
71
|
+
**Wrong:** `MathOps.clamp(rpc.data.value, -1, 1)` — clamp(NaN) returns NaN
|
|
72
|
+
**Correct:** `MathOps.clamp(finite(rpc.data.value), -1, 1)`
|
|
73
|
+
**Why:** MathOps.clamp does NOT handle NaN. Always check `Number.isFinite()` first.
|
|
74
|
+
|
|
75
|
+
## Schema
|
|
76
|
+
|
|
77
|
+
### Editing Generated Code
|
|
78
|
+
|
|
79
|
+
**Wrong:** Editing files in `code-gen/` directory
|
|
80
|
+
**Correct:** Edit `ecs.yaml` and run `pnpm codegen`
|
|
81
|
+
**Why:** Generated files are overwritten on every codegen run. Changes will be lost.
|
|
82
|
+
|
|
83
|
+
### Declaring Transform2d with simulationType
|
|
84
|
+
|
|
85
|
+
**Wrong:**
|
|
86
|
+
```yaml
|
|
87
|
+
simulationType: physics2d
|
|
88
|
+
components:
|
|
89
|
+
Transform2d: # Already auto-prepended!
|
|
90
|
+
positionX: float32
|
|
91
|
+
```
|
|
92
|
+
**Correct:**
|
|
93
|
+
```yaml
|
|
94
|
+
simulationType: physics2d
|
|
95
|
+
components:
|
|
96
|
+
PlayerBody: # Only declare your own components
|
|
97
|
+
playerSlot: uint8
|
|
98
|
+
```
|
|
99
|
+
**Why:** `simulationType: physics2d` auto-prepends Transform2d + PhysicsRefs. Declaring them manually causes conflicts.
|
|
100
|
+
|
|
101
|
+
## Systems
|
|
102
|
+
|
|
103
|
+
### Wrong System Order
|
|
104
|
+
|
|
105
|
+
**Wrong:** HashVerificationSystem before game logic systems
|
|
106
|
+
**Correct:** HashVerificationSystem always last
|
|
107
|
+
**Why:** Hash verification must check state after all game logic has run.
|
|
108
|
+
|
|
109
|
+
### Storing State Outside ArrayBuffer
|
|
110
|
+
|
|
111
|
+
**Wrong:**
|
|
112
|
+
```typescript
|
|
113
|
+
class MySystem {
|
|
114
|
+
private _cache = new Map(); // Lost on rollback!
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
**Correct:** Store all state in ECS components, singletons, or player resources.
|
|
118
|
+
**Why:** Rollback restores the ArrayBuffer but NOT JavaScript variables. External state causes desync.
|
|
119
|
+
|
|
120
|
+
### Modifying State in View Layer
|
|
121
|
+
|
|
122
|
+
**Wrong:**
|
|
123
|
+
```typescript
|
|
124
|
+
// In filterView onUpdate:
|
|
125
|
+
runner.Core.Transform2d.unsafe.positionX[entity] = smoothedX;
|
|
126
|
+
```
|
|
127
|
+
**Correct:** View layer is read-only. Only systems modify ECS state.
|
|
128
|
+
**Why:** View modifications bypass deterministic simulation, causing desync.
|
|
129
|
+
|
|
130
|
+
## Rendering
|
|
131
|
+
|
|
132
|
+
### Not Using VisualSmoother2d
|
|
133
|
+
|
|
134
|
+
**Wrong:**
|
|
135
|
+
```typescript
|
|
136
|
+
container.position.set(
|
|
137
|
+
transform.unsafe.positionX[entity],
|
|
138
|
+
transform.unsafe.positionY[entity],
|
|
139
|
+
);
|
|
140
|
+
```
|
|
141
|
+
**Better:**
|
|
142
|
+
```typescript
|
|
143
|
+
const smoothed = smoother.update(prevX, prevY, currX, currY, factor, dt);
|
|
144
|
+
container.position.set(smoothed.x, smoothed.y);
|
|
145
|
+
```
|
|
146
|
+
**Why:** Without smoothing, entities teleport on rollback. VisualSmoother2d absorbs position jumps and decays smoothly.
|
|
147
|
+
|
|
148
|
+
### Using getCursor in Hot Path
|
|
149
|
+
|
|
150
|
+
**Wrong:**
|
|
151
|
+
```typescript
|
|
152
|
+
onUpdate: ({ entity }, container) => {
|
|
153
|
+
const cursor = transform.getCursor(entity); // Object allocation every frame
|
|
154
|
+
container.x = cursor.positionX;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
**Correct:**
|
|
158
|
+
```typescript
|
|
159
|
+
onUpdate: ({ entity }, container) => {
|
|
160
|
+
container.x = transform.unsafe.positionX[entity]; // Direct typed array access
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
**Why:** getCursor creates an object per call. In onUpdate (called every frame per entity), use unsafe arrays.
|
|
164
|
+
|
|
165
|
+
## Physics
|
|
166
|
+
|
|
167
|
+
### Using handle | 0 for Rapier Handles
|
|
168
|
+
|
|
169
|
+
**Wrong:** `const index = handle | 0;`
|
|
170
|
+
**Correct:** Use `handleToIndex()` from `@lagless/physics-shared`
|
|
171
|
+
**Why:** Rapier handles are Float64 where low values are denormalized floats. Bitwise OR gives 0 for all of them.
|
|
172
|
+
|
|
173
|
+
### Setting Dynamic Body Position Directly
|
|
174
|
+
|
|
175
|
+
**Wrong:**
|
|
176
|
+
```typescript
|
|
177
|
+
this._transform.unsafe.positionX[entity] = newX; // Physics will overwrite!
|
|
178
|
+
```
|
|
179
|
+
**Correct:**
|
|
180
|
+
```typescript
|
|
181
|
+
this._physics.setLinearVelocity(entity, { x: vx, y: vy });
|
|
182
|
+
// or
|
|
183
|
+
this._physics.applyImpulse(entity, { x: fx, y: fy });
|
|
184
|
+
```
|
|
185
|
+
**Why:** PhysicsStep syncs Rapier→ECS, overwriting manual position changes. Move dynamic bodies via forces/velocity.
|
|
186
|
+
|
|
187
|
+
### Forgetting updateSceneQueries After Restore
|
|
188
|
+
|
|
189
|
+
The framework handles this automatically, but if you're doing custom Rapier operations:
|
|
190
|
+
**Always** call `world.updateSceneQueries()` after `World.restoreSnapshot()`.
|
|
191
|
+
**Why:** Restored worlds have empty QueryPipeline — ray casts and shape casts will miss all colliders.
|
|
192
|
+
|
|
193
|
+
## Multiplayer
|
|
194
|
+
|
|
195
|
+
### Keeping State in Frontend Variables
|
|
196
|
+
|
|
197
|
+
**Wrong:**
|
|
198
|
+
```typescript
|
|
199
|
+
const [score, setScore] = useState(0);
|
|
200
|
+
// Updated in signal handler, lost on page refresh
|
|
201
|
+
```
|
|
202
|
+
**Better:** Read score from `PlayerResource.score[slot]` in the ArrayBuffer.
|
|
203
|
+
**Why:** React state is not synchronized across clients. ECS state in the ArrayBuffer is.
|
|
204
|
+
|
|
205
|
+
### Not Testing with Two Players
|
|
206
|
+
|
|
207
|
+
Always test multiplayer with at least 2 browser tabs or dev-player instances. Single-player testing misses:
|
|
208
|
+
- Rollback behavior
|
|
209
|
+
- Input delay effects
|
|
210
|
+
- State transfer
|
|
211
|
+
- Determinism bugs
|
|
212
|
+
|
|
213
|
+
## Error Message Solutions
|
|
214
|
+
|
|
215
|
+
| Error | Cause | Solution |
|
|
216
|
+
|-------|-------|---------|
|
|
217
|
+
| `MathOps not initialized` | MathOps.init() not called | Add `await MathOps.init()` before simulation starts |
|
|
218
|
+
| `Entity X has no component Y` | Component not added before access | Call `addComponent(entity, Y)` first |
|
|
219
|
+
| `Filter overflow` | More entities than maxEntities | Increase `maxEntities` in ECSConfig |
|
|
220
|
+
| `Cannot read property of undefined` in system | DI injection failed | Check constructor parameter type matches imported class |
|
|
221
|
+
| `reflect-metadata` error | Missing import | Add `import '@abraham/reflection'` in main entry point |
|
|
222
|
+
| Hash mismatch in debug panel | Determinism violation | See [03-determinism.md](03-determinism.md) debugging section |
|
|
223
|
+
| `WASM module not found` | MathOps WASM not loaded | Ensure `await MathOps.init()` completes before any math |
|
|
224
|
+
| State transfer failed | No quorum on snapshot hash | Check for determinism bugs — clients diverged before state transfer |
|