# Infinite Craft Bot

You’ve probably seen Neil.fun’s Infinite Craft game somewhere on the internet. If not, in a nutshell:

• You start with 4 blocks: Earth, Fire, Water, and Wind.
• You can combine any two blocks, for example:
• Earth + Water = Plant
• Plant + Fire = Smoke
• Smoke + Smoke = Cloud

That’s… pretty much it, from a gameplay perspective. There’s not really any goal, other than what you set yourself (try to make Cthulhu!). Although if you manage to find something no one has ever made before, you get a neat little note for it!

So wait, what do I mean by ‘something no one has ever seen before’?

Well, if two elements have ever been combined by anyone before, you get a cached response. Barring resets of the game (no idea if / how often this has happened, but I assume it has), if A + B = C for you, A + B = C for everyone.

And here’s the fun part: if you find a combination no one has ever found before: Neil.fun will send the combination out to an LLM to generate the new answer. The specific prompt isn’t public (so far as I know), but essentially what that means is that you have a basically infinite crafting tree1!

So of course seeing something like this I want to automate it. π

## The bot

In a nutshell, my algorithm is this:

• Store all known elements and combinations in a cache
• Forever (or until the site blows up):
• Wait a moment2
• Choose two random elements we haven’t chosen together before, submit them
• Record the response in the cache

That’s… really it. It took a bit more than that to get working (see bot detection), but that’s really it. Here’s the code:

# Initial values
elements = ["Wind", "Earth", "Fire", "Water"]
emoji = {
"Wind": "π¬οΈ",
"Earth": "π",
"Fire": "π₯",
"Water": "π§",
}
children = {}
parents = {}
history = set()
discoveries = []

start = time.time()

failures = 0
max_failures = 5

# Start guessing
while failures < max_failures:
time.sleep(0.1)

# Save periodically
if time.time() - start > 60:
start = time.time()
save()

# Pick two random elements
e1 = random.choice(elements)
e2 = random.choice(elements)
e1, e2 = sorted([e1, e2])
pair = f"{e1}\0{e2}"

# Skip if we've already tried this pair
if pair in history:
continue

# Make the request
response = requests.get(
f"https://neal.fun/api/infinite-craft/pair",
params={
"first": e1,
"second": e2,
},
)

# Parse response, print it out
try:
data = response.json()
except json.JSONDecodeError:
failures += 1
print(f"Failed to fetch JSON, retrying ({failures}/{max_failures})...")
continue

if data["result"] not in emoji:
print(
f'{emoji[e1]} {e1} + {emoji[e2]} {e2} -> {data["emoji"]} {data["result"]}{" (NEW)" if data["isNew"] else ""}'
)

# Store any new discoveries
if data["isNew"]:
discoveries.append(data["result"])

# Store new elements
if not data["result"] in emoji:
elements.append(data["result"])
emoji[data["result"]] = data["emoji"]

# Store the forward relationship
children[pair] = data["result"]

# Store the reverse relationship
if data["result"] not in parents:
parents[data["result"]] = set()


As mentioned, we have some custom types:

• Element - A str representing the name of a single element.
• ElementPair - A str containing two null delimited Elements; so Earth + Wind would be encoded as "Earth\u0000Wind". This handles the case that a specific element might actually have a + in the name, while (so far as I can tell) they will never contain a null byte.
• Emoji - A str containing the single (unicode) character that represents an Element, like Wind is π¬οΈ and Earth is π, etc.

From there, we can represent our data structures:

• elements: list[Element] - Stores all elements that we know about. This has to be a List so that we can use random.choice (that should really be available on Set, no?)
• emoji: dict[Element, Emoji] - Maps each element to the (I assume generated) emoji that is sent with it; this also serves as an O(1) way to check if we’ve seen an Element before (which elements can’t do)
• children: dict[ElementPair, Element] - Maps all known combinations we’ve seen
• parents: dict[Element, set[ElementPair]] - Stores every combination that can be used to create a specific Element; because JSON doesn’t natively do sets, this is stored: list and converted on save/load
• history: set[ElementPair] - Stores all pairs we’ve tried; looking back on this now, this is just children.keys() so we didn’t have to store this separately
• discoveries: list[Element] - Any elements that have never been created before in the (current) history of Infinite Craft, discovered by us! (or at least this bot)… Some of these are weird.

