Like petnames, but funkier. Readable handles and higher-entropy codes from six hand-curated pools — and, unusually, the arithmetic that says what they're worth.
Everyone else pairs a plain adjective with a plain animal, so you get happy-otter until the heat death of the universe. These pools pull from martial arts, chess, dinosaurs, internet slang, retro-futurist transport and monster folklore from four continents.
▶ Try it in your browser — roll names, toggle pools, and watch the entropy move.
janissary-cheems-zircon generateHandle() readable, 30.1 bits
graphene-brief-troll-shaolin generateCode() flat draws, 46.3 bits
Zero runtime dependencies. CSPRNG by default. Runs in Node, browsers and React Native.
Because most of them hand you a string and leave you to guess whether it's safe to use as one.
A three-word name feels about as unguessable as a password. It isn't, and the gap is where the bugs live — a share link that looked fine, a room code someone walked into, a reset token that was really a six-character password. This library generates names and tells you what they're actually worth, in the two different senses that matter.
import { codeEntropy, timeToGuess } from 'funkynames';
const e = codeEntropy(3); // 34.7 bits — "roughly a 6-character password"
timeToGuess(e.keyspace, 50, 10_000); // → "15.5 hours"Three words, 27.8 billion combinations, and a determined attacker gets one inside a day. That number is the reason this repo exists.
npm install github:frankie-muller/funkynamesShips compiled ESM plus TypeScript declarations, and the source alongside. prepare builds on install, so a git install gets the same output a registry install would.
Not on npm yet. The API may move before
1.0— pin a commit if you depend on it.
import { generateHandle, generateHandles } from 'funkynames';
generateHandle(); // 'flow-tempest-dingo'
generateHandle({ separator: '_' }); // 'berserker_plushy_rabbit'
generateHandle({ minLength: 3, maxLength: 5 }); // 'rough-lorry-beet'
generateHandles(5); // five distinctThree structured slots — descriptor, colour or action, place or thing — each drawing from a pair of pools. The grammar is fixed, which is what makes it read like a name. No word repeats within a handle.
import { generateCode } from 'funkynames';
generateCode(); // 'squid-tart-cheems-sorcer' (4 words)
generateCode({ words: 6 }); // for a reset token
generateCode({ words: 3 }); // opt in, and read the entropy section firstFlat draws from every pool merged. Any word, any position: more bits per word, less like a name. Four words is the default, deliberately — see below.
import { codeEntropy, handleEntropy, timeToGuess, describeBits, wordsForBits } from 'funkynames';
codeEntropy(4);
// { keyspace: 84_288_924_810_000, bits: 46.26, birthday50: 10_809_592, readable: '84.3 trillion' }
describeBits(46.26);
// 'comparable to a 4-word Diceware passphrase — a reasonable rate-limited credential'
wordsForBits(60);
// { words: 6, achieved: { bits: 69.4, ... } } ← state the security, get the shapeimport { parseName } from 'funkynames';
parseName('flow-tempest-dingo');
// { words: [{ word: 'flow', pools: ['verbtrics'] }, …], looksLikeHandle: true }
parseName('not-a-real-name'); // nullUseful as an input filter — reject typos before spending a database round trip. Not an authorisation check: the word lists are public, so anyone can assemble a valid-looking name. Parsing proves the shape, never the claim.
Entropy is just how many equally likely outcomes, expressed as bits. Each bit doubles the space. The useful part is knowing which question you're asking, because there are two and they have very different answers.
The trap is the birthday paradox. A 30.1-bit handle space holds 1.1 billion names, so it feels like you'd need hundreds of millions of users before a clash.
You need about 39,500. Collisions arrive at the square root of the keyspace, not the total.
That's fine — check uniqueness when you issue one and reroll on a clash. It just tells you when rerolls start happening often enough to notice, and that's the signal to add a slot.
This is the one that matters when the string is a credential. And here's what most generators never mention:
The attacker doesn't want a specific name. They want any live one.
So the odds per guess aren't 1 / keyspace, they're live_targets / keyspace. A large keyspace gets diluted by a large population:
const e = codeEntropy(3); // 34.7 bits, 27.8 billion
timeToGuess(e.keyspace, 50, 1); // one target → '17.6 years'
timeToGuess(e.keyspace, 50, 10_000); // ten thousand → '15.5 hours'Same keyspace, same attacker. Ten thousand outstanding invite links turned 17.6 years into 15.5 hours.
Three levers, and the cheapest one is free:
| Lever | Effect | Cost |
|---|---|---|
| Add a word | +12 bits — 15.5 hours becomes 5.3 years | one word |
| Expire them | linear — halve what's outstanding, double the time | some plumbing |
| Rate limit | linear — 50/sec down to 1/min makes it centuries | ~nothing |
Rate limiting is not a layer on top of entropy. It is half of the calculation. If you take one thing from this library, take that.
| Words | Bits | Verdict |
|---|---|---|
| 3 | 34.7 | roughly a 6-character random password |
| 4 | 46.3 | comparable to a 4-word Diceware passphrase |
| 6 | 69.4 | brute force impractical even unthrottled |
Three words is enough for a name nobody is hunting, and not enough for anything a stranger profits from guessing. The fourth word costs one word and buys eleven bits — the cheapest security here. So four is what you get, and three is what you opt into.
Restricting word length narrows the pool, and the library reports the real number rather than the flattering one:
| Range | Bits at 4 words |
|---|---|
| all | 46.3 |
| 3–5 chars | 42.0 |
| 6–8 chars | 41.5 |
| 7–8 chars | 35.8 |
Long words only looks tidy and costs you 10 bits, because only 497 of 3,030 words qualify.
6 pools · 3,202 words · 3,030 distinct. The pool names are portmanteaus, and they are load-bearing — each one describes a register, not a part of speech. That register is why the output doesn't sound like everything else.
Most generators pair a plain adjective with a plain animal, so you get happy-otter and brave-badger forever. These pools pull from martial arts, chess, dinosaurs, internet slang, retro-futurist transport, workshop tools, root vegetables, and monster folklore from four continents. What comes out is odd on purpose.
Ninja + adjectives. Descriptors with something at stake. Starts in ordinary English and keeps going: plain adjectives, then martial arts, then chess vocabulary, then a long run of music genres used as attitude.
salty·eerie·sticky·aikido·samurai·janissary·fianchetto·citypop·taiko·drone·mashup
Verbs + metrics. Motion and measurement, colliding with a big seam of playful, faintly British adjectives that sound like they were invented for a children's book and mostly weren't.
buzz·snap·spinup·zepto·drift·fizzy·spiffy·plonky·twonky·wobbly·pesky·chortle·humbug
Kawaii + colours. Colour words, cute-adjacent Japanese, tactile textures — and then, unexpectedly, a workshop: the tools and vessels you'd find in a forge.
aqua·dijon·vermilion·neko·sakura·milky·gritty·anvil·forge·loom·vial·homunculus
Memes + actions. Internet-native slang sitting directly beside 1950s retro-futurist transport, which turns out to be a very good combination.
susmax·okboom·stonks·yeet·drifter·telepod·warpjet·timecar·flycar·skybus·wormax
Living and growing things. Animals, then dinosaurs, then the entire vegetable aisle, then folklore creatures — with no dividing line between them.
alpaca·krill·marten·triceratops·gorgosaurus·iguanodon·parsnip·radish·yam·pixie·nessie
Monsters + materials. Elements and invented alloys at one end, and at the other the deepest seam in the corpus: monster folklore from Europe, Japan and Mesoamerica, mostly untranslated.
vanadium·yttrium·lumen·novaic·xenite·griffin·djinn·tengu·kitsune·bakeneko·hobgoblin·nuckelavee·ahuizotl·quetzalcoatl·tzitzimitl
digital-pickup-tengu gritty-yeet-parsnip
hyperpop-anvil-triceratops samurai-vermilion-nuckelavee
aikido-milky-quetzalcoatl wobbly-forge-iguanodon
Curated for how they sound together, not for coverage — and, since the first release, grown: 718 words across two rounds were proposed by language models against each pool's voice brief, filtered by the harness rules, and accepted only when at least two of three independent adversarial judges (lexical validity, register fit, taste and safety) agreed. See Known gaps. That's the whole editorial position: a name should be memorable enough that someone reads it aloud.
Pools are exported, so you can build your own shapes from them:
import { POOLS, entropyOfSlots } from 'funkynames';
entropyOfSlots([POOLS.kawaiiolors.length, POOLS.monsterials.length]);Default source is crypto.getRandomValues — Node 18+, every current browser, Deno and Bun. On React Native, add react-native-get-random-values.
Not Math.random(). V8 implements it as xorshift128+, seeded once per process and never reseeded; observe enough consecutive outputs and the internal state, and every future output, can be recovered. Irrelevant when naming a test fixture, disqualifying when the string is worth something. A library can't know which case it's in, so the safe default is the only honest one.
Integers come from rejection sampling rather than % max, which is biased whenever max doesn't divide 2³² evenly.
For reproducible tests, inject a source explicitly:
generateCode({ random: seededRandom }); // deterministic, and obvious in a diffnpm testPOOLS 6 pools · 3202 words · 3030 distinct
HANDLE 30.06 bits · 1.1 billion · collides at 39,380
CODE x3 34.70 bits · 27.8 billion · collides at 196,376
CODE x4 46.26 bits · 84.3 trillion · collides at 10,809,592
✅ ALL GREEN
The harness asserts word hygiene (no duplicates, no separators or capitals hiding in the data), entropy floors so a shrunk pool fails the build instead of quietly weakening the product, deterministic injected randomness, and a stuck-generator canary. CI additionally packs the tarball and imports it from a scratch project, which catches the packaging faults a test suite can't see.
-
The corpus was repaired, not born clean. The source lists had been truncated at roughly seven characters — a hard cliff in the length distribution, with longer words chopped rather than excluded. That left fragments like
hippogr,tricerandquetzcositting in the pools as if they were words. 138 were restored and 28 unrecoverable fragments dropped.scripts/repairs.jsonrecords every decision, including the judgment calls:iguanbecameiguanodonrather thaniguanabecause its neighbours aretrexandstego, andblackbbecameblackberryrather thanblackbirdbecause its neighbours arelycheeandedamame.Two survivors were found later, by reading the deployed demo's own output rather than by any test:
edmont(the system dictionary has no dinosaur names, so the detector was blind to it) andpachycephalosaur— which was my own repair, itself truncated. Both were dropped rather than expanded:pachycephalosaurusis eighteen letters andedmontosaurusthirteen, and each would have been the only word at its length — one entry stranded at the top of the length filter buys nothing and reads as a glitch. There may be more; the detector can only catch fragments that are prefixes of words a 1934 dictionary happens to contain. -
718 words came from two machine-assisted growth rounds, not from the original hand-curated list. Each was proposed by a model briefed on the pool's voice, passed the eight harness rules, survived two of three independent judges told to refute by default, and was reviewed by a human before being applied. The tooling is in the repo:
scripts/grow.mjsproposes,scripts/apply-proposals.mjsapplies, andproposals/(gitignored) holds the unreviewed output. A per-pool critic reports what each round missed; round one's critique (it read "vivid" as "intense") became round two's brief, and round two's (one-joke clusters, dog breeds, coinage suffixes that repeat) is the brief for round three. -
159 words appear in two or more pools. Codes deduplicate them so every word is equally likely; handles keep them, but never repeat a word inside one name.
-
9 words are two characters —
gm,im,fm,cm,fr,gg,pi,ox,ok. Some are deliberate (chess titles, an ox), some are probably noise, and I've left them rather than guess which is which. -
Opinionated and uneven. Heavy on martial arts, chess, dinosaurs, mythology and internet slang; thin on almost everything else. It reflects one person's taste, which is exactly why the names have a voice — a balanced corpus would produce blander names.
-
The word lists are public, so
parseNameproves shape and never identity. Anyone can assemble a valid-looking name from the README.
MIT — code and word lists.
Built by Frank Müller.