@lagless/core 0.0.33
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/README.md +970 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/di/di-container.d.ts +7 -0
- package/dist/lib/di/di-container.d.ts.map +1 -0
- package/dist/lib/di/di-container.js +25 -0
- package/dist/lib/di/di-container.js.map +1 -0
- package/dist/lib/di/di-decorators.d.ts +4 -0
- package/dist/lib/di/di-decorators.d.ts.map +1 -0
- package/dist/lib/di/di-decorators.js +96 -0
- package/dist/lib/di/di-decorators.js.map +1 -0
- package/dist/lib/di/index.d.ts +3 -0
- package/dist/lib/di/index.d.ts.map +1 -0
- package/dist/lib/di/index.js +4 -0
- package/dist/lib/di/index.js.map +1 -0
- package/dist/lib/ecs-config.d.ts +16 -0
- package/dist/lib/ecs-config.d.ts.map +1 -0
- package/dist/lib/ecs-config.js +28 -0
- package/dist/lib/ecs-config.js.map +1 -0
- package/dist/lib/ecs-runner.d.ts +20 -0
- package/dist/lib/ecs-runner.d.ts.map +1 -0
- package/dist/lib/ecs-runner.js +60 -0
- package/dist/lib/ecs-runner.js.map +1 -0
- package/dist/lib/ecs-simulation.d.ts +44 -0
- package/dist/lib/ecs-simulation.d.ts.map +1 -0
- package/dist/lib/ecs-simulation.js +138 -0
- package/dist/lib/ecs-simulation.js.map +1 -0
- package/dist/lib/hash-verification/abstract-hash-verification.system.d.ts +32 -0
- package/dist/lib/hash-verification/abstract-hash-verification.system.d.ts.map +1 -0
- package/dist/lib/hash-verification/abstract-hash-verification.system.js +49 -0
- package/dist/lib/hash-verification/abstract-hash-verification.system.js.map +1 -0
- package/dist/lib/hash-verification/create-hash-reporter.d.ts +16 -0
- package/dist/lib/hash-verification/create-hash-reporter.d.ts.map +1 -0
- package/dist/lib/hash-verification/create-hash-reporter.js +21 -0
- package/dist/lib/hash-verification/create-hash-reporter.js.map +1 -0
- package/dist/lib/hash-verification/divergence.signal.d.ts +11 -0
- package/dist/lib/hash-verification/divergence.signal.d.ts.map +1 -0
- package/dist/lib/hash-verification/divergence.signal.js +5 -0
- package/dist/lib/hash-verification/divergence.signal.js.map +1 -0
- package/dist/lib/hash-verification/index.d.ts +4 -0
- package/dist/lib/hash-verification/index.d.ts.map +1 -0
- package/dist/lib/hash-verification/index.js +5 -0
- package/dist/lib/hash-verification/index.js.map +1 -0
- package/dist/lib/input/abstract-input-provider.d.ts +61 -0
- package/dist/lib/input/abstract-input-provider.d.ts.map +1 -0
- package/dist/lib/input/abstract-input-provider.js +110 -0
- package/dist/lib/input/abstract-input-provider.js.map +1 -0
- package/dist/lib/input/index.d.ts +8 -0
- package/dist/lib/input/index.d.ts.map +1 -0
- package/dist/lib/input/index.js +9 -0
- package/dist/lib/input/index.js.map +1 -0
- package/dist/lib/input/input-provider-di-token.d.ts +6 -0
- package/dist/lib/input/input-provider-di-token.d.ts.map +1 -0
- package/dist/lib/input/input-provider-di-token.js +12 -0
- package/dist/lib/input/input-provider-di-token.js.map +1 -0
- package/dist/lib/input/input-registry.d.ts +8 -0
- package/dist/lib/input/input-registry.d.ts.map +1 -0
- package/dist/lib/input/input-registry.js +21 -0
- package/dist/lib/input/input-registry.js.map +1 -0
- package/dist/lib/input/local-input-provider.d.ts +6 -0
- package/dist/lib/input/local-input-provider.d.ts.map +1 -0
- package/dist/lib/input/local-input-provider.js +12 -0
- package/dist/lib/input/local-input-provider.js.map +1 -0
- package/dist/lib/input/replay-input-provider.d.ts +12 -0
- package/dist/lib/input/replay-input-provider.d.ts.map +1 -0
- package/dist/lib/input/replay-input-provider.js +46 -0
- package/dist/lib/input/replay-input-provider.js.map +1 -0
- package/dist/lib/input/rpc-history.d.ts +40 -0
- package/dist/lib/input/rpc-history.d.ts.map +1 -0
- package/dist/lib/input/rpc-history.js +308 -0
- package/dist/lib/input/rpc-history.js.map +1 -0
- package/dist/lib/input/rpc.d.ts +8 -0
- package/dist/lib/input/rpc.d.ts.map +1 -0
- package/dist/lib/input/rpc.js +9 -0
- package/dist/lib/input/rpc.js.map +1 -0
- package/dist/lib/mem/abstract-memory.interface.d.ts +6 -0
- package/dist/lib/mem/abstract-memory.interface.d.ts.map +1 -0
- package/dist/lib/mem/abstract-memory.interface.js +3 -0
- package/dist/lib/mem/abstract-memory.interface.js.map +1 -0
- package/dist/lib/mem/index.d.ts +5 -0
- package/dist/lib/mem/index.d.ts.map +1 -0
- package/dist/lib/mem/index.js +6 -0
- package/dist/lib/mem/index.js.map +1 -0
- package/dist/lib/mem/managers/components-manager.d.ts +15 -0
- package/dist/lib/mem/managers/components-manager.d.ts.map +1 -0
- package/dist/lib/mem/managers/components-manager.js +30 -0
- package/dist/lib/mem/managers/components-manager.js.map +1 -0
- package/dist/lib/mem/managers/entities-manager.d.ts +31 -0
- package/dist/lib/mem/managers/entities-manager.d.ts.map +1 -0
- package/dist/lib/mem/managers/entities-manager.js +106 -0
- package/dist/lib/mem/managers/entities-manager.js.map +1 -0
- package/dist/lib/mem/managers/filters-manager.d.ts +17 -0
- package/dist/lib/mem/managers/filters-manager.d.ts.map +1 -0
- package/dist/lib/mem/managers/filters-manager.js +46 -0
- package/dist/lib/mem/managers/filters-manager.js.map +1 -0
- package/dist/lib/mem/managers/player-resources-manager.d.ts +21 -0
- package/dist/lib/mem/managers/player-resources-manager.d.ts.map +1 -0
- package/dist/lib/mem/managers/player-resources-manager.js +52 -0
- package/dist/lib/mem/managers/player-resources-manager.js.map +1 -0
- package/dist/lib/mem/managers/prng-manager.d.ts +36 -0
- package/dist/lib/mem/managers/prng-manager.d.ts.map +1 -0
- package/dist/lib/mem/managers/prng-manager.js +126 -0
- package/dist/lib/mem/managers/prng-manager.js.map +1 -0
- package/dist/lib/mem/managers/singletons-manager.d.ts +13 -0
- package/dist/lib/mem/managers/singletons-manager.d.ts.map +1 -0
- package/dist/lib/mem/managers/singletons-manager.js +29 -0
- package/dist/lib/mem/managers/singletons-manager.js.map +1 -0
- package/dist/lib/mem/managers/tick-manager.d.ts +10 -0
- package/dist/lib/mem/managers/tick-manager.d.ts.map +1 -0
- package/dist/lib/mem/managers/tick-manager.js +18 -0
- package/dist/lib/mem/managers/tick-manager.js.map +1 -0
- package/dist/lib/mem/mem.d.ts +28 -0
- package/dist/lib/mem/mem.d.ts.map +1 -0
- package/dist/lib/mem/mem.js +63 -0
- package/dist/lib/mem/mem.js.map +1 -0
- package/dist/lib/prefab.d.ts +16 -0
- package/dist/lib/prefab.d.ts.map +1 -0
- package/dist/lib/prefab.js +17 -0
- package/dist/lib/prefab.js.map +1 -0
- package/dist/lib/signals/event-emitter.d.ts +9 -0
- package/dist/lib/signals/event-emitter.d.ts.map +1 -0
- package/dist/lib/signals/event-emitter.js +22 -0
- package/dist/lib/signals/event-emitter.js.map +1 -0
- package/dist/lib/signals/signal.d.ts +40 -0
- package/dist/lib/signals/signal.d.ts.map +1 -0
- package/dist/lib/signals/signal.js +133 -0
- package/dist/lib/signals/signal.js.map +1 -0
- package/dist/lib/signals/signals.registry.d.ts +9 -0
- package/dist/lib/signals/signals.registry.d.ts.map +1 -0
- package/dist/lib/signals/signals.registry.js +31 -0
- package/dist/lib/signals/signals.registry.js.map +1 -0
- package/dist/lib/types/abstract-filter.d.ts +16 -0
- package/dist/lib/types/abstract-filter.d.ts.map +1 -0
- package/dist/lib/types/abstract-filter.js +53 -0
- package/dist/lib/types/abstract-filter.js.map +1 -0
- package/dist/lib/types/ecs-types.d.ts +109 -0
- package/dist/lib/types/ecs-types.d.ts.map +1 -0
- package/dist/lib/types/ecs-types.js +3 -0
- package/dist/lib/types/ecs-types.js.map +1 -0
- package/dist/lib/types/index.d.ts +3 -0
- package/dist/lib/types/index.d.ts.map +1 -0
- package/dist/lib/types/index.js +4 -0
- package/dist/lib/types/index.js.map +1 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
# @lagless/core
|
|
2
|
+
|
|
3
|
+
## 1. Responsibility & Context
|
|
4
|
+
|
|
5
|
+
Provides the core Entity-Component-System (ECS) engine for deterministic multiplayer games with rollback netcode. Manages all game state in a single ArrayBuffer, orchestrates simulation ticks with snapshot/rollback, implements a dependency injection container for systems and signals, and provides input handling with prediction/verification. This is the central library of the Lagless framework — all game logic is built on top of this ECS foundation.
|
|
6
|
+
|
|
7
|
+
## 2. Architecture Role
|
|
8
|
+
|
|
9
|
+
**Foundation layer** — sits above `@lagless/binary`, `@lagless/math`, and `@lagless/misc`. The core ECS engine that all game simulations depend on.
|
|
10
|
+
|
|
11
|
+
**Downstream consumers:**
|
|
12
|
+
- `circle-sumo-simulation` — Implements game-specific components, systems, and logic using the core ECS abstractions
|
|
13
|
+
- Game-specific runners — Extend `ECSRunner` to wire up custom game logic
|
|
14
|
+
|
|
15
|
+
**Upstream dependencies:**
|
|
16
|
+
- `@lagless/binary` — Binary serialization and memory layout (MemoryTracker, TypedArray schemas)
|
|
17
|
+
- `@lagless/math` — Deterministic math operations (MathOps.clamp01 for interpolation)
|
|
18
|
+
- `@lagless/misc` — SimulationClock, SnapshotHistory, PRNG, UUID
|
|
19
|
+
|
|
20
|
+
## 3. Public API
|
|
21
|
+
|
|
22
|
+
### Core Classes
|
|
23
|
+
|
|
24
|
+
#### Mem
|
|
25
|
+
|
|
26
|
+
Single ArrayBuffer containing all game state. Manages 7 memory regions via specialized managers:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
class Mem {
|
|
30
|
+
readonly tickManager: TickManager; // Current tick counter
|
|
31
|
+
readonly prngManager: PRNGManager; // Deterministic PRNG with seed
|
|
32
|
+
readonly componentsManager: ComponentsManager; // All ECS components (SoA layout)
|
|
33
|
+
readonly singletonsManager: SingletonsManager; // Global state singletons
|
|
34
|
+
readonly filtersManager: FiltersManager; // Entity filters (bitmask-based)
|
|
35
|
+
readonly entitiesManager: EntitiesManager; // Entity lifecycle (create/destroy)
|
|
36
|
+
readonly playerResourcesManager: PlayerResourcesManager; // Per-player resources
|
|
37
|
+
|
|
38
|
+
constructor(config: ECSConfig, deps: ECSDeps);
|
|
39
|
+
|
|
40
|
+
exportSnapshot(): ArrayBuffer; // Clone ArrayBuffer (for snapshot storage)
|
|
41
|
+
applySnapshot(arrayBuffer: ArrayBuffer): void; // Overwrite ArrayBuffer (for rollback)
|
|
42
|
+
getHash(): number; // Hash entire ArrayBuffer (for debugging desyncs)
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Key behavior:**
|
|
47
|
+
- All managers write to the same ArrayBuffer in strict order (deterministic layout)
|
|
48
|
+
- Components stored as SoA (Struct of Arrays) — e.g., `component.unsafe.positionX[entityId]`
|
|
49
|
+
- Snapshot = `arrayBuffer.slice(0)`, Rollback = overwrite bytes
|
|
50
|
+
|
|
51
|
+
#### ECSSimulation
|
|
52
|
+
|
|
53
|
+
Manages the simulation loop: tick accumulation, snapshot storage, rollback, input processing, and signal orchestration.
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
class ECSSimulation {
|
|
57
|
+
readonly mem: Mem; // Game state
|
|
58
|
+
readonly clock: SimulationClock; // Time accumulation with PhaseNudger
|
|
59
|
+
|
|
60
|
+
get tick(): number; // Current simulation tick
|
|
61
|
+
get interpolationFactor(): number; // [0, 1] for smooth rendering between ticks
|
|
62
|
+
|
|
63
|
+
constructor(config: ECSConfig, deps: ECSDeps, inputProvider: AbstractInputProvider);
|
|
64
|
+
|
|
65
|
+
registerSystems(systems: IECSSystem[]): void; // Register systems (called once by ECSRunner)
|
|
66
|
+
addTickHandler(handler: (tick: number) => void): () => void; // Subscribe to tick events
|
|
67
|
+
start(): void; // Start simulation clock
|
|
68
|
+
update(dt: number): void; // Main loop: check rollback, simulate ticks, update interpolation
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Simulation flow:**
|
|
73
|
+
1. `update(dt)` — Advances `clock.accumulatedTime` by dt
|
|
74
|
+
2. Check for rollback (if input provider invalidates past ticks)
|
|
75
|
+
3. Simulate ticks from current tick to target tick
|
|
76
|
+
4. Each tick: run all systems in order, handle signals, save snapshot (if snapshotRate)
|
|
77
|
+
5. Update `interpolationFactor` for smooth rendering
|
|
78
|
+
|
|
79
|
+
#### ECSRunner
|
|
80
|
+
|
|
81
|
+
Abstract base class that wires together DI container, simulation, systems, and signals. Extend this to create game-specific runners.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
abstract class ECSRunner {
|
|
85
|
+
readonly DIContainer: Container; // DI container for systems/signals
|
|
86
|
+
readonly Simulation: ECSSimulation; // Simulation instance
|
|
87
|
+
readonly Config: ECSConfig; // Configuration
|
|
88
|
+
readonly InputProviderInstance: AbstractInputProvider; // Input handling
|
|
89
|
+
|
|
90
|
+
protected constructor(
|
|
91
|
+
config: ECSConfig,
|
|
92
|
+
inputProvider: AbstractInputProvider,
|
|
93
|
+
systems: IECSSystemConstructor[],
|
|
94
|
+
signals?: ISignalConstructor[],
|
|
95
|
+
deps: ECSDeps,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
start(): void; // Start simulation
|
|
99
|
+
update(dt: number): void; // Update simulation
|
|
100
|
+
dispose(): void; // Clean up resources
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**What it does:**
|
|
105
|
+
- Registers all components, singletons, filters, player resources with DI container
|
|
106
|
+
- Resolves all systems and signals via DI
|
|
107
|
+
- Registers systems with simulation
|
|
108
|
+
- Initializes signal registry
|
|
109
|
+
|
|
110
|
+
#### ECSConfig
|
|
111
|
+
|
|
112
|
+
Configuration for simulation parameters. All values have sensible defaults.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
class ECSConfig {
|
|
116
|
+
readonly seed: RawSeed; // PRNG seed (16 bytes)
|
|
117
|
+
readonly maxEntities: number; // Max entities (default: 1000)
|
|
118
|
+
readonly maxPlayers: number; // Max players (default: 6)
|
|
119
|
+
readonly initialInputDelayTick: number; // Starting input delay (default: 2)
|
|
120
|
+
readonly minInputDelayTick: number; // Min delay (default: 1)
|
|
121
|
+
readonly maxInputDelayTick: number; // Max delay (default: 8)
|
|
122
|
+
readonly fps: number; // Target FPS (default: 60)
|
|
123
|
+
readonly frameLength: number; // Frame duration in ms (1000/fps)
|
|
124
|
+
readonly snapshotRate: number; // Save snapshot every N ticks (default: 1)
|
|
125
|
+
readonly snapshotHistorySize: number; // Max snapshots stored (default: 100)
|
|
126
|
+
readonly maxNudgePerFrame: number; // Max time correction per frame (default: frameLength/4)
|
|
127
|
+
|
|
128
|
+
constructor(options?: Partial<ECSConfig>);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type RawSeed = [number, ...number[]]; // 16-element array for 128-bit PRNG seed
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### Prefab
|
|
135
|
+
|
|
136
|
+
Builder pattern for creating entities with initial component values.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
class Prefab {
|
|
140
|
+
static create(): Prefab; // Create new prefab builder
|
|
141
|
+
|
|
142
|
+
with<T extends IComponentConstructor>(
|
|
143
|
+
Component: T,
|
|
144
|
+
values?: Partial<ComponentValues<T['schema']>>
|
|
145
|
+
): Prefab; // Add component with optional initial values
|
|
146
|
+
|
|
147
|
+
[Symbol.iterator](): IterableIterator<...>; // Iterate component assignments
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Usage:**
|
|
152
|
+
```typescript
|
|
153
|
+
const playerPrefab = Prefab.create()
|
|
154
|
+
.with(Transform2d, { positionX: 0, positionY: 0, rotation: 0 })
|
|
155
|
+
.with(Velocity2d, { velocityX: 0, velocityY: 0 })
|
|
156
|
+
.with(CircleBody, { radius: 10 });
|
|
157
|
+
|
|
158
|
+
const entityId = entitiesManager.createEntity(playerPrefab);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Dependency Injection
|
|
162
|
+
|
|
163
|
+
#### Container
|
|
164
|
+
|
|
165
|
+
DI container for automatic dependency resolution. Systems and signals use `@ECSSystem()` and `@ECSSignal()` decorators for dependency injection.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
class Container {
|
|
169
|
+
resolve<T>(cls: Token<T>): T; // Resolve class and its dependencies (cached as singleton)
|
|
170
|
+
register<T>(cls: Token<T>, instance: T): void; // Register pre-created instance
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type Token<T = any> = new (...args: any[]) => T; // Constructor type
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
#### Decorators
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
function ECSSystem(...overrideDeps: Token[]): ClassDecorator;
|
|
180
|
+
function ECSSignal(...overrideDeps: Token[]): ClassDecorator;
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**How it works:**
|
|
184
|
+
- Decorators use TypeScript metadata (`reflect-metadata`) to infer constructor dependencies
|
|
185
|
+
- Override deps explicitly if needed: `@ECSSystem(ComponentA, FilterB)`
|
|
186
|
+
- Container resolves dependencies recursively and caches instances
|
|
187
|
+
|
|
188
|
+
### Input System
|
|
189
|
+
|
|
190
|
+
#### AbstractInputProvider
|
|
191
|
+
|
|
192
|
+
Base class for input handling. Implementations: `LocalInputProvider` (client-side), `ReplayInputProvider` (for replays).
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
abstract class AbstractInputProvider {
|
|
196
|
+
abstract init(simulation: ECSSimulation): void;
|
|
197
|
+
abstract update(): void; // Called after simulation ticks
|
|
198
|
+
abstract getInvalidateRollbackTick(): number | undefined; // Return tick to rollback to if inputs changed
|
|
199
|
+
abstract dispose(): void;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
#### RPC (Remote Procedure Call)
|
|
204
|
+
|
|
205
|
+
Represents player input for a single tick.
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
class RPC {
|
|
209
|
+
tick: number; // Tick this input applies to
|
|
210
|
+
playerId: number; // Player who sent this input
|
|
211
|
+
ordinal: number; // Input type ID
|
|
212
|
+
data: ArrayBuffer; // Binary-encoded input data
|
|
213
|
+
|
|
214
|
+
constructor(tick: number, playerId: number, ordinal: number, data: ArrayBuffer);
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
#### RPCHistory
|
|
219
|
+
|
|
220
|
+
Stores input history with efficient tick-based lookup.
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
class RPCHistory {
|
|
224
|
+
addRPC(rpc: RPC): void; // Add input to history
|
|
225
|
+
getRPCsByTick(tick: number): RPC[]; // Get all inputs for a tick
|
|
226
|
+
getRPCsByTickAndPlayer(tick: number, playerId: number): RPC[]; // Get player's inputs for tick
|
|
227
|
+
clear(): void; // Clear all history
|
|
228
|
+
rollback(tick: number): void; // Remove inputs >= tick
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
#### InputRegistry
|
|
233
|
+
|
|
234
|
+
Maps input constructors to their ordinals and schemas.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
class InputRegistry {
|
|
238
|
+
constructor(inputs: IInputConstructor[]);
|
|
239
|
+
getByOrdinal(ordinal: number): IInputConstructor;
|
|
240
|
+
getByConstructor(constructor: IInputConstructor): number; // Returns ordinal
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Signals
|
|
245
|
+
|
|
246
|
+
#### Signal<TData>
|
|
247
|
+
|
|
248
|
+
Event system with rollback-aware Predicted/Verified/Cancelled lifecycle. Systems emit signals, UI subscribes to Predicted/Verified/Cancelled events.
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
abstract class Signal<TData = unknown> {
|
|
252
|
+
readonly Predicted: EventEmitter<SignalEvent<TData>>; // Emitted when signal first occurs
|
|
253
|
+
readonly Verified: EventEmitter<SignalEvent<TData>>; // Emitted after input delay confirms signal
|
|
254
|
+
readonly Cancelled: EventEmitter<SignalEvent<TData>>; // Emitted if rollback invalidates signal
|
|
255
|
+
|
|
256
|
+
constructor(config: ECSConfig);
|
|
257
|
+
|
|
258
|
+
emit(tick: number, data: TData): void; // Emit signal from system
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface SignalEvent<TData> {
|
|
262
|
+
tick: number; // Tick when signal occurred
|
|
263
|
+
data: TData; // Signal payload
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**Lifecycle:**
|
|
268
|
+
1. System emits signal at tick T → `Predicted` fires (UI shows immediate feedback)
|
|
269
|
+
2. At tick T + maxInputDelayTick → Check if signal still exists after rollback/replay
|
|
270
|
+
- If yes → `Verified` fires (confirmed)
|
|
271
|
+
- If no → `Cancelled` fires (was misprediction)
|
|
272
|
+
|
|
273
|
+
### Types
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
// ECS Schema (generated by codegen tool)
|
|
277
|
+
interface ECSSchema {
|
|
278
|
+
components: IComponentConstructor[];
|
|
279
|
+
singletons: ISingletonConstructor[];
|
|
280
|
+
filters: IFilterConstructor[];
|
|
281
|
+
inputs: IInputConstructor[];
|
|
282
|
+
playerResource: IPlayerResourceConstructor;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface ECSDeps extends ECSSchema {}
|
|
286
|
+
|
|
287
|
+
// System interface
|
|
288
|
+
interface IECSSystem {
|
|
289
|
+
run(dt: number): void; // Called every tick
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface IECSSystemConstructor {
|
|
293
|
+
new (...args: any[]): IECSSystem;
|
|
294
|
+
deps: Token[]; // Injected by @ECSSystem() decorator
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Component (SoA layout)
|
|
298
|
+
interface IComponentConstructor {
|
|
299
|
+
name: string;
|
|
300
|
+
ID: number; // Power of 2 for bitmask filtering
|
|
301
|
+
schema: Record<string, TypedArrayConstructor>; // Field name -> TypedArray constructor
|
|
302
|
+
|
|
303
|
+
calculateSize(maxEntities: number, memTracker: MemoryTracker): void;
|
|
304
|
+
new (maxEntities: number, buffer: ArrayBuffer, memTracker: MemoryTracker): IComponentInstance;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface IComponentInstance {
|
|
308
|
+
unsafe: Record<string, TypedArray>; // Field name -> TypedArray (e.g., positionX[entityId])
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Singleton (single instance, not per-entity)
|
|
312
|
+
interface ISingletonConstructor {
|
|
313
|
+
name: string;
|
|
314
|
+
schema: Record<string, TypedArrayConstructor>;
|
|
315
|
+
|
|
316
|
+
calculateSize(memTracker: MemoryTracker): void;
|
|
317
|
+
new (buffer: ArrayBuffer, memTracker: MemoryTracker): ISingletonInstance;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface ISingletonInstance {
|
|
321
|
+
unsafe: Record<string, TypedArray>; // Field name -> TypedArray (single element)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Filter (entity iteration)
|
|
325
|
+
abstract class AbstractFilter implements Iterable<number> {
|
|
326
|
+
[Symbol.iterator](): IterableIterator<number>; // Iterate entity IDs matching filter
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
interface IFilterConstructor {
|
|
330
|
+
new (...args: any[]): AbstractFilter;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Input (player commands)
|
|
334
|
+
interface IInputConstructor {
|
|
335
|
+
ordinal: number; // Input type ID
|
|
336
|
+
schema: Record<string, InputFieldDefinition>; // Binary layout schema
|
|
337
|
+
new (): IInputInstance;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
interface IInputInstance {
|
|
341
|
+
tick: number;
|
|
342
|
+
playerId: number;
|
|
343
|
+
[key: string]: any; // Input-specific fields
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Player Resource (per-player state, e.g., score)
|
|
347
|
+
interface IPlayerResourceConstructor {
|
|
348
|
+
name: string;
|
|
349
|
+
schema: Record<string, TypedArrayConstructor>;
|
|
350
|
+
|
|
351
|
+
calculateSize(maxPlayers: number, memTracker: MemoryTracker): void;
|
|
352
|
+
new (maxPlayers: number, buffer: ArrayBuffer, memTracker: MemoryTracker): IPlayerResourceInstance;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
interface IPlayerResourceInstance {
|
|
356
|
+
unsafe: Record<string, TypedArray>; // Field name -> TypedArray[playerId]
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Managers (exported from Mem)
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
class PRNGManager {
|
|
364
|
+
readonly prng: PRNG; // Deterministic random number generator
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
class PRNG {
|
|
368
|
+
next(): number; // [0, 1) uniform random
|
|
369
|
+
nextInt(max: number): number; // [0, max) integer
|
|
370
|
+
nextIntInRange(min: number, max: number): number; // [min, max) integer
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
class EntitiesManager {
|
|
374
|
+
createEntity(prefab?: Prefab): number; // Create entity, returns entityId
|
|
375
|
+
destroyEntity(entityId: number): void; // Destroy entity (deferred until end of tick)
|
|
376
|
+
hasComponent(entityId: number, componentId: number): boolean;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
class PlayerResourcesManager {
|
|
380
|
+
readonly PlayerResources: IPlayerResourceInstance; // Access per-player resources
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## 4. Preconditions
|
|
385
|
+
|
|
386
|
+
- **`await MathOps.init()` must be called before starting ECSRunner** — MathOps uses WASM and needs async initialization
|
|
387
|
+
- **Systems must be registered before calling `simulation.start()`** — Throws error if no systems registered
|
|
388
|
+
- **ECSRunner constructor requires valid `ECSDeps` schema** — Components/singletons/filters/inputs must be generated by codegen tool
|
|
389
|
+
- **Component IDs must be powers of 2** — Required for bitmask filtering (1, 2, 4, 8, 16, ...)
|
|
390
|
+
- **System execution order matters** — Systems run in the order passed to ECSRunner constructor
|
|
391
|
+
|
|
392
|
+
## 5. Postconditions
|
|
393
|
+
|
|
394
|
+
- After `simulation.start()`, `simulation.update(dt)` runs the tick loop until stopped
|
|
395
|
+
- After `mem.applySnapshot(snapshot)`, all game state reverts to the snapshot's tick
|
|
396
|
+
- After `simulation.update()`, `interpolationFactor` is in [0, 1] for smooth rendering
|
|
397
|
+
- Systems registered via `ECSRunner` are injected with all dependencies via `DIContainer`
|
|
398
|
+
|
|
399
|
+
## 6. Invariants & Constraints
|
|
400
|
+
|
|
401
|
+
- **Single ArrayBuffer constraint:** All game state MUST fit in the allocated ArrayBuffer. Exceeding this size is undefined behavior.
|
|
402
|
+
- **Determinism guarantee:** Given identical inputs and seed, simulation produces identical results across all platforms.
|
|
403
|
+
- **System execution order:** Systems MUST run in the same order every tick. Changing order breaks determinism.
|
|
404
|
+
- **Component SoA layout:** Components are stored as Struct of Arrays. Access via `component.unsafe.fieldName[entityId]`, NOT `component[entityId].fieldName`.
|
|
405
|
+
- **Entity destruction is deferred:** `destroyEntity()` marks entity for deletion, but actual cleanup happens at end of tick.
|
|
406
|
+
- **Signal Predicted→Verified/Cancelled flow:** Signals emitted at tick T are verified/cancelled at tick T + maxInputDelayTick.
|
|
407
|
+
- **Snapshot rate:** Snapshots are saved every `snapshotRate` ticks. Rollback finds nearest past snapshot.
|
|
408
|
+
|
|
409
|
+
## 7. Safety Notes (AI Agent)
|
|
410
|
+
|
|
411
|
+
### DO NOT
|
|
412
|
+
|
|
413
|
+
- **DO NOT use `Math.random()`, `Date.now()`, or async I/O inside systems** — This breaks determinism. Use `PRNG` for randomness.
|
|
414
|
+
- **DO NOT allocate JS objects inside systems** — Components are SoA arrays in ArrayBuffer. Allocating objects breaks snapshot/rollback.
|
|
415
|
+
- **DO NOT reorder systems** — Execution order is critical for determinism. Changing order causes desyncs.
|
|
416
|
+
- **DO NOT mutate component data outside of systems** — Systems are the only place game logic should run.
|
|
417
|
+
- **DO NOT access `component[entityId]`** — Components use SoA layout. Use `component.unsafe.fieldName[entityId]` instead.
|
|
418
|
+
- **DO NOT call `destroyEntity()` and then access the entity in the same tick** — Destruction is deferred until end of tick.
|
|
419
|
+
- **DO NOT modify `ECSConfig` after ECSRunner constructor** — Config is readonly and baked into managers during initialization.
|
|
420
|
+
- **DO NOT forget to register systems** — Simulation throws error if `start()` is called without systems.
|
|
421
|
+
|
|
422
|
+
### Common Mistakes
|
|
423
|
+
|
|
424
|
+
**Using non-deterministic APIs:**
|
|
425
|
+
```typescript
|
|
426
|
+
// ❌ WRONG
|
|
427
|
+
class MovementSystem {
|
|
428
|
+
run() {
|
|
429
|
+
const randomSpeed = Math.random() * 10; // ← NON-DETERMINISTIC
|
|
430
|
+
entity.velocity = randomSpeed;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ✅ CORRECT
|
|
435
|
+
class MovementSystem {
|
|
436
|
+
constructor(private prng: PRNG) {}
|
|
437
|
+
run() {
|
|
438
|
+
const randomSpeed = this.prng.next() * 10; // ← Deterministic PRNG
|
|
439
|
+
entity.velocity = randomSpeed;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
**Allocating objects in systems:**
|
|
445
|
+
```typescript
|
|
446
|
+
// ❌ WRONG
|
|
447
|
+
class CollisionSystem {
|
|
448
|
+
run() {
|
|
449
|
+
const collisions = []; // ← JS object allocation breaks rollback
|
|
450
|
+
for (const entity of filter) {
|
|
451
|
+
collisions.push({ a: entity, b: other });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ✅ CORRECT
|
|
457
|
+
class CollisionSystem {
|
|
458
|
+
run() {
|
|
459
|
+
// Store collision data in components (SoA arrays)
|
|
460
|
+
for (const entity of filter) {
|
|
461
|
+
component.unsafe.collidingWith[entity] = other;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**Accessing components incorrectly:**
|
|
468
|
+
```typescript
|
|
469
|
+
// ❌ WRONG
|
|
470
|
+
const position = transform2d[entityId]; // ← Components are NOT indexed by entity
|
|
471
|
+
|
|
472
|
+
// ✅ CORRECT
|
|
473
|
+
const positionX = transform2d.unsafe.positionX[entityId];
|
|
474
|
+
const positionY = transform2d.unsafe.positionY[entityId];
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**Forgetting system order matters:**
|
|
478
|
+
```typescript
|
|
479
|
+
// ❌ WRONG - Order changed between sessions
|
|
480
|
+
const runner1 = new Runner(config, provider, [PhysicsSystem, MovementSystem], ...);
|
|
481
|
+
const runner2 = new Runner(config, provider, [MovementSystem, PhysicsSystem], ...); // ← DESYNC
|
|
482
|
+
|
|
483
|
+
// ✅ CORRECT - Same order always
|
|
484
|
+
const systemOrder = [MovementSystem, PhysicsSystem];
|
|
485
|
+
const runner = new Runner(config, provider, systemOrder, ...);
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
## 8. Usage Examples
|
|
489
|
+
|
|
490
|
+
### Creating an ECS Runner
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
import { ECSRunner, ECSConfig, LocalInputProvider, MathOps } from '@lagless/core';
|
|
494
|
+
import { MyGameSchema } from './generated/schema'; // From codegen
|
|
495
|
+
import * as Systems from './systems';
|
|
496
|
+
import * as Signals from './signals';
|
|
497
|
+
|
|
498
|
+
class MyGameRunner extends ECSRunner {
|
|
499
|
+
constructor() {
|
|
500
|
+
const config = new ECSConfig({
|
|
501
|
+
fps: 60,
|
|
502
|
+
maxEntities: 500,
|
|
503
|
+
maxPlayers: 4,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const inputProvider = new LocalInputProvider();
|
|
507
|
+
|
|
508
|
+
const systems = [
|
|
509
|
+
Systems.InputSystem,
|
|
510
|
+
Systems.MovementSystem,
|
|
511
|
+
Systems.PhysicsSystem,
|
|
512
|
+
Systems.CollisionSystem,
|
|
513
|
+
Systems.RenderSystem,
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
const signals = [
|
|
517
|
+
Signals.GameOverSignal,
|
|
518
|
+
Signals.ScoreChangedSignal,
|
|
519
|
+
];
|
|
520
|
+
|
|
521
|
+
super(config, inputProvider, systems, signals, MyGameSchema);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Usage
|
|
526
|
+
await MathOps.init(); // MUST call before ECS
|
|
527
|
+
const runner = new MyGameRunner();
|
|
528
|
+
runner.start();
|
|
529
|
+
|
|
530
|
+
// Game loop
|
|
531
|
+
requestAnimationFrame(function loop() {
|
|
532
|
+
const dt = getDeltaTime();
|
|
533
|
+
runner.update(dt);
|
|
534
|
+
requestAnimationFrame(loop);
|
|
535
|
+
});
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### Writing a System
|
|
539
|
+
|
|
540
|
+
```typescript
|
|
541
|
+
import { ECSSystem } from '@lagless/core';
|
|
542
|
+
|
|
543
|
+
@ECSSystem() // Decorator enables DI
|
|
544
|
+
export class MovementSystem {
|
|
545
|
+
constructor(
|
|
546
|
+
private transform2d: Transform2d, // Component
|
|
547
|
+
private velocity2d: Velocity2d, // Component
|
|
548
|
+
private filter: MovingEntitiesFilter // Filter
|
|
549
|
+
) {}
|
|
550
|
+
|
|
551
|
+
run(dt: number): void {
|
|
552
|
+
for (const entityId of this.filter) {
|
|
553
|
+
const vx = this.velocity2d.unsafe.velocityX[entityId];
|
|
554
|
+
const vy = this.velocity2d.unsafe.velocityY[entityId];
|
|
555
|
+
|
|
556
|
+
this.transform2d.unsafe.positionX[entityId] += vx * dt;
|
|
557
|
+
this.transform2d.unsafe.positionY[entityId] += vy * dt;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Using Signals
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
import { Signal, ECSSignal } from '@lagless/core';
|
|
567
|
+
|
|
568
|
+
interface GameOverData {
|
|
569
|
+
winnerId: number;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
@ECSSignal()
|
|
573
|
+
export class GameOverSignal extends Signal<GameOverData> {}
|
|
574
|
+
|
|
575
|
+
// In a system: emit signal
|
|
576
|
+
class GameLogicSystem {
|
|
577
|
+
constructor(
|
|
578
|
+
private gameOver: GameOverSignal,
|
|
579
|
+
private gameState: GameState
|
|
580
|
+
) {}
|
|
581
|
+
|
|
582
|
+
run(): void {
|
|
583
|
+
if (this.gameState.unsafe.playersLeft[0] === 1) {
|
|
584
|
+
const tick = this.gameState.unsafe.currentTick[0];
|
|
585
|
+
const winnerId = this.findLastPlayer();
|
|
586
|
+
this.gameOver.emit(tick, { winnerId });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// In UI: subscribe to signal
|
|
592
|
+
gameOver.Predicted.on((event) => {
|
|
593
|
+
console.log(`Game over! Winner: ${event.data.winnerId} (predicted)`);
|
|
594
|
+
showGameOverScreen(event.data.winnerId);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
gameOver.Verified.on((event) => {
|
|
598
|
+
console.log(`Game over! Winner: ${event.data.winnerId} (verified)`);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
gameOver.Cancelled.on((event) => {
|
|
602
|
+
console.log(`Game over was mispredicted, hiding screen`);
|
|
603
|
+
hideGameOverScreen();
|
|
604
|
+
});
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### Creating Entities
|
|
608
|
+
|
|
609
|
+
```typescript
|
|
610
|
+
import { Prefab, EntitiesManager } from '@lagless/core';
|
|
611
|
+
|
|
612
|
+
class SpawnSystem {
|
|
613
|
+
constructor(
|
|
614
|
+
private entities: EntitiesManager,
|
|
615
|
+
private transform2d: Transform2d,
|
|
616
|
+
private circleBody: CircleBody
|
|
617
|
+
) {}
|
|
618
|
+
|
|
619
|
+
spawnPlayer(x: number, y: number): number {
|
|
620
|
+
const prefab = Prefab.create()
|
|
621
|
+
.with(Transform2d, { positionX: x, positionY: y, rotation: 0 })
|
|
622
|
+
.with(CircleBody, { radius: 10 });
|
|
623
|
+
|
|
624
|
+
return this.entities.createEntity(prefab);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### Snapshot and Rollback
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
// ECSSimulation handles this automatically, but you can trigger manually:
|
|
633
|
+
|
|
634
|
+
// Save snapshot
|
|
635
|
+
const snapshot = simulation.mem.exportSnapshot();
|
|
636
|
+
snapshotHistory.set(currentTick, snapshot);
|
|
637
|
+
|
|
638
|
+
// Rollback to tick
|
|
639
|
+
const rollbackTick = 100;
|
|
640
|
+
const snapshot = snapshotHistory.getNearest(rollbackTick);
|
|
641
|
+
simulation.mem.applySnapshot(snapshot);
|
|
642
|
+
snapshotHistory.rollback(rollbackTick); // Clear snapshots >= tick
|
|
643
|
+
|
|
644
|
+
// Replay from rollbackTick to currentTick
|
|
645
|
+
while (sim.tick < currentTick) {
|
|
646
|
+
sim.simulateTick();
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
## 9. Testing Guidance
|
|
651
|
+
|
|
652
|
+
**Framework:** Vitest (see `libs/core/src/lib/di/di.test.ts` for existing tests)
|
|
653
|
+
|
|
654
|
+
**Running tests:**
|
|
655
|
+
```bash
|
|
656
|
+
# From monorepo root
|
|
657
|
+
nx test core
|
|
658
|
+
|
|
659
|
+
# Or with direct runner
|
|
660
|
+
npm test -- libs/core
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
**Existing test patterns:**
|
|
664
|
+
- `di.test.ts` — DI Container dependency resolution, decorator tests
|
|
665
|
+
|
|
666
|
+
**When adding tests:**
|
|
667
|
+
- **Use deterministic seeds:** Pass explicit seed to `ECSConfig` for reproducible tests
|
|
668
|
+
- **Test system execution order:** Verify output doesn't change if systems run in wrong order (should catch non-determinism)
|
|
669
|
+
- **Test rollback:** Save snapshot, mutate state, restore snapshot, verify state is identical
|
|
670
|
+
- **Test signals:** Emit Predicted, rollback, verify Cancelled fires
|
|
671
|
+
- **Use `mem.getHash()` for desync detection:** Compare hashes between two simulations with same inputs
|
|
672
|
+
|
|
673
|
+
**Example test pattern:**
|
|
674
|
+
```typescript
|
|
675
|
+
import { describe, it, expect } from 'vitest';
|
|
676
|
+
import { ECSSimulation, ECSConfig, Mem } from '@lagless/core';
|
|
677
|
+
|
|
678
|
+
describe('ECSSimulation rollback', () => {
|
|
679
|
+
it('should restore state after rollback', () => {
|
|
680
|
+
const config = new ECSConfig({ seed: [1, 2, 3, ...] });
|
|
681
|
+
const sim = new ECSSimulation(config, deps, inputProvider);
|
|
682
|
+
|
|
683
|
+
// Save snapshot at tick 10
|
|
684
|
+
sim.update(16.666 * 10);
|
|
685
|
+
const snapshot = sim.mem.exportSnapshot();
|
|
686
|
+
const hash1 = sim.mem.getHash();
|
|
687
|
+
|
|
688
|
+
// Mutate state
|
|
689
|
+
sim.update(16.666 * 5);
|
|
690
|
+
expect(sim.mem.getHash()).not.toBe(hash1);
|
|
691
|
+
|
|
692
|
+
// Rollback
|
|
693
|
+
sim.mem.applySnapshot(snapshot);
|
|
694
|
+
expect(sim.mem.getHash()).toBe(hash1);
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
## 10. Change Checklist
|
|
700
|
+
|
|
701
|
+
When modifying this module:
|
|
702
|
+
|
|
703
|
+
1. **Verify determinism:** Test on multiple platforms (Windows/Mac/Linux, different browsers)
|
|
704
|
+
2. **Maintain system order:** Document any system ordering requirements
|
|
705
|
+
3. **Update schema generation:** If changing component/singleton/filter types, update codegen templates
|
|
706
|
+
4. **Test rollback:** Add tests for snapshot/rollback if changing Mem layout
|
|
707
|
+
5. **Check allocation:** Profile to ensure systems don't allocate JS objects
|
|
708
|
+
6. **Update this README:** Document new APIs in Public API section
|
|
709
|
+
7. **Preserve SoA layout:** Components MUST remain Struct of Arrays for snapshot/rollback to work
|
|
710
|
+
8. **DO NOT break DI:** Decorator changes must preserve backward compatibility with existing systems
|
|
711
|
+
|
|
712
|
+
## 11. Integration Notes
|
|
713
|
+
|
|
714
|
+
### Used By
|
|
715
|
+
|
|
716
|
+
- **`circle-sumo-simulation`:**
|
|
717
|
+
- Extends `ECSRunner` to create `CircleSumoRunner`
|
|
718
|
+
- Uses codegen to generate components (Transform2d, Velocity2d, CircleBody, etc.)
|
|
719
|
+
- Systems implement game logic (movement, collision, scoring)
|
|
720
|
+
- Signals for game events (GameOver, HighImpact, PlayerFinishedGame)
|
|
721
|
+
|
|
722
|
+
### Common Integration Patterns
|
|
723
|
+
|
|
724
|
+
**ECS Runner Setup:**
|
|
725
|
+
```typescript
|
|
726
|
+
import { ECSRunner, ECSConfig } from '@lagless/core';
|
|
727
|
+
|
|
728
|
+
export class MyGameRunner extends ECSRunner {
|
|
729
|
+
constructor() {
|
|
730
|
+
// 1. Configure simulation
|
|
731
|
+
const config = new ECSConfig({ fps: 60, maxEntities: 1000 });
|
|
732
|
+
|
|
733
|
+
// 2. Choose input provider
|
|
734
|
+
const inputProvider = isReplay ? new ReplayInputProvider(replayData) : new LocalInputProvider();
|
|
735
|
+
|
|
736
|
+
// 3. Define system execution order (CRITICAL for determinism)
|
|
737
|
+
const systems = [
|
|
738
|
+
InputProcessingSystem, // Read player inputs
|
|
739
|
+
MovementSystem, // Update positions
|
|
740
|
+
PhysicsSystem, // Apply physics
|
|
741
|
+
CollisionSystem, // Detect collisions
|
|
742
|
+
GameLogicSystem, // Handle game rules
|
|
743
|
+
DestructionSystem, // Clean up destroyed entities
|
|
744
|
+
];
|
|
745
|
+
|
|
746
|
+
// 4. Define signals
|
|
747
|
+
const signals = [GameOverSignal, ScoreChangedSignal];
|
|
748
|
+
|
|
749
|
+
// 5. Pass generated schema from codegen
|
|
750
|
+
super(config, inputProvider, systems, signals, GeneratedSchema);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
**Rendering with Interpolation:**
|
|
756
|
+
```typescript
|
|
757
|
+
// In your render loop (runs at display refresh rate, e.g., 144 Hz)
|
|
758
|
+
function render() {
|
|
759
|
+
const factor = runner.Simulation.interpolationFactor; // [0, 1]
|
|
760
|
+
|
|
761
|
+
for (const entityId of visibleEntities) {
|
|
762
|
+
const transform = getTransform2dComponent(entityId);
|
|
763
|
+
|
|
764
|
+
// Interpolate between prev and current transform
|
|
765
|
+
const result = interpolateTransform2dCursor(transform, factor);
|
|
766
|
+
|
|
767
|
+
sprite.x = result.x;
|
|
768
|
+
sprite.y = result.y;
|
|
769
|
+
sprite.rotation = result.rotation;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
requestAnimationFrame(render);
|
|
773
|
+
}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
**Network Integration (with `@lagless/net-wire`):**
|
|
777
|
+
```typescript
|
|
778
|
+
import { ClockSync, InputDelayController } from '@lagless/net-wire';
|
|
779
|
+
|
|
780
|
+
// Setup
|
|
781
|
+
const clockSync = new ClockSync(...);
|
|
782
|
+
const runner = new MyGameRunner();
|
|
783
|
+
|
|
784
|
+
// When clock sync is ready
|
|
785
|
+
clockSync.on('ready', () => {
|
|
786
|
+
runner.Simulation.clock.phaseNudger.activate();
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// On tick input from server
|
|
790
|
+
connection.on('tickInput', (msg) => {
|
|
791
|
+
const serverTick = msg.tick;
|
|
792
|
+
const localTick = runner.Simulation.tick;
|
|
793
|
+
|
|
794
|
+
// Nudge local clock to sync with server
|
|
795
|
+
runner.Simulation.clock.phaseNudger.onServerTickHint(serverTick, localTick);
|
|
796
|
+
|
|
797
|
+
// Add server input to input provider
|
|
798
|
+
runner.InputProviderInstance.addServerInput(msg);
|
|
799
|
+
});
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
## 12. Appendix
|
|
803
|
+
|
|
804
|
+
### Memory Layout (Single ArrayBuffer)
|
|
805
|
+
|
|
806
|
+
```
|
|
807
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
808
|
+
│ Single ArrayBuffer │
|
|
809
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
810
|
+
│ TickManager │ 8 bytes: Uint32Array[1] for current tick │
|
|
811
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
812
|
+
│ PRNGManager │ 64 bytes: PRNG state (xoshiro256++) │
|
|
813
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
814
|
+
│ ComponentsManager │ N components × maxEntities × field sizes │
|
|
815
|
+
│ - Component 1 │ - Field 1: TypedArray[maxEntities] │
|
|
816
|
+
│ - Component 2 │ - Field 2: TypedArray[maxEntities] │
|
|
817
|
+
│ - ... │ - ... │
|
|
818
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
819
|
+
│ SingletonsManager │ M singletons × field sizes (1 instance) │
|
|
820
|
+
│ - Singleton 1 │ - Field 1: TypedArray[1] │
|
|
821
|
+
│ - ... │ - ... │
|
|
822
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
823
|
+
│ FiltersManager │ F filters × bitmask arrays │
|
|
824
|
+
│ - Filter 1 │ - Bitmask: Uint32Array[ceil(maxEntities/32)] │
|
|
825
|
+
│ - ... │ - ... │
|
|
826
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
827
|
+
│ EntitiesManager │ Entity lifecycle tracking │
|
|
828
|
+
│ - Free list │ - Uint32Array for free entity IDs │
|
|
829
|
+
│ - Component masks │ - Bitmask per entity │
|
|
830
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
831
|
+
│ PlayerResourcesManager│ Player resources × maxPlayers │
|
|
832
|
+
│ - Resource Field 1 │ - TypedArray[maxPlayers] │
|
|
833
|
+
│ - ... │ - ... │
|
|
834
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
**Key points:**
|
|
838
|
+
- All managers write to the same ArrayBuffer sequentially
|
|
839
|
+
- Each manager calculates its size first, then writes at the correct offset
|
|
840
|
+
- `MemoryTracker` tracks current write position (ptr)
|
|
841
|
+
- Snapshot = `arrayBuffer.slice(0)` (clones entire buffer)
|
|
842
|
+
- Rollback = `new Uint8Array(dest).set(new Uint8Array(src))` (overwrites bytes)
|
|
843
|
+
|
|
844
|
+
### Component SoA Layout Example
|
|
845
|
+
|
|
846
|
+
**Component definition (from codegen):**
|
|
847
|
+
```typescript
|
|
848
|
+
class Transform2d {
|
|
849
|
+
static ID = 1; // Power of 2
|
|
850
|
+
static schema = {
|
|
851
|
+
positionX: Float32Array,
|
|
852
|
+
positionY: Float32Array,
|
|
853
|
+
rotation: Float32Array,
|
|
854
|
+
prevPositionX: Float32Array,
|
|
855
|
+
prevPositionY: Float32Array,
|
|
856
|
+
prevRotation: Float32Array,
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
constructor(maxEntities: number, buffer: ArrayBuffer, tracker: MemoryTracker) {
|
|
860
|
+
const byteOffset = tracker.ptr;
|
|
861
|
+
this.unsafe = {
|
|
862
|
+
positionX: new Float32Array(buffer, byteOffset, maxEntities),
|
|
863
|
+
positionY: new Float32Array(buffer, byteOffset + maxEntities * 4, maxEntities),
|
|
864
|
+
rotation: new Float32Array(buffer, byteOffset + maxEntities * 8, maxEntities),
|
|
865
|
+
// ... other fields
|
|
866
|
+
};
|
|
867
|
+
tracker.advance(maxEntities * 6 * 4); // 6 fields × 4 bytes
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
**Memory layout (maxEntities = 1000):**
|
|
873
|
+
```
|
|
874
|
+
positionX: [float, float, float, ...] (1000 floats = 4000 bytes)
|
|
875
|
+
positionY: [float, float, float, ...] (1000 floats = 4000 bytes)
|
|
876
|
+
rotation: [float, float, float, ...] (1000 floats = 4000 bytes)
|
|
877
|
+
prevPositionX: [float, float, float, ...] (1000 floats = 4000 bytes)
|
|
878
|
+
prevPositionY: [float, float, float, ...] (1000 floats = 4000 bytes)
|
|
879
|
+
prevRotation: [float, float, float, ...] (1000 floats = 4000 bytes)
|
|
880
|
+
──────────────────────────
|
|
881
|
+
Total: 24000 bytes
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
**Access pattern:**
|
|
885
|
+
```typescript
|
|
886
|
+
// Get entity 42's position
|
|
887
|
+
const x = transform2d.unsafe.positionX[42];
|
|
888
|
+
const y = transform2d.unsafe.positionY[42];
|
|
889
|
+
|
|
890
|
+
// Set entity 42's rotation
|
|
891
|
+
transform2d.unsafe.rotation[42] = MathOps.PI_HALF;
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
### Filter Bitmask System
|
|
895
|
+
|
|
896
|
+
Filters use bitmasks to efficiently iterate entities with specific components.
|
|
897
|
+
|
|
898
|
+
**Example:**
|
|
899
|
+
```typescript
|
|
900
|
+
// Filter definition (from codegen)
|
|
901
|
+
class MovingEntitiesFilter extends AbstractFilter {
|
|
902
|
+
static requiredComponents = [Transform2d.ID, Velocity2d.ID];
|
|
903
|
+
// requiredComponents = [1, 2] (powers of 2)
|
|
904
|
+
// Bitmask = 1 | 2 = 3 (binary: 11)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Entity component masks
|
|
908
|
+
entity[0] mask: 0000 (no components)
|
|
909
|
+
entity[1] mask: 0001 (Transform2d only)
|
|
910
|
+
entity[2] mask: 0011 (Transform2d + Velocity2d) ← matches filter
|
|
911
|
+
entity[3] mask: 0111 (Transform2d + Velocity2d + CircleBody) ← matches filter
|
|
912
|
+
|
|
913
|
+
// Iteration
|
|
914
|
+
for (const entityId of movingEntitiesFilter) {
|
|
915
|
+
// Only entities 2 and 3 are visited
|
|
916
|
+
}
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
**Why powers of 2?**
|
|
920
|
+
- Component IDs are powers of 2: 1, 2, 4, 8, 16, 32, ...
|
|
921
|
+
- Bitmask operations: `mask & requiredMask === requiredMask` checks if entity has all required components
|
|
922
|
+
- Efficient: Single bitwise AND operation, no array lookups
|
|
923
|
+
|
|
924
|
+
### Signal Lifecycle Example
|
|
925
|
+
|
|
926
|
+
```
|
|
927
|
+
Tick 10: System emits GameOver signal
|
|
928
|
+
→ Predicted fires → UI shows "Game Over" screen
|
|
929
|
+
→ Signal added to _awaitingVerification[10]
|
|
930
|
+
|
|
931
|
+
Tick 11-17: Simulation continues...
|
|
932
|
+
|
|
933
|
+
Tick 18 (= 10 + maxInputDelayTick=8):
|
|
934
|
+
→ Check _pending[10] (signals still present after all inputs confirmed)
|
|
935
|
+
→ Signal exists in both pending and awaiting
|
|
936
|
+
→ Verified fires → "Game Over" is confirmed
|
|
937
|
+
|
|
938
|
+
Alternative scenario (rollback):
|
|
939
|
+
Tick 15: Late input arrives for tick 9 → Rollback to tick 9
|
|
940
|
+
Tick 9-17: Replay simulation with new inputs
|
|
941
|
+
Tick 10: GameOver signal NOT emitted this time (different outcome)
|
|
942
|
+
Tick 18: Check _pending[10]
|
|
943
|
+
→ Signal NOT in pending (was cancelled by rollback)
|
|
944
|
+
→ Cancelled fires → UI hides "Game Over" screen
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
### System Execution Order Example
|
|
948
|
+
|
|
949
|
+
**Correct order (deterministic):**
|
|
950
|
+
```
|
|
951
|
+
1. InputProcessingSystem — Reads RPC inputs, updates player commands
|
|
952
|
+
2. MovementSystem — Applies velocity to position
|
|
953
|
+
3. PhysicsSystem — Applies gravity, friction
|
|
954
|
+
4. CollisionSystem — Detects collisions, applies impulses
|
|
955
|
+
5. GameLogicSystem — Checks win conditions, emits signals
|
|
956
|
+
6. DestructionSystem — Destroys marked entities
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
**Why this order matters:**
|
|
960
|
+
- Movement must happen before collision detection (or collisions use stale positions)
|
|
961
|
+
- Physics must happen before collision (or impulses are applied twice)
|
|
962
|
+
- Destruction must be last (so systems don't access destroyed entities)
|
|
963
|
+
|
|
964
|
+
**Wrong order causes desyncs:**
|
|
965
|
+
```
|
|
966
|
+
Client A: [Movement, Collision, Physics] → Entity 5 at (10.5, 20.3)
|
|
967
|
+
Client B: [Movement, Physics, Collision] → Entity 5 at (10.7, 20.1) ← DESYNC
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
Even tiny differences (0.2 pixels) accumulate over time and cause divergence.
|