npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 (a THREE.Group you 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 SceneCardManager for 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 (a THREE.Group); animate it with whatever you bring (motion, gsap, three's AnimationMixer, 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 add

three 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 a console.warn
  • unknown config.kind → effect is skipped with a console.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 back

setCardBackface 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 scene
  • card.id, card.name, card.persona — for your bookkeeping
  • card.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:

  • persona groups variants of the same subject
  • id identifies 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 in holo/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 .ts string modules.
  • Semantic versioning: breaking public API or effect-schema changes require a new major version.

License

MIT