# Road to Branchly

> A validation function that returns { valid: false, unreachable: ['q3', 'q5'] } is doing something different from one that returns false. The first tells the author which nodes are disconnected; the second tells them that something is wrong somewhere.

Source: https://saascode.ai/inside/road-to-branchly · Published: 2026-08-29 · Section: builds · Product: Branchly (https://saascode.ai/products/branchly)

---
A validation function that returns `{ valid: false, unreachable: ['q3', 'q5'] }` is doing something different from one that returns `false`. The first tells the author which nodes are disconnected; the second tells them that something is wrong somewhere. `validateGraph()` in the graph engine returns the former — an object whose three arrays carry the offending node IDs — and that choice is the clearest design decision in the build.

The function is pure: no database access, no I/O, one honest boundary documented in its own comment. Reachability is a breadth-first search from the start node that deliberately ignores edge conditions. A node sitting behind a condition that can never be satisfied still counts as reachable — the check answers "is this node wired into the graph", not "will a respondent ever reach it in practice". Saying that plainly is the kind of precision this codebase is for. Three consumers call the same function: the publish endpoint, the AI generation pipeline, and the canvas editor's lighter advisory pass. None of them gets a different guarantee.

Branchly is a visual node-canvas builder for branching interactive experiences — quizzes, conversational forms, onboarding flows, assessments. Creators design the full decision tree as a spatial surface; an anonymous player walks the graph with the server deciding every branch and every point; an AI generator produces a complete playable graph from a prompt. Any published experience can be embedded on any third-party site from a 4 KB script tag.

## Starting point

The market read pointed at a specific gap: the whole branching tree, visible and editable at once on a freeform canvas, with the graph topology itself as the primary authoring object. The canvas library choice followed from a business constraint. tldraw carries a proprietary per-buyer licence that conflicts with a source-sold product, so we pinned `@xyflow/react` (MIT), which ships the node and edge primitives without usage restrictions.

Canvas state is a JSONB hybrid: nodes, edges, viewport, and settings live on one row, not as individual database records. That simplifies the save path to a single upsert, keeps the optimistic edit model tractable, and means the decoder can be written defensively — a non-array `nodes` or `edges` field decodes to an empty list rather than throwing. A draft experience can contain anything, including nothing. Only publish is strict.

We had an earlier internal build to learn from — a product that shipped the same canvas-plus-widget primitives but with three verified defects: a preview stub where a real player should have been, non-atomic response metering that could drift under concurrent writes, and an analytics aggregator that silently stopped at 200 responses. Branchly's spec was written as an explicit inversion of all three, and all three were verified at the debug phase.

The player's interaction model took Typeform as a reference for feel — full-screen single-question presentation, deliberate entrance motion, keyboard-first, mobile-optimised. That is where the reference stops. Branchly's canvas authors the full branching structure in 2D; the player follows whatever path the graph defines; the colour palette is coral-on-white.

## Reading the market

We checked every significant interactive-experience builder for a freeform node-canvas in mid-2026. The verdict was consistent: sidebar conditional logic, sequential step lists, and fixed left-to-right layout maps. Typeform's Logic Map arranges questions sequentially, the layout is fixed, and complex trees hit a documented "too many logic rules to visualize" constraint. Tally and involve.me use per-question sidebar rules on a linear form. Outgrow's branching is sidebar-configured, with reviewers citing a high learning curve for complex paths. The closest partial is Interact, which ships a visual, zoomable branching map — but it is quiz-only, questions sit in a structured grid rather than free positions, and there is no freeform placement of logic-only nodes. Storylane branches, but it is a product-demo tool that captures a real application; Branchly builds an authored graph. Different mechanism, different category.

AI generation to a form prompt is now table stakes in this category. The differentiator is not that capability in isolation but AI generation to a graph that lands on the canvas as an editable spatial object — and that has to pass the same structural validation a hand-built graph does, or it is refused rather than saved.

The real economic constraint worth understanding in this category is the live-artifact cap, not per-response pricing. involve.me limits the number of live funnels at their entry paid tier; that is the constraint their users navigate. Branchly's quota model is per organisation, resolved from that organisation's plan, enforced atomically by the operator who owns the deployment. The ceiling is not set by someone else's pricing page.

## The decisions that shaped it

**Canvas state is one row.** Storing nodes and edges as JSONB on a single experience record keeps the optimistic save model simple. It also means the decoder can be explicitly defensive — partial or malformed graphs decode to empty lists rather than throwing — which lets draft authoring be completely unrestricted. Only the publish gate applies hard rules. The editor runs a separate, deliberately weaker canvas check that powers a live "all reachable / N unreachable" status line in the footer, updating as you wire nodes together, without ever interrupting the authoring flow.

