@lagless/create 0.0.38 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +26 -0
  2. package/dist/index.js +96 -16
  3. package/dist/index.js.map +1 -1
  4. package/package.json +5 -4
  5. package/templates/pixi-react/AGENTS.md +57 -27
  6. package/templates/pixi-react/CLAUDE.md +225 -49
  7. package/templates/pixi-react/README.md +16 -6
  8. package/templates/pixi-react/__packageName__-backend/package.json +1 -0
  9. package/templates/pixi-react/__packageName__-backend/src/main.ts +2 -0
  10. package/templates/pixi-react/__packageName__-frontend/package.json +8 -0
  11. package/templates/pixi-react/__packageName__-frontend/src/app/game-view/grid-background.tsx +4 -0
  12. package/templates/pixi-react/__packageName__-frontend/src/app/game-view/player-view.tsx +68 -0
  13. package/templates/pixi-react/__packageName__-frontend/src/app/game-view/runner-provider.tsx +57 -0
  14. package/templates/pixi-react/__packageName__-frontend/src/app/hooks/use-start-multiplayer-match.ts +5 -5
  15. package/templates/pixi-react/__packageName__-frontend/src/app/screens/title.screen.tsx +18 -1
  16. package/templates/pixi-react/__packageName__-simulation/package.json +7 -0
  17. package/templates/pixi-react/__packageName__-simulation/src/lib/arena.ts +12 -0
  18. package/templates/pixi-react/__packageName__-simulation/src/lib/schema/ecs.yaml +90 -6
  19. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/apply-move-input.system.ts +73 -0
  20. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/boundary.system.ts +2 -0
  21. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/damping.system.ts +2 -0
  22. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/index.ts +8 -0
  23. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/integrate.system.ts +2 -0
  24. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/physics-step.system.ts +65 -0
  25. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-connection.system.ts +158 -0
  26. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-leave.system.ts +70 -0
  27. package/templates/pixi-react/__packageName__-simulation/src/lib/systems/save-prev-transform.system.ts +46 -0
  28. package/templates/pixi-react/docs/01-schema-and-codegen.md +244 -0
  29. package/templates/pixi-react/docs/02-ecs-systems.md +293 -0
  30. package/templates/pixi-react/docs/03-determinism.md +204 -0
  31. package/templates/pixi-react/docs/04-input-system.md +255 -0
  32. package/templates/pixi-react/docs/05-signals.md +175 -0
  33. package/templates/pixi-react/docs/06-rendering.md +256 -0
  34. package/templates/pixi-react/docs/07-multiplayer.md +277 -0
  35. package/templates/pixi-react/docs/08-physics2d.md +266 -0
  36. package/templates/pixi-react/docs/08-physics3d.md +312 -0
  37. package/templates/pixi-react/docs/09-recipes.md +362 -0
  38. package/templates/pixi-react/docs/10-common-mistakes.md +224 -0
  39. package/templates/pixi-react/docs/api-quick-reference.md +254 -0
  40. package/templates/pixi-react/package.json +6 -0
  41. /package/templates/pixi-react/__packageName__-backend/{tsconfig.json → tsconfig.json.ejs} +0 -0
  42. /package/templates/pixi-react/__packageName__-frontend/{tsconfig.json → tsconfig.json.ejs} +0 -0
  43. /package/templates/pixi-react/__packageName__-frontend/{vite.config.ts → vite.config.ts.ejs} +0 -0
  44. /package/templates/pixi-react/__packageName__-simulation/{.swcrc → .swcrc.ejs} +0 -0
  45. /package/templates/pixi-react/__packageName__-simulation/{tsconfig.json → tsconfig.json.ejs} +0 -0
  46. /package/templates/pixi-react/{tsconfig.base.json → tsconfig.base.json.ejs} +0 -0
@@ -2,78 +2,254 @@
2
2
 
3
3
  ## What This Is
4
4
 
5
- <%= projectName %> is a multiplayer browser game built with **Lagless**, a deterministic ECS framework. TypeScript, simulate/rollback netcode, all simulation state in a single ArrayBuffer.
5
+ <%= projectName %> is a multiplayer browser game built with **Lagless** a deterministic ECS framework with simulate/rollback netcode. All simulation state lives in a single ArrayBuffer. Server relays inputs, clients run deterministic simulation.
6
6
 
7
7
  ## Commands
8
8
 
9
9
  ```bash
10
- # Install
11
- pnpm install
12
-
13
- # Start game server (Bun)
14
- pnpm dev:backend
10
+ pnpm install # Install dependencies
11
+ pnpm codegen # Regenerate ECS code from schema (MUST run after schema changes)
12
+ pnpm dev # Start backend + frontend + dev-player (all at once)
13
+ pnpm dev:backend # Game server only (Bun, auto-reload)
14
+ pnpm dev:frontend # Frontend only (Vite HMR)
15
+ pnpm dev:player # Dev-player multiplayer testing tool (port 4210)
16
+ ```
15
17
 
