• TypeScript 50.2%
  • Svelte 39.1%
  • CSS 7.5%
  • Just 1.9%
  • HTML 0.9%
  • Other 0.3%
Find a file
PatillaCode 3c81b4d35e
All checks were successful
Release / verify (push) Successful in 18s
Release / build (push) Successful in 1m0s
new-collections-II (#8)
Co-authored-by: PatillaCode <patillacode@gmail.com>
Co-committed-by: PatillaCode <patillacode@gmail.com>
2026-09-04 15:45:11 +02:00
.forgejo/workflows Fix app version showing "dev" in production builds 2026-09-02 17:34:17 +02:00
public Add crest branding and sync rules/wording with the android app 2026-08-30 18:12:25 +02:00
src new-collections-II (#8) 2026-09-04 15:45:11 +02:00
.dockerignore Scoundrel v1: Svelte web app, static-only deploy 2026-08-28 19:44:15 +02:00
.gitignore collections-revamp (#1) 2026-08-29 02:38:44 +02:00
CLAUDE.md Add Toy Box, Wild West, and Greek Myth collections (#5) 2026-09-01 12:31:24 +02:00
Dockerfile Fix app version showing "dev" in production builds 2026-09-02 17:34:17 +02:00
index.html Add PatillaCode and game-design credits to Rules card 2026-09-02 17:16:57 +02:00
instructions.pdf collections-revamp (#1) 2026-08-29 02:38:44 +02:00
justfile full-collections-revamp (#4) 2026-08-31 22:17:03 +02:00
nginx.conf Scoundrel v1: Svelte web app, static-only deploy 2026-08-28 19:44:15 +02:00
package-lock.json Scoundrel v1: Svelte web app, static-only deploy 2026-08-28 19:44:15 +02:00
package.json Add PatillaCode and game-design credits to Rules card 2026-09-02 17:16:57 +02:00
README.md Add Toy Box, Wild West, and Greek Myth collections (#5) 2026-09-01 12:31:24 +02:00
svelte.config.js Scoundrel v1: Svelte web app, static-only deploy 2026-08-28 19:44:15 +02:00
tsconfig.app.json Scoundrel v1: Svelte web app, static-only deploy 2026-08-28 19:44:15 +02:00
tsconfig.json Scoundrel v1: Svelte web app, static-only deploy 2026-08-28 19:44:15 +02:00
tsconfig.node.json Scoundrel v1: Svelte web app, static-only deploy 2026-08-28 19:44:15 +02:00
vite.config.ts Fix app version showing "dev" in production builds 2026-09-02 17:34:17 +02:00

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 2A are monsters, diamonds 210 are weapons, hearts 210 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.ts and src/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 localStorage under scoundrel.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.ts builds and shuffles the 44-card deck; engine.ts is the whole game as (State) => State functions; engine.test.ts is the rules spec.
  • src/lib/ — UI components (Card, Room, StatusBar, MenuCard, RulesCard, GameOver, Art) plus sound.ts, stats.ts and i18n.svelte.ts, all thin localStorage wrappers. i18n.svelte.ts holds every UI string in English and Spanish; the menu's "Language" button toggles between them (scoundrel.lang in localStorage).
  • src/collections/ — one folder of pixel-art PNGs per collection, plus index.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.css is 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 S2S14, C2C14, D2D10, H2H10 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.