Lift the trace-to-shape geometry into @tts/mesh (traceToShape/ traceToUvBounds), parameterized by scale so the web token viewer and @tts/tabletop share one implementation. Move the CORS proxy HTTP helpers (assetUrl, resolveAssetUrl, traceImage) into a new @tts/http package, and make @tts/tabletop's ErrorBoundary the single source used by the web app. This removes the duplicated ErrorBoundary, assetUrl, tabletopHttp, and trace-to-shape code from apps/web and packages/tabletop.
TTS Workshop
Search the Tabletop Simulator Steam Workshop, fetch full TTS save files, and analyze their contents. A lightweight, client-only pnpm monorepo.
Packages
| Package | Role | Runtime |
|---|---|---|
apps/web |
React frontend: search + mod pages + 3D object viewers | Browser |
apps/proxy |
Hono HTTP server: Workshop search, save fetch, tracing | Node |
packages/tts |
Fetch save from Steam, BSON-parse to TTSMod |
Node |
packages/extract |
Analyze a TTSMod: objects, asset refs, downloads |
Isomorphic |
packages/mesh |
2D shapes + extrusion into 3D mesh geometry | Isomorphic |
packages/shared |
Shared types + zod schemas | Isomorphic |
See docs/architecture.md for the architecture and
docs/implementation-plan.md for the plan.
Setup
pnpm install
cp .env.example .env # then set STEAM_API_KEY
pnpm dev # runs the proxy at http://localhost:3000
Get a Steam Web API key at https://steamcommunity.com/dev/apikey (free).
To run the frontend alongside the proxy, open a second terminal and run
pnpm --filter @tts/web dev (serves at http://localhost:5173 and proxies
/search, /items, /health, /asset, and /trace to the backend).
API
| Method | Path | Description |
|---|---|---|
| GET | /health |
Liveness |
| GET | /search?q=&page= |
Search the Workshop (scrapes browse page) |
| GET | /items/:id |
Full parsed TTSMod (BSON save) |
| GET | /items/:id/file |
Raw save bytes, filename from header |
| GET | /asset?url= |
CORS-safe proxy for external assets (textures, models) |
| GET | /trace?url=&mode=&format=&offset= |
Trace an image into a vector shape (BSON); offset insets/outsets in pixels |
Commands
pnpm dev # run the proxy (tsx watch)
pnpm dev:web # run the frontend (vite)
pnpm build # compile all packages
pnpm typecheck # typecheck all packages
pnpm test # run the unit tests (vitest)
pnpm lint # lint all packages
Object viewers
The mod page renders each selected object in 3D. Tiles, tokens, cards, and
custom models each have a viewer built on @react-three/fiber, @react-three/drei,
and @react-three/postprocessing, registered per object class and lazy-loaded
so the three.js stack is code-split out of the main bundle.
- Tiles / tokens — extruded from a 2D shape; tokens trace the image's alpha
channel via
/traceto match the artwork's silhouette. - Cards — a thin rounded rect. Deck images are sheets divided into a
NumWidthxNumHeightgrid; the face/back sprite is selected byCardIDfrom the containing deck's config. - Custom models — GLTF/OBJ/FBX loaded from
CustomMesh.MeshURL.
The camera fits the object's bounds on load, and back faces are flipped so
they aren't mirrored. See docs/decisions.md for the
rationale behind these choices.
How search works
Steam has no official search API. The proxy fetches the Workshop browse page
(steamcommunity.com/workshop/browse/?appid=286160) and parses the embedded
window.SSR.renderContext JSON (a React Query cache containing a
workshop_browse entry with the results). This is more robust than scraping
the DOM, but Steam can still change the page structure — if search breaks, that
parser is the first place to look.
Notes
STEAM_API_KEYis optional. Search works without it. The mod page works without it too when you arrive from a search result: the frontend passes the item'sfile_url(already present in search results) to/items/:idvia afileUrlquery param, so the proxy downloads the save directly instead of calling the Steam API. Without a key and without afileUrl(e.g. a deep-linked mod page),/items/*returns 500.- Each request fetches fresh; there is no caching by design (client-only tool).
packages/extracthas zero runtime dependencies and runs in browser or Node, ready for a future frontend.