@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.
Files changed (46) hide show
  1. package/LICENSE +26 -0
  2. package/dist/index.js +96 -16
  3. package/dist/index.js.map +1 -1
  4. package/package.json +5 -4
  5. package/templates/pixi-react/AGENTS.md +57 -27
  6. package/templates/pixi-react/CLAUDE.md +225 -49
  7. package/templates/pixi-react/README.md +16 -6
  8. package/templates/pixi-react/__packageName__-backend/package.json +1 -0
  9. package/templates/pixi-react/__packageName__-backend/src/main.ts +2 -0
  10. package/templates/pixi-react/__packageName__-frontend/package.json +8 -0
  11. package/templates/pixi-react/__packageName__-frontend/src/app/game-view/grid-background.tsx +4 -0
  12. package/templates/pixi-react/__packageName__-frontend/src/app/game-view/player-view.tsx +68 -0
  13. package/templates/pixi-react/__packageName__-frontend/src/app/game-view/runner-provider.tsx +57 -0
  14. package/templates/pixi-react/__packageName__-frontend/src/app/hooks/use-start-multiplayer-match.ts +5 -5
  15. package/templates/pixi-react/__packageName__-frontend/src/app/screens/title.screen.tsx +18 -1
  16. package/templates/pixi-react/__packageName__-simulation/package.json +7 -0
  17. package/templates/pixi-react/__packageName__-simulation/src/lib/arena.ts +12 -0
  18. package/templates/pixi-react/__packageName__-simulation/src/lib/schema/ecs.yaml +90 -6
  19. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/apply-move-input.system.ts +73 -0
  20. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/boundary.system.ts +2 -0
  21. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/damping.system.ts +2 -0
  22. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/index.ts +8 -0
  23. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/integrate.system.ts +2 -0
  24. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/physics-step.system.ts +65 -0
  25. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-connection.system.ts +158 -0
  26. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-leave.system.ts +70 -0
  27. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/save-prev-transform.system.ts +46 -0
  28. package/templates/pixi-react/docs/01-schema-and-codegen.md +244 -0
  29. package/templates/pixi-react/docs/02-ecs-systems.md +293 -0
  30. package/templates/pixi-react/docs/03-determinism.md +204 -0
  31. package/templates/pixi-react/docs/04-input-system.md +255 -0
  32. package/templates/pixi-react/docs/05-signals.md +175 -0
  33. package/templates/pixi-react/docs/06-rendering.md +256 -0
  34. package/templates/pixi-react/docs/07-multiplayer.md +277 -0
  35. package/templates/pixi-react/docs/08-physics2d.md +266 -0
  36. package/templates/pixi-react/docs/08-physics3d.md +312 -0
  37. package/templates/pixi-react/docs/09-recipes.md +362 -0
  38. package/templates/pixi-react/docs/10-common-mistakes.md +224 -0
  39. package/templates/pixi-react/docs/api-quick-reference.md +254 -0
  40. package/templates/pixi-react/package.json +6 -0
  41. /package/templates/pixi-react/__packageName__-backend/{tsconfig.json → tsconfig.json.ejs} +0 -0
  42. /package/templates/pixi-react/__packageName__-frontend/{tsconfig.json → tsconfig.json.ejs} +0 -0
  43. /package/templates/pixi-react/__packageName__-frontend/{vite.config.ts → vite.config.ts.ejs} +0 -0
  44. /package/templates/pixi-react/__packageName__-simulation/{.swcrc → .swcrc.ejs} +0 -0
  45. /package/templates/pixi-react/__packageName__-simulation/{tsconfig.json → tsconfig.json.ejs} +0 -0
  46. /package/templates/pixi-react/{tsconfig.base.json → tsconfig.base.json.ejs} +0 -0
