@fettstorch/card-engine
v1.0.0
Published
Three.js-based engine for rendering trading-card-style 3D cards with composable, schema-versioned effects.
Readme
@fettstorch/card-engine
A Three.js engine for rendering trading-card-style 3D cards with composable, schema-versioned effects.
You bring your own art and your own scene. The engine gives you:
- A
Card(aTHREE.Groupyou can add to any scene) - A config-driven effect pipeline — texture, normal map, holographic, parallax, shockwave, clearcoat, iridescence, baseColor
- A project-wide backface image via
setCardBackface— set it once, every card uses it - A scene-agnostic
SceneCardManagerfor routing per-frame ticks and pointer interactions - A built-in 3D card mesh and frame texture, so you have a working baseline before you've drawn anything
What it deliberately does not ship:
- Animations — the engine exposes
card.object(aTHREE.Group); animate it with whatever you bring (motion, gsap, three'sAnimationMixer, manual lerp). - A scene, camera, renderer, or input system — those are your application's choices.
- Card art — only the card mesh and the built-in frame textures ship with the package. Bring your own URLs.
Install
bun add @fettstorch/card-engine three
# or: npm install / pnpm add / yarn addthree is a peer dependency (>=0.180 <1). Your bundler needs to support Vite-style ?url and ?raw imports — Vite, webpack 5, rollup, and esbuild all do (see Limitations if that's a constraint).
Quickstart
import { Card, SceneCardManager, EFFECT_SCHEMA_VERSION } from '@fettstorch/card-engine'
import { Clock, Raycaster, Vector2 } from 'three'
// 1. Build a card from a config
const card = await Card.create({
id: 'julian-common',
name: 'Julian',
persona: 'julian',
front: [
{
kind: 'config',
effects: [
{ kind: 'texture', version: EFFECT_SCHEMA_VERSION, url: '/julian.png' },
{ kind: 'clearcoat', version: EFFECT_SCHEMA_VERSION },
],
},
{ kind: 'preset', preset: 'frame' },
],
})
// 2. Add it to your scene
scene.add(card.object) // card.object is a THREE.Group
// 3. Track it for runtime signals
const manager = new SceneCardManager()
manager.add(card)
// 4. Drive ticks from your render loop
const clock = new Clock()
function loop() {
requestAnimationFrame(loop)
manager.update({
delta: clock.getDelta(),
elapsed: clock.getElapsedTime(),
})
renderer.render(scene, camera)
}
loop()
// 5. Forward pointer raycasts so interactive effects (e.g. shockwave) can fire
const raycaster = new Raycaster()
canvas.addEventListener('pointerdown', (event) => {
const rect = canvas.getBoundingClientRect()
const ndc = new Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
)
raycaster.setFromCamera(ndc, camera)
const hit = raycaster.intersectObject(card.object, true)[0]
if (hit) manager.handleIntersection(hit)
})Concepts
CardConfig
type CardConfig = {
id: string // unique card id (e.g. "julian-common")
name: string // human-readable display name — not unique across variants
persona: string // semi-identifier — see "Persona vs id" below
front: LayerConfig[] // layers, bottom → top
}LayerConfig
Each front layer is either a named preset or an inline list of effects.
type LayerConfig = { kind: 'preset'; preset: 'frame' } | { kind: 'config'; effects: EffectConfig[] }Layers stack bottom to top. Every layer above the base gets transparent: true so PNG alpha reveals what's beneath. Use multiple config layers to get "layered holographic" cards — separate background, hologram strip, and foreground silhouette, all with their own effects.
EffectConfig
A discriminated union; every entry carries an explicit version: number.
| kind | what it does |
| ------------- | ----------------------------------------------------- |
| texture | sets the base color map |
| baseColor | tints the material |
| normalMap | applies surface roughness via a normal map |
| clearcoat | adds a glossy clearcoat layer |
| holo | rainbow holographic shader, view-angle dependent |
| iridescence | iridescent thin-film effect |
| parallax | parallax-mapped depth (great for fg/bg layered cards) |
| shockwave | wave ripple triggered by card.touch(uv) |
The engine also exports the constant EFFECT_SCHEMA_VERSION — the highest version this engine understands (bound to the package's major version). Your config-emitting code should stamp it on every effect.
Schema versioning (forward compatibility)
EffectConfig.version is required so older engines can detect newer schemas and skip what they don't understand:
config.version > EFFECT_SCHEMA_VERSION→ effect is skipped with aconsole.warn- unknown
config.kind→ effect is skipped with aconsole.warn - everything else renders normally
So a card config can carry a future v3 effect alongside familiar v1 effects, and a v1 engine renders only the v1 ones. Apps degrade gracefully instead of failing to load the whole card.
The package's major version corresponds to EFFECT_SCHEMA_VERSION — a breaking schema change means a new major.
Backface
The front is a per-card layered effect stack. The back is deliberately not — every card in a project shares a single backface image, set once:
import { setCardBackface, Card } from '@fettstorch/card-engine'
setCardBackface('/card-back.jpg') // call once, before creating cards
const card = await Card.create(config) // every card now uses this backsetCardBackface records the image; each Card.create applies it to the shared back material. Because all cards reuse one back material instance, there's nothing per-card to configure — it's a project-level setting, not part of CardConfig.
If you never call it, cards fall back to the back image baked into the bundled card mesh. Skipping it entirely is fine.
Card
Card.create(config) returns a Card. The interesting surface:
card.object: THREE.Group— add this to your scenecard.id,card.name,card.persona— for your bookkeepingcard.dispose()— frees materials
You don't usually call the runtime methods (update, touch) directly; let the manager do it.
SceneCardManager
Tracks a set of cards and routes runtime signals to them. The manager is scene-agnostic — it doesn't know how you render or detect input. You call two methods at the right moments:
const manager = new SceneCardManager()
manager.add(card) // start tracking
manager.remove(card) // stop and dispose the card
// in your render loop, every frame:
manager.update({ delta, elapsed })
// in your pointer handler, after raycasting:
manager.handleIntersection(intersection)handleIntersection walks up the intersection's parent chain to find a tracked card, then calls card.touch(uv). It's a no-op if the hit's outside any tracked card or has no uv.
You stay responsible for adding/removing card.object to/from your scene graph — the manager only routes signals.
ASSETS
The engine ships its own card mesh and frame texture. Their URLs are exposed so you can reuse them in your own configs without duplicating the files into your static dir:
import { ASSETS } from '@fettstorch/card-engine'
// reuse the engine's normal map on a custom layer:
{ kind: 'normalMap', version: EFFECT_SCHEMA_VERSION, url: ASSETS.cardRoughNormalMap }Available: ASSETS.cardGlb, ASSETS.frameTexture, ASSETS.cardRoughNormalMap.
Persona vs id
Same person, multiple card variants (common, rare, holographic). You want your app to say "Julian owns three Julian cards." Hence the split:
personagroups variants of the same subjectididentifies the specific card
{ id: 'julian-common', persona: 'did:plc:abc123', ... }
{ id: 'julian-rare', persona: 'did:plc:abc123', ... }persona is opaque to the engine — use whatever stable identifier you have. A Bluesky DID is a good candidate; it identifies the subject across account migrations.
Workspace / sibling-package consumers
If you're consuming the engine via npm install, nothing special is needed. If you're consuming it as a workspace dependency from a sibling package (as the playground in this repo does), Vite needs permission to serve files from outside the consumer's root:
// apps/<your-consumer>/vite.config.ts
export default defineConfig({
server: {
fs: { allow: ['../..'] }, // allow the workspace root
},
})Without this, the engine's bundled assets 403 in dev (because they live in the sibling package, outside Vite's default file-serving root).
Limitations
- Bundler-dependent: the engine uses Vite-style
?raw(for GLSL shaders inholo/parallax) and?url(for bundled assets) imports. Any modern bundler handles these — Vite, webpack 5, rollup, esbuild — but pure tsc-only or pure-Node consumers would fail. Supporting those consumers would require inlining the shaders as.tsstring modules. - Semantic versioning: breaking public API or effect-schema changes require a new major version.
License
MIT
