avatars as
PNGs, from a
URL.

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.

GET · no auth CORS * habbo-imaging drop-in animated (APNG) rendered by ducket.suites
avatar.png
Avatar rendered at size=l, 128×220 size=l
128 × 220
Same avatar, head only, 54×62 headonly=1
54 × 62

quickstart

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">
result live request · rendered at natural size
Avatar rendered at size=m, 64×110 64 × 110

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.

addressing an avatar

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.

?user=habbo
Avatar for the Habbo username 'habbo' user=habbo
64 × 110
Prefer 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.

parameters

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 07 2 Body facing, clockwise from north. 0 N, 2 E, 4 S, 6 W. Out-of-range integers wrap (91, -17); non-numeric falls back to the default.
head_direction 07 = 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 sleep
held-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 124 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".

poses

action · gesture · headonly
Standing std
64 × 110
Waving action=wav
64 × 110
Sitting action=sit
64 × 110
Walking action=wlk
64 × 110
Smiling gesture=sml
64 × 110
Head only headonly=1
54 × 62
Lying down action=lay
110 × 64

facing

direction · head_direction
Facing north direction=0
N
Facing east direction=2
E · default
Facing south direction=4
S
Facing west direction=6
W
Body south, head east 4 + head 2
body S, head E

animation

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">
animated=1 live request · these should be moving
Waving, animated action=wav
2 frames
Talking, animated action=spk
2 frames
Respect, animated action=respect
2 frames
Sitting — a pose with no animation action=sit
1 frame · still

which poses actually animate

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.

Every response carries 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 URLs are separate cache entries. A URL with 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.

output dimensions

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
s · m · l — same figure, natural size
size=s, 33×56 size=s
33 × 56
size=m, 64×110 size=m
64 × 110
size=l, 128×220 size=l
128 × 220

replacing habbo-imaging

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:

embedding on your page

set width and height

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.

if you scale, scale by whole numbers

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 */
}

lazy-load when there are many

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">

keep URLs stable

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.

be reasonable about volume

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.

caching and failure

caching

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.

failure never breaks your image

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.

Follow redirects. Most HTTP clients do by default; <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.

endpoints

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.