16
- # Start frontend dev server (Vite)
17
- pnpm dev:frontend
18
+ ## Project Structure
18
19
 
19
- # Regenerate ECS code after schema changes
20
- pnpm codegen
20
+ ```
21
+ <%= packageName %>-simulation/ # Shared deterministic game logic
22
+ src/lib/schema/ecs.yaml # ECS schema definition → run pnpm codegen
23
+ src/lib/code-gen/ # GENERATED — never edit manually
24
+ src/lib/systems/ # Game systems (execution order = array order)
25
+ src/lib/signals/ # Rollback-aware events
26
+ src/lib/arena.ts # Arena config (systems, signals, ECS config)
27
+ <%= packageName %>-frontend/ # React + Pixi.js game client
28
+ src/app/game-view/ # Pixi rendering components
29
+ src/app/screens/ # Game screens (title, game)
30
+ src/app/hooks/ # React hooks (match start, inputs)
31
+ <%= packageName %>-backend/ # Bun game server (relay model, NO simulation)
32
+ src/main.ts # Server entry point
33
+ src/game-hooks.ts # Room lifecycle hooks
21
34
  ```
22
35
 
23
- ## Architecture
36
+ ## Quick Recipe: Adding a Feature
37
+
38
+ 1. **Schema** — Add components/inputs/filters to `<%= packageName %>-simulation/src/lib/schema/ecs.yaml`
39
+ 2. **Codegen** — Run `pnpm codegen`
40
+ 3. **System** — Create `my-feature.system.ts` with `@ECSSystem()` decorator
41
+ 4. **Register** — Add system to `systems` array in `systems/index.ts` (order matters!)
42
+ 5. **Render** — Add Pixi.js view component using `filterView()` or `<FilterViews>`
43
+ 6. **Input** — Wire UI events via `drainInputs` in runner-provider
44
+
45
+ ## ECS System Pattern
46
+
47
+ ```typescript
48
+ import { ECSSystem, IECSSystem } from '@lagless/core';
49
+
50
+ @ECSSystem()
51
+ export class MySystem implements IECSSystem {
52
+ constructor(
53
+ private readonly _transform: Transform2d, // component (unsafe typed arrays)
54
+ private readonly _filter: PlayerFilter, // filter (iterable entity list)
55
+ private readonly _entities: EntitiesManager, // entity CRUD
56
+ private readonly _prng: PRNG, // deterministic random
57
+ private readonly _config: ECSConfig, // simulation config
58
+ ) {}
59
+
60
+ update(tick: number): void {
61
+ for (const entity of this._filter) {
62
+ this._transform.unsafe.positionX[entity] += 1;
63
+ }
64
+ }
65
+ }
66
+ ```
24
67
 
25
- ### Three Packages
68
+ ## Input Handling Pattern
69
+
70
+ ```typescript
71
+ // In runner-provider.tsx — drainInputs callback:
72
+ drainInputs={(tick, addRPC) => {
73
+ const dir = getDirection(); // from keyboard/joystick
74
+ addRPC(MoveInput, { directionX: dir.x, directionY: dir.y });
75
+ }}
76
+
77
+ // In system — reading inputs:
78
+ const rpcs = this._input.collectTickRPCs(tick, MoveInput);
79
+ for (const rpc of rpcs) {
80
+ const slot = rpc.meta.playerSlot;
81
+ const dirX = finite(rpc.data.directionX); // ALWAYS sanitize!
82
+ const dirY = finite(rpc.data.directionY);
83
+ // use dirX, dirY...
84
+ }
85
+
86
+ // Sanitization helper:
87
+ const finite = (v: number): number => Number.isFinite(v) ? v : 0;
88
+ ```
26
89
 
27
- - **<%= packageName %>-simulation** — Shared deterministic game logic (ECS systems, components, signals)
28
- - **<%= packageName %>-frontend** — React + Pixi.js game client
29
- - **<%= packageName %>-backend** — Bun game server (relay model, no simulation)
90
+ ## Rendering Pattern
91
+
92
+ ```typescript
93
+ // FilterViews — auto-manages lifecycle for entities matching a filter
94
+ <FilterViews filter={runner.Core.PlayerFilter} View={PlayerView} />
95
+
96
+ // filterView — define a view component for an entity
97
+ const PlayerView = filterView(({ entity }, ref) => {
98
+ const transform = runner.Core.Transform2d;
99
+ // onCreate: set up sprites
100
+ // Return Pixi container
101
+ return <pixiContainer ref={ref} />;
102
+ }, {
103
+ onUpdate: ({ entity }, container, runner) => {
104
+ container.position.set(
105
+ transform.unsafe.positionX[entity],
106
+ transform.unsafe.positionY[entity]
107
+ );
108
+ },
109
+ });
110
+ ```
30
111
 
