A cozy, browser-based ambient pixel-art campsite that reacts to your local time of day and your real weather. A chibi camper wanders the site — fishing, roasting marshmallows, playing ukulele, stargazing, hiding under an umbrella when it rains. Ambient audio crossfades with the conditions. A "camp companion" occasionally offers a gentle one-liner based on harmless things it can tell about your browser. There are no goals — it's a calm tab to leave open and relax.
Inspired by the painterly pixel backgrounds of Monkey Island and the gentle slice-of-life mood of Yuru Camp.
Check out the live demo
This project is a portfolio piece with a specific point to make: a complete, polished, production-quality web app — art, animation, sound*, music, and code — built end-to-end with AI, and none of it hand-drawn or licensed from a pack.
* a few sound effects were sourced from freesound.org as mentioned in CREDITS.md
| Layer | How it was made |
|---|---|
| 20 background composites | Flux 2 Pro — one painterly pixel-art render per time-of-day phase × weather bucket |
| 28 character sprites | Flux 2 Pro against a magenta key, background-removed, chroma-keyed, trimmed and pixel-scaled by a sharp pipeline |
| Sound effects | Thunder generated with ElevenLabs Sound Effects V2; ambient beds are CC0 Freesound recordings |
| Music | 5 AI-generated ambient tracks using MiniMax Music 2.6 |
| The asset pipeline itself | 9 Node scripts in scripts/ — generation, background removal, chroma-keying, pixelation, sprite-sheet assembly, batch orchestration |
| The application code | Written with Claude Code — TypeScript strict, 531 unit tests, zero framework |
The pipeline is reproducible, not a one-off: node --env-file=.env scripts/batch-sprites.mjs regenerates the full sprite set from prompts,
skipping anything already rendered. See CREDITS.md for the full
provenance chain and scripts/ for the code.
No AI runs at runtime. The shipped app is static files — the AI was the studio, not a dependency.
Nothing about you is ever stored, logged, or transmitted.
- All "observations" the camp companion makes (your OS, color scheme, pointer
type, etc.) are computed entirely in your browser, from a hardcoded
whitelist of 10 harmless signals (see
src/core/Observer.ts). They are never combined into a fingerprint, never written to a cookie orlocalStorage, and never sent anywhere. They're forgotten the moment you refresh the page. - The only network calls this app ever makes are: loading its own static assets, an anonymous, keyless request to Open-Meteo for the current weather at a deliberately coarsened location (coordinates are rounded to ~1 km before the request leaves your browser — far finer than any weather model needs, far too coarse to identify you), and — only if the browser's own geolocation prompt is denied, unsupported, or times out — a fallback IP-based location lookup via ipwho.is so the weather request still has coordinates to use. Neither third-party service receives anything beyond the request itself, and no result from either is ever stored, logged, or combined with anything else. If both location sources and/or the Open-Meteo fetch fail, the app quietly assumes clear weather and moves on — weather is flavor, never a hard dependency.
- There is no analytics, no tracking pixel, no backend, no database. This is a fully static site — read the source, all of it is right here.
- A
Content-Security-Policymeta tag pins network egress to exactly those two hosts, andscript-srcto'self'with no'unsafe-eval'(PixiJS is imported via itspixi.js/unsafe-eval-free build path specifically so this stays possible). - The app displays a visible, dismissible privacy notice on load, restating
this. In a regular browser tab, dismissal is remembered only for the tab's
sessionStoragelifetime, so it's honest about what it is every session. - A handful of UI preferences you explicitly set — language, whether
companion speech bubbles are on, and background music on/off + track —
are likewise remembered only in
sessionStoragefor that same tab lifetime, purely so a same-tab reload (e.g. from the debug picker) doesn't reset them. Never sent anywhere, gone the moment the tab closes, and never combined with the observation signals above. - Installed-app exception: if you've installed Pixel Camp as a PWA (see
below), it detects that it's running standalone — no browser chrome, its
own window — via the
display-mode: standalonemedia query (with annavigator.standalonefallback for iOS Safari). Since an installed app is reasonably assumed to be your own device rather than a shared tab, the same preferences above (plus the privacy-notice dismissal) persist across app restarts inlocalStorageinstead ofsessionStorage. This is a narrow, deliberate exception to the same-tab-only rule — still just your own UI preferences, still never sent anywhere. Seesrc/core/runtimeMode.ts.
- 20 background composites — 4 time-of-day phases × 5 weather buckets, swapped as your real clock and real weather change (weather re-checked every 15 minutes).
- 25 camper activities — fishing, napping in the tent, reading, roasting marshmallows, sipping from a tin mug, playing ukulele, birdwatching, sketching, picking flowers, photographing, stretching, stargazing, pointing at constellations, holding a lantern, sheltering under an umbrella, catching snowflakes, huddling in the cold… each weather- and phase-gated, each anchored to a named spot or a random point in a hand-authored walkable polygon.
- Code-driven motion, no video — campfire flicker, embers and glow; rain,
snow, wind, fog and thunderstorm lightning; fireflies, drifting clouds and
flickering stars at night; sparkling ice caps in sunshine; floating
Zzzwhen she naps; steam from a hot mug. All procedural particles over static art, so the bundle stays small and nothing loop-seams. - Pathfinding — walking routes avoid the campfire and are routed through the walkable polygon's vertices via a visibility graph + Dijkstra, so she never cuts across water on a concave shoreline.
- 7 ambient audio layers gated by phase and weather, gain-node crossfaded, plus a 5-track music playlist you can toggle and pick from.
- 23 companion one-liners, localized, weighted, and matched against the detector whitelist.
- i18n — English, German, Japanese, and Simplified Chinese, auto-selected
from
navigator.language, with a manual language toggle to override it. - Accessibility —
prefers-reduced-motionis honored throughout: particle counts drop, lightning is disabled, walk animation slows, and CSS animations stop. - Installable PWA — "install as app" from the browser, plus offline
caching via a service worker, courtesy of
vite-plugin-pwa(standard build only — see Deploying). The app detects when it's running installed/standalone vs. in a regular browser tab and adapts its storage accordingly — see the next bullet and 🔒 Privacy guarantee. - A handful of remembered UI preferences — language, whether companion
speech bubbles are on, and the music on/off + track choice — persist across
a same-tab reload via
sessionStorage(gone the moment the tab closes) in a regular browser tab, or across app restarts vialocalStoragewhen installed as a standalone PWA. No visitor data is ever involved — see 🔒 Privacy guarantee. - Click/tap the camper to skip straight to a freshly-picked next activity instead of waiting out whatever she's currently doing.
- A checkbox to turn off the companion's one-liner speech bubbles, if you'd rather just watch the scene.
- An owl. Click it.
- A raccoon that rarely wanders in from the trees at dawn, dusk, or night.
- Build: Vite 6 +
vite-plugin-singlefile(one-file export) +vite-plugin-pwa(install + offline caching) - Language: TypeScript, strict mode
- Rendering: PixiJS v8 (WebGL/Canvas, procedural particles)
- Audio: native Web Audio API (no library), gain-node crossfading
- i18n: a tiny hand-rolled JSON dictionary loader —
en(default) andde - Weather: Open-Meteo — no API key required
- Tests: Vitest + happy-dom — 531 tests across 39 files
- Asset pipeline:
sharp(devDependency, not shipped)
No frontend framework (React/Vue/Svelte), no state-management library, no backend. One runtime dependency: PixiJS. The dependency tree is intentionally small and auditable.
npm install
npm run dev # start the Vite dev server (hot reload)| Command | Description |
|---|---|
npm run dev |
Start the Vite dev server |
npm run build |
Standard static build → dist/ |
npm run build:single |
Single-file build → dist-single/index.html |
npm run preview |
Preview a production build locally |
npm run test |
Run the unit test suite (Vitest) |
npm run lint |
ESLint |
npm run format |
Prettier (writes) |
npm run typecheck |
tsc --noEmit |
npm run generate:placeholders |
Regenerates the procedurally-drawn PWA app icons |
The asset pipeline is offline and optional — the finished art is committed, so you never need to run this to build the app. It's here because reproducing the assets is the point of the project.
cp .env.example .env # add AI_API_BASE_URL and AI_API_KEY
node --env-file=.env scripts/batch-sprites.mjs| Script | Role |
|---|---|
gen-image.mjs |
Text-to-image and reference-guided image-edit calls, with async job polling and a hard spend cap |
remove-bg.mjs |
Model-based background removal |
chroma-key.mjs |
Knocks out the magenta key and trims transparent margins |
pixelate.mjs |
Downscales to the target pixel grid with nearest-neighbour |
build-edit-canvas.mjs |
Assembles reference canvases for character-consistent edits |
normalize-strip.mjs |
Aligns a walk strip to a common anchor |
make-sheet.mjs |
Packs frames into a sprite sheet |
batch-sprites.mjs |
Orchestrates the whole run; skips sprites already present |
generate-placeholders.mjs |
Model-free PWA icon generation using only Node's zlib |
gen-image.mjs enforces a MAX_IMAGES budget in .image-budget.json and
caches uploaded reference file_ids, so re-runs are cheap and a runaway loop
can't drain an account.
Pixel Camp ships as static files — no server runtime needed.
npm install
npm run buildUpload the contents of dist/ into your web root (e.g. cPanel's
public_html/, or a subfolder of it). Supports code-splitting and — if you
don't strip it out — the PWA service worker for offline caching and "install
as app".
npm install
npm run build:singleProduces one self-contained dist-single/index.html with every script, style,
and asset inlined as data URIs. Upload that single file anywhere. Handy for
drag-and-drop hosting with zero folder structure, at the cost of a larger
initial download (assets can't be cached separately, and there's no service
worker in this mode — a single file has nothing separate left for it to cache).
Pixel Camp has no backend, so this is just for hosts that only run containers.
The included Dockerfile builds the app and serves dist/ via
nginx:
docker build -t pixel-camp .
docker run -p 8080:80 pixel-campOpen your browser at http://localhost:8080
npm run build && npm run previewpixel-camp/
├── index.html # app shell + all UI overlay CSS
├── src/
│ ├── main.ts # entry point / boot sequence
│ ├── core/ # logic (pure, unit-tested) + PixiJS renderers
│ │ ├── SceneManager.ts # loads scenes, swaps backgrounds
│ │ ├── sceneLoader.ts # manifest discovery + validation
│ │ ├── TimeOfDay.ts # local clock -> dawn/day/dusk/night
│ │ ├── Weather.ts # Open-Meteo + geolocation fallback chain
│ │ ├── AudioMixer.ts # gated ambient layers + crossfade
│ │ ├── BackgroundMusic.ts # shuffled music playlist, lazy-loaded
│ │ ├── Observer.ts # the whitelisted, safe signal detectors
│ │ ├── Companion.ts # picks + displays observation one-liners
│ │ ├── companionEffect.ts # camper state/activity/pathfinding (pure)
│ │ ├── CompanionRenderer.ts # camper sprite rendering
│ │ ├── weatherEffects.ts # particle simulation (pure)
│ │ ├── campfireEffect.ts # flame/ember/glow simulation (pure)
│ │ ├── owlEffect.ts # blink + easter-egg logic (pure)
│ │ ├── raccoonEffect.ts # forest-edge raccoon visit (pure)
│ │ ├── iceSparkleEffect.ts # ice-cap sparkles (pure)
│ │ ├── zzzEffect.ts # sleep Zzz particles (pure)
│ │ ├── steamEffect.ts # hot-drink steam particles (pure)
│ │ ├── *Renderer.ts # PixiJS display objects for each of the above
│ │ └── i18n.ts # dictionary loader + t() helper
│ ├── types/ # scene.ts / observation.ts manifest types
│ └── ui/ # DOM overlays (bubble, toggles, loading screen)
├── scenes/lakeside/ # the v1 scene — drop-in, self-contained
│ ├── scene.json
│ ├── layers/*.jpg # 20 background composites
│ ├── companion/*.png # 28 character sprites
│ ├── owl/*.png # blinking owl easter-egg frames (open/closed)
│ ├── raccoon/*.png # walk sheet + confused/trip/tumble cameo frames
│ └── audio/*.ogg # 7 ambient beds + 5 music tracks
├── observations/*.json # 23 companion one-liners
├── locales/{en,de,ja,zh}.json
├── scripts/ # the offline AI asset pipeline (not shipped)
├── public/icons/ # PWA app icons
└── tests/ # mirrors src/, one test file per module
Scenes, observations, locales, and scene assets are all auto-discovered at
build time via Vite's import.meta.glob — nothing is hardcoded in a registry
anywhere.
Adding a scene is meant to be "drop in a folder" — zero core code changes.
- Create
scenes/<your-id>/scene.jsonfollowing the schema used byscenes/lakeside/scene.json: a background image per phase × weather bucket (20 total), plus optionalcampfire,companion,owl,raccoon, andiceSparkleanchors and anaudiolist. - Drop the referenced JPGs, PNGs and
.oggfiles alongside it. - That's it — picked up automatically on the next build/dev reload. Invalid manifests are logged clearly to the console and skipped rather than crashing the app.
If more than one scene folder exists (say scenes/beach/, scenes/lakeside/,
scenes/mountains/), one is picked at random each time the app loads —
not alphabetically, so every scene gets an equal chance regardless of its
directory name. A visitor can pin a specific one with ?scene=<id> (see
Debugging), and the on-screen scene picker offers a dropdown to
switch between them whenever more than one is available.
All motion (weather particles, campfire, the camper) is rendered in code on top of the static background — no video assets needed. See SPEC.md §5 for the full manifest schema.
Each file in observations/ is one client-side one-liner: a
detector name, the value it must match, a relative weight, and localized lines.
For example, observations/os-windows.json:
{
"id": "os-windows",
"detector": "os",
"match": "Windows",
"weight": 1,
"lines": {
"en": ["Don't forget to close your Windows — it's getting cold out here."],
"de": ["Vergiss nicht, deine Fenster zu schließen — es wird kalt draußen."]
}
}Drop a new JSON file into observations/ and it's auto-discovered — no code
change required.
Allowed detectors are a hardcoded whitelist in
src/core/Observer.ts: os, timeOfDay, battery,
connection, colorScheme, reducedMotion, locale, cores, pointer,
privacy. Nothing else may ever be added — no canvas/WebGL fingerprinting, no
font enumeration, no precise geolocation, no combining signals into anything
identifying, and nothing is ever persisted or sent over the network. See
SPEC.md §6–§7 for the full, non-negotiable rules.
On-screen, a time and weather picker lets you pin the scene to any phase or condition without waiting for night to fall or rain to start — useful for demoing, and for checking art against every one of the 20 background composites. Whenever more than one scene folder is available, a scene picker appears alongside it, letting you jump straight to a specific one instead of waiting for a random reload to land on it.
Open DevTools and a collapsed "Pixel Camp debug" group is logged on boot, on every phase change, and on every weather refresh, showing exactly what the app sees: local time + phase, weather + how it was determined (geolocation/IP/fallback), the loaded scene, every audio track's active/gated state + volume, and the companion's resolved signals. It's read-only diagnostic output — nothing is stored or sent.
The same pinning is available via URL query parameters:
?time=night # dawn | day | dusk | night
?weather=rain # clear | rain | drizzle | thunderstorm | snow | wind | fog
?os=Linux # Windows | macOS | Linux | other (pins the companion's os signal)
?popups=off # on | off (overrides the popups-enabled checkbox's default)
?scene=lakeside # pins which scene loads instead of the random pick (see "Adding a scene")
?poses=on # cycle every companion pose (idle, walk, then every activity; ignores weather/time; ↑↓ pose, ←→ scene)
?raccoon=on # skip the raccoon cooldown (still needs ?time=dawn|dusk|night and decent weather)
?time=dusk&weather=fog # any combination together
An invalid value is ignored (with a console warning) rather than breaking the
app. Pinning ?time= disables the real-clock polling loop; pinning ?weather=
disables the Open-Meteo fetch and its 15-minute refresh; ?os= exercises
OS-gated observations (e.g.
observations/os-linux.json) without needing a
different machine; an unrecognized ?scene= id falls back to the normal
random pick (with a console warning) rather than breaking the app.
?poses=on walks every companion pose in the loaded scene (idle, walk cycle,
then each activity in manifest order) with no weather or time-of-day filter —
night-only and snow-only actions still appear. The catalog previews a matching
phase/weather for gated poses so you can actually see them. Up/Down or [ ]
step poses, Left/Right jump to the previous/next scene.
?raccoon=on skips the cameo's 90s–16min cooldown so a visit starts as soon
as dawn/dusk/night and decent weather are met (pair with ?time=night).
- Code: MIT — see
LICENSE. - Art, sound effects and music: AI-generated for this project, or CC0
where sourced externally. Nothing under a restrictive or
attribution-required license is used. See
CREDITS.mdfor the full provenance of every asset.
Shipped: the static app; the lakeside scene reacting to real time and
weather; the AI asset pipeline; 26 camper activities with pathfinding; code-driven
weather, campfire and ambience effects; the ambient mixer and music player; the
client-side companion; i18n (en/de/ja/zh); PWA and dual build targets.
Next: a second scene (deep-sea / diver) to prove the drop-in scene system across a completely different setting and character, and an automated pipeline that generates a whole new scene — backgrounds, sprites and manifest — from prompts.
Documented, deliberately not built: an actual AI camp companion, preferring a precomputed offline-generated line pool or a client-side model (WebLLM/transformers.js via WebGPU) over any server-hosted inference — and if server inference is ever added, it must ship with a hard global spend cap, per-IP rate limiting, and a CAPTCHA/edge layer. See SPEC.md §15.