Sensory Space: the test that had to fail
A generative artwork of slow light and sound for well-being, designed with particular care for people on the autistic spectrum. Every frame passes through a brightness limiter on the GPU, and the test that guards it has to prove it can fail. Live at sensory-space.org.
Live at sensory-space.org; source at jason-chao/sensory_space (MIT).

Sensory Space is a generative artwork of slow light and sound, made to support well-being. It is for anyone who wants a quiet place to linger. I designed it with particular care for people on the autistic spectrum, and for others who need sensory support, who may find it especially helpful. It runs in a browser, on anything from a phone to a projected wall, with nothing to install and no account. The pace, the brightness and the sound are left to the person in the room. It is an art project, and it makes no medical claims.
It is live now. Open sensory-space.org and the first scene is already moving behind a small card. A tap anywhere begins.

Before I wrote the first scene, I set one rule that no scene, setting, sensor or bug of mine should be able to break: the picture must not flash. Many of the people it is designed for are sensitive to light and flicker. Epilepsy alone is far more common among autistic people (a median of about 12%, against under 1% in the general population). And a projector fills most of the visual field.
This piece is about how that rule is enforced, how I tried to break it, and the two occasions when people saw something wrong that no test of mine could have caught.
Safety as the last pass
A scene in Sensory Space is a single GLSL function of position and time. None of the twenty-one scenes contains a line of safety code. Instead, every frame passes through a limiter that runs as the very last stage on the GPU before the screen. Whatever a scene, a setting or a sensor asks for, the limiter has the final word.

WCAG 2.3.1 defines a flash as a pair of opposing changes in relative luminance of 0.1 or more, and allows no more than three in any one second. At 0.35 per second, one such pair takes at least 0.57 seconds, so no region can produce more than about 1.75 flashes a second. A region that wants to change faster does not cut. It dissolves.
Three things were less obvious than I expected.
Regions, not the whole frame. Two patches of the picture can flash in opposite directions while the average brightness of the whole frame stays perfectly still. A limiter that watched only the average would pass a strobing chessboard.
The stricter rule. WCAG 2.3.1 exempts flashes that cover only a small part of the visual field. On a phone, that exemption is reasonable. On a wall, it is meaningless. So the limiter targets WCAG 2.3.2, which has no exemption at all.
Colour can flash too. Two saturated hues alternating at the same brightness sail through a brightness check. The limiter now paces a change of colour at the same rate as a change of brightness, and it keeps saturated red below WCAG’s red-flash criterion. (How the colour pacing came about is a story of its own, below.)
For readers who like shaders: the composite pass writes linear luminance into the alpha channel, so the GPU’s own mipmaps give the mean brightness of every region for free. And a dither smaller than one 8-bit step stops a slow dissolve from stalling on rounding just short of its target.
The same holds for touch. A tap opens a bloom of light and plays a note, and in many scenes it also changes the scene itself. None of that is exempt.

A test that has to fail
A safety check that has never failed proves very little. It may simply be blind.
The flash check drives the real renderer, in a headless browser with software WebGL, through eleven worst cases. Brightness is slammed between 0 and 1 fifteen times a second. The scene is switched every 100 milliseconds. Every control is randomised every two frames. Two saturated palettes are swapped every four frames. A finger is dragged back and forth across the picture at full speed. For each of the 144 regions, the check counts flashes the way WCAG defines them, in a sliding one-second window, and fails if any region shows more than three.

Then comes a twelfth case. The limiter is switched off and the brightness is slammed again. That case must fail. If it ever passes, the check itself is broken, and the whole run fails.

On the current build, the worst limited case shows one flash in a second (a pattern replaced abruptly six times a second), and the other ten show none. With the limiter bypassed, the same test region flashes seven times a second, and its brightness changes about a hundred times faster than the limiter allows.
The type check, the unit tests and the flash check run in GitHub Actions on every push, so a change cannot quietly break the rule.
Sound gets the same treatment on a smaller scale. A compressor and a soft clipper hold the output under 0.85 of full scale. An audio check plays every layer at maximum and fails if that ceiling is crossed, or if the first half-second is not close to silence. Software can only bound the digital signal, though. How loud it is in the room is up to the amplifier, which is why sound only ever begins quietly and rises over five seconds.
The bug was the projector
A few users reported two colours flashing in the colour wash, the quietest scene of all. Others thought they were looking at a projector’s “no signal” screen.
I measured the scene at its worst settings. From one frame to the next, brightness and colour changed by less than half a percent. The software was not flashing. The projector was. A flat, dark field of colour is exactly what makes a projector’s dynamic contrast or auto-iris hunt for an exposure, and what makes the colour wheel of a single-chip DLP projector visible.
Both complaints had one root: the scene was too close to a blank screen. The fix was design, not code. The colour wash is now always textured, with soft clouds at two scales and slow pools of lighter colour, so a projector always has a picture to hold on to.

The investigation did expose one real gap in my own code: a change of colour at constant brightness was not limited at all. That is how the limiter came to pace colour as well, and why the flash check now measures colour steps. A case that alternates two saturated hues every four frames now changes colour at about a quarter of its former rate.
The lesson I took: the hardware in the room is part of the medium. No test that ends at the GPU can see it.
Spooky is not a bug class
The fractal garden passed every check I have. Some users still found it spooky.

