Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pixel Camp 🏕️

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

screencast

(Almost) Everything you see and hear was generated by AI

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.


🔒 Privacy guarantee

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 or localStorage, 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-Policy meta tag pins network egress to exactly those two hosts, and script-src to 'self' with no 'unsafe-eval' (PixiJS is imported via its pixi.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 sessionStorage lifetime, 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 sessionStorage for 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: standalone media query (with an navigator.standalone fallback 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 in localStorage instead of sessionStorage. This is a narrow, deliberate exception to the same-tab-only rule — still just your own UI preferences, still never sent anywhere. See src/core/runtimeMode.ts.

What's in it

  • 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 Zzz when 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.
  • Accessibilityprefers-reduced-motion is 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 via localStorage when 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.

Tech stack

  • 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) and de
  • 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.


Getting started

npm install
npm run dev          # start the Vite dev server (hot reload)

All commands

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

Regenerating the art

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.


Deploying

Pixel Camp ships as static files — no server runtime needed.

1. Standard build (recommended)

npm install
npm run build

Upload 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".

2. Single-file build

npm install
npm run build:single

Produces 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).

3. Docker (optional)

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-camp

Open your browser at http://localhost:8080

Verify locally before deploying

npm run build && npm run preview

Project structure

pixel-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

Adding a scene is meant to be "drop in a folder" — zero core code changes.

  1. Create scenes/<your-id>/scene.json following the schema used by scenes/lakeside/scene.json: a background image per phase × weather bucket (20 total), plus optional campfire, companion, owl, raccoon, and iceSparkle anchors and an audio list.
  2. Drop the referenced JPGs, PNGs and .ogg files alongside it.
  3. 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.


Adding a companion observation

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.


Debugging

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).


Assets & licensing

  • 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.md for the full provenance of every asset.

Roadmap

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.

About

A cozy, browser-based ambient pixel-art campsite

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages