https://cdn.ducket.net/furni-renders/<classname>.png returns the
size-64 room art for any Habbo furni as a transparent PNG. No key, no signup, no
query string — put the URL in an <img> tag. Every image is
pre-rendered from our own .ducket asset bundles and served from R2 at
the edge, so a request is a static file read.
*
immutable · 1y cache
size 64 · colour variants
rendered by ducket.suites
throneOne tag. Nothing to install.
<img src="https://cdn.ducket.net/furni-renders/throne.png" alt="Throne" loading="lazy">
That is the whole API for the common case. The path is the item's classname; the response is a transparent PNG at the item's natural size.
Items are addressed by Habbo classname — the same identifier used in
furnidata. Colour variants carry a * and a variant id:
rare_dragonlamp*3.
encodeURIComponent, not a strict
percent-encoder.
Filenames in the bucket keep a literal *. JavaScript's
encodeURIComponent leaves * alone, which is correct. Encoders
that escape it to %2A — PHP's rawurlencode, Python's
quote with default safe, Ruby's CGI.escape — will
miss every variant and 404. If you use one of those, add * to its
safe/unreserved set.
// correct — literal asterisk survives const url = `https://cdn.ducket.net/furni-renders/${encodeURIComponent(classname)}.png` // rare_dragonlamp*3 → .../rare_dragonlamp*3.png 200 // PHP — rawurlencode() escapes * to %2A, so undo it str_replace('%2A', '*', rawurlencode($classname)) # Python — mark * as safe urllib.parse.quote(classname, safe="*")
%2A is not silently normalised. rare_dragonlamp%2A3.png returns
404; rare_dragonlamp*3.png returns 200.
Habbo ships furni art at several sizes. We publish two of them, for different jobs.
| prefix | what it is | use for | colour variants |
|---|---|---|---|
/furni-renders/ |
Size-64 room visualization — the real display art, drawn from the
.ducket bundle
|
Item pages, listings, anywhere the furni is the subject | Yes |
/catalog-thumbs/ |
The small catalogue icon_* slice |
Dense lists, inline flavour, favicons | No — base classname only |
/catalog-thumbs/ returned 0 hits and /furni-renders/ returned
117. If you are rendering anything colour-dependent, use /furni-renders/.
For icons, strip everything from the * onward and request the base
classname.
A variant is <classname>*<id>. The valid ids for an item come
from furnidata, which lists each variant as its own classname.
*1
*3
*6
*8.ducket manifest carries a colour table under cl, but that is a
table of tints, not a list of valid variants. An id absent from it renders
untinted, which is a real, shippable colour — the silver dragon lamp
(*3) is exactly that case. Enumerate variants from
furnidata.
Around 3 in 120 sampled variants have no render. Treat a variant 404 as fall back to the base classname rather than an error.
A lot of furni moves. The PNG you get from /furni-renders/ is
one frame of that loop — the animation lives in the .ducket
bundle, and today you play it yourself.
rare_dragonlamp is a furniture_multistate item with a
furniture_animated visualization: 7 layers, directions
[2, 4], and two resting states.
| state | what it is | loop | moving layer |
|---|---|---|---|
0 |
Off — unlit | 1 frame, clampToEnd |
none (all 7 layers hold frame 0) |
1 |
On — lit | 8 ticks, unbounded |
layer 5 cycles sprites [1,2,3,4] — the flame |
When there is more than one resting state, the renderer picks the one showing the most visible layers — ties broken by frame count, then by higher id. For the dragon lamp that is state 1, so our still is the lit lamp. Other renderers pick state 0 and show it dark. Neither is wrong; it is a choice about which state represents the item, and ours is the one that looks like the thing people trade.
Decode the bundle, composite the "on" state to a horizontal sheet, and step it. In this repo that is one call:
// node — writes a 260x134 sheet of 4 cells npx tsx scripts/dev/gen-furni-spritesheet.ts 'rare_dragonlamp*1' // or call the compositor directly import { compositeManifestSpriteSheetToRaster } from "ducket/compositeManifestSprite" const sheet = compositeManifestSpriteSheetToRaster(atlas, manifest, "rare_dragonlamp*1", createCanvas, 64) // → { canvas, frameW: 65, frameH: 134, frameCount: 4, durationMs: 480 }
Then the CSS is mechanical — steps(frameCount) across the sheet width:
.lamp { width: 65px; /* frameW */ height: 134px; /* frameH */ background: url(sheet.png) no-repeat; image-rendering: pixelated; animation: flame 480ms steps(4) infinite; } @keyframes flame { to { background-position: -260px 0; } /* frameW x frameCount */ }
/furni-renders/rare_dragonlamp*1.apng returns 404 today. The
filename is reserved (see not live yet) and the generator is
already parameterised for it, but nothing publishes APNGs to the CDN. Until it does,
the sheet above is the supported way to animate furni.
There is no fixed canvas. Each PNG is cropped to the item's own art, so dimensions vary per furni — a rug is wide and flat, a lamp is tall and narrow.
| classname | size |
|---|---|
rare_elephant_statue | 47 × 53 |
rare_globe | 45 × 63 |
rare_fountain | 45 × 74 |
throne | 67 × 95 |
rare_daffodil_rug | 134 × 87 |
rare_dragonlamp*1 | 65 × 134 |
Do not hard-code width/height from one item onto all of them.
Give the image a box and let it sit inside — max-width:100% with
height:auto, or a fixed-size flex cell with
object-fit:contain. If you scale up, scale by whole numbers and set
image-rendering:pixelated so the pixel art stays crisp.
.furni { width: 100%; height: auto; image-rendering: pixelated; }
If you want to draw furni yourself — animate it, pick a state, composite it into a room —
request the .ducket bundle instead of a PNG. It carries every sprite, the
draw definition, and the colour tables in one file.
https://cdn.ducket.net/hof_furni/<revision>/<classname>.ducket
# example — 3.5 KB
https://cdn.ducket.net/hof_furni/45512/rare_globe.ducket
The revision is Habbo's asset revision for that item, so bundles are content-addressed and safe to cache forever. There are 16,922 bundles and 13,558 catalogue icons published. Format is documented in ducket/SPEC.md: a 20-byte header, a gzipped CBOR manifest, and a WebP atlas.
Every asset is served public, max-age=31536000, immutable with an
ETag, behind the edge cache. URLs are stable: the same classname always
names the same image. Cache them, hot-link them, put them in a CDN of your own.
A missing asset returns 404 with a text/plain body of
not found. It never returns a placeholder image, so an
<img> for an unknown classname shows your alt text.
Handle it with an onerror fallback to /catalog-thumbs/ or the
base classname.
No key and no rate limit today. It is a static bucket at the edge, so normal page traffic is fine. Do not use it as a bulk scraping target — if you want the whole corpus, clone the bundles from the repo pipeline instead.
The generator is parameterised on more than the classname, and the filename format reserves room for the rest. Only the defaults are published today — everything in this table 404s right now. It is here so you can see the shape the API is growing into, not so you can call it.
| param | default | filename tag | status |
|---|---|---|---|
size | 64 |
__s32 | reserved |
state | auto |
__st<n> | reserved |
direction | auto |
__d<n> | reserved |
frame | 0 |
__f<n> | reserved |
animated | false |
.apng | reserved — see animation |
Non-default values are tagged onto the filename after a double underscore, so parameterised
renders can share one prefix with the plain ones —
rare_globe__s32.png, rare_globe__st1_f2.png,
rare_globe.apng. The defaults deliberately produce a bare
<classname>.png, which is why the common case is a string concat.
RenderParams in
scripts/dev/gen-furni-renders.ts is the single definition shared by the
static generator and any future dynamic endpoint.
| method | path | returns |
|---|---|---|
GET |
/furni-renders/<classname>.png |
Size-64 render · image/png |
GET |
/catalog-thumbs/<classname>.png |
Catalogue icon · image/png |
GET |
/hof_furni/<rev>/<classname>.ducket |
Asset bundle · application/octet-stream |
GET |
/healthz |
Service + R2 binding check · application/json |
GET |
/version |
Build SHA and timestamp · application/json |
Host is cdn.ducket.net. HEAD works everywhere GET
does. OPTIONS returns 204 with CORS headers. Anything else returns 405.
Avatars are a separate service with a real query API — see the imager docs.