Skip to content

Commit c41b48e

Browse files
committed
feat(sync): SyncZone, SyncBox, useZoneKey, and server APIs
1 parent 30413e9 commit c41b48e

File tree

10 files changed

+1537
-2
lines changed

10 files changed

+1537
-2
lines changed

.changeset/sixty-plants-give.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@robojs/sync': minor
3+
---
4+
5+
feat: SyncZone, SyncBox, useZoneKey, and server APIs

packages/plugin-sync/README.md

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,215 @@ interface Client<ClientData> {
190190
}
191191
```
192192

193+
## Components
194+
195+
### `SyncZone`
196+
197+
A context provider for hierarchical key prefixing and host inheritance. Zones enable component reusability and organized state namespacing.
198+
199+
```tsx
200+
import { SyncZone, SyncBox } from '@robojs/sync'
201+
202+
function Game() {
203+
return (
204+
<SyncZone id={['game', 'room1']}>
205+
<SyncBox id={['player']} initialState={{ x: 0, y: 0 }}>
206+
<PlayerSprite />
207+
</SyncBox>
208+
{/* key becomes 'game.room1.player' */}
209+
</SyncZone>
210+
)
211+
}
212+
```
213+
214+
**Nested zones** accumulate prefixes:
215+
216+
```tsx
217+
<SyncZone id={['game']}>
218+
<SyncZone id={['board']}>
219+
<SyncBox id={['piece']} />
220+
{/* key: 'game.board.piece' */}
221+
</SyncZone>
222+
</SyncZone>
223+
```
224+
225+
**Host rules** control host determination:
226+
227+
```tsx
228+
// First subscriber becomes host (default)
229+
<SyncZone id={['match']} hostRules="first">
230+
231+
// Explicit host assignment
232+
<SyncZone id={['match']} hostRules="explicit" host={adminClientId}>
233+
```
234+
235+
| Prop | Type | Description |
236+
|------|------|-------------|
237+
| `id` | `(string \| null)[]` | Key prefix for this zone |
238+
| `hostRules` | `'first' \| 'explicit'` | Host determination strategy (default: `'first'`) |
239+
| `host` | `string \| null` | Explicit host client ID (when `hostRules='explicit'`) |
240+
241+
### `SyncBox`
242+
243+
A synced container component that synchronizes arbitrary state across clients. General-purpose—apps define their own logic for what to sync.
244+
245+
**With render props (recommended):**
246+
247+
```tsx
248+
import { SyncBox } from '@robojs/sync'
249+
250+
function Cursor() {
251+
return (
252+
<SyncBox id={['cursor']} initialState={{ x: 0.5, y: 0.5 }} throttle={16}>
253+
{(state, setState, status) => (
254+
<div style={{ left: `${state?.x * 100}%`, top: `${state?.y * 100}%` }}>
255+
{status.stale && <span>Reconnecting...</span>}
256+
</div>
257+
)}
258+
</SyncBox>
259+
)
260+
}
261+
```
262+
263+
**With lockable (exclusive ownership):**
264+
265+
```tsx
266+
<SyncBox id={['ball']} initialState={{ x: 0, y: 0 }} lockable>
267+
{(state, setState, status, context, lock) => (
268+
<div
269+
onMouseDown={() => lock?.lock()}
270+
onMouseUp={() => lock?.unlock()}
271+
style={{
272+
cursor: lock?.isLocked
273+
? lock.isLockHolder ? 'grabbing' : 'not-allowed'
274+
: 'grab'
275+
}}
276+
/>
277+
)}
278+
</SyncBox>
279+
```
280+
281+
**With interpolation (smooth remote movement):**
282+
283+
```tsx
284+
<SyncBox
285+
id={['cursor']}
286+
initialState={{ x: 0.5, y: 0.5 }}
287+
interpolate={{ x: 0.15, y: 0.15 }}
288+
>
289+
{(state) => <Cursor x={state?.x} y={state?.y} />}
290+
</SyncBox>
291+
```
292+
293+
**With optimistic updates:**
294+
295+
```tsx
296+
<SyncBox id={['counter']} initialState={{ count: 0 }}>
297+
{(state, setState) => (
298+
<button onClick={() => setState({ count: (state?.count ?? 0) + 1 }, { optimistic: true })}>
299+
{state?.count}
300+
</button>
301+
)}
302+
</SyncBox>
303+
```
304+
305+
**No wrapper element:**
306+
307+
```tsx
308+
<SyncBox as={null} id={['data']} initialState={{ value: '' }}>
309+
{(state, setState) => <input value={state?.value} onChange={e => setState({ value: e.target.value })} />}
310+
</SyncBox>
311+
```
312+
313+
| Prop | Type | Description |
314+
|------|------|-------------|
315+
| `id` | `(string \| null)[]` | Key suffix (combined with zone prefix) |
316+
| `initialState` | `T` | Initial state value |
317+
| `children` | `ReactNode \| (state, setState, status, context, lock?) => ReactNode` | Children or render function |
318+
| `throttle` | `number \| { [field]: number }` | Throttle setState calls (ms), global or per-field |
319+
| `lockable` | `boolean` | Enable exclusive ownership mode |
320+
| `interpolate` | `{ [field]: number }` | Lerp factor per field for smooth remote updates (0-1) |
321+
| `as` | `ElementType \| null` | Wrapper element (default: `'div'`, `null` for none) |
322+
| `onStateChange` | `(state, prev) => void` | Called when synced state changes |
323+
| `onSyncStatusChange` | `(status) => void` | Called when sync status changes |
324+
| `onConflict` | `(local, remote) => T` | Resolve conflicts between optimistic and remote state |
325+
| `style` | `CSSProperties` | Styles for wrapper element |
326+
| `className` | `string` | Class name for wrapper element |
327+
328+
**Render function signature:**
329+
330+
```ts
331+
(state, setState, status, context, lock?) => ReactNode
332+
333+
// setState supports options
334+
setState({ x: 1 }, { optimistic: true, throttle: 16 })
335+
```
336+
337+
**Lock context** (when `lockable` is enabled):
338+
339+
```ts
340+
interface LockContext {
341+
isLocked: boolean // Anyone holds the lock
342+
lockedBy: string | null // Lock holder's client ID
343+
isLockHolder: boolean // Current client holds the lock
344+
lock: () => void // Acquire lock (auto-assigns to you)
345+
unlock: () => void // Release lock
346+
}
347+
```
348+
349+
**Imperative handle** (`SyncBoxHandle<T>`):
350+
351+
```ts
352+
interface SyncBoxHandle<T> {
353+
getState: () => T | undefined
354+
setState: (value, options?) => void
355+
getSyncStatus: () => SyncStatus
356+
lock?: LockContext // When lockable=true
357+
}
358+
359+
interface SyncStatus {
360+
synced: boolean // Initial state received
361+
syncing: boolean // Update in progress
362+
stale: boolean // May be outdated
363+
lastSyncedAt?: number // Timestamp
364+
}
365+
```
366+
367+
### `useZoneContext`
368+
369+
Access the current zone's context:
370+
371+
```tsx
372+
import { useZoneContext } from '@robojs/sync'
373+
374+
function GameStatus() {
375+
const zone = useZoneContext()
376+
if (!zone) return null
377+
378+
return (
379+
<div>
380+
{zone.isHost ? 'You are the host' : `Host: ${zone.hostId}`}
381+
Players: {zone.clients.length}
382+
</div>
383+
)
384+
}
385+
```
386+
387+
### `useZoneKey`
388+
389+
Compute the full key by combining zone prefix with a local id:
390+
391+
```tsx
392+
import { useZoneKey, useSyncState } from '@robojs/sync'
393+
394+
function PlayerMarker({ playerId }) {
395+
const fullKey = useZoneKey(['player', playerId])
396+
// In <SyncZone id={['game']}>, fullKey = ['game', 'player', playerId]
397+
const [position, setPosition] = useSyncState({ x: 0, y: 0 }, fullKey)
398+
// ...
399+
}
400+
```
401+
193402
## Server Access
194403

195404
For advanced use cases, access the WebSocket server directly:
@@ -201,6 +410,47 @@ SyncServer.start() // Manually start (usually automatic)
201410
const wss = SyncServer.getSocketServer() // Get underlying WebSocketServer
202411
```
203412