On inspection, it had three qualities that the rest of the work avoids: a dark, speckled interior; fine, high-contrast detail at the edge that shimmered as the shape evolved; and a form that never settled. I softened it into a rounded, connected, pale shape. Viewers still found it uneasy, so I withdrew it from the picker and from the automatic changes. Its code stays, and a saved setting that names it still renders.
The limiter answers one question: does the light flash? It cannot tell whether a picture feels safe. Fine detail and dark voids read as unease even when nothing flashes. Only design, and the people looking at it, can catch that.
The controls, rebuilt by feedback
The first version had a “Calm” button. Some autistic users with a strong sense of logic asked a fair question: why is there a button that only slows things down, and where is the control that speeds them up again?
The honest answer was that “less” is not one operation. There are now three, kept deliberately apart.
- Ease (
E) makes everything dimmer, slower and quieter on top of the current settings, and stays on until it is pressed again. - Stop (
X) goes to black and silence at once. It is the one deliberate exception to gradual change in the whole work. - Intensity presets change the settings themselves, and show “Custom” as soon as anything has been adjusted by hand.

Other changes came the same way. The bar can be hidden on demand, because some users never noticed it hiding by itself, and the automatic hiding stopped reacting to the tiny jitter of a resting mouse. Colours gained the same previous and next arrows as scenes. Scenes are picked from live thumbnails, rendered in the current palette. Every action also has a key, and a browser set to reduce motion gets a slower default speed on the first visit.
The opening changed too. Browsers will not start audio without a tap, so the first version put a dark, blurred card with a Begin button in front of everything. Users found it uninviting. Now the first scene moves in full view behind a small card, as in the screenshot near the top, and that first tap on the picture is also the first bloom of light and the first note.

Ideas, not works
Some users love the immersive digital-art exhibitions of a well-known Japanese collective, and asked whether that style could come to Sensory Space. Ideas and techniques are free to use; specific works, names and recordings are not. So I borrowed ideas, not works: lamps that pass their light to their neighbours when touched, flowers that bud, open and scatter, waves of colour through a deep lattice of points, rolling waves drawn as flowing lines in the manner of Japanese woodblock seas, and fields of soft dots at many depths after the infinity rooms of Yayoi Kusama. Those exhibitions can be dense and fast. These scenes stay slow, and they pass through the same limiter.


Users also asked why a tap only made a note while everything else carried on unmoved. Now a press-and-drag leaves a wake that every scene answers in its own nature. Lava follows the hand and splits. Fireflies part and drift back. Silk threads bend away like water plants. Lamps light up along the path. Displacements are capped at about a hand’s width, and the wake fades in about a second, so the picture always returns to rest.

A body sensor that cannot turn up the volume
For custom installations, Sensory Space can take signals from an electroencephalography (EEG) headband, which measures electrical activity at the scalp. The feature is left out of the public site, and I have not yet re-tested it on a real headset with the current protocol. So this section is about design, not results.
Every device is reduced to named signals between 0 and 1, where 0.5 means “this person’s usual level”, each with a confidence and a timestamp. A signal that stops arriving does not freeze at its last value; within seconds, it relaxes to neutral. Signals may tint the colour, the detail, the tone and the reverberation, within a bounded share of each range. They can never move brightness or volume. Movement slows the scene down rather than exciting it, because a loop that intensifies with agitation could escalate it. There is no score, no target and no failure state.
The weak coupling is deliberate. On a single dry electrode at the forehead, blinks and facial muscle dominate the signal. Slow trends over tens of seconds are credible. “Attention” and “calm” scores are not.
Devices reach the browser through an open protocol built on the stream model of Lab Streaming Layer, an existing research standard, so any device with an LSL connector can be relayed. The only thing a device must send is raw EEG. Band powers and signal quality are computed in the browser, so every device is treated alike. In an installation, the bridge sits behind the same server as the app, on a narrow path that accepts only GET requests (the method a WebSocket upgrade uses). One origin means the page needs no mixed-content exception and triggers no local-network permission prompt.
What I do not know yet
- Feedback so far has come from a few autistic users, informally, over several rounds. That is not a study. The next step is structured sessions that ask about enjoyment, a sense of control and whether people want to come back, not about reduced behaviours.
- The limiter has been checked only by my own flash check, not by a certified broadcast analyser. It governs regions, not single pixels, so fine detail can still twinkle.
- Real rooms, with their projectors, ambient light and viewing distances, are outside the software’s control and largely untested.
- The EEG path is waiting for a bridge that speaks the new protocol.
Meeting WCAG lowers the risk. It does not make a picture safe for everyone, and the About page says so in as many words: the limit “lowers risk but cannot remove it”. What I can do is make the claim checkable: a limiter that nothing upstream can bypass, a test that proves it can fail, and an honest list of what neither of them can see.
Links
- Try it: sensory-space.org
- Source code: github.com/jason-chao/sensory_space
- The EEG bridge protocol: docs/EEG-BRIDGE-PROTOCOL.md