Apr 3, 2026

8 min read

Generative Art and Puzzle

An interesting puzzle for my D&D campaign.

Generative Art and Puzzle post cover image

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!

Mysterious-One.png

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.

The-Avernus.png

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 2×22\times2 grid, with the center circle containing three dots showing you “where” to start as the letters are ordered through a spiral.

spiralavernus.png

The next question one might ask is, then:

How are letters represented through some lines and some dots for each 2×22\times 2 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 2×22\times 2 grid. Therefore, I have eight outer edges on the grid which each could have something or don’t, giving me 28=2562^8=256 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 2×22\times 2 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 be
2
class StrokeType(IntEnum):
3
EMPTY = 0
4
SINGLE = 1
5
DOUBLE = 2
6
DOT = 3
1
# a vertex is the center point of "any" 2x2 grid
2
class VertexType(IntEnum):
3
JUNCTION = 0
4
CIRCLE = 1
5
MARKER = 2
6
FISH = 3
1
# these are used to index movement and edge lookup
2
class Direction(IntEnum):
3
RIGHT = 0
4
DOWN = 1
5
LEFT = 2
6
UP = 3

Using those primitive types, we define the actual grid, of edges (horizontal and vertical) and vertices (the type and the direction).

1
self.hor_edges = [
2
[StrokeType.EMPTY for j in range(self.width)]
3
for i in range(self.height + 1)
4
]
5
6
self.ver_edges = [
7
[StrokeType.EMPTY for j in range(self.width + 1)]
8
for i in range(self.height)
9
]
10
11
self.vertex_types = [
12
[VertexType.JUNCTION for j in range(self.width + 1)]
13
for i in range(self.height + 1)
14
]
15
16
self.vertex_dirs = [
17
[Direction.RIGHT for j in range(self.width + 1)]
18
for i in range(self.height + 1)
19
]

The following is then how the actual embedding of the edges is made.

1
for char, new_dir in zip(self.chars, embed.path):
2
mid = 2 * cur_pos + (1, 1)
3
4
for dir in Direction:
5
if dir not in [cur_dir.reverse(), new_dir]:
6
self.grid.set_edge(mid, dir, StrokeType.SINGLE)
7
8
for (off, dir), can in zip(edge_order, char.data):
9
if not can:
10
continue
11
12
side = mid.advance(off)
13
cur_stroke = self.grid.get_edge(side, dir)
14
15
if cur_stroke == StrokeType.SINGLE:
16
self.grid.set_edge(side, dir, StrokeType.DOUBLE)
17
else:
18
adj_pos = mid.advance(off, 2) // 2
19
if adj_pos in vis:
20
self.grid.set_edge(side, dir, StrokeType.DOT)
21
else:
22
self.grid.set_edge(side, dir, StrokeType.SINGLE)
23
24
vis.add(cur_pos)
25
cur_pos = cur_pos.advance(new_dir)
26
cur_dir = new_dir

