@lagless/create 0.0.38 → 0.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/dist/index.js +96 -16
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/templates/pixi-react/AGENTS.md +57 -27
- package/templates/pixi-react/CLAUDE.md +225 -49
- package/templates/pixi-react/README.md +16 -6
- package/templates/pixi-react/__packageName__-backend/package.json +1 -0
- package/templates/pixi-react/__packageName__-backend/src/main.ts +2 -0
- package/templates/pixi-react/__packageName__-frontend/package.json +8 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/grid-background.tsx +4 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/player-view.tsx +68 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/runner-provider.tsx +57 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/hooks/use-start-multiplayer-match.ts +5 -5
- package/templates/pixi-react/__packageName__-frontend/src/app/screens/title.screen.tsx +18 -1
- package/templates/pixi-react/__packageName__-simulation/package.json +7 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/arena.ts +12 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/schema/ecs.yaml +90 -6
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/apply-move-input.system.ts +73 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/boundary.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/damping.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/index.ts +8 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/integrate.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/physics-step.system.ts +65 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-connection.system.ts +158 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-leave.system.ts +70 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/save-prev-transform.system.ts +46 -0
- package/templates/pixi-react/docs/01-schema-and-codegen.md +244 -0
- package/templates/pixi-react/docs/02-ecs-systems.md +293 -0
- package/templates/pixi-react/docs/03-determinism.md +204 -0
- package/templates/pixi-react/docs/04-input-system.md +255 -0
- package/templates/pixi-react/docs/05-signals.md +175 -0
- package/templates/pixi-react/docs/06-rendering.md +256 -0
- package/templates/pixi-react/docs/07-multiplayer.md +277 -0
- package/templates/pixi-react/docs/08-physics2d.md +266 -0
- package/templates/pixi-react/docs/08-physics3d.md +312 -0
- package/templates/pixi-react/docs/09-recipes.md +362 -0
- package/templates/pixi-react/docs/10-common-mistakes.md +224 -0
- package/templates/pixi-react/docs/api-quick-reference.md +254 -0
- package/templates/pixi-react/package.json +6 -0
- /package/templates/pixi-react/__packageName__-backend/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-frontend/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-frontend/{vite.config.ts → vite.config.ts.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-simulation/{.swcrc → .swcrc.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-simulation/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/{tsconfig.base.json → tsconfig.base.json.ejs} +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Rendering
|
|
2
|
+
|
|
3
|
+
## Architecture
|
|
4
|
+
|
|
5
|
+
Rendering is **non-deterministic** and **read-only**. The view layer reads ECS state but never writes to it. Simulation runs independently of rendering.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Simulation (deterministic) View Layer (non-deterministic)
|
|
9
|
+
ECS systems → ArrayBuffer ──→ FilterViews → Pixi.js sprites
|
|
10
|
+
Tick N state Interpolated between ticks
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## FilterViews — Entity Lifecycle Management
|
|
14
|
+
|
|
15
|
+
`FilterViews` from `@lagless/pixi-react` automatically manages Pixi.js containers for entities matching a filter. When an entity enters the filter, a view is created. When it leaves, the view is destroyed.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { FilterViews } from '@lagless/pixi-react';
|
|
19
|
+
|
|
20
|
+
// In game scene:
|
|
21
|
+
<FilterViews
|
|
22
|
+
filter={runner.Core.PlayerFilter}
|
|
23
|
+
View={PlayerView}
|
|
24
|
+
/>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## filterView — Define Entity Views
|
|
28
|
+
|
|
29
|
+
`filterView` creates a view component with lifecycle hooks:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { filterView } from '@lagless/pixi-react';
|
|
33
|
+
|
|
34
|
+
const PlayerView = filterView(
|
|
35
|
+
// Render function — called once on entity creation
|
|
36
|
+
({ entity, runner }, ref) => {
|
|
37
|
+
return (
|
|
38
|
+
<pixiContainer ref={ref}>
|
|
39
|
+
<pixiGraphics
|
|
40
|
+
draw={(g) => {
|
|
41
|
+
g.circle(0, 0, 20);
|
|
42
|
+
g.fill({ color: 0x4488ff });
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
</pixiContainer>
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
// Called every render frame — update positions
|
|
50
|
+
onUpdate: ({ entity, runner }, container) => {
|
|
51
|
+
const transform = runner.Core.Transform2d;
|
|
52
|
+
const sim = runner.Simulation;
|
|
53
|
+
|
|
54
|
+
// Use interpolation for smooth rendering between ticks
|
|
55
|
+
const factor = sim.interpolationFactor;
|
|
56
|
+
const prevX = transform.unsafe.prevPositionX[entity];
|
|
57
|
+
const prevY = transform.unsafe.prevPositionY[entity];
|
|
58
|
+
const currX = transform.unsafe.positionX[entity];
|
|
59
|
+
const currY = transform.unsafe.positionY[entity];
|
|
60
|
+
|
|
61
|
+
container.position.set(
|
|
62
|
+
prevX + (currX - prevX) * factor,
|
|
63
|
+
prevY + (currY - prevY) * factor,
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### filterView Lifecycle
|
|
71
|
+
|
|
72
|
+
| Hook | When | Use For |
|
|
73
|
+
|------|------|---------|
|
|
74
|
+
| Render function | Entity enters filter | Create sprites, set up initial state |
|
|
75
|
+
| `onUpdate` | Every render frame | Update position, rotation, animation |
|
|
76
|
+
| `onDestroy` | Entity leaves filter | Cleanup (optional, auto-handled) |
|
|
77
|
+
|
|
78
|
+
## VisualSmoother2d — Rollback Smoothing
|
|
79
|
+
|
|
80
|
+
`VisualSmoother2d` handles both simulation↔render interpolation AND rollback lag smoothing. Without it, entities teleport when a rollback changes their position.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import { VisualSmoother2d } from '@lagless/misc';
|
|
84
|
+
|
|
85
|
+
// Create one per entity (e.g., in filterView render):
|
|
86
|
+
const smoother = new VisualSmoother2d();
|
|
87
|
+
|
|
88
|
+
// In onUpdate:
|
|
89
|
+
onUpdate: ({ entity, runner }, container) => {
|
|
90
|
+
const transform = runner.Core.Transform2d;
|
|
91
|
+
const sim = runner.Simulation;
|
|
92
|
+
|
|
93
|
+
const smoothed = smoother.update(
|
|
94
|
+
transform.unsafe.prevPositionX[entity],
|
|
95
|
+
transform.unsafe.prevPositionY[entity],
|
|
96
|
+
transform.unsafe.positionX[entity],
|
|
97
|
+
transform.unsafe.positionY[entity],
|
|
98
|
+
sim.interpolationFactor,
|
|
99
|
+
sim.clock.deltaTime,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
container.position.set(smoothed.x, smoothed.y);
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### How VisualSmoother2d Works
|
|
107
|
+
|
|
108
|
+
1. **Normal tick:** Interpolates between prevPosition and position using interpolationFactor
|
|
109
|
+
2. **After rollback:** Detects position jump, absorbs it into an offset
|
|
110
|
+
3. **Decay:** Offset decays exponentially (halfLife=200ms) — entity slides smoothly to correct position
|
|
111
|
+
4. **No feedback loop:** Stores raw sim position (not smoothed) for next-frame comparison
|
|
112
|
+
|
|
113
|
+
## Pixi.js Setup
|
|
114
|
+
|
|
115
|
+
The template uses Pixi.js 8 with `@pixi/react` for declarative rendering.
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
// In main.tsx:
|
|
119
|
+
import '@abraham/reflection';
|
|
120
|
+
import { extend } from '@pixi/react';
|
|
121
|
+
import { Container, Graphics, Sprite, Text, Application } from 'pixi.js';
|
|
122
|
+
|
|
123
|
+
extend({ Container, Graphics, Sprite, Text, Application });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### RunnerTicker
|
|
127
|
+
|
|
128
|
+
The `RunnerTicker` component connects the simulation loop to Pixi.js's render loop:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { RunnerTicker } from '@lagless/pixi-react';
|
|
132
|
+
|
|
133
|
+
// Inside <Application>:
|
|
134
|
+
<RunnerTicker runner={runner} />
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
This calls `runner.update(deltaTime)` every frame, advancing the simulation.
|
|
138
|
+
|
|
139
|
+
## Adding New Entity Visuals
|
|
140
|
+
|
|
141
|
+
### Step 1: Define Component and Filter
|
|
142
|
+
|
|
143
|
+
```yaml
|
|
144
|
+
# ecs.yaml
|
|
145
|
+
components:
|
|
146
|
+
Projectile:
|
|
147
|
+
ownerSlot: uint8
|
|
148
|
+
radius: float32
|
|
149
|
+
|
|
150
|
+
filters:
|
|
151
|
+
ProjectileFilter:
|
|
152
|
+
include: [Transform2d, Projectile]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Step 2: Run Codegen
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
pnpm codegen
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Step 3: Create View Component
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
// game-view/projectile-view.tsx
|
|
165
|
+
import { filterView } from '@lagless/pixi-react';
|
|
166
|
+
import { VisualSmoother2d } from '@lagless/misc';
|
|
167
|
+
|
|
168
|
+
const ProjectileView = filterView(
|
|
169
|
+
({ entity, runner }, ref) => {
|
|
170
|
+
const smoother = new VisualSmoother2d();
|
|
171
|
+
const radius = runner.Core.Projectile.unsafe.radius[entity];
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<pixiContainer ref={ref} userData={{ smoother }}>
|
|
175
|
+
<pixiGraphics
|
|
176
|
+
draw={(g) => {
|
|
177
|
+
g.circle(0, 0, radius);
|
|
178
|
+
g.fill({ color: 0xff4444 });
|
|
179
|
+
}}
|
|
180
|
+
/>
|
|
181
|
+
</pixiContainer>
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
onUpdate: ({ entity, runner }, container) => {
|
|
186
|
+
const smoother = container.userData.smoother as VisualSmoother2d;
|
|
187
|
+
const t = runner.Core.Transform2d;
|
|
188
|
+
const sim = runner.Simulation;
|
|
189
|
+
|
|
190
|
+
const pos = smoother.update(
|
|
191
|
+
t.unsafe.prevPositionX[entity], t.unsafe.prevPositionY[entity],
|
|
192
|
+
t.unsafe.positionX[entity], t.unsafe.positionY[entity],
|
|
193
|
+
sim.interpolationFactor, sim.clock.deltaTime,
|
|
194
|
+
);
|
|
195
|
+
container.position.set(pos.x, pos.y);
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
export { ProjectileView };
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Step 4: Add to Game Scene
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
// game-view/game-scene.tsx
|
|
207
|
+
<FilterViews filter={runner.Core.ProjectileFilter} View={ProjectileView} />
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Performance Tips
|
|
211
|
+
|
|
212
|
+
- **Use unsafe arrays in `onUpdate`** — avoid `getCursor()` in render loops
|
|
213
|
+
- **Minimize React re-renders** — FilterViews handles lifecycle, don't use React state for positions
|
|
214
|
+
- **Batch draw calls** — use `pixiGraphics.draw()` callback, not imperative calls every frame
|
|
215
|
+
- **Object pooling** — FilterViews already pools containers; entity recycling handles the rest
|
|
216
|
+
- **Avoid allocations** — use `VECTOR2_BUFFER_1..10` scratch vectors in calculation-heavy render code
|
|
217
|
+
|
|
218
|
+
## Screen Management
|
|
219
|
+
|
|
220
|
+
The template uses React Router for screen navigation:
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
// router.tsx
|
|
224
|
+
<Routes>
|
|
225
|
+
<Route path="/" element={<TitleScreen />} />
|
|
226
|
+
<Route path="/game" element={<GameScreen />} />
|
|
227
|
+
</Routes>
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Title Screen → Game Screen
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
// screens/title.screen.tsx
|
|
234
|
+
const navigate = useNavigate();
|
|
235
|
+
const startMatch = useStartMatch(); // or useStartMultiplayerMatch
|
|
236
|
+
|
|
237
|
+
const handleStart = () => {
|
|
238
|
+
startMatch(); // sets up runner, navigates to /game
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Debug Panel
|
|
243
|
+
|
|
244
|
+
The `<DebugPanel>` from `@lagless/react` shows network stats in development:
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
import { DebugPanel } from '@lagless/react';
|
|
248
|
+
|
|
249
|
+
<DebugPanel
|
|
250
|
+
runner={runner}
|
|
251
|
+
hashVerification={true} // show hash table
|
|
252
|
+
// Toggle with F3 key
|
|
253
|
+
/>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Shows: RTT, jitter, input delay, nudger offset, tick, rollback count, FPS, hash verification table, disconnect/reconnect buttons.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# Multiplayer
|
|
2
|
+
|
|
3
|
+
## Architecture
|
|
4
|
+
|
|
5
|
+
Lagless uses a **relay model**: the server relays inputs between clients but does NOT run the simulation. Clients run identical deterministic simulations independently. The server is authoritative on time (tick assignment) and input acceptance, but clients are authoritative on game state.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Client A ──→ Server (relay) ──→ Client B
|
|
9
|
+
↓ ↓ ↓
|
|
10
|
+
Simulation No simulation Simulation
|
|
11
|
+
(same inputs → same state)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Input Providers
|
|
15
|
+
|
|
16
|
+
The input provider determines how RPCs are processed:
|
|
17
|
+
|
|
18
|
+
| Provider | Use Case | Rollback | verifiedTick |
|
|
19
|
+
|----------|---------|---------|-------------|
|
|
20
|
+
| `LocalInputProvider` | Single-player | None | `= tick` |
|
|
21
|
+
| `ReplayInputProvider` | Recorded playback | None | `= tick` |
|
|
22
|
+
| `RelayInputProvider` | Multiplayer | On remote inputs | `= maxServerTick - 1` |
|
|
23
|
+
|
|
24
|
+
### Single-Player Setup
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { LocalInputProvider } from '@lagless/core';
|
|
28
|
+
|
|
29
|
+
const inputProvider = new LocalInputProvider(inputRegistry);
|
|
30
|
+
const runner = new MyRunner(arenaConfig, inputProvider);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Multiplayer Setup
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { RelayInputProvider } from '@lagless/relay-client';
|
|
37
|
+
|
|
38
|
+
const connection = new RelayConnection({
|
|
39
|
+
serverUrl: 'ws://localhost:3333',
|
|
40
|
+
scope: 'my-game',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const inputProvider = new RelayInputProvider(inputRegistry, connection);
|
|
44
|
+
const runner = new MyRunner(arenaConfig, inputProvider);
|
|
45
|
+
|
|
46
|
+
// Connect and matchmake
|
|
47
|
+
await connection.connect();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## RelayConnection
|
|
51
|
+
|
|
52
|
+
Manages WebSocket connection to the relay server. Handles matchmaking, reconnection, and message routing.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
const connection = new RelayConnection({
|
|
56
|
+
serverUrl: 'ws://localhost:3333', // server URL
|
|
57
|
+
scope: 'my-game', // matchmaking scope (game type)
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Connect
|
|
61
|
+
await connection.connect();
|
|
62
|
+
|
|
63
|
+
// Events
|
|
64
|
+
connection.onMatchFound(() => { ... });
|
|
65
|
+
connection.onDisconnect(() => { ... });
|
|
66
|
+
|
|
67
|
+
// Disconnect
|
|
68
|
+
connection.disconnect();
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Server Setup
|
|
72
|
+
|
|
73
|
+
### RelayGameServer
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
import { RelayGameServer } from '@lagless/relay-game-server';
|
|
77
|
+
import { hooks } from './game-hooks.js';
|
|
78
|
+
import { MyInputRegistry } from '@my-game/simulation';
|
|
79
|
+
|
|
80
|
+
const server = new RelayGameServer({
|
|
81
|
+
port: 3333,
|
|
82
|
+
loggerName: 'MyGameServer',
|
|
83
|
+
roomType: {
|
|
84
|
+
name: 'my-game',
|
|
85
|
+
config: {
|
|
86
|
+
maxPlayers: 4,
|
|
87
|
+
reconnectTimeoutMs: 15_000,
|
|
88
|
+
},
|
|
89
|
+
hooks,
|
|
90
|
+
inputRegistry: MyInputRegistry,
|
|
91
|
+
},
|
|
92
|
+
matchmaking: {
|
|
93
|
+
scope: 'my-game',
|
|
94
|
+
config: {
|
|
95
|
+
minPlayersToStart: 1,
|
|
96
|
+
maxPlayers: 4,
|
|
97
|
+
waitTimeoutMs: 5_000,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.start();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Dev Tools Integration
|
|
106
|
+
|
|
107
|
+
For development, add dev-tools for the dev-player testing tool:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { setupDevTools } from '@lagless/dev-tools';
|
|
111
|
+
|
|
112
|
+
setupDevTools(server); // Register latency API routes
|
|
113
|
+
server.start();
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Room Hooks
|
|
117
|
+
|
|
118
|
+
Room hooks define server-side game lifecycle. The server calls these hooks at appropriate times.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import { RoomHooks, PlayerLeaveReason } from '@lagless/relay-server';
|
|
122
|
+
|
|
123
|
+
export const hooks: RoomHooks = {
|
|
124
|
+
// Room created (before any players join)
|
|
125
|
+
onRoomCreated: (ctx) => {
|
|
126
|
+
console.log('Room created:', ctx.roomId);
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// Player joins the room
|
|
130
|
+
onPlayerJoin: (ctx, player) => {
|
|
131
|
+
ctx.emitServerEvent(PlayerJoined, {
|
|
132
|
+
slot: player.slot,
|
|
133
|
+
playerId: player.id,
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// Player leaves (disconnect, kick, etc.)
|
|
138
|
+
onPlayerLeave: (ctx, player, reason) => {
|
|
139
|
+
ctx.emitServerEvent(PlayerLeft, {
|
|
140
|
+
slot: player.slot,
|
|
141
|
+
reason,
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// Player reconnects after disconnect
|
|
146
|
+
onPlayerReconnect: (ctx, player) => {
|
|
147
|
+
ctx.emitServerEvent(PlayerJoined, {
|
|
148
|
+
slot: player.slot,
|
|
149
|
+
playerId: player.id,
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
// Whether to accept a late-joining player (room already started)
|
|
154
|
+
shouldAcceptLateJoin: (ctx, player) => {
|
|
155
|
+
return true; // or false to reject
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// Whether to accept a reconnecting player
|
|
159
|
+
shouldAcceptReconnect: (ctx, player) => {
|
|
160
|
+
return true; // or false to reject
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// Player reports finished (e.g., game over for them)
|
|
164
|
+
onPlayerFinished: (ctx, player, result) => {
|
|
165
|
+
// result is game-specific data
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// Match ends (all players finished or room timeout)
|
|
169
|
+
onMatchEnd: (ctx, results) => {
|
|
170
|
+
// Persist results to database, etc.
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
// Room is being disposed
|
|
174
|
+
onRoomDisposed: (ctx) => {
|
|
175
|
+
console.log('Room disposed:', ctx.roomId);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### RoomContext (ctx)
|
|
181
|
+
|
|
182
|
+
The `ctx` parameter provides safe room interaction:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
ctx.emitServerEvent(InputClass, data); // Send server-originated RPC
|
|
186
|
+
ctx.getPlayers(); // Get all player info
|
|
187
|
+
ctx.endMatch(results); // End the match
|
|
188
|
+
ctx.roomId; // Room identifier
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Server Events via emitServerEvent
|
|
192
|
+
|
|
193
|
+
Server events are RPCs that originate from the server, not from players. They're used for authoritative game events.
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// In hooks:
|
|
197
|
+
ctx.emitServerEvent(PlayerJoined, { slot: player.slot, playerId: player.id });
|
|
198
|
+
ctx.emitServerEvent(PlayerLeft, { slot: player.slot, reason: 0 });
|
|
199
|
+
|
|
200
|
+
// Custom server events:
|
|
201
|
+
ctx.emitServerEvent(RoundStart, { roundNumber: 1 });
|
|
202
|
+
ctx.emitServerEvent(PowerUpSpawned, { x: 100, y: 200, type: 3 });
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Server events have `playerSlot = 255` (SERVER_SLOT) in the RPC metadata.
|
|
206
|
+
|
|
207
|
+
## State Transfer (Late Join)
|
|
208
|
+
|
|
209
|
+
When a player joins a room that's already running:
|
|
210
|
+
|
|
211
|
+
1. Server sends `StateRequest` to all connected clients
|
|
212
|
+
2. Clients export `ArrayBuffer.slice(0)` snapshot + hash + tick
|
|
213
|
+
3. `StateTransfer` picks majority hash (quorum) — protects against corrupted clients
|
|
214
|
+
4. Server sends chosen `StateResponse` to joining player
|
|
215
|
+
5. Server sends **only** events with `tick > stateResult.tick` (events baked into state are not re-sent)
|
|
216
|
+
6. Client applies state via `ECSSimulation.applyExternalState()` — replaces ArrayBuffer, resets clock + snapshots
|
|
217
|
+
|
|
218
|
+
### What You Need to Do
|
|
219
|
+
|
|
220
|
+
State transfer works automatically with the framework. However:
|
|
221
|
+
- Ensure all game state is in the ArrayBuffer (components, singletons, player resources)
|
|
222
|
+
- Don't keep simulation state in JavaScript variables or closures
|
|
223
|
+
- Physics: `ColliderEntityMap` must be rebuilt after state transfer (handled by physics runner)
|
|
224
|
+
|
|
225
|
+
## Reconnect
|
|
226
|
+
|
|
227
|
+
When a player disconnects:
|
|
228
|
+
|
|
229
|
+
1. Server tracks `PlayerConnection` as `Disconnected` with configurable timeout (`reconnectTimeoutMs`)
|
|
230
|
+
2. If player reconnects before timeout: state transfer restores their simulation
|
|
231
|
+
3. If timeout expires: `onPlayerLeave` is called with `TIMEOUT` reason
|
|
232
|
+
4. `shouldAcceptReconnect` hook can reject reconnection
|
|
233
|
+
|
|
234
|
+
### Testing Reconnect
|
|
235
|
+
|
|
236
|
+
1. Open F3 debug panel in game
|
|
237
|
+
2. Click "Disconnect" button
|
|
238
|
+
3. Wait a few seconds
|
|
239
|
+
4. Click "Reconnect" button
|
|
240
|
+
5. Verify: player resumes with correct state, no permanent desync
|
|
241
|
+
|
|
242
|
+
## Testing Multiplayer
|
|
243
|
+
|
|
244
|
+
### Quick Test: Two Browser Tabs
|
|
245
|
+
|
|
246
|
+
1. Start server: `pnpm dev:backend`
|
|
247
|
+
2. Start client: `pnpm dev:frontend`
|
|
248
|
+
3. Open `http://localhost:4200` in two tabs
|
|
249
|
+
4. Both click "Multiplayer" → they should see each other
|
|
250
|
+
|
|
251
|
+
### Dev Player Tool
|
|
252
|
+
|
|
253
|
+
The dev-player opens N game instances in an iframe grid with auto-matchmaking:
|
|
254
|
+
|
|
255
|
+
1. Start everything: `pnpm dev`
|
|
256
|
+
2. Open `http://localhost:4210`
|
|
257
|
+
3. Set instance count (2-8)
|
|
258
|
+
4. Click "Start" — instances auto-matchmake with unique scope
|
|
259
|
+
|
|
260
|
+
Features:
|
|
261
|
+
- Per-instance stats (FPS, tick, rollbacks)
|
|
262
|
+
- Hash timeline for divergence detection
|
|
263
|
+
- Per-player latency sliders
|
|
264
|
+
- Auto-match on load
|
|
265
|
+
|
|
266
|
+
### Debug Panel (F3)
|
|
267
|
+
|
|
268
|
+
Press F3 in-game to toggle the debug panel:
|
|
269
|
+
- **RTT** — round-trip time to server
|
|
270
|
+
- **Jitter** — RTT variance
|
|
271
|
+
- **Input Delay** — ticks ahead inputs are scheduled
|
|
272
|
+
- **Nudger** — clock offset correction
|
|
273
|
+
- **Tick** — current simulation tick
|
|
274
|
+
- **Rollbacks** — count of rollbacks
|
|
275
|
+
- **FPS** — render frame rate
|
|
276
|
+
- **Hash Table** — state hash comparison (red = divergence)
|
|
277
|
+
- **Disconnect/Reconnect** — buttons for testing
|