### Bot detection

One oddity (as hinted at with that headers=... line above) is that you can’t just fire off a request and hope that it will work. I’m honestly not sure if this is intentional (minimal) bot prevention or if it’s a side effect of a library or cache used (there is CloudFront involved here).

But after a fair bit of experimentation, I’ve found that you need to supply:

headers={
"User-Agent": user_agent,
"Referer": "https://neal.fun/infinite-craft/",
"Referrer-Policy": "strict-origin-when-cross-origin",
},


Where user_agent is a valid/new enough user agent. So a current version of Firefox/Chrome/etc.

It’s interesting looking back at previous implementations others have done of this and/or some libraries people have written to do exactly this behavior. Many of them just don’t currently work–and it’s all because of these header checks.2

### The cache

So we don’t want to just run this once. What if it crashes? We’d have to lose all that progress on each run!

I’m using atexit to save all of these values to a JSON file any time this program exists:

def save():
print(f"Saving {len(elements)} elements to cache")
with open("cache.json", "w") as f:
json.dump(
{
"elements": elements,
"emoji": emoji,
"children": children,
"parents": {k: list(v) for k, v in parents.items()},
"history": list(history),
"discoveries": discoveries,
},
f,
indent=2,
)

atexit.register(save)


try:
with open("cache.json", "r") as f:
elements = data["elements"]
emoji = data["emoji"]
children = data["children"]
parents = {k: set(v) for k, v in data["parents"].items()}
history = set(data["history"])
discoveries = data["discoveries"]

except FileNotFoundError:
elements = ["Wind", "Earth", "Fire", "Water"]
emoji = {
"Wind": "π¬οΈ",
"Earth": "π",
"Fire": "π₯",
"Water": "π§",
}
children = {}
parents = {}
history = set()
discoveries = []


## Findings

And … that’s it. Off we go! I ran it off and on over the course of a few nights (whenever I remembered it) and all together found some 4000 or so elements; over 400 of them never seen before! That’s more than I expected.

So, what interesting things did we find/discover with this data?

### Raw data

Well, as mentioned, we have some 4000 elements:

$cat cache.json | jq -r '.elements[]' | wc -l 4321$ cat cache.json | jq -r '.elements[]'

Wind
Earth
Fire
Water
Dust
...
C-3po
Space Fish
Steampunk Poseidon
Harry Potter and the Sorcererβs Stone
Apex Cider


### Discoveries

And of those, a good number have never been seen before!

$cat cache.json | jq -r '.discoveries[]' | wc -l 454$ cat cache.json | jq -r '.discoveries[]'

Yggdrasil Fishing Pole
Snow Pig Fishing Pole
Treebillionaire
Donut-dusa
Moldy Fireman Rich
...
E-megatitan
Mr. Selfie Mayhem
Baconnaut Trumpwave
Ginguitilla Hobopoly
Apex Cider


I know right3? A lot of them are of the form ‘(adjective) (noun)’, sometimes with multiple in either category. And one reason we have so many… well look at those first two. If no one has ever seen a Yggrasil Fishing Pole, well it stands to reason they’ve never combined it with Snow Pig to get a Snow Pig Fishing Pole! π

### Minimal ancestors

Next, let’s do something a bit more interesting. Let’s take all of those parents lists we’ve generated before and find the minimal set of ancestors needed to build any specific element. More specifically:

• Create a dict[Element, Set[Element]] of minimal ancestors; populate with initial elements
• Until this dict doesn’t change:
• For each element e:
• For each pair (e1) + (e2) that can make e:
• Look up the minimal ancestors of e1 and e2
• If either is not yet set, ignore this pair for now
• If both are set, union their minimal ancestors and add {e1, e2}
• If this union is smaller than the current minimal set of e (or if that hans’t been set)
• Update e’s minimal set

In code:

import json

with open("cache.json", "r") as f:
parents = data["parents"]

minimal_ancestors = {
"Wind": set(),
"Earth": set(),
"Fire": set(),
"Water": set(),
}

settled = False

while not settled:
settled = True

