create-fluxstack 1.14.0 → 1.16.0
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/LLMD/INDEX.md +4 -3
- package/LLMD/resources/live-binary-delta.md +507 -0
- package/LLMD/resources/live-components.md +208 -12
- package/LLMD/resources/live-rooms.md +731 -333
- package/app/client/.live-stubs/LiveAdminPanel.js +5 -0
- package/app/client/.live-stubs/LiveCounter.js +9 -0
- package/app/client/.live-stubs/LiveForm.js +11 -0
- package/app/client/.live-stubs/LiveLocalCounter.js +8 -0
- package/app/client/.live-stubs/LivePingPong.js +10 -0
- package/app/client/.live-stubs/LiveRoomChat.js +11 -0
- package/app/client/.live-stubs/LiveSharedCounter.js +10 -0
- package/app/client/.live-stubs/LiveUpload.js +15 -0
- package/app/client/src/App.tsx +19 -7
- package/app/client/src/components/AppLayout.tsx +18 -10
- package/app/client/src/live/PingPongDemo.tsx +199 -0
- package/app/client/src/live/RoomChatDemo.tsx +187 -22
- package/app/client/src/live/SharedCounterDemo.tsx +142 -0
- package/app/server/auth/DevAuthProvider.ts +2 -2
- package/app/server/auth/JWTAuthProvider.example.ts +2 -2
- package/app/server/index.ts +2 -2
- package/app/server/live/LiveAdminPanel.ts +1 -1
- package/app/server/live/LivePingPong.ts +61 -0
- package/app/server/live/LiveProtectedChat.ts +1 -1
- package/app/server/live/LiveRoomChat.ts +106 -38
- package/app/server/live/LiveSharedCounter.ts +73 -0
- package/app/server/live/rooms/ChatRoom.ts +68 -0
- package/app/server/live/rooms/CounterRoom.ts +51 -0
- package/app/server/live/rooms/DirectoryRoom.ts +42 -0
- package/app/server/live/rooms/PingRoom.ts +40 -0
- package/app/server/routes/room.routes.ts +1 -2
- package/core/build/live-components-generator.ts +11 -2
- package/core/build/vite-plugins.ts +28 -0
- package/core/client/hooks/useLiveUpload.ts +3 -4
- package/core/client/index.ts +25 -35
- package/core/framework/server.ts +1 -1
- package/core/server/index.ts +1 -2
- package/core/server/live/auto-generated-components.ts +5 -8
- package/core/server/live/index.ts +90 -21
- package/core/server/live/websocket-plugin.ts +54 -1079
- package/core/types/types.ts +76 -1025
- package/core/utils/version.ts +1 -1
- package/create-fluxstack.ts +1 -1
- package/package.json +100 -95
- package/plugins/crypto-auth/index.ts +1 -1
- package/plugins/crypto-auth/server/CryptoAuthLiveProvider.ts +2 -2
- package/tsconfig.json +4 -1
- package/vite.config.ts +40 -12
- package/app/client/src/live/ChatDemo.tsx +0 -107
- package/app/client/src/live/LiveDebuggerPanel.tsx +0 -779
- package/app/server/live/LiveChat.ts +0 -78
- package/core/client/LiveComponentsProvider.tsx +0 -531
- package/core/client/components/Live.tsx +0 -111
- package/core/client/components/LiveDebugger.tsx +0 -1324
- package/core/client/hooks/AdaptiveChunkSizer.ts +0 -215
- package/core/client/hooks/state-validator.ts +0 -130
- package/core/client/hooks/useChunkedUpload.ts +0 -359
- package/core/client/hooks/useLiveChunkedUpload.ts +0 -87
- package/core/client/hooks/useLiveComponent.ts +0 -853
- package/core/client/hooks/useLiveDebugger.ts +0 -392
- package/core/client/hooks/useRoom.ts +0 -409
- package/core/client/hooks/useRoomProxy.ts +0 -382
- package/core/server/live/ComponentRegistry.ts +0 -1128
- package/core/server/live/FileUploadManager.ts +0 -446
- package/core/server/live/LiveComponentPerformanceMonitor.ts +0 -931
- package/core/server/live/LiveDebugger.ts +0 -462
- package/core/server/live/LiveLogger.ts +0 -144
- package/core/server/live/LiveRoomManager.ts +0 -278
- package/core/server/live/RoomEventBus.ts +0 -234
- package/core/server/live/RoomStateManager.ts +0 -172
- package/core/server/live/SingleConnectionManager.ts +0 -0
- package/core/server/live/StateSignature.ts +0 -705
- package/core/server/live/WebSocketConnectionManager.ts +0 -710
- package/core/server/live/auth/LiveAuthContext.ts +0 -71
- package/core/server/live/auth/LiveAuthManager.ts +0 -304
- package/core/server/live/auth/index.ts +0 -19
- package/core/server/live/auth/types.ts +0 -179
package/LLMD/INDEX.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# FluxStack LLM Documentation
|
|
2
2
|
|
|
3
|
-
**Version:**
|
|
3
|
+
**Version:** 2.0.0 | **Framework:** Bun + Elysia + React + Eden Treaty
|
|
4
4
|
|
|
5
5
|
## Quick Navigation
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
**Creating Routes?** → [resources/routes-eden.md](resources/routes-eden.md)
|
|
10
10
|
**REST API Auth?** → [resources/rest-auth.md](resources/rest-auth.md)
|
|
11
11
|
**Live Components Auth?** → [resources/live-auth.md](resources/live-auth.md)
|
|
12
|
-
**Real-time Rooms?** → [resources/live-rooms.md](resources/live-rooms.md)
|
|
12
|
+
**Real-time Rooms?** → [resources/live-rooms.md](resources/live-rooms.md) (Typed LiveRoom + untyped, password rooms, directory)
|
|
13
13
|
**Debugging Logs?** → [resources/live-logging.md](resources/live-logging.md)
|
|
14
14
|
**Config Issues?** → [config/declarative-system.md](config/declarative-system.md)
|
|
15
15
|
**Plugin Development?** → [resources/plugins-external.md](resources/plugins-external.md)
|
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
- [Live Components](resources/live-components.md) - WebSocket components
|
|
35
35
|
- [REST Auth](resources/rest-auth.md) - Session & Token guards, middleware, rate limiting
|
|
36
36
|
- [Live Auth](resources/live-auth.md) - Authentication for Live Components
|
|
37
|
-
- [Live Rooms](resources/live-rooms.md) -
|
|
37
|
+
- [Live Rooms](resources/live-rooms.md) - Typed rooms (LiveRoom), password protection, room directory, untyped rooms
|
|
38
38
|
- [Live Logging](resources/live-logging.md) - Per-component logging control
|
|
39
39
|
- [Live Upload](resources/live-upload.md) - Chunked upload via Live Components
|
|
40
|
+
- [Live Binary Delta](resources/live-binary-delta.md) - High-frequency binary state sync
|
|
40
41
|
- [External Plugins](resources/plugins-external.md) - Plugin development
|
|
41
42
|
- [Routing (React Router v7)](reference/routing.md) - Frontend routing setup
|
|
42
43
|
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
# Binary Delta (High-Frequency State Sync)
|
|
2
|
+
|
|
3
|
+
**Version:** 1.14.0 | **Updated:** 2025-03-09
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Binary Delta allows Live Components to send state updates as raw binary frames instead of JSON. This bypasses the JSON batcher and sends directly over the WebSocket, making it ideal for high-frequency updates like game state (positions, rotations, physics) or real-time sensor data.
|
|
8
|
+
|
|
9
|
+
## When to Use Binary vs JSON
|
|
10
|
+
|
|
11
|
+
| Scenario | Use | Why |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Forms, chat, CRUD | **JSON** (default `setState`) | Low frequency, readability matters |
|
|
14
|
+
| Dashboard metrics | **JSON** | Updates every few seconds |
|
|
15
|
+
| Game state (30-60 fps) | **Binary Delta** | Hundreds of updates/sec, payload size matters |
|
|
16
|
+
| Real-time collaboration (cursors) | **Binary Delta** | High frequency, small payloads |
|
|
17
|
+
| IoT sensor streams | **Binary Delta** | Continuous data, compact encoding |
|
|
18
|
+
|
|
19
|
+
**Rule of thumb:** If you're sending state updates more than ~10 times per second, Binary Delta will reduce bandwidth and latency significantly.
|
|
20
|
+
|
|
21
|
+
## Wire Format
|
|
22
|
+
|
|
23
|
+
Each binary frame has this structure:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
[0x01] [idLen:u8] [componentId:utf8] [payload:bytes]
|
|
27
|
+
1B 1B N bytes M bytes
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Field | Size | Description |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `0x01` | 1 byte | BINARY_STATE_DELTA marker |
|
|
33
|
+
| `idLen` | 1 byte | Length of componentId string |
|
|
34
|
+
| `componentId` | N bytes | UTF-8 encoded component ID |
|
|
35
|
+
| `payload` | M bytes | Your custom-encoded delta |
|
|
36
|
+
|
|
37
|
+
Total overhead: **2 + componentId.length** bytes. The payload is entirely yours to define.
|
|
38
|
+
|
|
39
|
+
## Server-Side: `sendBinaryDelta()`
|
|
40
|
+
|
|
41
|
+
### API
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
public sendBinaryDelta(
|
|
45
|
+
delta: Partial<TState>,
|
|
46
|
+
encoder: (delta: Partial<TState>) => Uint8Array
|
|
47
|
+
): void
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **delta** - Object with the state fields that changed (same shape as `setState`)
|
|
51
|
+
- **encoder** - Function that serializes the delta into bytes
|
|
52
|
+
|
|
53
|
+
### Behavior
|
|
54
|
+
|
|
55
|
+
1. Compares `delta` against current state - only actually changed fields are kept
|
|
56
|
+
2. Updates internal state (same as `setState`)
|
|
57
|
+
3. Calls your `encoder` with only the changed fields
|
|
58
|
+
4. Wraps the result in the wire format and sends it
|
|
59
|
+
5. If nothing changed, no frame is sent
|
|
60
|
+
6. If WebSocket is closed (readyState !== 1), state updates but no frame is sent
|
|
61
|
+
|
|
62
|
+
### Example: Simple Component
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// app/server/live/LiveTracker.ts
|
|
66
|
+
import { LiveComponent } from '@core/types/types'
|
|
67
|
+
|
|
68
|
+
// Encoder: convert delta to binary using DataView
|
|
69
|
+
function encodePosition(delta: Record<string, any>): Uint8Array {
|
|
70
|
+
// Calculate size: 1 byte flags + 4 bytes per float field
|
|
71
|
+
let flags = 0
|
|
72
|
+
let size = 1 // flags byte
|
|
73
|
+
|
|
74
|
+
if ('x' in delta) { flags |= 0x01; size += 4 }
|
|
75
|
+
if ('y' in delta) { flags |= 0x02; size += 4 }
|
|
76
|
+
if ('speed' in delta) { flags |= 0x04; size += 4 }
|
|
77
|
+
|
|
78
|
+
const buffer = new ArrayBuffer(size)
|
|
79
|
+
const dv = new DataView(buffer)
|
|
80
|
+
let offset = 0
|
|
81
|
+
|
|
82
|
+
dv.setUint8(offset, flags); offset += 1
|
|
83
|
+
if ('x' in delta) { dv.setFloat32(offset, delta.x, true); offset += 4 }
|
|
84
|
+
if ('y' in delta) { dv.setFloat32(offset, delta.y, true); offset += 4 }
|
|
85
|
+
if ('speed' in delta) { dv.setFloat32(offset, delta.speed, true); offset += 4 }
|
|
86
|
+
|
|
87
|
+
return new Uint8Array(buffer)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class LiveTracker extends LiveComponent<typeof LiveTracker.defaultState> {
|
|
91
|
+
static componentName = 'LiveTracker'
|
|
92
|
+
static publicActions = ['updatePosition'] as const
|
|
93
|
+
static defaultState = {
|
|
94
|
+
x: 0,
|
|
95
|
+
y: 0,
|
|
96
|
+
speed: 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
declare x: number
|
|
100
|
+
declare y: number
|
|
101
|
+
declare speed: number
|
|
102
|
+
|
|
103
|
+
private _interval?: ReturnType<typeof setInterval>
|
|
104
|
+
|
|
105
|
+
protected onMount() {
|
|
106
|
+
// Send position 30 times per second
|
|
107
|
+
this._interval = setInterval(() => {
|
|
108
|
+
this.sendBinaryDelta(
|
|
109
|
+
{ x: this.x + Math.random(), y: this.y + Math.random() },
|
|
110
|
+
encodePosition
|
|
111
|
+
)
|
|
112
|
+
}, 33) // ~30fps
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
protected onDestroy() {
|
|
116
|
+
clearInterval(this._interval)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async updatePosition(payload: { x: number; y: number }) {
|
|
120
|
+
this.sendBinaryDelta(
|
|
121
|
+
{ x: payload.x, y: payload.y },
|
|
122
|
+
encodePosition
|
|
123
|
+
)
|
|
124
|
+
return { success: true }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Client-Side: `binaryDecoder` option
|
|
130
|
+
|
|
131
|
+
### With React (`useLiveComponent` / `Live.use`)
|
|
132
|
+
|
|
133
|
+
Pass the `binaryDecoder` option when mounting the component. The decoder receives the raw payload bytes (without the wire format header - that's already stripped) and must return an object to merge into state.
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// app/client/src/live/TrackerDemo.tsx
|
|
137
|
+
import { useLiveComponent } from '@fluxstack/live-react'
|
|
138
|
+
|
|
139
|
+
// Decoder: must mirror the encoder logic
|
|
140
|
+
function decodePosition(buffer: Uint8Array): Record<string, any> {
|
|
141
|
+
const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
142
|
+
let offset = 0
|
|
143
|
+
|
|
144
|
+
const flags = dv.getUint8(offset); offset += 1
|
|
145
|
+
const result: Record<string, any> = {}
|
|
146
|
+
|
|
147
|
+
if (flags & 0x01) { result.x = dv.getFloat32(offset, true); offset += 4 }
|
|
148
|
+
if (flags & 0x02) { result.y = dv.getFloat32(offset, true); offset += 4 }
|
|
149
|
+
if (flags & 0x04) { result.speed = dv.getFloat32(offset, true); offset += 4 }
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function TrackerDemo() {
|
|
155
|
+
const { state, call, connected } = useLiveComponent('LiveTracker', {
|
|
156
|
+
initialState: { x: 0, y: 0, speed: 0 },
|
|
157
|
+
binaryDecoder: decodePosition // <-- register decoder here
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div>
|
|
162
|
+
<p>Position: ({state.x.toFixed(2)}, {state.y.toFixed(2)})</p>
|
|
163
|
+
<p>Speed: {state.speed.toFixed(2)}</p>
|
|
164
|
+
<p>{connected ? 'Connected' : 'Disconnected'}</p>
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### With Vanilla JS (`LiveComponentHandle`)
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { LiveConnection, LiveComponentHandle } from '@fluxstack/live-client'
|
|
174
|
+
|
|
175
|
+
const conn = new LiveConnection({ url: 'ws://localhost:3000/api/live/ws' })
|
|
176
|
+
const tracker = new LiveComponentHandle(conn, 'LiveTracker', {
|
|
177
|
+
x: 0, y: 0, speed: 0
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await tracker.mount()
|
|
181
|
+
|
|
182
|
+
// Register binary decoder AFTER mount
|
|
183
|
+
tracker.setBinaryDecoder(decodePosition)
|
|
184
|
+
|
|
185
|
+
tracker.onStateChange((state, delta) => {
|
|
186
|
+
console.log('Position:', state.x, state.y)
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Important:** `setBinaryDecoder()` must be called AFTER `mount()`. The component needs a `componentId` (assigned by the server on mount) to register the binary handler.
|
|
191
|
+
|
|
192
|
+
## Writing Encoders and Decoders
|
|
193
|
+
|
|
194
|
+
### Strategy 1: DataView (Best Performance)
|
|
195
|
+
|
|
196
|
+
Use `DataView` with typed fields. Best for fixed schemas with numbers.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
// Shared between server and client (e.g. app/shared/codec/trackerCodec.ts)
|
|
200
|
+
|
|
201
|
+
export function encode(delta: Record<string, any>): Uint8Array {
|
|
202
|
+
let flags = 0, size = 1
|
|
203
|
+
if ('x' in delta) { flags |= 0x01; size += 4 }
|
|
204
|
+
if ('y' in delta) { flags |= 0x02; size += 4 }
|
|
205
|
+
|
|
206
|
+
const buf = new ArrayBuffer(size)
|
|
207
|
+
const dv = new DataView(buf)
|
|
208
|
+
let off = 0
|
|
209
|
+
dv.setUint8(off, flags); off += 1
|
|
210
|
+
if (flags & 0x01) { dv.setFloat32(off, delta.x, true); off += 4 }
|
|
211
|
+
if (flags & 0x02) { dv.setFloat32(off, delta.y, true); off += 4 }
|
|
212
|
+
return new Uint8Array(buf)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function decode(buffer: Uint8Array): Record<string, any> {
|
|
216
|
+
const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
217
|
+
let off = 0
|
|
218
|
+
const flags = dv.getUint8(off); off += 1
|
|
219
|
+
const result: Record<string, any> = {}
|
|
220
|
+
if (flags & 0x01) { result.x = dv.getFloat32(off, true); off += 4 }
|
|
221
|
+
if (flags & 0x02) { result.y = dv.getFloat32(off, true); off += 4 }
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Tip:** Put codec files in `app/shared/` so both server and client can import them.
|
|
227
|
+
|
|
228
|
+
### Strategy 2: JSON-in-Binary (Simplest)
|
|
229
|
+
|
|
230
|
+
If you want binary transport without writing a custom codec, just JSON-encode into bytes:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
function encode(delta: Record<string, any>): Uint8Array {
|
|
234
|
+
return new TextEncoder().encode(JSON.stringify(delta))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function decode(buffer: Uint8Array): Record<string, any> {
|
|
238
|
+
return JSON.parse(new TextDecoder().decode(buffer))
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
This still bypasses the JSON batcher (lower latency) but doesn't save bandwidth. Good for prototyping before writing a proper codec.
|
|
243
|
+
|
|
244
|
+
### Strategy 3: Bitmask Flags (Complex Schemas)
|
|
245
|
+
|
|
246
|
+
For state with many optional fields (like game state with tanks, bullets, explosions), use bitmask flags to indicate which fields are present:
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
// Field presence flags
|
|
250
|
+
const FLAG_TANKS = 0x01
|
|
251
|
+
const FLAG_BULLETS = 0x02
|
|
252
|
+
const FLAG_EXPLOSIONS = 0x04
|
|
253
|
+
|
|
254
|
+
function encode(delta: Record<string, any>): Uint8Array {
|
|
255
|
+
let flags = 0
|
|
256
|
+
if (delta.tanks) flags |= FLAG_TANKS
|
|
257
|
+
if (delta.bullets) flags |= FLAG_BULLETS
|
|
258
|
+
if (delta.explosions) flags |= FLAG_EXPLOSIONS
|
|
259
|
+
|
|
260
|
+
// Calculate total size, allocate buffer, write fields...
|
|
261
|
+
// See the full game codec example below
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Writing Strings in Binary
|
|
266
|
+
|
|
267
|
+
Helper functions for encoding/decoding strings inside binary payloads:
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
const textEncoder = new TextEncoder()
|
|
271
|
+
const textDecoder = new TextDecoder()
|
|
272
|
+
|
|
273
|
+
// Write: [1 byte length][N bytes UTF-8]
|
|
274
|
+
function writeString(dv: DataView, offset: number, str: string): number {
|
|
275
|
+
const bytes = textEncoder.encode(str)
|
|
276
|
+
dv.setUint8(offset, bytes.length) // max 255 chars
|
|
277
|
+
offset += 1
|
|
278
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
279
|
+
dv.setUint8(offset + i, bytes[i])
|
|
280
|
+
}
|
|
281
|
+
return offset + bytes.length
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Read: [1 byte length][N bytes UTF-8]
|
|
285
|
+
function readString(dv: DataView, offset: number): [string, number] {
|
|
286
|
+
const len = dv.getUint8(offset)
|
|
287
|
+
offset += 1
|
|
288
|
+
const bytes = new Uint8Array(dv.buffer, dv.byteOffset + offset, len)
|
|
289
|
+
return [textDecoder.decode(bytes), offset + len]
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Real-World Example: Game State Codec
|
|
294
|
+
|
|
295
|
+
This codec is used by Battle Tanks to encode tanks, bullets, explosions, and laser beams into a single binary frame. It uses bitmask flags, DataView for typed fields, and string helpers for IDs.
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
// app/shared/codec/gameCodec.ts
|
|
299
|
+
|
|
300
|
+
interface TankDynamic {
|
|
301
|
+
id: string
|
|
302
|
+
x: number
|
|
303
|
+
z: number
|
|
304
|
+
rot: number
|
|
305
|
+
tRot: number
|
|
306
|
+
hp: number
|
|
307
|
+
alive: boolean
|
|
308
|
+
laserCharge: number
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const FLAG_TANKS = 0x01
|
|
312
|
+
const FLAG_BULLETS = 0x02
|
|
313
|
+
const FLAG_EXPLOSIONS = 0x04
|
|
314
|
+
const FLAG_LASERS = 0x08
|
|
315
|
+
|
|
316
|
+
export function encodeGameState(delta: Record<string, any>): Uint8Array {
|
|
317
|
+
let size = 1 + 4 // flags (1B) + matchTime (4B)
|
|
318
|
+
let flags = 0
|
|
319
|
+
|
|
320
|
+
const tanks: TankDynamic[] | undefined = delta.tanks
|
|
321
|
+
if (tanks) {
|
|
322
|
+
flags |= FLAG_TANKS
|
|
323
|
+
size += 2 // tank count (uint16)
|
|
324
|
+
for (const t of tanks) {
|
|
325
|
+
const idBytes = new TextEncoder().encode(t.id)
|
|
326
|
+
// 1B idLen + id + 4 floats (x,z,rot,tRot) + hp (2B) + alive (1B) + laserCharge (4B)
|
|
327
|
+
size += 1 + idBytes.length + 16 + 2 + 1 + 4
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ... similar for bullets, explosions, lasers ...
|
|
332
|
+
|
|
333
|
+
const buffer = new ArrayBuffer(size)
|
|
334
|
+
const dv = new DataView(buffer)
|
|
335
|
+
let offset = 0
|
|
336
|
+
|
|
337
|
+
dv.setUint8(offset, flags); offset += 1
|
|
338
|
+
dv.setUint32(offset, delta.matchTime ?? 0, true); offset += 4
|
|
339
|
+
|
|
340
|
+
if (tanks) {
|
|
341
|
+
dv.setUint16(offset, tanks.length, true); offset += 2
|
|
342
|
+
for (const t of tanks) {
|
|
343
|
+
offset = writeString(dv, offset, t.id)
|
|
344
|
+
dv.setFloat32(offset, t.x, true); offset += 4
|
|
345
|
+
dv.setFloat32(offset, t.z, true); offset += 4
|
|
346
|
+
dv.setFloat32(offset, t.rot, true); offset += 4
|
|
347
|
+
dv.setFloat32(offset, t.tRot, true); offset += 4
|
|
348
|
+
dv.setUint16(offset, t.hp, true); offset += 2
|
|
349
|
+
dv.setUint8(offset, t.alive ? 1 : 0); offset += 1
|
|
350
|
+
dv.setFloat32(offset, t.laserCharge, true); offset += 4
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return new Uint8Array(buffer)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function decodeGameState(buffer: Uint8Array): Record<string, any> {
|
|
358
|
+
const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
359
|
+
let offset = 0
|
|
360
|
+
|
|
361
|
+
const flags = dv.getUint8(offset); offset += 1
|
|
362
|
+
const matchTime = dv.getUint32(offset, true); offset += 4
|
|
363
|
+
const result: Record<string, any> = { matchTime }
|
|
364
|
+
|
|
365
|
+
if (flags & FLAG_TANKS) {
|
|
366
|
+
const count = dv.getUint16(offset, true); offset += 2
|
|
367
|
+
const tanks: TankDynamic[] = []
|
|
368
|
+
for (let i = 0; i < count; i++) {
|
|
369
|
+
let id: string
|
|
370
|
+
;[id, offset] = readString(dv, offset)
|
|
371
|
+
const x = dv.getFloat32(offset, true); offset += 4
|
|
372
|
+
const z = dv.getFloat32(offset, true); offset += 4
|
|
373
|
+
const rot = dv.getFloat32(offset, true); offset += 4
|
|
374
|
+
const tRot = dv.getFloat32(offset, true); offset += 4
|
|
375
|
+
const hp = dv.getUint16(offset, true); offset += 2
|
|
376
|
+
const alive = dv.getUint8(offset) === 1; offset += 1
|
|
377
|
+
const laserCharge = dv.getFloat32(offset, true); offset += 4
|
|
378
|
+
tanks.push({ id, x, z, rot, tRot, hp, alive, laserCharge })
|
|
379
|
+
}
|
|
380
|
+
result.tanks = tanks
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ... similar for bullets, explosions, lasers ...
|
|
384
|
+
|
|
385
|
+
return result
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Server Usage (Game Loop)
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
import { encodeGameState } from '@app/shared/codec/gameCodec'
|
|
393
|
+
|
|
394
|
+
export class LiveBattleTanks extends LiveComponent<typeof LiveBattleTanks.defaultState> {
|
|
395
|
+
static componentName = 'LiveBattleTanks'
|
|
396
|
+
static singleton = true
|
|
397
|
+
static publicActions = ['join', 'move', 'shoot'] as const
|
|
398
|
+
static defaultState = {
|
|
399
|
+
tanks: [] as TankDynamic[],
|
|
400
|
+
bullets: [] as any[],
|
|
401
|
+
explosions: [] as any[],
|
|
402
|
+
matchTime: 0
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private _loop?: ReturnType<typeof setInterval>
|
|
406
|
+
|
|
407
|
+
protected onMount() {
|
|
408
|
+
// Game loop at 30fps
|
|
409
|
+
this._loop = setInterval(() => {
|
|
410
|
+
this.tick()
|
|
411
|
+
this.sendBinaryDelta(
|
|
412
|
+
{
|
|
413
|
+
tanks: this.state.tanks,
|
|
414
|
+
bullets: this.state.bullets,
|
|
415
|
+
explosions: this.state.explosions,
|
|
416
|
+
matchTime: this.state.matchTime
|
|
417
|
+
},
|
|
418
|
+
encodeGameState
|
|
419
|
+
)
|
|
420
|
+
}, 33)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
protected onDestroy() {
|
|
424
|
+
clearInterval(this._loop)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private tick() {
|
|
428
|
+
// Update physics, process collisions, etc.
|
|
429
|
+
this.state.matchTime += 33
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Client Usage (React)
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
import { useLiveComponent } from '@fluxstack/live-react'
|
|
438
|
+
import { decodeGameState } from '@app/shared/codec/gameCodec'
|
|
439
|
+
|
|
440
|
+
export function BattleTanks() {
|
|
441
|
+
const { state, call } = useLiveComponent('LiveBattleTanks', {
|
|
442
|
+
initialState: { tanks: [], bullets: [], explosions: [], matchTime: 0 },
|
|
443
|
+
binaryDecoder: decodeGameState
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
// Render game using state.tanks, state.bullets, etc.
|
|
447
|
+
return <GameCanvas tanks={state.tanks} bullets={state.bullets} />
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
## Bandwidth Comparison
|
|
452
|
+
|
|
453
|
+
For a game with 8 tanks, 20 bullets, and 3 explosions at 30fps:
|
|
454
|
+
|
|
455
|
+
| Method | Payload Size | Per Second | Savings |
|
|
456
|
+
|---|---|---|---|
|
|
457
|
+
| JSON (`setState`) | ~2.4 KB | ~72 KB/s | baseline |
|
|
458
|
+
| Binary (DataView) | ~0.5 KB | ~15 KB/s | **~80%** |
|
|
459
|
+
|
|
460
|
+
Binary encoding is especially effective when state contains many numeric fields (floats, integers) since JSON encodes numbers as variable-length text while DataView uses fixed-size typed representations.
|
|
461
|
+
|
|
462
|
+
## Key Differences: `sendBinaryDelta` vs `setState`
|
|
463
|
+
|
|
464
|
+
| | `setState` | `sendBinaryDelta` |
|
|
465
|
+
|---|---|---|
|
|
466
|
+
| **Format** | JSON | Custom binary |
|
|
467
|
+
| **Batching** | Merged per microtask | Immediate send |
|
|
468
|
+
| **Deduplication** | Yes (by componentId) | No |
|
|
469
|
+
| **Encoder** | Built-in (JSON.stringify) | You provide it |
|
|
470
|
+
| **Client decoder** | Built-in (JSON.parse) | You provide it |
|
|
471
|
+
| **Best for** | Low-frequency, readable data | High-frequency, compact data |
|
|
472
|
+
| **State update** | Yes | Yes (same internal behavior) |
|
|
473
|
+
|
|
474
|
+
Both methods update internal state identically. The difference is only in how the data is serialized and sent over the wire.
|
|
475
|
+
|
|
476
|
+
## Important Notes
|
|
477
|
+
|
|
478
|
+
- Encoder and decoder must be **symmetric** - what you encode, you must decode in the same order and format
|
|
479
|
+
- Put codec files in `app/shared/` so server and client share the same code
|
|
480
|
+
- `sendBinaryDelta` only sends fields that actually changed (same diffing as `setState`)
|
|
481
|
+
- Binary frames bypass the JSON batcher and message deduplication
|
|
482
|
+
- Use `setBinaryDecoder()` only AFTER `mount()` (vanilla JS client)
|
|
483
|
+
- With React, just pass `binaryDecoder` in options - lifecycle is handled automatically
|
|
484
|
+
- If both `setState` and `sendBinaryDelta` are used on the same component, the client handles both (JSON messages go through the normal path, binary frames go through the decoder)
|
|
485
|
+
|
|
486
|
+
## Files
|
|
487
|
+
|
|
488
|
+
**Core (Server)**
|
|
489
|
+
- `packages/core/src/component/LiveComponent.ts` - `sendBinaryDelta()` method
|
|
490
|
+
- `packages/core/src/component/managers/ComponentStateManager.ts` - Wire format implementation
|
|
491
|
+
|
|
492
|
+
**Client (Browser)**
|
|
493
|
+
- `packages/client/src/component.ts` - `setBinaryDecoder()` method
|
|
494
|
+
- `packages/client/src/connection.ts` - `handleBinaryMessage()` + `registerBinaryHandler()`
|
|
495
|
+
|
|
496
|
+
**React**
|
|
497
|
+
- `packages/react/src/hooks/useLiveComponent.ts` - `binaryDecoder` option in `UseLiveComponentOptions`
|
|
498
|
+
|
|
499
|
+
**Tests**
|
|
500
|
+
- `packages/core/src/__tests__/component/LiveComponent.binary.test.ts` - Wire format and behavior tests
|
|
501
|
+
- `packages/core/src/__tests__/component/fixtures/gameCodec.ts` - Full game codec example
|
|
502
|
+
|
|
503
|
+
## Related
|
|
504
|
+
|
|
505
|
+
- [Live Components](./live-components.md) - Core Live Component documentation
|
|
506
|
+
- [Live Upload](./live-upload.md) - Chunked file upload (different binary protocol)
|
|
507
|
+
- [Live Rooms](./live-rooms.md) - Multi-room communication
|