Today I connected a Meta Quest 3 to the terminal on my iMac. Not to play anything. I wanted to know whether I could walk into my own archive. The answer is yes, though almost nothing about it worked the way I assumed it would, and the four things that broke along the way taught me considerably more than the things that worked on the first attempt.
The cable
A Quest stops being a headset the moment you look at it from a command line. It is an ARM computer running Android 14. Once you see it that way the tool is obvious, and it is not a new one. Android Debug Bridge, adb, the same utility phone developers have used for as long as Android has existed. macOS found the headset the instant I plugged the cable in, listing it by name in the USB tree alongside every other device on the bus.
+-o Quest 3@02400000 <class IOUSBHostDevice>
"USB Product Name" = "Quest 3"
"USB Vendor Name" = "Oculus"
adb found nothing at all, because with Developer Mode switched off a Quest presents itself to the operating system as an ordinary storage device and never exposes the debug interface. Your Mac can see it. Your terminal cannot reach it. Turning Developer Mode on took three minutes in the Meta Horizon app on my phone, and then the authorization prompt that is supposed to appear inside the headset never appeared at all. I stood there in passthrough for several minutes waiting for a dialog to float up in front of my face, and it had been sitting in the notification tray the entire time, unread. Write that one down if you ever try this. The prompt does not always render as a dialog.
The black screenshot
The first thing I wanted was a picture of what I was actually seeing in there, and there is a standard adb command for exactly that, which returned a perfectly black image at 4128 by 2208. Not an error. Not a permissions failure. Black. The reason is structural rather than a fault in the tooling. A Quest renders through a compositor that bypasses the normal Android display path entirely, so a screenshot of the Android layer faithfully captures a surface that nobody is looking at. There is nothing there to see. The headset’s own capture function works perfectly well and writes real files into a folder I can pull from over the cable, so the whole workflow simply inverted itself. I trigger the capture from inside, and the machine hands the file back a second later.
Cutting the cable
Wireless was one command and one more authorization prompt, hiding in the same tray.
adb tcpip 5555
adb connect 192.168.1.7:5555
A five megabyte file then pushed to the headset over Wi-Fi at 22.8 MB/s, which is fast enough to move a build across or pull a video clip back without ever sitting there waiting on it. I unplugged the cable and kept the shell. There are two honest limits worth stating plainly, which are that the wireless connection does not survive a reboot of the headset and that it drops whenever the headset goes to sleep on your shelf. Both are thirty second fixes. Neither is automatic.
Serving my own work to myself
I have a set of HTML pieces that have been sitting at graffanimation.com/html-gallery since 2016. Eight files. Raw net art, crude on purpose, buttons and clashing background colors and animated GIFs going back to 2014. I will be exact about what they are, because it is the whole distance this post travels. They are hand-typed HTML. I wrote them in a text editor called Brackets and pushed them up to the host server over FTP, the first one in early September 2016 in New York City. No framework, no build step, no CSS file. Tables, divs, image tags and paragraph tags. The kind of page a browser has understood since before most of the web existed. Flat documents. That is all they have ever been.

I wrote a description for that page at the time and it still says the thing properly, so here it is as written:
The pieces are created by writing code in html editors — raw, direct, CSS-free executions of digital graffiti aesthetics using only the fundamental building blocks of the web: <table>, <div>, <img>, <p>.
Seven of those became the seven rooms: TABLEFUN, TRANSCEND, FURTHER-ALONG, NETART III, FLOW, CITYMINES and WONDERS. This is one of the GIFs hanging inside CITYMINES, running, the way it runs on the page.

