A supermarket you can walk around
Grocery shopping has two shapes, the shop and the website, and browsing falls into the gap between them. I built the middle: a walkable pixel-art store running on a real catalogue.
Buying groceries has two shapes right now, and there's a gap between them.
There's the shop you walk into. You move through a place, you see things, you change your mind in front of a shelf. You also deal with the crowd, the queue, and an environment built to put things in front of your eyes that you had no intention of buying. And if you can't eat something, you find out by picking up each jar and turning it over.
Then there's the website. It's faster, it remembers what you bought last time, and for a weekly shop it's hard to argue with. What it can't give you is browsing. Not searching, not filtering: walking down an aisle and looking at what's on it.
Both of these work. I just think we could meet somewhere in the middle and keep the browsing.
I worked at Rohlik, and the whole time I was there I wanted to see what that middle looked like. So I built it on my evenings: a top-down store you walk around with a small character, stocked from Rohlik's real catalogue, with real prices and real stock.
The part of the physical store worth keeping
My argument is narrow: the good part of a physical shop is browsing, and the bad part is all the machinery built to make you browse wrong. Endcaps, sightlines, sweets at the till.
So I kept the place and deleted the machinery. You walk an aisle and press a button, the shelf opens, and what you see is that department from the live catalogue with the things you've ordered before floated to the top. The point isn't convenience. It's that you leave with what you came for.
Some of what the physical shop costs you, a digital shelf just doesn't have to charge. Allergens are the clearest case. In a real aisle, eating gluten-free means picking up every jar and reading the back of it, and the shelf is no help at all. A shelf that's a query can simply not show you the things you can't eat. The version I built has a Special Diet aisle doing a narrow form of this already, stocked from gluten-free, lactose-free, protein, and supplement searches. The honest extension is the whole store behaving that way: you tell it once, and no aisle ever shows you a product you'd have had to put back.
There's a tension here worth naming rather than hiding. A store that shows you what you came for is a store with a smaller basket, so this optimises for the shopper's list ahead of the order value. That's the trade I wanted as a shopper, and I'd rather say it out loud than pretend the choice is neutral.
This isn't trying to replace rohlik.cz. People will keep ordering on the website. This is a side door: something fun enough that you'd open it on a Sunday, and in particular something you'd open with someone else.
A five-product shelf
The first version of a shelf was embarrassing, and fixing it turned out to be the whole project in miniature.
Rohlik's product search is recommendation-shaped. It returns roughly five matches per keyword and takes no limit, page, or offset parameter. The total count comes back as a single digit even for something as broad as jogurt. One keyword per aisle meant I opened the dairy shelf and five products looked back at me.
Read as an API problem that's a minor annoyance. Read as a design problem it's fatal, because a shelf with five things on it isn't a shelf. It's a list again, and the list is the thing I was arguing against. The browsing I'd built the entire store to protect needs enough stock on the wall to actually scan.
The call does accept up to four queries at once. So every aisle carries a small set of related Czech keywords instead of one, and the results merge on product id:
dairy: { queries: ['mléko čerstvé', 'mléko trvanlivé', 'smetana', 'máslo'] }
Fresh milk and long-life milk return overlapping products, so first sighting of each wins. Shelves went from 5 products to 16 in the same single round trip. The keyword sets live with the aisle definition and the server resolves them from the zone name, so the browser only ever says which aisle it's opening.

Shopping with someone else
This is the feature I'd lead a demo with. Send someone a link and you're walking the same store. You see each other move, you see each other's name plates, and everyone in an aisle is depth sorted together, so a shopper further up the aisle draws behind the shelving that a nearer one walks in front of.
Get close to someone and a small video window opens over their head. Walk away and it closes. There's hysteresis on it (it opens at 3.2 tiles and doesn't close until 4.8) because without that it flickers every time two people shuffle past each other.
The whole thing runs with no dependencies. The WebSocket server is a small RFC 6455 implementation: handshake, masked client frames, fragmentation, ping/pong heartbeats to drop peers that stop answering. Text frames only, because that's all this needs. Video is WebRTC peer to peer and the server only forwards opaque signalling, never media. The lower connection id makes the offer, so two people who spot each other at the same moment don't both try to initiate.
Positions go out at most 15 times a second and only when something changed, and remote shoppers are eased toward their last reported point so they glide instead of teleporting.

