- TypeScript 50.2%
- Svelte 39.1%
- CSS 7.5%
- Just 1.9%
- HTML 0.9%
- Other 0.3%
Co-authored-by: PatillaCode <patillacode@gmail.com> Co-committed-by: PatillaCode <patillacode@gmail.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| public | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| Dockerfile | ||
| index.html | ||
| instructions.pdf | ||
| justfile | ||
| nginx.conf | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| svelte.config.js | ||
| tsconfig.app.json | ||
| tsconfig.json | ||
| tsconfig.node.json | ||
| vite.config.ts | ||
Scoundrel
A solitaire dungeon-crawl played with a stripped poker deck, as a web app.
Rules: instructions.pdf, the original by Zach Gage and
Kurt Bieg.
Design decisions and rulings: .claude/plans/ in this repo's history, or ask.
Svelte 5 + TypeScript + Vite. No backend — the whole game runs in the browser;
nginx just serves the static build. Local run history lives in localStorage.
Rules, in short
- 44 cards: clubs/spades 2–A are monsters, diamonds 2–10 are weapons, hearts 2–10 are potions. Ace counts as 14.
- Start at 20 hp (hard cap). Each room deals 4 cards; resolve 3 of them and the 4th carries into the next room, or avoid the whole room (not twice in a row, and never once the deck is empty).
- Fight a monster bare-handed for full damage, or with your equipped weapon
for
max(0, monster − weapon)— but a weapon can only strike monsters weaker than the last one it killed. Equipping a new weapon discards the old one and everything it had killed. - The first potion in a room heals you (capped at 20 hp); any further potion that room is wasted.
- Clear every card in the deck to win. The engine and its full rules table
live in
src/game/engine.tsandsrc/game/engine.test.ts. isDoomed(state)(src/game/engine.ts) flags a room with no surviving line of play, checked against the current room only (not the whole remaining deck). The UI shows this as a banner rather than ending the run — you can still play it out for the less-negative score.- The run persists to
localStorageunderscoundrel.run(survives a refresh) and undo is unlimited within a run — but a run that used undo isn't recorded in the stats panel, since replaying the same fixed deck order is a retry, not a fresh result.
Develop
just install # npm install (--legacy-peer-deps, see justfile)
just dev # http://localhost:5173
just test # engine rules, vitest
just check # svelte-check + tsc
just fmt # prettier --write .
just dev starts Vite with hot reload; edits to .svelte/.ts/.css files
apply instantly without a page reload.
Deploy
just build # vite build -> dist/
just docker-build
just docker-run # http://localhost:8080
Dockerfile is a two-stage build: npm run build in a node image, then
the static dist/ output is served by nginx:alpine. There is no backend
process and nothing to configure — the container just needs a port.
Pushing a v* tag runs .forgejo/workflows/release.yml: check + test, then
build and push forgejo.patilla.es/patillacode/scoundrel:latest and
:<version> for linux/amd64,linux/arm64. just release 1.0.0 creates and
pushes the tag (bare just release just lists recent tags). Every PR against
main runs .forgejo/workflows/ci.yml (check + test + a container smoke test).
On the homeserver:
services:
scoundrel:
image: forgejo.patilla.es/patillacode/scoundrel:latest
ports: ["8080:80"]
restart: unless-stopped
docker compose pull && docker compose up -d
Layout
src/game/— the rules engine. Pure TypeScript, no DOM, no Svelte.deck.tsbuilds and shuffles the 44-card deck;engine.tsis the whole game as(State) => Statefunctions;engine.test.tsis the rules spec.src/lib/— UI components (Card,Room,StatusBar,MenuCard,RulesCard,GameOver,Art) plussound.ts,stats.tsandi18n.svelte.ts, all thinlocalStoragewrappers.i18n.svelte.tsholds every UI string in English and Spanish; the menu's "Language" button toggles between them (scoundrel.langinlocalStorage).src/collections/— one folder of pixel-art PNGs per collection, plusindex.ts, which is the only place that knows what each collection's art looks like (see below).src/themes/— one CSS palette file per collection (see below).base.cssis structural and shared by every collection; it's the only file that sets sizes, gaps, or layout.
How collections work
A collection is a full skin: a folder of PNGs plus a matching CSS palette, picked as one unit. Nothing in game or component code ever mentions a specific collection by name.
Switching: src/lib/theme.svelte.ts holds a Svelte 5 $state rune with
the active collection name, persisted to localStorage under
scoundrel.theme. Setting it writes data-theme="<name>" onto <html>;
every palette's CSS is scoped under :root[data-theme='<name>'], so only
one collection's rules ever apply. THEME_NAMES/THEME_LABELS are derived
straight from COLLECTIONS in src/collections/index.ts — no separate
list to keep in sync. The in-app menu's "Theme" button calls
cycleTheme(), which steps through them in order.
Colors: each src/themes/<name>.css file defines the same set of CSS
custom properties — --bg, --card-bg, --ink, --accent, --shadow,
plus the per-kind colors --monster, --weapon, --potion and the
per-suit colors --suit-s/-c/-d/-h (some collections point all suit vars
at the kind vars; the real-card packs give all four suits their own true
color instead). --ink is chosen to contrast --card-bg — pair it with a
card or panel background, never with --bg directly; use --accent for
text sitting on the bare page background instead.
Art: src/collections/index.ts exports COLLECTIONS, keyed by
collection name. Every entry is { label, cardArt }, where cardArt is a
map keyed ${suit}${rank} (e.g. S14, H7) holding one exact image per
card — no banded/tiered art exists. artFor(collection, card) is a plain
lookup: c.cardArt[\${card.suit}${card.rank}`]. collections.test.tsasserts every collection resolves art for all 44 cards in the deck.Art.svelteis just a pixelated`; there's no glyph/bitmap system.
Raw source art lives in art-src/<name>/ as S2…S14, C2…C14,
D2…D10, H2…H10 plus backdrop (45 files), each with a full
PROMPTS.md documenting the generation prompt used for every card. just art converts them into the WebP files collections import: cards are
cropped to fill 400×546 (magick -resize 400x546^ -gravity center -extent 400x546 -strip -quality 82), the backdrop is resized to 1600px wide only.
Output is opaque WebP — there's no transparency step. art-src/brand/ is
skipped by the recipe. Never hand-edit files under src/collections/<name>/
— regenerate from art-src/ instead.
Card chrome: Card.svelte draws its own rank badge (top-right, always
the plain number — 11/12/13/14, never J/Q/K/A) and kind-label bar
(MONSTER/WEAPON/POTION) on every collection.
Adding a collection: add art-src/<name>/PROMPTS.md and the 45
generated images, run just art to produce src/collections/<name>/*.webp,
add 44 imports plus an entry to COLLECTIONS in
src/collections/index.ts, then add a matching src/themes/<name>.css
palette file and its @import in src/app.css. Nothing else changes —
theme.svelte.ts and the menu pick it up automatically.
Current collections, in menu cycle order: Space (default), Samurai, Toy Box, Wild West, Greek Myth.