Skip to content

Add realtime sync#85

Merged
rockfactory merged 5 commits into
devfrom
feature/realtime-sync
Apr 15, 2026
Merged

Add realtime sync#85
rockfactory merged 5 commits into
devfrom
feature/realtime-sync

Conversation

@th3fallen
Copy link
Copy Markdown
Collaborator

Summary

Ports the patch-based realtime sync system from dev to mainline. This enables cross-device live synchronization of game state via Supabase Realtime broadcast channels.

How it works

  • Immer patches as the sync primitive: Every Zustand state mutation now goes through produceWithPatches, which emits fine-grained Immer patches. Only patches touching game-related slices (games, factories, solvers) are broadcast — unrelated store changes (auth, UI state) are ignored, preventing the feedback loops that plagued the earlier full-state approach.

  • Debounced broadcasting: Outgoing patches are batched over 150ms before being sent, reducing network chatter during rapid edits.

  • Sequence numbers for ordering: Each broadcast carries an incrementing sequence number. If a receiver detects a gap (missed patches), it requests a full state snapshot from peers rather than applying stale data.

  • Full-state fallback: On channel join, each client requests a full state from any connected peer. If no peer responds within 3 seconds, it falls back to loading from the Supabase database — so reconnecting after all peers have gone offline still works.

  • Silent auto-save: Local changes trigger a debounced (5s) auto-save to the database. The silent flag prevents the save button spinner from firing during these background saves. On unmount, any pending auto-save flushes immediately.

  • updatedAt tracking: The Game model now carries updatedAt from the database, persisted through load/save cycles but excluded from serializeGame so it doesn't affect content-based change detection.

  • Sync status indicator: A green dot appears on the game menu when the realtime channel is connected (always visible in dev mode).

Resolves #69

@th3fallen th3fallen requested a review from rockfactory April 15, 2026 15:13
@th3fallen th3fallen force-pushed the feature/realtime-sync branch from 31b7d78 to c7cc128 Compare April 15, 2026 16:30
Comment thread src/core/zustand-helpers/immer.ts Outdated
const isSaving = useStore(state => state.gameSave.isSaving);
const isSyncConnected =
useStore(state => state.gameSave.isRealtimeSyncConnected) ||
import.meta.env.DEV;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this || DEV?

Comment thread src/games/sync/useRealtimeGameSync.ts Outdated
Comment on lines +205 to +213
function scheduleAutoSave() {
if (autoSaveTimer !== null) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => {
autoSaveTimer = null;
saveRemoteGame(gameId, { silent: true }).catch(err =>
logger.error('Auto-save failed', err),
);
}, AUTO_SAVE_DEBOUNCE_MS);
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be managed by a "lock" mechanism, elseway we're going to make multiple users save the same game on DB at the same time; I was thinking about a Leader-election mechanism so that only the first of the connected users (we can use Supabase realtime presence API I think to handle this recognition) would be responsible of saving the game. This way it's easier to model the peer-to-peer connection (it would be a coordinated primary/secondary election)

Comment thread src/games/sync/useRealtimeGameSync.ts Outdated
Comment thread src/games/sync/useRealtimeGameSync.ts Outdated
Comment on lines +181 to +197
if (status === 'SUBSCRIBED') {
channel.send({
type: 'broadcast',
event: BROADCAST_FULL_REQUEST,
payload: { senderId: SENDER_ID } satisfies FullStateRequestPayload,
});

dbFallbackTimer = setTimeout(() => {
dbFallbackTimer = null;
logger.info('No peer response, loading from database');
isApplyingRemoteRef.current = true;
loadRemoteGame(gameId, { override: true }).finally(() => {
isApplyingRemoteRef.current = false;
});
}, DB_FALLBACK_MS);
}
});
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removes local-only changes which have not been saved if no-one reply (even if technically there is no one else at all). We should check local updatedAt maybe?

Not sure on how to handle this but we'd lose previously un-uploaded edits if this is not handled

Comment thread src/games/sync/useRealtimeGameSync.ts Outdated
Comment thread src/games/sync/useRealtimeGameSync.ts Outdated
Comment thread src/games/sync/useRealtimeGameSync.ts
Comment thread src/games/sync/useRealtimeGameSync.ts Outdated
Comment on lines +93 to +106
dbFallbackTimer = setTimeout(async () => {
dbFallbackTimer = null;
logger.info(
'No peer response, saving local state then loading from database',
);
try {
await saveRemoteGame(gameId, { silent: true });
} catch (err) {
logger.error('Pre-fallback save failed', err);
}
isApplyingRemoteRef.current = true;
loadRemoteGame(gameId, { override: true }).finally(() => {
isApplyingRemoteRef.current = false;
});
Copy link
Copy Markdown
Owner

@rockfactory rockfactory Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds strange; if we're saving it, why are we loading it right after?
I think we're going in the right direction, but we should define a pattern like:

  1. we are falling back to DB state, we need to fetch it and compare the updated_at
  2. if the updated_at is more recent than our, we **load it **
  3. elseway (if it's the same or we've got a more recent version, not sure on how to check this exactly) we save it

what do you think?

@rockfactory rockfactory merged commit ee364f6 into dev Apr 15, 2026
1 check passed
@th3fallen th3fallen deleted the feature/realtime-sync branch April 15, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants