Imported from maximgladkov/ducks (
AGENTS.md). Install upstream withnpx skills add maximgladkov/ducks. Copyright stays with the author.
Marketplace item art
This file is for agents adding catalog items and card art. Gameplay sprites (host/public/sprites/ducks/, dog/, hud/, fx/) are authored NES tiles and are not generated this way.
Weapons already follow this pipeline. Worlds, characters, modes, and sights should too when they get unique art.
What a catalog item is
Every purchasable lives in host/src/store/catalog.ts as a StoreItem:
{
id: "weapon-shotgun", // stable; wallets persist this
category: "weapons", // weapons | worlds | characters | modes | crosshairs
name: "SHOTGUN", // ALL CAPS, short
tagline: "WIDER BLAST", // ALL CAPS, one beat
description: "A short barrel…",
price: 120, // credits; 0 is allowed
art: "/sprites/weapons/shotgun.png", // optional; omit → CSS pixel art
effect: { kind: "weapon", … }, // optional; omit → cosmetic / placeholder
}
Rules:
- Ids are unique, kebab-case, prefixed by a category stem (
weapon-,world-,char-,mode-,sight-). - Never rename an id.
equippedItem()inhost/src/store/loadout.tslooks upwallet.equipped[category]throughcatalogById. Unknown ids already fall back to the default loadout; renaming orphans owned copies. effect.kindmust match the category (effectKindForCategory). Weapons usekind: "weapon", worldsworld, characterscharacter, modesmode, sightscrosshair.- Cosmetic placeholders (price, no
effect) are fine. Do not invent gameplay for a category until loadout actually reads that effect.
Art pipeline (same as weapons)
- Generate one 1:1 PNG per item with the image tool (
GenerateImage). - Prompt hard for chunky NES-era pixel art (see recipe below).
- Copy the file into
host/public/sprites/<folder>/<file>.png. - Set
item.artto the public URL/sprites/<folder>/<file>.png. - Leave CSS fallbacks in place.
artInner()inhost/src/render/marketplace.tsuses the<img>whenartis set, otherwise.store-art-pixel+artClass(item). - Do not downscale, quantize, or recompress unless the user asks. The generator outputs photographic-resolution PNGs;
image-rendering: pixelatedon.store-art-imginhost/style.cssis the sharpening step. If a card still looks too smooth, flag a one-off downscale rather than doing it silently.
Folders and filenames
| Category | Folder | Example file | Example art URL |
|---|---|---|---|
| weapons | host/public/sprites/weapons/ |
shotgun.png |
/sprites/weapons/shotgun.png |
| worlds | host/public/sprites/worlds/ |
night.png |
/sprites/worlds/night.png |
| characters | host/public/sprites/chars/ |
parrot.png |
/sprites/chars/parrot.png |
| modes | host/public/sprites/modes/ |
flock.png |
/sprites/modes/flock.png |
| crosshairs | — (live reticle) | — | artInner mounts .crosshair |
Filenames are short, lowercase, no hyphens (machinegun.png, not machine-gun.png). They name the object, not the catalog id (weapon-double-rifle → rifle.png).
Existing weapon files (do not rename):
rifle.png— DOUBLE TAP RIFLEshotgun.png— SHOTGUNmachinegun.png— MACHINE GUNbazooka.png— BAZOOKA
Sights do not use generated PNGs. In-round reticles are CSS (.crosshair + skin-*). artInner() in host/src/render/marketplace.ts mounts that same markup so store cards match play. Do not add art on a sight.
The default zapper is not a catalog item. Everyone already has its 5-shot magazine (WAVE_SHOTS in host/src/rules.ts). Do not put HEAVY CLIP back in the store.
Prompt recipe
Use aspect_ratio: "1:1". One subject, centered, readable at card size (~160–220px on the TV).
Always include:
- Chunky 8-bit NES-era pixel art, large pixels, limited palette (about 8–12 colors).
- Thick 1px-class black outline around the subject.
- Flat colors, no gradients, no photorealism, no film grain, no anti-aliasing, no text, no UI chrome, no watermark.
- Side-on or simple 3/4 view. No perspective vanishing points.
Backgrounds must match the CSS card stripes already in host/style.css (.store-art-*):
| Category | Background to prompt |
|---|---|
| weapons | Split field: sky #3cbcfc on top (~60%), grass #00a800 below, dirt #503000 optional at the very bottom. Subject: the gun, muzzle pointing right, stock left. Wood #503000 / #a44a00, metal #7c7c7c / #fcfcfc, gold trigger #fcb400 if it needs an accent. |
| worlds | A tiny landscape in that world's sky. Night marsh: #101838 sky, #0c3010 ground, moon #fcfcfc. Winter: pale #dcdcfc sky, white snow, reed brown. Neon city: #203878 sky, magenta/gold windows. Keep it a backdrop, not a screenshot of the meadow sprite. |
| characters | Sky #5c94fc over grass #00a800. Subject: the bird species, side-on NES duck pose. Cards are composited from in-round frames (python3 scripts/worlds/compose-cards.py), not a separate image-tool pass. |
| modes | Flat black #000000 or sky, with one icon (clay pigeon, flock of ducks, giant duck). Accent #fcb400 / #e40058 like .store-art-mode. |
| crosshairs | Do not generate. Store cards use the live .crosshair markup. |
Reference an existing card when generating the next one in a category (reference_image_paths pointing at e.g. host/public/sprites/weapons/rifle.png) so palette and pixel size stay aligned.
Copy after generation:
cp <generated.png> host/public/sprites/<folder>/<file>.png
Vite serves host/public/ at /, so the catalog URL is always /sprites/….
Rendering contract
artInner(item) (host/src/render/marketplace.ts) is the only place card art is chosen. Feature cards, rows, and the detail pane all call it. If you add a new surface, use artInner — do not inline a second <img>.
Crosshair items keep the live .crosshair DOM (host/src/reticle.ts) so the card is the same reticle as play. artInner layers that mark over /sprites/sights/backdrop.png (meadow sprites, one duck under the pip). Do not bake the reticle into a PNG. Holo and scope add .lock on the mark.
<img class="store-art-img" src="…" alt="" />
.store-art-img already has object-fit: contain and image-rendering: pixelated. Do not set image-rendering: auto on store cards.
When an item has no art and is not a sight, artClass() maps:
- known ids:
world-night-marsh→store-art-night - else by category:
store-art-weapon/store-art-world/store-art-char/store-art-mode/store-art-sight
Add a new CSS class only if the item is staying CSS-only (like NIGHT MARSH until it has a PNG). Prefer a generated PNG once the item is a real product. Sights stay on the live reticle over the meadow backdrop — never a PNG of the sight itself.
Copy and pricing
name: 1–3 words, ALL CAPS (NIGHT MARSH,IRON SIGHT).tagline: ALL CAPS, what it does in a few words (TWO ROUNDS A PULL,HOLD TO SPRAY).description: for weapons, 3 short paragraphs (\\n\\nseparated) that sell the fantasy and name the real stat (magazine, burst/auto/blast). Browse cards usetaglineonly; the detail pane splits paragraphs. Other categories can stay 1–2 sentences.- Prices sit in the same band as neighbors (weapons ~90–260, worlds ~25–60, chars ~50–120, modes ~40–100, sights 0–95). Jumping an order of magnitude needs a reason.
Gameplay wiring (keep loadout consistent)
Art-only items need no extra code. Items with an effect must actually change play:
effect.kind |
Resolved in | Notes |
|---|---|---|
weapon |
weaponFromEffect / currentWeapon() |
shots is magazine per wave. fire defaults to { mode: "single" }. radius is extra hit px. multi: true hits every duck in range (shotgun, bazooka). Burst/auto go through FireController in host/src/fire.ts. |
world |
resolveLoadout → sky / tint / worldPack / ground / smooth |
applyTheme sets --sky and --ground. Packs swap sprite files. |
crosshair |
resolveLoadout → crosshairSkin, aimBonus, targetLock |
Applied with applyCrosshairSkin. assist adds hit px on top of the weapon radius (resolveShot sightBonus). lock lights the reticle when a hittable duck sits in that same reach. |
character |
resolveLoadout → species |
Buying unlocks the bird into the spawn pool (ownedSpecies). No equip slot. effect.species is a BirdKind. |
mode |
resolveLoadout → modeId |
Equipping a mode adds a third title-screen target. Classic variants (clay, flock, boss) reuse matchMachine. Arcade variants (endless, fruit, survival) run arcadeMachine. GameMode "A" | "B" is still chosen by the GAME A / GAME B ducks. |
Default weapon (unequipped) is the 5-shot zapper from WAVE_SHOTS in host/src/rules.ts, sfx "gunshot". Equipping a store weapon copies weapon.shots onto Loadout.shots so HUD, shotsPerWave(), and the match machine stay in sync. Saved wallets that still list weapon-heavy-clip fall through as an unknown id and get this default.
Removing a catalog id is safe: unknown equipped ids fall through catalogById and the default loadout. Do not leave a new required field on ItemEffect without a default in weaponFromEffect (or the equivalent helper).
Preview and tests
Eyeball cards without playing a round: host lab, store story (host/src/lab/stories.ts).
When adding items:
host/tests/store.test.ts— unique ids, prices ≥ 0,effect.kindmatches category, everyartstring non-empty, loadout still equalsDEFAULT_LOADOUTon an empty wallet, unknown equipped id still falls back.- If the item has mechanics, add a focused assertion (
toMatchObjecton the effect) rather than restating the whole catalog.
Do not
- Generate art for the in-round NES sprites (ducks, dog, HUD digits, meadow). Those are ripped tiles.
- Generate art for sights. Those are the CSS reticle.
- Point
artat a CSS class, a remote URL, orhost/src/. - Add a second image-rendering path or a per-category
<img>helper. - Silently resample generated PNGs “to look more pixelated.”
- Ship a weapon (or later, a world/character) whose
effectthe loadout ignores.
World sprite packs
Playable worlds can replace in-round art. Colors stay on the catalog item; files live in a pack.
effect: {
kind: "world",
sky: "#4a5c38",
tint: "rgba(24, 48, 16, 0.32)",
pack: "zombie", // optional; folder under host/public/sprites/worlds/
ground: "#2c3c10", // optional; HUD / dirt strip
smooth: false, // optional; true only for 4K ULTRA
}
Layout:
host/public/sprites/worlds/<pack>/
pack.json
meadow_back.png
duck_horiz0.png
card.png
...
pack.json maps logical sprite paths (the same keys loadSpriteBank already loads, e.g. ducks/black_horiz0.png) to pack-relative files. Missing keys fall back to the NES base art. Two logical paths may share one file.
{
"meadow_back.png": "meadow_back.png",
"ducks/black_horiz0.png": "duck_horiz0.png",
"ducks/brown_horiz0.png": "duck_horiz0.png"
}
Generate pixel packs with PixelLab (needs PIXELLAB_SECRET in .env):
node scripts/pixellab.mjs --world zombie
node scripts/pixellab.mjs --world wasteland --only duck_horiz0.png
node scripts/pixellab.mjs --dry-run
Recipes live in scripts/worlds/*.mjs. Bitforge uses the NES frame as init_image so pose and size stay put. 4K ULTRA is photoreal stills (smooth: true), not PixelLab; drop PNGs into host/public/sprites/worlds/ultra4k/ and keep pack.json aliases.
World card art is composited from that world's in-round sprites (python3 scripts/worlds/compose-cards.py). Packed worlds write card.png in the pack folder (item.art = "/sprites/worlds/<pack>/card.png"). Night marsh uses /sprites/worlds/night.png. 4K cards use .store-art-img-smooth so the photo is not crunchy.
Do not overwrite host/public/sprites/ducks/, dog/, fx/, or the meadow PNGs. Those remain the default pack.
Bird species
CHARS sells bird species, not dog skins. Buying is a permanent unlock: resolveLoadout reads owned character items into Loadout.species. An empty wallet keeps the base trio (black 6 / blue 3 / brown 1).
In-round frames live at host/public/sprites/birds/<species>_<frame>.png (same 9 frames as a NES duck). Generate them with PixelLab:
node scripts/pixellab.mjs --bird --dry-run
node scripts/pixellab.mjs --bird parrot
node scripts/pixellab.mjs --bird
Recipes live in scripts/birds/*.mjs. birdJobs in scripts/worlds/shared.mjs uses the NES duck frame as init_image. Store cards are composited into host/public/sprites/chars/ by compose-cards.py.
Do not rename char-duck-king. Deleted dog-skin ids (char-tuxedo-pup, char-space-dog) are gone; unknown equipped ids already fall through.
Mode props
In-round fruit, clay discs, and zombie walkers live under host/public/sprites/props/<set>/. Generate them with PixelLab:
node scripts/pixellab.mjs --props --dry-run
node scripts/pixellab.mjs --props fruit
node scripts/pixellab.mjs --props clay
node scripts/pixellab.mjs --props zombie
node scripts/pixellab.mjs --props
Recipes live in scripts/props/*.mjs. Walker jobs use NES dog sniff frames as init_image. Fruit halves are not generated one-at-a-time — that makes two unrelated fruits. Generate a matching pair in the Fruit Ninja 3/4 cut pose (same fruit, rotated outward so the flesh shows), drop the pair and whole into scripts/props/fruit-src/, then:
python3 scripts/props/split-pair.py
PixelLab --props fruit only regenerates bomb.png. Mode store cards are 1:1 PNGs in host/public/sprites/modes/ (same GenerateImage recipe as other catalog art).
Do not rename mode-clay, mode-flock, or mode-boss.