EVILGRAM
Challenge
A page renders a Plotly 3D animation — a voxel cellular automaton stepping through 128 frames inside a 4×4×4 world. The flag isn't drawn anywhere; it's encoded in which update rule the automaton is running.
Extracting the animation data
Plotly embeds the whole animation as a literal Plotly.addFrames(...) call in the page's inline script. Regex out that JSON array directly from the HTML rather than trying to drive a browser:
m = re.search(r"Plotly\.addFrames\('[^']*',\s*(\[.*\])\);\s*$", data.strip())
frames = json.loads(m.group(1))
Recovering the automaton's ruleset
Each frame's scatter points give the active voxel coordinates, so every frame can be rasterized into a 4×4×4 occupancy grid. A cellular automaton like this typically updates every cell from an 8-neighbor "block" (a 2×2×2 corner cube, Margolus-style), so each observed (before, after) frame pair yields many samples of blockState → newState for whichever offset parity that step used:
def calcBlockState(m, x, y, z):
return int(
m[z][y][x] + m[z][y][x+1]*2 + m[z][y+1][x]*4 + m[z][y+1][x+1]*8 +
m[z+1][y][x]*16 + m[z+1][y][x+1]*32 + m[z+1][y+1][x]*64 + m[z+1][y+1][x+1]*128
) # (mod-wrapped indices omitted for brevity)
Walking all 128 frame transitions across every block-parity offset fills in all 256 possible blockState → newState entries with zero conflicts — confirming the automaton's ruleset really is a full lookup table, and (crucially) that the 256 output values form a permutation of 0..255.
The ruleset is the message
A ruleset that's specifically a permutation of 256 values is exactly what you get from applying a Fisher–Yates-style shuffle to range(256) seeded by a single large number — i.e. encoding an arbitrary integer as a permutation via its factorial-base ("factoradic") digits. That's invertible: replay the shuffle's removals to recover each step's chosen index, reassemble the factoradic digits, and recombine them back into the original integer:
l = list(range(256))
factoradic_rev = []
for j in range(256):
idx = l.index(ruleset[j])
factoradic_rev.append(idx)
l.pop(idx)
F = list(reversed(factoradic_rev))
acc = 0
for i in range(255, -1, -1):
acc = acc * (i + 1) + F[i]
msg_bytes = acc.to_bytes((acc.bit_length() + 7) // 8, 'big')
Decoding those bytes as UTF-8 recovers the flag — hidden not in the animation's visuals, but in the exact permutation its update rule turned out to be.