@"/root/.claude/uploads/ccf71eb5-b431-519a-bc9e-b57105af9910/6c6977e7-IMG_7213.JPG" @"/root/.claude/uploads/ccf71eb5-b431-519a-bc9e-b57105af9910/c0180cca-IMG_7212.JPG" Build this 3D game, for the browser.
This repo is configured to serve static files so make sure there is an index.html that loads everything else.
Make sure it is mobile-friendly (touch controls, works well on small screens).
You have an OpenAI API key and access to their image generation model APIs, use that for textures to use with your 3D models. Docs here: https://developers.openai.com/api/docs/guides/image-generation - use gpt-image-2
Work independently - do not ask me to make any further design decisions. Make sure the game is fun, a little surprising, has good raccoon heist vibes, and is visually pleasing.
Commit and push as often as possible so I can preview your work - start with an index.html that presents a title screen, then build from there.
Append to a notes.md file as you work, including your changes to that as part of every commit.
{
"file_path": "/root/.claude/uploads/ccf71eb5-b431-519a-bc9e-b57105af9910/6c6977e7-IMG_7213.JPG"
}{
"file_path": "/root/.claude/uploads/ccf71eb5-b431-519a-bc9e-b57105af9910/c0180cca-IMG_7212.JPG"
}env | grep -i -E 'openai|api_key' | sed 's/=.*/=***set***/'
total 596 drwxr-xr-x 3 root root 4096 Aug 5 14:53 . drwxr-xr-x 3 root root 4096 Aug 5 14:53 .. drwxr-xr-x 8 root root 4096 Aug 5 14:53 .git -rw-r--r-- 1 root root 281874 Aug 5 14:53 IMG_7212.jpeg -rw-r--r-- 1 root root 309796 Aug 5 14:53 IMG_7213.jpeg -rw-r--r-- 1 root root 999 Aug 5 14:53 README.md 3b11a53 Images to go with prompt 21bd738 README 5a9f092 Initial commit * claude/3d-raccoon-heist-game-50n293 main remotes/origin/claude/3d-raccoon-heist-game-50n293 remotes/origin/main On branch claude/3d-raccoon-heist-game-50n293 nothing to commit, working tree clean
1 # raccoon-heist 2 3 Seeing if Fable 5 can one-shot this game 4 5 Starting prompt (plus the two initial images): 6 7 > Build this 3D game, for the browser. 8 > 9 > This repo is configured to serve static files so make sure there is an index.html that loads everything else. 10 > 11 > Make sure it is mobile-friendly (touch controls, works well on small screens). 12 > 13 > You have an OpenAI API key and access to their image generation model APIs, use that for textures to use with your 3D models. Docs here: https://developers.openai.com/api/docs/guides/image-generation - use gpt-image-2 14 > 15 > Work independently - do not ask me to make any further design decisions. Make sure the game is fun, a little surprising, has good raccoon heist vibes, and is visually pleasing. 16 > 17 > Commit and push as often as possible so I can preview your work - start with an index.html that presents a title screen, then build from there. 18 > 19 > Append to a notes.md file as you work, including your changes to that as part of every commit. 20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0a1024">
<title>Raccoon Heist</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #060a18;
font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
color: #dfe8ff;
touch-action: none;
overscroll-behavior: none;
user-select: none;
-webkit-user-select: none;
}
/* ---------- Title screen ---------- */
#title-screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background:
radial-gradient(ellipse 70% 45% at 78% 16%, rgba(190, 210, 255, 0.28), transparent 60%),
radial-gradient(ellipse 120% 70% at 50% 110%, rgba(48, 118, 168, 0.35), transparent 65%),
linear-gradient(180deg, #060a18 0%, #0a1024 45%, #10203c 100%);
z-index: 50;
transition: opacity 0.8s ease;
}
#title-screen.hidden { opacity: 0; pointer-events: none; }
.moon {
position: absolute;
top: 7%; right: 12%;
width: clamp(60px, 12vmin, 110px);
height: clamp(60px, 12vmin, 110px);
border-radius: 50%;
background: radial-gradient(circle at 38% 34%, #ffffff, #cfdcff 55%, #93a8d8 100%);
box-shadow: 0 0 60px 18px rgba(200, 216, 255, 0.35);
}
.stars { position: absolute; inset: 0; overflow: hidden; }
.stars span {
position: absolute;
width: 2px; height: 2px;
background: #fff; border-radius: 50%;
opacity: 0.8;
animation: twinkle 3s ease-in-out infinite;
}
@keyframes twinkle { 0%,100% { opacity: 0.25; } 50% { opacity: 0.95; } }
.skyline {
position: absolute; bottom: 0; left: 0; right: 0;
height: 26vh;
background:
linear-gradient(transparent 0, transparent 100%),
repeating-linear-gradient(90deg,
#050912 0 12vw, #0a1226 12vw 13vw,
#071020 13vw 26vw, #0a1226 26vw 27vw);
clip-path: polygon(0 100%, 0 46%, 8% 46%, 8% 26%, 16% 26%, 16% 58%, 25% 58%, 25% 34%, 33% 34%, 33% 12%, 42% 12%, 42% 48%, 52% 48%, 52% 30%, 61% 30%, 61% 55%, 70% 55%, 70% 20%, 79% 20%, 79% 44%, 88% 44%, 88% 32%, 100% 32%, 100% 100%);
opacity: 0.9;
}
h1.game-title {
font-size: clamp(2.6rem, 11vw, 6rem);
letter-spacing: 0.04em;
text-align: center;
line-height: 1.02;
color: #f4f7ff;
text-shadow:
0 2px 0 #1b2a52,
0 6px 24px rgba(120, 160, 255, 0.45),
0 0 60px rgba(90, 140, 255, 0.3);
z-index: 2;
}
h1 .accent { color: #ffd23f; text-shadow: 0 2px 0 #6b4b00, 0 6px 28px rgba(255, 210, 63, 0.5); }
.tagline {
margin-top: 0.9rem;
font-size: clamp(0.85rem, 3.4vw, 1.15rem);
color: #9db4e6;
letter-spacing: 0.12em;
text-transform: uppercase;
text-align: center;
z-index: 2;
padding: 0 1rem;
}
.mask {
z-index: 2;
margin-bottom: 1.2rem;
font-size: clamp(2.2rem, 9vw, 4rem);
filter: drop-shadow(0 4px 14px rgba(0,0,0,0.6));
animation: bob 3.2s ease-in-out infinite;
}
@keyframes bob { 0%,100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-10px) rotate(4deg); } }
#play-btn {
margin-top: 2.4rem;
z-index: 2;
font-family: inherit;
font-size: clamp(1.1rem, 4.5vw, 1.5rem);
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.85em 2.6em;
color: #1a1200;
background: linear-gradient(180deg, #ffe27a, #ffc31f 60%, #f0a500);
border: none; border-radius: 999px;
box-shadow: 0 6px 0 #8a5d00, 0 12px 30px rgba(255, 195, 31, 0.35);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
#play-btn:active { transform: translateY(4px); box-shadow: 0 2px 0 #8a5d00, 0 6px 18px rgba(255, 195, 31, 0.3); }
#play-btn:disabled { filter: grayscale(0.6); opacity: 0.7; }
.subtext {
margin-top: 1.1rem;
z-index: 2;
font-size: clamp(0.7rem, 2.8vw, 0.85rem);
color: #6e84b8;
text-align: center;
padding: 0 1.5rem;
line-height: 1.6;
}
#loading-status {
margin-top: 0.8rem; z-index: 2;
font-size: 0.8rem; color: #ffd23f;
min-height: 1.2em;
}
</style>
</head>
<body>
<div id="title-screen">
<div class="stars" id="stars"></div>
<div class="moon"></div>
<div class="mask">🦝</div>
<h1 class="game-title">RACCOON<br><span class="accent">HEIST</span></h1>
<p class="tagline">Grab the loot · Dodge the law · Trash is treasure</p>
<button id="play-btn">START THE HEIST</button>
<p id="loading-status"></p>
<p class="subtext">WASD / arrows to scamper · on phones, use the touch stick<br>A furry crew caper under the midnight moon</p>
<div class="skyline"></div>
</div>
<script>
// Scatter twinkling stars on the title screen.
(function () {
const wrap = document.getElementById('stars');
for (let i = 0; i < 90; i++) {
const s = document.createElement('span');
s.style.left = Math.random() * 100 + '%';
s.style.top = Math.random() * 70 + '%';
s.style.animationDelay = (Math.random() * 3).toFixed(2) + 's';
s.style.transform = `scale(${0.6 + Math.random() * 1.6})`;
wrap.appendChild(s);
}
})();
</script>
<script type="module" src="js/main.js"></script>
</body>
</html>
File created successfully at: /home/user/raccoon-heist/index.html (file state is current in your context — no need to Read it back)
# Raccoon Heist — build notes Working log for the 3D browser game build. Newest entries at the bottom. ## 2026-08-05 — Kickoff Read the two brief images: a low-poly isometric night scene of raccoons sneaking around trash cans outside glowing suburban windows (with a gold coin on the ground), plus a product blurb: a team of thieving raccoons pulling off light-hearted heists, dodging the police, escaping with the loot. **Plan:** - Three.js game, vendored locally (no CDN dependency at runtime). - Night-time suburban backyard heist: play a raccoon, sneak around houses and fences, grab loot (coins, shinies, leftover pizza), avoid patrolling flashlight cones, dump loot in your trash-can stash to bank it. - Mobile-first: virtual joystick + action button, also WASD/arrows + mouse on desktop. - Textures generated with OpenAI `gpt-image-2` (grass, brick, fence wood, roof shingles, asphalt path, trash-can metal) baked into `textures/` as small tileable PNGs so the deployed game never needs the API at runtime. - Surprises planned: a rival seagull that swoops for dropped loot, and a "trash panda frenzy" speed boost when you eat pizza. **Commit 1:** `index.html` title screen — pure CSS night sky, moon, twinkling stars, city skyline silhouette, bobbing 🦝, START button. `js/main.js` comes next; the script tag already points at it.
File created successfully at: /home/user/raccoon-heist/notes.md (file state is current in your context — no need to Read it back)
mkdir -p /home/user/raccoon-heist/js /home/user/raccoon-heist/textures /home/user/raccoon-heist/vendor && git add index.html notes.md && git commit -m "Title screen: night sky, moon, skyline, START button Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push -u origin claude/3d-raccoon-heist-game-50n293
2 files changed, 195 insertions(+) create mode 100644 index.html create mode 100644 notes.md remote: remote: Create a pull request for 'claude/3d-raccoon-heist-game-50n293' on GitHub by visiting: remote: https://github.com/simonw/raccoon-heist/pull/new/claude/3d-raccoon-heist-game-50n293 remote: To https://github.com/simonw/raccoon-heist * [new branch] claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293 branch 'claude/3d-raccoon-heist-game-50n293' set up to track 'origin/claude/3d-raccoon-heist-game-50n293'.
curl -sSL -o vendor/three.module.js https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js && ls -la vendor/ && head -c 200 vendor/three.module.js
total 684
drwxr-xr-x 2 root root 4096 Aug 5 14:55 .
drwxr-xr-x 6 root root 4096 Aug 5 14:55 ..
-rw-r--r-- 1 root root 691648 Aug 5 14:55 three.module.js
/**
* @license
* Copyright 2010-2024 Three.js Authors
* SPDX-License-Identifier: MIT
*/
const t="170",e={LEFT:0,MIDDLE:1,RIGHT:2,ROTATE:0,DOLLY:1,PAN:2},n={ROTATE:0,PAN:1,DOLLY_PAN:2,DOLLY_ROTATE:python3 -c "import PIL; print(PIL.__version__)" 2>&1; which python3
Traceback (most recent call last): File "<string>", line 1, in <module> ModuleNotFoundError: No module named 'PIL' /usr/local/bin/python3
pip install --quiet pillow 2>&1 | tail -1; python3 -c "import PIL; print('pillow', PIL.__version__)"WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv pillow 12.3.0
#!/usr/bin/env python3
"""Generate tileable game textures with OpenAI gpt-image-2 and save 512px JPEGs."""
import base64, io, json, os, sys, urllib.request
API_KEY = os.environ["OPENAI_API_KEY"]
OUT = "/home/user/raccoon-heist/textures"
STYLE = ("Seamless tileable texture, top-down flat orthographic view, no perspective, "
"even lighting, video game texture map, stylized low-poly game art style, "
"moody nighttime color palette with cool blue tones. ")
TEXTURES = {
"grass": "Dark night-time lawn grass texture, deep blue-green stylized grass blades, subtle variation.",
"brick": "House wall of painted wooden siding boards, dark slate blue-grey horizontal clapboard siding, weathered, front-on view.",
"fence": "Old wooden fence planks texture, vertical weathered brown wood boards with grain and nails, front-on view.",
"roof": "Roof shingles texture, overlapping dark grey-blue asphalt shingles rows, front-on view.",
"path": "Cracked concrete pavement texture at night, grey-blue concrete slabs with subtle cracks and stains.",
"metal": "Galvanized metal trash can surface texture, brushed dull silver metal with vertical ridges, scuffs and scratches.",
"soil": "Dark garden soil dirt texture with small pebbles and leaf litter, night time, deep browns and blues.",
}
def gen(name, prompt):
body = json.dumps({
"model": "gpt-image-2",
"prompt": STYLE + prompt,
"size": "1024x1024",
"quality": "low",
"output_format": "jpeg",
"output_compression": 80,
"n": 1,
}).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/images/generations",
data=body,
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=300) as r:
data = json.load(r)
b64 = data["data"][0]["b64_json"]
raw = base64.b64decode(b64)
from PIL import Image
img = Image.open(io.BytesIO(raw)).convert("RGB")
img = img.resize((512, 512), Image.LANCZOS)
path = os.path.join(OUT, f"{name}.jpg")
img.save(path, "JPEG", quality=82)
print(f"{name}: saved {os.path.getsize(path)} bytes")
if __name__ == "__main__":
names = sys.argv[1:] or list(TEXTURES)
for n in names:
try:
gen(n, TEXTURES[n])
except Exception as e:
print(f"{n}: FAILED {e}", file=sys.stderr)
File created successfully at: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/gen_textures.py (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && python3 gen_textures.py grass 2>&1
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && python3 gen_textures.py brick fence roof path metal soil
Command running in background with ID: bjcj63elm. Output is being written to: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/tasks/bjcj63elm.output. You will be notified when it completes. To check interim output, use Read on that file path. Session cwd remains /home/user/raccoon-heist; directory changes made by the backgrounded command do not apply to subsequent commands.
// Procedural WebAudio: sneaky bass groove + sound effects. No audio assets needed.
let ctx = null;
let musicOn = true;
let master = null;
export function initAudio() {
if (ctx) return;
try {
ctx = new (window.AudioContext || window.webkitAudioContext)();
master = ctx.createGain();
master.gain.value = 0.5;
master.connect(ctx.destination);
startMusic();
} catch (e) {
ctx = null;
}
}
export function resumeAudio() {
if (ctx && ctx.state === 'suspended') ctx.resume();
}
function env(gainNode, t, attack, decay, peak = 1) {
gainNode.gain.setValueAtTime(0.0001, t);
gainNode.gain.linearRampToValueAtTime(peak, t + attack);
gainNode.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
}
function blip(freq, t, dur, type = 'sine', vol = 0.3, slide = 0) {
if (!ctx) return;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t + dur);
env(g, t, 0.008, dur, vol);
o.connect(g).connect(master);
o.start(t); o.stop(t + dur + 0.05);
}
function noiseBurst(t, dur, vol = 0.2, freq = 1200) {
if (!ctx) return;
const len = Math.floor(ctx.sampleRate * dur);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buf;
const f = ctx.createBiquadFilter();
f.type = 'bandpass'; f.frequency.value = freq; f.Q.value = 0.8;
const g = ctx.createGain();
env(g, t, 0.005, dur, vol);
src.connect(f).connect(g).connect(master);
src.start(t); src.stop(t + dur + 0.05);
}
// ---------- Sound effects ----------
export const sfx = {
coin() { if (!ctx) return; const t = ctx.currentTime; blip(950, t, 0.09, 'square', 0.16); blip(1420, t + 0.07, 0.16, 'square', 0.16); },
gem() { if (!ctx) return; const t = ctx.currentTime; blip(660, t, 0.1, 'triangle', 0.22); blip(880, t + 0.08, 0.1, 'triangle', 0.22); blip(1320, t + 0.16, 0.2, 'triangle', 0.22); },
munch() { if (!ctx) return; const t = ctx.currentTime; noiseBurst(t, 0.08, 0.3, 500); noiseBurst(t + 0.1, 0.08, 0.25, 400); },
bank() { if (!ctx) return; const t = ctx.currentTime; [523, 659, 784, 1047].forEach((f, i) => blip(f, t + i * 0.07, 0.18, 'triangle', 0.2)); },
alert() { if (!ctx) return; const t = ctx.currentTime; blip(600, t, 0.25, 'sawtooth', 0.12, 500); },
caught() { if (!ctx) return; const t = ctx.currentTime; blip(400, t, 0.3, 'sawtooth', 0.2, -200); blip(300, t + 0.25, 0.4, 'sawtooth', 0.2, -180); noiseBurst(t, 0.3, 0.15, 300); },
squawk() { if (!ctx) return; const t = ctx.currentTime; blip(1100, t, 0.12, 'sawtooth', 0.14, 500); blip(900, t + 0.14, 0.15, 'sawtooth', 0.13, -300); },
frenzy() { if (!ctx) return; const t = ctx.currentTime; [440, 554, 659, 880, 1109].forEach((f, i) => blip(f, t + i * 0.05, 0.12, 'square', 0.14)); },
dash() { if (!ctx) return; noiseBurst(ctx.currentTime, 0.15, 0.18, 2000); },
win() { if (!ctx) return; const t = ctx.currentTime; [523, 659, 784, 1047, 784, 1047, 1319].forEach((f, i) => blip(f, t + i * 0.12, 0.25, 'triangle', 0.22)); },
lose() { if (!ctx) return; const t = ctx.currentTime; [392, 370, 349, 330].forEach((f, i) => blip(f, t + i * 0.22, 0.35, 'triangle', 0.2)); },
meow() { if (!ctx) return; const t = ctx.currentTime; blip(700, t, 0.25, 'sawtooth', 0.1, 250); },
};
// ---------- Sneaky bass groove ----------
// A little walking-bass jazz loop with brushed-hat noise. Scheduled bar by bar.
const BASS = [110, 0, 131, 110, 98, 0, 110, 131, 87, 0, 104, 87, 98, 0, 110, 98];
let step = 0, nextNoteTime = 0, schedTimer = null;
const TEMPO = 96;
function scheduleLoop() {
if (!ctx || !musicOn) return;
const stepDur = (60 / TEMPO) / 2; // eighth notes
while (nextNoteTime < ctx.currentTime + 0.3) {
const f = BASS[step % BASS.length];
if (f) {
const t = nextNoteTime;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = 'triangle';
o.frequency.value = f;
env(g, t, 0.01, stepDur * 0.9, 0.16);
o.connect(g).connect(master);
o.start(t); o.stop(t + stepDur);
}
// brushed hats on off-beats
if (step % 2 === 1) noiseBurst(nextNoteTime, 0.04, 0.03, 6000);
step++;
nextNoteTime += stepDur;
}
schedTimer = setTimeout(scheduleLoop, 120);
}
function startMusic() {
if (!ctx) return;
nextNoteTime = ctx.currentTime + 0.1;
step = 0;
scheduleLoop();
}
export function toggleMusic() {
musicOn = !musicOn;
if (musicOn && ctx) { nextNoteTime = ctx.currentTime + 0.1; scheduleLoop(); }
else if (schedTimer) clearTimeout(schedTimer);
return musicOn;
}
File created successfully at: /home/user/raccoon-heist/js/audio.js (file state is current in your context — no need to Read it back)
// Input: keyboard (WASD/arrows + Space dash) and a dynamic touch joystick
// that appears wherever the player touches the left 60% of the screen,
// plus a dash button on the right for touch devices.
export class Controls {
constructor() {
this.moveX = 0; // -1..1
this.moveZ = 0; // -1..1
this.dashPressed = false;
this._keys = new Set();
this._joyActive = false;
this._joyId = null;
this._joyOrigin = { x: 0, y: 0 };
this._joyVec = { x: 0, y: 0 };
this.isTouch = false;
window.addEventListener('keydown', (e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
this._keys.add(e.key.toLowerCase());
if (e.key === ' ') this.dashPressed = true;
});
window.addEventListener('keyup', (e) => this._keys.delete(e.key.toLowerCase()));
window.addEventListener('blur', () => this._keys.clear());
this._buildTouchUI();
}
_buildTouchUI() {
// Joystick visuals
const ring = document.createElement('div');
ring.id = 'joy-ring';
ring.style.cssText = 'position:fixed;width:110px;height:110px;border-radius:50%;border:3px solid rgba(255,255,255,0.35);background:rgba(255,255,255,0.06);display:none;z-index:40;pointer-events:none;transform:translate(-50%,-50%);backdrop-filter:blur(2px);';
const nub = document.createElement('div');
nub.id = 'joy-nub';
nub.style.cssText = 'position:fixed;width:52px;height:52px;border-radius:50%;background:rgba(255,210,63,0.85);box-shadow:0 2px 10px rgba(0,0,0,0.4);display:none;z-index:41;pointer-events:none;transform:translate(-50%,-50%);';
document.body.appendChild(ring);
document.body.appendChild(nub);
this._ring = ring;
this._nub = nub;
// Dash button (shown once touch is detected)
const dash = document.createElement('div');
dash.id = 'dash-btn';
dash.textContent = 'DASH';
dash.style.cssText = 'position:fixed;right:max(18px, env(safe-area-inset-right));bottom:max(26px, env(safe-area-inset-bottom));width:76px;height:76px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #ffe27a, #f0a500);color:#3a2800;font:700 15px/76px sans-serif;text-align:center;z-index:40;display:none;box-shadow:0 4px 0 #8a5d00, 0 8px 18px rgba(0,0,0,0.5);letter-spacing:0.05em;touch-action:none;';
document.body.appendChild(dash);
this._dashBtn = dash;
dash.addEventListener('touchstart', (e) => { e.preventDefault(); this.dashPressed = true; dash.style.transform = 'scale(0.92)'; }, { passive: false });
dash.addEventListener('touchend', () => { dash.style.transform = ''; });
const touchArea = document.body;
touchArea.addEventListener('touchstart', (e) => this._onTouchStart(e), { passive: false });
touchArea.addEventListener('touchmove', (e) => this._onTouchMove(e), { passive: false });
touchArea.addEventListener('touchend', (e) => this._onTouchEnd(e), { passive: false });
touchArea.addEventListener('touchcancel', (e) => this._onTouchEnd(e), { passive: false });
}
_onTouchStart(e) {
this.isTouch = true;
this._dashBtn.style.display = 'block';
for (const t of e.changedTouches) {
if (this._joyActive) continue;
// ignore touches on UI elements (dash button, overlays with buttons)
const el = document.elementFromPoint(t.clientX, t.clientY);
if (el && (el.closest('#dash-btn') || el.closest('button') || el.closest('.overlay'))) continue;
if (t.clientX > window.innerWidth * 0.62) continue;
e.preventDefault();
this._joyActive = true;
this._joyId = t.identifier;
this._joyOrigin = { x: t.clientX, y: t.clientY };
this._joyVec = { x: 0, y: 0 };
this._ring.style.display = 'block';
this._nub.style.display = 'block';
this._ring.style.left = t.clientX + 'px';
this._ring.style.top = t.clientY + 'px';
this._nub.style.left = t.clientX + 'px';
this._nub.style.top = t.clientY + 'px';
}
}
_onTouchMove(e) {
if (!this._joyActive) return;
for (const t of e.changedTouches) {
if (t.identifier !== this._joyId) continue;
e.preventDefault();
const dx = t.clientX - this._joyOrigin.x;
const dy = t.clientY - this._joyOrigin.y;
const max = 48;
const len = Math.hypot(dx, dy) || 1;
const cl = Math.min(len, max);
const nx = (dx / len) * cl, ny = (dy / len) * cl;
this._joyVec = { x: nx / max, y: ny / max };
this._nub.style.left = (this._joyOrigin.x + nx) + 'px';
this._nub.style.top = (this._joyOrigin.y + ny) + 'px';
}
}
_onTouchEnd(e) {
for (const t of e.changedTouches) {
if (t.identifier === this._joyId) {
this._joyActive = false;
this._joyId = null;
this._joyVec = { x: 0, y: 0 };
this._ring.style.display = 'none';
this._nub.style.display = 'none';
}
}
}
update() {
let x = 0, z = 0;
const k = this._keys;
if (k.has('a') || k.has('arrowleft')) x -= 1;
if (k.has('d') || k.has('arrowright')) x += 1;
if (k.has('w') || k.has('arrowup')) z -= 1;
if (k.has('s') || k.has('arrowdown')) z += 1;
if (this._joyActive) { x = this._joyVec.x; z = this._joyVec.y; }
const len = Math.hypot(x, z);
if (len > 1) { x /= len; z /= len; }
this.moveX = x;
this.moveZ = z;
}
consumeDash() {
const d = this.dashPressed;
this.dashPressed = false;
return d;
}
}
File created successfully at: /home/user/raccoon-heist/js/controls.js (file state is current in your context — no need to Read it back)
// Procedural low-poly characters: raccoon, security guard, police car, seagull.
// Each builder returns a THREE.Group plus an animate(t, moveSpeed) hook.
import * as THREE from '../vendor/three.module.js';
const M = (color, opts = {}) => new THREE.MeshLambertMaterial({ color, ...opts });
const GREY = 0x8a8f9c, DARKGREY = 0x3c4048, LIGHT = 0xe8e4da, BLACK = 0x17181d;
export function makeRaccoon({ scale = 1, shirt = null } = {}) {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(0.55, 12, 10), M(GREY));
body.scale.set(1, 0.85, 1.25);
body.position.y = 0.62;
body.castShadow = true;
g.add(body);
if (shirt) {
const vest = new THREE.Mesh(new THREE.SphereGeometry(0.57, 12, 10), M(shirt));
vest.scale.set(1, 0.72, 1.1);
vest.position.set(0, 0.6, 0.12);
g.add(vest);
}
const head = new THREE.Group();
head.position.set(0, 1.15, 0.55);
g.add(head);
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.38, 12, 10), M(GREY));
skull.castShadow = true;
head.add(skull);
// bandit mask
const mask = new THREE.Mesh(new THREE.SphereGeometry(0.385, 12, 8, 0, Math.PI * 2, Math.PI * 0.32, Math.PI * 0.3), M(BLACK));
mask.rotation.x = -0.35;
head.add(mask);
// muzzle
const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), M(LIGHT));
muzzle.scale.set(1, 0.8, 1.1);
muzzle.position.set(0, -0.12, 0.28);
head.add(muzzle);
const nose = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), M(BLACK));
nose.position.set(0, -0.08, 0.46);
head.add(nose);
// eyes
for (const s of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), M(0xffffff, { emissive: 0x888888 }));
eye.position.set(0.15 * s, 0.06, 0.32);
head.add(eye);
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.22, 6), M(DARKGREY));
ear.position.set(0.24 * s, 0.34, -0.05);
ear.rotation.z = -0.25 * s;
head.add(ear);
}
// striped tail — alternating rings, curved upward
const tail = new THREE.Group();
tail.position.set(0, 0.62, -0.6);
for (let i = 0; i < 6; i++) {
const seg = new THREE.Mesh(new THREE.SphereGeometry(0.19 - i * 0.02, 8, 6), M(i % 2 ? BLACK : GREY));
seg.position.set(0, Math.sin(i * 0.38) * 0.5, -i * 0.19);
seg.castShadow = i < 3;
tail.add(seg);
}
g.add(tail);
// legs
const legs = [];
const legGeo = new THREE.CylinderGeometry(0.09, 0.11, 0.38, 6);
for (const [x, z] of [[-0.3, 0.35], [0.3, 0.35], [-0.3, -0.35], [0.3, -0.35]]) {
const leg = new THREE.Mesh(legGeo, M(DARKGREY));
leg.position.set(x, 0.2, z);
g.add(leg);
legs.push(leg);
}
// loot backpack anchor (banked visuals get stacked here)
const lootAnchor = new THREE.Group();
lootAnchor.position.set(0, 1.05, -0.15);
g.add(lootAnchor);
g.scale.setScalar(scale);
let phase = Math.random() * 10;
return {
group: g,
lootAnchor,
head,
tail,
animate(dt, speed) {
phase += dt * (4 + speed * 9);
const amp = Math.min(0.55, 0.12 + speed * 0.5);
legs[0].rotation.x = Math.sin(phase) * amp;
legs[3].rotation.x = Math.sin(phase) * amp;
legs[1].rotation.x = -Math.sin(phase) * amp;
legs[2].rotation.x = -Math.sin(phase) * amp;
body.position.y = 0.62 + Math.abs(Math.sin(phase)) * 0.05 * (0.3 + speed);
tail.rotation.x = -0.2 + Math.sin(phase * 0.5) * 0.15;
tail.rotation.z = Math.sin(phase * 0.7) * 0.12;
head.rotation.y = Math.sin(phase * 0.3) * 0.08;
},
};
}
export function makeGuard() {
const g = new THREE.Group();
const torso = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.5, 1.15, 10), M(0x2b3a5e));
torso.position.y = 1.0;
torso.castShadow = true;
g.add(torso);
const belly = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), M(0x2b3a5e));
belly.position.set(0, 0.85, 0.08);
g.add(belly);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 8), M(0xd9a87c));
head.position.y = 1.85;
head.castShadow = true;
g.add(head);
const capTop = new THREE.Mesh(new THREE.CylinderGeometry(0.31, 0.33, 0.16, 10), M(0x1c2740));
capTop.position.y = 2.05;
g.add(capTop);
const brim = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.05, 10, 1, false, 0, Math.PI), M(0x1c2740));
brim.position.set(0, 1.98, 0.18);
g.add(brim);
const armGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.7, 6);
const armL = new THREE.Mesh(armGeo, M(0x2b3a5e));
armL.position.set(-0.55, 1.25, 0);
armL.rotation.z = 0.25;
g.add(armL);
// right arm holds the flashlight forward
const armR = new THREE.Mesh(armGeo, M(0x2b3a5e));
armR.position.set(0.5, 1.35, 0.3);
armR.rotation.x = -1.2;
g.add(armR);
const torch = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.12, 0.35, 8), M(0x222831));
torch.position.set(0.5, 1.45, 0.72);
torch.rotation.x = Math.PI / 2;
g.add(torch);
const lens = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.03, 8), M(0xfff2b0, { emissive: 0xffe89a }));
lens.position.set(0.5, 1.45, 0.9);
lens.rotation.x = Math.PI / 2;
g.add(lens);
const legGeo = new THREE.CylinderGeometry(0.13, 0.15, 0.5, 6);
const legs = [];
for (const s of [-1, 1]) {
const leg = new THREE.Mesh(legGeo, M(0x1c2740));
leg.position.set(0.2 * s, 0.25, 0);
g.add(leg);
legs.push(leg);
}
let phase = Math.random() * 10;
return {
group: g,
torchTip: new THREE.Vector3(0.5, 1.45, 0.9),
animate(dt, speed) {
phase += dt * (2 + speed * 6);
const amp = Math.min(0.5, speed * 0.9);
legs[0].rotation.x = Math.sin(phase) * amp;
legs[1].rotation.x = -Math.sin(phase) * amp;
armL.rotation.x = -Math.sin(phase) * amp * 0.7;
g.position.y = Math.abs(Math.sin(phase)) * 0.04 * speed;
},
};
}
export function makePoliceCar() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.62, 4.6), M(0x1a2233));
body.position.y = 0.62;
body.castShadow = true;
g.add(body);
const stripe = new THREE.Mesh(new THREE.BoxGeometry(2.12, 0.2, 4.62), M(0xdfe6f2));
stripe.position.y = 0.68;
g.add(stripe);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.6, 2.1), M(0x9db8d8, { transparent: true, opacity: 0.9 }));
cabin.position.set(0, 1.2, -0.2);
cabin.castShadow = true;
g.add(cabin);
const wheelGeo = new THREE.CylinderGeometry(0.42, 0.42, 0.3, 10);
for (const [x, z] of [[-1, 1.5], [1, 1.5], [-1, -1.5], [1, -1.5]]) {
const w = new THREE.Mesh(wheelGeo, M(0x111318));
w.rotation.z = Math.PI / 2;
w.position.set(x, 0.42, z);
g.add(w);
}
// light bar
const barBase = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.12, 0.4), M(0x222833));
barBase.position.set(0, 1.56, -0.2);
g.add(barBase);
const redMat = new THREE.MeshLambertMaterial({ color: 0xff2244, emissive: 0xff2244, emissiveIntensity: 1 });
const blueMat = new THREE.MeshLambertMaterial({ color: 0x2266ff, emissive: 0x2266ff, emissiveIntensity: 1 });
const red = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.36), redMat);
red.position.set(-0.3, 1.72, -0.2);
g.add(red);
const blue = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.36), blueMat);
blue.position.set(0.3, 1.72, -0.2);
g.add(blue);
// headlights
const hlMat = new THREE.MeshLambertMaterial({ color: 0xfff6cc, emissive: 0xfff0aa, emissiveIntensity: 0.9 });
for (const s of [-1, 1]) {
const hl = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.16, 0.08), hlMat);
hl.position.set(0.62 * s, 0.62, 2.32);
g.add(hl);
}
const redLight = new THREE.PointLight(0xff2244, 0, 14);
redLight.position.set(0, 2.2, -0.2);
g.add(redLight);
const blueLight = new THREE.PointLight(0x2266ff, 0, 14);
blueLight.position.set(0, 2.2, -0.2);
g.add(blueLight);
let t = 0;
return {
group: g,
animate(dt) {
t += dt;
const flash = Math.sin(t * 10) > 0;
redMat.emissiveIntensity = flash ? 1.6 : 0.15;
blueMat.emissiveIntensity = flash ? 0.15 : 1.6;
redLight.intensity = flash ? 2.2 : 0;
blueLight.intensity = flash ? 0 : 2.2;
},
};
}
export function makeSeagull() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(0.32, 10, 8), M(0xf2f4f8));
body.scale.set(1, 0.8, 1.5);
g.add(body);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 8), M(0xf2f4f8));
head.position.set(0, 0.22, 0.42);
g.add(head);
const beak = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.25, 6), M(0xf7a922));
beak.rotation.x = Math.PI / 2;
beak.position.set(0, 0.2, 0.62);
g.add(beak);
const wingGeo = new THREE.BoxGeometry(0.9, 0.04, 0.42);
const wings = [];
for (const s of [-1, 1]) {
const wing = new THREE.Mesh(wingGeo, M(0xd8dde6));
wing.position.set(0.5 * s, 0.1, 0);
wing.geometry = wingGeo;
g.add(wing);
wings.push(wing);
}
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.04, 0.35), M(0xd8dde6));
tail.position.set(0, 0.05, -0.5);
g.add(tail);
let phase = 0;
return {
group: g,
animate(dt, flying = 1) {
phase += dt * 14 * flying;
wings[0].rotation.z = Math.sin(phase) * 0.7;
wings[1].rotation.z = -Math.sin(phase) * 0.7;
},
};
}
// Small loot meshes. kind: coin | gem | pizza | fish | donut | ring
export function makeLoot(kind) {
const g = new THREE.Group();
let mesh;
switch (kind) {
case 'gem': {
mesh = new THREE.Mesh(new THREE.OctahedronGeometry(0.32), new THREE.MeshLambertMaterial({ color: 0x35e0c0, emissive: 0x1a8f78, emissiveIntensity: 0.7 }));
mesh.position.y = 0.38;
break;
}
case 'pizza': {
mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.42, 0.08, 3), new THREE.MeshLambertMaterial({ color: 0xf5b840, emissive: 0x7a4a00, emissiveIntensity: 0.25 }));
mesh.position.y = 0.1;
const pep = new THREE.MeshLambertMaterial({ color: 0xc0392b });
for (let i = 0; i < 3; i++) {
const p = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.04, 8), pep);
p.position.set(Math.sin(i * 2.1) * 0.15, 0.16, Math.cos(i * 2.1) * 0.15);
g.add(p);
}
break;
}
case 'fish': {
mesh = new THREE.Mesh(new THREE.SphereGeometry(0.26, 8, 6), new THREE.MeshLambertMaterial({ color: 0x7fb2d8, emissive: 0x2a5a7a, emissiveIntensity: 0.3 }));
mesh.scale.set(0.7, 0.7, 1.5);
mesh.position.y = 0.2;
const fin = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.28, 4), new THREE.MeshLambertMaterial({ color: 0x6aa0c8 }));
fin.rotation.x = -Math.PI / 2;
fin.position.set(0, 0.2, -0.45);
g.add(fin);
break;
}
case 'donut': {
mesh = new THREE.Mesh(new THREE.TorusGeometry(0.24, 0.11, 8, 14), new THREE.MeshLambertMaterial({ color: 0xe88ab0, emissive: 0x8a3055, emissiveIntensity: 0.3 }));
mesh.rotation.x = Math.PI / 2;
mesh.position.y = 0.14;
break;
}
case 'ring': {
mesh = new THREE.Mesh(new THREE.TorusGeometry(0.2, 0.06, 8, 14), new THREE.MeshLambertMaterial({ color: 0xffd23f, emissive: 0xb8860b, emissiveIntensity: 0.8 }));
mesh.position.y = 0.26;
const stone = new THREE.Mesh(new THREE.OctahedronGeometry(0.1), new THREE.MeshLambertMaterial({ color: 0xff5f9e, emissive: 0xaa2255, emissiveIntensity: 0.8 }));
stone.position.set(0, 0.5, 0);
g.add(stone);
break;
}
default: { // coin
mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.28, 0.09, 14), new THREE.MeshLambertMaterial({ color: 0xffd23f, emissive: 0xcc9900, emissiveIntensity: 0.75 }));
mesh.position.y = 0.3;
mesh.rotation.x = Math.PI / 2;
}
}
g.add(mesh);
return g;
}
export const LOOT_TYPES = {
coin: { value: 10, label: 'coin', weight: 5 },
gem: { value: 30, label: 'gem', weight: 2 },
donut: { value: 15, label: 'donut', weight: 3 },
fish: { value: 20, label: 'fish', weight: 3 },
ring: { value: 50, label: 'ring', weight: 1 },
pizza: { value: 15, label: 'pizza', weight: 2 }, // also triggers FRENZY
};
File created successfully at: /home/user/raccoon-heist/js/actors.js (file state is current in your context — no need to Read it back)
cat /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/tasks/bjcj63elm.output && ls -la textures/
brick: saved 34431 bytes fence: saved 36704 bytes roof: saved 32247 bytes path: saved 33929 bytes metal: saved 42649 bytes soil: saved 41117 bytes total 284 drwxr-xr-x 2 root root 4096 Aug 5 14:58 . drwxr-xr-x 6 root root 4096 Aug 5 14:55 .. -rw-r--r-- 1 root root 34431 Aug 5 14:56 brick.jpg -rw-r--r-- 1 root root 36704 Aug 5 14:56 fence.jpg -rw-r--r-- 1 root root 46352 Aug 5 14:56 grass.jpg -rw-r--r-- 1 root root 42649 Aug 5 14:57 metal.jpg -rw-r--r-- 1 root root 33929 Aug 5 14:57 path.jpg -rw-r--r-- 1 root root 32247 Aug 5 14:57 roof.jpg -rw-r--r-- 1 root root 41117 Aug 5 14:58 soil.jpg
// Builds the night-time suburban heist map: ground, road, houses with glowing
// windows, fences, trash cans, dumpster hideout, trees. Returns collision
// obstacles, line-of-sight blockers, and gameplay anchor points.
import * as THREE from '../vendor/three.module.js';
const texLoader = new THREE.TextureLoader();
function loadTex(name, repeatX, repeatY, fallbackColor) {
return new Promise((resolve) => {
texLoader.load(
`textures/${name}.jpg`,
(t) => {
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(repeatX, repeatY);
t.colorSpace = THREE.SRGBColorSpace;
resolve(t);
},
undefined,
() => resolve(null) // fall back to flat color material
);
});
}
function mat(tex, fallbackColor, opts = {}) {
return tex
? new THREE.MeshLambertMaterial({ map: tex, ...opts })
: new THREE.MeshLambertMaterial({ color: fallbackColor, ...opts });
}
export const BOUNDS = { minX: -42, maxX: 42, minZ: -30, maxZ: 32 };
export async function buildWorld(scene) {
const [grassT, pathT, brickT, fenceT, roofT, metalT, soilT] = await Promise.all([
loadTex('grass', 12, 10), loadTex('path', 10, 2), loadTex('brick', 3, 1.4),
loadTex('fence', 2, 1), loadTex('roof', 3, 2), loadTex('metal', 1, 1),
loadTex('soil', 2, 2),
]);
const obstacles = []; // {type:'box',minX,maxX,minZ,maxZ} or {type:'circle',x,z,r}
const losBlockers = []; // subset of boxes that block guard sight (houses)
const windows = []; // {mesh, light, pos:Vector3, dir:Vector3}
// ---------- Ground ----------
const ground = new THREE.Mesh(new THREE.PlaneGeometry(96, 76), mat(grassT, 0x11302e));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Street strip where the police car patrols
const road = new THREE.Mesh(new THREE.PlaneGeometry(96, 8.4), mat(pathT, 0x2a3140));
road.rotation.x = -Math.PI / 2;
road.position.set(0, 0.02, -13);
road.receiveShadow = true;
scene.add(road);
// Sidewalk-ish soil patches for variety
for (const [x, z, w, h] of [[-24, 8, 10, 7], [18, 20, 12, 8], [-10, 26, 8, 6]]) {
const patch = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat(soilT, 0x201812));
patch.rotation.x = -Math.PI / 2;
patch.rotation.z = Math.random();
patch.position.set(x, 0.015, z);
patch.receiveShadow = true;
scene.add(patch);
}
// ---------- Houses (along the back, fronts facing the street) ----------
const houseDefs = [
{ x: -28, z: -25, w: 14, d: 9 },
{ x: 0, z: -25, w: 16, d: 9 },
{ x: 28, z: -25, w: 14, d: 9 },
];
const wallMat = mat(brickT, 0x2e3950);
const roofMat = mat(roofT, 0x27314a);
const winMatOn = new THREE.MeshLambertMaterial({ color: 0xffc75f, emissive: 0xffb347, emissiveIntensity: 1.1 });
const winMatDim = new THREE.MeshLambertMaterial({ color: 0x24334f, emissive: 0x101b30, emissiveIntensity: 0.4 });
for (const h of houseDefs) {
const wallH = 6;
const walls = new THREE.Mesh(new THREE.BoxGeometry(h.w, wallH, h.d), wallMat);
walls.position.set(h.x, wallH / 2, h.z);
walls.castShadow = true;
walls.receiveShadow = true;
scene.add(walls);
// simple prism roof
const roofGeo = new THREE.CylinderGeometry(0.01, (h.w / 2) * 1.16, 3.2, 4, 1);
const roof = new THREE.Mesh(roofGeo, roofMat);
roof.rotation.y = Math.PI / 4;
roof.scale.z = (h.d + 1.6) / (h.w * 1.16);
roof.position.set(h.x, wallH + 1.6, h.z);
roof.castShadow = true;
scene.add(roof);
// chimney
const chim = new THREE.Mesh(new THREE.BoxGeometry(1, 2.4, 1), wallMat);
chim.position.set(h.x + h.w * 0.28, wallH + 2.2, h.z - 1);
scene.add(chim);
// front windows + door (facing +z, toward the yard/street)
const faceZ = h.z + h.d / 2 + 0.06;
const winCount = Math.floor(h.w / 5);
for (let i = 0; i < winCount; i++) {
const wx = h.x - h.w / 2 + (i + 0.75) * (h.w / (winCount + 0.5));
const lit = Math.random() < 0.5;
const win = new THREE.Mesh(new THREE.PlaneGeometry(2, 1.7), (lit ? winMatOn : winMatDim).clone());
win.position.set(wx, 3.1, faceZ);
scene.add(win);
const frame = new THREE.Mesh(new THREE.PlaneGeometry(2.35, 2.05), new THREE.MeshLambertMaterial({ color: 0x161d2e }));
frame.position.set(wx, 3.1, faceZ - 0.03);
scene.add(frame);
let light = null;
if (lit) {
light = new THREE.PointLight(0xffb347, 5, 12, 1.6);
light.position.set(wx, 3.0, faceZ + 1.2);
scene.add(light);
}
windows.push({ mesh: win, light, lit, pos: new THREE.Vector3(wx, 1, faceZ + 0.5), dir: new THREE.Vector3(0, 0, 1) });
}
const door = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 3), new THREE.MeshLambertMaterial({ color: 0x3a2a1c }));
door.position.set(h.x, 1.5, faceZ);
scene.add(door);
const box = { type: 'box', minX: h.x - h.w / 2, maxX: h.x + h.w / 2, minZ: h.z - h.d / 2, maxZ: h.z + h.d / 2 };
obstacles.push(box);
losBlockers.push(box);
}
// ---------- Perimeter + yard fences ----------
const fenceMat = mat(fenceT, 0x4a3826);
const addFence = (x, z, len, horizontal) => {
const geo = new THREE.BoxGeometry(horizontal ? len : 0.3, 1.9, horizontal ? 0.3 : len);
const f = new THREE.Mesh(geo, fenceMat);
f.position.set(x, 0.95, z);
f.castShadow = true;
f.receiveShadow = true;
scene.add(f);
if (horizontal) obstacles.push({ type: 'box', minX: x - len / 2, maxX: x + len / 2, minZ: z - 0.35, maxZ: z + 0.35 });
else obstacles.push({ type: 'box', minX: x - 0.35, maxX: x + 0.35, minZ: z - len / 2, maxZ: z + len / 2 });
};
// perimeter (with the map bounds as backup clamp)
addFence(0, BOUNDS.maxZ + 0.5, 88, true);
addFence(BOUNDS.minX - 0.5, 1, 64, false);
addFence(BOUNDS.maxX + 0.5, 1, 64, false);
// yard dividers with sneak gaps
addFence(-14, 14, 0.3 ? 16 : 16, false); // vertical divider left
addFence(-14, 30, 4, false);
addFence(14, 10, 12, false);
addFence(14, 28, 6, false);
addFence(-2, 4, 16, true);
addFence(26, 4, 12, true);
addFence(-30, 18, 12, true);
// ---------- Trash cans ----------
const canMat = mat(metalT, 0x8a919c);
const lidMat = mat(metalT, 0x767d88);
const canAt = (x, z, tipped = false) => {
const can = new THREE.Group();
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.7, 1.9, 12), canMat);
body.position.y = 0.95;
body.castShadow = true;
can.add(body);
const lid = new THREE.Mesh(new THREE.CylinderGeometry(0.95, 0.95, 0.22, 12), lidMat);
lid.position.y = 2.0;
can.add(lid);
const knob = new THREE.Mesh(new THREE.SphereGeometry(0.14, 8, 6), lidMat);
knob.position.y = 2.18;
can.add(knob);
if (tipped) {
can.rotation.z = Math.PI / 2 - 0.15;
can.position.set(x, -0.1, z);
} else {
can.position.set(x, 0, z);
}
can.rotation.y = Math.random() * Math.PI;
scene.add(can);
obstacles.push({ type: 'circle', x, z, r: 1.0 });
};
canAt(-21, -6); canAt(-19, -5.4); canAt(8, -6, true); canAt(35, -5);
canAt(-36, 12); canAt(20, 16); canAt(2, 24, true); canAt(33, 26);
// ---------- Dumpster hideout (bank your loot here) ----------
const hideout = new THREE.Group();
const dump = new THREE.Mesh(new THREE.BoxGeometry(5, 2.4, 2.6), mat(metalT, 0x2f6b4f));
dump.position.y = 1.3;
dump.castShadow = true;
hideout.add(dump);
// green paint tint over metal texture
dump.material = dump.material.clone();
dump.material.color = new THREE.Color(0x59a37a);
const lid1 = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.18, 2.7), mat(metalT, 0x3a7a5c));
lid1.material = lid1.material.clone();
lid1.material.color = new THREE.Color(0x4c8f6b);
lid1.position.set(-1.25, 2.52, 0);
hideout.add(lid1);
const lid2 = lid1.clone();
lid2.position.x = 1.25;
lid2.rotation.x = -0.9; // propped open — that's the door
lid2.position.y = 2.9;
lid2.position.z = -0.8;
hideout.add(lid2);
// glowing eyes peeking from the open lid
for (const s of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 6), new THREE.MeshBasicMaterial({ color: 0xffe9a0 }));
eye.position.set(1.25 + s * 0.22, 2.1, -0.4);
hideout.add(eye);
}
const hideoutPos = new THREE.Vector3(0, 0, 30);
hideout.position.copy(hideoutPos);
scene.add(hideout);
obstacles.push({ type: 'box', minX: -2.7, maxX: 2.7, minZ: 28.6, maxZ: 31.4 });
// ---------- Trees & bushes ----------
const trunkMat = mat(fenceT, 0x3a2c1e);
const leafMat = new THREE.MeshLambertMaterial({ color: 0x1d4d3a });
const leafMat2 = new THREE.MeshLambertMaterial({ color: 0x173f45 });
const treeAt = (x, z, s = 1) => {
const tr = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.35 * s, 0.5 * s, 3 * s, 7), trunkMat);
trunk.position.y = 1.5 * s;
trunk.castShadow = true;
tr.add(trunk);
for (let i = 0; i < 3; i++) {
const blob = new THREE.Mesh(new THREE.SphereGeometry((1.7 - i * 0.35) * s, 8, 7), i % 2 ? leafMat : leafMat2);
blob.position.set(Math.sin(i * 2.4) * 0.7 * s, (3.4 + i * 1.0) * s, Math.cos(i * 2.4) * 0.7 * s);
blob.castShadow = i === 0;
tr.add(blob);
}
tr.position.set(x, 0, z);
scene.add(tr);
obstacles.push({ type: 'circle', x, z, r: 0.7 * s });
};
treeAt(-38, 24, 1.2); treeAt(38, 8, 1.0); treeAt(-24, 2, 0.9); treeAt(30, 20, 1.3); treeAt(-6, 16, 1.1); treeAt(40, 30, 0.9);
const bushAt = (x, z, s = 1) => {
const b = new THREE.Mesh(new THREE.SphereGeometry(0.9 * s, 8, 6), Math.random() < 0.5 ? leafMat : leafMat2);
b.position.set(x, 0.5 * s, z);
b.scale.y = 0.75;
b.castShadow = true;
scene.add(b);
obstacles.push({ type: 'circle', x, z, r: 0.75 * s });
};
bushAt(-32, 6); bushAt(12, 2); bushAt(-8, 28); bushAt(24, 30); bushAt(-40, 16); bushAt(6, 12); bushAt(36, 14); bushAt(-18, 22);
// ---------- Sky, moon, lighting ----------
scene.background = new THREE.Color(0x0a1024);
scene.fog = new THREE.Fog(0x0a1024, 40, 95);
const moonGroup = new THREE.Group();
const moon = new THREE.Mesh(new THREE.SphereGeometry(3.4, 16, 14), new THREE.MeshBasicMaterial({ color: 0xeef2ff, fog: false }));
moon.position.set(30, 38, -55);
moonGroup.add(moon);
const moonHalo = new THREE.Mesh(new THREE.SphereGeometry(4.6, 16, 14), new THREE.MeshBasicMaterial({ color: 0xaabbee, transparent: true, opacity: 0.22, fog: false }));
moonHalo.position.copy(moon.position);
moonGroup.add(moonHalo);
scene.add(moonGroup);
// stars
const starGeo = new THREE.BufferGeometry();
const starPos = [];
for (let i = 0; i < 260; i++) {
const a = Math.random() * Math.PI * 2;
const r = 70 + Math.random() * 60;
starPos.push(Math.cos(a) * r, 22 + Math.random() * 70, -40 - Math.random() * 80);
}
starGeo.setAttribute('position', new THREE.Float32BufferAttribute(starPos, 3));
const stars = new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xcfd8ff, size: 0.5, fog: false }));
scene.add(stars);
const ambient = new THREE.AmbientLight(0x3a4a78, 0.85);
scene.add(ambient);
const moonLight = new THREE.DirectionalLight(0x9db8ff, 0.9);
moonLight.position.set(28, 42, -30);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(1024, 1024);
moonLight.shadow.camera.left = -50;
moonLight.shadow.camera.right = 50;
moonLight.shadow.camera.top = 45;
moonLight.shadow.camera.bottom = -45;
moonLight.shadow.camera.far = 120;
moonLight.shadow.bias = -0.002;
scene.add(moonLight);
const hemi = new THREE.HemisphereLight(0x24365e, 0x0c1220, 0.5);
scene.add(hemi);
// fireflies
const ffGeo = new THREE.BufferGeometry();
const ffPos = [];
for (let i = 0; i < 60; i++) {
ffPos.push(BOUNDS.minX + Math.random() * (BOUNDS.maxX - BOUNDS.minX), 0.5 + Math.random() * 2.2, -6 + Math.random() * (BOUNDS.maxZ + 6));
}
ffGeo.setAttribute('position', new THREE.Float32BufferAttribute(ffPos, 3));
const fireflies = new THREE.Points(ffGeo, new THREE.PointsMaterial({ color: 0xd8ffa0, size: 0.28, transparent: true, opacity: 0.9 }));
scene.add(fireflies);
return {
obstacles,
losBlockers,
windows,
hideoutPos,
ambient,
moonLight,
hemi,
fireflies,
// guard patrol routes (clockwise-ish around the yards)
patrolRoutes: [
[new THREE.Vector3(-30, 0, -6), new THREE.Vector3(-30, 0, 26), new THREE.Vector3(-8, 0, 22), new THREE.Vector3(-6, 0, -4)],
[new THREE.Vector3(30, 0, -4), new THREE.Vector3(34, 0, 24), new THREE.Vector3(8, 0, 20), new THREE.Vector3(6, 0, -6)],
[new THREE.Vector3(-20, 0, 10), new THREE.Vector3(20, 0, 10), new THREE.Vector3(22, 0, 28), new THREE.Vector3(-22, 0, 28)],
],
roadZ: -13,
};
}
File created successfully at: /home/user/raccoon-heist/js/world.js (file state is current in your context — no need to Read it back)
// Raccoon Heist — main game. Boots Three.js after the title screen,
// runs the night-heist loop: sneak, grab loot, dodge the law, bank it.
import * as THREE from '../vendor/three.module.js';
import { buildWorld, BOUNDS } from './world.js';
import { makeRaccoon, makeGuard, makePoliceCar, makeSeagull, makeLoot, LOOT_TYPES } from './actors.js';
import { Controls } from './controls.js';
import { initAudio, resumeAudio, sfx, toggleMusic } from './audio.js';
const NIGHT_LEN = 150; // seconds per night
const PLAYER_RADIUS = 0.55;
const MAX_CARRY = 8;
// ---------------------------------------------------------------- HUD & CSS
const css = document.createElement('style');
css.textContent = `
#hud { position: fixed; inset: 0; pointer-events: none; z-index: 30; display: none;
font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif; }
.hud-chip { position: fixed; background: rgba(8, 14, 32, 0.72); border: 1px solid rgba(140,170,255,0.25);
border-radius: 14px; padding: 6px 12px; color: #eaf0ff; font-weight: 700;
font-size: clamp(13px, 3.4vw, 17px); backdrop-filter: blur(3px); }
#hud-bank { top: max(10px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); }
#hud-carry { top: max(10px, env(safe-area-inset-top)); right: max(10px, env(safe-area-inset-right)); }
#hud-night { top: max(10px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);
text-align: center; padding: 6px 14px; }
#hud-timebar { width: clamp(90px, 26vw, 180px); height: 6px; background: rgba(255,255,255,0.15);
border-radius: 3px; margin-top: 4px; overflow: hidden; }
#hud-timefill { height: 100%; width: 100%; background: linear-gradient(90deg, #8fb4ff, #ffd23f); border-radius: 3px; }
#vignette { position: fixed; inset: 0; pointer-events: none; z-index: 25; opacity: 0;
background: radial-gradient(ellipse at center, transparent 55%, rgba(255, 30, 30, 0.55) 100%);
transition: opacity 0.25s; }
#toast { position: fixed; left: 50%; bottom: 18%; transform: translateX(-50%); z-index: 35;
background: rgba(8, 14, 32, 0.85); color: #ffd23f; border: 1px solid rgba(255,210,63,0.4);
padding: 10px 20px; border-radius: 999px; font-weight: 700; font-size: clamp(14px, 3.6vw, 18px);
opacity: 0; transition: opacity 0.3s; pointer-events: none; white-space: nowrap;
font-family: "Trebuchet MS", system-ui, sans-serif; max-width: 92vw; text-overflow: ellipsis; overflow: hidden; }
.overlay { position: fixed; inset: 0; z-index: 45; display: flex; flex-direction: column; align-items: center;
justify-content: center; background: rgba(5, 8, 20, 0.82); color: #eaf0ff; text-align: center;
font-family: "Trebuchet MS", system-ui, sans-serif; backdrop-filter: blur(4px); padding: 20px; }
.overlay h2 { font-size: clamp(1.8rem, 8vw, 3.2rem); margin-bottom: 0.4em; text-shadow: 0 4px 20px rgba(120,160,255,0.4); }
.overlay p { color: #9db4e6; margin-bottom: 1.6em; font-size: clamp(0.95rem, 3.6vw, 1.2rem); line-height: 1.5; }
.overlay .stars { font-size: clamp(1.6rem, 7vw, 2.6rem); margin-bottom: 0.5em; letter-spacing: 0.15em; }
.overlay button { font-family: inherit; font-size: clamp(1rem, 4vw, 1.3rem); font-weight: 700; padding: 0.8em 2.2em;
color: #1a1200; background: linear-gradient(180deg, #ffe27a, #ffc31f 60%, #f0a500); border: none;
border-radius: 999px; box-shadow: 0 5px 0 #8a5d00, 0 10px 24px rgba(255,195,31,0.3); cursor: pointer; }
.overlay button:active { transform: translateY(3px); box-shadow: 0 2px 0 #8a5d00; }
.float-label { position: fixed; z-index: 32; pointer-events: none; font: 800 16px "Trebuchet MS", sans-serif;
color: #ffd23f; text-shadow: 0 2px 6px #000; transition: transform 0.9s ease-out, opacity 0.9s; }
#music-btn { position: fixed; bottom: max(12px, env(safe-area-inset-bottom)); left: max(12px, env(safe-area-inset-left));
z-index: 36; width: 42px; height: 42px; border-radius: 50%; border: 1px solid rgba(140,170,255,0.3);
background: rgba(8,14,32,0.7); color: #eaf0ff; font-size: 20px; line-height: 42px; text-align: center;
pointer-events: auto; cursor: pointer; }
`;
document.head.appendChild(css);
const hud = document.createElement('div');
hud.id = 'hud';
hud.innerHTML = `
<div class="hud-chip" id="hud-bank">💰 <span id="bank-val">0</span> / <span id="goal-val">0</span></div>
<div class="hud-chip" id="hud-night">🌙 <span id="night-val">Night 1</span><div id="hud-timebar"><div id="hud-timefill"></div></div></div>
<div class="hud-chip" id="hud-carry">🎒 <span id="carry-val">0</span>/${MAX_CARRY}</div>
<div id="music-btn" title="music">🎷</div>
`;
document.body.appendChild(hud);
const vignette = document.createElement('div');
vignette.id = 'vignette';
document.body.appendChild(vignette);
const toastEl = document.createElement('div');
toastEl.id = 'toast';
document.body.appendChild(toastEl);
let toastTimer = null;
function toast(msg, ms = 2600) {
toastEl.textContent = msg;
toastEl.style.opacity = '1';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastEl.style.opacity = '0'; }, ms);
}
function floatLabel(text, screenX, screenY, color = '#ffd23f') {
const el = document.createElement('div');
el.className = 'float-label';
el.textContent = text;
el.style.left = screenX + 'px';
el.style.top = screenY + 'px';
el.style.color = color;
document.body.appendChild(el);
requestAnimationFrame(() => {
el.style.transform = 'translateY(-70px)';
el.style.opacity = '0';
});
setTimeout(() => el.remove(), 950);
}
// ---------------------------------------------------------------- Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.domElement.style.cssText = 'position:fixed;inset:0;z-index:1;display:block;';
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 220);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// ---------------------------------------------------------------- Game state
const controls = new Controls();
let world = null;
let player = null;
const playerPos = new THREE.Vector3(0, 0, 26);
let playerAngle = Math.PI;
let playerSpeedVis = 0;
let dashTime = -10, dashCooldownUntil = 0, frenzyUntil = -10;
const carried = []; // {kind, value, mesh}
let banked = 0, goal = 200, night = 1;
let nightTime = 0;
let state = 'title'; // title | playing | caught | results
let caughtTimer = 0;
const guards = []; // {api, pos, angle, waypoints, wpIndex, alert, mode, loseSightT, cone, exclaim}
let policeCar = null, carX = -40, carDir = 1;
const lootItems = []; // {kind, value, group, x, z, taken}
let seagull = null; // {api, state, target, t, from}
let seagullTimer = 18;
let windowEvent = null; // {win, t, circle}
let windowTimer = 14;
let crew = [];
let lastFrame = performance.now();
let camShake = 0;
const $ = (id) => document.getElementById(id);
// ---------------------------------------------------------------- Helpers
function circleVsObstacles(x, z, r) {
// returns corrected [x, z] pushed out of all obstacles
for (const o of world.obstacles) {
if (o.type === 'box') {
const nx = Math.max(o.minX, Math.min(x, o.maxX));
const nz = Math.max(o.minZ, Math.min(z, o.maxZ));
const dx = x - nx, dz = z - nz;
const d2 = dx * dx + dz * dz;
if (d2 < r * r) {
if (d2 > 1e-6) {
const d = Math.sqrt(d2);
x = nx + (dx / d) * r;
z = nz + (dz / d) * r;
} else {
// center inside box: push out along smallest penetration
const pl = x - o.minX, pr = o.maxX - x, pt = z - o.minZ, pb = o.maxZ - z;
const m = Math.min(pl, pr, pt, pb);
if (m === pl) x = o.minX - r; else if (m === pr) x = o.maxX + r;
else if (m === pt) z = o.minZ - r; else z = o.maxZ + r;
}
}
} else {
const dx = x - o.x, dz = z - o.z;
const rr = r + o.r;
const d2 = dx * dx + dz * dz;
if (d2 < rr * rr && d2 > 1e-6) {
const d = Math.sqrt(d2);
x = o.x + (dx / d) * rr;
z = o.z + (dz / d) * rr;
}
}
}
x = Math.max(BOUNDS.minX, Math.min(BOUNDS.maxX, x));
z = Math.max(BOUNDS.minZ, Math.min(BOUNDS.maxZ, z));
return [x, z];
}
function losBlocked(x1, z1, x2, z2) {
for (const b of world.losBlockers) {
// 2D segment vs AABB slab test
const dx = x2 - x1, dz = z2 - z1;
let tmin = 0, tmax = 1;
if (Math.abs(dx) < 1e-8) {
if (x1 < b.minX || x1 > b.maxX) continue;
} else {
let t1 = (b.minX - x1) / dx, t2 = (b.maxX - x1) / dx;
if (t1 > t2) [t1, t2] = [t2, t1];
tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2);
if (tmin > tmax) continue;
}
if (Math.abs(dz) < 1e-8) {
if (z1 < b.minZ || z1 > b.maxZ) continue;
} else {
let t1 = (b.minZ - z1) / dz, t2 = (b.maxZ - z1) / dz;
if (t1 > t2) [t1, t2] = [t2, t1];
tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2);
if (tmin > tmax) continue;
}
return true;
}
return false;
}
function randomLootSpot() {
for (let tries = 0; tries < 40; tries++) {
const x = BOUNDS.minX + 2 + Math.random() * (BOUNDS.maxX - BOUNDS.minX - 4);
const z = -8 + Math.random() * (BOUNDS.maxZ - 4 + 8);
if (Math.hypot(x - world.hideoutPos.x, z - world.hideoutPos.z) < 6) continue;
let ok = true;
for (const o of world.obstacles) {
if (o.type === 'box') {
if (x > o.minX - 1 && x < o.maxX + 1 && z > o.minZ - 1 && z < o.maxZ + 1) { ok = false; break; }
} else if (Math.hypot(x - o.x, z - o.z) < o.r + 1) { ok = false; break; }
}
if (ok) return [x, z];
}
return [0, 10];
}
function pickLootKind() {
const entries = Object.entries(LOOT_TYPES);
const total = entries.reduce((s, [, v]) => s + v.weight, 0);
let r = Math.random() * total;
for (const [kind, v] of entries) {
r -= v.weight;
if (r <= 0) return kind;
}
return 'coin';
}
function worldToScreen(v3) {
const v = v3.clone().project(camera);
return [(v.x * 0.5 + 0.5) * window.innerWidth, (-v.y * 0.5 + 0.5) * window.innerHeight];
}
// ---------------------------------------------------------------- Setup
function spawnLoot(count) {
for (const l of lootItems) scene.remove(l.group);
lootItems.length = 0;
for (let i = 0; i < count; i++) {
const kind = pickLootKind();
const [x, z] = randomLootSpot();
const group = makeLoot(kind);
group.position.set(x, 0, z);
scene.add(group);
lootItems.push({ kind, value: LOOT_TYPES[kind].value, group, x, z, taken: false });
}
}
function makeVisionCone(range, halfAngleDeg) {
const angle = THREE.MathUtils.degToRad(halfAngleDeg);
const geo = new THREE.ConeGeometry(Math.tan(angle) * range, range, 24, 1, true);
const matC = new THREE.MeshBasicMaterial({
color: 0xfff2b0, transparent: true, opacity: 0.13, side: THREE.DoubleSide,
depthWrite: false,
});
const cone = new THREE.Mesh(geo, matC);
cone.rotation.x = -Math.PI / 2; // point along +z
cone.position.set(0, 0.9, range / 2);
const wrap = new THREE.Group();
wrap.add(cone);
return { wrap, mat: matC };
}
function addGuard(route, speedBoost = 0) {
const api = makeGuard();
scene.add(api.group);
const cone = makeVisionCone(11, 26);
api.group.add(cone.wrap);
const exclaim = document.createElement('div');
exclaim.className = 'float-label';
exclaim.style.transition = 'none';
exclaim.style.fontSize = '26px';
exclaim.style.display = 'none';
exclaim.textContent = '❗';
document.body.appendChild(exclaim);
const g = {
api, cone, exclaim,
pos: route[0].clone(),
angle: 0,
waypoints: route,
wpIndex: 1,
alert: 0,
mode: 'patrol',
loseSightT: 0,
baseSpeed: 2.3 + speedBoost,
chaseSpeed: 4.9 + speedBoost * 1.4,
};
guards.push(g);
}
function clearGuards() {
for (const g of guards) { scene.remove(g.api.group); g.exclaim.remove(); }
guards.length = 0;
}
async function setupGame() {
world = await buildWorld(scene);
player = makeRaccoon({ shirt: 0x232a3a });
scene.add(player.group);
// crew raccoons at the hideout
for (const [dx, dz, s] of [[-3.6, 0.6, 0.8], [3.6, 0.2, 0.75]]) {
const c = makeRaccoon({ scale: s, shirt: [0x5a3a6e, 0x2f6b4f][crew.length] });
c.group.position.set(world.hideoutPos.x + dx, 0, world.hideoutPos.z + dz);
c.group.rotation.y = Math.random() * Math.PI * 2;
scene.add(c.group);
crew.push(c);
}
policeCar = makePoliceCar();
policeCar.group.position.set(carX, 0, world.roadZ);
scene.add(policeCar.group);
seagull = { api: makeSeagull(), state: 'away', target: null, t: 0 };
seagull.api.group.position.set(0, 30, -40);
scene.add(seagull.api.group);
const shadow = new THREE.Mesh(new THREE.CircleGeometry(0.8, 16), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0 }));
shadow.rotation.x = -Math.PI / 2;
shadow.position.y = 0.03;
scene.add(shadow);
seagull.shadow = shadow;
// window-event ground glow
const glow = new THREE.Mesh(new THREE.CircleGeometry(5.2, 24), new THREE.MeshBasicMaterial({ color: 0xffd88a, transparent: true, opacity: 0, depthWrite: false }));
glow.rotation.x = -Math.PI / 2;
glow.position.y = 0.04;
scene.add(glow);
windowEvent = { active: false, t: 0, win: null, circle: glow, cx: 0, cz: 0 };
}
function startNight(n) {
night = n;
goal = 150 + (n - 1) * 120;
nightTime = 0;
banked = 0;
for (const c of carried) player.lootAnchor.remove(c.mesh);
carried.length = 0;
playerPos.set(0, 0, 26);
playerAngle = Math.PI;
frenzyUntil = -10;
clearGuards();
const routes = world.patrolRoutes;
const boost = (n - 1) * 0.25;
if (n === 1) addGuard(routes[2], boost);
else if (n === 2) { addGuard(routes[0], boost); addGuard(routes[1], boost); }
else { addGuard(routes[0], boost); addGuard(routes[1], boost); addGuard(routes[2], boost); }
spawnLoot(16 + n * 4);
carX = -40; carDir = 1;
seagullTimer = 15 + Math.random() * 15;
windowTimer = 10 + Math.random() * 12;
$('night-val').textContent = `Night ${n}`;
$('goal-val').textContent = goal;
$('bank-val').textContent = '0';
$('carry-val').textContent = '0';
state = 'playing';
hud.style.display = 'block';
if (n === 1) toast('Grab shinies 💎 — stash them in the crew dumpster! 🗑️', 4200);
else toast(`Night ${n}: more heat, bigger score. Stay in the shadows.`, 3600);
}
// ---------------------------------------------------------------- Results
function showResults(won) {
state = 'results';
const overlay = document.createElement('div');
overlay.className = 'overlay';
const ratio = banked / goal;
const stars = won ? (ratio >= 2 ? '⭐⭐⭐' : ratio >= 1.4 ? '⭐⭐' : '⭐') : '';
if (won) {
sfx.win();
overlay.innerHTML = `
<h2>NIGHT ${night} CLEAR! 🦝</h2>
<div class="stars">${stars}</div>
<p>The crew banked <b style="color:#ffd23f">${banked}</b> in shiny loot (goal ${goal}).<br>
Word on the street: an even bigger score tomorrow…</p>
<button id="next-btn">NIGHT ${night + 1} ➜</button>`;
} else {
sfx.lose();
overlay.innerHTML = `
<h2>THE SUN CAME UP ☀️</h2>
<p>Only <b style="color:#ffd23f">${banked}</b> of ${goal} banked before dawn.<br>
The crew scattered into the hedges. Tonight, we go again.</p>
<button id="next-btn">RETRY NIGHT ${night}</button>`;
}
document.body.appendChild(overlay);
overlay.querySelector('#next-btn').addEventListener('click', () => {
overlay.remove();
resumeAudio();
startNight(won ? night + 1 : night);
});
}
// ---------------------------------------------------------------- Caught
function onCaught(guard) {
state = 'caught';
caughtTimer = 2.0;
camShake = 0.7;
sfx.caught();
vignette.style.opacity = '1';
const dropCount = Math.ceil(carried.length / 2);
for (let i = 0; i < dropCount; i++) {
const item = carried.pop();
player.lootAnchor.remove(item.mesh);
// scatter it back on the ground near the catch point
const a = Math.random() * Math.PI * 2, d = 2 + Math.random() * 3;
let [lx, lz] = circleVsObstacles(playerPos.x + Math.cos(a) * d, playerPos.z + Math.sin(a) * d, 0.5);
const group = makeLoot(item.kind);
group.position.set(lx, 0, lz);
scene.add(group);
lootItems.push({ kind: item.kind, value: item.value, group, x: lx, z: lz, taken: false });
}
updateCarryHUD();
toast(carried.length ? 'Busted! You dropped some loot! 🚨' : 'Busted! The guard shooed you off! 🚨', 3000);
for (const g of guards) { g.alert = 0; g.mode = 'patrol'; }
}
function updateCarryHUD() {
$('carry-val').textContent = carried.length;
$('hud-carry').style.borderColor = carried.length >= MAX_CARRY ? 'rgba(255,90,90,0.8)' : 'rgba(140,170,255,0.25)';
}
// ---------------------------------------------------------------- Per-frame updates
const tmpV = new THREE.Vector3();
function updatePlayer(dt) {
controls.update();
let mx = controls.moveX, mz = controls.moveZ;
const now = performance.now() / 1000;
if (controls.consumeDash() && now > dashCooldownUntil && (mx || mz || true)) {
dashTime = now;
dashCooldownUntil = now + 1.4;
sfx.dash();
}
const dashing = now - dashTime < 0.28;
const frenzy = now < frenzyUntil;
const carryPenalty = 1 - Math.min(0.35, carried.length * 0.045);
let speed = 7.2 * carryPenalty * (frenzy ? 1.55 : 1) * (dashing ? 2.1 : 1);
const mlen = Math.hypot(mx, mz);
if (mlen > 0.05) {
playerAngle = Math.atan2(mx, mz);
playerSpeedVis = Math.min(1, mlen) * (dashing ? 1.6 : 1);
} else if (dashing) {
// dash continues in facing direction
mx = Math.sin(playerAngle); mz = Math.cos(playerAngle);
playerSpeedVis = 1.4;
} else {
playerSpeedVis *= Math.max(0, 1 - dt * 8);
speed = 0;
}
if (speed > 0) {
let nx = playerPos.x + mx * speed * dt;
let nz = playerPos.z + mz * speed * dt;
[nx, nz] = circleVsObstacles(nx, nz, PLAYER_RADIUS);
playerPos.x = nx; playerPos.z = nz;
}
player.group.position.copy(playerPos);
const targetRot = playerAngle;
let dr = targetRot - player.group.rotation.y;
while (dr > Math.PI) dr -= Math.PI * 2;
while (dr < -Math.PI) dr += Math.PI * 2;
player.group.rotation.y += dr * Math.min(1, dt * 14);
player.api?.animate?.(dt, playerSpeedVis);
player.animate(dt, playerSpeedVis);
// carried loot bobs on the back
carried.forEach((c, i) => {
c.mesh.position.y = 0.25 + i * 0.34 + Math.sin(now * 6 + i) * 0.03;
c.mesh.rotation.y += dt * 2;
});
// pickup
for (const l of lootItems) {
if (l.taken) continue;
if (Math.hypot(l.x - playerPos.x, l.z - playerPos.z) < 1.15) {
if (l.kind === 'pizza') {
l.taken = true;
scene.remove(l.group);
frenzyUntil = now + 6;
sfx.frenzy();
toast('🍕 PIZZA FRENZY! Zoom zoom!', 2200);
continue;
}
if (carried.length >= MAX_CARRY) {
toast('Paws full! Bank it at the dumpster 🗑️', 1800);
continue;
}
l.taken = true;
scene.remove(l.group);
const mini = makeLoot(l.kind);
mini.scale.setScalar(0.65);
player.lootAnchor.add(mini);
carried.push({ kind: l.kind, value: l.value, mesh: mini });
(l.kind === 'coin' ? sfx.coin : l.kind === 'gem' || l.kind === 'ring' ? sfx.gem : sfx.munch)();
const [sx, sy] = worldToScreen(tmpV.set(playerPos.x, 1.5, playerPos.z));
floatLabel(`+${l.value}`, sx, sy);
updateCarryHUD();
}
}
// prune taken loot
for (let i = lootItems.length - 1; i >= 0; i--) if (lootItems[i].taken) lootItems.splice(i, 1);
// banking
if (carried.length && Math.hypot(playerPos.x - world.hideoutPos.x, playerPos.z - world.hideoutPos.z) < 3.4) {
let total = 0;
for (const c of carried) { total += c.value; player.lootAnchor.remove(c.mesh); }
carried.length = 0;
banked += total;
sfx.bank();
$('bank-val').textContent = banked;
updateCarryHUD();
const [sx, sy] = worldToScreen(tmpV.set(world.hideoutPos.x, 3, world.hideoutPos.z));
floatLabel(`BANKED +${total}!`, sx, sy, '#7fff9e');
camShake = Math.max(camShake, 0.15);
for (const c of crew) c.group.position.y = 0.5; // lil celebratory hop
if (banked >= goal) toast('Goal reached! Keep looting or savor the win 😎', 3000);
}
// frenzy visual: tail wag speed & vignette tint
player.tail.rotation.z = frenzy ? Math.sin(now * 20) * 0.5 : player.tail.rotation.z;
}
function updateGuards(dt) {
const now = performance.now() / 1000;
let maxAlertRatio = 0;
for (const g of guards) {
// --- movement
let speed = 0;
if (g.mode === 'patrol' || g.mode === 'return') {
const wp = g.waypoints[g.wpIndex];
tmpV.set(wp.x - g.pos.x, 0, wp.z - g.pos.z);
const d = tmpV.length();
if (d < 0.8) { g.wpIndex = (g.wpIndex + 1) % g.waypoints.length; }
else {
tmpV.normalize();
const targetAngle = Math.atan2(tmpV.x, tmpV.z);
let da = targetAngle - g.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
g.angle += da * Math.min(1, dt * 3);
speed = g.baseSpeed;
g.pos.x += Math.sin(g.angle) * speed * dt;
g.pos.z += Math.cos(g.angle) * speed * dt;
}
} else if (g.mode === 'chase') {
tmpV.set(playerPos.x - g.pos.x, 0, playerPos.z - g.pos.z);
const d = tmpV.length();
tmpV.normalize();
const targetAngle = Math.atan2(tmpV.x, tmpV.z);
let da = targetAngle - g.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
g.angle += da * Math.min(1, dt * 6);
speed = g.chaseSpeed;
g.pos.x += Math.sin(g.angle) * speed * dt;
g.pos.z += Math.cos(g.angle) * speed * dt;
if (d < 1.35 && state === 'playing') onCaught(g);
}
[g.pos.x, g.pos.z] = circleVsObstacles(g.pos.x, g.pos.z, 0.6);
g.api.group.position.set(g.pos.x, 0, g.pos.z);
g.api.group.rotation.y = g.angle;
g.api.animate(dt, speed / 4);
// --- vision
const dx = playerPos.x - g.pos.x, dz = playerPos.z - g.pos.z;
const dist = Math.hypot(dx, dz);
const inRange = dist < 11;
let sees = false;
if (inRange) {
const dirAngle = Math.atan2(dx, dz);
let da = dirAngle - g.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
if (Math.abs(da) < THREE.MathUtils.degToRad(28)) {
sees = !losBlocked(g.pos.x, g.pos.z, playerPos.x, playerPos.z);
}
}
if (sees && state === 'playing') {
const rate = 55 + (1 - dist / 11) * 130;
g.alert = Math.min(100, g.alert + rate * dt);
if (g.alert >= 100 && g.mode !== 'chase') {
g.mode = 'chase';
sfx.alert();
toast('You’ve been spotted! RUN! 🏃💨', 2000);
}
} else {
g.alert = Math.max(0, g.alert - 26 * dt);
if (g.mode === 'chase') {
g.loseSightT += dt;
if (g.loseSightT > 3.5 || g.alert <= 0) {
g.mode = 'patrol';
g.loseSightT = 0;
// head to nearest waypoint
let best = 0, bd = 1e9;
g.waypoints.forEach((wp, i) => {
const d = Math.hypot(wp.x - g.pos.x, wp.z - g.pos.z);
if (d < bd) { bd = d; best = i; }
});
g.wpIndex = best;
}
}
}
if (sees) g.loseSightT = 0;
// cone color: yellow -> orange -> red
const a = g.alert / 100;
maxAlertRatio = Math.max(maxAlertRatio, a);
g.cone.mat.color.setHSL(0.13 - a * 0.13, 1, 0.62);
g.cone.mat.opacity = 0.11 + a * 0.14;
// exclamation UI
if (g.alert > 25) {
const [sx, sy] = worldToScreen(tmpV.set(g.pos.x, 2.9, g.pos.z));
g.exclaim.style.display = 'block';
g.exclaim.style.left = (sx - 10) + 'px';
g.exclaim.style.top = (sy - 20) + 'px';
g.exclaim.textContent = g.mode === 'chase' ? '‼️' : '❓';
} else {
g.exclaim.style.display = 'none';
}
}
vignette.style.opacity = state === 'caught' ? '1' : String(maxAlertRatio * 0.8);
}
function updatePoliceCar(dt) {
const speed = 7 + night * 1.2;
carX += carDir * speed * dt;
if (carX > 46) { carDir = -1; }
if (carX < -46) { carDir = 1; }
policeCar.group.position.set(carX, 0, world.roadZ);
policeCar.group.rotation.y = carDir > 0 ? Math.PI / 2 : -Math.PI / 2;
policeCar.animate(dt);
// headlight beam catch: on the road, ahead of the car
if (state === 'playing') {
const ahead = (playerPos.x - carX) * carDir;
if (Math.abs(playerPos.z - world.roadZ) < 4 && ahead > 0 && ahead < 12) {
for (const g of guards) g.alert = Math.min(100, g.alert + 240 * dt);
if (Math.hypot(playerPos.x - carX, playerPos.z - world.roadZ) < 3) {
onCaught(null);
}
}
}
}
function updateSeagull(dt) {
const s = seagull;
s.api.animate(dt, s.state === 'away' ? 0.3 : 1);
if (s.state === 'away') {
seagullTimer -= dt;
if (seagullTimer <= 0 && lootItems.length > 2) {
// pick a target loot
s.target = lootItems[Math.floor(Math.random() * lootItems.length)];
s.state = 'swoop';
s.t = 0;
s.from = new THREE.Vector3(playerPos.x + 30, 26, playerPos.z - 30);
sfx.squawk();
toast('🐦 Seagull! It wants YOUR loot!', 2200);
}
return;
}
if (s.state === 'swoop') {
s.t += dt / 3.2;
if (!s.target || s.target.taken) {
s.state = 'flee'; s.t = 0;
s.grabbed = null;
return;
}
const tx = s.target.x, tz = s.target.z;
const p = s.t;
const x = THREE.MathUtils.lerp(s.from.x, tx, p);
const z = THREE.MathUtils.lerp(s.from.z, tz, p);
const y = THREE.MathUtils.lerp(s.from.y, 0.5, p * p);
s.api.group.position.set(x, y, z);
s.api.group.lookAt(tx, 0.5, tz);
s.shadow.position.set(tx, 0.03, tz);
s.shadow.material.opacity = Math.min(0.45, p * 0.6);
s.shadow.scale.setScalar(0.4 + p * 0.8);
if (p >= 1) {
// grab!
s.target.taken = true;
scene.remove(s.target.group);
const mini = makeLoot(s.target.kind);
mini.scale.setScalar(0.6);
mini.position.set(0, -0.3, 0.2);
s.api.group.add(mini);
s.grabbed = mini;
sfx.squawk();
s.state = 'flee';
s.t = 0;
s.shadow.material.opacity = 0;
}
return;
}
if (s.state === 'flee') {
s.t += dt / 4;
s.api.group.position.y += dt * 8;
s.api.group.position.x -= dt * 14;
s.api.group.position.z -= dt * 6;
if (s.t >= 1) {
if (s.grabbed) { s.api.group.remove(s.grabbed); s.grabbed = null; }
s.state = 'away';
seagullTimer = 20 + Math.random() * 22;
s.api.group.position.set(0, 30, -40);
}
}
}
function updateWindowEvent(dt) {
const we = windowEvent;
if (!we.active) {
windowTimer -= dt;
if (windowTimer <= 0 && world.windows.length) {
we.active = true;
we.t = 5;
we.win = world.windows[Math.floor(Math.random() * world.windows.length)];
we.cx = we.win.pos.x + we.win.dir.x * 3.4;
we.cz = we.win.pos.z + we.win.dir.z * 3.4;
we.circle.position.set(we.cx, 0.04, we.cz);
we.win.mesh.material.emissive = new THREE.Color(0xffe9b0);
we.win.mesh.material.emissiveIntensity = 2.0;
sfx.meow();
toast('👀 A homeowner is peeking out!', 2200);
}
return;
}
we.t -= dt;
const pulse = 0.32 + Math.sin(performance.now() / 130) * 0.08;
we.circle.material.opacity = we.t > 0.6 ? pulse : pulse * (we.t / 0.6);
if (state === 'playing' && Math.hypot(playerPos.x - we.cx, playerPos.z - we.cz) < 5.2) {
for (const g of guards) g.alert = Math.min(100, g.alert + 65 * dt);
}
if (we.t <= 0) {
we.active = false;
we.circle.material.opacity = 0;
we.win.mesh.material.emissiveIntensity = we.win.lit ? 1.1 : 0.4;
we.win.mesh.material.emissive = new THREE.Color(we.win.lit ? 0xffb347 : 0x101b30);
windowTimer = 16 + Math.random() * 18;
}
}
function updateDawn(dt) {
nightTime += dt;
const frac = Math.min(1, nightTime / NIGHT_LEN);
$('hud-timefill').style.width = ((1 - frac) * 100) + '%';
// last 30 seconds: sky begins to lighten
const dawn = Math.max(0, (nightTime - (NIGHT_LEN - 30)) / 30);
if (dawn > 0) {
const sky = new THREE.Color(0x0a1024).lerp(new THREE.Color(0x6a5a7e), dawn);
scene.background = sky;
scene.fog.color = sky;
world.ambient.intensity = 0.85 + dawn * 0.9;
world.ambient.color.setHex(0x3a4a78).lerp(new THREE.Color(0xcf9a6a), dawn);
if (dawn > 0.65 && Math.floor(nightTime * 2) !== Math.floor((nightTime - dt) * 2) && Math.floor(nightTime) % 5 === 0) {
// gentle tick reminder handled by bar; skip extra toasts
}
}
if (nightTime >= NIGHT_LEN && state === 'playing') {
showResults(banked >= goal);
}
}
// ---------------------------------------------------------------- Camera
function updateCamera(dt) {
const portrait = window.innerHeight > window.innerWidth;
const dy = portrait ? 19 : 15;
const dz = portrait ? 14 : 12;
tmpV.set(playerPos.x, dy, playerPos.z + dz);
camera.position.lerp(tmpV, Math.min(1, dt * 4));
if (camShake > 0) {
camShake = Math.max(0, camShake - dt * 1.6);
camera.position.x += (Math.random() - 0.5) * camShake * 0.6;
camera.position.y += (Math.random() - 0.5) * camShake * 0.4;
}
camera.lookAt(playerPos.x, 1, playerPos.z - 2);
}
// ---------------------------------------------------------------- Main loop
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(0.05, (now - lastFrame) / 1000);
lastFrame = now;
if (state === 'title') return;
const t = now / 1000;
// idle animations always run
for (const l of lootItems) {
l.group.position.y = Math.sin(t * 2.4 + l.x) * 0.08;
l.group.rotation.y += dt * 1.6;
}
for (const c of crew) {
c.animate(dt, 0.05);
c.group.position.y = Math.max(0, c.group.position.y - dt * 2);
}
if (world.fireflies) {
const pos = world.fireflies.geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.setY(i, 0.6 + Math.sin(t * 0.7 + i * 1.3) * 0.5 + Math.sin(t * 0.31 + i) * 0.4);
}
pos.needsUpdate = true;
world.fireflies.material.opacity = 0.5 + Math.sin(t * 2) * 0.3;
}
if (state === 'playing') {
updatePlayer(dt);
updateGuards(dt);
updatePoliceCar(dt);
updateSeagull(dt);
updateWindowEvent(dt);
updateDawn(dt);
} else if (state === 'caught') {
caughtTimer -= dt;
updateGuards(dt * 0.3);
updatePoliceCar(dt);
if (caughtTimer <= 0) {
playerPos.set(0, 0, 26);
player.group.position.copy(playerPos);
vignette.style.opacity = '0';
state = 'playing';
}
updateDawn(dt);
} else if (state === 'results') {
updatePoliceCar(dt);
for (const g of guards) g.api.animate(dt, 0.1);
}
updateCamera(dt);
renderer.render(scene, camera);
}
// ---------------------------------------------------------------- Boot
const playBtn = document.getElementById('play-btn');
const statusEl = document.getElementById('loading-status');
let worldReady = false, worldLoading = false;
async function ensureWorld() {
if (worldReady || worldLoading) return;
worldLoading = true;
statusEl.textContent = 'Casing the joint…';
try {
await setupGame();
worldReady = true;
statusEl.textContent = '';
} catch (e) {
statusEl.textContent = 'Load hiccup — tap START to retry.';
worldLoading = false;
console.error(e);
}
}
ensureWorld();
playBtn.addEventListener('click', async () => {
playBtn.disabled = true;
initAudio();
resumeAudio();
await ensureWorld();
if (!worldReady) { playBtn.disabled = false; return; }
document.getElementById('title-screen').classList.add('hidden');
startNight(1);
playBtn.disabled = false;
});
document.getElementById('music-btn').addEventListener('click', () => {
const on = toggleMusic();
document.getElementById('music-btn').style.opacity = on ? '1' : '0.4';
});
requestAnimationFrame((n) => { lastFrame = n; requestAnimationFrame(frame); });
File created successfully at: /home/user/raccoon-heist/js/main.js (file state is current in your context — no need to Read it back)
addFence(-14, 14, 0.3 ? 16 : 16, false); // vertical divider left
addFence(-14, 14, 16, false); // vertical divider left
The file /home/user/raccoon-heist/js/world.js has been updated successfully. (file state is current in your context — no need to Read it back)
if (controls.consumeDash() && now > dashCooldownUntil && (mx || mz || true)) { if (controls.consumeDash() && now > dashCooldownUntil) {The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
player.api?.animate?.(dt, playerSpeedVis); player.animate(dt, playerSpeedVis);
player.animate(dt, playerSpeedVis);
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
python3 -m http.server 8642 >/dev/null 2>&1 &
sleep 1; curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8642/index.html// Smoke test: load the game, click START, simulate movement, screenshot.
const { chromium } = require('playwright');
const OUT = '/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad';
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
const errors = [];
page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.screenshot({ path: OUT + '/shot-title.png' });
await page.click('#play-btn');
await page.waitForTimeout(2500);
await page.screenshot({ path: OUT + '/shot-game.png' });
// move the raccoon around
await page.keyboard.down('w');
await page.waitForTimeout(1500);
await page.keyboard.up('w');
await page.keyboard.down('a');
await page.waitForTimeout(1200);
await page.keyboard.up('a');
await page.keyboard.press(' ');
await page.waitForTimeout(800);
await page.screenshot({ path: OUT + '/shot-move.png' });
// let events fire for a while
await page.waitForTimeout(12000);
await page.screenshot({ path: OUT + '/shot-later.png' });
console.log(errors.length ? errors.join('\n') : 'NO ERRORS');
await browser.close();
})();
File created successfully at: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/smoke.js (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && (npm ls playwright --depth=0 2>/dev/null || npm install --silent playwright 2>&1 | tail -1) && node smoke.js
/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad `-- (empty) CONSOLE: Failed to load resource: the server responded with a status of 404 (File not found) Shell cwd was reset to /home/user/raccoon-heist
{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-title.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-game.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-move.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-later.png"
}const ambient = new THREE.AmbientLight(0x3a4a78, 0.85); scene.add(ambient); const moonLight = new THREE.DirectionalLight(0x9db8ff, 0.9);
const ambient = new THREE.AmbientLight(0x4a5c92, 1.25); scene.add(ambient); const moonLight = new THREE.DirectionalLight(0xaec4ff, 1.35);
The file /home/user/raccoon-heist/js/world.js has been updated successfully. (file state is current in your context — no need to Read it back)
const hemi = new THREE.HemisphereLight(0x24365e, 0x0c1220, 0.5); scene.add(hemi);
const hemi = new THREE.HemisphereLight(0x35508c, 0x141c30, 0.85); scene.add(hemi);
The file /home/user/raccoon-heist/js/world.js has been updated successfully. (file state is current in your context — no need to Read it back)
const portrait = window.innerHeight > window.innerWidth; const dy = portrait ? 19 : 15; const dz = portrait ? 14 : 12;
const portrait = window.innerHeight > window.innerWidth; const dy = portrait ? 16 : 12; const dz = portrait ? 12.5 : 12.5;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
player = makeRaccoon({ shirt: 0x232a3a });
scene.add(player.group); player = makeRaccoon({ shirt: 0x232a3a });
scene.add(player.group);
// soft glow that follows the hero so they always read clearly
const heroLight = new THREE.PointLight(0xbfd4ff, 6, 9, 1.4);
heroLight.position.set(0, 3, 0);
player.group.add(heroLight);The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node smoke.js
CONSOLE: Failed to load resource: the server responded with a status of 404 (File not found) Shell cwd was reset to /home/user/raccoon-heist
{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-game.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-later.png"
}Much better — glowing windows, visible flashlight cone, readable raccoon. Committing this playable build now.
cat >> notes.md << 'EOF' ## Playable core build - Vendored `three@0.170` module build into `vendor/` (no CDN at runtime). - Generated 7 tileable textures with OpenAI `gpt-image-2` (low quality, 1024px, downscaled to 512px JPEG): grass, siding, fence wood, roof shingles, cracked concrete, trash-can metal, soil. Stylized night palette came out great. - `js/actors.js`: procedural low-poly characters — raccoon (bandit mask, striped tail, trotting legs, loot stacks on its back), security guard with flashlight, police car with flashing light bar, seagull, and six loot types. - `js/world.js`: the map — three houses with glowing windows, street for the police car, yard fences with sneak gaps, trash cans, trees/bushes, the crew's green dumpster hideout (with glowing eyes inside), moon, stars, fireflies, fog. Also exports collision obstacles + line-of-sight blockers. - `js/main.js`: game loop — WASD/joystick movement with collision + dash, auto-pickup loot (max 8 carried, slows you down), bank at the dumpster, guard vision cones with alert build-up → chase → caught (drop half your loot), police headlight sweeps, seagull loot-theft event, homeowner window-peek event, pizza frenzy speed boost, dawn timer with sky lightening, night-by-night escalation (more guards, bigger goals), win/lose overlays with star ratings. - `js/controls.js`: dynamic touch joystick (appears where you touch, left 62% of screen) + DASH button; keyboard WASD/arrows + Space. - `js/audio.js`: procedural WebAudio — sneaky walking-bass jazz loop, coin/gem plings, alarm, squawk, win/lose jingles. No audio files. - Smoke-tested with Playwright + SwiftShader: no JS errors; brightened lighting and lowered camera angle after first screenshots read too dark. EOF git add -A && git commit -m "Playable core: 3D night heist with guards, loot, events, touch controls Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push -u origin claude/3d-raccoon-heist-game-50n293 && git log --oneline -3
14 files changed, 1802 insertions(+) create mode 100644 js/actors.js create mode 100644 js/audio.js create mode 100644 js/controls.js create mode 100644 js/main.js create mode 100644 js/world.js create mode 100644 textures/brick.jpg create mode 100644 textures/fence.jpg create mode 100644 textures/grass.jpg create mode 100644 textures/metal.jpg create mode 100644 textures/path.jpg create mode 100644 textures/roof.jpg create mode 100644 textures/soil.jpg create mode 100644 vendor/three.module.js To https://github.com/simonw/raccoon-heist b0c9e08..d221ef0 claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293 branch 'claude/3d-raccoon-heist-game-50n293' set up to track 'origin/claude/3d-raccoon-heist-game-50n293'. d221ef0 Playable core: 3D night heist with guards, loot, events, touch controls b0c9e08 Title screen: night sky, moon, skyline, START button 3b11a53 Images to go with prompt
requestAnimationFrame((n) => { lastFrame = n; requestAnimationFrame(frame); });requestAnimationFrame((n) => { lastFrame = n; requestAnimationFrame(frame); });
// tiny debug/testing hook (also handy in devtools)
window.__rh = {
get state() { return state; },
get banked() { return banked; },
get carriedCount() { return carried.length; },
get nightTime() { return nightTime; },
get guards() { return guards.map((g) => ({ mode: g.mode, alert: g.alert, x: g.pos.x, z: g.pos.z })); },
get loot() { return lootItems.map((l) => ({ x: l.x, z: l.z, kind: l.kind })); },
teleport(x, z) { playerPos.set(x, 0, z); },
};The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
// Gameplay verification: pickup, banking, guard chase/caught, mobile joystick.
const { chromium } = require('playwright');
const OUT = '/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad';
async function desktopTest(browser, errors) {
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.click('#play-btn');
await page.waitForTimeout(1500);
// --- pickup: teleport next to a loot item, walk into it
const loot = await page.evaluate(() => window.__rh.loot[0]);
await page.evaluate(([x, z]) => window.__rh.teleport(x - 2, z), [loot.x, loot.z]);
await page.keyboard.down('d');
await page.waitForTimeout(900);
await page.keyboard.up('d');
let carried = await page.evaluate(() => window.__rh.carriedCount);
console.log('carried after walkover:', carried, carried > 0 ? 'PASS' : 'FAIL (may be pizza)');
// grab a few more to be safe
for (let i = 0; i < 3; i++) {
const l = await page.evaluate(() => window.__rh.loot[1]);
if (!l) break;
await page.evaluate(([x, z]) => window.__rh.teleport(x - 1.5, z), [l.x, l.z]);
await page.keyboard.down('d');
await page.waitForTimeout(600);
await page.keyboard.up('d');
}
carried = await page.evaluate(() => window.__rh.carriedCount);
console.log('carried after multi:', carried);
// --- banking
await page.evaluate(() => window.__rh.teleport(0, 25));
await page.keyboard.down('s');
await page.waitForTimeout(1200);
await page.keyboard.up('s');
const banked = await page.evaluate(() => window.__rh.banked);
console.log('banked:', banked, banked > 0 ? 'PASS' : 'FAIL');
// --- guard chase: teleport right in front of a guard
await page.evaluate(() => {
const g = window.__rh.guards[0];
// 3 units ahead of guard's position, roughly toward map center
window.__rh.teleport(g.x, g.z + 0.1);
});
// stand close; alert should spike then chase then caught
await page.waitForTimeout(4000);
const gstate = await page.evaluate(() => ({ guards: window.__rh.guards, state: window.__rh.state }));
console.log('guard after standing close:', JSON.stringify(gstate));
await page.screenshot({ path: OUT + '/shot-chase.png' });
await page.close();
}
async function mobileTest(browser, errors) {
const page = await browser.newPage({
viewport: { width: 390, height: 844 },
hasTouch: true,
isMobile: true,
deviceScaleFactor: 2,
});
page.on('pageerror', (e) => errors.push('MOBILE PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.screenshot({ path: OUT + '/shot-mobile-title.png' });
await page.tap('#play-btn');
await page.waitForTimeout(1500);
await page.screenshot({ path: OUT + '/shot-mobile-game.png' });
const before = await page.evaluate(() => { const g = window.__rh; return { s: g.state }; });
const posBefore = await page.evaluate(() => JSON.stringify(window.__rh.loot.length));
// simulate joystick drag: touch at (120, 600), drag up
const start = await page.evaluate(() => JSON.stringify([window.innerWidth, window.innerHeight]));
const client = await page.context().newCDPSession(page);
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: 120, y: 620, id: 1 }] });
for (let i = 1; i <= 8; i++) {
await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: 120, y: 620 - i * 6, id: 1 }] });
await page.waitForTimeout(40);
}
const p1 = await page.evaluate(() => { const r = window.__rh; return 'joy-active'; });
await page.waitForTimeout(1200);
await page.screenshot({ path: OUT + '/shot-mobile-joy.png' });
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
console.log('mobile ran, state:', before.s, start, posBefore, p1);
await page.close();
}
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = [];
await desktopTest(browser, errors);
await mobileTest(browser, errors);
console.log(errors.length ? errors.join('\n') : 'NO PAGE ERRORS');
await browser.close();
})();
File created successfully at: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/gameplay-test.js (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node gameplay-test.js
carried after walkover: 1 PASS
carried after multi: 4
banked: 80 PASS
guard after standing close: {"guards":[{"mode":"patrol","alert":0,"x":-14.95,"z":10.659334780557502}],"state":"playing"}
mobile ran, state: playing [390,844] 20 joy-active
NO PAGE ERRORS
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-mobile-game.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-mobile-joy.png"
}The joystick works and the HUD fits, but the raccoon isn't visible in the portrait shots — I need to find out where the camera is pointing. Let me debug.
const { chromium } = require('playwright');
const OUT = '/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad';
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.tap('#play-btn');
await page.waitForTimeout(8000);
const info = await page.evaluate(() => {
const r = window.__rh;
return JSON.stringify(r.debug ? r.debug() : 'no debug');
});
console.log(info);
await page.screenshot({ path: OUT + '/shot-mobile-idle.png' });
await browser.close();
})();
File created successfully at: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/debug-cam.js (file state is current in your context — no need to Read it back)
teleport(x, z) { playerPos.set(x, 0, z); },
}; teleport(x, z) { playerPos.set(x, 0, z); },
debug() {
return {
player: playerPos.toArray(),
playerGroup: player ? player.group.position.toArray() : null,
cam: camera.position.toArray(),
fps: 1 / Math.max(0.001, lastDt),
};
},
};The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
let lastFrame = performance.now(); let camShake = 0;
let lastFrame = performance.now(); let lastDt = 0.016; let camShake = 0;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
const dt = Math.min(0.05, (now - lastFrame) / 1000); lastFrame = now;
const dt = Math.min(0.05, (now - lastFrame) / 1000); lastDt = dt; lastFrame = now;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node debug-cam.js
{"player":[0,0,26],"playerGroup":[0,0,26],"cam":[0,15.996123638857293,38.490672506000365],"fps":20}
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-mobile-idle.png"
}The camera numbers are correct but the raccoon isn't on screen — contradiction. Let me project the player's position to screen coordinates to pin this down.
debug() {
return {
player: playerPos.toArray(),
playerGroup: player ? player.group.position.toArray() : null,
cam: camera.position.toArray(),
fps: 1 / Math.max(0.001, lastDt),
};
}, debug() {
const [sx, sy] = worldToScreen(new THREE.Vector3(playerPos.x, 1, playerPos.z));
return {
player: playerPos.toArray(),
playerScreen: [Math.round(sx), Math.round(sy)],
screen: [window.innerWidth, window.innerHeight],
cam: camera.position.toArray(),
camQuat: camera.quaternion.toArray().map((v) => +v.toFixed(3)),
visible: player ? player.group.visible : null,
inScene: player ? player.group.parent === scene : null,
fps: Math.round(1 / Math.max(0.001, lastDt)),
};
},The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node debug-cam.js
{"player":[0,0,26],"playerScreen":[195,482],"screen":[390,844],"cam":[0,15.99242898214315,38.48178223828195],"camQuat":[-0.391,0,0,0.921],"visible":true,"inScene":true,"fps":20}
Shell cwd was reset to /home/user/raccoon-heistcd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > tp-test.js << 'EOF'
const { chromium } = require('playwright');
const OUT = __dirname;
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.tap('#play-btn');
await page.waitForTimeout(3000);
await page.evaluate(() => window.__rh.teleport(0, 10));
await page.waitForTimeout(3000);
console.log(await page.evaluate(() => JSON.stringify(window.__rh.debug())));
await page.screenshot({ path: OUT + '/shot-tp.png' });
await browser.close();
})();
EOF
node tp-test.js{"player":[0,0,10],"playerScreen":[195,481],"screen":[390,844],"cam":[0,15.990532171197016,22.837506007132458],"camQuat":[-0.385,0,0,0.923],"visible":true,"inScene":true,"fps":20}
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-tp.png"
}The rendered content doesn't match the camera transform — very suspicious. I'll expose the scene and THREE for a direct marker test.
// tiny debug/testing hook (also handy in devtools)
window.__rh = {// tiny debug/testing hook (also handy in devtools)
window.__rhScene = scene;
window.__rhCamera = camera;
window.__THREE = THREE;
window.__rh = {The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > marker-test.js << 'EOF'
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.tap('#play-btn');
await page.waitForTimeout(2500);
await page.evaluate(() => {
const T = window.__THREE;
const m = new T.Mesh(new T.BoxGeometry(2, 6, 2), new T.MeshBasicMaterial({ color: 0xff0000, fog: false }));
m.position.set(0, 3, 26); // at the player spawn
window.__rhScene.add(m);
// count scene children + report canvas size
const c = document.querySelector('canvas');
window.__mk = { children: window.__rhScene.children.length, canvas: [c.width, c.height, c.clientWidth, c.clientHeight] };
});
await page.waitForTimeout(1500);
console.log(await page.evaluate(() => JSON.stringify({ ...window.__mk, dbg: window.__rh.debug() })));
await page.screenshot({ path: __dirname + '/shot-marker.png' });
await browser.close();
})();
EOF
node marker-test.js{"children":104,"canvas":[780,1688,780,1688],"dbg":{"player":[0,0,26],"playerScreen":[195,485],"screen":[390,844],"cam":[0,15.731023412975222,37.85277508747163],"camQuat":[-0.397,0,0,0.918],"visible":true,"inScene":true,"fps":20}}
Shell cwd was reset to /home/user/raccoon-heistconst renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.domElement.style.cssText = 'position:fixed;inset:0;z-index:1;display:block;';
document.body.appendChild(renderer.domElement);const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
// set base styles BEFORE setSize — setSize writes inline width/height styles
// that must not be clobbered (clobbering them breaks high-DPR phones).
renderer.domElement.style.cssText = 'position:fixed;top:0;left:0;z-index:1;display:block;';
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
if (Math.abs(da) < THREE.MathUtils.degToRad(28)) {
sees = !losBlocked(g.pos.x, g.pos.z, playerPos.x, playerPos.z);
}
} if (Math.abs(da) < THREE.MathUtils.degToRad(28)) {
sees = !losBlocked(g.pos.x, g.pos.z, playerPos.x, playerPos.z);
}
// guards notice a raccoon brushing right past them, cone or not
if (dist < 2.4) sees = true;
}The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node debug-cam.js && node gameplay-test.js
{"player":[0,0,26],"playerScreen":[195,482],"screen":[390,844],"cam":[0,15.996898911085834,38.492538004800295],"camQuat":[-0.391,0,0,0.921],"visible":true,"inScene":true,"fps":20}
carried after walkover: 1 PASS
carried after multi: 3
banked: 70 PASS
guard after standing close: {"guards":[{"mode":"patrol","alert":0,"x":-14.95,"z":11.048708423111204}],"state":"caught"}
mobile ran, state: playing [390,844] 20 joy-active
NO PAGE ERRORS
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-mobile-idle.png"
}The raccoon, dumpster hideout, and both crew raccoons are now perfectly visible on mobile. Committing this critical fix.
cat >> notes.md << 'EOF' ## Mobile canvas bug (critical fix) Playwright device-emulation testing (390×844, deviceScaleFactor 2) caught a nasty one: assigning `canvas.style.cssText` AFTER `renderer.setSize()` wiped the inline width/height styles Three.js sets, so on any real phone (DPR ≥ 2) the canvas rendered at 2× CSS size — you'd only ever see the top-left quarter of the game. Desktop tests (DPR 1) masked it completely. Fixed by setting base styles before `setSize()`. Verified with a projected-marker test: player now lands dead-center on the emulated phone screen. Also: guards now notice a raccoon that brushes within 2.4 units of them even outside the flashlight cone (verified alert → chase → caught in an automated test), and added a `window.__rh` debug hook used by the Playwright gameplay tests (pickup PASS, banking PASS, chase/caught PASS). EOF git add -A && git commit -m "Fix high-DPR mobile canvas scaling; close-range guard detection; test hooks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push
2 files changed, 49 insertions(+), 1 deletion(-) To https://github.com/simonw/raccoon-heist d221ef0..582cca5 claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > gen_title.py << 'EOF'
import base64, io, json, os, urllib.request
API_KEY = os.environ["OPENAI_API_KEY"]
body = json.dumps({
"model": "gpt-image-2",
"prompt": ("Video game key art, low-poly 3D render style, moody nighttime scene: "
"a cute low-poly raccoon wearing a tiny black burglar mask sneaking on its hind legs "
"carrying a glowing gold coin, next to a tipped-over metal trash can, "
"suburban house with warm glowing windows in the background, deep blue night, "
"full moon, fireflies, cinematic rim lighting, charming heist caper mood. "
"No text, no words, no logos."),
"size": "1024x1536",
"quality": "medium",
"output_format": "jpeg",
"output_compression": 80,
}).encode()
req = urllib.request.Request("https://api.openai.com/v1/images/generations", data=body,
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as r:
data = json.load(r)
raw = base64.b64decode(data["data"][0]["b64_json"])
from PIL import Image
img = Image.open(io.BytesIO(raw)).convert("RGB")
img = img.resize((512, 768), Image.LANCZOS)
img.save("/home/user/raccoon-heist/textures/title-art.jpg", "JPEG", quality=84)
print("saved", os.path.getsize("/home/user/raccoon-heist/textures/title-art.jpg"))
EOF
python3 gen_title.pyCommand running in background with ID: b2src9iw4. Output is being written to: /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/tasks/b2src9iw4.output. You will be notified when it completes. To check interim output, use Read on that file path. Session cwd remains /home/user/raccoon-heist; directory changes made by the backgrounded command do not apply to subsequent commands.
default: { // coin case 'tv': {
// the legendary golden TV — huge score, heavy to haul
mesh = new THREE.Mesh(new THREE.BoxGeometry(0.85, 0.6, 0.5), new THREE.MeshLambertMaterial({ color: 0xffd23f, emissive: 0xcc9200, emissiveIntensity: 0.6 }));
mesh.position.y = 0.34;
const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.6, 0.4), new THREE.MeshLambertMaterial({ color: 0x8fd8ff, emissive: 0x5faccf, emissiveIntensity: 1.2 }));
screen.position.set(0, 0.34, 0.26);
g.add(screen);
const antMat = new THREE.MeshLambertMaterial({ color: 0xffd23f, emissive: 0x997000, emissiveIntensity: 0.5 });
for (const s of [-1, 1]) {
const ant = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.5, 4), antMat);
ant.position.set(0.12 * s, 0.82, 0);
ant.rotation.z = -0.5 * s;
g.add(ant);
}
break;
}
default: { // coinThe file /home/user/raccoon-heist/js/actors.js has been updated successfully. (file state is current in your context — no need to Read it back)
pizza: { value: 15, label: 'pizza', weight: 2 }, // also triggers FRENZY
}; pizza: { value: 15, label: 'pizza', weight: 2 }, // eaten on pickup — triggers FRENZY
tv: { value: 120, label: 'golden TV', weight: 0.35, heavy: true },
};The file /home/user/raccoon-heist/js/actors.js has been updated successfully. (file state is current in your context — no need to Read it back)
const carryPenalty = 1 - Math.min(0.35, carried.length * 0.045);
const carryWeight = carried.reduce((s, c) => s + (c.kind === 'tv' ? 4 : 1), 0); const carryPenalty = 1 - Math.min(0.45, carryWeight * 0.045);
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
(l.kind === 'coin' ? sfx.coin : l.kind === 'gem' || l.kind === 'ring' ? sfx.gem : sfx.munch)();
if (l.kind === 'tv') {
toast('📺 THE GOLDEN TV! Haul it home… slowly.', 3000);
camShake = Math.max(camShake, 0.25);
}
(l.kind === 'coin' ? sfx.coin : l.kind === 'gem' || l.kind === 'ring' || l.kind === 'tv' ? sfx.gem : sfx.munch)();The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
const ratio = banked / goal;
const stars = won ? (ratio >= 2 ? '⭐⭐⭐' : ratio >= 1.4 ? '⭐⭐' : '⭐') : '';
if (won) {
sfx.win();
overlay.innerHTML = `
<h2>NIGHT ${night} CLEAR! 🦝</h2>
<div class="stars">${stars}</div>
<p>The crew banked <b style="color:#ffd23f">${banked}</b> in shiny loot (goal ${goal}).<br>
Word on the street: an even bigger score tomorrow…</p>
<button id="next-btn">NIGHT ${night + 1} ➜</button>`; const ratio = banked / goal;
const [stars, rank] = ratio >= 2 ? ['⭐⭐⭐', 'MASTER OF DISGUISE'] : ratio >= 1.4 ? ['⭐⭐', 'CAT BURGLAR'] : ['⭐', 'TRASH PANDA'];
if (won) {
sfx.win();
overlay.innerHTML = `
<h2>NIGHT ${night} CLEAR! 🦝</h2>
<div class="stars">${stars}</div>
<p><b style="color:#ffd23f">Rank: ${rank}</b><br>
The crew banked <b style="color:#ffd23f">${banked}</b> in shiny loot (goal ${goal}).<br>
Word on the street: an even bigger score tomorrow…</p>
<button id="next-btn">NIGHT ${night + 1} ➜</button>`;The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cat /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/tasks/b2src9iw4.output 2>/dev/null; ls -la textures/title-art.jpg 2>/dev/null
Title art is still generating. Meanwhile, add an emoji favicon (also fixes the 404 the tests saw):
<title>Raccoon Heist</title>
<title>Raccoon Heist</title> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🦝</text></svg>">
The file /home/user/raccoon-heist/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node smoke.js 2>&1; ls -la /home/user/raccoon-heist/textures/title-art.jpg 2>/dev/null || echo "title art not ready"
NO ERRORS -rw-r--r-- 1 root root 55601 Aug 5 15:15 /home/user/raccoon-heist/textures/title-art.jpg Shell cwd was reset to /home/user/raccoon-heist
.stars { position: absolute; inset: 0; overflow: hidden; } .title-art {
position: absolute; inset: 0;
background: url('textures/title-art.jpg') center 30% / cover no-repeat;
opacity: 0.55;
-webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,0.25) 100%);
mask-image: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,0.25) 100%);
}
@media (min-aspect-ratio: 4/3) {
.title-art { background-position: center 22%; opacity: 0.45; }
}
.stars { position: absolute; inset: 0; overflow: hidden; }The file /home/user/raccoon-heist/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
<div class="stars" id="stars"></div>
<div class="moon"></div>
<div class="mask">🦝</div>
<h1 class="game-title">RACCOON<br><span class="accent">HEIST</span></h1> <div class="stars" id="stars"></div>
<div class="title-art"></div>
<div class="moon"></div>
<div class="mask">🦝</div>
<h1 class="game-title">RACCOON<br><span class="accent">HEIST</span></h1>The file /home/user/raccoon-heist/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > title-shot.js << 'EOF'
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
for (const [name, vp, mobile] of [['desktop', { width: 900, height: 600 }, false], ['mobile', { width: 390, height: 844 }, true]]) {
const page = await browser.newPage({ viewport: vp, hasTouch: mobile, isMobile: mobile, deviceScaleFactor: mobile ? 2 : 1 });
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.waitForTimeout(600);
await page.screenshot({ path: __dirname + '/title-' + name + '.png' });
await page.close();
}
await browser.close();
})();
EOF
node title-shot.js{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/title-desktop.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/title-mobile.png"
} <div class="title-art"></div>
<div class="moon"></div>
<div class="mask">🦝</div> <div class="title-art"></div>
<div class="mask">🦝</div>The file /home/user/raccoon-heist/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
cat >> notes.md << 'EOF' ## Polish round 1 - Title screen now uses a gpt-image-2 hero illustration (low-poly masked raccoon clutching a glowing coin by a tipped trash can, 1024×1536 → 512×768 JPEG) as a masked backdrop behind the CSS title. Removed the CSS moon — the art brings its own. - New rare loot: the ⭐ GOLDEN TV 📺 (value 120, spawn weight 0.35). It's heavy — counts as 4 items for the carry-slowdown, so hauling it home is a risk/reward waddle. - Win screen now grants heist ranks: TRASH PANDA ⭐ / CAT BURGLAR ⭐⭐ / MASTER OF DISGUISE ⭐⭐⭐. - Added 🦝 SVG-emoji favicon (also silences the 404 from tests). EOF git add -A && git commit -m "Title hero art, golden TV mega-loot, heist ranks, favicon Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push
5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 textures/title-art.jpg To https://github.com/simonw/raccoon-heist 582cca5..d0d5f63 claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293
Now let me verify the full night cycle — dawn, results screen, night 2 progression — and soften the night-1 goal for winnability.
goal = 150 + (n - 1) * 120;
goal = 120 + (n - 1) * 110;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
teleport(x, z) { playerPos.set(x, 0, z); },
debug() { teleport(x, z) { playerPos.set(x, 0, z); },
setTime(t) { nightTime = t; },
addBank(v) { banked += v; $('bank-val').textContent = banked; },
debug() {The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > cycle-test.js << 'EOF'
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.click('#play-btn');
await page.waitForTimeout(1500);
// dawn approach visual
await page.evaluate(() => { window.__rh.addBank(150); window.__rh.setTime(135); });
await page.waitForTimeout(2000);
await page.screenshot({ path: __dirname + '/shot-dawn.png' });
await page.waitForTimeout(14000); // let night end
await page.screenshot({ path: __dirname + '/shot-win.png' });
// advance to night 2
await page.click('#next-btn');
await page.waitForTimeout(2000);
const info = await page.evaluate(() => JSON.stringify({ guards: window.__rh.guards.length, state: window.__rh.state }));
console.log('night2:', info);
await page.screenshot({ path: __dirname + '/shot-night2.png' });
// lose path
await page.evaluate(() => window.__rh.setTime(149));
await page.waitForTimeout(2500);
await page.screenshot({ path: __dirname + '/shot-lose.png' });
const retry = await page.evaluate(() => document.querySelector('#next-btn')?.textContent);
console.log('lose overlay button:', retry);
await browser.close();
})();
EOF
node cycle-test.jsExit code 1
node:internal/process/promises:394
triggerUncaughtException(err, true /* fromPromise */);
^
page.click: Timeout 30000ms exceeded.
Call log:
[2m - waiting for locator('#next-btn')[22m
[2m - locator resolved to <button id="next-btn">NIGHT 2 ➜</button>[22m
[2m - attempting click action[22m
[2m 2 × waiting for element to be visible, enabled and stable[22m
[2m - element is visible, enabled and stable[22m
[2m - scrolling into view if needed[22m
[2m - done scrolling[22m
[2m - <div class="stars">⭐</div> intercepts pointer events[22m
[2m - retrying click action[22m
[2m - waiting 20ms[22m
[2m 2 × waiting for element to be visible, enabled and stable[22m
[2m - element is visible, enabled and stable[22m
[2m - scrolling into view if needed[22m
[2m - done scrolling[22m
[2m - <div class="stars">⭐</div> intercepts pointer events[22m
[2m - retrying click action[22m
[2m - waiting 100ms[22m
[2m 3 × waiting for element to be visible, enabled and stable[22m
[2m - element is visible, enabled and stable[22m
[2m - scrolling into view if needed[22m
[2m - done scrolling[22m
[2m - <div class="stars">⭐</div> intercepts pointer events[22m
[2m - retrying click action[22m
[2m - waiting 500ms[22m
at /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/cycle-test.js:16:14 {
log: [
" - waiting for locator('#next-btn')",
' - locator resolved to <button id="next-btn">NIGHT 2 ➜</button>',
' - attempting click action',
' 2 × waiting for element to be visible, enabled and stable',
' - element is visible, enabled and stable',
' - scrolling into view if needed',
' - done scrolling',
' - <div class="stars">⭐</div> intercepts pointer events',
' - retrying click action',
' - waiting 20ms',
' 2 × waiting for element to be visible, enabled and stable',
' - element is visible, enabled and stable',
' - scrolling into view if needed',
' - done scrolling',
' - <div class="stars">⭐</div> intercepts pointer events',
' - retrying click action',
' - waiting 100ms',
' 3 × waiting for element to be visible, enabled and stable',
' - element is visible, enabled and stable',
' - scrolling into view if needed',
' - done scrolling',
' - <div class="stars">⭐</div> intercepts pointer events',
' - retrying click action',
' - waiting 500ms'
],
name: 'TimeoutError'
}
Node.js v22.22.242: .overlay .stars { font-size: clamp(1.6rem, 7vw, 2.6rem); margin-bottom: 0.5em; letter-spacing: 0.15em; }
382: <div class="stars">${stars}</div> .overlay .stars { font-size: clamp(1.6rem, 7vw, 2.6rem); margin-bottom: 0.5em; letter-spacing: 0.15em; } .overlay .rank-stars { font-size: clamp(1.6rem, 7vw, 2.6rem); margin-bottom: 0.5em; letter-spacing: 0.15em; }The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
<div class="stars">${stars}</div> <div class="rank-stars">${stars}</div>The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node cycle-test.js
night2: {"guards":2,"state":"playing"}
lose overlay button: RETRY NIGHT 2
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-dawn.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-win.png"
} if (dawn > 0) {
const sky = new THREE.Color(0x0a1024).lerp(new THREE.Color(0x6a5a7e), dawn);
scene.background = sky;
scene.fog.color = sky;
world.ambient.intensity = 0.85 + dawn * 0.9;
world.ambient.color.setHex(0x3a4a78).lerp(new THREE.Color(0xcf9a6a), dawn);
if (dawn > 0.65 && Math.floor(nightTime * 2) !== Math.floor((nightTime - dt) * 2) && Math.floor(nightTime) % 5 === 0) {
// gentle tick reminder handled by bar; skip extra toasts
}
} if (dawn > 0) {
const sky = new THREE.Color(0x0a1024).lerp(new THREE.Color(0x8a6a86), dawn);
scene.background = sky;
scene.fog.color = sky;
world.ambient.intensity = 1.25 + dawn * 1.4;
world.ambient.color.setHex(0x4a5c92).lerp(new THREE.Color(0xe8a070), dawn);
world.moonLight.intensity = 1.35 * (1 - dawn * 0.55);
world.moonLight.color.setHex(0xaec4ff).lerp(new THREE.Color(0xffc890), dawn);
world.hemi.intensity = 0.85 + dawn * 0.5;
if (!this._dawnWarned && dawn > 0.02) {
toast('☀️ Dawn is coming — bank what you can!', 2600);
this._dawnWarned = true;
}
} else {
this._dawnWarned = false;
}The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
if (!this._dawnWarned && dawn > 0.02) {
toast('☀️ Dawn is coming — bank what you can!', 2600);
this._dawnWarned = true;
}
} else {
this._dawnWarned = false;
} if (!dawnWarned && dawn > 0.02) {
toast('☀️ Dawn is coming — bank what you can!', 2600);
dawnWarned = true;
}
} else {
dawnWarned = false;
}The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
let lastFrame = performance.now(); let lastDt = 0.016;
let lastFrame = performance.now(); let lastDt = 0.016; let dawnWarned = false;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
carX = -40; carDir = 1; seagullTimer = 15 + Math.random() * 15;
// reset sky/lighting to deep night (retries can start from a dawn-lit scene) scene.background = new THREE.Color(0x0a1024); scene.fog.color.setHex(0x0a1024); world.ambient.intensity = 1.25; world.ambient.color.setHex(0x4a5c92); world.moonLight.intensity = 1.35; world.moonLight.color.setHex(0xaec4ff); world.hemi.intensity = 0.85; dawnWarned = false; carX = -40; carDir = 1; seagullTimer = 15 + Math.random() * 15;
The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node cycle-test.js && node smoke.js
night2: {"guards":2,"state":"playing"}
lose overlay button: RETRY NIGHT 2
NO ERRORS
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-dawn.png"
}cat >> notes.md << 'EOF' ## Night-cycle QA round Automated a full progression test (jump to dawn → win overlay → Night 2 → lose overlay). It caught another real bug: the win screen's ⭐ rating div used class `stars`, which collided with the title screen's full-screen `.stars` CSS — the invisible stretched div swallowed every click on the "NIGHT 2 ➜" button. Renamed to `.rank-stars`. Other changes: - Dawn is dramatic now: sky/fog lerp to mauve, ambient warms and brightens, moonlight fades to peach, plus a "Dawn is coming" warning toast. - `startNight` resets all sky/lighting state (retrying after a dawn loss used to keep the morning sky). - Night 1 goal softened 150 → 120 so a clean first run is winnable; escalation is +110/night. EOF git add -A && git commit -m "Dramatic dawn, lighting reset on retry, fix win-screen button click blocker Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push
2 files changed, 42 insertions(+), 8 deletions(-) To https://github.com/simonw/raccoon-heist d0d5f63..ad805b3 claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293
export function makeSeagull() {export function makeDog() {
const g = new THREE.Group();
const BROWN = 0x8a6440, DARK = 0x5e4128;
const body = new THREE.Mesh(new THREE.SphereGeometry(0.42, 10, 8), M(BROWN));
body.scale.set(0.9, 0.8, 1.5);
body.position.y = 0.55;
body.castShadow = true;
g.add(body);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 8), M(BROWN));
head.position.set(0, 0.85, 0.62);
g.add(head);
const snout = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 6), M(DARK));
snout.scale.set(0.9, 0.7, 1.3);
snout.position.set(0, 0.76, 0.9);
g.add(snout);
const nose = new THREE.Mesh(new THREE.SphereGeometry(0.06, 6, 6), M(BLACK));
nose.position.set(0, 0.78, 1.08);
g.add(nose);
for (const s of [-1, 1]) {
const ear = new THREE.Mesh(new THREE.SphereGeometry(0.12, 6, 6), M(DARK));
ear.scale.set(0.7, 1.3, 0.5);
ear.position.set(0.2 * s, 1.08, 0.55);
g.add(ear);
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 6), M(0x1a1a1a, { emissive: 0x331111 }));
eye.position.set(0.13 * s, 0.92, 0.86);
g.add(eye);
}
const tail = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.09, 0.5, 6), M(DARK));
tail.position.set(0, 0.8, -0.62);
tail.rotation.x = 0.8;
g.add(tail);
// spiked collar
const collar = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.05, 6, 12), M(0xc0392b));
collar.position.set(0, 0.78, 0.5);
collar.rotation.x = Math.PI / 2.4;
g.add(collar);
const legGeo = new THREE.CylinderGeometry(0.07, 0.09, 0.34, 6);
const legs = [];
for (const [x, z] of [[-0.22, 0.35], [0.22, 0.35], [-0.22, -0.35], [0.22, -0.35]]) {
const leg = new THREE.Mesh(legGeo, M(DARK));
leg.position.set(x, 0.17, z);
g.add(leg);
legs.push(leg);
}
let phase = Math.random() * 10;
return {
group: g,
animate(dt, speed) {
phase += dt * (3 + speed * 10);
const amp = Math.min(0.6, 0.1 + speed * 0.6);
legs[0].rotation.x = Math.sin(phase) * amp;
legs[3].rotation.x = Math.sin(phase) * amp;
legs[1].rotation.x = -Math.sin(phase) * amp;
legs[2].rotation.x = -Math.sin(phase) * amp;
tail.rotation.z = Math.sin(phase * 1.5) * 0.4;
body.position.y = 0.55 + Math.abs(Math.sin(phase)) * 0.04 * (0.3 + speed);
},
};
}
export function makeSeagull() {The file /home/user/raccoon-heist/js/actors.js has been updated successfully. (file state is current in your context — no need to Read it back)
meow() { if (!ctx) return; const t = ctx.currentTime; blip(700, t, 0.25, 'sawtooth', 0.1, 250); },
}; meow() { if (!ctx) return; const t = ctx.currentTime; blip(700, t, 0.25, 'sawtooth', 0.1, 250); },
bark() { if (!ctx) return; const t = ctx.currentTime; blip(220, t, 0.09, 'sawtooth', 0.25, 120); blip(190, t + 0.13, 0.11, 'sawtooth', 0.25, 90); noiseBurst(t, 0.07, 0.12, 700); },
};The file /home/user/raccoon-heist/js/audio.js has been updated successfully. (file state is current in your context — no need to Read it back)
import { makeRaccoon, makeGuard, makePoliceCar, makeSeagull, makeLoot, LOOT_TYPES } from './actors.js';import { makeRaccoon, makeGuard, makePoliceCar, makeSeagull, makeDog, makeLoot, LOOT_TYPES } from './actors.js';The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
let windowEvent = null; // {win, t, circle}
let windowTimer = 14;let windowEvent = null; // {win, t, circle}
let windowTimer = 14;
let dog = null; // {api, pos, mode, wanderTarget, wanderT, barkT, sniff}The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
spawnLoot(16 + n * 4);
spawnLoot(16 + n * 4);
// night 3+: they bring out the hound — tracks by scent, no flashlight needed
if (dog) { scene.remove(dog.api.group); dog.sniff.remove(); dog = null; }
if (n >= 3) {
const api = makeDog();
scene.add(api.group);
const sniff = document.createElement('div');
sniff.className = 'float-label';
sniff.style.transition = 'none';
sniff.style.fontSize = '22px';
sniff.style.display = 'none';
document.body.appendChild(sniff);
dog = { api, sniff, pos: new THREE.Vector3(0, 0, -4), angle: 0, mode: 'wander', wanderTarget: new THREE.Vector3(10, 0, 8), wanderT: 0, barkT: 0 };
setTimeout(() => toast('🐕 They brought the HOUND tonight. It smells you…', 3400), 3800);
}The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
function updateSeagull(dt) {function updateDog(dt) {
if (!dog) return;
const d = dog;
const distToPlayer = Math.hypot(playerPos.x - d.pos.x, playerPos.z - d.pos.z);
let speed = 0;
if (d.mode === 'wander') {
d.wanderT -= dt;
const dx = d.wanderTarget.x - d.pos.x, dz = d.wanderTarget.z - d.pos.z;
const dist = Math.hypot(dx, dz);
if (dist < 1 || d.wanderT <= 0) {
const [x, z] = randomLootSpot();
d.wanderTarget.set(x, 0, z);
d.wanderT = 8 + Math.random() * 6;
} else {
speed = 1.6;
const ta = Math.atan2(dx, dz);
let da = ta - d.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
d.angle += da * Math.min(1, dt * 3);
d.pos.x += Math.sin(d.angle) * speed * dt;
d.pos.z += Math.cos(d.angle) * speed * dt;
}
if (distToPlayer < 12 && state === 'playing') {
d.mode = 'track';
sfx.bark();
toast('🐕 The hound caught your scent!', 2000);
}
} else { // track — no line of sight needed, it's all nose
if (distToPlayer > 17 || state !== 'playing') {
d.mode = 'wander';
} else {
speed = 3.5;
const ta = Math.atan2(playerPos.x - d.pos.x, playerPos.z - d.pos.z);
let da = ta - d.angle;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
d.angle += da * Math.min(1, dt * 5);
d.pos.x += Math.sin(d.angle) * speed * dt;
d.pos.z += Math.cos(d.angle) * speed * dt;
d.barkT -= dt;
if (d.barkT <= 0) { sfx.bark(); d.barkT = 1.6 + Math.random(); }
if (distToPlayer < 1.25 && state === 'playing') onCaught(null);
}
}
[d.pos.x, d.pos.z] = circleVsObstacles(d.pos.x, d.pos.z, 0.5);
d.api.group.position.set(d.pos.x, 0, d.pos.z);
d.api.group.rotation.y = d.angle;
d.api.animate(dt, speed / 3.5);
if (d.mode === 'track') {
const [sx, sy] = worldToScreen(tmpV.set(d.pos.x, 1.9, d.pos.z));
d.sniff.style.display = 'block';
d.sniff.style.left = (sx - 10) + 'px';
d.sniff.style.top = (sy - 20) + 'px';
d.sniff.textContent = '👃';
} else {
d.sniff.style.display = 'none';
}
}
function updateSeagull(dt) {The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
updatePlayer(dt);
updateGuards(dt);
updatePoliceCar(dt);
updateSeagull(dt);
updateWindowEvent(dt);
updateDawn(dt);
} else if (state === 'caught') {
caughtTimer -= dt;
updateGuards(dt * 0.3);
updatePoliceCar(dt); updatePlayer(dt);
updateGuards(dt);
updatePoliceCar(dt);
updateDog(dt);
updateSeagull(dt);
updateWindowEvent(dt);
updateDawn(dt);
} else if (state === 'caught') {
caughtTimer -= dt;
updateGuards(dt * 0.3);
updatePoliceCar(dt);
updateDog(dt * 0.3);The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
setTime(t) { nightTime = t; }, setTime(t) { nightTime = t; },
get dog() { return dog ? { mode: dog.mode, x: dog.pos.x, z: dog.pos.z } : null; },
get night() { return night; },The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > dog-test.js << 'EOF'
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.click('#play-btn');
await page.waitForTimeout(1200);
for (let n = 1; n <= 2; n++) {
await page.evaluate(() => { window.__rh.addBank(500); window.__rh.setTime(149.5); });
await page.waitForTimeout(2500);
await page.click('#next-btn');
await page.waitForTimeout(1200);
}
let info = await page.evaluate(() => JSON.stringify({ night: window.__rh.night, dog: window.__rh.dog }));
console.log('night3 start:', info);
// walk near the dog
await page.evaluate(() => { const d = window.__rh.dog; window.__rh.teleport(d.x + 6, d.z); });
await page.waitForTimeout(2000);
info = await page.evaluate(() => JSON.stringify({ dog: window.__rh.dog, state: window.__rh.state, player: window.__rh.debug().player }));
console.log('after approach:', info);
await page.waitForTimeout(3000);
info = await page.evaluate(() => JSON.stringify({ dog: window.__rh.dog, state: window.__rh.state }));
console.log('after chase:', info);
await page.screenshot({ path: __dirname + '/shot-dog.png' });
await browser.close();
})();
EOF
node dog-test.jsnight3 start: {"night":3,"dog":{"mode":"wander","x":0.41128127177725887,"z":-3.239298622418116}}
after approach: {"dog":{"mode":"track","x":4.1249945883825925,"z":-2.946349051657692},"state":"playing","player":[6.465531629232794,0,-3.1805032253538337]}
after chase: {"dog":{"mode":"wander","x":6.00345960226507,"z":-2.9713565919658085},"state":"caught"}
Shell cwd was reset to /home/user/raccoon-heist{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-dog.png"
}function onCaught(guard) {function onCaught(source) {The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
updateCarryHUD(); toast(carried.length ? 'Busted! You dropped some loot! 🚨' : 'Busted! The guard shooed you off! 🚨', 3000);
updateCarryHUD();
const who = source === 'dog' ? 'The hound ran you off!' : source === 'car' ? 'Caught in the headlights!' : 'The guard shooed you off!';
toast('Busted! 🚨 ' + (dropCount ? 'You dropped some loot!' : who), 3000);The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
if (Math.hypot(playerPos.x - carX, playerPos.z - world.roadZ) < 3) {
onCaught(null);
} if (Math.hypot(playerPos.x - carX, playerPos.z - world.roadZ) < 3) {
onCaught('car');
}The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
if (distToPlayer < 1.25 && state === 'playing') onCaught(null);
if (distToPlayer < 1.25 && state === 'playing') onCaught('dog');The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node smoke.js && node dog-test.js
NO ERRORS
night3 start: {"night":3,"dog":{"mode":"wander","x":0.1157473086380818,"z":-3.0479856589110126}}
after approach: {"dog":{"mode":"track","x":3.79216304408906,"z":-2.6036479470416998},"state":"playing","player":[6.130034709196829,0,-2.9692718088890886]}
after chase: {"dog":{"mode":"wander","x":5.613400212692408,"z":-2.569842029748454},"state":"caught"}
Shell cwd was reset to /home/user/raccoon-heistcat >> notes.md << 'EOF' ## The hound (night 3+) New escalation: from night 3 the yards get a patrolling guard dog — a low-poly brown hound with a spiked red collar and a wagging tail. It wanders between random spots, and within 12 units it catches your scent and tracks you by smell (line of sight is irrelevant — it's all nose, shown by a 👃 over its head and barking). It gives up if you open a 17-unit gap. Getting caught messages are now source-specific: guard / headlights / hound. Verified wander → track → caught with an automated test. EOF git add -A && git commit -m "Night 3+ scent-tracking guard hound with barks and source-aware bust messages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push
4 files changed, 155 insertions(+), 4 deletions(-) To https://github.com/simonw/raccoon-heist ad805b3..e599884 claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293
function showResults(won) {
state = 'results';function showResults(won) {
state = 'results';
if (won) {
try {
const best = +(localStorage.getItem('rh-best-night') || 0);
if (night > best) localStorage.setItem('rh-best-night', String(night));
} catch (e) { /* private mode */ }
}The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
ensureWorld();
playBtn.addEventListener('click', async () => {ensureWorld();
// show best run on the title screen, if any
try {
const best = +(localStorage.getItem('rh-best-night') || 0);
if (best > 0) {
const p = document.createElement('p');
p.style.cssText = 'margin-top:0.7rem;color:#ffd23f;font-size:0.85rem;z-index:2;font-weight:700;letter-spacing:0.06em;';
p.textContent = `🏆 Best run: cleared Night ${best}`;
statusEl.parentNode.insertBefore(p, statusEl.nextSibling);
}
} catch (e) { /* private mode */ }
playBtn.addEventListener('click', async () => {The file /home/user/raccoon-heist/js/main.js has been updated successfully. (file state is current in your context — no need to Read it back)
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && cat > landscape-test.js << 'EOF'
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 740, height: 360 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 });
page.on('pageerror', (e) => console.log('PAGEERROR: ' + e.message));
await page.goto('http://localhost:8642/', { waitUntil: 'networkidle' });
await page.screenshot({ path: __dirname + '/shot-land-title.png' });
await page.tap('#play-btn');
await page.waitForTimeout(2500);
// fake a touch to reveal dash button
const client = await page.context().newCDPSession(page);
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: 150, y: 250, id: 1 }] });
await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: 150, y: 215, id: 1 }] });
await page.waitForTimeout(900);
await page.screenshot({ path: __dirname + '/shot-land-game.png' });
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await browser.close();
})();
EOF
node landscape-test.js{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-land-game.png"
}{
"file_path": "/tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad/shot-land-title.png"
}cat >> notes.md << 'EOF'
## Final polish
- Best run persists in localStorage and shows on the title screen
("🏆 Best run: cleared Night N").
- Landscape-phone layout verified (740×360, DPR 2): HUD, joystick and DASH
button all clear of each other; title screen crops gracefully.
## Where things ended up
The heist loop: sneak out from the crew's dumpster, grab coins/gems/rings/
donuts/fish (pizza = 6s speed FRENZY, the golden TV = 120 points but a heavy
waddle), dodge flashlight cones, the police cruiser's headlights, peeking
homeowners, a loot-stealing seagull — and from night 3, a scent-tracking
hound. Bank loot at the dumpster before dawn to clear the night; each night
raises the goal and adds heat. Ranks: TRASH PANDA / CAT BURGLAR / MASTER OF
DISGUISE.
All textures + title art were generated with OpenAI gpt-image-2 at build
time and committed as static assets; the deployed game is fully static
(vendored Three.js, procedural WebAudio, no runtime API calls).
EOF
git add -A && git commit -m "Best-run persistence on title screen; final notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUBoCfnhGETcCDyEUPS8jp" && git push2 files changed, 38 insertions(+) To https://github.com/simonw/raccoon-heist e599884..a6ef046 claude/3d-raccoon-heist-game-50n293 -> claude/3d-raccoon-heist-game-50n293
cd /tmp/claude-0/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910/scratchpad && node smoke.js && node gameplay-test.js && node cycle-test.js
NO ERRORS
carried after walkover: 1 PASS
carried after multi: 4
banked: 165 PASS
guard after standing close: {"guards":[{"mode":"patrol","alert":0,"x":-14.95,"z":11.064852750926395}],"state":"caught"}
mobile ran, state: playing [390,844] 20 joy-active
NO PAGE ERRORS
night2: {"guards":2,"state":"playing"}
lose overlay button: RETRY NIGHT 2
Shell cwd was reset to /home/user/raccoon-heistpkill -f "http.server 8642" 2>/dev/null; git log --oneline origin/claude/3d-raccoon-heist-game-50n293 | head -8; git status --short
git log --oneline -8 && git status --short && git diff origin/claude/3d-raccoon-heist-game-50n293 --stat | tail -1
a6ef046 Best-run persistence on title screen; final notes e599884 Night 3+ scent-tracking guard hound with barks and source-aware bust messages ad805b3 Dramatic dawn, lighting reset on retry, fix win-screen button click blocker d0d5f63 Title hero art, golden TV mega-loot, heist ranks, favicon 582cca5 Fix high-DPR mobile canvas scaling; close-range guard detection; test hooks d221ef0 Playable core: 3D night heist with guards, loot, events, touch controls b0c9e08 Title screen: night sky, moon, skyline, START button 3b11a53 Images to go with prompt
Raccoon Heist is built, tested, and pushed — 7 commits on claude/3d-raccoon-heist-game-50n293, all verified with automated Playwright playthroughs on desktop, portrait-phone, and landscape-phone viewports.
The game: You're a masked raccoon sneaking out of your crew's dumpster hideout into a moonlit cul-de-sac. Grab coins, gems, rings, donuts, and fish (they visibly stack on your back and slow you down), then waddle home and bank them before dawn. Each night raises the goal and the heat: night 1 has one flashlight-sweeping guard, night 2 has two, night 3 adds a scent-tracking hound that ignores line-of-sight entirely. A police cruiser sweeps the street with headlights, homeowners randomly peek out of windows, a seagull periodically dive-bombs your unclaimed loot, pizza slices trigger a 6-second speed FRENZY, and a rare golden TV is worth 120 points if you can haul its weight home. Win ranks: TRASH PANDA ⭐ → CAT BURGLAR ⭐⭐ → MASTER OF DISGUISE ⭐⭐⭐, with your best run saved to localStorage.
How it's built: Vendored Three.js (no CDN), procedural low-poly characters, and a procedural WebAudio soundtrack (sneaky walking-bass jazz plus all sound effects — zero audio files). All seven ground/wall textures and the title-screen hero art were generated with gpt-image-2 and committed as static assets, so the deployed game makes no API calls. Mobile gets a dynamic touch joystick (appears wherever you touch) plus a DASH button; desktop uses WASD/arrows and Space.
Testing caught two real bugs worth mentioning: the canvas rendered at 2× size on any real phone (a cssText assignment was wiping Three.js's inline sizing — desktop DPR-1 tests masked it completely), and the win screen's star-rating div inherited the title screen's full-screen .stars CSS and silently swallowed every tap on the "next night" button. Both are fixed and regression-tested, along with pickup, banking, chase/caught, dawn, night progression, and lose/retry flows.
notes.md has the full build log. The game is live on the branch — just serve the repo root and open index.html.
Run this command: uvx claude-code-transcripts --help - then save the transcript HTML into the tmp directory with the JSON as well
Installed 16 packages in 120ms Usage: claude-code-transcripts [OPTIONS] COMMAND [ARGS]... Convert Claude Code session JSON to mobile-friendly HTML pages. Options: -v, --version Show the version and exit. --help Show this message and exit. Commands: local* Select and convert a local Claude Code session to HTML. all Convert all local Claude Code sessions to a browsable HTML archive. json Convert a Claude Code session JSON/JSONL file or URL to HTML. web Select and convert a web session from the Claude API to HTML.
ls ~/.claude/projects/ 2>/dev/null && ls -lat ~/.claude/projects/*/ 2>/dev/null | head -20
-home-user-raccoon-heist total 17464 drwx------ 2 root root 4096 Aug 5 15:43 . -rw------- 1 root root 89 Aug 5 15:43 ccf71eb5-b431-519a-bc9e-b57105af9910.ccr-tip.json -rw------- 1 root root 17864165 Aug 5 15:43 ccf71eb5-b431-519a-bc9e-b57105af9910.jsonl drwx------ 3 root root 4096 Aug 5 14:53 ..
uvx claude-code-transcripts json ~/.claude/projects/-home-user-raccoon-heist/ccf71eb5-b431-519a-bc9e-b57105af9910.jsonl -o /tmp/transcript 2>&1 | tail -5; ls -la /tmp/transcript/ 2>/dev/null