413+
### Server-Side Zone API
414+
415+
Control sync state from the server for game logic, admin actions, or validation:
416+
417+
```ts
418+
import { SyncServer } from '@robojs/sync/server.js'
419+
420+
// Get a zone handle
421+
const zone = SyncServer.getZone(['game', 'room1'])
422+
423+
// Read state
424+
const state = zone.getState()
425+
426+
// Update state (broadcasts to all subscribers)
427+
zone.setState({ phase: 'playing', round: 2 })
428+
429+
// Override host (e.g., for admin actions)
430+
zone.setHost(newHostId)
431+
zone.setHost(null) // Clear host
432+
433+
// Get current host and clients
434+
const hostId = zone.getHost()
435+
const clients = zone.getClients()
436+
437+
// Send ephemeral messages
438+
zone.broadcast({ event: 'countdown', seconds: 5 })
439+
zone.send(clientId, { message: 'private' })
440+
```
441+
442+
Server-initiated messages include `fromClientId: '__server__'` so clients can distinguish them from player messages.
443+
444+
| Method | Description |
445+
|--------|-------------|
446+
| `getState()` | Get current state for the zone key |
447+
| `setState(data)` | Set state and broadcast to all subscribers |
448+
| `setHost(clientId \| null)` | Override or clear the host |
449+
| `getHost()` | Get current host client ID |
450+
| `getClients()` | Get all subscribed clients |
451+
| `broadcast(payload)` | Send to all subscribers |
452+
| `send(clientId, payload)` | Send to specific client |
453+
204454
## Need more power? ⚡
205455

206456
For complex multiplayer games, check out [**Colyseus**](https://colyseus.io/)—a powerful multiplayer game server that pairs perfectly with Robo.js.

0 commit comments

Comments
 (0)