I served them straight from my own machine and opened them in the headset browser, and the server log is the part of this whole day that I keep going back to look at.
192.168.1.7 - - "GET / HTTP/1.1" 200 -
192.168.1.7 - - "GET /favicon.ico HTTP/1.1" 404 -
That second line is the headset asking for a favicon, which is precisely what a browser does when it is genuinely rendering a page rather than merely probing to see whether something is there. My work, in front of my face, with no host anywhere in between. No upload. No deploy.
Phase four
The shift this belongs to started earlier than the site does. In the early 2000s the mediums moved. Digital art practices came online properly, and artists began running experiments, some inside the browser and some pointedly outside it. I was there for that, paying attention, and running experiments of my own. The HTML gallery is not where any of this starts. It is one phase of something a lot older, and the original site is still running at graffanimation.com, still opening with a line I wrote a long time ago.
Graffiti + Animation + < CODE > + the Internet = The Graffanimation Platform
The about page says the project took form with the birth of the internet, inspired by and evolving out of the 1980s NYC subway graffiti movement and the cartoon characters that had been woven onto the trains. It calls itself a two-dimensional adaptation for evolution. Then it lists the phases, and it is oddly precise about the dates.
Phase 1 began creating and archiving works on September 10th 2014 and ended on January 2nd 2016 with a gallery of animated GIFs. Phase 2 completed as of May 15th 2016 and consists of HTML Table Formations. Phase 3 completed as of January of 2018 and consists of more animated GIFs using Adobe After Effects and mobile app animations.
HTML Table Formations. That is the phrase I used in 2016 for the eight files in this post, and it is precisely what the room turned out to be made of. The tables were always the formation. I had just never stood inside one.
And then the last line of that list, which has been sitting on my own website unfinished since 2018.
Phase 4 – yet to be applied, but evolving for sure!
There is one more paragraph on that page. I wrote it about a fictional crew of graffiti writers and I did not think of it as a plan.
SLOW is a fictional crew of digital graffiti writers living in both worlds, the physical 3-dimensional world as we know it, as well as the virtual 2-dimensional world we record and archive as data. SLOW writes through the dichotomy of both planes.
Both planes. The three-dimensional one and the two-dimensional one, and a crew that works across the seam between them. I meant it as fiction. Reading it back this week it looks like a specification I filed and forgot. I started writing graffiti as a teenager. Forty years later a terminal read one of those table formations and stood it up nineteen storeys tall, and I walked in through a door. I had no idea it would morph into this. Phase four, apparently.
Then the real build
Flat pages on a floating browser panel are still flat pages, so the next move was to build an actual WebXR gallery around them instead. Seven rooms arranged in a ring, one room for each piece, seventy works in total, three hundred and fifty one megabytes of animated GIFs hung on the walls.

I worked on this with Claude Code in the terminal, and I want to document the failures accurately, because four of them were the kind of thing you would lose a weekend to alone.
Four traps
One. WebXR requires a secure context. I served the gallery at http://192.168.1.2:8080, my Mac’s address on my own network. In that context the browser reports navigator.xr as undefined. Not restricted. Undefined. You cannot enter VR at all. Localhost is treated as trustworthy and a plain LAN address is not, so the fix was to forward the port into the headset and browse it as localhost. The tunnel I had dismissed as unnecessary an hour earlier turned out to be the whole thing.
Two. My own server blocked me. The GIFs live on my WordPress installs, behind a security layer that sends no cross-origin headers and redirects every request. A browser fetching them from my gallery gets refused. I had to proxy them through my own machine and cache all three hundred and fifty one megabytes locally, which had the side benefit of making everything load at LAN speed.
Three. A texture built before its canvas is resized can never display. This is the one that cost the most time. The 3D library allocates video memory the first time it uploads an image, and every update afterward writes into those original dimensions. My canvases started at four pixels square and grew to five hundred and twelve. The artwork loaded correctly, sat in memory correctly, and rendered as solid black forever with no error message anywhere.


