12 KiB
Pot Patrol — Plan
A single-screen, sprite-based, widescreen browser game built with Kaplay (the JS/TS game library, successor to Kaboom.js). Link (AI) rampages across a procedurally generated scene smashing pots; you (the player) chase him and sweep up the shards before he leaves the screen.
1. Concept & core loop
- Actors:
Link(computer-controlled) andPlayer(you). - Link's behavior: wander toward the nearest pot, pick it up, and throw it in
a random direction. The pot flies 2–3 tiles, shatters, and scatters 1–3 shards
onto nearby tiles. Along the way Link gets distracted:
- Grass — he detours to cut it.
- Chickens — he hits one once, then it chases him, so he flees briefly before resuming.
- Player's job: follow Link and clean up shards by touching them.
- Win: zero shards remain at the moment Link leaves the screen (or after his last pot, once the board is clear).
- Lose: Link leaves the screen with shards still on the ground.
- Balance: Link is slightly faster than the player, but loses time to distractions — that lost time is the player's window to catch up.
2. Tech: Kaplay
Kaplay gives us the game loop, rendering, sprites, input, collision, scenes, timers, tweens, vectors, and seeded RNG out of the box — so most of the old "engine" work disappears and we focus on game logic.
- Load: ES-module import from a CDN, keeping a no-build setup:
Serve over HTTP for module loading:
<script type="module"> import kaplay from "https://unpkg.com/kaplay@3000/dist/kaplay.mjs"; // ... or import ./src/main.js which imports kaplay </script>python3 -m http.server→http://localhost:8000. (Alternative, better DX:npm create kaplay@latestscaffolds a Vite project. We can switch to that if we want bundling/TS; the plan below is engine-API identical either way.) - Init:
kaplay({ width: 960, height: 560, background: [...], letterbox: true, global: false }).letterboxkeeps the widescreen aspect ratio and scales to the window. - Seeded RNG:
randSeed(seed)+rand(),randi(),choose()— reproducible scenes for debugging and "retry same level" (no custom PRNG needed). - Delta time:
dt()for frame-independent movement. - Depth: set
obj.z = obj.pos.yeach frame for top-down y-sorting.
Kaplay features we lean on hardest:
state()component — a built-in finite state machine, ideal for Link's AI (onStateEnter/Update/End).addLevel()— turns an ASCII tile map into game objects, matching our procedural grid.area()+onCollide/body({ isStatic })— collision & obstacle blocking (top-down, gravity 0).scene()/go()— game / win / lose screens.tween()— pot throw arc, sweep juice.
3. Coordinate system
- Tile-based grid, continuous pixel positions (Kaplay
vec2). - Config constants (tunable in
config.js):TILE = 40pxCOLS = 24,ROWS = 14→960 × 560canvas (≈16:9 widescreen).
- AI reasoning in tile coordinates; movement continuous.
addLevelusestileWidth/tileHeight = TILE.
4. File structure
index.html # imports src/main.js as a module
styles.css # page bg, canvas centering
src/
config.js # tunable constants (sizes, speeds, counts, radii)
main.js # kaplay() init, load assets, register scenes, go("game")
gen.js # seeded procedural tile map + entity placement
sprites.js # loadSprite / placeholder object factories
scenes/
game.js # main scene: build level, spawn actors, wire collisions
end.js # win & lose scenes (result + retry / new-seed)
actors/
player.js # player object + input-driven movement
link.js # link object + state() FSM (the AI)
chicken.js # chicken idle + chase behavior
pots.js # pickup, throw arc, shatter, shard spawning
hud.js # shard counter, pots-remaining, dev state readout
Kaplay's built-in RNG/loop/render/input mean no rng.js, render.js, or
input.js — that logic is inline via Kaplay APIs.
5. Procedural scene generation (gen.js)
Seeded via randSeed(seed). Produce a level the game scene consumes:
- Grid & ground: fill a
COLS × ROWSmap with floor tiles (optionally 2 variants for noise). - Obstacles: scatter impassable decor (rocks/trees), light count (6–12), keeping the interior open. Border walkable except one exit gap (§9).
- Pots: place
POT_COUNT(5–8) on random passable, unoccupied tiles. - Grass: place
GRASS_COUNT(8–15). - Chickens: place
CHICKEN_COUNT(2–4). - Spawns: Link at one edge; player opposite/center. Keep spawn tiles + their neighbors clear.
- Validation: flood-fill from Link's spawn; reroll any unreachable pot (or regenerate) so the level is always solvable.
Two build options (either works — leaning toward B for dynamic control):
- A. Emit an ASCII map and hand it to
addLevel()with atilesmap of component factories per symbol. - B. Emit a data structure (tile types + entity list) and
add()each object directly — more flexible for entities that move, spawn, and despawn at runtime.
6. Entity model — Kaplay components
Every actor is a Kaplay game object: add([...components, "tag"]).
- Player:
[sprite/placeholder, pos, area(), body(), anchor("center"), "player"], moved from input;PLAYER_SPEED. - Link:
[..., area(), body(), state("seek", [...]), "link"];LINK_SPEED(≈1.15× player). AI in thestate()FSM (§8). - Pot:
[..., area(), "pot"]; static until targeted, then handled bypots.js. - Shard:
[..., area(), "shard"]; removed on player overlap. - Grass:
[..., area(), "grass"]; destroyed/stubbed when cut. - Chicken:
[..., area(), body(), state("idle",[...]), "chicken"](§chickens). - Obstacles:
[..., area(), body({ isStatic: true }), "obstacle"]— block movers.
Movement helper: obj.move(dir.scale(speed)) toward a target vec2; body
resolves collisions against static obstacles. Update z = pos.y for depth.
7. Pots, throwing & shards (pots.js)
- Target: Link's FSM picks the nearest
"pot"still on the board (get("pot")+ min distance). - Pickup: within
INTERACT_DIST, pot attaches to Link (brief wind-up). - Throw: pick a random 8-way direction and 2–3 tiles distance. Animate
with
tween(): linear travel along the path plus a parabolic height offset (tween az-heightup then down, drawn as vertical sprite offset + a shadow) so it visibly arcs. - Shatter on land: spawn 1–3 shards on the landing tile + random adjacent
passable tiles (dedupe to one per tile).
addKaboom()/ small particle pop +shake()for juice. - If the target tile is impassable, land on the nearest passable tile.
8. Link AI (link.js) — state() FSM
States, checked by priority each onStateUpdate:
flee— active forFLEE_TIMEafter hitting a chicken; run away from the nearest chasing chicken, then return toseek.distracted— if grass/chicken withinDISTRACT_RADIUS, with per-tick probability divert:- grass → move adjacent → cut (destroy grass), brief pause →
seek. - chicken → move adjacent → hit once → triggers chicken chase + Link
flee.
- grass → move adjacent → cut (destroy grass), brief pause →
seek— move to nearest pot → transition tothrow.throw— run the throw sequence (§7) → back toseek.leave— no pots remain (or a max-time fuse fires): walk to the exit gap and off-screen → end the round (§9). Detected withoffscreen().
"More distractions" = generous DISTRACT_RADIUS + divert probability + flee
detours, so Link squanders his speed edge. enterState() transitions log to
console in dev; HUD shows current state.
9. Win / lose (scenes/)
gamescene tracksshardCount(count of"shard"objects) live in the HUD.- Link leaves: his
leavestate +offscreen()ends the round; evaluate:shardCount === 0→go("win").shardCount > 0→go("lose", { left: shardCount }).
- Early win: last pot thrown and
shardCounthits 0 before he exits → win immediately. - End scenes show result + Retry (same seed) and New scene (new seed)
(
go("game", { seed })), driven by key press.
10. Player input & shard cleanup
- Kaplay input:
onKeyDown("left"/"a"/…)orisKeyDown()to build a directionvec2; normalize so diagonals aren't faster;player.move(dir.scale(PLAYER_SPEED)). - Cleanup:
player.onCollide("shard", (s) => { destroy(s); shardCount--; }), with an optional sweep tween. - Obstacle blocking handled by
body()vs static obstacle bodies.
11. Rendering & HUD
Kaplay renders automatically; we control order via z (set each frame to
pos.y for actors/chickens; fixed low z for ground/decor, high z for the
flying pot + HUD). Sprites via loadSprite; placeholders first so it's
playable immediately:
- Placeholder glyphs with
text()— Link 🧝, player 🧹, pot 🏺, shard 🔶, grass 🌿, chicken 🐔, rock 🪨 — or coloredrect()/circle()components. - HUD (
hud.js): shard counter, pots remaining, Link state (dev), win/lose banner —add([text(), fixed(), z(top)]). image-rendering: pixelatedon the canvas for crisp sprites.
12. Milestones (build order)
- Skeleton:
index.html+main.jswithkaplay()init, emptygamescene, letterboxed canvas. Runs via local server. - Procgen:
gen.jsbuilds a seeded level;gamescene renders ground, obstacles, pots, grass, chickens, spawns; reachability validated. - Player: input-driven movement + obstacle collision via
body(). - Link seek+move:
state()FSM withseektargeting nearest pot (no throw). - Throw + shards: pickup →
tweenarc → shatter → 1–3 shards. - Cleanup + HUD:
onCollideshard pickup; live counter. - Distractions: grass cut; chicken hit → chase → Link
flee. - End states:
leave+offscreen→ win/lose scenes + retry/new-seed. - Polish: real sprite atlas,
addKaboom/particles, sfx (loadSound/play),shake(), tuning pass on speeds / radii / counts.
Each milestone is independently runnable for playtesting.
13. Tunable constants (initial guesses, config.js)
| Constant | Value | Meaning |
|---|---|---|
TILE |
40 px | tile size |
COLS × ROWS |
24 × 14 | grid → 960×560 canvas |
PLAYER_SPEED |
130 px/s | player movement |
LINK_SPEED |
~150 px/s | ≈1.15× player |
POT_COUNT |
5–8 | pots per scene |
GRASS_COUNT |
8–15 | grass tufts |
CHICKEN_COUNT |
2–4 | chickens |
THROW_TILES |
2–3 | pot flight distance |
SHARDS_PER_POT |
1–3 | shards on shatter |
DISTRACT_RADIUS |
~4 tiles | how far grass/chickens tempt Link |
FLEE_TIME |
~1.5 s | how long Link flees after hitting chicken |
First-pass numbers; §12 step 9 tunes them against playtests.
14. Assumptions & open questions
- "Your PC" = the player-controlled character (Link is the AI). Assumed.
- Kaplay delivery: CDN ES-module import for a no-build start; can switch to
npm create kaplay+ Vite for bundling/TypeScript if we want it. - Sprites: no Zelda art bundled; starting with emoji/
text()and shape placeholders, swappable for a real atlas vialoadSpritelater. - Link leaves when out of pots (max-time fuse as backstop); a fixed round
timer is a one-line alternative in
config.js. - Chicken chase: only the hit chicken chases (simplest); could expand later.
- Audio is polish (step 9), not core.