for element in parents:
for pair in parents[element]:
e1, e2 = pair.split("\0")
if e1 not in minimal_ancestors or e2 not in minimal_ancestors:
continue

candidate = (
{e1, e2}.union(minimal_ancestors[e1]).union(minimal_ancestors[e2])
)

new_best = False
if element not in minimal_ancestors:
new_best = True
elif len(candidate) < len(minimal_ancestors[element]):
new_best = True

if new_best:
settled = False
minimal_ancestors[element] = candidate


This lets us find the elements that are the most ‘complicated’ to build. Even if we take the easiest (known) way to build them, it will still take at least some minimal number of steps to make them.

for i, (
size,
key,
) in enumerate(reversed(sorted((len(v), k) for k, v in minimal_ancestors.items())), 1):
print(f"[{i}] {key}: {size}")
if i >= 10:
break


Out of my data set, we have:

$poetry run python minimal-ancestors.py [1] The Flying Squidtopus: 313 [2] Banana Sword: 291 [3] The Flying Sharktopus: 287 [4] Pigsink: 279 [5] Bananamobile: 275 [6] Cryptopopet: 268 [7] Banana Kong: 267 [8] Blueberry: 266 [9] Baconnaut Creeperzilla: 265 [10] Antbacon Sharktopus: 264  Yup. It takes at least 287 other elements to make The Flying Sharktopus (the The is part of the Element!). And another 26 to make that into The Flying Squidtopus. The most interesting part of that (to me)? $ cat cache.json | jq -r '.discoveries[]' | grep 'The Flying'

The Flying Porkosaurus
The Flying Organ Grinder
The Flying Charizard Express
The Flying Crabstick Of The Apes
The Flying Apple Fritter
The Flying Cryptotortoise
The Flying Fisherman Express
The Flying Garbage Truck
The Flying Golemzilla
The Flying Cactus Lobster Express
The Flying Porkinator Express
The Flying Dumbledore Fritter


Neither of those are ones we actually discovered. Someone else found both of them! It’s entirely possible there are easier ways to get to them, but I still found that interesting.

### Rebuilding a path

Okay, we can find the hard elements, but what about actually printing out the path to any particular element? Well, first we start with the same minimal element code, but then we need to do another loop:

• Generate a set of minimal elements
• Start with a set of the elements we have built and those to_build (everything but the basic starting 4)
• Until the to_build is empty:
• For each element e in to_build; check each pair of elements e1 and e2 in built; if any pair makes e, report it and move it from to_build to built

In code:

for arg in sys.argv[1:]:
print(f"=== {arg} ===")
if arg not in minimal_ancestors:
print("Unknown element\n")
continue

ancestors = minimal_ancestors[arg]

def seek(known, target):
for e1 in known:
for e2 in known:
pair = f"{e1}\0{e2}"

if pair in parents[target]:
return (e1, e2)

raise Exception(f"Could not find a way to build {target}")

built = {"Earth", "Fire", "Water", "Wind"}
to_build = set(ancestors).union({arg}).difference(built)

# Need to loop until settled to handle cases where two tied ancestors only one can build

while to_build:
for _, ancestor in sorted(
(len(minimal_ancestors[ancestor]), ancestor) for ancestor in list(to_build)
):
newly_built = set()

if minimal_ancestors[ancestor]:
try:
e1, e2 = seek(built, ancestor)
print(ancestor, ":", e1, "x", e2)

except Exception:
continue

to_build = to_build.difference(newly_built)

print()


This will eventually print out the exact combination of elements needed to get to any one target (if we know how):

