title
Userspace Propagators
dated
April-Juli '26
This labnote is a progress report on the first step towards adding propagator networks into Playbook.
The current prototype uses basic nodes and wires, which are both implemented as reusable userspace parts. The goal of this prototype is to explore the basic primitives for propagator networks in Playbook. I’ve attempted to get as much as possible into userspace. Both the visual & interaction design can be improved, but I think the general direction is promising.
The ambition is that the propagator networks essentially function as a lower level language that can be used to implement more ‘concrete’ programming primitives. For instance, the draw arrow is currently a built-in part, but it could be implemented in userspace using a propagator network. This would give us a nice ladder of abstraction, from ‘materials’ to ‘dynamic parts’, to ‘propagator networks’, to, potentially, something like JS at the very bottom.
Here’s a simple distance constraint implemented with a propagator. The distance node connects with wires to two handles, which are kept at a fixed distance. The distance is specified by the value in the number node. You can drag the handles around, and the distance propagator will try to keep them at the specified distance. You can scrub the distance number, which will update the distance between the two handles.
The main approach we’ve used in previous prototypes is to make the programming primitives behave very differently than ink. This is the approach we took in, for instance, Crosscut. This has some advantages. For one, it’s easy to conceptually and visually separate logic from concrete stuff. As an approach, it also pairs nicely with the idea of a ‘meta-mode’ that can be used to display and edit the logic, while keeping the logic hidden when you want to focus on the concrete stuff.
However, we also want the ability to add new nodes and wires in userspace, as well as have a large number of the ‘built-ins’ simply be ‘standard library’. For this it’s important for those standard library parts to look like you could have made them yourself. This, I think, pushes us strongly towards making the built-in programming primitives out of the same materials as everything else. That way, making new nodes and wires in userspace is the same thing as making any other kind of reusable part. So, in this prototype, both the nodes and wires are both parts which contain ink.
This means that the nodes and wires behave like any other part, and can be used in the same way. For instance, you can select them, and duplicate just like you would any other part.
Because nodes & wires have been set to hidden by default, when you wrap them up in a new part, the internal nodes will be hidden, and only the concrete ink will be visible, which is how you make a re-usable part.
Inside the nodes, drawn with ink, there is a box, as well as a text label. There are also some other parts called ports which we use to wire to and from.
The ports are basically just handles, and it remains tbd if we want them to be a special kind of handle, or just a regular handle with perhaps some extra properties.
Finally, for any built-in nodes, there is an escape hatch ‘node’ entity, which represents the nodes implementation. This is where the system bottoms out. It is how we make nodes feel like they’ve been made in user space on the outside. Internally, the node entity is associated with some javascript, so in principle we can surface that to the end user as well. It seems also entirely plausible that we could have nodes be implemented on the fly by llms.
A wire is also just a component. Inside of it are: the line segment, two handles, as well as a ‘wire’ escape hatch, that implements the wire behaviour. This means that you can, if you wish, create wires out of any component with two handles. This makes it possible to have wires in many shapes. It also means that, we can use the same primitives to layout ink as well as wires. There is, for instance, nothing stopping you from applying a distance constraint to a wire.
You can create your own nodes, simply by wrapping up some network in a new part, and drawing a box for your new node. You add ‘ports’ to your new node, which you essentially just wire to from the internal network.
The handles for ports and wires behave a bit differently than regular handles. Although I find this somewhat unsatisfying, it’s needed for the wiring to feel good. When you’re snapping together together two line segments, this feels like a symmetrical relation. But when you snap a wire to a line segment, that is an asymmetrical relationship. I don’t want the wire to disconnect when moving the line segment, but I do want it to disconnect when dragging the wire. It just feels wrong otherwise. Similarly, handles inside ports are snapable but they’re not movable from the outside. You want wires to move along with the node when you move it, but you don’t want nodes to move when you move the wire.
In order to make things like ‘snapping’ handles across different parts possible, we first convert handles local positions into a global position. That is, we run the solver in a single global space, rather than bothering with scenegraph transforms inside the solver.
This what enables the oriented line constraint, which can respond in different ways depending on user input.
If the user directly drags one of the handles, it’s position is set directly by the user. The propagator doesn’t push back on the directly dragged endpoint and instead corrects only the opposite endpoint, keeping the orientation of the line fixed. Other hard constraints can still override the result (see the section on compliance below). For example, if an endpoint is close enough to a snap target, the snap propagator emits a hard position.
If the user indirectly drags both handles and rotates them, the propagator doesn’t try to correct the angle, and instead updates the internal angle value instead. The angle, in this case being measured in global space, so you can rotate any parent element, and the propagator will still function correctly.
This gives us an oriented line with a variable length, which can still be rotated with the rotation gesture, which is quite useful for all kinds of things.
In order to make propagators and the interactions work well for things like geometric constraints, and user interactions, I added the notion of compliance.
Conceptually, each node can add a contribution to a cell (a value). In the basic case of directional spreadsheet-like computation, each node simply adds a contribution to their output cell, and the cell simply assumes the value of that contribution. However, once multiple nodes are connected to the same cell, the cell needs to decide how to combine those contributions.
So, each contribution also has a compliance value, which can be set to either fixed, hard, input, or soft. We use these to decide which contribution to use, and how to combine them. The important nuance is that these are not a simple total ordering.
fixed always wins If at least one fixed contribution is present, every other contribution is discarded This is how a port stays anchored to its part while other pins snap onto it. A constraint cannot pull the fixed port away.
input indicates the user is directly setting the value. If there is an input contribution, the selection becomes: hard > input > soft This gives two important interaction behaviours:
input beats soft.hard beats input.input contribution is present, hard does not outrank soft. Instead, all non-fixed contributions are passed to the cell’s merge function. So, “hard” currently means “override direct manipulation” not “must always be satisfied exactly.”