https://i.ducket.net/avatar.png renders a Habbo-style avatar and
returns a transparent PNG. No key, no signup, no SDK — put the URL in an
<img> tag. It takes the same query parameters as
habbo.com/habbo-imaging/avatarimage and returns the same pixel
dimensions, so swapping the host in an existing tag is the entire migration.
*
habbo-imaging drop-in
animated (APNG)
rendered by ducket.suites
One tag. Nothing to install.
<img src="https://i.ducket.net/avatar.png?figure=hd-180-1.ch-255-66.lg-280-110.sh-305-62.hr-828-61&size=m" width="64" height="110" alt="Avatar" loading="lazy">
The figure string is a Habbo figure/look — a dot-separated list of
<part>-<set>-<colour> triples. Build one in the
wardrobe, or copy any figure string you already have.
Two ways to say which avatar you want. Give exactly one.
| Param | What it does |
|---|---|
figure |
A figure string, e.g.
hd-180-1.ch-255-66.lg-280-110.sh-305-62.hr-828-61.
Rendered exactly as given. Max 4 KiB.Known gap: parts in the mc (misc / wrist) slot are
not drawn yet — a figure carrying one renders correctly in every other
respect, but that item is missing. The assets are present; the bug is in
the draw path and is being fixed.
|
user |
A Habbo username, e.g. ?user=habbo. The name is resolved to that
player's current figure through Habbo's public user API and the mapping is
cached for about an hour, so a wardrobe change shows up within the hour.
|
If you pass both, figure wins and user is
ignored. Neither is a 400: missing required `figure` param.
figure when you already have one. It caches for a year;
user caches for an hour, because the name→figure mapping can change
under you.
Everything below is optional. Unknown query parameters are ignored, and — this is the
point of the service — unknown values for action,
gesture and size are tolerated rather than rejected, so a
habbo-imaging URL you already have in production keeps working.
| Param | Accepted values | Default | Notes |
|---|---|---|---|
direction |
0–7 |
2 |
Body facing, clockwise from north. 0 N, 2 E,
4 S, 6 W. Out-of-range integers wrap
(9 → 1, -1 → 7);
non-numeric falls back to the default.
|
head_direction |
0–7 |
= direction |
Head facing, independent of the body. Ignored when
action=lay (Habbo ignores it too).
|
size |
s · m · b · l |
l |
Output canvas. b is an alias for m. Anything
else — n, xl, 1, garbage — renders
at m, matching Habbo. See
output dimensions.
|
action |
Comma-separated list of:std wlk wav sit
lay spk respect blow
laugh dance sleepheld-item poses, each taking an id: crr=<id> drk=<id>
sig=<id>aliases: carry use sign
|
std |
Combine poses: action=sit,wav. Three tokens carry a held
item and take an id: crr=6 (carry),
drk=6 (drink), sig=5 (hold up a sign).
carry / use / sign are our own
aliases for the first three and take the same id. An id that isn't a
number, or is out of range, falls back to the empty-handed pose rather
than erroring. Unrecognised tokens are dropped silently; if nothing
recognised is left, the avatar stands. wlk renders a walk
pose — mid-stride, not standing.The id is an hh_human_item sprite id — the
<id> in h_crr_ri_<id>_<dir>_0,
which is exactly what habbo-imaging accepts. It is not a
“logical carry id”: the <param> table in
actions.xml translates those for the game client, and
neither habbo nor we apply it here. An id with no sprite draws the
pose with an empty hand, same as habbo.
|
gesture |
std sml sad agr
srp spk eyb
|
std |
Facial expression, one token only. Habbo's neutral value
gesture=nor — and any other unrecognised value — maps to
std instead of erroring.
|
headonly |
1 0 true false
yes on |
0 |
Crop to the head. Its own canvas, not a crop of the body one. |
animated |
1 0 true false
yes on |
0 |
Return the pose's animation loop as an APNG instead of a single frame.
Still Content-Type: image/png. Safe to set on any request —
a pose that doesn't animate just returns its normal still. See
animation.
|
frames |
1–24 |
full loop |
Cap the number of captured frames. Only meaningful with
animated=1. Values above 24 clamp to 24; 0 or
absent means "however long the loop actually is".
|
Add animated=1 and the pose comes back as an
APNG — the animated PNG format — instead of a single frame. It is
still a PNG: same Content-Type: image/png, same signature, same
dimensions. Put it in an <img> and it plays. A browser that
somehow doesn't support APNG shows the first frame as an ordinary still.
<img src="https://i.ducket.net/avatar.png?figure=hd-180-1.ch-255-66.lg-280-110.sh-305-62.hr-828-61&size=m&action=wav&animated=1" width="64" height="110" alt="Waving">
Most don't. The animation data behind these avatars only defines multi-frame loops
for a handful of actions, and animated=1 on anything else returns the
same still you'd get without it — not a one-frame animation, the actual still, from
the actual same cache entry. So you can set it unconditionally and let the pose
decide.
| Action | Frames | What moves |
|---|---|---|
wav |
2 | The raised arm. |
spk |
2 |
The mouth. Also animates as gesture=spk, and it's the one
that still animates under headonly=1.
|
respect |
2 | The salute arm. |
blow |
2 | The hand and the face. |
| everything else | 1 |
std, sit, lay, sleep,
laugh, sig, crr,
drk, use, carry,
sign, dance and every
gesture are single poses in the data. You get a still.
|
Frames play at 166.67 ms each and loop forever, which is the
rate habbo-imaging uses for its own animated output. Combining poses
works the way you'd expect — action=sit,wav animates the wave while
sitting. And a pose that animates a body part your crop excludes collapses back to a
still: action=wav&headonly=1 is one frame, because the arm isn't in
the head canvas.
x-imager-frames — the number of frames in the
body. 1 means you got a still. It's the quickest way to check whether a
URL animated without decoding the image.
animated=1 and the same URL without it are different images and cache
independently, so asking for both doubles the first-render cost for that avatar.
Pick one per surface rather than requesting both.
The canvas is fixed per size and pose — the PNG does not shrink-wrap the
figure, so a hat or a raised arm never changes the output size or moves the character
within it. These are Habbo's exact numbers.
size |
Full body | headonly=1 |
action=lay |
|---|---|---|---|
s |
33 × 56 |
27 × 30 |
56 × 33 |
m · b · anything unrecognised |
64 × 110 |
54 × 62 |
110 × 64 |
l · no size given |
128 × 220 |
108 × 124 |
220 × 128 |
l is a pixel-exact 2× nearest-neighbour upscale of
m — no resampling, no blur. Ask for l
rather than rendering m and doubling it in CSS.
lay is the one landscape pose; the canvas rotates with it.size gives you l. An unrecognised
size gives you m. That asymmetry is Habbo's, and it is
preserved deliberately.
Same parameter names, same values, same output dimensions. Change the host and the path; leave the query string alone.
<!-- before --> <img src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-180-1.ch-255-66.lg-280-110.sh-305-62.hr-828-61&direction=2&head_direction=3&size=l&gesture=sml"> <!-- after --> <img src="https://i.ducket.net/avatar.png?figure=hd-180-1.ch-255-66.lg-280-110.sh-305-62.hr-828-61&direction=2&head_direction=3&size=l&gesture=sml">
Two differences worth knowing before you switch:
/avatar.png, not /avatarimage.
/avatar also works.
Use the dimensions from the table above as explicit
width/height attributes. The browser reserves the box before
the PNG arrives, so nothing reflows when a row of avatars loads.
These are pixel art. Default browser smoothing turns a scaled avatar into mush. Ask
for the size you actually want; when you genuinely have to scale in CSS,
use an integer multiple and turn interpolation off.
.avatar {
image-rendering: pixelated;
image-rendering: crisp-edges; /* firefox */
}
Add loading="lazy" to every avatar on a page that shows more than a
handful. This matters more than it sounds: a page that fires roughly fifteen
uncached avatars simultaneously is enough to push renders into the queue and
start tripping the fallback. Lazy loading spreads that over the scroll instead of
landing it all in the first 200 ms.
<img class="avatar" loading="lazy" width="64" height="110" src="https://i.ducket.net/avatar.png?figure=…&size=m" alt="Avatar for Alice">
Every response is cached against the exact parameter set. Adding a cache-buster, reordering nothing but rewriting the figure string, or varying an ignored parameter creates a fresh render each time. Build the URL once per avatar and reuse it.
There is no key and no published rate limit. Cached avatars are cheap; uncached ones cost a real browser render. Don't loop over thousands of figures, and don't hammer cold URLs in parallel — you'll mostly be rate-limiting yourself into the fallback. If you need bulk rendering, open an issue.
A rendered PNG is addressed by a key covering every parameter plus the renderer
version, so the bytes behind a given ?figure= URL cannot change without
the URL changing. That makes them safe to treat as immutable, and we do.
| Response | Cache-Control |
|---|---|
?figure= PNG |
public, max-age=31536000, immutable,
stale-while-revalidate=86400 |
?user= PNG |
public, max-age=3600, stale-while-revalidate=86400 |
| fallback redirect | public, max-age=60 |
For you that means: a ?figure= URL is worth putting in a CDN, an
<img>, or a saved template and forgetting about. A
?user= URL is not — it tracks a player's current look, so it goes stale
on purpose. Responses carry an ETag and the endpoint answers
HEAD and OPTIONS;
Access-Control-Allow-Origin: * is set on everything, so canvas and
fetch use are fine.
animated=1 is part of the key — but only when the pose can actually
animate. Ask for action=sit&animated=1 and you land on the exact
cache entry action=sit already has, because the bytes are identical.
Ask for action=wav&animated=1 and you get a second entry alongside
the still. That's the whole reason animated=1 is safe to set on
everything: it only costs a render where it changes the picture.
A first, uncached render usually takes one to three seconds; a cached one is tens of milliseconds. Cold renders run on shared infrastructure, so that first number moves around — treat it as "seconds, occasionally worse", and lean on the cache rather than rendering a fresh figure on a critical path.
An avatar URL usually lives in an <img>, where a 5xx is a broken
image on someone else's page. So render failures don't return one. If we can't render
— the renderer is saturated, a figure references an asset we can't load, a username
won't resolve — the response is a 302 to
habbo.com/habbo-imaging/avatarimage carrying the translated
parameters. Your <img> follows it and paints a real avatar at the
same dimensions.
$ curl -sI "https://i.ducket.net/avatar.png?user=not-a-real-user&size=m" HTTP/2 302 location: https://www.habbo.com/habbo-imaging/avatarimage?user=not-a-real-user cache-control: public, max-age=60 x-imager-fallback: user-unresolved
The x-imager-fallback header names the reason, which is the thing to log
if you're monitoring: user-unresolved, busy,
rate-limited, no-budget, render-failed,
render-failed-cached, unexpected. Redirects are cached for
60 seconds only, so a transient hiccup doesn't pin an avatar to Habbo for the day.
<img> always does. If yours doesn't, a fallback looks like a
missing image.
A fallback on an animated=1 request still works, and mostly still
animates. animated and frames are our extension, so they
aren't forwarded — but Habbo animates wav and wlk on its
own, unconditionally, and returns a still for every other pose. Which means a
fallback gives you an animation exactly where we would have, a still where we would
have, and the right dimensions either way. The only visible difference is weight:
Habbo's animated output is far larger than ours.
A malformed request is still an error — that's your bug, and there is nothing sensible
to redirect to. Missing figure and user, or a figure over
4 KiB, returns 400 with a plain-text reason.
| Route | Returns |
|---|---|
GET /avatar.png |
The avatar PNG. GET /avatar is the same route. |
GET /render |
The HTML page the renderer screenshots, with the same query parameters. Useful for debugging what a figure actually composites to. |
GET /healthz · /version |
JSON build and health metadata. |
Rendering is done by Ducket Suites, the same open-source isometric renderer this site documents. If you want the avatars animated, walking, and in a room rather than baked to a PNG, that's the library.