Contents

Writing a script together, live

Status: built, 2026-10-11 (see "So far" at the end). The third product audit's finding 14 (docs/audits/product-audit-2026-10d.md): on a shared show two writers on one script are merged line by line when they save, with a conflict view where they both changed the same lines (docs/design/m13-shared-shows.md, "Scripts"). WriterDuet, Final Draft 13's collaboration and Google Docs let people type in one script together and see each other's carets. For a writers' room that is the difference between a tool and a file share. This note says how Studio's script page (docs/design/script-page-editing.md) becomes a place several people write at once.

What a person sees

Ada opens the Lighthouse's script on a show she shares with Bo. Bo has it open too. Ada sees Bo's caret on the page in his colour with his name over it, and the lines he selects tinted in it. What Bo types appears on Ada's page as he types it, a fraction of a second later, and hers on his. The bar over the script says who else is writing in it ("Bo is here"). Ctrl+Z takes back Ada's own typing, never Bo's. Nobody has to save: the show's file has what they wrote a moment after they stop, so the reading, the director, Claude and a teammate's desktop Studio all read it. Save marks a version in History ("Mark a version"); one is also kept when the last writer leaves and after five quiet minutes. A viewer sees the writing as it happens and cannot type; a reviewer can add notes to lines, as now. If Ada's network drops, she goes on typing; when it is back, what she typed goes in where she typed it, once, and she sees what Bo wrote meanwhile.

The sync model: one daemon orders every edit

Central-server operational transformation over plain text, the Jupiter protocol as Google Docs, ot.js and ShareDB's text type run it.

Every shared show already has exactly one daemon, which everyone in it talks to through the service (docs/design/m12-sharing.md). That daemon is a natural single place to put every edit in one order. With one orderer, OT needs only the simple transformation property (TP1: two edits made at once give the same text whichever way round they are applied), which the transform below has and the property tests prove. The text is the whole document: no ids per letter, no tombstones, nothing that grows with the script's history. The daemon's text is a string the daemon writes to the file as it is, so the file and what everyone sees are the same bytes.

We looked at the CRDTs the brief names:

  • Yjs (MIT; yjs 13.6 is about 70 KB minified, with lib0, MIT). The daemon side would be easy. Studio is GDScript, on the desktop and in the browser's web export alike, so its side would be a port of Yjs's update encoding and its YATA struct store: several thousand lines to keep in step with upstream, for a topology that never needs two peers to merge without a server. Its documents keep deleted items, so a script's live state outgrows its text, and the .fountain file would become an export of the CRDT rather than the truth.
  • Automerge (MIT) is a Rust core shipped as WebAssembly. GDScript cannot run it without a GDExtension built for each platform and a separate path for the web export.

OT here is about 300 lines a side, the same algorithm in TypeScript (core/src/studio/live/) and GDScript (RwTextOp, RwLiveScript), and the two are held to each other by vectors the TypeScript side writes and the engine test reads. No new dependency.

Text and positions

  • A script is its Fountain text. Positions count Unicode code points, as Godot's String does (JavaScript counts UTF-16 units, so the core never uses .length on script text).
  • Line breaks are LF. Godot's text box drops every CR (measured: "a\r\nb\rc" reads back as "a\nbc"), so a CR could never be what anyone sees. The live text reads a file's CRLF as LF and drops a lone CR; the first write from the live script is LF, as Studio's editor already does today.

Operations

