@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,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
|
+
```
|