**The server decides every branch.** The client submits `{ node_id, value }` and waits. The server evaluates the outgoing edges in order — the first condition that satisfies wins — and returns the next node or the terminal result. Score accumulation and variable resolution happen on the server. The client never computes where the respondent goes next. This makes scoring verifiable rather than manipulable, and it means the embed player, the metered public player, and the preview player share the same traversal rules even though they are three separate implementations bound to three separate contracts.

**Dead-end detection knows the difference between a result screen and an unfinished question.** A result screen with no outgoing edge is a correct terminal state; a question node with no outgoing edge is a defect. The validator checks for an explicit terminal flag or a result-kind node before classifying a zero-edge node as a dead end. Cycles are handled by construction rather than by a special case: the BFS keeps a visited set and only enqueues a target it has not already seen, so a loop terminates on its own and is never reported as an error. Looping back to an earlier question is a legitimate authoring pattern.

**Metering is one atomic statement.** The gating function is a `SECURITY DEFINER` SQL RPC whose entire logic is a single conditional UPDATE: increment the response count where the count is below the limit, return whether it updated. There is no read, no subsequent write, no window between them. The 80% and 100% threshold alerts flip inside the same statement — each fires exactly once per period because the flag is set atomically on the response that first crosses the line.

**The embed is an iframe behind a 4 KB script, deliberately.** Shadow DOM was evaluated and rejected; an iframe has a cleaner content-security boundary and avoids CSS bleed in both directions. The script is built separately from the main application — a standalone esbuild bundle that the widget build step produces independently of the Next.js build. Triggers are click, timer, scroll-depth, and exit-intent; modes are inline, modal, and slide-over. Exit-intent is desktop-only by nature, which the documentation states.

## What fought back

**A published experience with an empty graph reached the database through the seed.** The demo experience was written directly to the database with `status=published`, an empty node array, and a start-node ID pointing at a node that did not exist. The player had nowhere to go. The publish gate did not catch it because the seed bypassed the publish endpoint entirely — it was a direct SQL insert. Fixed by authoring a real three-step branching graph in the seed, with the correct dual node taxonomy. The canvas renderer and the traversal engine each have their own node-kind field; both must be set correctly or the canvas throws "node type not found". The fix was in the seed; nothing was papered over at the API boundary.

**Five starter templates are in the same state.** The gallery templates — quiz, tour, onboarding, skills assessment, and intake — clone as empty drafts. The editor and clone path work; what is missing is seeded content for each template. That pass did not happen before the release cut and remains open.

**The embed script did not exist until the release phase.** The share page, the landing page, and the publish verb were already generating a script-tag snippet pointing at `/embed.js` weeks before the widget source was authored. The file would have 404'd for any buyer who tried the snippet. It was built at the release cut, against the player's postMessage contract, and the gap was closed before the tag. The instructive part: the missing file read like a path in every source that referenced it, and nothing in the build pipeline caught that the path was empty.

**Three player implementations stay separate, deliberately.** `ExperiencePlayer` (metered public play), `EmbedPlayer` (self-contained iframe), and `PreviewPlayer` (no-meter authoring preview) each drive their own contract. Consolidating them would require either metering the preview — which breaks the authoring loop — or adding a conditional to skip metering in the canonical player. The separation is correct; the cost is maintaining three implementations.

## What shipped

A complete multi-tenant visual experience builder: freeform canvas authoring with screen nodes and logic nodes as first-class spatial objects, a real anonymous player that walks the graph with server-side branching and scoring, AI generation from prompt to editable graph on the canvas, an embeddable widget that puts any published experience on any third-party site, pre-aggregated analytics with no response ceiling, atomic per-organisation quota enforcement, five plan tiers, and a workflow API that lets an agent drive the full create-generate-publish-retrieve cycle programmatically. Multi-organisation from day one, row-level security on every table, and 3,594 translation rows with none missing.

The honest limits: the five gallery templates need a seed-authoring pass before they clone as useful starting points; the reachability guarantee is structural rather than semantic — nodes wired in, not necessarily reachable by every real respondent; integrations are wired but inert until the operator supplies keys, each degrading to a structured failure rather than a crash; the workflow API ships no idempotency keys and no async operation model in v1.

## See it

[See Branchly →](https://branchly.saascode.ai)

## Related reading

- [Branchly vs Typebot](https://saascode.ai/inside/branchly-vs-typebot.md)
- [Quiz and interactive-content platforms compared: which ceiling will you hit first?](https://saascode.ai/inside/quiz-and-interactive-content-platforms-compared-which-ceiling-will-you.md)
