2026-06-29 10:52:46 -04:00
|
|
|
import json
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
import uuid
|
2026-06-29 10:52:46 -04:00
|
|
|
|
|
|
|
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
|
|
|
|
|
perf: hand a joiner the room instead of asking everyone to answer
A joining peer learnt the roster by announcing itself and having every member
reply with their state. That's one broadcast per member — O(N^2) messages for
a room of N — and the in-memory channel layer caps each connection's queue at
100 and drops the overflow silently. Measured with 100 synthetic peers: every
peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic —
joinable? and mirrors? both read the roster, so you couldn't join someone you
couldn't see, and a mate whose state was dropped wouldn't move you.
The consumer now keeps the roster it is already relaying and hands a newcomer
a snapshot in one message, so a join costs two broadcasts instead of N. Same
100 peers: roster complete and identical for everyone, 2310 messages sent down
to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav
latency unchanged at ~14ms p50.
This is a cache of relayed gossip, not a source of truth: parties are still
worked out entirely in the browsers, and the server still decides nothing
about them. It is process-local, like the in-memory channel layer it sits
next to — if that ever moves to Redis for multiple workers, this moves too.
Measured ceiling for the pathological case (everyone in ONE party, all
mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms
at 27%, 250 degrades to ~450ms p50 with the roster incomplete again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
|
|
|
# Who is connected, per project: {group: {cid: {...presence...}}}. Purely a
|
|
|
|
|
# cache of what has already been relayed, so a joiner can be handed the room in
|
|
|
|
|
# one message instead of asking everyone in it to answer (which costs one
|
|
|
|
|
# broadcast per member, i.e. N^2 messages for a room of N — at 100 that loses
|
|
|
|
|
# most of the roster to the channel layer's queue limits). Nothing about a party
|
|
|
|
|
# is decided here; the browsers still work that out from the roster (tl.party).
|
|
|
|
|
#
|
|
|
|
|
# Process-local, like the in-memory channel layer this runs on. If that ever
|
|
|
|
|
# moves to Redis for multiple workers, this has to move with it.
|
|
|
|
|
ROOMS = {}
|
|
|
|
|
|
2026-06-29 10:52:46 -04:00
|
|
|
|
|
|
|
|
class SceneConsumer(AsyncWebsocketConsumer):
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
"""One connection per open project. Two things ride it.
|
|
|
|
|
|
|
|
|
|
Scene deltas: relayed from what the server broadcasts on a save (see
|
|
|
|
|
scenes.views.scene). Read-only — edits still go through the PUT endpoint,
|
|
|
|
|
which is the broadcast source.
|
|
|
|
|
|
|
|
|
|
Presence: who's viewing, which ad-hoc party they're in, and where that party
|
perf: hand a joiner the room instead of asking everyone to answer
A joining peer learnt the roster by announcing itself and having every member
reply with their state. That's one broadcast per member — O(N^2) messages for
a room of N — and the in-memory channel layer caps each connection's queue at
100 and drops the overflow silently. Measured with 100 synthetic peers: every
peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic —
joinable? and mirrors? both read the roster, so you couldn't join someone you
couldn't see, and a mate whose state was dropped wouldn't move you.
The consumer now keeps the roster it is already relaying and hands a newcomer
a snapshot in one message, so a join costs two broadcasts instead of N. Same
100 peers: roster complete and identical for everyone, 2310 messages sent down
to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav
latency unchanged at ~14ms p50.
This is a cache of relayed gossip, not a source of truth: parties are still
worked out entirely in the browsers, and the server still decides nothing
about them. It is process-local, like the in-memory channel layer it sits
next to — if that ever moves to Redis for multiple workers, this moves too.
Measured ceiling for the pathological case (everyone in ONE party, all
mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms
at 27%, 250 degrades to ~450ms p50 with the roster incomplete again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
|
|
|
is looking. Peers gossip it between themselves; all this does is hand out a
|
|
|
|
|
connection id, stamp the sender's server-side identity onto every message —
|
|
|
|
|
so nobody can post as somebody else — and relay.
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
RELAYED = ("state", "nav")
|
2026-06-29 10:52:46 -04:00
|
|
|
|
perf: hand a joiner the room instead of asking everyone to answer
A joining peer learnt the roster by announcing itself and having every member
reply with their state. That's one broadcast per member — O(N^2) messages for
a room of N — and the in-memory channel layer caps each connection's queue at
100 and drops the overflow silently. Measured with 100 synthetic peers: every
peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic —
joinable? and mirrors? both read the roster, so you couldn't join someone you
couldn't see, and a mate whose state was dropped wouldn't move you.
The consumer now keeps the roster it is already relaying and hands a newcomer
a snapshot in one message, so a join costs two broadcasts instead of N. Same
100 peers: roster complete and identical for everyone, 2310 messages sent down
to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav
latency unchanged at ~14ms p50.
This is a cache of relayed gossip, not a source of truth: parties are still
worked out entirely in the browsers, and the server still decides nothing
about them. It is process-local, like the in-memory channel layer it sits
next to — if that ever moves to Redis for multiple workers, this moves too.
Measured ceiling for the pathological case (everyone in ONE party, all
mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms
at 27%, 250 degrades to ~450ms p50 with the roster incomplete again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
|
|
|
@property
|
|
|
|
|
def room(self):
|
|
|
|
|
return ROOMS.setdefault(self.group, {})
|
|
|
|
|
|
2026-06-29 10:52:46 -04:00
|
|
|
async def connect(self):
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
self.pk = self.scope["url_route"]["kwargs"]["pk"]
|
|
|
|
|
self.group = f"project_{self.pk}"
|
|
|
|
|
self.cid = uuid.uuid4().hex[:12]
|
|
|
|
|
user = self.scope.get("user")
|
|
|
|
|
self.username = user.get_username() if user and user.is_authenticated else None
|
2026-06-29 10:52:46 -04:00
|
|
|
await self.channel_layer.group_add(self.group, self.channel_name)
|
|
|
|
|
await self.accept()
|
perf: hand a joiner the room instead of asking everyone to answer
A joining peer learnt the roster by announcing itself and having every member
reply with their state. That's one broadcast per member — O(N^2) messages for
a room of N — and the in-memory channel layer caps each connection's queue at
100 and drops the overflow silently. Measured with 100 synthetic peers: every
peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic —
joinable? and mirrors? both read the roster, so you couldn't join someone you
couldn't see, and a mate whose state was dropped wouldn't move you.
The consumer now keeps the roster it is already relaying and hands a newcomer
a snapshot in one message, so a join costs two broadcasts instead of N. Same
100 peers: roster complete and identical for everyone, 2310 messages sent down
to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav
latency unchanged at ~14ms p50.
This is a cache of relayed gossip, not a source of truth: parties are still
worked out entirely in the browsers, and the server still decides nothing
about them. It is process-local, like the in-memory channel layer it sits
next to — if that ever moves to Redis for multiple workers, this moves too.
Measured ceiling for the pathological case (everyone in ONE party, all
mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms
at 27%, 250 degrades to ~450ms p50 with the roster incomplete again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
|
|
|
|
|
|
|
|
me = {"cid": self.cid, "user": self.username, "party": None, "joinable": True}
|
|
|
|
|
others = list(self.room.values())
|
|
|
|
|
self.room[self.cid] = me
|
|
|
|
|
await self.send(text_data=json.dumps({"kind": "welcome", **me}))
|
|
|
|
|
await self.send(text_data=json.dumps({"kind": "roster", "peers": others}))
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
await self._relay({"kind": "join"})
|
2026-06-29 10:52:46 -04:00
|
|
|
|
|
|
|
|
async def disconnect(self, code):
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
if hasattr(self, "cid"):
|
perf: hand a joiner the room instead of asking everyone to answer
A joining peer learnt the roster by announcing itself and having every member
reply with their state. That's one broadcast per member — O(N^2) messages for
a room of N — and the in-memory channel layer caps each connection's queue at
100 and drops the overflow silently. Measured with 100 synthetic peers: every
peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic —
joinable? and mirrors? both read the roster, so you couldn't join someone you
couldn't see, and a mate whose state was dropped wouldn't move you.
The consumer now keeps the roster it is already relaying and hands a newcomer
a snapshot in one message, so a join costs two broadcasts instead of N. Same
100 peers: roster complete and identical for everyone, 2310 messages sent down
to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav
latency unchanged at ~14ms p50.
This is a cache of relayed gossip, not a source of truth: parties are still
worked out entirely in the browsers, and the server still decides nothing
about them. It is process-local, like the in-memory channel layer it sits
next to — if that ever moves to Redis for multiple workers, this moves too.
Measured ceiling for the pathological case (everyone in ONE party, all
mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms
at 27%, 250 degrades to ~450ms p50 with the roster incomplete again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
|
|
|
self.room.pop(self.cid, None)
|
|
|
|
|
if not self.room:
|
|
|
|
|
ROOMS.pop(self.group, None)
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
await self._relay({"kind": "leave"})
|
|
|
|
|
await self.channel_layer.group_discard(self.group, self.channel_name)
|
|
|
|
|
|
|
|
|
|
async def receive(self, text_data=None, bytes_data=None):
|
|
|
|
|
try:
|
|
|
|
|
msg = json.loads(text_data or "{}")
|
|
|
|
|
except ValueError:
|
|
|
|
|
return
|
perf: hand a joiner the room instead of asking everyone to answer
A joining peer learnt the roster by announcing itself and having every member
reply with their state. That's one broadcast per member — O(N^2) messages for
a room of N — and the in-memory channel layer caps each connection's queue at
100 and drops the overflow silently. Measured with 100 synthetic peers: every
peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic —
joinable? and mirrors? both read the roster, so you couldn't join someone you
couldn't see, and a mate whose state was dropped wouldn't move you.
The consumer now keeps the roster it is already relaying and hands a newcomer
a snapshot in one message, so a join costs two broadcasts instead of N. Same
100 peers: roster complete and identical for everyone, 2310 messages sent down
to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav
latency unchanged at ~14ms p50.
This is a cache of relayed gossip, not a source of truth: parties are still
worked out entirely in the browsers, and the server still decides nothing
about them. It is process-local, like the in-memory channel layer it sits
next to — if that ever moves to Redis for multiple workers, this moves too.
Measured ceiling for the pathological case (everyone in ONE party, all
mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms
at 27%, 250 degrades to ~450ms p50 with the roster incomplete again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
|
|
|
if not isinstance(msg, dict) or msg.get("kind") not in self.RELAYED:
|
|
|
|
|
return
|
|
|
|
|
if msg.get("kind") == "state" and self.cid in self.room:
|
|
|
|
|
self.room[self.cid].update(party=msg.get("party"),
|
|
|
|
|
joinable=bool(msg.get("joinable")))
|
|
|
|
|
await self._relay(msg)
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
|
|
|
|
|
async def _relay(self, msg):
|
|
|
|
|
await self.channel_layer.group_send(
|
|
|
|
|
self.group,
|
|
|
|
|
{"type": "peer.msg", "msg": {**msg, "cid": self.cid, "user": self.username}})
|
|
|
|
|
|
|
|
|
|
# broadcast handler: {type: "peer.msg", msg: {...}} — presence gossip
|
|
|
|
|
async def peer_msg(self, event):
|
|
|
|
|
await self.send(text_data=json.dumps(event["msg"]))
|
2026-06-29 10:52:46 -04:00
|
|
|
|
|
|
|
|
# broadcast handler: {type: "scene.delta", delta: {...}}
|
|
|
|
|
async def scene_delta(self, event):
|
feat: who's viewing, and ad-hoc parties that watch together
The project socket already carried scene deltas; it now also carries presence.
The top bar grows a Google-Docs-style cluster of faces, and the menu behind it
groups the room into parties, then everyone flying solo, with a Join on each.
A party has no host — you only join one. Joining someone solo adopts *their
cid* as the party id, so two people clicking Join in the same instant converge
instead of minting two parties of one, and the party outlives whoever was
joined first. Everyone in it drives everyone else: a jump, a scrub or a play
from any member moves all the others. That's why being joined needs consent
("Let others join me", on by default, remembered) and why Leave is one click.
Ordering was the thing to get right. A save is a PUT and a jump rides the
socket, so "create an annotation, then jump into it" can arrive at a peer in
the wrong order. Rather than truncate the stack and strand them at the root, a
nav naming a group we haven't been told about is parked and replayed the
moment the delta lands. Authoring parks it for the same reason: a draft
detaches you from the party so hunting for marks is nobody else's business,
and closing the form replays the park, putting you exactly where the party got
to. Deleting a timeline someone is standing in now pops them out too.
Playback ticks stay off the wire — every member runs the same clip off its own
clock, so streaming positions would only fight them. Only deliberate moves and
transport changes go out, and a play we started because a peer did isn't
echoed back at them.
The socket now reconnects with backoff and, on the way back, re-states the
party id it was carrying and pulls the scene it missed. That catch-up is
deliberately additive: "absent from the server" can also mean "saved a moment
ago", and a wrong deletion costs someone their work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
|
|
|
await self.send(text_data=json.dumps(event["delta"]))
|