Skip to content

Latest commit

 

History

History
610 lines (530 loc) · 31.1 KB

File metadata and controls

610 lines (530 loc) · 31.1 KB

SPEC.md — Pixel Camp 🏕️

1. Project Overview

Pixel Camp is a cozy, browser-based ambient pixel-art campsite generator. It shows an animated pixel-art campsite scene that reacts to the visitor's local time of day and real weather, layered with ambient audio (crackling fire, cicadas, rain). A small "camp companion" occasionally offers gentle, funny one-liners. There are no goals — it's a calm tab to leave open.

Inspiration: the painterly pixel backgrounds of Monkey Island and the gentle slice-of-life mood of Yuru Camp.

Audience: techies or people who enjoy cozy pixel art.

Portfolio thesis: almost every asset in this project — backgrounds, character sprites, (some) sound effects, music — is AI-generated for it via the reproducible offline pipeline in scripts/ (see §17), and the application code was written with Claude Code. The demonstration is that a complete, polished, tested web app can be produced this way end-to-end. Crucially, no AI runs at runtime: the shipped artifact is plain static files.

Core values (non-negotiable)

  • Static-first: ships as static files, deployable by drag-and-drop to a cPanel public_html folder. No server runtime required.
  • Transparency & privacy: Nothing about the visitor is ever stored, logged, or transmitted. All "observations" happen client-side and are forgotten on refresh. This is a selling point, not a footnote.
  • Extensibility: Adding a new scene or a new companion joke must mean "drop in a folder/file" — zero changes to core code.
  • Restraint: Modern tech, minimal dependencies, no hype. AI is the studio, not a runtime dependency — no inference in the shipped app.

2. Tech Stack