31
- ### ECS Memory Model
112
+ ## Signal Pattern (Rollback-Aware Events)
32
113
 
33
- All state lives in one contiguous ArrayBuffer with Structure-of-Arrays layout. Snapshot = `ArrayBuffer.slice(0)`. Rollback = `Uint8Array.set()` from snapshot.
114
+ ```typescript
115
+ // Define signal in signals/index.ts:
116
+ @ECSSignal()
117
+ export class ScoreSignal extends Signal<{ slot: number; points: number }> {}
34
118
 
35
- ### Simulation Loop
119
+ // Emit in system:
120
+ this._scoreSignal.emit(tick, { slot: 0, points: 100 });
36
121
 
37
- ```
38
- 1. clock.update(dt)
39
- 2. targetTick = floor(accTime / frameLength)
40
- 3. checkAndRollback() if remote inputs arrived
41
- 4. simulationTicks: for each tick → run systems in order → process signals
42
- 5. inputProvider.update() — drain inputs, send to server
43
- 6. interpolationFactor = leftover / frameLength
122
+ // Subscribe in view (three streams):
123
+ signal.Predicted.subscribe(e => playSound()); // instant feedback
124
+ signal.Verified.subscribe(e => updateScore()); // survived all rollbacks
125
+ signal.Cancelled.subscribe(e => undoSound()); // was rolled back
44
126
  ```
45
127
 
46
- ### Input System
128
+ ## DETERMINISM RULES (CRITICAL)
47
129
 
48
- RPCs are deterministically ordered by `(playerSlot, ordinal, seq)`. Local inputs are scheduled `currentTick + inputDelay` ticks ahead. Server relays inputs to all clients.
130
+ **Breaking these causes permanent desync between clients game becomes unplayable.**
49
131
 
50
- ### Key Conventions
132
+ **ALWAYS use:**
133
+ - `MathOps.sin/cos/atan2/sqrt/clamp` — WASM-backed, deterministic across platforms
134
+ - `PRNG.getFloat()/getRandomInt()` — deterministic random (state in ArrayBuffer)
135
+ - Set `prevPositionX/Y = positionX/Y` when spawning entities (avoids interpolation jump)
51
136
 
52
- - **Determinism is paramount**: Same inputs + same seed = identical state on every client
53
- - All deterministic math must use `MathOps` (WASM-backed), never `Math.*` trig functions
54
- - `MathOps.init()` must be called before simulation starts
55
- - When spawning entities: always set `prevPositionX/Y = positionX/Y` to avoid interpolation jumps
56
- - `@abraham/reflection` must be imported before any decorated class
57
- - Generated code in `code-gen/` directoriesnever edit manually, regenerate from YAML
58
- - Systems array order = execution order (deterministic)
137
+ **NEVER use in simulation code:**
138
+ - `Math.sin/cos/tan/atan2/sqrt/pow/log` — platform-dependent floating point
139
+ - `Math.random()` non-deterministic
140
+ - `Date.now()` or `performance.now()` non-deterministic
141
+ - `Array.sort()` without explicit comparator unstable sort order
142
+ - `for...in` on objects non-deterministic key order
143
+ - `Map/Set` iteration — insertion-order dependent on network timing
59
144
 
60
- ### Schema & Codegen
145
+ **SAFE Math functions (platform-identical):**
146
+ `Math.abs`, `Math.min`, `Math.max`, `Math.floor`, `Math.ceil`, `Math.round`, `Math.trunc`, `Math.sign`, `Math.fround`
61
147
 
62
- ECS schema is defined in `<%= packageName %>-simulation/src/lib/schema/ecs.yaml`. Run `pnpm codegen` after changes.
148
+ ## Input Validation Rules
63
149
 
64
- Supported field types: `uint8`, `uint16`, `uint32`, `int8`, `int16`, `int32`, `float32`, `float64`, `uint8[N]` (fixed arrays).
150
+ All RPC data from players is potentially malicious. **In every system reading RPCs:**
65
151
 
66
- ### Adding Components/Systems
152
+ 1. `Number.isFinite(value)` FIRST — rejects NaN and Infinity
153
+ 2. `MathOps.clamp(value, min, max)` SECOND — bounds to valid range
154
+ 3. **Never** `MathOps.clamp(NaN, min, max)` — returns NaN, propagates everywhere
67
155
 
68
- 1. Add to `ecs.yaml`, run `pnpm codegen`
69
- 2. Create system file in `systems/` with `@ECSSystem()` decorator
70
- 3. Add to systems array in `systems/index.ts` (order matters)
71
- 4. Systems get dependencies via constructor injection (DI container)
156
+ ```typescript
157
+ const finite = (v: number): number => Number.isFinite(v) ? v : 0;
158
+ let dirX = MathOps.clamp(finite(rpc.data.directionX), -1, 1);
159
+ ```
160
+
161
+ ## Schema Quick Reference
162
+
163
+ ```yaml
164
+ components:
165
+ MyComponent:
166
+ field: float32 # Types: uint8, uint16, uint32, int8, int16, int32, float32, float64
167
+ arrayField: uint8[16] # Fixed-size array
168
+ TagComponent: {} # Empty = tag (bitmask only, zero memory per entity)
169
+
170
+ singletons:
171
+ GameState:
172
+ phase: uint8
72
173
 
