Interactive pixel art generator powered by Spooled Cloud
Live Demo • Quick Start • How It Works • Deployment • API
SpriteForge is a real-time pixel art sprite generator that demonstrates Spooled Cloud workflows, workers, schedules, retries, and realtime events.
Releases: latest published release
Try it live: example.spooled.cloud
| Feature | How It's Used |
|---|---|
| Workflows (DAG) | Each sprite is a workflow with parallel frame jobs + dependent assemble job |
| Workers | 3 worker types process frames, assemble sprites, and generate public sprites |
| Realtime + Reconciliation | Spooled WebSocket events are forwarded to the browser over SSE; scoped polling recovers missed updates |
| Automatic Retries | "Chaos mode" simulates transient failures on frame jobs so Spooled retries are visible |
| Schedules | "Sprite of the Minute" generated via cron schedule |
- Node.js 22+
- A Spooled Cloud API key (or self-hosted Spooled)
SpriteForge needs a Starter plan or higher. It will not do its job on a Free key. Limits checked against spooled-backend/src/config/plans.rs (Free / Starter):
| Resource | SpriteForge uses | Free | Starter |
|---|---|---|---|
| Workers | 3 (frames, assemble, public) | 1 | 5 |
| Queues | 3 (spriteforge-frames, -assemble, -public) |
2 | 10 |
| Workflows (counted while pending or running) | 1 per Forge click | 0 (disabled) | 5 at the same time |
| Active jobs (pending + processing + scheduled) | up to 13 per Forge (12 frames + assemble), plus 1 per minute from the public schedule | 10 | 500 |
| Schedules | 1 (public sprite) | 1 | 10 |
| Jobs per day | the public schedule alone is 1,440/day | 1,000 | 10,000 |
What happens on Free: the web UI still comes up, but only the first worker registers (the others get 429 QUOTA_EXCEEDED, logged and shown under workers in /health), so /health answers 503 (ok: false, missingWorkers) and POST /api/forge answers 503 workers_unavailable. When the workers do run but the plan has no workflows (Free), POST /api/forge answers 403 feature_disabled ("workflows is not available on the Free plan"), which the app relays as such instead of UNKNOWN_ERROR. On Starter the workflows limit counts workflows that are pending or running, so a sixth simultaneous Forge gets 429 QUOTA_EXCEEDED until one finishes; finished workflows no longer count. Set ENABLE_PUBLIC_SCHEDULE=false if you want to keep the daily job budget for interactive use.
A worker that cannot register for a transient reason (API 5xx, timeout, network) is retried in the background with exponential backoff (2 s up to 60 s), each try on a fresh SpooledWorker; plan-limit and other 4xx refusals are retried every five minutes, so an upgrade or a recovered API is picked up without a restart. /health and Forge turn healthy again as soon as the frames and assemble workers run.
Workers register with stable ids (<WORKER_ID_PREFIX>-frames|assemble|public), so a restart reuses its rows instead of holding the plan worker cap for about two minutes. Worker ids are a global primary key across all Spooled organizations, so the default prefix is spriteforge-<first 8 hex of sha256(SPOOLED_API_KEY)>, not a shared name; if the API still answers 409 (the id belongs to another organization) the worker registers without an id and the server generates one (logged once; each restart then adds a row until the old one expires). Run one replica per prefix (the Kubernetes manifests use one replica and the Recreate strategy for that reason).
# Clone the repository
git clone https://github.com/spooled-cloud/spooled-example-spriteforge.git
cd spooled-example-spriteforge
# Copy environment template
cp .env.example .env
# Edit .env and add your API key
# SPOOLED_API_KEY=sp_live_your_key_here
# Install dependencies
npm ci
# Load .env into this shell (Node does not auto-load it), then start
set -a
. ./.env
set +a
npm run dev# Using Docker Compose (recommended)
cp .env.example .env
# Edit .env with your API key
docker compose up --buildOr run directly:
docker run -p 3000:3000 \
-e SPOOLED_API_KEY=sp_live_your_key \
ghcr.io/spooled-cloud/spooled-example-spriteforge:<release-tag>┌─────────────────────────────────────────────────────────────────────────┐
│ SpriteForge │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Browser Server Spooled Cloud │
│ ─────── ────── ───────────── │
│ │
│ ┌──────────┐ click ┌──────────────┐ create ┌──────────────┐ │
│ │ UI │ ────────► │ Node.js │ ────────► │ Workflow │ │
│ └──────────┘ │ Server │ │ + Jobs │ │
│ ▲ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ │ SSE ┌─────┴─────┐ Workers│ │
│ │ │ │ ▼ │
│ ┌────┴─────┐ ┌─────┴───┐ ┌─────┴───┐ ┌──────────────┐ │
│ │ Events │ ◄─── │ frame │ │ assemble│ ◄────── │ Process │ │
│ │ Stream │ │ worker │ │ worker │ │ & Return │ │
│ └──────────┘ └─────────┘ └─────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
When you click "Forge Sprite":
- Workflow Created: A DAG workflow with N+1 jobs
- Frame Jobs (parallel): Each generates one animation frame
- Assemble Job (depends on all frames): Combines frames into sprite
- Events Stream: Job status updates flow to your browser in real-time
START ──► [frame-0] [frame-1] [frame-2] ... [frame-N] ──► [assemble] ──► DONE
│ │ │ │ │
└─────────┴─────────┴──────────────┘ │
all must complete before │
▼
sprite rendered
SPOOLED_API_KEY is the only variable required by the application. The production Compose stack additionally requires CLOUDFLARE_TUNNEL_TOKEN.
See DEPLOY.md#environment-variables for the canonical variable table and .env.example for a copyable template.
📘 Full guide: See DEPLOY.md for comprehensive deployment instructions.
The CI workflow publishes multi-architecture (linux/amd64, linux/arm64) images to GHCR. Pushes to main publish latest and a short commit SHA tag; release tags publish the matching version and refresh latest:
ghcr.io/spooled-cloud/spooled-example-spriteforge:latest
ghcr.io/spooled-cloud/spooled-example-spriteforge:<short-commit-sha>
ghcr.io/spooled-cloud/spooled-example-spriteforge:<release-tag>
The easiest production deployment with Cloudflare Tunnel:
cp .env.example .env
# Edit .env: add SPOOLED_API_KEY and CLOUDFLARE_TUNNEL_TOKEN
docker compose -f docker-compose.prod.yml up -dThis starts:
- SpriteForge container (port 3000 internal)
- Cloudflared tunnel (secure external access)
# Create secret
kubectl apply -f k8s/base/namespace.yaml
kubectl create secret generic spooled-example-spriteforge-secrets \
--from-literal=SPOOLED_API_KEY=sp_live_your_key \
--namespace spooled-example-spriteforge
# Deploy
kubectl apply -k k8s/overlays/productionIncluded resources:
- Deployment with liveness/readiness probes
- Service (ClusterIP)
- Ingress (nginx + cert-manager TLS)
- HorizontalPodAutoscaler pinned to one replica (documents the CPU target but does not scale above one)
- PodDisruptionBudget
- ServiceAccount + ConfigMap
These are high-level manual starting points, not tested one-click deployments. This repository does not include Railway, Render, or DigitalOcean manifests; confirm each platform's Docker build, start command, health check, port, persistence, and secret settings before calling a deployment production-ready.
| Platform | Starting point |
|---|---|
| Railway | Connect the repository, select the Docker build, configure health/port settings, and add SPOOLED_API_KEY |
| Fly.io | fly launch → fly secrets set SPOOLED_API_KEY=... → fly deploy |
| Render | Create a Docker Web Service, configure health/port settings, and add SPOOLED_API_KEY |
| DigitalOcean | Create an App from GitHub, confirm Docker/health/port settings, and add SPOOLED_API_KEY |
GET /health
Returns 200 OK when the Spooled API checks are passing and the frames and assemble workers are running; otherwise 503 with ok: false and the missing workers under missingWorkers (the web UI itself still serves). HEAD answers like GET without a body, here and on the static files, so monitors that probe with HEAD work.
POST /api/forge
Content-Type: application/json
{
"sessionId": "uuid",
"seed": "my-sprite",
"paletteName": "neon",
"animation": "walk",
"frameCount": 8,
"width": 24,
"height": 24,
"failChance": 0.1
}
GET /api/events?sessionId=<uuid>
Streams realtime events:
hello- connection established; includes palettes, queues, session info, and the latest public spriteping- 15-second keepalivespooled- job lifecycle or global Spooled eventspublic.sprite- new public sprite generatedserver.realtime- server-to-Spooled WebSocket state
POST /api/jobs/batch
Content-Type: application/json
{
"sessionId": "uuid",
"jobIds": ["job-id"],
"includeResult": true
}
Returns status for at most 50 job IDs already associated with that in-memory session. The browser polls this endpoint while a forge is active to recover updates or results missed by realtime delivery.
spooled-example-spriteforge/
├── .github/
│ └── workflows/
│ └── ci.yml # CI/CD pipeline
├── k8s/
│ ├── base/ # Base Kubernetes manifests
│ └── overlays/
│ ├── development/ # Dev overrides
│ └── production/ # Prod overrides
├── public/
│ ├── index.html # Main HTML
│ ├── styles.css # Styles
│ └── app.js # Frontend logic
├── server/
│ ├── server.mjs # HTTP server + workers
│ └── spriteforge.mjs # Pixel art generation
├── .env.example # Environment template
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Local Docker development
├── package.json
└── README.md
npm run dev # Start the server (same command as npm start; no file watcher)
npm start # Start production serverYou can run against:
- Spooled Cloud (recommended): Sign up at spooled.cloud
- Self-hosted Spooled: See spooled-backend
For self-hosted:
SPOOLED_BASE_URL=http://localhost:8080
SPOOLED_WS_URL=ws://localhost:8080The Kubernetes manifests pin both the Deployment and HPA to one replica. SSE clients, job-to-session routing, workflow mappings, rate-limit buckets, and the latest public sprite are process-local.
A multi-replica deployment therefore needs more than worker scaling: use sticky sessions for browser requests and move routing/state to shared infrastructure (for example Redis), or split the stateless HTTP frontend from singleton event-routing/schedule responsibilities.
When 100+ users test the demo, jobs and workflows accumulate. SpriteForge handles this automatically:
1. Job Expiration and Retention
- Interactive workflow jobs receive an
expiresAttimestamp 24 hours in the future by default (JOB_RETENTION_HOURS). - The Spooled backend cleanup task runs every five minutes. Explicit expiration removes pending, scheduled, failed, or dead-letter jobs after
expiresAt; completed/cancelled jobs use organization retention instead.
2. Workflow Cleanup
- Completed, failed, or cancelled workflows use organization job-retention limits.
- Current built-in defaults are Free 3 days, Starter 14 days, Pro 30 days, and Enterprise 90 days; organization overrides may change them.
3. In-Memory Cleanup
- Session mappings (for routing events) are cleaned every 10 minutes
- Mappings older than 1 hour are automatically removed
4. Queue Reuse
- Queues (
spriteforge-frames,spriteforge-assemble,spriteforge-public) are reused by all users - No queue accumulation occurs
Tip: For high-traffic public demos, set
JOB_RETENTION_HOURS=1in your.envfile.
Monitor via health endpoint:
curl http://localhost:3000/health
# Returns stats: { activeSessions, trackedJobs, trackedWorkflows, totalWorkflows, totalJobs }SpriteForge declares @spooled/sdk as ^1.4.1 in package.json; the committed package-lock.json records the exact SDK version reproduced by npm ci. Review and record both for each release, but do not force the SDK version to equal the SpriteForge application version. It uses SpooledClient, SpooledWorker, and WebSocket SpooledRealtime; workflows are created with client.workflows.create(), dependency results are read with client.workflows.jobs.getDependencies() and client.jobs.get(), and the public schedule uses client.schedules.
When deploying publicly:
- Use a dedicated Spooled organization for the demo.
- Set plan limits and retain the app’s built-in per-IP request limits.
- The live event stream (
GET /api/events) is capped toMAX_SSE_CLIENTS(default 500) per process andMAX_SSE_PER_IP(default 30, because many visitors share one NAT address and each open tab holds a stream) per client IP; over the cap the server answers503 sse_capacity/429 sse_per_ip_limitwithRetry-After. Per-IP limits trustcf-connecting-ip/x-real-ip/x-forwarded-forby default; setTRUST_PROXY=false(also0,no,off) if the port is reachable without Cloudflare or an ingress in front.sessionIdmust be 8-64 URL-safe characters on/api/forgeand/api/jobs/batchtoo (400 invalid_session_idotherwise). - Keep
SPOOLED_API_KEYserver-side; the browser only calls SpriteForge endpoints. - Every response carries
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, aPermissions-Policyand aContent-Security-Policy(this origin plus the Google Fonts stylesheet and the Cloudflare Web Analytics beacon a zone may inject; no inline script or style; no framing).CONTENT_SECURITY_POLICYreplaces the policy (offsends none); see DEPLOY.md. HSTS is left to your proxy.
Contributions are welcome! Please read our Contributing Guide first.
# Fork the repo, then:
git checkout -b feature/my-feature
# Make changes
git commit -m "feat: add my feature"
git push origin feature/my-feature
# Open a PRApache 2.0 © Spooled Cloud
Built with ⚡ by Spooled Cloud