Four. A decode call never returns when the page is hidden. There is a browser function that waits for an image to finish decoding. In a background tab it simply never resolves, and I was waiting on it before drawing anything. Everything stalled, silently, with no failure to catch.
Where I was wrong
I want this in the record because I nearly built the wrong thing on top of it. Partway through, my tests kept timing out and I concluded the GIFs were too heavy to decode. I started designing around that assumption, adding rules to freeze the biggest files permanently. Then I went back and measured it properly, and a browser throttles background tabs to one second per timer, and my test loop was forty short waits. I was measuring my own test harness. The GIFs were never slow. The real numbers, once I actually took them: a still frame from the largest file, sixty five megabytes and two hundred and four frames, draws in sixty six milliseconds. Full animated decode runs at 16.5 milliseconds per frame for that one, and 0.7 milliseconds for a small one. A frame at 72 Hz is 13.9 milliseconds. So the constraint is per-frame decode, not file size, which is the opposite of what I had assumed. The system now measures whatever device it is running on and quietly demotes anything that cannot keep up to a still image. It does not guess. A Mac animates more than a headset will, and both stay honest about it.
The dizziness
I built smooth movement first, the kind where you glide across the floor by pushing a thumbstick forward, and I put the headset on and felt genuinely sick within about a minute. That is the oldest problem in VR and I walked directly into it, because your eyes report motion that your inner ear cannot confirm and your body treats the mismatch as something close to poison. Smooth locomotion came out. What is there now is teleport on the trigger and a thirty degree snap turn, each of them masked by a blink short enough that your vision never has to carry the movement itself. The archive should not make you nauseous.
What it costs and what it does not
Thirty two works painted, three animating at once, thirty seven megabytes of memory. That last number started at four hundred and seven because I was holding every downloaded file in memory at the same time. Releasing them dropped it by more than ninety percent. Only the rooms near you load into memory at all, and only the pieces you actually walk toward will wake up and begin to move. Walk away and they go back to sleep. It is not a portfolio. It is not a slideshow. It is not a virtual museum. It is a room that fills in as you approach it and empties behind you.
What the cable actually found
Here is the part I did not plan for. With a shell open on the device I went through the headset’s own folders, and found twenty eight saved graffiti pieces I had stopped thinking about. Three scenes: a rooftop, an alleyway, and a subway platform. The earliest is 7 December 2023 and the most recent is 29 September 2025, which means I was painting in there five months ago and had not mentioned it to anybody, including myself.


The figure in the pink puffer holding a can is the same character that turns up in my collages and in the GIFs hanging in those rooms. I did not decide to do that. It just comes out of the can the way it comes out of everything else. Then there is this one.


A painted train. Red and pale blue and yellow across the panels, a character riding the window. It is not a picture of a train. It is the surface this whole form started on, rebuilt in software and painted again.

Each of those saves keeps more than a picture. There is a paint file holding the paint alone, unwrapped off the geometry, one of them eight thousand pixels wide. There is a replay file that records how the piece was made, stroke by stroke. The process is in there, not only the result. And one more, from the last day of 2025, standing inside a virtual gallery in the headset browser with work along a white wall and two other people in the room.

Eight hundred and twenty megabytes came off that headset tonight. None of it was in my archive. None of it was backed up. It has been sitting on a device I mostly pick up for other reasons, and it took opening a terminal to find it. So today was not a first, and I would rather say that plainly than dress it up. Graffiti, then graffanimation, then a headset. What changed is where the room runs, and that I now know what is on the device.
The room was the wrong shape
The first version I built was a museum. Seven identical box rooms arranged in a ring, work hung on the walls at eye level, wall colours taken from each piece and dimmed down until the yellow came out as mud. It ran at sixty frames a second and every number was green. It was still wrong. It was wrong because a polite beige room is an argument, and the argument it makes is the opposite of the work. These pieces are deliberately raw. CSS-free, loud, full of clashing colour and stray buttons that do nothing. Putting them in a tasteful container is a way of apologising for them. So I went and read what people have actually built, which I should have done first. LaTurbo Avedon has run Panther Modern since 2013, a file-based exhibition space where artists make site-specific installations for the internet, and the rule there is that every project gets its own architecture instead of a shared room. DiMoDA does something adjacent as a virtual institution with artist-built environments inside it. Rachel Rossin puts the bugs inside her work on purpose, so the instability is material rather than failure.
That handed me the rule I had missed. Do not build a container. Let the file build the space. The second version reads the actual markup, and the shapes turned out to be sitting in the documents the whole time. tablefun is a table nineteen rows deep and three columns wide, so it becomes a lattice twenty eight metres tall that you stand at the bottom of and look up. CITYMINES is one row and nine columns, so it becomes a long band you walk beside, the way you read the side of a train. Same code, no hand-authoring, completely different architecture, because the files are different. Then the better idea, which was not mine. Make the pieces the walls.

