furni as
PNGs, from a
URL.

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.

GET · no auth CORS * immutable · 1y cache size 64 · colour variants rendered by ducket.suites
furni-renders
Throne rendered at size 64 throne
67 × 95
Rare globe rendered at size 64 rare_globe
45 × 63

quickstart

One 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.

addressing a furni

Items are addressed by Habbo classname — the same identifier used in furnidata. Colour variants carry a * and a variant id: rare_dragonlamp*3.

Encode the classname with 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.

two image sets

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
Icons have no colour variants. In a sample of 120 variant classnames, /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.
same item, both sets live requests · natural size
Rare fountain, size-64 render furni-renders
45 × 74
Rare fountain, catalogue icon catalog-thumbs
26 × 34

colour variants

A variant is <classname>*<id>. The valid ids for an item come from furnidata, which lists each variant as its own classname.

rare_dragonlamp
Dragon lamp, variant 1 *1
blue
Dragon lamp, variant 3 *3
silver
Dragon lamp, variant 6 *6
gold
Dragon lamp, variant 8 *8
bronze
Do not read variant ids out of the bundle's tint table. A .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.

animation

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 · state 1 · 4 frames · 480ms animating · sprite sheet + CSS steps
*1
blue
*3
silver
*6
gold
*8
bronze

what the bundle actually contains

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
Eight ticks, four images. A tick is not a frame. Layers hold the same sprite for several ticks, so walking the loop and keeping only the frames that actually change turns those 8 ticks into 4 distinct frames — a quarter of the sheet, identical motion. At 120 ms per frame that is a 480 ms loop.

why the still render is lit

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.

playing it yourself

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 */
}
There is no animated endpoint yet. /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.

output dimensions

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_statue47 × 53
rare_globe45 × 63
rare_fountain45 × 74
throne67 × 95
rare_daffodil_rug134 × 87
rare_dragonlamp*165 × 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;
}

raw asset bundles

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.

caching and failure

caching

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.

failure

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.

be reasonable about volume

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.

not live yet

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
size64 __s32reserved
stateauto __st<n>reserved
directionauto __d<n>reserved
frame0 __f<n>reserved
animatedfalse .apngreserved — 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.

endpoints

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.