An edit is an operation over the whole text, a JSON array of components, as ot.js has them: a positive integer keeps that many code points, a string inserts itself, a negative integer deletes that many. [12, "Bo ", -3, 400] keeps 12, inserts "Bo ", deletes 3, keeps the 400 left; the components' kept and deleted lengths sum to the text's length. apply, compose, transform(a, b) (a's inserts go first where both insert at one place), invert and transformIndex (a caret through an edit) are the whole algebra.

The client

Each Studio holds the script as the daemon last confirmed it (base, at revision rev) and its own edits not confirmed yet (pending), in order. Only the first pending edit is ever on the wire; edits typed while it is out are composed into the next one. When the daemon confirms it (its new revision), the next goes. An edit from someone else, at revision rev + 1, is transformed against every pending edit (the pending edit counts as first, as the daemon counts it) and applied to the text box. Messages carry their revision, so a repeat is dropped and one early waits for its turn.

The daemon

A live script (LiveScript) per script path that someone has joined: the text, the revision, the edits since the oldest a client may still be behind (up to 5,000), and for each client the last edit it applied (client id and sequence number). An edit made from revision r is transformed against the edits after r, applied, logged, journalled, confirmed to its sender and sent to everyone else in the script.

The protocol: session protocol 1.3

Minor version 1.3 adds only methods, notifications and one capability (ADR-0005). The daemon says cowrite in the hello's capabilities when it keeps live scripts for this connection: on the web service, where the hello names a person. An older Studio never asks for it and saves as before; a newer Studio on an older daemon sees no cowrite and saves as before.

Method Access Params Result
screenplays.join read path, client (an id Studio makes when it opens the script, kept with its draft), optional resume (epoch, rev, base, pending: [{seq, op}]) epoch, rev, text (a fresh join) or ops to catch up with, dirty, carets
screenplays.edit write path, client, epoch, rev, seq, op, optional caret after it rev it became
screenplays.caret read path, client, rev, carets (anchor, head each) {}
screenplays.save write path, client, optional version (false: only write the file now) hash, rev, text, summary, the version made
screenplays.leave read path, client {}
Notification Carries
screenplay.event path, epoch, rev, and an edit (op, client, seq, by), or the file written (saved: hash), or a version kept (version: id, name, by), or an outside change merged in (op, outside: true); always dirty (the file does not have the text at rev yet)
screenplay.carets path, rev, each other person's client, id, name, color and carets

epoch names one run of a live script's journal: a client from another epoch, or behind the oldest edit kept, catches up the slow way (below). The access column is the method classes of docs/design/m13-shared-shows.md: a viewer joins and watches and its caret is shown, a reviewer the same and writes notes (comments.*, as now), an editor and the owner type and mark versions. The daemon refuses an edit from a viewer (session.read_only) or a reviewer (session.comment_only) whatever Studio shows.

The service needs nothing new: it pipes each socket to the show's daemon frame for frame (core/src/host/server.ts, #pipe), and a show on one of the person's computers goes over its runner's socket the same way.

Never lost, never doubled

  • A dropped socket. Studio keeps base, rev and pending and reconnects as now (RwSessionClient). Its screenplays.join resumes from its epoch and revision: the daemon sends the edits after it, its own among them, which count as confirmed. Then it sends what is still pending. Each edit has a sequence number per client, and the daemon applies a number once: an edit sent again whose confirmation was lost is confirmed again, not applied again.
  • Typing offline. Edits wait in pending, composed into one, and go when the socket is back, as above. Studio's draft of the show (get_state) keeps client, epoch, rev, base and pending, so a Studio closed offline resumes them when it opens the show again.
  • Too far behind, or the daemon's journal is gone. The join sends base and pending whole. The daemon skips each pending edit it already applied, takes the difference from the rest of base to its text as one edit from someone else (D), transforms the pending edits against it exactly as the client does, and applies them; the client is sent D then confirmations. Edits are only ever applied through a transform, so nothing is applied twice.
  • The daemon stops or crashes. Every edit is appended to the live script's journal before it is confirmed or sent on, so an edit anyone saw is kept. A daemon started again reads the journal and goes on in the same epoch.
  • Typing at one spot. Two people typing at the same place each keep their words whole. Studio tells a keystroke as one stretch between what the text shares before and after it (RwTextOp.diff; the core's typedOp). Where a key could stand in more than one place, a "T" typed just before the "T" of "Title" or Enter before an empty line, the caret decides: an insert ends at it, a deletion starts at it. Placed by the shared start alone, the "T" counted as typed after Title's own, and letters someone else typed at that spot meanwhile went in between: in the two Studios check (2026-10-13), Bo's whole sentence inside Ada's word. core/test/live-race.test.ts races two writers at one spot over the daemon in 80 orders.

Saving, and what is in the file

  • Typing changes the live script, shared by everyone in it, and the file is written as people write: two seconds after the last edit (WRITE_AFTER_MS), and at once when the last writer leaves (a viewer or a reviewer staying does not hold it back). Everything downstream reads the file (the reading, the director, Claude's tools, a render, a teammate's desktop Studio), and nothing written waits for a click. The write is Studio's own, so its doc.event is not "changed outside Reelwright". Studio shows no unsaved dot for a live script; the dot is only for typing that waits for the connection to come back.
  • The journal (.reelwright/live/<hash of the path>.json and .lines.jsonl beside it: the last snapshot and every change since, an edit or what the file holds now) holds what is written across stops and restarts, the two seconds before a write included: a daemon that stopped first writes the file as it opens the script again. It is never in a Show Pack.
  • Versions in History are kept apart from the file: one when someone presses Save (Ctrl+S; the button says "Mark a version"), named "The Lighthouse script, kept by Ada"; one when the last writer leaves ("…, as its writers left it"); and one after five minutes without an edit (QUIET_MS, "…, after five quiet minutes"). Each says who wrote since the last ("Written by Ada and Bo."), is by the person who saved (or left, or wrote last), and commits the script in the show's own repository as them. A trigger with nothing new since the last version makes none. Everyone in the script hears of a version ("Bo kept a version in History: …").
  • Direct, a draft locked and a note given have the file written now first (screenplays.save with version: false), so they act on the script as everyone has it.
  • A change from outside (the showrunner's approved rewrite, Claude Code, a version restored, a change undone from History, a hand edit) comes in as an edit: the daemon takes the difference from the text the file last had to the file's, transforms it against the writing not written yet (where both write at one place, the writing goes first, so a change never comes between a cue and the line someone is typing under it) and applies it for everyone (the change's own change.event says who). Nothing is overwritten and nobody is asked to choose: this is a merge by letters, not by lines. Every difference keeps every letter the two texts share (a diff by letters over the whole text, merge.ts's Myers), never deleting and writing one again: two differences that both wrote a shared letter again would each insert it, and it would be there twice.
  • The file is the truth: a live script starts from the file, and when the file has moved on while nobody was in the script, any writing the journal has that the file did not is merged with it the same way when someone opens it.

A proposal approved against a live script

A live script's file is written as people type, so a proposal the showrunner made for it (its write_screenplay) would almost always find the file moved by approval time, and the store refused such a change as stale (the lead's decision, 2026-10-12: approve it as a change from outside instead).

  • At approval a change that only writes scripts (.fountain text) and whose script moved since it was proposed is merged, not refused (ChangeStore.#mergeScripts, live/proposal-merge.ts): from the text the proposal was made from (kept by the store), to the file as people left it, and to the proposal's text, by letters. Where both added at one place, the people's text goes first, as for any change from outside.
  • A paragraph both changed (a Fountain line: an action paragraph, a line of dialogue, a heading) where the proposal changes letters someone changed since is not applied: the person's text stays, and the change records both texts (merged on the change set: the script, and for each place the line, what was proposed, and the text kept), for the approval to show. The rest of the proposal goes in around it, by letters.
  • The file is written with the merge, as the approval always wrote it, and the change says it wrote that text, so undo takes back exactly it. The live room then merges the file with any typing not written yet, as a change from outside: nothing anyone typed is overwritten.
  • At proposal time a script-only proposal is no longer refused when its script was written while the proposal was being made.
  • A change that writes more than scripts (direct_episode, whose episode was directed from the proposal's text) is still refused as stale when its script moved: ask again.
  • The daemon writes the script first when a change for a live script is approved (changes.approve), so the merge sees everything typed so far, not only what the last autosave wrote.
  • The approval says so (session protocol 1.4): changes.approve and changes.get give merged, each script and its kept places. Studio's proposal view lists them under "Kept as people wrote it" (the line, the text kept, what was proposed), its note after Approve says how many places, and the chat card says so too.

Reading a proposal for a script

A reviewer has to see what Claude changed in a script. A script's change used to reach Studio as one field, the whole text before and after, each cut at 20,000 letters: Studio struck the whole script out and wrote the whole new one under it, and could not see a change past the cut at all (session protocol 1.5, 2026-10-12):

  • The core says it by line (core/src/studio/changes/text-diff.ts, DocDiff.text): the changed paragraphs with two lines around each, each unchanged run between them counted, not sent, and each line the same, taken out, added, or changed (both texts, paired in order within a change). Up to 2,000 lines are carried; a longer change says so. A client of 1.5 or later gets a script's change by line only: the old field's whole text, cut at 20,000 letters, still goes to older clients, never twice to a new one. A 150-page script (196,089 letters, 8,260 lines) changed in three places is 1,154 bytes by line; changes.get's diffs for it over a real daemon about 1,100.
  • Studio shows it in one column (RwScriptDiff): each hunk's lines with their numbers, a line saying each unchanged run ("792 lines unchanged"), a lightly changed line with its words marked in place and one rewritten whole as it was then as it is. The text wraps in one column, on a desktop and on a phone (390): the page's head wraps the line over its name and puts Approve under the words where they cannot sit side by side (RwPageHeader).
  • The places an approval kept as people wrote them sit at their lines among the hunks. After such an approval the proposal is fetched again: its lines are then what the approval wrote to the file as people left it, the same lines the kept places count.

One script diff everywhere

A script's change reads the same wherever Studio shows one (session protocol 1.9, 2026-10-13). History's comparison of a version with now (or with another version) and a review's Changes (v2 against v1) showed a script as bare line stretches, as it was then as it is, with nothing around them; a proposal already read as changed paragraphs in their places.

  • Asked by line (byLine on versions.diff and renders.changes): the script's change comes as a proposal's does (text, the same textDiff), and its line stretches keep their pointers alone, so nothing is sent twice. It is the client's choice, not the protocol's: the script editor's revision marks put the locked draft back from those stretches and still ask for them. Studio asks so only of a daemon that says 1.9 or later (RwSessionClient.speaks(9)); an older daemon's stretches show as they always did. A 150-page script (210,037 letters) with two edits: 1,288 bytes from versions.diff and 1,310 from renders.changes, by line.
  • Studio shows it with RwScriptDiff in both: History's detail under the document's name, the review's Changes tab under "Script: N lines in, M out". One column of wrapped lines that never widens the page: History at its narrowest (an upright tablet; on a phone History says it needs a bigger screen), and the review's Changes on a phone, a tab of its Notes (the bar at Studio's foot).
  • CRLF reads as LF in the line diff: a script saved on Windows differs only where its words do.

Presence

  • Carets and selections, each person's, in their colour (the service's personColor, the same as everywhere in Studio), with their name over the caret: on the page view through the page map, and in the source view. Studio sends its caret at most every 50 ms and with each edit; the daemon sends a script's carets at most every 50 ms. Carets are carried through every edit on both sides, so they stay on their letters between messages.
  • Who is in the script, in the bar over it: "Bo is here", "Bo and Cy are here", their colours. Leaving or dropping takes them off.
  • Hidden in the show's other places, as session.people already does.

Undo

Ctrl+Z and Ctrl+Shift+Z in a live script are the person's own edits only. Studio keeps its own undo and redo of the edits it made (typing a word at a time: a space or a line end begins the next step, however many keys a frame brought, so the steps never depend on how fast the computer is), and transforms both stacks through every edit from someone else, as ot.js's undo manager does. Undoing is a new edit everyone sees. The text box's own undo is emptied after every edit, so it can never take back someone else's.

The merge on save, and the conflict view

They stay, for what is not a live script: a show on this computer, a script not saved yet, and a Studio or service from before 1.3. In a live script they never run: everyone's edits meet in the daemon as they are typed, and outside changes are merged by letters. editing ("Ada is editing this script") stays for the same cases.

Where it is on

Shows on Reelwright (the hello names the person), in both editions. A show on this computer has one writer and its daemon names nobody, so its editor is as it is now. Every script on the service is a live script, whoever is in it, so the second writer to open one finds it as live as the first.

Steps and proof

  1. The core (core/src/studio/live/): the operations, the live script and its journal, a reference client. Property tests: three clients typing at random at once, with dropped sockets, offline typing, a daemon stopped and started from its journal, outside changes and time passing, always end with every client's text equal to the daemon's and to the file; with inserts only, every letter typed is there exactly once; the GDScript vectors.
  2. The daemon and the service: the methods, refusals by role, carets, outside changes; a test with three sockets on a real daemon, and one through a real host.
  3. Studio: RwTextOp, RwLiveScript, the script editor in live mode (local edits from the text box, others' edits into it, carets on the page and in the source, the bar's people, own undo, save and direct, drafts), in both editions.
  4. Two real Studios against a local host in one check: two installed Studios, then the web edition (by name, tools/web/names.ts) with an installed one, typing in one script at once; every letter in both and in the file, nobody saving.
  5. A mutation check per guarantee: convergence (the transform's tie-break), never doubled (the sequence check), never lost (the journal before the confirmation), own undo (the stacks' transform), roles (the edit's access), the file (written a moment after the last edit, and at once when the last writer leaves), versions (one on each trigger, none with nothing new).

Not in this step

An input method's unfinished text is not sent until it is done. A script past 400,000 characters is not live. Carets of more than 64 people are not shown. Comments stay anchored by their quoted lines, as now, not to live positions.

So far

Built on 2026-10-11, on branch co-write:

  • The core (core/src/studio/live/): text-op.ts (the edits: apply, compose, transform, invert, a caret carried, the letter diff), live-script.ts (LiveScript: the text, the revisions, the log, each client's last edit, joins the quick way and the slow way, outside changes, saves), journal.ts (LiveJournal: the snapshot and its lines, read at the edge, a torn line ending the replay, an unreadable snapshot put aside), rooms.ts (LiveScripts: the scripts of a show and who is in each, carets at most every 50 ms, the file written two seconds after the last edit and at once when the last writer leaves, versions on Save, on leaving and after five quiet minutes, one after another), client.ts (LiveClient, the client the tests run and Studio's is held to).
  • The daemon (core/src/session/daemon.ts): the five methods, the two notifications and the cowrite capability where the hello names a person; a socket that closes leaves its scripts; doc.event and an applied change.event for a .fountain file merge it in.
  • Studio: RwTextOp, RwLiveClient and RwLiveScript (engine/addons/reelwright/ui/ script/); the script editor live on a show on Reelwright (RwScriptEditor._go_live): another's edit into the text box from its end back (_apply_to_text, carets and selections kept), the person's typing taken as edits at each change (RwTextOp.diff, one stretch, cut into its words), undo steps a word at a time, the text box's own undo kept empty, Edit › Undo through the text box's own_undo meta (rw_studio_commands.gd), carets and names on the page and in the source, "Bo is here" beside the title, "Mark a version" and Direct through screenplays.save, no unsaved dot but for typing offline, a draft's live place resumed, a failure or a deleted file falling back to the editor as it was.
  • Proof: core/test/live-text-op.test.ts; live-scripts.test.ts (three writers over a simulated network with drops, offline typing, restarts from the journal, outside changes, time passing and few edits kept, so the slow way runs often: all converge to the daemon's text and the file is it, nobody saving; with inserts only every letter is there once; Undo takes back only the writer's own and Redo puts it back once); live-journal.test.ts; live-daemon.test.ts (real sockets on a real daemon, every message checked against the schema); host-live.test.ts (through a real host to serve --grant-fd); live-vectors.test.ts and engine/tests/ui/rw_live_client_test.gd (the GDScript held to the core, edit by edit and call by call); engine/tests/ui/rw_live_script_test.gd; tools/web/cowrite-check.ts (headless editors on a real daemon, two installed Studios and the web edition with an installed Studio through a real service). Mutation checks: the client's pending edit counted second, the slow way applying an edit again, the journal keeping nothing, the undo stacks not carried, a diff by one stretch, an edit open to a viewer, a change from outside put before the writing at the same place, the keys of one frame kept as one edit: each fails a test.
  • Autosave and versions (2026-10-11, the lead's decision): live-journal.test.ts on a clock the test moves (the file not written before two seconds after the last edit, written then, put off by more typing, written at once when the last writer leaves; one version on Save, on the last writer leaving with a viewer staying, and after five quiet minutes, none with nothing new), live-daemon.test.ts on a real daemon (the file within seconds, Bo's version in versions.list, the quiet and leaving versions), each trigger mutation-checked.
  • Measured: typing reached the other editor in 74–148 ms on a real daemon (2026-10-12, the box at load 50 on 24 cores), and the file had it 1.9 s after the last edit; on a 205,000-letter script a keystroke's live work takes about half a millisecond, its line starts about one.