Be specific and use these versions (or the latest stable at build time):

  • Build tool: Vite (latest stable) with vite-plugin-singlefile for the optional one-file export.
  • Language: TypeScript (strict mode).
  • Rendering: PixiJS v8+ (Canvas/WebGL, sprite layers, parallax, tinting).
  • Audio: Native Web Audio API (no library) with gain-node crossfading.
  • i18n: Tiny custom JSON dictionary loader (no heavy framework). Locales: en (default), de, ja, and zh (Simplified Chinese).
  • PWA: vite-plugin-pwa (optional install + offline caching).
  • Data source: Open-Meteo (https://api.open-meteo.com) — no API key, sends CORS headers, called directly from the client.

Do not add React/Vue/Svelte, state-management libraries, or any backend framework. Keep the dependency tree small and auditable.


3. Commands

npm install # install dependencies npm run dev # start Vite dev server (hot reload) npm run build # standard static build -> dist/ npm run build:single # single-file build -> dist-single/index.html npm run preview # preview production build locally npm run test # run unit tests (Vitest) npm run lint # ESLint npm run format # Prettier npm run typecheck # tsc --noEmit npm run generate:placeholders # regenerate PWA icons (no AI, no network)

Before considering any task complete, npm run lint, npm run typecheck, and npm run test must all pass.


4. Project Structure

pixel-camp/ ├── index.html # app shell + all UI overlay CSS ├── package.json ├── tsconfig.json ├── vite.config.ts ├── Dockerfile # optional nginx wrapper around dist/ ├── README.md ├── CLAUDE.md # fast-orientation summary for the agent ├── LICENSE # MIT for code ├── CREDITS.md # provenance for every asset ├── .env.example # AI_API_BASE_URL / AI_API_KEY for scripts/ ├── src/ │ ├── main.ts # entry point, boot sequence │ ├── core/ # logic (pure, unit-tested) + PixiJS renderers │ │ ├── SceneManager.ts # owns the stage; swaps backgrounds │ │ ├── sceneLoader.ts # manifest discovery + validation │ │ ├── sceneAssets.ts # ?url asset resolution │ │ ├── AudioMixer.ts # gated ambient layers + crossfade │ │ ├── BackgroundMusic.ts # shuffled music playlist, lazy-loaded │ │ ├── Weather.ts # Open-Meteo fetch + fallback chain │ │ ├── TimeOfDay.ts # local clock -> phase (dawn/day/dusk/night) │ │ ├── Companion.ts # picks + displays observation lines │ │ ├── Observer.ts # safe client-side signal detectors │ │ ├── companionEffect.ts # camper state/activity/pathfinding (pure) │ │ ├── poseAnim.ts # idle/activity pose-strip frame picker (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 state machine (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 │ │ ├── debug.ts / debugOverrides.ts # console summary + ?time/?weather/?os/?scene/?poses │ │ ├── runtimeMode.ts # standalone-PWA vs. browser-tab detection (see §7) │ │ ├── sessionState.ts # tab-lifetime (or, in an installed PWA, permanent) UI prefs (see §7) │ │ └── i18n.ts # dictionary loader + t() helper │ ├── types/ │ │ ├── scene.ts # Scene manifest TypeScript types │ │ └── observation.ts # Observation TypeScript types │ └── ui/ # DOM overlays (bubble, notice, toggles, loading) ├── scenes/ │ └── lakeside/ # v1 scene (see schema §5) │ ├── 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/ # companion jokes (see schema §6) │ ├── os-windows.json │ ├── night-owl.json │ └── ... ├── locales/ │ ├── en.json │ ├── de.json │ ├── ja.json │ └── zh.json ├── scripts/ # offline AI asset pipeline (see §17) ├── public/icons/ # PWA app icons └── tests/ └── *.test.ts

Scenes and observations are auto-discovered via Vite's import.meta.glob('/scenes/*/scene.json') and import.meta.glob('/observations/*.json'). Never hardcode a registry.


5. Scene Manifest Schema (scene.json)

A scene is fully self-contained. Adding a scene = adding a folder. The schema is deliberately stable so a whole new scene — art, sprites and manifest — can be dropped in without touching core code.

{ "id": "lakeside", "name": { "en": "Lakeside", "de": "Am See" }, "backgrounds": { // one pre-rendered composite per phase x weather bucket "dawn": { "clear": "layers/dawn-clear.jpg", "rain": "layers/dawn-rain.jpg", "thunderstorm": "layers/dawn-thunderstorm.jpg", "snow": "layers/dawn-snow.jpg", "fog": "layers/dawn-fog.jpg" }, "day": { "clear": "layers/day-clear.jpg", "rain": "layers/day-rain.jpg", "thunderstorm": "layers/day-thunderstorm.jpg", "snow": "layers/day-snow.jpg", "fog": "layers/day-fog.jpg" }, "dusk": { "clear": "layers/dusk-clear.jpg", "rain": "layers/dusk-rain.jpg", "thunderstorm": "layers/dusk-thunderstorm.jpg", "snow": "layers/dusk-snow.jpg", "fog": "layers/dusk-fog.jpg" }, "night": { "clear": "layers/night-clear.jpg", "rain": "layers/night-rain.jpg", "thunderstorm": "layers/night-thunderstorm.jpg", "snow": "layers/night-snow.jpg", "fog": "layers/night-fog.jpg" } }, "campfire": { // optional: anchors the code-driven campfire effect (see §8) "position": { "x": 0.501, "y": 0.682 } // relative 0–1 coords, base of the flame }, "owl": { // optional: perch points + scene-local art for the blinking owl easter egg (see §8) "sprites": { "open": "owl/owl-open.png", "closed": "owl/owl-closed.png" }, "positions": [ { "x": 0.1256, "y": 0.3716 }, { "x": 0.247, "y": 0.3662 } ] }, "raccoon": { // optional: forest-edge routes + scene-local art for the rare raccoon cameo (see §8) "sprites": { "walkSheet": "raccoon/walk-sheet.png", "confused": "raccoon/confused.png", "trip": "raccoon/trip.png", "tumble": "raccoon/tumble.png" }, "routes": [ { "enter": { "x": -0.04, "y": 0.66 }, "stage": { "x": 0.10, "y": 0.67 }, "exit": { "x": -0.04, "y": 0.66 } } ], "cover": [ { "x": -0.02, "y": 0.61 }, { "x": 0.0, "y": 0.6128 }, { "x": 0.0439, "y": 0.6076 }, { "x": 0.0439, "y": 0.691 } ] }, "iceSparkle": { // optional: points that sparkle in sunshine (see §8) "positions": [ { "x": 0.3369, "y": 0.1736 }, { "x": 0.4443, "y": 0.1563 } ] }, "companion": { // optional: the code-driven wandering character (see §8) "walkableZone": [ // closed polygon (relative 0–1 coords, ≥3 points); { "x": 0.06, "y": 0.71 }, { "x": 0.07, "y": 0.58 }, { "x": 0.95, "y": 0.57 }, { "x": 0.95, "y": 0.75 }, { "x": 0.49, "y": 0.78 } // random wandering never leaves this shape ], "avoidZone": { // optional circular no-go area, e.g. the campfire "center": { "x": 0.501, "y": 0.682 }, "radius": 0.025 }, "spots": { // named points she walks to; referenced by activity.spot "fish": { "x": 0.72, "y": 0.703 }, "tentEntrance": { "x": 0.3271, "y": 0.6736 }, "read": { "x": 0.165, "y": 0.708 }, "roast": { "x": 0.41, "y": 0.6968 }, "roastFire": { "x": 0.428, "y": 0.6968 }, // a few px closer to the fire; used by roasting/toastingSausage "sip": { "x": 0.4023, "y": 0.7014 } }, "sprites": { // the two always-needed poses "walkSheet": "companion/walk-sheet.png", // horizontal strip; lakeside is 8 frames (companion.walkFrameCount) at 8 fps (companion.walkFps) "idle": "companion/idle.png" // still or a horizontal strip; auto-sliced by frameSize, blinks if 2+ frames }, "activities": [ // everything else she does; each one sprite + gating { "id": "roasting", "sprite": "companion/roasting.png", // still or horizontal strip, auto-sliced "spriteAnim": "loop", "spriteFps": 2, // omitted = loop if 2+ frames; blink/rare/oneshot/still also valid "weight": 2, "weather": ["clear", "fog", "wind"], // omitted = any weather "spot": "roast", // omitted = random point in the zone "duration": [6, 10], // seconds, [min, max] "fixedOrientation": true }, // always faces right (toward the fire) { "id": "stargazing", "sprite": "companion/stargazing.png", "weight": 1, "activePhases": ["night"], "weather": ["clear"], "duration": [8, 14] }, { "id": "napping", "sprite": "companion/napping.png", "weight": 2, "spot": "tentEntrance", "duration": [10, 18], "badWeatherWeight": 6 } // overrides weight in rain/snow/storm ] }, "audio": [ { "id": "fire", "src": "audio/fire.ogg", "loop": true, "baseVolume": 0.6 }, { "id": "cicadas", "src": "audio/cicadas.ogg", "loop": true, "baseVolume": 0.4, "activePhases": ["day", "dusk"], // only plays during these phases "weather": ["clear", "fog", "wind"] }, // and only in this weather { "id": "rain", "src": "audio/rain.ogg", "loop": true, "baseVolume": 0.5, "weather": ["rain", "drizzle", "thunderstorm"] } // gated by weather alone ] }

Background music is not listed in the manifest: any audio/bg_*.ogg file in the scene folder is auto-discovered and sorted numerically, so dropping in a bg_6.ogg just works. See BackgroundMusic.ts.

Every phase must provide all five weather-bucket entries (20 total per scene). Finer-grained WeatherCondition values collapse onto whichever bucket its art most resembles: drizzle -> rain, wind -> clear, unknown/undefined -> clear. See resolveWeatherBucket in sceneUtils.ts.

All motion on top of the static background — campfire flicker/embers, rain, snow, wind, fog, thunderstorm lightning, fireflies, flickering stars — is rendered as particles/procedural animation in code (see §8), not baked into any video clip. Two video-based approaches were tried and dropped: a full-scene video per background (loop seams were too visible at this resolution/duration) and small looping clips for individual elements like the campfire (the code-driven version simply looked better and has zero asset/bundle-size cost).

Validate manifests at load time; log a clear console error and skip a broken scene rather than crashing the app.

When more than one valid scene manifest is discovered, one is picked at random on each load rather than favoring whichever directory sorts first alphabetically (manifests[0]). A ?scene=<id> debug override (debugOverrides.ts, see README.md's Debugging section) pins a specific one instead; an unrecognized id warns and falls back to a random pick. The on-screen debug picker (DebugScenePicker.ts) exposes the same override as a dropdown, shown only when more than one scene exists, with option labels sourced from each manifest's localized name field. ?poses=on cycles every companion pose in the loaded scene (idle, in-place walk, then each activity in manifest order) with no weather or phase filter; gated poses preview a matching time/weather so they are visible. Arrow keys step pose (up/down) or scene (left/right).


6. Companion / Observation Schema (observations/*.json)

Each file is one playful, client-side-only observation: a detector plus a pool of localized lines. The companion fires whichever detectors match, weights them, and shows one occasionally (not a data dump).

{ "id": "os-windows", "detector": "os", // maps to a function in Observer.ts "match": "Windows", // value the detector must return to fire "weight": 1, // relative frequency "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."] } }

Allowed detectors (SAFE — whitelist)

Observer.ts may ONLY implement these. They use obvious, non-identifying signals:

  • os — from navigator.userAgentData / UA (Windows / macOS / Linux / other)
  • timeOfDay — local clock bucket (e.g. "lateNight")
  • battery — Battery Status API, low/charging (where supported)
  • connectionnavigator.connection.effectiveType (slow/fast)
  • colorSchemeprefers-color-scheme
  • reducedMotionprefers-reduced-motion
  • localenavigator.language
  • coresnavigator.hardwareConcurrency (bucketed: few/many)
  • pointer — touch vs. mouse (matchMedia('(pointer: coarse)'))
  • privacy — Do Not Track / Global Privacy Control signal

FORBIDDEN — never implement (creepy / fingerprinting)

  • Canvas/WebGL fingerprint hashing
  • Font enumeration
  • Precise geolocation or timezone-to-city triangulation
  • Any combining of signals to build a unique identifier
  • Anything persisted to storage, cookies, or sent over the network

7. Privacy Guarantee (must be enforced in code AND UI)

  • No localStorage/cookies of visitor signals; no analytics; no network calls except loading the app's own assets, the Open-Meteo weather fetch, and (only as a fallback, if browser geolocation is denied/unsupported/times out) an IP-based location lookup via ipwho.is so that fetch still has coordinates to use.
  • Include a visible, dismissible one-liner in the UI acknowledging both of those network calls, e.g.: "All observations happen in your browser and stay there — the only network calls fetch this page and an anonymous weather/location lookup (Open-Meteo, with ipwho.is as a location fallback). Go read the source." Dismissal is remembered for the rest of the tab's sessionStorage lifetime (not localStorage/cookies, not sent anywhere, cleared when the tab closes) so it doesn't reappear every time the debug time/weather picker reloads the page — this is a UI dismissal flag, not a "visitor signal" under §6's storage ban. The same handful of UI preferences (sessionState.ts: locale, popups-enabled, music on/off + track) follow the same rule. Exception: when running as an installed, standalone PWA (runtimeMode.isStandalonePwa(), detected via the display-mode: standalone media query with an iOS navigator.standalone fallback — not a §6 detector, since it isn't combined with anything else or sent anywhere), these same preferences and the notice dismissal persist in localStorage instead, so an installed app doesn't lose its settings or re-show the notice on every relaunch. This is a narrow, deliberate exception scoped to exactly these UI-preference keys — never extend it to visitor signals.
  • Document this prominently in README.md.
  • index.html ships a Content-Security-Policy meta tag pinning connect-src to 'self', ipwho.is, api.open-meteo.com, and data: (needed for PixiJS's internal ImageBitmap decode-support probe, which resolves a data: URL and never leaves the page), plus script-src 'self', object-src 'none', base-uri 'none', and form-action 'none'. style-src needs 'unsafe-inline' for the inline <style> block in <head>; worker-src needs blob: for the Workbox service worker. frame-ancestors is deliberately omitted: it's inert inside a <meta> tag and only takes effect via a real HTTP response header, which a static, drag-and-drop-to-cPanel deployment doesn't control — see the comment above the CSP meta tag in index.html. Single-file build caveat: npm run build:single (vite-plugin-singlefile) inlines the entire app as a bare <script type="module"> with no src, which a plain script-src 'self' blocks outright. vite.config.ts loosens script-src to 'self' 'unsafe-inline' for that build only (via a small transformIndexHtml plugin, post-inlining) — the standard npm run build output keeps the strict script-src 'self'. This was verified by diffing the built dist/index.html vs dist-single/index.html CSP meta tags, not by loading either build in an actual browser with the console open (no browser was available in the environment this was implemented in) — do that check before relying on this for a real deployment.

8. Weather & Time Behavior

  • TimeOfDay.ts derives phase from the visitor's local clock: dawn / day / dusk / night. Drives which pre-rendered background composite is shown and gates ambient audio tracks.
  • Weather.ts fetches current conditions from Open-Meteo. Graceful degradation: if geolocation is denied or the fetch fails, fall back to a pleasant default (clear weather) — never block the scene or show an error to the user. Weather is optional flavor, not a hard dependency.
  • Ask for geolocation politely and only once; if denied, proceed silently.
  • SceneManager swaps in the single pre-rendered static background image matching the current phase + weather bucket (see §5); the overall lighting mood is baked into that art. All motion on top of it is rendered in code (see campfireEffect.ts / CampfireRenderer.ts and weatherEffects.ts / WeatherEffectsRenderer.ts) rather than baked into any video clip.
  • The campfire (flickering flame licks, rising embers, a soft glow) is anchored to the campfire already painted into the background art via the optional campfire manifest field (see §5); a scene with no campfire in its art simply omits it.
  • A small chibi camper character (companionEffect.ts / CompanionRenderer.ts — note: distinct from Companion.ts, which is the one-liner system in §6) wanders the scene, constrained to the optional companion.walkableZone polygon in the manifest (see §5) so she never walks on water. She cycles through idling, walking to a randomly chosen next activity, and holding that activity for a while. A scene with no companion field simply renders no character.
    • 25 activities, each declared in the manifest with its own sprite, weight, duration range and gating: roasting, fishing, reading, sitting, admiring, waving, napping, bug-watching, drinking, catching breath, playing ukulele, birdwatching, sketching, picking flowers, photographing, stretching, sipping from a mug, toasting a sausage, writing a postcard, stargazing, holding a lantern, pointing at constellations, holding an umbrella, catching snowflakes, and huddling against the cold.
    • Gating is weather- and phase-aware, driven entirely by manifest data rather than hardcoded rules: weather and activePhases allow-lists decide availability, and badWeatherWeight overrides an activity's weight during rain/drizzle/thunderstorm/snow (so napping becomes far likelier in a storm). Activities anchored to a named companion.spots entry walk there first; the rest happen at a random point in the walkable zone.
    • Rendering uses real sprite art from the scene folder (scenes/lakeside/companion/): an 8-frame walk-cycle sheet cycled while walking, a standing/idle pose that blinks if it's a strip, and a dedicated pose per activity (a still or a horizontal strip auto-sliced by frameSize, played as loop / blink / rare / oneshot). Right-facing movement mirrors the (left-facing) source art horizontally in code rather than shipping separate art; an activity may set fixedOrientation to always face a particular way regardless of which direction she arrived from (roasting faces the fire).
    • Pathfinding: walking paths steer around the optional circular companion.avoidZone (the campfire), and are routed through the walkableZone polygon's own vertices via a visibility graph + Dijkstra shortest path whenever a straight line between waypoints would cut outside a concave stretch of the walkable area — e.g. the shoreline dipping toward the water between two activity spots (planWalkPath in companionEffect.ts). Random wander targets keep her oval ground-shadow inside the polygon, not just her foot point. The lakeside zone adds a tent-apron notch and shoreline indents on top of the original peninsula outline; it does not enlarge the walkable area.
    • Per-weather outfit art doesn't exist yet, so resolveOutfit in companionEffect.ts is exported and tested but currently unused by the renderer — kept for when it does.
  • Two small ambience effects are attached to the character rather than the scene: floating Zzz glyphs while she naps (zzzEffect.ts) and steam rising from a hot drink while she sips (steamEffect.ts). Both are code-drawn particles, not sprite art.
  • An owl (owlEffect.ts / OwlRenderer.ts) perches at one of the points in the optional owl.positions list, blinks occasionally, and responds to a click with a deliberately quiet easter egg.
  • A raccoon (raccoonEffect.ts / RaccoonRenderer.ts) occasionally walks or tumbles in along one of the optional raccoon.routes, does a short clumsy bit (confused look / trip / tumble), then leaves off-canvas. Dawn/dusk/night and decent weather only; visits are rare and in-memory. A click pops a startled "!" and it flees; it also flees if the camper walks close. Optional raccoon.cover is a relative 0–1 polygon of baked foreground (e.g. a shoreline rock) that clips the sprite in 320×180 scene space so the raccoon walks behind that outline at any window size.
  • Ice-cap sparkles (iceSparkleEffect.ts / IceSparkleRenderer.ts) glint intermittently at the optional iceSparkle.positions points, but only when the weather and phase mean there's sunshine to catch.
  • Particle effects (weatherEffects.ts / WeatherEffectsRenderer.ts) are purely weather/phase-driven and independent of any baked art:
    • Precipitation (at most one active at a time, driven by weather alone): rain, snow, wind (drifting streaks), fog (soft haze bands), thunderstorm (heavier rain + occasional lightning flash).
    • Night sky (driven by phase + weather together, and can render alongside a precipitation effect like wind): fireflies drifting near the ground on a clear night, flickering stars whenever the sky isn't obscured by cloud, fog, or precipitation, and a few soft, translucent clouds slowly drifting left-to-right across a clear night sky.
    • Respects prefers-reduced-motion by rendering far fewer particles and disabling lightning flashes, rather than hiding the effect entirely.

9. Internationalization

  • All user-facing strings live in locales/{en,de,ja,zh}.json.
  • Default to en; auto-select de / ja / zh when navigator.language starts with that prefix (zh* is Simplified Chinese).
  • Provide a manual language toggle in the UI.
  • Scene names and observation lines are localized inline in their own JSON (see schemas above).

10. Code Style

  • TypeScript strict mode; no any unless justified with a comment.
  • ESLint + Prettier; 2-space indent; single quotes; semicolons.
  • Prefer small, pure, testable functions. Side effects isolated in core/.
  • Naming: PascalCase classes, camelCase functions/vars, kebab-case files for data (scenes/observations), PascalCase.ts for classes.
  • Example of expected style:

export function pickPhase(date: Date): TimeOfDayPhase { const hour = date.getHours(); if (hour < 6) return 'night'; if (hour < 9) return 'dawn'; if (hour < 18) return 'day'; if (hour < 21) return 'dusk'; return 'night'; }

  • Every exported function in core/ and every detector in Observer.ts must have a unit test.

11. Testing

  • Framework: Vitest.
  • Location: tests/, mirroring src/ structure.
  • Unit-test: TimeOfDay phase logic, manifest validation, observation matching/weighting, i18n fallback, and each safe detector (mock navigator/matchMedia).
  • Mock all browser APIs; tests must run headless in CI with no network.
  • Weather fetch: test the fallback path (fetch throws → default returned).

12. Git Workflow

  • Branch naming: feature/<short-desc>, fix/<short-desc>.
  • Conventional Commits: feat:, fix:, docs:, refactor:, test:, chore:.
  • Keep PRs small and scoped to a single task/section of this spec.
  • Never commit: secrets, .env files, node_modules/, build output (dist/, dist-single/), assets/.

13. Licensing (public repo)

  • Code: MIT (LICENSE).
  • Assets (art + audio): AI-generated for this project, or CC0. No CC-BY, no custom "free" licenses that restrict redistribution. Record provenance in CREDITS.md as good practice even where no attribution is legally required.
  • Current state: all art (20 backgrounds, 28 camper sprites, 2 owl sprites, raccoon walk/confused/trip/tumble frames) and the thunder SFX and music are AI-generated (see §17); the six ambient audio beds are CC0 from Freesound. Nothing else is used.
  • If external assets are ever added, CC0-filtered OpenGameArt / Kenney.nl are the suggested sources — but generating them through the §17 pipeline is preferred, since reproducibility is the point of the project.

14. Build & Deploy (cPanel)

  • npm run build → upload contents of dist/ into public_html (or a subfolder). No runtime needed.
  • npm run build:single → a single portable index.html for anyone who wants one-file deployment (assets inlined; note this bloats the file — the standard bundle is preferred).
  • Document both paths in README.md.

15. Roadmap (phased — do NOT build ahead)

Phase 1 — shipped:

  • Static app, one polished lakeside scene reacting to real time + weather.
  • The AI asset pipeline (§17) and all final art, SFX and music produced by it.
  • 26 camper activities with manifest-driven gating and visibility-graph pathfinding.
  • Code-driven weather, campfire, owl, raccoon, ice-sparkle, Zzz and steam effects.
  • Ambient audio mixer with crossfades, plus the background music player.
  • Client-side companion with the safe observation system.
  • i18n (en/de/ja/zh), PWA, dual build targets, Docker option.

Phase 2 — next:

  • A second scene (deep-sea / diver) to prove the drop-in scene system across a completely different setting and character. This is the main open architectural question: sprites and activities are already scene-owned (companion.activities in the manifest), so the test is whether a new scene needs zero core changes in practice.
  • An automated pipeline that generates an entire new scene — backgrounds, sprites and manifest — from prompts, extending §17 end-to-end.

16. Working Instructions for the Agent

  • Plan first. Before writing code, produce a short implementation plan and a task breakdown mapped to the sections above. Confirm before executing.
  • Work in small, reviewable increments, one section/task at a time.
  • If a requirement is ambiguous, ask rather than assume.
  • Respect the boundaries in §6 (forbidden detectors), §7 (privacy), and §12 (never-commit list) at all times.
  • After each task: run lint, typecheck, and test; report status.
  • Suggested build order: (1) project scaffold + build config → (2) types + manifest loaders + validation + tests → (3) SceneManager rendering + tinting → (4) AudioMixer → (5) TimeOfDay + Weather → (6) Observer + Companion → (7) i18n + UI overlays + privacy notice → (8) PWA + single-file build → (9) README + CREDITS.

17. Asset Pipeline (scripts/)

Offline, optional, and not part of the shipped app — the finished art is committed, so building Pixel Camp never requires running any of this. It exists because reproducing the assets from prompts is a core claim of the project (§1).

Requires a .env with AI_API_BASE_URL and AI_API_KEY (see .env.example). Scripts are run with node --env-file=.env scripts/<name>.mjs.

Script Role
gen-image.mjs Text-to-image, and reference-guided image-edit for character consistency; async job polling
remove-bg.mjs Model-based background removal (ideogram-remove-background)
chroma-key.mjs Knocks out the magenta key, trims transparent margins
pixelate.mjs Nearest-neighbour downscale to the target pixel grid
build-edit-canvas.mjs Assembles reference canvases for character-consistent edits
normalize-strip.mjs Aligns walk-strip frames to a common anchor
make-sheet.mjs Packs frames into a sprite sheet
batch-sprites.mjs Orchestrates a full run; skips sprites already present
generate-placeholders.mjs PWA icons — model-free, network-free, zlib only

Cost safety is a requirement, not a nicety. gen-image.mjs enforces a MAX_IMAGES cap persisted in .image-budget.json and fails closed when the cap is reached, and caches uploaded reference file_ids in .file-id-cache.json so re-runs don't re-upload. Both cache files and .env are gitignored and must never be committed (§12).

The pipeline is deliberately model-agnostic: AI_API_BASE_URL is configuration, not a hardcoded vendor, so the same scripts work against any OpenAI-compatible image endpoint.