The first inner loop is the one that creates the edges inside of the 2×22 \times 2 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 junction
2
# expects grid to be centered at the vertex, preserves transformations
3
# top_level detects if i'm being used by another larger piece
4
# (for properly placing caps)
5
def draw_junction(self, *nbrs, top_level=False):
6
7
# only EMPTY, SINGLE, and DOUBLE are relevant
8
r, d, l, u = (0 if x >= 3 else x for x in nbrs)
9
10
vbox_size = self.cfg["vbox_size"]
11
circle_size = self.cfg["circle_size"]
12
double_gap = self.cfg["double_gap"]
13
y_connect = self.cfg["y_connect"]
14
curve_out = self.cfg["curve_out"]
15
cap_double = self.cfg["cap_double"]
16
17
match r, d, l, u:
18
case 0, 0, 0, 0:
19
pass
20
21
# use symmetry to get rid of a lot of cases
22
case 0, _, _, _:
23
self.ctx.save()
24
self.ctx.rotate(math.pi / 2)
25
self.draw_junction(d, l, u, r, top_level=top_level)
26
self.ctx.restore()
27
28
# 1 segment
29
30
case 1, 0, 0, 0:
31
self.draw_line((0, 0), (vbox_size, 0))
32
33
case 2, 0, 0, 0:
34
self.draw_line((0, double_gap), (vbox_size, double_gap))
35
self.draw_line((0, -double_gap), (vbox_size, -double_gap))
36
37
if cap_double and top_level:
38
self.ctx.arc(0, 0, double_gap, math.pi / 2, 3 * math.pi / 2)
39
self.ctx.stroke()
40
41
# 2 segments - angled
42
43
case 1, 1, 0, 0:
44
self.ctx.arc(vbox_size, vbox_size, vbox_size, math.pi, 3 * math.pi / 2)
45
self.ctx.stroke()
46
47
case 1, 2, 0, 0:
48
# draw the curve
49
self.ctx.save()
50
self.ctx.translate(vbox_size, vbox_size)
51
self.ctx.scale(vbox_size + double_gap, vbox_size)
52
self.ctx.arc(0, 0, 1, math.pi, 3 * math.pi / 2)
53
self.ctx.restore()
54
self.ctx.stroke()
55
56
# calculate where the line intersects the ellipse
57
ratio = (vbox_size - double_gap) ** 2 / (vbox_size + double_gap) ** 2
58
isect_pos = vbox_size * (1 - math.sqrt(1 - ratio))
59
60
# draw the second stroke
61
self.draw_line((double_gap, isect_pos), (double_gap, vbox_size))
62
63
case 2, 1, 0, 0:
64
self.ctx.save()
65
self.ctx.rotate(-math.pi / 2)
66
self.ctx.scale(-1, 1) # reflect wrt y
67
self.draw_junction(1, 2, 0, 0)
68
self.ctx.restore()
69
70
case 2, 2, 0, 0:
71
self.ctx.arc(
72
vbox_size,
73
vbox_size,
74
vbox_size - double_gap,
75
math.pi,
76
3 * math.pi / 2,
77
)
78
self.ctx.stroke()
79
self.ctx.arc(
80
vbox_size,
81
vbox_size,
82
vbox_size + double_gap,
83
math.pi,
84
3 * math.pi / 2,
85
)
86
self.ctx.stroke()
87
88
# 2 segments - across
89
90
case 1, 0, 1, 0:
91
self.draw_junction(1, 0, 0, 0)
92
self.draw_junction(0, 0, 1, 0)
93
94
case 2, 0, 2, 0:
95
self.draw_junction(2, 0, 0, 0)
96
self.draw_junction(0, 0, 2, 0)
97
98
case 1, 0, 2, 0:
99
if y_connect:
100
self.draw_line((double_gap, 0), (vbox_size, 0))
101
self.draw_line((-double_gap, double_gap), (-vbox_size, double_gap))
102
self.draw_line(
103
(-double_gap, -double_gap), (-vbox_size, -double_gap)
104
)
105
106
# maybe make it a param?
107
slack = double_gap * 0.4
108
109
self.ctx.move_to(-double_gap, double_gap)
110
self.ctx.curve_to(
111
double_gap - slack,
112
double_gap,
113
-double_gap + slack,
114
0,
115
double_gap,
116
0,
117
)
118
self.ctx.stroke()
119
120
self.ctx.move_to(-double_gap, -double_gap)
121
self.ctx.curve_to(
122
double_gap - slack,
123
-double_gap,
124
-double_gap + slack,
125
0,
126
double_gap,
127
0,
128
)
129
self.ctx.stroke()
130
else:
131
self.draw_junction(1, 0, 0, 0)
132
self.draw_junction(0, 0, 2, 0)
133
134
case 2, 0, 1, 0:
135
self.ctx.save()
136
self.ctx.rotate(math.pi)
137
self.draw_junction(1, 0, 2, 0)
138
self.ctx.restore()
139
140
# 3 segments
141
142
case (_, 0, _, _) | (_, _, 0, _):
143
self.ctx.save()
144
self.ctx.rotate(math.pi / 2)
145
self.draw_junction(d, l, u, r)
146
self.ctx.restore()
147
148
case 1, x, 1, 0:
149
self.draw_junction(1, 0, 1, 0)
150
self.draw_junction(0, x, 0, 0)
151
152
case 2, 1, 2, 0:
153
self.draw_junction(2, 0, 2, 0)
154
self.draw_line((0, double_gap), (0, vbox_size))
155
156
case 2, 2, 2, 0:
157
if curve_out:
158
self.draw_line((-vbox_size, -double_gap), (vbox_size, -double_gap))
159
160
self.ctx.arc(
161
vbox_size,
162
vbox_size,
163
vbox_size - double_gap,
164
math.pi,
165
3 * math.pi / 2,
166
)
167
self.ctx.stroke()
168
169
self.ctx.arc(
170
-vbox_size,
171
vbox_size,
172
vbox_size - double_gap,
173
3 * math.pi / 2,
174
2 * math.pi,
175
)
176
self.ctx.stroke()
177
else:
178
self.draw_line((-vbox_size, -double_gap), (vbox_size, -double_gap))
179
self.draw_line(
180
(-vbox_size, double_gap),
181
(-double_gap, double_gap),
182
(-double_gap, vbox_size),
183
)
184
self.draw_line(
185
(vbox_size, double_gap),
186
(double_gap, double_gap),
187
(double_gap, vbox_size),
188
)
189
190
case 1, 1, 2, 0:
191
self.draw_junction(1, 0, 2, 0)
192
self.draw_line((0, double_gap / 2), (0, vbox_size))
193
194
case 1, 2, 2, 0:
195
if curve_out:
196
isect = math.sqrt((vbox_size + double_gap) ** 2 - vbox_size**2)
197
self.draw_line((isect - vbox_size, 0), (vbox_size, 0))
198
self.draw_junction(0, 2, 2, 0)
199
else:
200
self.draw_junction(1, 0, 2, 0)
201
self.draw_line((double_gap, 0), (double_gap, vbox_size))
202
self.draw_line((-double_gap, double_gap), (-double_gap, vbox_size))
203
204
case 2, x, 1, 0:
205
self.ctx.save()
206
self.ctx.scale(-1, 1) # reflect wrt y
207
self.draw_junction(1, x, 2, 0)
208
self.ctx.restore()
209
210
# 4 segments
211
212
case 2, 2, 2, 2:
213
if curve_out:
214
self.draw_junction(2, 2, 0, 0)
215
self.draw_junction(0, 0, 2, 2)
216
else:
217
self.ctx.save()
218
for _ in range(4):
219
self.draw_line(
220
(vbox_size, double_gap),
221
(double_gap, double_gap),
222
(double_gap, vbox_size),
223
)
224
self.ctx.rotate(math.pi / 2)
225
self.ctx.restore()
226
227
case 2, _, _, _:
228
self.ctx.save()
229
self.ctx.rotate(math.pi / 2)
230
self.draw_junction(d, l, u, r)
231
self.ctx.restore()
232
233
case 1, 2, 1, 2:
234
self.draw_junction(0, 2, 0, 2)
235
self.draw_line((-double_gap, 0), (-vbox_size, 0))
236
self.draw_line((double_gap, 0), (vbox_size, 0))
237
238
case 1, x, 1, y:
239
self.draw_junction(1, 0, 1, 0)
240
self.draw_junction(0, x, 0, y)
241
242
case 1, 1, 2, 1:
243
self.ctx.save()
244
self.ctx.scale(-1, 1) # reflect wrt y
245
self.draw_junction(2, 1, 1, 1)
246
self.ctx.restore()
247
248
case 1, 1, 2, 2:
249
if curve_out:
250
self.draw_junction(1, 1, 0, 0)
251
self.draw_junction(0, 0, 2, 2)
252
else:
253
self.draw_line(
254
(0, vbox_size), (0, double_gap), (-vbox_size, double_gap)
255
)
256
257
self.draw_line(
258
(-vbox_size, -double_gap),
259
(-double_gap, -double_gap),
260
(-double_gap, -vbox_size),
261
)
262
263
self.draw_line(
264
(double_gap, -vbox_size), (double_gap, 0), (vbox_size, 0)
265
)
266
267
case 1, 2, 2, 1:
268
self.ctx.save()
269
self.ctx.rotate(-math.pi / 2)
270
self.draw_junction(1, 1, 2, 2)
271
self.ctx.restore()
272
273
case 1, 2, 2, 2:
274
self.draw_junction(0, 2, 2, 2)
275
self.draw_line((double_gap, 0), (vbox_size, 0))
276
277
case _:
278
print(l, d, r, u)
279
print("not implemented / invalid!")

§