Everything is drawn in code
There are no image files in this project. Not one.
The store is painted once at boot into two big canvases, a floor layer under the player and a props layer with the walls and fixtures, and the render loop only blits. That's the architectural decision the whole look depends on. Because nothing is drawn per tile per frame, a fixture can be an arbitrary multi-tile drawing with a crown along its top, a kickplate at its foot, and a hanging sign, instead of one 16px stamp repeated until it reads as wallpaper. The props layer blits in two bands split at the player's feet, which is what puts you partly behind the shelving when you walk up an aisle.
A few details I spent real time on.
Fixtures label themselves. Tall units wear a header board across the top with the category name. Counters and chiller cases get a small enamel plate bolted to the bottom corner, the way a real fish counter does. Anything too narrow to label legibly gets neither and the interaction prompt names it instead. There's a 3x5 pixel font in here for the signage.
The timber tone shifts per unit, derived from each fixture's position, so twenty-odd aisles of shelving don't read as one repeated colour. Same trick stocks the shelves: the RNG is seeded from the fixture's position, so a shelf looks hand-stocked but comes back identical on every reload.
The character is an editable pixel grid. One string per row, with a key mapping characters to colours, so redrawing the 16x22 shopper is a text edit. Two rules keep the four facings honest: every row shares one column skeleton, and right is generated by mirroring left rather than drawn by hand, so the two can't drift. A width and character check over the grids catches the mistakes I actually made, which included a stray Cyrillic character and a row that was 15 pixels wide instead of 16.
The thing nobody will notice. Rohlik shoots products tall, 865x2000 is typical. A square thumbnail with object-fit: contain rendered them about 20 pixels wide: a pale sliver of milk carton on a pale tile, which looks exactly like an image that failed to load. The tile is now portrait, 50x62, and white, so the photo's own white backdrop blends into it and only the product reads.

The characters I turned off
I built twelve shop assistants. Green aprons, a tag over the head, standing around the shop. Walk up to one and they'd tell you what was on sale in the department they were standing in, and the offers were real: Rohlik's discount endpoint takes a category id, and their own nineteen department ids come back from the API, so every aisle carries the department it belongs to. They weren't a separate list to maintain either. Each one adopts the nearest shelf that sells something, so moving an assistant on the map changes what they talk about.
Then I turned all of them off.
const HIDDEN = new Set(['5']); // empty it to bring them back
Standing next to a shelf, the only thing an assistant could offer was that shelf's own department on sale, which is exactly what the shelf's ON SALE filter already did. A duplicate is worse than an absence. So they're hidden: their tiles read as plain floor, not solid, not drawn, not interactive. Nothing else came out. The map still places all twelve and the painter and the interaction are untouched, so bringing them back is a one line change whenever they have something to say.
That's the decision in this project I'd most want to talk about. Not because cutting a feature is clever, but because it took me a while to admit that a character I'd enjoyed drawing had nothing to contribute.
Where it actually is
Honest status, because the alternative is worse.
Not deployed. It runs locally and across a LAN. There's a Dockerfile and a Fly config, and the app name is still unclaimed.
No usage data. Nobody has shopped in it except me and the people I've shown it to in a room. There are no adoption numbers to report and I'd rather say that than dress up a demo as a launch.
The video chat is unverified end to end. Signalling, proximity, and the window are written, and a blocked camera surfaces as a message rather than a silent failure, but the environment I built this in blocks camera capture, so I've never watched two real peers exchange video. Presence, movement, and name plates are confirmed with two live browsers.
STUN only, no TURN. Peers connect directly, which covers most home networks. Two people behind strict symmetric NATs would need a relay I haven't set up.
One instance by design. The room, the catalogue session, and the caches live in memory, so two replicas would be two separate stores with two separate sets of shoppers. This scales up, not out.
Sign-in is deliberately host-only. Guests join your store and shop into your basket, and only if you started the server with sharing switched on. The checkout link stays host-only whatever that flag says, because it opens a checkout with a payment method already on file. With sharing off, guests get a bundled demo catalogue and their shelves are badged DEMO rather than quietly faking it.
What I'd build next
The assistants come back, but with something to say.
What would earn their place is the thing the shelving structurally cannot do: tell you what's good this week, what's trending, and what to cook from the thing you're standing in front of. An assistant that answers "what do I do with this" is a different object from one that recites a discount list. That's the version I'd switch the hidden flag back off for, and it's the next thing I want to build.
After that, sound. There isn't any yet, and a shop with no ambient noise feels more like a diagram than a place.
