diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aeda74c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.safeclade
+.safeclaude
+.claude
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..25b8aed
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Pot Patrol
+
+
+
+
+
+
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..e42b1ed
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,31 @@
+// Tunable constants. Ranges are [min, max] inclusive and rolled per-scene.
+
+export const TILE = 40;
+export const COLS = 24;
+export const ROWS = 14;
+export const CANVAS_W = TILE * COLS; // 960
+export const CANVAS_H = TILE * ROWS; // 560
+
+// Movement (px/sec). Link is slightly faster than the player.
+export const PLAYER_SPEED = 130;
+export const LINK_SPEED = 150;
+
+// Per-scene entity counts.
+export const OBSTACLE_COUNT = [6, 12];
+export const POT_COUNT = [5, 8];
+export const GRASS_COUNT = [8, 15];
+export const CHICKEN_COUNT = [2, 4];
+
+// Gameplay tuning (used from later milestones).
+export const THROW_TILES = [2, 3];
+export const SHARDS_PER_POT = [1, 3];
+export const DISTRACT_RADIUS = 4; // tiles
+export const FLEE_TIME = 1.5; // seconds
+
+// Draw layers. World entities use z = pos.y so lower sprites overlap higher
+// ones; ground sits below everything, flying pots and HUD above.
+export const Z = {
+ GROUND: -10,
+ FLYING: 5000,
+ HUD: 10000,
+};
diff --git a/src/gen.js b/src/gen.js
new file mode 100644
index 0000000..5ba2f4b
--- /dev/null
+++ b/src/gen.js
@@ -0,0 +1,157 @@
+// Seeded procedural scene generation.
+//
+// Produces a solvable single-screen level: a walled arena with exactly one exit
+// gap, scattered obstacles, and randomly placed pots / grass / chickens — all
+// guaranteed reachable from Link's spawn. Returns plain data; scenes/game.js
+// turns it into Kaplay objects.
+
+import {
+ COLS,
+ ROWS,
+ OBSTACLE_COUNT,
+ POT_COUNT,
+ GRASS_COUNT,
+ CHICKEN_COUNT,
+} from "./config.js";
+
+const key = (x, y) => `${x},${y}`;
+const NEIGHBORS = [
+ [1, 0],
+ [-1, 0],
+ [0, 1],
+ [0, -1],
+];
+
+// Inclusive integer in [min, max] using Kaplay's seeded RNG.
+function randint(k, [min, max]) {
+ return min + Math.floor(k.rand() * (max - min + 1));
+}
+
+// Flood fill of walkable tiles from a start tile (4-directional).
+function reachableFrom(walkable, sx, sy) {
+ const seen = new Set([key(sx, sy)]);
+ const queue = [[sx, sy]];
+ while (queue.length) {
+ const [x, y] = queue.shift();
+ for (const [dx, dy] of NEIGHBORS) {
+ const nx = x + dx;
+ const ny = y + dy;
+ if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
+ if (!walkable[ny][nx] || seen.has(key(nx, ny))) continue;
+ seen.add(key(nx, ny));
+ queue.push([nx, ny]);
+ }
+ }
+ return seen;
+}
+
+// One generation attempt. Returns a level or null if it came out unsolvable.
+function attempt(k) {
+ const walkable = Array.from({ length: ROWS }, () => Array(COLS).fill(true));
+ const occupied = new Set(); // tiles that already hold a wall or entity
+
+ // Border walls.
+ for (let x = 0; x < COLS; x++) {
+ for (let y = 0; y < ROWS; y++) {
+ if (x === 0 || y === 0 || x === COLS - 1 || y === ROWS - 1) {
+ walkable[y][x] = false;
+ occupied.add(key(x, y));
+ }
+ }
+ }
+
+ // Carve one exit gap on a random edge.
+ const exitEdge = k.choose(["top", "bottom", "left", "right"]);
+ let exit;
+ if (exitEdge === "top") exit = { x: randint(k, [2, COLS - 3]), y: 0 };
+ else if (exitEdge === "bottom")
+ exit = { x: randint(k, [2, COLS - 3]), y: ROWS - 1 };
+ else if (exitEdge === "left") exit = { x: 0, y: randint(k, [2, ROWS - 3]) };
+ else exit = { x: COLS - 1, y: randint(k, [2, ROWS - 3]) };
+ walkable[exit.y][exit.x] = true;
+ occupied.delete(key(exit.x, exit.y));
+
+ // Spawns: Link near the side opposite the exit, player near center.
+ let linkSpawn;
+ if (exitEdge === "top") linkSpawn = { x: randint(k, [2, COLS - 3]), y: ROWS - 2 };
+ else if (exitEdge === "bottom") linkSpawn = { x: randint(k, [2, COLS - 3]), y: 1 };
+ else if (exitEdge === "left") linkSpawn = { x: COLS - 2, y: randint(k, [2, ROWS - 3]) };
+ else linkSpawn = { x: 1, y: randint(k, [2, ROWS - 3]) };
+ const playerSpawn = { x: Math.floor(COLS / 2), y: Math.floor(ROWS / 2) };
+
+ // Reserve spawns + their neighbors so nothing spawns on top of an actor.
+ const reserve = (x, y) => {
+ occupied.add(key(x, y));
+ for (const [dx, dy] of NEIGHBORS) occupied.add(key(x + dx, y + dy));
+ };
+ reserve(linkSpawn.x, linkSpawn.y);
+ reserve(playerSpawn.x, playerSpawn.y);
+
+ // Grabs a random free, walkable interior tile (null if it can't find one).
+ const freeTile = () => {
+ for (let tries = 0; tries < 400; tries++) {
+ const x = randint(k, [1, COLS - 2]);
+ const y = randint(k, [1, ROWS - 2]);
+ if (walkable[y][x] && !occupied.has(key(x, y))) return { x, y };
+ }
+ return null;
+ };
+
+ // Obstacles (block movement).
+ const obstacles = [];
+ const obstacleCount = randint(k, OBSTACLE_COUNT);
+ for (let i = 0; i < obstacleCount; i++) {
+ const t = freeTile();
+ if (!t) break;
+ walkable[t.y][t.x] = false;
+ occupied.add(key(t.x, t.y));
+ obstacles.push(t);
+ }
+
+ // Reachability: player and exit must be reachable from Link's spawn.
+ const reachable = reachableFrom(walkable, linkSpawn.x, linkSpawn.y);
+ if (!reachable.has(key(playerSpawn.x, playerSpawn.y))) return null;
+ if (!reachable.has(key(exit.x, exit.y))) return null;
+
+ // Place interactables only on reachable free tiles.
+ const placeMany = (range) => {
+ const out = [];
+ const count = randint(k, range);
+ for (let i = 0; i < count; i++) {
+ const t = freeTile();
+ if (!t || !reachable.has(key(t.x, t.y))) continue;
+ occupied.add(key(t.x, t.y));
+ out.push(t);
+ }
+ return out;
+ };
+
+ const pots = placeMany(POT_COUNT);
+ if (pots.length === 0) return null; // no pots -> no game
+ const grass = placeMany(GRASS_COUNT);
+ const chickens = placeMany(CHICKEN_COUNT);
+
+ return {
+ seed: null, // filled in by generate()
+ walkable,
+ exit,
+ exitEdge,
+ linkSpawn,
+ playerSpawn,
+ obstacles,
+ pots,
+ grass,
+ chickens,
+ };
+}
+
+// Generate a solvable level for `seed`, retrying a few times if an attempt comes
+// out blocked. Falls back to whatever the last attempt produced.
+export function generate(k, seed) {
+ k.randSeed(seed);
+ let level = null;
+ for (let i = 0; i < 12 && !level; i++) level = attempt(k);
+ if (!level) level = attempt(k); // last resort, accept as-is
+ level.seed = seed;
+ return level;
+}
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..37fa65b
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,21 @@
+import kaplay from "https://unpkg.com/kaplay@3001.0.19/dist/kaplay.mjs";
+import { CANVAS_W, CANVAS_H } from "./config.js";
+import { registerGameScene } from "./scenes/game.js";
+
+const k = kaplay({
+ width: CANVAS_W,
+ height: CANVAS_H,
+ background: [24, 26, 34],
+ letterbox: true,
+ global: false,
+ pixelDensity: 1,
+});
+
+// No gravity: this is a top-down game.
+k.setGravity(0);
+
+registerGameScene(k);
+k.go("game");
+
+// Dev-only handle for headless checks / console poking: open with ?debug.
+if (location.search.includes("debug")) window.__k = k;
diff --git a/src/scenes/game.js b/src/scenes/game.js
new file mode 100644
index 0000000..551d3af
--- /dev/null
+++ b/src/scenes/game.js
@@ -0,0 +1,102 @@
+// The main scene. Milestones 1-2: build a seeded procedural level and render it.
+// Actors are placed but static for now (movement/AI arrive in later milestones).
+
+import { TILE, COLS, ROWS, Z } from "../config.js";
+import { generate } from "../gen.js";
+import { visual } from "../sprites.js";
+
+export function registerGameScene(k) {
+ k.scene("game", (opts = {}) => {
+ const seed = opts.seed ?? Math.floor(Math.random() * 1e9);
+ const level = generate(k, seed);
+
+ // Pixel center of a tile.
+ const tc = (t) => k.vec2((t.x + 0.5) * TILE, (t.y + 0.5) * TILE);
+
+ // --- Ground (checkerboard of two greens) ---
+ for (let y = 0; y < ROWS; y++) {
+ for (let x = 0; x < COLS; x++) {
+ const alt = (x + y) % 2 === 0;
+ k.add([
+ k.rect(TILE, TILE),
+ k.pos(x * TILE, y * TILE),
+ k.color(alt ? 104 : 96, alt ? 166 : 156, alt ? 96 : 88),
+ k.z(Z.GROUND),
+ ]);
+ }
+ }
+
+ // Border walls (every non-walkable border tile except the exit gap).
+ for (let y = 0; y < ROWS; y++) {
+ for (let x = 0; x < COLS; x++) {
+ const isBorder = x === 0 || y === 0 || x === COLS - 1 || y === ROWS - 1;
+ const isExit = x === level.exit.x && y === level.exit.y;
+ if (isBorder && !isExit) {
+ k.add([
+ k.rect(TILE, TILE),
+ k.pos(x * TILE, y * TILE),
+ k.color(72, 66, 60),
+ k.outline(1, k.rgb(52, 48, 42)),
+ k.z(Z.GROUND + 2),
+ ]);
+ }
+ }
+ }
+
+ // Exit tile marker (walkable gap in the wall).
+ k.add([
+ k.rect(TILE, TILE),
+ k.pos(level.exit.x * TILE, level.exit.y * TILE),
+ k.color(210, 196, 120),
+ k.opacity(0.6),
+ k.z(Z.GROUND + 1),
+ ]);
+
+ // Helper: add a world entity anchored at its tile center, y-sorted.
+ const spawn = (kind, tile, ...tags) => {
+ const p = tc(tile);
+ return k.add([...visual(k, kind), k.pos(p), k.z(p.y), ...tags]);
+ };
+
+ // --- Static world ---
+ level.obstacles.forEach((t) => spawn("obstacle", t, "obstacle"));
+ level.grass.forEach((t) => spawn("grass", t, "grass"));
+ level.pots.forEach((t) => spawn("pot", t, "pot"));
+ level.chickens.forEach((t) => spawn("chicken", t, "chicken"));
+
+ // --- Actors (static this milestone) ---
+ spawn("link", level.linkSpawn, "link");
+ spawn("player", level.playerSpawn, "player");
+
+ // --- Dev HUD ---
+ k.add([
+ k.text(
+ `Pot Patrol seed:${seed}\n` +
+ `pots:${level.pots.length} grass:${level.grass.length} ` +
+ `chickens:${level.chickens.length} obstacles:${level.obstacles.length} ` +
+ `exit:${level.exitEdge}`,
+ { size: 16 }
+ ),
+ k.pos(10, 8),
+ k.color(255, 255, 255),
+ k.fixed(),
+ k.z(Z.HUD),
+ ]);
+ k.add([
+ k.text("R: new scene", { size: 14 }),
+ k.pos(10, CANVAS_HUD_BOTTOM()),
+ k.color(230, 230, 230),
+ k.opacity(0.8),
+ k.fixed(),
+ k.z(Z.HUD),
+ ]);
+
+ // Regenerate with a fresh seed.
+ k.onKeyPress("r", () => k.go("game"));
+ });
+}
+
+// Small helper kept out of the layout math above.
+function CANVAS_HUD_BOTTOM() {
+ return ROWS * TILE - 24;
+}
diff --git a/src/sprites.js b/src/sprites.js
new file mode 100644
index 0000000..88f0e67
--- /dev/null
+++ b/src/sprites.js
@@ -0,0 +1,26 @@
+// Placeholder visuals. Each factory returns the drawing components for a kind of
+// thing (shape + color + outline, anchored at center). Game logic never depends
+// on these, so a real sprite atlas can replace them later via loadSprite without
+// touching the rest of the game.
+
+const SPEC = {
+ obstacle: { shape: "rect", w: 34, h: 34, radius: 6, color: [96, 90, 82], outline: [52, 48, 42] },
+ pot: { shape: "rect", w: 24, h: 28, radius: 8, color: [196, 110, 62], outline: [122, 66, 36] },
+ grass: { shape: "rect", w: 18, h: 18, radius: 3, color: [150, 205, 96], outline: [86, 146, 54] },
+ chicken: { shape: "circle", r: 12, color: [242, 242, 236], outline: [206, 176, 60] },
+ link: { shape: "rect", w: 28, h: 32, radius: 5, color: [34, 148, 70], outline: [14, 66, 32] },
+ player: { shape: "rect", w: 24, h: 28, radius: 5, color: [80, 150, 230], outline: [36, 82, 152] },
+ shard: { shape: "rect", w: 12, h: 12, radius: 2, color: [212, 152, 90], outline: [132, 82, 40] },
+};
+
+export function visual(k, kind) {
+ const s = SPEC[kind];
+ const comps =
+ s.shape === "circle"
+ ? [k.circle(s.r)]
+ : [k.rect(s.w, s.h, { radius: s.radius ?? 0 })];
+ comps.push(k.color(...s.color));
+ if (s.outline) comps.push(k.outline(2, k.rgb(...s.outline)));
+ comps.push(k.anchor("center"));
+ return comps;
+}
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..ee93f8f
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,13 @@
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+html, body {
+ width: 100%;
+ height: 100%;
+ background: #0e0e12;
+ overflow: hidden;
+}
+
+canvas {
+ image-rendering: pixelated;
+ display: block;
+}