Twenty eight metres square and thirteen high, every surface a different piece, edge to edge, floor to ceiling. Then a door cut through the south wall so you can walk out and look back at the thing as an object standing on open ground.

The floor is CITYMINES, which means you stand on a field of spent spray caps. That one was not planned and it is my favourite thing in the build.

Which produced the one genuinely useful mistake of the rebuild. The caps floor was animated, and an animated floor is unbearable. Moving texture in your lower peripheral vision is a sickness trigger, the same family of problem as the smooth locomotion I had already cut. So the floor is now static, permanently, by rule rather than by preference. Nothing underfoot moves. The third version stops giving each wall its own document and starts mixing them. Every surface is assembled from fragments of all seven pieces at once, eighty two image cells and a hundred and five text cells pulled from the whole set and dealt out round robin so no wall belongs to one piece.

Then I put a sculpture in it.. of course!

That is a GLB file, thirty three megabytes, generated rather than typed, dropped into the middle of a space whose every wall came out of documents I wrote by hand ten years ago. It has real volume and real shading, and the panels around it are flat by nature and stay flat. Standing between the two is the clearest picture I have of what this week actually was. There is one behaviour in that room I want to describe properly, because it is the part people notice first and it was not a design decision to begin with. Of the two hundred and thirteen fragments on those surfaces, only three are ever moving at once. Everything else is holding a single frame. Walk toward a wall and the piece in front of you starts running, somewhere around eight or ten metres out. Walk away and it stops.
That started as a constraint. Decoding a GIF into a texture is real work on every frame, a heavy one costs about sixteen milliseconds, and a frame at 72 Hz only gives you fourteen to spend. Animating two hundred at once is not a thing that runs anywhere. So the code sorts every fragment by distance on every frame, wakes the closest three, and explicitly stops the rest. What came out of it is better than what I was aiming at. The room is still until you approach it. You have to go to the work for the work to move, and it settles again behind you. Your attention is what animates the place, and only the part of it you are actually giving. It is staying exactly as it is. All three versions still run, side by side, on three ports. The museum box is kept on purpose. The comparison is the argument.
The whole stack, if you want to try this
Everything below is what actually ran, with versions, so you are not guessing at mine. Nothing here is paid and nothing needed a package manager.
Hardware -> An Apple M1 iMac with 16 GB of memory, running macOS 26.3. A Meta Quest 3 on Android 14, Horizon OS build releases-oculus-14.0-v207, Quest Browser 150. One USB-C cable. Both devices on the same Wi-Fi.
On the Mac -> Claude Code in the terminal. Android platform-tools 37.0.1 for adb 1.0.41, which is a free zip from Google and does not need Homebrew. Python 3.9, which macOS already has, running the local server. three.js r160, downloaded once and served from my own folder rather than a CDN. rclone 1.74.4 for the backups. Node with sharp 0.35 for image work, and macOS sips and qlmanage for the rest. wp-cli 2.11 over SSH to put this post in as a draft.
In the browser -> The WebXR Device API for the headset session, and the WebCodecs ImageDecoder for pulling GIF frames out one at a time. Both are standard. Neither needs a library.
# get adb, no package manager required
curl -O https://dl.google.com/android/repository/platform-tools-latest-darwin.zip
unzip platform-tools-latest-darwin.zip
# turn on Developer Mode in the Meta Horizon phone app first, then
adb devices # says "unauthorized" until you tap Allow IN the headset
adb tcpip 5555 # cut the cable
adb connect 192.168.1.7:5555 # your headset's own IP
python3 -m http.server 8080 # or your own server
adb reverse tcp:8080 tcp:8080 # THIS IS THE IMPORTANT ONE
adb shell am start -a android.intent.action.VIEW -d "http://localhost:8080/"
Seven things will cost you an evening if nobody tells you, so here they are:
Browse localhost, never the LAN address. WebXR and the GIF decoder both require a secure context. On http://192.168.1.2:8080 the browser reports navigator.xr as undefined and VR simply cannot start. localhost counts as trusted, which is the entire reason for the adb reverse line above.
The Allow prompt hides. Developer Mode is set in the Meta Horizon phone app, and you need a free developer organisation before the toggle exists at all. Then the authorization dialog often lands in the headset’s notification tray instead of appearing in front of you.
Screenshots of a Quest come back black. The VR compositor bypasses the normal Android display path, so adb screencap captures a surface nobody is looking at. Trigger the headset’s own capture instead and pull the file from /sdcard/Oculus/Screenshots.
Strip the metadata before you publish a headset capture. Meta embeds an account-linked OCULUS_ATTRIBUTION_ID in the file. Re-encoding does not remove it. The JPEG application segments have to be rebuilt.
Your own server may block you. If the images live on a WordPress install behind a security layer, the browser cannot fetch them cross-origin. Proxy them through your own machine and cache them locally.
A texture built before its canvas is resized can never display. The 3D library fixes the video memory on first upload and writes into those dimensions forever after. The artwork renders as solid black with no error anywhere.
Do not await an image decode while the page is hidden. That call never resolves in a background tab and everything silently stops. And two rules that are about bodies rather than code. No smooth stick movement, ever, because your eyes report motion your inner ear cannot confirm. Teleport and snap turn instead, each masked by a short blink. And nothing on the floor may animate, for the same reason. All of it is plain files. HTML, one JavaScript module per job, a small Python server, and a folder of GIFs from 2014.
What is not done..
I have not tested any of it in the headset yet, because everything above was verified in a browser on the iMac, and whether a Quest can actually carry it at frame rate is genuinely unknown to me right now. I could tell you it will be fine.. but I do not know that yet (I will in about 30 mins though!) The four traps are written down now, so the next object I connect starts further along than this one did. The remix room carries eighty three image cells on its ceiling alone, which is the heaviest surface by a distance and the first place I would look if a headset struggles. netartIII is still not a space. It is twenty three buttons and two embedded documents, and I think it wants to be the way in rather than another wall, but that is a decision about the work and I have not made it.
Next is a Raspberry Pi on the desk with a single sensor, wired to the catalog, to find out whether physical input against my own archive feels like anything at all. The flat pieces are still up and still work, at graffanimation.com/html-gallery. The rooms are not, yet. What I keep turning over is how little had to be added. I did not remake the pieces. The files are untouched, the same tables and image tags I typed in 2016, still sitting on the same server. All that changed is that something finally read the structure instead of the surface, and the structure turned out to be a room the whole time. A flat page for ten years. Then somebody asked it how tall it was..

If you have a headset and a terminal, you already have everything you need to try this. Tell me what you connect. I have been curious since I was three years old, drawing on the walls of my bedroom. Drawings became paintings. Paintings became sculptures, sculptures became photographs, photographs became collages, collage became prints, and prints became webpages and digital illustrations. Then I learned what was on the back end of those webpages, and kept pushing.
One URL works everywhere. Want it in VR? Grab your headset, want it in a browser, cool – GO HERE ->
https://www.ryanseslow.com/room/
– Desktop movement: use these keys on your keyboard W – forward A – move to the left S – move backwards – D – move to the right – to move, drag to look, Q and E to rise and fall.
– In a headset: open the same URL in the Quest browser, press ENTER VR. Trigger teleports, right stick snap-turns.
– Phone or tablet: it renders and you can look around.. use your finger.
Art is so much about discovery, and about choosing who we want to be in relationship to our time here. That is the magic of it. I keep reminding myself, and my students, Do not be afraid to try new things. Embrace everything, especially the things you make that you do not like right away.. I have made far more work I do not like or resonate with than work I do. Those are the time stamps. Those are the pieces that produce the best questions and the best dialogue about the process.
I am literally just getting started!