$poetry run python ancestors.py "Gandalf" === Gandalf === Dust : Earth x Wind Lava : Earth x Fire Planet : Dust x Earth Sandstorm : Dust x Wind Moon : Earth x Planet Mud : Dust x Water Storm : Planet x Wind Clay : Mud x Mud Swamp : Mud x Water Volcano : Clay x Lava Brick : Clay x Clay Dust Storm : Sandstorm x Storm Mist : Swamp x Wind Pottery : Clay x Earth Fog : Brick x Mist House : Brick x Earth Ceramic : Fire x Pottery Cloud : Fog x Wind Dustbin : Dust x House Ghost : Fog x Swamp Lunar Eclipse : Dust Storm x Moon Pig : Dustbin x Mud Pot : Ceramic x Earth Rain : Cloud x Earth Fireball : Fire x Ghost Haunted House : House x Moon Stone : Lava x Rain Piggy Bank : Ceramic x Pig Sun : Fireball x Moon Plant : Pot x Sun Desert : Sandstorm x Sun Glass : Brick x Sun Lens : Fog x Glass Telescope : Dust x Glass Wine : Glass x Water Camera : Lens x Plant Rich Ghost : Ghost x Piggy Bank Ghost Town : Desert x Haunted House Gold Rush : Dust x Ghost Town Rich Lava : Rich Ghost x Volcano Gold : Earth x Rich Lava Petra : Camera x Stone Pirate : Gold x Swamp Stonehenge : Petra x Telescope Money Tree : Gold Rush x Plant Pirate Ship : Pirate x Sun Druid : Lunar Eclipse x Stonehenge Titanic : Petra x Pirate Ship Bankrupt : Money Tree x Titanic Drunk : Bankrupt x Wine Black Hole : Dust Storm x Telescope Wizard : Black Hole x Druid Gandalf : Drunk x Wizard  Yup. Drunk + Wizard = Gandalf apparently. I would have gone for High, but so it goes. π ### The most/least interesting And finally, we have arguably the ‘most/least’ interesting elements. Either from the perspective of the Emoji used by the most/least known Elements or from the Elements with the most children. For the most part, it’s a bunch of sorting and filtering. Here’s what I found from my data: $ poetry run python most-least.py

easiest to get (179, 'Pig') : Pigpen x Tsunami, Church x Rich Bacon, Fake x Pig King Kong ...
singletons ( 2278 ):  Sad Mangrotron, Porky Poseidon, Sea Weed ...
most children (52, 'Earth') : Eruption, Dust, Earth ...
singleton children ( 449 ) :  The Flying Porkinator Express, Captain Narcissist, Herculean Flying Organ Grinder ...
most common emoji (222, 'π¦') : Spongebob Crab Rangoon Pants, Crab Titanium, Crab Cone ...
singleton emoji ( 171 ) :  π, π€, π¦, π¦‘, π, π, π΅, π, π», π ...


Specifically, ’easiest to get’ is the Element we can get 179 different ways. Take that Pig.

singletons is the more than half we’ve only found one way (so far) to get to, with singleton_children being those elements that only create 1 other element (but not zero) and singleton emoji the Emoji only used by a single element (necessarily a singleton itself).

most_children is the most prolific element: Earth, which can make 52 others (makes sense, it’s a base element), while the most common emoji… Carcinisation is real, yo.

And that’s it. It’s a fun little project based on a fun little project. Onwards!

1. Technically, you could end up in a state where every single possible element has been found, since there are multiple ways to make any specific element. For example you can make Gandalf from Bilbo + Thunderstorm or Blacksmith + Dumbledore. Or even Old Man + Rainbow Smaug3!. But eventually, you might combine Bilbo with every other element. And Thunderstorm with every other element. Etc. But it seems that the branching factor is sufficiently high that this should never practical happen4↩︎

2. Okay, first up: This is not at all ‘optimal’ web citizen behavior.

On one hand, there’s nothing that suggests Infinite Craft was designed to be automated–there’s only an API so much as the site itself exists and even that has some basic bot protection (intentional or not).

On the other hand, there’s little specifically on Neil.fun that prohibits this behavior. There’s not really a terms of service (although there is a Privacy Policy). Nor even a robots.txt (not that I went looking before implementing this).

So… I suppose your milage may vary. I did make sure to put in some manual delays in my code (so it didn’t just run as quickly as possible). I ran it primarily at night (although when that is varies depending on where in the world you are, of course). And when the site started responding oddly, I stopped scraping (although that was half or more because it stopped giving useful data). ↩︎ ↩︎

3. I know right? You’d think just Old Man + Smaug, but you’ll see / have seen in the implementation details that I’m choosing which things to combine randomly. So I never actually tried just Smaug for this. I do have him (Fire Flower + The Hobbit. Or actually Fire + Gandalf), I just didn’t try that combination. ↩︎ ↩︎

4. Astrologer + Rainbow Smoke Train = The End of the World` ↩︎