Tenfold is a quirky little stew of a project, touched by many hands. We put all ten kinds of design in it: visual design, user interface design, API design, and then the half-dozen known forms of “social” design — the design of creative constraints, personality, self-revealing functionality, false walls, sticky friction, and spite, seeping into every facet from DX to docs. Todd outlined the visual design, I’ll shade in the rest.
Background: Todd tapped me for Tenfold in mid-November '25, shortly after we’d wrapped the new Automerge
Example Design Decisions for Exemplary Clarity
- Our APIs only let you draw white lines and arcs of a preordained thickness.
- You can draw outside the area apportioned to your letter, but the coordinates will subconsciously hint you away from this (see discussion of “clip space” below).
- It would be nice for non-programmers to be able to make letters too. But hand-drawn letters might be aesthetically at odds with the code-generated ones, and might be hard to animate or print. But if you can get your paws on an SVG-to-canvas renderer and you possess judicious taste, then I’m not going to stop you (Todd).
- There’s a balance between free expression and legibility. For now, you can experiment freely and we will only delicately hint that your letter best look like a letter. Later, we may legislate it — stay on our good side, mm?
An excerpt from the docs, showing how we define space for each letter. The coordinates hint you toward drawing something centered and symmetrical (because 0,0 is in the middle) that comfortably fills the space (because coords extend to a nice round 1 at each edge).
API Design
Tenfold uses my beloved 2d <canvas> for rendering, encapsulated behind our own lil drawing API. This API is written firstly for ergonomics, with sane defaults and helpful helpers, but also firstly to creative constrain you. Why? Several of the people participating in this project have never before created this kind of procedural art; an environment that feels shallow (even if only superficially) is less intimidating than one that feels unknowable in its vastness. That’s the most important part of a creative constraint — the way it frees you from the many hesitations of the blank page.
The drawing API offers these parameters so that letters can be wiggly and ticklish:
time— slowly climbs from 0 and 1 like a sawtooth, snapping back to 0 every ~8 secondsxandy— the last known position where someone tapped/dragged on your letter- some other stuff (shh not important — giant winking asterisk)
The Tenfold playground. Docs on the right, replaced with an editor when you select a letter. Note that the controls in the second row are quite different from the final version. There was a little draggable area full of “waffles” which were a second set of x/y params available to each letter. Letters rarely used both sets of x/y params, so we removed the waffles.
So you’re gonna make a letter. You have a little preview of the entirety of Tenfold on the left, and a little docs viewer / JS editor on the right. You select which letter variant you want to edit, and then write some JS. You do a bit of math, some loops, eventually issue drawing commands. All the code for your letter is re-evaluated every time the browser renders a frame of animation, and it draws whatever stuff you tell it to draw. Here’s an example:
// Draw a circle at 0,0 (centered within the space for your letter),
// with a radius of 1 (so it just touches the edges of the space)
circle(0, 0, 1)
// Say hi to several friends in different corners
text("hi elliot", -1, -1) // top left
text("hi beano", +1, -1) // top right
text("hi chee", -1, +1) // bottom left
text("hi ivy", +1, +1) // bottom right
// Draw a circle, complicated version
let radius = 1
let p = {x:0, y:0}
for (let angle = 0; angle < 360; angle++) {
let x = p.x + cos(angle * PI/180) * radius
let y = p.y + sin(angle * PI/180) * radius
if (angle == 0) move(x, y)
else line(x, y)
}
If you’ve ever drawn graphics with code, that complicated circle should feel familiar. For me, it’s chock with pet peeves. So when I designed the Tenfold drawing API, I tried something a tiny bit nicer. While that above code would totally work in Tenfold, here’s the idiomatic way to write it:
for (let angle = 0; angle < 1; angle += 0.01) {
let x = p.x + cosn(angle) * radius
let y = p.y + sinn(angle) * radius
line(x, y)
}
- Using
cosnandsinn, you specify the angle in turns rather than radians. This lets you work with numbers that are normalized between 0 and 1, like (say) thetimeparameter, quite naturally. - Instead of the typical
<canvas>where you must callmoveTo(x, y)beforelineTo(x, y), we just assume that the first time you callline(x, y)you’re specifying where the line begins. If you want to pick up the pen and start a new line, you callbegin()without any argument, ormove(x, y)if you already know where you wanna go.
The Tenfold API is full of these little considerations. Nice defaults. Normalized values. Charming helpers for common math (reliable mod, range conversion, random in range) and transforms (translate, rotaten which is rotate-by-turns and is perhaps the most important git commit in all of history, or so I’ve been assured).
Normalized and “clip” number ranges
Let’s look at numbers. The happy path is to stick with numbers between 0 and 1 (normalized) or between –1 and 1 (clip). What’s nice about these numbers is how they behave under multiplication. Multiplying normalized numbers always gives you a normalized number. Multiplying clip numbers always gives you a clip number. And, it follows, raising a normalized or clip number to any exponent always gives you a normalized or clip number. This makes it really easy to use multiplication and exponentiation to play with, say, the way the numbers combine and then curve as they move from one end of the range to another.
// params.t is the current time, a normalized number
// that rises from 0 to 1 every eight seconds or so
let easeIn = params.t ** 2
let easeOut = params.t ** .5
If something about this graph makes you mad — other than the font — I regret to inform you that you and I are the same kind of graphics witch and are now friends. If not, carry on, nothing to see here.
We also have functions for mapping a given number (v) with respect to various ranges.
norm(v, lo = -1, hi = 1)— Convert from lo–hi to norm (0–1).clip(v, lo = 0, hi = 1)— Convert from lo–hi to clip (-1–1).denorm(v, lo = -1, hi = 1)— Convert from norm to lo–hi. AKAlerp .declip(v, lo = 0, hi = 1)— Convert from clip to lo–hi.clamp(v, lo = -1, hi = 1)— Limits the value to be between lo and hi.renorm(v, lo, hi, LO, HI, doClamp = false)— This function combines all the above. Takes a value relative to lo–hi, and remaps it to be relative to LO–HI. If doClamp is true, the result will be clamped to the range LO–HI.
Closet Full of 🩻 Skeletons
What about the bad stuff? What about the problems and paper cuts? I thought this was research software, where are the science crimes?
In the playground, every change you make is dutifully noted in an Automerge doc. This doc is immediately (ie: before the next frame) synced to a service worker, which syncs it to a sync server, which syncs it to everyone else. This is lovely — during our “art week” of hacking on letters, we’d form up in little polycules (is that the word?) and work on our letters “together”, hanging out on a call, swapping lil helper functions and venting those little creative frustrations that are so life-giving.
But then something started to happen. Someone would write a loop, and want to change the loop terminating condition. Eg, they might have this:
for (let i = 1; i < 1000; i++)
Then they might delete the < 1000 before typing something else, giving this:
for (let i = 1; i ; i++)
Immediately, before the next frame, this would sync to a service worker, a sync server, and then everyone else working in the playground. Then on the next frame, this code would be evaluated, and loop infinitely, locking-up every instance of the playground that happened to have this letter open.
This lil bomb caterpillar hit me first, and I thought “hmm better not do that again.” Then it hit me again. Then someone else, and someone else. Alright, fine — Alex Warth helpfully ““fixed”” it by adding a little JS parser
OH, and while we’re talking science crimes, here’s how we evaluate all the code for all the letters every frame:
new Function("ctx", "params", `with (Math) { with (ctx) { ${
/* big string of your letter code */
}}}`)
Yes that’s right, double with statement, just to make extra sure browsers put us on the slow path. 🙄
Giant Winking Asterisk
Remember? Up above? When I said this:
When you write code for a letter, you’re given a handful of parameters:
time— blah blah blahxandy— blah blah blah blah- some other stuff (shh not important — giant winking asterisk)
What’s that other stuff; why is it winking?
As you saw, the code for every letter is re-evaluated every frame, inside a new Function().
……What if you need some state?! How!
The party line is: no state!! We command you to write your letter in such a way that it’s deterministic, driven solely by the time and x/y params. Like some kind of pure function.
Why? Is it because purity is good? Better for novices? Simpler, easier to reason ab—
No! Of course not, don’t be daft. The reason we didn’t permit state is that this was a natural place to add some sticky friction — a little bit of resistance that’d split people into two groups. On the one hand, we’d have people who were new to this creative space, and perhaps ought to try making something as a pure function of time, since this is a lovely lil creative challenge to experience once in one’s life. On the other hand, we’d have people who are dyed in the wool. Their time with Flash or Processing or ShaderToy helped them cry the tears which filled the mines with salt, and now they know bounds of their own sanity and are prepared for the meager all that the von Neumann architecture has to offer. They crave state, and when given state, dose themselves at a responsible rate.
But we did not offer state. Instead, we merely didn’t prevent people from finding their own little crevasses in which to stash their state. Say, by reaching for a lil something like window.tenfoldIV ??= { hey: 'cool' }. Or, hehe, grabbing the lil window.repo Automerge doc that’s part of Patchwork, or making their own Automerge doc. Life finds a way.
This sticky friction worked wonderfully, revealing false walls. After people realized “hey, you’re not blocking access to window” they thought “maybe you’re not blocking the DOM? Or anything?” then a wave of wilder experimentation kicked off. Lilith made a game engine that used the keyboard for input, and then built a little bullet hell game inside a letter. Several folks (myself included) reached for OffscreenCanvas to do some speedy rendering off to the side, blitting the result back into the main Tenfold canvas. Orion went further, cranking out a handful of wacky simulations using WebGL and WebGPU, some of which flagrantly violated the aesthetic guidelines of Tenfold by using color and pixel-scale noise, or waiting for the next microtask so that these simulations could react to the rendered content of all the other letters.
These “violations” of our guidelines were exactly what we’d hoped people would do. And everyone felt like such a little stinker when they came up with some new way to exploit the system. This is what I mean when I talk about the “social design” of the project — we made a space where people felt like they were able to get one over on us by expressing themselves more playfully than we’d intended. Classic comic
Conc
The thing that matters most to me, above everything else, is the joy of being enticed to play with a thing, learning how it works, feeling the seams, wondering how to crack it open and peek inside. The Tenfold Playground was designed in this spirit, and it seems like everyone who came had a blast. I genuinely hope you enjoyed the final result of Tenfold, and now know how to love. Don’t hesitate to say hi.