73
- ### Multiplayer
174
+ playerResources:
175
+ PlayerResource:
176
+ score: uint32
177
+
178
+ inputs:
179
+ MyInput:
180
+ value: float32
181
+
182
+ filters:
183
+ MyFilter:
184
+ include: [Transform2d, MyComponent]
185
+ exclude: [Frozen] # Optional
186
+ ```
74
187
 
75
- - Server never runs simulation — relay model
76
- - `RelayInputProvider` handles prediction + rollback
77
- - `RelayConnection` manages WebSocket to relay server
78
- - State transfer for late-join via `StateRequest`/`StateResponse`
79
- - `RoomHooks` in backend define game lifecycle (join, leave, reconnect)
188
+ ## Key APIs Cheat Sheet
189
+
190
+ | Class | Purpose | Access |
191
+ |-------|---------|--------|
192
+ | `EntitiesManager` | Create/remove entities, add/remove components | DI constructor |
193
+ | `Component.unsafe.field[entity]` | Read/write component data (hot path) | DI constructor |
194
+ | `Component.getCursor(entity)` | Convenient single-entity access | DI constructor |
195
+ | `Filter` | Iterate entities matching component mask | DI constructor, iterable |
196
+ | `PRNG` | Deterministic random: `getFloat()`, `getRandomInt(from, to)` | DI constructor |
197
+ | `ECSConfig` | `maxEntities`, `maxPlayers`, `frameLength`, `tickRate` | DI constructor |
198
+ | `AbstractInputProvider` | `collectTickRPCs(tick, InputClass)` | DI constructor |
199
+ | `Signal` | `emit(tick, data)` / `.Predicted/.Verified/.Cancelled.subscribe()` | DI constructor |
200
+ | `Singleton` | Global typed fields, `singleton.field` | DI constructor |
201
+ | `PlayerResource` | Per-player data, `playerResource.field[slot]` | DI constructor |
202
+ <% if (simulationType === 'physics2d') { -%>
203
+ | `PhysicsWorldManager2d` | Create/remove bodies and colliders | DI constructor |
204
+ | `CollisionEvents2d` | Drain collision start/end events | DI constructor |
205
+ <% } else if (simulationType === 'physics3d') { -%>
206
+ | `PhysicsWorldManager3d` | Create/remove bodies and colliders | DI constructor |
207
+ | `CollisionEvents3d` | Drain collision start/end events | DI constructor |
208
+ <% } -%>
209
+
210
+ ## System Execution Order
211
+
212
+ Systems run in array order every tick. Canonical ordering:
213
+
214
+ 1. `SavePrevTransformSystem` — store previous positions for interpolation
215
+ 2. `PlayerConnectionSystem` — handle join/leave server events
216
+ 3. `ApplyMoveInputSystem` — read RPCs, apply to entities
217
+ <% if (simulationType === 'physics2d') { -%>
218
+ 4. `PhysicsStepSystem` — step Rapier 2D, sync transforms
219
+ <% } else if (simulationType === 'physics3d') { -%>
220
+ 4. `PhysicsStepSystem` — step Rapier 3D, sync transforms
221
+ <% } else { -%>
222
+ 4. Game logic systems (integrate, damping, boundary, etc.)
223
+ <% } -%>
224
+ 5. `PlayerLeaveSystem` — cleanup disconnected player entities
225
+ 6. `HashVerificationSystem` — detect simulation divergence (always last)
226
+
227
+ ## Detailed Documentation
228
+
229
+ | File | Contents |
230
+ |------|----------|
231
+ | [docs/01-schema-and-codegen.md](docs/01-schema-and-codegen.md) | YAML schema format, field types, codegen workflow, generated files |
232
+ | [docs/02-ecs-systems.md](docs/02-ecs-systems.md) | Writing systems, DI tokens, entity lifecycle, prefabs, PRNG |
233
+ | [docs/03-determinism.md](docs/03-determinism.md) | **CRITICAL** — determinism rules, pitfalls, debugging divergence |
234
+ | [docs/04-input-system.md](docs/04-input-system.md) | RPCs, drainInputs, collectTickRPCs, sanitization, server events |
235
+ | [docs/05-signals.md](docs/05-signals.md) | Predicted/Verified/Cancelled events, rollback behavior |
236
+ | [docs/06-rendering.md](docs/06-rendering.md) | FilterViews, VisualSmoother, Pixi.js patterns |
237
+ | [docs/07-multiplayer.md](docs/07-multiplayer.md) | Relay architecture, RoomHooks, state transfer, reconnect |
238
+ <% if (simulationType === 'physics2d') { -%>
239
+ | [docs/08-physics2d.md](docs/08-physics2d.md) | Rapier 2D integration, bodies, colliders, collision events |
240
+ <% } else if (simulationType === 'physics3d') { -%>
241
+ | [docs/08-physics3d.md](docs/08-physics3d.md) | Rapier 3D integration, character controller, animation |
242
+ <% } -%>
243
+ | [docs/09-recipes.md](docs/09-recipes.md) | Step-by-step cookbook for common game features |
244
+ | [docs/10-common-mistakes.md](docs/10-common-mistakes.md) | "Never do X" reference + error solutions |
245
+ | [docs/api-quick-reference.md](docs/api-quick-reference.md) | One-page API cheat sheet |
246
+
247
+ ## Source Reference
248
+
249
+ `docs/sources/lagless/` contains a full clone of the Lagless framework repository for deep reference.
250
+ - `libs/` — all framework library source code
251
+ - `circle-sumo/` — complete example game (2D, gameplay focused)
252
+ - `sync-test/` — determinism verification test bench
253
+ - `roblox-like/` — 3D example with character controller + BabylonJS
254
+
255
+ **Do NOT import from `docs/sources/`** — always use `@lagless/*` npm packages.
@@ -12,18 +12,28 @@ A multiplayer game built with [Lagless](https://github.com/GbGr/lagless) — a d
12
12
 
13
13
  ```bash
14
14
  pnpm install
15
-
16
- # Terminal 1 Start the game server
17
- pnpm dev:backend
18
-
19
- # Terminal 2 — Start the frontend dev server
20
- pnpm dev:frontend
15
+ pnpm codegen # Generate ECS code from schema
16
+ pnpm dev # Start backend + frontend + dev-player
21
17
  ```
22
18
 
23
19
  Open http://localhost:<%= frontendPort %> in your browser. Click "Play Local" for single-player or "Play Online" for multiplayer.
24
20
 
25
21
  Press **F3** to toggle the debug panel (shows network stats, tick info, hash verification).
26
22
 
23
+ You can also run services individually:
24
+
25
+ ```bash
26
+ pnpm dev:backend # Game server (Bun, watches for changes)
27
+ pnpm dev:frontend # Frontend (Vite HMR)
28
+ pnpm dev:player # Dev-player (multiplayer testing tool, port 4210)
29
+ ```
30
+
31
+ ## Dev Player
32
+
33
+ The **dev-player** (http://localhost:4210) is a multiplayer testing tool that opens multiple game instances in a grid. It auto-matchmakes them, displays per-instance network stats, and provides a hash timeline for detecting simulation divergence between clients.
34
+
35
+ Use it to test multiplayer without opening multiple browser tabs manually.
36
+
27
37
  ## Project Structure
28
38
 
29
39
  ```
@@ -10,6 +10,7 @@
10
10
  "dependencies": {
11
11
  "<%= packageName %>-simulation": "workspace:*",
12
12
  "@lagless/relay-game-server": "^<%= laglessVersion %>",
13
+ "@lagless/dev-tools": "^<%= laglessVersion %>",
13
14
  "@lagless/misc": "^<%= laglessVersion %>",
14
15
  "reflect-metadata": "^0.2.0"
15
16
  },
@@ -1,5 +1,6 @@
1
1
  import 'reflect-metadata';
2
2
  import { RelayGameServer } from '@lagless/relay-game-server';
3
+ import { setupDevTools } from '@lagless/dev-tools';
3
4
  import { <%= projectName %>InputRegistry } from '<%= packageName %>-simulation';
4
5
  import { gameHooks } from './game-hooks.js';
5
6
 
@@ -25,4 +26,5 @@ const server = new RelayGameServer({
25
26
  },
26
27
  });
27
28
 
29
+ setupDevTools(server);
28
30
  server.start();
@@ -16,6 +16,14 @@
16
16
  "@lagless/net-wire": "^<%= laglessVersion %>",
17
17
  "@lagless/react": "^<%= laglessVersion %>",
18
18
  "@lagless/pixi-react": "^<%= laglessVersion %>",
19
+ <% if (simulationType === 'physics2d') { -%>
20
+ "@lagless/physics2d": "^<%= laglessVersion %>",
21
+ "@dimforge/rapier2d-compat": "^0.14.0",
22
+ <% } else if (simulationType === 'physics3d') { -%>
23
+ "@lagless/physics3d": "^<%= laglessVersion %>",
24
+ "@lagless/physics-shared": "^<%= laglessVersion %>",
25
+ "@dimforge/rapier3d-compat": "^0.14.0",
26
+ <% } -%>
19
27
  "@abraham/reflection": "^0.12.0",
20
28
  "pixi.js": "^8.12.0",
21
29
  "@pixi/react": "^8.0.5",
@@ -10,7 +10,11 @@ export const GridBackground: FC = () => {
10
10
  if (!g) return;
11
11
 
12
12
  const w = <%= projectName %>Arena.width;
13
+ <% if (simulationType === 'physics3d') { -%>
14
+ const h = <%= projectName %>Arena.depth;
15
+ <% } else { -%>
13
16
  const h = <%= projectName %>Arena.height;
17
+ <% } -%>
14
18
  const step = 100;
15
19
 
16
20
  g.clear();
@@ -1,3 +1,70 @@
1
+ <% if (simulationType === 'physics3d') { -%>
2
+ import { useImperativeHandle, useRef } from 'react';
3
+ import { Container, Graphics } from 'pixi.js';
4
+ import { filterView, FilterViewRef } from '@lagless/pixi-react';
5
+ import { useRunner } from './runner-provider';
6
+ import { PlayerBody, <%= projectName %>Arena, Transform3d } from '<%= packageName %>-simulation';
7
+ import { VisualSmoother2d } from '@lagless/misc';
8
+
9
+ const PLAYER_COLORS = [0xff4444, 0x4488ff, 0x44ff44, 0xffff44];
10
+
11
+ // Top-down rendering: 3D positionX → screen X, positionZ → screen Y
12
+ const SCALE = 20; // pixels per world unit
13
+ const OFFSET_X = 400;
14
+ const OFFSET_Y = 300;
15
+
16
+ export const PlayerView = filterView(({ entity }, ref) => {
17
+ const runner = useRunner();
18
+ const containerRef = useRef<Container>(null);
19
+ const graphicsRef = useRef<Graphics>(null);
20
+ const smootherRef = useRef<VisualSmoother2d>(new VisualSmoother2d());
21
+
22
+ const transform3d = runner.DIContainer.resolve(Transform3d);
23
+ const playerBody = runner.DIContainer.resolve(PlayerBody);
24
+
25
+ useImperativeHandle(ref, (): FilterViewRef => ({
26
+ onCreate() {
27
+ const g = graphicsRef.current;
28
+ if (!g) return;
29
+ const slot = playerBody.unsafe.playerSlot[entity];
30
+ const radius = playerBody.unsafe.radius[entity] * SCALE;
31
+ const color = PLAYER_COLORS[slot % PLAYER_COLORS.length];
32
+ g.clear();
33
+ g.circle(0, 0, radius);
34
+ g.fill(color);
35
+ g.circle(0, 0, radius);
36
+ g.stroke({ color: 0xffffff, width: 2, alpha: 0.3 });
37
+ },
38
+ onUpdate() {
39
+ const container = containerRef.current;
40
+ if (!container) return;
41
+ const smoother = smootherRef.current;
42
+ const factor = runner.Simulation.interpolationFactor;
43
+
44
+ // Map 3D (X, Z) to 2D screen space (top-down view)
45
+ smoother.update(
46
+ transform3d.unsafe.prevPositionX[entity] * SCALE + OFFSET_X,
47
+ transform3d.unsafe.prevPositionZ[entity] * SCALE + OFFSET_Y,
48
+ transform3d.unsafe.positionX[entity] * SCALE + OFFSET_X,
49
+ transform3d.unsafe.positionZ[entity] * SCALE + OFFSET_Y,
50
+ factor,
51
+ );
52
+
53
+ container.x = smoother.x;
54
+ container.y = smoother.y;
55
+ },
56
+ onDestroy() {
57
+ // cleanup
58
+ },
59
+ }));
60
+
61
+ return (
62
+ <pixiContainer ref={containerRef}>
63
+ <pixiGraphics ref={graphicsRef} />
64
+ </pixiContainer>
65
+ );
66
+ });
67
+ <% } else { -%>
1
68
  import { useImperativeHandle, useRef } from 'react';
2
69
  import { Container, Graphics } from 'pixi.js';
3
70
  import { filterView, FilterViewRef } from '@lagless/pixi-react';
@@ -57,3 +124,4 @@ export const PlayerView = filterView(({ entity }, ref) => {
57
124
  </pixiContainer>
58
125
  );
59
126
  });
127
+ <% } -%>
@@ -7,6 +7,11 @@ import {
7
7
  PlayerJoined,
8
8
  ReportHash,
9
9
  <%= projectName %>Arena,
10
+ <% if (simulationType !== 'raw') { -%>
11
+ PhysicsRefs,
12
+ PhysicsRefsFilter,
13
+ PlayerFilter,
14
+ <% } -%>
10
15
  } from '<%= packageName %>-simulation';
11
16
  import { createContext, FC, ReactNode, useContext, useEffect, useState } from 'react';
12
17
  import { useTick } from '@pixi/react';
@@ -16,6 +21,12 @@ import { ECSConfig, LocalInputProvider, RPC, createHashReporter } from '@lagless
16
21
  import { RelayInputProvider, RelayConnection } from '@lagless/relay-client';
17
22
  import { getMatchInfo } from '../hooks/use-start-multiplayer-match';
18
23
  import { UUID } from '@lagless/misc';
24
+ import { useDevBridge } from '@lagless/react';
25
+ <% if (simulationType === 'physics2d') { -%>
26
+ import { PhysicsWorldManager2d, type RapierModule2d } from '@lagless/physics2d';
27
+ <% } else if (simulationType === 'physics3d') { -%>
28
+ import { PhysicsWorldManager3d, type RapierModule3d } from '@lagless/physics3d';
29
+ <% } -%>
19
30
 
20
31
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
21
32
  const RunnerContext = createContext<<%= projectName %>Runner>(null!);
@@ -66,6 +77,23 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
66
77
  return;
67
78
  }
68
79
 
80
+ <% if (simulationType === 'physics2d') { -%>
81
+ // Load Rapier 2D WASM
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ const RAPIER = (await import('@dimforge/rapier2d-compat')).default as any;
84
+ await RAPIER.init();
85
+ const rapier = RAPIER as unknown as RapierModule2d;
86
+ if (disposed) { inputProvider.dispose(); return; }
87
+
88
+ <% } else if (simulationType === 'physics3d') { -%>
89
+ // Load Rapier 3D WASM
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ const RAPIER = (await import('@dimforge/rapier3d-compat')).default as any;
92
+ await RAPIER.init();
93
+ const rapier = RAPIER as unknown as RapierModule3d;
94
+ if (disposed) { inputProvider.dispose(); return; }
95
+
96
+ <% } -%>
69
97
  if (inputProvider instanceof RelayInputProvider) {
70
98
  const matchInfo = getMatchInfo(inputProvider);
71
99
  if (matchInfo) {
@@ -94,14 +122,40 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
94
122
  if (disposed) { inputProvider.dispose(); return; }
95
123
 
96
124
  const seededConfig = new ECSConfig({ ...inputProvider.ecsConfig, seed: serverHello.seed });
125
+ <% if (simulationType === 'raw') { -%>
97
126
  _runner = new <%= projectName %>Runner(seededConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals);
127
+ <% } else { -%>
128
+ _runner = new <%= projectName %>Runner(seededConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals, rapier);
129
+ <% } -%>
98
130
  } else {
131
+ <% if (simulationType === 'raw') { -%>
99
132
  _runner = new <%= projectName %>Runner(inputProvider.ecsConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals);
133
+ <% } else { -%>
134
+ _runner = new <%= projectName %>Runner(inputProvider.ecsConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals, rapier);
135
+ <% } -%>
100
136
  }
101
137
  } else {
138
+ <% if (simulationType === 'raw') { -%>
102
139
  _runner = new <%= projectName %>Runner(inputProvider.ecsConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals);
140
+ <% } else { -%>
141
+ _runner = new <%= projectName %>Runner(inputProvider.ecsConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals, rapier);
142
+ <% } -%>
103
143
  }
104
144
 
145
+ <% if (simulationType !== 'raw') { -%>
146
+ // Hook state transfer to rebuild ColliderEntityMap after receiving external state
147
+ const worldManager = _runner.PhysicsWorldManager;
148
+ _runner.Simulation.addStateTransferHandler(() => {
149
+ worldManager.colliderEntityMap.clear();
150
+ const physicsFilter = _runner.DIContainer.resolve(PhysicsRefsFilter);
151
+ const refs = _runner.DIContainer.resolve(PhysicsRefs);
152
+ const refsUnsafe = refs.unsafe;
153
+ for (const e of physicsFilter) {
154
+ worldManager.registerCollider(refsUnsafe.colliderHandle[e], e);
155
+ }
156
+ });
157
+
158
+ <% } -%>
105
159
  // Set up keyboard input drainer with hash reporting
106
160
  const reportHash = createHashReporter(_runner, {
107
161
  reportInterval: <%= projectName %>Arena.hashReportInterval,
@@ -127,6 +181,7 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
127
181
  reportHash(addRPC);
128
182
  });
129
183
 
184
+ _runner.Simulation.enableHashTracking(<%= projectName %>Arena.hashReportInterval);
130
185
  _runner.start();
131
186
 
132
187
  if (inputProvider instanceof RelayInputProvider) {
@@ -167,6 +222,8 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
167
222
  };
168
223
  }, [v, navigate]);
169
224
 
225
+ useDevBridge(runner, { hashTrackingInterval: <%= projectName %>Arena.hashReportInterval });
226
+
170
227
  return !runner ? null : <RunnerContext.Provider value={runner}>{children}</RunnerContext.Provider>;
171
228
  };
172
229
 
@@ -5,7 +5,9 @@ import { useCallback, useRef, useState } from 'react';
5
5
  import { useNavigate } from 'react-router-dom';
6
6
  import { ProviderStore } from './use-start-match';
7
7
 
8
- const SERVER_URL = import.meta.env.VITE_RELAY_URL || 'ws://localhost:<%= serverPort %>';
8
+ const _params = new URLSearchParams(window.location.search);
9
+ const SERVER_URL = _params.get('serverUrl') || import.meta.env.VITE_RELAY_URL || 'ws://localhost:<%= serverPort %>';
10
+ const SCOPE = _params.get('scope') || '<%= packageName %>';
9
11
 
10
12
  export type MatchmakingState = 'idle' | 'queuing' | 'connecting' | 'error';
11
13
 
@@ -48,7 +50,7 @@ export const useStartMultiplayerMatch = () => {
48
50
  ws.send(
49
51
  JSON.stringify({
50
52
  type: 'join',
51
- scope: '<%= packageName %>',
53
+ scope: SCOPE,
52
54
  }),
53
55
  );
54
56
  };
@@ -85,9 +87,7 @@ export const useStartMultiplayerMatch = () => {
85
87
  };
86
88
 
87
89
  ws.onclose = () => {
88
- if (state === 'queuing') {
89
- setState('idle');
90
- }
90
+ setState((prev) => (prev === 'queuing' ? 'idle' : prev));
91
91
  };
92
92
  }, [state]);
93
93
 
@@ -1,6 +1,7 @@
1
- import { FC } from 'react';
1
+ import { FC, useEffect } from 'react';
2
2
  import { useStartMatch } from '../hooks/use-start-match';
3
3
  import { useStartMultiplayerMatch } from '../hooks/use-start-multiplayer-match';
4
+ import { DevBridge } from '@lagless/react';
4
5
 
5
6
  export const TitleScreen: FC = () => {
6
7
  const { isBusy, startMatch } = useStartMatch();
@@ -8,6 +9,22 @@ export const TitleScreen: FC = () => {
8
9
 
9
10
  const isMultiplayerBusy = multiplayer.state !== 'idle';
10
11
 
12
+ // Dev-bridge: auto-match on URL param or parent command
13
+ useEffect(() => {
14
+ const params = new URLSearchParams(window.location.search);
15
+ if (params.get('autoMatch') === 'true' && multiplayer.state === 'idle') {
16
+ multiplayer.startMatch();
17
+ }
18
+ const bridge = DevBridge.fromUrlParams();
19
+ if (!bridge) return;
20
+ bridge.sendMatchState(multiplayer.state === 'idle' ? 'idle' : multiplayer.state);
21
+ return bridge.onParentMessage((msg) => {
22
+ if (msg.type === 'dev-bridge:start-match' && multiplayer.state === 'idle') {
23
+ multiplayer.startMatch();
24
+ }
25
+ });
26
+ }, [multiplayer.state]);
27
+
11
28
  return (
12
29
  <div style={styles.screen}>
13
30
  <div style={styles.title}><%= projectName %></div>
@@ -18,6 +18,13 @@
18
18
  "@lagless/math": "^<%= laglessVersion %>",
19
19
  "@lagless/binary": "^<%= laglessVersion %>",
20
20
  "@lagless/misc": "^<%= laglessVersion %>",
21
+ <% if (simulationType === 'physics2d') { -%>
22
+ "@lagless/physics2d": "^<%= laglessVersion %>",
23
+ "@lagless/physics-shared": "^<%= laglessVersion %>",
24
+ <% } else if (simulationType === 'physics3d') { -%>
25
+ "@lagless/physics3d": "^<%= laglessVersion %>",
26
+ "@lagless/physics-shared": "^<%= laglessVersion %>",
27
+ <% } -%>
21
28
  "@swc/helpers": "~0.5.11"
22
29
  }
23
30
  }