8 min read
Generative Art and Puzzle
An interesting puzzle for my D&D campaign.

Here’s an interesting puzzle I used in my D&D campaign’s final session. The puzzle and the code to generate it is attributed to @usuyus. If you know him, you know him; if you don’t… You should meet him!

This glyph, as inscribed on an altar in the final boss’s lair, is an encoding of some English statement. In addition to that, I have also gave the following glyph with the words “The Avernus” written beneath it.

If you want to figure out how things are encoded yourself, be my guest! (Hint: It’s binary!)
§
In this puzzle, a letter is a grid, with the center circle containing three dots showing you “where” to start as the letters are ordered through a spiral.

The next question one might ask is, then:
How are letters represented through some lines and some dots for each grid?
Thinking about the hint, letters may be represented with binary using ASCII as a baseline. And to do that, the “bits” could be if there is something or if there is nothing on an edge of a grid. Therefore, I have eight outer edges on the grid which each could have something or don’t, giving me different combinations, just like ASCII. So, the middle edges inside of the grids, don’t really matter in the final decoding; they’re basically just there to make the glyph cooler.
One thing that attentive readers may notice is that an edge could have two grids touching it. When both grids have an actual edge there, it’s easy to decode as it would look like a double edge on the glyph. However, when they only have a single edge, either one grid has that bit set or the other. Which is the exact thing that makes this a puzzle as you need to make sure that you’re not overlooking any possibilities.
Generativeness
Thanks to my friend @usuyus, I could generate any image with some code for this puzzle. Let’s see how the data types are defined, for our mental model:
1# what an edge's stroke can be2class StrokeType(IntEnum):3EMPTY = 04SINGLE = 15DOUBLE = 26DOT = 3
1# a vertex is the center point of "any" 2x2 grid2class VertexType(IntEnum):3JUNCTION = 04CIRCLE = 15MARKER = 26FISH = 3
1# these are used to index movement and edge lookup2class Direction(IntEnum):3RIGHT = 04DOWN = 15LEFT = 26UP = 3
Using those primitive types, we define the actual grid, of edges (horizontal and vertical) and vertices (the type and the direction).
1self.hor_edges = [2[StrokeType.EMPTY for j in range(self.width)]3for i in range(self.height + 1)4]56self.ver_edges = [7[StrokeType.EMPTY for j in range(self.width + 1)]8for i in range(self.height)9]1011self.vertex_types = [12[VertexType.JUNCTION for j in range(self.width + 1)]13for i in range(self.height + 1)14]1516self.vertex_dirs = [17[Direction.RIGHT for j in range(self.width + 1)]18for i in range(self.height + 1)19]
The following is then how the actual embedding of the edges is made.
1for char, new_dir in zip(self.chars, embed.path):2mid = 2 * cur_pos + (1, 1)34for dir in Direction:5if dir not in [cur_dir.reverse(), new_dir]:6self.grid.set_edge(mid, dir, StrokeType.SINGLE)78for (off, dir), can in zip(edge_order, char.data):9if not can:10continue1112side = mid.advance(off)13cur_stroke = self.grid.get_edge(side, dir)1415if cur_stroke == StrokeType.SINGLE:16self.grid.set_edge(side, dir, StrokeType.DOUBLE)17else:18adj_pos = mid.advance(off, 2) // 219if adj_pos in vis:20self.grid.set_edge(side, dir, StrokeType.DOT)21else:22self.grid.set_edge(side, dir, StrokeType.SINGLE)2324vis.add(cur_pos)25cur_pos = cur_pos.advance(new_dir)26cur_dir = new_dir
The first inner loop is the one that creates the edges inside of the grids. It is special in the sense that it tries to create a “path” through the insides of the grids in the general image, which is why it doesn’t have a stroke from the direction from the previous position into this character or the direction from this character to the next one. Because the center strokes are chosen consistently from the path, the whole string gets a continuous structural flow, producing a maze-like appeal!
The second inner loop is the one that places the actual data of the characters into the outer edges, making the doubled strokes if necessary. One thing to notice is how a dot is produced. It happens when a character wants to place one of its eight outer strokes, but that stroke would point toward a character position that has already been used earlier in the embedding path; basically preventing edges from colliding.
One thing that I really find fascinating is how the rendering is made with all of these different types and things conjoning together. The rendering of junctions is made like such:
1# given the stroke type of each direction, draw the junction2# expects grid to be centered at the vertex, preserves transformations3# top_level detects if i'm being used by another larger piece4# (for properly placing caps)5def draw_junction(self, *nbrs, top_level=False):67# only EMPTY, SINGLE, and DOUBLE are relevant8r, d, l, u = (0 if x >= 3 else x for x in nbrs)910vbox_size = self.cfg["vbox_size"]11circle_size = self.cfg["circle_size"]12double_gap = self.cfg["double_gap"]13y_connect = self.cfg["y_connect"]14curve_out = self.cfg["curve_out"]15cap_double = self.cfg["cap_double"]1617match r, d, l, u:18case 0, 0, 0, 0:19pass2021# use symmetry to get rid of a lot of cases22case 0, _, _, _:23self.ctx.save()24self.ctx.rotate(math.pi / 2)25self.draw_junction(d, l, u, r, top_level=top_level)26self.ctx.restore()2728# 1 segment2930case 1, 0, 0, 0:31self.draw_line((0, 0), (vbox_size, 0))3233case 2, 0, 0, 0:34self.draw_line((0, double_gap), (vbox_size, double_gap))35self.draw_line((0, -double_gap), (vbox_size, -double_gap))3637if cap_double and top_level:38self.ctx.arc(0, 0, double_gap, math.pi / 2, 3 * math.pi / 2)39self.ctx.stroke()4041# 2 segments - angled4243case 1, 1, 0, 0:44self.ctx.arc(vbox_size, vbox_size, vbox_size, math.pi, 3 * math.pi / 2)45self.ctx.stroke()4647case 1, 2, 0, 0:48# draw the curve49self.ctx.save()50self.ctx.translate(vbox_size, vbox_size)51self.ctx.scale(vbox_size + double_gap, vbox_size)52self.ctx.arc(0, 0, 1, math.pi, 3 * math.pi / 2)53self.ctx.restore()54self.ctx.stroke()5556# calculate where the line intersects the ellipse57ratio = (vbox_size - double_gap) ** 2 / (vbox_size + double_gap) ** 258isect_pos = vbox_size * (1 - math.sqrt(1 - ratio))5960# draw the second stroke61self.draw_line((double_gap, isect_pos), (double_gap, vbox_size))6263case 2, 1, 0, 0:64self.ctx.save()65self.ctx.rotate(-math.pi / 2)66self.ctx.scale(-1, 1) # reflect wrt y67self.draw_junction(1, 2, 0, 0)68self.ctx.restore()6970case 2, 2, 0, 0:71self.ctx.arc(72vbox_size,73vbox_size,74vbox_size - double_gap,75math.pi,763 * math.pi / 2,77)78self.ctx.stroke()79self.ctx.arc(80vbox_size,81vbox_size,82vbox_size + double_gap,83math.pi,843 * math.pi / 2,85)86self.ctx.stroke()8788# 2 segments - across8990case 1, 0, 1, 0:91self.draw_junction(1, 0, 0, 0)92self.draw_junction(0, 0, 1, 0)9394case 2, 0, 2, 0:95self.draw_junction(2, 0, 0, 0)96self.draw_junction(0, 0, 2, 0)9798case 1, 0, 2, 0:99if y_connect:100self.draw_line((double_gap, 0), (vbox_size, 0))101self.draw_line((-double_gap, double_gap), (-vbox_size, double_gap))102self.draw_line(103(-double_gap, -double_gap), (-vbox_size, -double_gap)104)105106# maybe make it a param?107slack = double_gap * 0.4108109self.ctx.move_to(-double_gap, double_gap)110self.ctx.curve_to(111double_gap - slack,112double_gap,113-double_gap + slack,1140,115double_gap,1160,117)118self.ctx.stroke()119120self.ctx.move_to(-double_gap, -double_gap)121self.ctx.curve_to(122double_gap - slack,123-double_gap,124-double_gap + slack,1250,126double_gap,1270,128)129self.ctx.stroke()130else:131self.draw_junction(1, 0, 0, 0)132self.draw_junction(0, 0, 2, 0)133134case 2, 0, 1, 0:135self.ctx.save()136self.ctx.rotate(math.pi)137self.draw_junction(1, 0, 2, 0)138self.ctx.restore()139140# 3 segments141142case (_, 0, _, _) | (_, _, 0, _):143self.ctx.save()144self.ctx.rotate(math.pi / 2)145self.draw_junction(d, l, u, r)146self.ctx.restore()147148case 1, x, 1, 0:149self.draw_junction(1, 0, 1, 0)150self.draw_junction(0, x, 0, 0)151152case 2, 1, 2, 0:153self.draw_junction(2, 0, 2, 0)154self.draw_line((0, double_gap), (0, vbox_size))155156case 2, 2, 2, 0:157if curve_out:158self.draw_line((-vbox_size, -double_gap), (vbox_size, -double_gap))159160self.ctx.arc(161vbox_size,162vbox_size,163vbox_size - double_gap,164math.pi,1653 * math.pi / 2,166)167self.ctx.stroke()168169self.ctx.arc(170-vbox_size,171vbox_size,172vbox_size - double_gap,1733 * math.pi / 2,1742 * math.pi,175)176self.ctx.stroke()177else:178self.draw_line((-vbox_size, -double_gap), (vbox_size, -double_gap))179self.draw_line(180(-vbox_size, double_gap),181(-double_gap, double_gap),182(-double_gap, vbox_size),183)184self.draw_line(185(vbox_size, double_gap),186(double_gap, double_gap),187(double_gap, vbox_size),188)189190case 1, 1, 2, 0:191self.draw_junction(1, 0, 2, 0)192self.draw_line((0, double_gap / 2), (0, vbox_size))193194case 1, 2, 2, 0:195if curve_out:196isect = math.sqrt((vbox_size + double_gap) ** 2 - vbox_size**2)197self.draw_line((isect - vbox_size, 0), (vbox_size, 0))198self.draw_junction(0, 2, 2, 0)199else:200self.draw_junction(1, 0, 2, 0)201self.draw_line((double_gap, 0), (double_gap, vbox_size))202self.draw_line((-double_gap, double_gap), (-double_gap, vbox_size))203204case 2, x, 1, 0:205self.ctx.save()206self.ctx.scale(-1, 1) # reflect wrt y207self.draw_junction(1, x, 2, 0)208self.ctx.restore()209210# 4 segments211212case 2, 2, 2, 2:213if curve_out:214self.draw_junction(2, 2, 0, 0)215self.draw_junction(0, 0, 2, 2)216else:217self.ctx.save()218for _ in range(4):219self.draw_line(220(vbox_size, double_gap),221(double_gap, double_gap),222(double_gap, vbox_size),223)224self.ctx.rotate(math.pi / 2)225self.ctx.restore()226227case 2, _, _, _:228self.ctx.save()229self.ctx.rotate(math.pi / 2)230self.draw_junction(d, l, u, r)231self.ctx.restore()232233case 1, 2, 1, 2:234self.draw_junction(0, 2, 0, 2)235self.draw_line((-double_gap, 0), (-vbox_size, 0))236self.draw_line((double_gap, 0), (vbox_size, 0))237238case 1, x, 1, y:239self.draw_junction(1, 0, 1, 0)240self.draw_junction(0, x, 0, y)241242case 1, 1, 2, 1:243self.ctx.save()244self.ctx.scale(-1, 1) # reflect wrt y245self.draw_junction(2, 1, 1, 1)246self.ctx.restore()247248case 1, 1, 2, 2:249if curve_out:250self.draw_junction(1, 1, 0, 0)251self.draw_junction(0, 0, 2, 2)252else:253self.draw_line(254(0, vbox_size), (0, double_gap), (-vbox_size, double_gap)255)256257self.draw_line(258(-vbox_size, -double_gap),259(-double_gap, -double_gap),260(-double_gap, -vbox_size),261)262263self.draw_line(264(double_gap, -vbox_size), (double_gap, 0), (vbox_size, 0)265)266267case 1, 2, 2, 1:268self.ctx.save()269self.ctx.rotate(-math.pi / 2)270self.draw_junction(1, 1, 2, 2)271self.ctx.restore()272273case 1, 2, 2, 2:274self.draw_junction(0, 2, 2, 2)275self.draw_line((double_gap, 0), (vbox_size, 0))276277case _:278print(l, d, r, u)279print("not implemented / invalid!")
§