The code for my personal website. This mainly consists of the radio page, the exploration pages, and the blog. I do try to credit assets, but I'm pretty bad at it...
New PC-98-style art (nav icons, sprites, scenes) is made in a separate workspace:
VivaLaPanda/pc98-assets. It has the tools, the source for each asset,
and the PC-98 style guide.
Only finished PNGs get copied into img/ here. This repo is served as-is, so working files stay over there.
js/pc98.js is the in-browser PC-98 filter the blog runs its cover images through. Its research, lint and test
harness are in that repo's filter/ folder.
Pages are compiled from templates/ with microtemplate by
python3 scripts/build.py, which also stamps each local script and stylesheet a page loads with a hash of its
contents (?v=...), so there are no version numbers to bump. Templates load plain /js/x.js. While editing,
cd templates && ../microtemplate --watch --out ../ --path . rebuilds on every change (without stamps; run
scripts/build.py before committing), and python3 -m http.server 8765 from the repo root serves the site.
The blog reads blog/posts.json, generated from the Substack feed (and its pinned posts) by
python3 scripts/substack_feed.py. It's gitignored. On the server, a systemd timer refreshes it hourly
(scripts/systemd/).
The reading list (/reading/) works the same way: python3 scripts/goodreads_feed.py turns Panda's public Goodreads
shelves (their RSS feeds; the API is closed) into reading/books.json, gitignored, refreshed hourly on the server by
goodreads-feed.timer. Read dates and reviews added on Goodreads show up on the page automatically. The ★ Favourites section is the Goodreads
shelf named favorites (hidden while it doesn't exist or is empty).
The recipe book is generated from content/recipes.md (an export of the recipe doc: ## Course, ### Recipe,
**Verdict:**, a "Serves · Source" line, then **Ingredients** / **Method** / **Notes** sections). After
editing it, run python3 scripts/build_recipes.py, which writes templates/recipes.html and
templates/recipes/<slug>.html, then rebuild with microtemplate. Short URLs, the links between recipes, the
"★ Starred" list and Holo's face per verdict (HOLO_FACES, HOLO_OVERRIDES) are set at the top of the script; the pancakes and katsudon pages are hand-written
(listed under Mains via OLDER in the script).
/living-room.html (Home, the sidebar's kotatsu) and /room.html (the bedroom, through the living room's left door)
are PC-98 rooms drawn live from Panda's real house: the time of day, the lamps (on/off, brightness, colour), the
blinds, the thermostat, the TV, music on the speakers, the kotatsu's plug. Plain JS, no libraries, no build step:
js/house-render.js: paints a room's index map in the current time of day's 16 colours plus each lit lamp's light.js/house-room.js: loads the room's scene and the house state, draws, and redraws every minute.js/scene.js: the hotspots and Panda's dialogue box. It runs both rooms and every Explore page.js/living-room-data.js,js/room-data.js: each room's objects;data/dialogue.json: every line Panda says (hand-edited; its_readmelists the variants and placeholders).js/scene-music.js: the music and the mute speaker (the site's sharedmutedsetting).templates/t_scene.html,css/scene.css: the scene page's markup and style. Each page sets its music, alt text and noscript fallback through{{ define }}blocks.img/living-room/,img/room/house/: the exported scenes. They're made in pc98-assets (easel/pieces/living-room,easel/pieces/panda-room): edit the passages there, run the export, copy the output here, and bump the room'shouse.v(and the scene?v=) in its data file.
Explore uses the same engine. js/explore-locations.js lists every place: its art, music, exits (rects in
the 740×528 picture), objects, views and layers (pictures over the art, e.g. lanterns, glints). Its adapter at the
bottom turns the current place (?loc= or the page's data-scene) into the scene. Explore's lines are in
data/dialogue.json under explore.<place>. Explore has no LOOK button, and its exits aren't outlined: the map is
meant to be poked at.
- What there is to do is in four scripts:
js/explore-core.js(shared state, menus, greetings), thenexplore-games.js,explore-town.js(the cat, the camera, the rally, the threads) andexplore-ambient.js(particles, weather, sound). Each feature script keeps its ownPLAY[place]. - Plain menus are data: a choice can
say,pickone line at random, open amenu,servea drink, change thesong,gosomewhere, or need something you've heard (need). - New art for a place goes in
img/explore/layers/, drawn on the picture's own pixel grid in its own inks. - Pockets: prizes and finds (the claw machine, the festival, the harbour, a camera, Panda's notebook of errands, a stamp rally card for eight special
places) sit by the dialog box's ▼; names and lines in
data/dialogue.jsonunderpockets. What the browser remembers (flags, pockets, what a pocket says when clicked) isjs/state.js(window.PANDA), shared by the house key and Explore. - The city remembers you (all in this browser): places say something different on a return visit
(
greeting_return), after five visits (greeting_regular), and once in a while something rare happens on arrival (rare); stay still somewhere quiet and something small happens (linger); characters greet you differently after you've helped them (greeting_after,oncefor just the first time); letters from them arrive in the living room (lettersindata/dialogue.json); the Pandopolis Times at the konbini reports it. Some dates change greetings (greeting_on: the Pumpo rave on 5/2, Tanabata, Christmas, New Year's Eve, Valentine's, the Perseids). - The camera (Half Pipe) offers a photo by itself at the first three scenic spots, then goes in your bag: click it in your pockets for a photo, a print or the album.
- The festival's fireworks show over the skyline (the roof, the penthouse) once you've been to the festival.
- Long lines split into ▼ pages and long menus get a "More..." choice (
js/scene.js), so text never overflows. - It rains on the outdoor night streets about one hour in three (the same hour everywhere); add
&rainto a URL to see it.
The map (each place's main exits; ⇢ a way that has to be found or unlocked). Every place has a way out: a door where one is drawn, and the bottom edge of the picture (behind you) back to the street you came from. Where the picture has no door for a place, a choice in the place's menu or on a person offers the way (the plaza's smell of coffee, the purring on the residential street, the regular at Club Polka).
Home ─ Main Street ─┬─ Casino (pans; roulette) ─ High Rollers' Lounge (the back corridor)
├─ Tokugawa Tower lobby (board) ──┬─ B1 car park · 3F gallery · 12F metr.org
│ ├─ Penthouse (watch the city; telescope; fireworks)
│ └─ Tower Bar (drinks; the man in the hat)
├─ Picrew Plaza ─┬─ Night Bookshop (Panda's five-star books) ─ reading list
│ ├─ A Late Cafe
│ ├─ Pumpo Park (lanterns; a rave flyer) ─┬─ Summer Festival (goldfish, ring toss) ─ Shrine (ema) ─ Under the Bridge
│ │ └─ Forest Edge (fireflies) ─┬─ Hidden Glade (pond: the house key) ─ the stream ─ Riverboat
│ │ └─ the hills: Sunlit Path ─ Tea Party ─ Hilltop (─ Observatory)
│ │ ─ Lily Field ─ Campfire ─ Fox Gates ─ ↺ Shrine
│ └─ Residential ─┬─ Konbini · Neko no Ma (cat cafe)
│ └─ Houses ─┬─ the girl under the streetlight ─ Acacia Apartments
│ │ ─┬─ the Roof (stars, fireworks, the ending)
│ │ └⇢ (the cat) Back Passage ─ Old House ─ its room
│ │ (⇢ 2-4am only: Bar 25)
│ ├─ Coin Laundry
│ └─ Andy's House
└─ Isogashii Blvd ─┬─ Taito Game Centre (claw machine, purikura)
├─ Club Polka (dance) ─ Ruri's back room (high-low)
├─ Agency (Johnny: the hunt, rumours, people) ─ Denchi's (symphoslots)
├─ Crossing (─ Izakaya Gonta) ─ Platform 1 ─ On the train ─┬─ train.html
│ └─ Otojima (back: the Last Train)
│ ─ Half Pipe (camera) ─ Beach ─ Sunset ─ Harbour (fishing) ─┬─ Lighthouse · Pier (Ferris wheel)
│ ├─ Aquarium
│ └─ Lake ─ Riverboat ─ ↺ Hidden Glade
└─ Alley ─┬─ Shizuka St ─┬─ Erratic Errata ⇢ (second edition) Hidden Garden
│ ├─ Net Cafe Noa (Panda's old bookmarks; Dollars BBS)
│ ├─ Namba Bears (song requests)
│ └─ Bathhouse ─ inner bath
├─ Ghandi's ─ Rabu Rabu (drinks, stamp card)
└─ Ramen stall (Taisho; venki.com) ─ Karaoke Box
Loops: Harbour → Otojima; Riverboat ↔ Hidden Glade (the stream); Fox Gates → Shrine. Drawn for the city (FAL from a neighbouring picture, quantized to 16 inks): the ramen stall, game centre, roof, aquarium, net cafe, coin laundry, karaoke box, the empty bridge the train crosses, the observatory, the last train, the lighthouse, the fox gates, the cat cafe, the pier, and the residential street without the girl.
Threads that cross the city (flags in localStorage pandaHeard_*; Panda's notebook in your pockets tracks them;
letters arrive in the living room after some of them): Sabrina ⇄ Tony (a note and a coaster); Sakura → Andy (a
fuse; then they're at the late cafe); Taisho → Brownie (takeout; a free soak); Ghandi → Sakura (a broken radio
becomes a pocket radio); the beach → Sabrina (a seashell); the konbini → the back passage (an onigiri); the cat
(chase it, pet it, feed it a mackerel from the pier, it roams town); the girl under the streetlight (her charm, off
the pier) and the man in the hat (he was waiting for her; he leaves when she goes home); the shrine's ema → the
girl under the bridge (who writes the bookshop's poems); the fox mask → the fox gates; Ruri's ace; the camera →
the gallery's exhibition; the stamp rally (eight places, a pin, the rooftop ending); the Pandopolis Times reports
it all. Places also greet you differently in the
rain, in the small hours, on Thursdays, at Halloween (?season=halloween), in December snow (?season=winter), in cherry blossom season (?season=spring), at New
Year, and after things you've done.
The house key. Visitors who find Panda's spare key can switch the living room's lamps, blinds and AC on and off
(nothing else). js/house-key.js runs on every scene page: the key slot and the hunt's items by the dialog box's ▼,
Johnny Uppercut's hunt (four favours around Explore, dropped in the Hidden Glade's pond), the bedroom PC's login (the
real password, or an SQL injection), and the living room's switches. Lines: data/dialogue.json under house_key.
scripts/house_key.py(VPS,house-key.service, nginx/house/api/): issues keys (permanent, signed tokens) and forwards switches to rodney'sPOST /api/house/actwith its own write token (/etc/vivalapanda/house_write_token). Per-key and global rate limits; every key and action is logged with its key id and IP to/var/log/vivalapanda/house_keys.jsonl(rodney logs each action too, in~/.config/home-mcp/house_actions.jsonl).- Kill switch:
touch /etc/vivalapanda/house_writes_offon the VPS, orhome-mcp house-write-token --offon rodney. Revoke one key: add its id torevokedin/etc/vivalapanda/house_keys.json; all: change itssecret. - The PC password is in
~/.config/vivalapanda/house_pc_passwordon rodney (hashed on the VPS). To change it:ssh root@<vps> python3 /var/www/html/vivalapanda.moe/scripts/house_key.py set-password < newfile. - The hunt's words are in
js/house-key.js(ITEMS); after changing them, runhouse_key.py set-huntwith the four.
The feed. The house state is a static file, house/state.json (gitignored), so visitors never reach the house:
- rodney (Panda's home server) runs home-mcp, which answers
GET /api/houseathome.vivalapanda.moethrough its Cloudflare tunnel, only for this site's server: a read-only token plus an IP allowlist. - On the VPS,
house-feed.timerrunsscripts/house_feed.pyevery minute (Python standard library only).- It writes
house/state.jsonatomically. - A failed fetch leaves the last good file in place.
- The token is in
/etc/vivalapanda/house_token(mode 600). - The unit files' headers say how to install them.
- It writes
- The pages fetch the file every minute.
When it breaks. Nothing on the page depends on the feed being up:
- State older than 30 minutes, or missing: the devices count as unknown, so lamps are off and the dialogue uses
its
hover_unknownlines. The time of day still follows the house's clock, worked out in the browser from the sun at San Francisco. The readout says "offline". - Scene can't load, or the browser blocks canvas readback: the room is its static picture
(
img/living-room/noon@4x.png,img/room/room@4x.png). Its hotspots still work, and the living room's two doors keep fallback outlines so you can always go on or out. - To check the feed:
systemctl list-timers house-feed.timerandjournalctl -u house-feed.serviceon the VPS.
Timers on the VPS (all in scripts/systemd/, installed by copying to /etc/systemd/system/):
house-feed: every minute.substack-feed(the blog) andgoodreads-feed(the reading list): hourly.
Dev. dev.vivalapanda.moe serves rodney's working tree, through the same tunnel:
- It runs
scripts/dev_server.pyfrom the user unitscripts/systemd/vivalapanda-dev.service. - It serves only git-tracked files and the generated feeds.
- It always reads
house/state.jsonfrom production, since only the VPS may call the house. - Untracked files 404 on dev until you
git addthem.
Explore check. tests/explore_check.py loads every Explore place on dev at desktop and phone size and
fails on page errors, text that overflows its box, click zones that are covered or swallow the picture, and link
targets that don't exist; it warns about home menus with more than five choices. Run it before pushing:
uv run --with playwright tests/explore_check.py [base-url] [place ...].
Cache-busting. scripts/build.py stamps assets by content hash; scripts/build.py --check fails if a page's
stamps are stale (the Explore check runs it).
Fonts. The pc-98 font is split so pages load fast:
fonts/pc-9800-core.woff2(and the bold one) holds the glyphs the site uses, 12KB. It's made byscripts/subset_fonts.py; re-run that after adding text in a new script.css/style.cssgives every other character to the full font throughunicode-range. A new character therefore still renders, just with a bigger download.- If the core file can't load, the full font stands in.
microtemplate on Linux. The committed microtemplate binary is for macOS (arm64). Elsewhere, build it with Go:
git clone https://github.com/VivaLaPanda/microtemplate && cd microtemplate && go build -o ~/.local/bin/microtemplate .