@@ -0,0 +1,204 @@
1
+ # Determinism — THE MOST CRITICAL DOCUMENT
2
+
3
+ ## Why Determinism Matters
4
+
5
+ Lagless uses **deterministic lockstep with rollback**. Every client runs the same simulation independently. If any client produces a different result from the same inputs, the simulations **permanently diverge** — players see different game states and the game is unplayable. There is no automatic recovery without a full state reset.
6
+
7
+ ## The Golden Rule
8
+
9
+ **Same inputs + same initial state + same PRNG seed = byte-identical simulation state on every client, every platform, every time.**
10
+
11
+ ## ALWAYS Do These
12
+
13
+ ### Use MathOps for Trigonometry and Square Root
14
+
15
+ ```typescript
16
+ import { MathOps } from '@lagless/math';
17
+
18
+ // CORRECT — WASM-backed, identical on all platforms
19
+ const s = MathOps.sin(angle);
20
+ const c = MathOps.cos(angle);
21
+ const a = MathOps.atan2(dy, dx);
22
+ const d = MathOps.sqrt(dx * dx + dy * dy);
23
+ const v = MathOps.clamp(value, min, max);
24
+ ```
25
+
26
+ ### Use PRNG for Randomness
27
+
28
+ ```typescript
29
+ // CORRECT — deterministic, state stored in ArrayBuffer
30
+ const random = this._prng.getFloat(); // [0, 1)
31
+ const roll = this._prng.getRandomInt(1, 7); // [1, 7) = 1-6
32
+ ```
33
+
34
+ ### Set prevPosition on Entity Spawn
35
+
36
+ ```typescript
37
+ // CORRECT — prevents one-frame interpolation jump from (0,0)
38
+ this._transform.set(entity, {
39
+ positionX: spawnX,
40
+ positionY: spawnY,
41
+ prevPositionX: spawnX,
42
+ prevPositionY: spawnY,
43
+ });
44
+ ```
45
+
46
+ ### Initialize MathOps Before Simulation
47
+
48
+ ```typescript
49
+ // In app startup (main.tsx or runner-provider.tsx):
50
+ await MathOps.init();
51
+ ```
52
+
53
+ ## NEVER Do These in Simulation Code
54
+
55
+ ### Platform-Dependent Math
56
+
57
+ ```typescript
58
+ // WRONG — results differ between browsers/platforms
59
+ Math.sin(x); // Use MathOps.sin(x)
60
+ Math.cos(x); // Use MathOps.cos(x)
61
+ Math.tan(x); // Use MathOps.tan(x)
62
+ Math.atan2(y,x); // Use MathOps.atan2(y, x)
63
+ Math.sqrt(x); // Use MathOps.sqrt(x)
64
+ Math.pow(x, y); // Use MathOps.pow(x, y) or x ** y for integer powers
65
+ Math.log(x); // Use MathOps.log(x)
66
+ Math.exp(x); // Use MathOps.exp(x)
67
+ ```
68
+
69
+ ### Non-Deterministic Sources
70
+
71
+ ```typescript
72
+ // WRONG — different on every client
73
+ Math.random(); // Use PRNG.getFloat()
74
+ Date.now(); // Use tick number instead
75
+ performance.now(); // Use tick number instead
76
+ new Date(); // Use tick number instead
77
+ crypto.getRandomValues(); // Use PRNG
78
+ ```
79
+
80
+ ### Unstable Iteration
81
+
82
+ ```typescript
83
+ // WRONG — sort is not stable without comparator, may differ between engines
84
+ array.sort();
85
+ // CORRECT
86
+ array.sort((a, b) => a - b);
87
+
88
+ // WRONG — key order is not guaranteed
89
+ for (const key in obj) { ... }
90
+ // CORRECT — use arrays or Maps with deterministic insertion order
91
+
92
+ // CAUTION — Map/Set iteration order depends on insertion order
93
+ // Only safe if insertions happen in deterministic order (e.g., by entity ID)
94
+ ```
95
+
96
+ ## SAFE Math Functions
97
+
98
+ These JavaScript Math functions are **safe** — they produce identical results on all platforms because they operate on exact integer or bit-level operations:
99
+
100
+ ```typescript
101
+ Math.abs(x) // absolute value
102
+ Math.min(a, b) // minimum
103
+ Math.max(a, b) // maximum
104
+ Math.floor(x) // round down
105
+ Math.ceil(x) // round up
106
+ Math.round(x) // round to nearest
107
+ Math.trunc(x) // truncate decimal
108
+ Math.sign(x) // sign (-1, 0, 1)
109
+ Math.fround(x) // round to float32
110
+ Math.hypot(a, b) // safe — but prefer MathOps.sqrt(a*a + b*b) for consistency
111
+ ```
112
+
113
+ ## NaN Propagation — Silent Killer
114
+
115
+ NaN is the most dangerous desync source because it propagates silently through all operations:
116
+
117
+ ```
118
+ NaN + 5 = NaN
119
+ MathOps.clamp(NaN, 0, 1) = NaN // clamp does NOT fix NaN!
120
+ MathOps.sin(NaN) = NaN
121
+ Rapier body.setTranslation({x: NaN, y: NaN}) → corrupted physics state
122
+ ```
123
+
124
+ **The chain:** Malicious/buggy RPC → `NaN` in field → `MathOps.clamp(NaN, -1, 1)` → still `NaN` → entity position → physics engine → **permanent divergence across all clients**
125
+
126
+ **The fix:** Always check `Number.isFinite()` BEFORE any math:
127
+
128
+ ```typescript
129
+ const finite = (v: number): number => Number.isFinite(v) ? v : 0;
130
+
131
+ // In system:
132
+ let dirX = finite(rpc.data.directionX); // NaN → 0, Infinity → 0
133
+ dirX = MathOps.clamp(dirX, -1, 1); // Now safe to clamp
134
+ ```
135
+
136
+ ## Float32 Precision
137
+
138
+ The framework stores most values as `float32`. TypedArray access automatically truncates `float64` → `float32`. Do NOT manually cast with `Math.fround()` — the framework handles it.
139
+
140
+ However, **intermediate calculations** in JavaScript are always `float64`. This is fine as long as you write results back through typed arrays (which truncate) and don't compare intermediate float64 values across clients.
141
+
142
+ ## Debugging Divergence
143
+
144
+ ### Step 1: Detect
145
+
146
+ Open the F3 debug panel → hash verification table. Red entries = divergence detected at that tick.
147
+
148
+ ### Step 2: Reproduce
149
+
150
+ Use dev-player (`pnpm dev:player`) with 2+ instances. Perform the same actions. Watch for hash mismatch.
151
+
152
+ ### Step 3: Narrow Down
153
+
154
+ Binary search by adding temporary hash checks between systems:
155
+
156
+ ```typescript
157
+ // In systems/index.ts — temporarily split execution to find diverging system
158
+ export const systems = [
159
+ SavePrevTransformSystem,
160
+ PlayerConnectionSystem, // check hash after this
161
+ ApplyMoveInputSystem, // check hash after this
162
+ IntegrateSystem, // check hash after this — if diverges here, problem is in IntegrateSystem
163
+ // ...
164
+ ];
165
+ ```
166
+
167
+ ### Step 4: Common Causes
168
+
169
+ 1. **`Math.sin/cos` instead of `MathOps.sin/cos`** — most common
170
+ 2. **Missing `prevPosition` initialization** — causes one-frame desync on spawn
171
+ 3. **`Math.random()` in simulation code** — each client gets different values
172
+ 4. **Unsorted array iteration** — `Array.sort()` without comparator
173
+ 5. **NaN from unsanitized RPC data** — corrupts physics state
174
+ 6. **Reading `Date.now()` or `performance.now()`** — differs per client
175
+
176
+ ## Determinism Code Review Checklist
177
+
178
+ Before merging any simulation code:
179
+
180
+ - [ ] No `Math.sin/cos/tan/atan2/sqrt/pow/log/exp` — all use `MathOps.*`
181
+ - [ ] No `Math.random()` — uses `PRNG`
182
+ - [ ] No `Date.now()`, `performance.now()`, `new Date()` — uses tick number
183
+ - [ ] All `Array.sort()` calls have explicit comparator
184
+ - [ ] All RPC fields validated with `Number.isFinite()` before use
185
+ - [ ] All spawned entities have `prevPosition = position`
186
+ - [ ] No `for...in` loops on objects with variable key order
187
+ - [ ] No external state (DOM, global variables, closures over non-deterministic data)
188
+ - [ ] PRNG calls are in deterministic order (same code path every tick)
189
+
190
+ ## Testing Determinism
191
+
192
+ The simplest test: run the same inputs twice and compare final state hashes.
193
+
194
+ ```typescript
195
+ // Run simulation with recorded inputs
196
+ const hash1 = computeStateHash(simulation1.mem.buffer);
197
+
198
+ // Reset, run same inputs again
199
+ const hash2 = computeStateHash(simulation2.mem.buffer);
200
+
201
+ assert(hash1 === hash2, 'Simulation is not deterministic!');
202
+ ```
203
+
204
+ The framework's hash verification system does this automatically in multiplayer — each client reports state hashes and they're compared. Use the F3 debug panel to monitor.
@@ -0,0 +1,255 @@
1
+ # Input System
2
+
3
+ ## Architecture Overview
4
+
5
+ ```
6
+ Client UI → drainInputs() → addRPC() → RPCHistory → WebSocket → Server
7
+ Server → relay to all clients → TickInputFanout → RPCHistory
8
+ System → collectTickRPCs(tick, InputClass) → process RPCs
9
+ ```
10
+
11
+ 1. **Client sends inputs** via `drainInputs` callback (called every frame)
12
+ 2. **RPCs are scheduled** at `currentTick + inputDelay` ticks ahead
13
+ 3. **Server relays** inputs to all connected clients
14
+ 4. **Systems read RPCs** via `collectTickRPCs(tick, InputClass)` during simulation
15
+ 5. **Rollback** occurs when remote inputs arrive for already-simulated ticks
16
+
17
+ ## Client Side: drainInputs
18
+
19
+ The `drainInputs` callback is passed to the ECSRunner and called every frame. It receives the current tick and an `addRPC` function.
20
+
21
+ ```typescript
22
+ // In runner-provider.tsx:
23
+ <RunnerProvider
24
+ drainInputs={(tick, addRPC) => {
25
+ // Read keyboard/joystick state
26
+ const keys = getActiveKeys();
27
+ let dirX = 0, dirY = 0;
28
+ if (keys.has('ArrowLeft') || keys.has('a')) dirX -= 1;
29
+ if (keys.has('ArrowRight') || keys.has('d')) dirX += 1;
30
+ if (keys.has('ArrowUp') || keys.has('w')) dirY -= 1;
31
+ if (keys.has('ArrowDown') || keys.has('s')) dirY += 1;
32
+
33
+ // Send input RPC
34
+ addRPC(MoveInput, { directionX: dirX, directionY: dirY });
35
+ }}
36
+ />
37
+ ```
38
+
39
+ ### When to Send RPCs
40
+
41
+ - **Every frame** for continuous inputs (movement) — even if direction is (0,0)
42
+ - **On action** for discrete inputs (shoot, use ability) — only when triggered
43
+ - **Batch** if multiple inputs happen same frame — each `addRPC()` creates a separate RPC
44
+
45
+ ### Virtual Joystick
46
+
47
+ ```typescript
48
+ import { VirtualJoystickProvider, useVirtualJoystick } from '@lagless/pixi-react';
49
+
50
+ // Wrap game view:
51
+ <VirtualJoystickProvider>
52
+ <GameScene />
53
+ </VirtualJoystickProvider>
54
+
55
+ // In drainInputs:
56
+ const joystick = useVirtualJoystick();
57
+ drainInputs={(tick, addRPC) => {
58
+ addRPC(MoveInput, {
59
+ directionX: joystick.current.x,
60
+ directionY: joystick.current.y,
61
+ });
62
+ }}
63
+ ```
64
+
65
+ ## System Side: Reading RPCs
66
+
67
+ Systems read inputs via `AbstractInputProvider.collectTickRPCs()`:
68
+
69
+ ```typescript
70
+ @ECSSystem()
71
+ export class ApplyMoveInputSystem implements IECSSystem {
72
+ constructor(
73
+ private readonly _input: AbstractInputProvider,
74
+ private readonly _playerBody: PlayerBody,
75
+ private readonly _velocity: Velocity2d,
76
+ private readonly _filter: PlayerFilter,
77
+ ) {}
78
+
79
+ update(tick: number): void {
80
+ const rpcs = this._input.collectTickRPCs(tick, MoveInput);
81
+ for (const rpc of rpcs) {
82
+ // rpc.meta — metadata
83
+ const slot = rpc.meta.playerSlot; // which player sent this
84
+ const seq = rpc.meta.seq; // sequence number
85
+
86
+ // rpc.data — the input fields defined in YAML
87
+ const dirX = rpc.data.directionX;
88
+ const dirY = rpc.data.directionY;
89
+
90
+ // Find and update the player's entity
91
+ for (const entity of this._filter) {
92
+ if (this._playerBody.unsafe.playerSlot[entity] !== slot) continue;
93
+ this._velocity.unsafe.velocityX[entity] = dirX * speed;
94
+ this._velocity.unsafe.velocityY[entity] = dirY * speed;
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ### RPC Ordering
103
+
104
+ RPCs are deterministically ordered by `(playerSlot, ordinal, seq)`. This means:
105
+ - Same RPCs produce the same order regardless of network arrival order
106
+ - Multiple RPCs per tick from the same player are ordered by sequence number
107
+ - Different input types from the same player use ordinal for stable ordering
108
+
109
+ ## Input Sanitization
110
+
111
+ **All RPC data must be treated as potentially malicious.** The binary layer validates message structure but NOT field values — NaN, Infinity, and out-of-range numbers pass through.
112
+
113
+ ```typescript
114
+ // Helper: returns 0 for NaN/Infinity, value otherwise
115
+ const finite = (v: number): number => Number.isFinite(v) ? v : 0;
116
+
117
+ // In system:
118
+ update(tick: number): void {
119
+ const rpcs = this._input.collectTickRPCs(tick, MoveInput);
120
+ for (const rpc of rpcs) {
121
+ // Step 1: Reject non-finite values
122
+ let dirX = finite(rpc.data.directionX);
123
+ let dirY = finite(rpc.data.directionY);
124
+
125
+ // Step 2: Clamp to valid range
126
+ dirX = MathOps.clamp(dirX, -1, 1);
127
+ dirY = MathOps.clamp(dirY, -1, 1);
128
+
129
+ // Now safe to use dirX, dirY
130
+ }
131
+ }
132
+ ```
133
+
134
+ ### Why This Order Matters
135
+
136
+ ```typescript
137
+ // WRONG — NaN passes through clamp
138
+ MathOps.clamp(NaN, -1, 1) // → NaN (NOT -1 or 0!)
139
+
140
+ // CORRECT — check finite first
141
+ finite(NaN) // → 0
142
+ MathOps.clamp(0, -1, 1) // → 0 ✓
143
+ ```
144
+
145
+ ### What to Validate
146
+
147
+ | Field Type | Validation |
148
+ |-----------|-----------|
149
+ | Direction vector (float32) | `finite()` → `clamp(-1, 1)` per component |
150
+ | Angle (float32) | `finite()` — any finite value valid for trig |
151
+ | Speed/power (float32) | `finite()` → `clamp(0, maxSpeed)` |
152
+ | Boolean (uint8) | Treat as `!= 0` — auto-masked to 0-255 by framework |
153
+ | Entity ID (uint32) | Verify entity exists before using |
154
+
155
+ ## Server Events
156
+
157
+ Server events are RPCs emitted by the server (not by players). They represent authoritative game events.
158
+
159
+ ### Emitting from Server Hooks
160
+
161
+ ```typescript
162
+ // In game-hooks.ts:
163
+ const hooks: RoomHooks = {
164
+ onPlayerJoin: (ctx, player) => {
165
+ ctx.emitServerEvent(PlayerJoined, {
166
+ slot: player.slot,
167
+ playerId: player.id,
168
+ });
169
+ },
170
+ onPlayerLeave: (ctx, player, reason) => {
171
+ ctx.emitServerEvent(PlayerLeft, {
172
+ slot: player.slot,
173
+ reason,
174
+ });
175
+ },
176
+ };
177
+ ```
178
+
179
+ ### Reading Server Events in Systems
180
+
181
+ Server events are read the same way as player RPCs:
182
+
183
+ ```typescript
184
+ const rpcs = this._input.collectTickRPCs(tick, PlayerJoined);
185
+ for (const rpc of rpcs) {
186
+ const slot = rpc.data.slot;
187
+ // Create entity for new player...
188
+ }
189
+ ```
190
+
191
+ The key difference: server events have `rpc.meta.playerSlot === SERVER_SLOT` (255).
192
+
193
+ ## Adding a New Input Type
194
+
195
+ 1. **Schema** — Add to `ecs.yaml`:
196
+ ```yaml
197
+ inputs:
198
+ ShootInput:
199
+ targetX: float32
200
+ targetY: float32
201
+ power: float32
202
+ ```
203
+
204
+ 2. **Codegen** — Run `pnpm codegen`
205
+
206
+ 3. **Send from client** — In `drainInputs`:
207
+ ```typescript
208
+ if (shootButtonPressed) {
209
+ addRPC(ShootInput, { targetX: mouseX, targetY: mouseY, power: 1.0 });
210
+ }
211
+ ```
212
+
213
+ 4. **Read in system** — Create or update a system:
214
+ ```typescript
215
+ const rpcs = this._input.collectTickRPCs(tick, ShootInput);
216
+ for (const rpc of rpcs) {
217
+ let targetX = finite(rpc.data.targetX);
218
+ let targetY = finite(rpc.data.targetY);
219
+ let power = MathOps.clamp(finite(rpc.data.power), 0, 1);
220
+ // Create projectile entity...
221
+ }
222
+ ```
223
+
224
+ ## Input Delay
225
+
226
+ Local inputs are scheduled at `currentTick + inputDelay` ticks ahead. This gives RPCs time to reach the server and be relayed to other clients before that tick is simulated.
227
+
228
+ - **Higher input delay** → fewer rollbacks, more latency feel
229
+ - **Lower input delay** → more responsive, more rollbacks on high latency
230
+ - **Default:** adaptive, managed by `InputDelayController`
231
+
232
+ The input delay is automatically adjusted based on network conditions. You generally don't need to configure it.
233
+
234
+ ## Hash Reporting
235
+
236
+ Hash reporting sends periodic state hashes to the server for divergence detection.
237
+
238
+ ```typescript
239
+ // In runner-provider.tsx:
240
+ import { createHashReporter } from '@lagless/core';
241
+
242
+ // After runner creation:
243
+ const hashReporter = createHashReporter(runner, {
244
+ inputProvider: runner.InputProviderInstance,
245
+ ReportHashInput: ReportHash,
246
+ });
247
+
248
+ // In drainInputs:
249
+ drainInputs={(tick, addRPC) => {
250
+ hashReporter.drain(tick, addRPC); // Report hashes for verified ticks
251
+ // ... other inputs
252
+ }}
253
+ ```
254
+
255
+ The `HashVerificationSystem` on the simulation side compares reported hashes and emits `DivergenceSignal` on mismatch.
@@ -0,0 +1,175 @@
1
+ # Signals — Rollback-Aware Events
2
+
3
+ ## Overview
4
+
5
+ Signals are the bridge between deterministic simulation and non-deterministic view layer. They handle the complexity of rollback by providing three event streams:
6
+
7
+ - **Predicted** — fired immediately when the signal is emitted (may be rolled back later)
8
+ - **Verified** — fired when the signal survives all rollbacks (guaranteed permanent)
9
+ - **Cancelled** — fired when a previously predicted signal is rolled back (undo)
10
+
11
+ ## Why Three Streams?
12
+
13
+ In a rollback-based multiplayer game:
14
+ 1. Player shoots → system emits signal → **Predicted**: play gunshot sound
15
+ 2. Network delivers remote player's dodge input → rollback to before the shot
16
+ 3. Re-simulation: shot never happened → **Cancelled**: stop gunshot sound
17
+ 4. OR: re-simulation confirms the shot → **Verified**: add score, show hit marker
18
+
19
+ ## Defining a Signal
20
+
21
+ Signals are classes decorated with `@ECSSignal()` that extend `Signal<TData>`:
22
+
23
+ ```typescript
24
+ // In signals/index.ts:
25
+ import { ECSSignal, Signal } from '@lagless/core';
26
+
27
+ @ECSSignal()
28
+ export class ScoreSignal extends Signal<{ slot: number; points: number }> {}
29
+
30
+ @ECSSignal()
31
+ export class DeathSignal extends Signal<{ entityId: number; killerSlot: number }> {}
32
+
33
+ @ECSSignal()
34
+ export class ExplosionSignal extends Signal<{ x: number; y: number; radius: number }> {}
35
+ ```
36
+
37
+ The generic type `TData` defines the event payload. It must be a plain object with primitive values (for shallow comparison during rollback verification).
38
+
39
+ ## Emitting Signals in Systems
40
+
41
+ ```typescript
42
+ @ECSSystem()
43
+ export class CombatSystem implements IECSSystem {
44
+ constructor(
45
+ private readonly _scoreSignal: ScoreSignal,
46
+ private readonly _deathSignal: DeathSignal,
47
+ ) {}
48
+
49
+ update(tick: number): void {
50
+ // When something happens:
51
+ if (playerDied) {
52
+ this._deathSignal.emit(tick, { entityId: entity, killerSlot: killerSlot });
53
+ this._scoreSignal.emit(tick, { slot: killerSlot, points: 100 });
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ **Key:** `emit(tick, data)` — the tick is used for rollback tracking.
60
+
61
+ ## Subscribing in View Layer
62
+
63
+ ```typescript
64
+ // In a React component or game-view setup:
65
+ useEffect(() => {
66
+ const sub1 = scoreSignal.Predicted.subscribe(event => {
67
+ // Instant feedback — may be rolled back
68
+ showFloatingText(`+${event.data.points}`);
69
+ });
70
+
71
+ const sub2 = scoreSignal.Verified.subscribe(event => {
72
+ // Permanent — survived all rollbacks
73
+ updateScoreboard(event.data.slot, event.data.points);
74
+ });
75
+
76
+ const sub3 = scoreSignal.Cancelled.subscribe(event => {
77
+ // Undo the prediction
78
+ removeFloatingText();
79
+ });
80
+
81
+ return () => {
82
+ sub1.unsubscribe();
83
+ sub2.unsubscribe();
84
+ sub3.unsubscribe();
85
+ };
86
+ }, []);
87
+ ```
88
+
89
+ ## Verification Mechanism
90
+
91
+ Signals are verified via `verifiedTick` — the latest tick guaranteed to never be rolled back.
92
+
93
+ | Input Provider | verifiedTick | Meaning |
94
+ |---------------|-------------|---------|
95
+ | `LocalInputProvider` | `= simulation.tick` | Immediate — no rollback possible in single-player |
96
+ | `ReplayInputProvider` | `= simulation.tick` | Immediate — replaying recorded inputs |
97
+ | `RelayInputProvider` | `= max(serverTick) - 1` | Server-confirmed, hard guarantee |
98
+
99
+ Each tick, `SignalsRegistry.onTick(verifiedTick)` processes:
100
+ 1. All ticks from `_lastVerifiedTick + 1` to `verifiedTick`
101
+ 2. Compares `_awaitingVerification` (predicted) against `_pending` (actual after re-simulation)
102
+ 3. Matching signals → **Verified** stream
103
+ 4. Missing signals (were predicted but not re-emitted) → **Cancelled** stream
104
+
105
+ ## Use Case Guide
106
+
107
+ | Scenario | Predicted | Verified | Cancelled |
108
+ |----------|----------|---------|-----------|
109
+ | Sound effects | Play sound | — | Stop/fade sound |
110
+ | Score display | Show floating +100 | Update scoreboard | Remove floating text |
111
+ | Particle effects | Spawn particles | — | Fade particles early |
112
+ | Death/respawn | Play death animation | Remove entity from UI | Reverse death animation |
113
+ | Chat/notifications | — | Show message | — |
114
+ | Achievement | — | Award achievement | — |
115
+
116
+ **Rule of thumb:**
117
+ - Use **Predicted** for immediate sensory feedback (sounds, particles, animations)
118
+ - Use **Verified** for permanent game state changes (score, achievements, chat)
119
+ - Use **Cancelled** to undo Predicted effects when rollback happens
120
+
121
+ ## Signal Registration
122
+
123
+ Signals must be registered in the arena config alongside systems:
124
+
125
+ ```typescript
126
+ // In arena.ts:
127
+ import { ScoreSignal, DeathSignal } from './signals/index.js';
128
+
129
+ export const arenaConfig = {
130
+ // ...
131
+ signals: [ScoreSignal, DeathSignal],
132
+ };
133
+ ```
134
+
135
+ ## Deduplication
136
+
137
+ Signals use shallow object comparison (`_dataEquals`) for deduplication. If the same signal with identical data is emitted at the same tick across prediction and re-simulation, it's treated as the same event (Verified, not double-emitted).
138
+
139
+ This means signal data should contain only primitive values, not references. Object identity is compared field-by-field.
140
+
141
+ ## Common Patterns
142
+
143
+ ### Sound Effect with Rollback Protection
144
+
145
+ ```typescript
146
+ // Predicted: play sound immediately
147
+ explosionSignal.Predicted.subscribe(e => {
148
+ const sound = playExplosionSound(e.data.x, e.data.y);
149
+ pendingSounds.set(e.tick, sound);
150
+ });
151
+
152
+ // Cancelled: stop sound if rolled back
153
+ explosionSignal.Cancelled.subscribe(e => {
154
+ const sound = pendingSounds.get(e.tick);
155
+ if (sound) sound.stop();
156
+ pendingSounds.delete(e.tick);
157
+ });
158
+
159
+ // Verified: cleanup tracking (sound already playing)
160
+ explosionSignal.Verified.subscribe(e => {
161
+ pendingSounds.delete(e.tick);
162
+ });
163
+ ```
164
+
165
+ ### Score with Verified-Only Update
166
+
167
+ ```typescript
168
+ // Only update scoreboard on verified events
169
+ scoreSignal.Verified.subscribe(e => {
170
+ setScores(prev => ({
171
+ ...prev,
172
+ [e.data.slot]: (prev[e.data.slot] ?? 0) + e.data.points,
173
+ }));
174
+ });
175
+ ```