File size: 89,421 Bytes
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6742ef0
7d93bf0
db17af1
9f89772
d16591c
020d2dc
 
 
 
 
 
 
 
 
6742ef0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
020d2dc
 
 
 
 
 
71eae78
6742ef0
 
 
 
 
 
 
584bb8c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc4864e
 
 
 
020d2dc
71eae78
020d2dc
 
 
 
71eae78
 
 
 
 
 
 
44bae7c
71eae78
 
42ec301
020d2dc
 
71eae78
 
42ec301
 
 
 
 
71eae78
 
 
 
d16591c
42ec301
 
 
 
71eae78
 
d16591c
42ec301
 
 
 
 
 
71eae78
 
 
 
 
 
 
2428d78
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2428d78
 
 
 
71eae78
2428d78
71eae78
 
 
 
 
2428d78
 
 
71eae78
 
 
 
 
2428d78
71eae78
 
 
 
 
 
 
 
d16591c
 
 
 
 
71eae78
 
 
 
 
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
020d2dc
 
9986f05
 
 
 
 
 
45cc183
 
 
9986f05
 
 
 
 
 
 
 
 
 
 
45cc183
 
9986f05
45cc183
9986f05
 
 
 
 
 
 
d16591c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438638a
 
45cc183
835b445
e6021c6
 
 
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835b445
 
 
 
2428d78
f38d705
 
 
 
 
 
 
835b445
2428d78
f38d705
 
 
 
 
 
 
 
 
 
 
 
 
2428d78
 
 
818aea9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88ca85c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f7f7b5
db17af1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e6021c6
45cc183
 
e6021c6
45cc183
 
 
 
 
 
 
 
 
 
e6021c6
 
 
 
 
 
 
 
 
 
 
ac20418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e6021c6
ac20418
 
 
 
 
 
 
 
 
 
 
e6021c6
ac20418
 
 
 
 
 
 
 
 
 
 
 
e6021c6
ac20418
45cc183
ac20418
e6021c6
 
d1e0284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f89772
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3647d73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f89772
 
 
 
 
 
 
3647d73
 
 
 
 
 
 
 
 
 
 
 
9f89772
3647d73
 
 
 
 
9f89772
3647d73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f89772
 
 
d1e0284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
db17af1
 
 
 
 
 
0b13ced
2428d78
 
0b13ced
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
0b13ced
 
7d93bf0
0b13ced
7d93bf0
 
2428d78
 
0b13ced
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d93bf0
 
 
 
 
 
 
 
0b13ced
 
0d682f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad47ef2
0d682f1
45cc183
 
 
 
 
ad47ef2
 
0d682f1
 
 
 
 
ad47ef2
0d682f1
 
71eae78
 
7d93bf0
0b13ced
 
 
 
 
 
7d93bf0
0b13ced
835b445
 
 
7d93bf0
0b13ced
 
 
 
 
 
 
 
 
 
 
 
 
7d93bf0
0b13ced
 
7d93bf0
835b445
 
 
 
 
 
 
7d93bf0
 
 
 
 
 
 
 
 
 
 
f38d705
7d93bf0
 
 
 
 
f38d705
 
 
 
 
 
7d93bf0
f38d705
 
 
7d93bf0
 
 
 
 
 
 
 
 
32103e5
 
 
 
 
 
 
 
 
 
 
0b13ced
 
 
0d682f1
32103e5
7d93bf0
0b13ced
 
7d93bf0
 
32103e5
 
 
0b13ced
7d93bf0
32103e5
 
 
 
 
 
 
 
 
 
 
 
0b13ced
32103e5
0b13ced
32103e5
0b13ced
32103e5
 
 
 
 
0b13ced
32103e5
 
 
 
835b445
0b13ced
 
 
835b445
 
 
 
32103e5
 
 
 
0b13ced
 
32103e5
0b13ced
 
32103e5
 
 
 
7d93bf0
 
a24e266
45cc183
a24e266
45cc183
 
 
 
a24e266
 
 
 
 
 
 
 
 
 
835b445
45cc183
835b445
45cc183
 
 
835b445
 
 
 
 
 
 
 
 
45cc183
 
835b445
 
a24e266
835b445
 
 
 
 
a24e266
 
438638a
45cc183
 
 
438638a
45cc183
835b445
45cc183
 
 
835b445
45cc183
 
 
 
 
 
835b445
 
 
 
45cc183
 
 
 
835b445
45cc183
835b445
45cc183
 
 
 
 
835b445
f38d705
 
 
45cc183
 
 
 
 
 
f38d705
818aea9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835b445
 
 
 
818aea9
835b445
db17af1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45cc183
db17af1
 
 
45cc183
db17af1
 
 
 
 
 
 
 
835b445
 
 
 
 
 
 
 
 
45cc183
835b445
 
 
 
 
 
 
 
 
 
 
 
 
438638a
835b445
 
 
 
 
438638a
835b445
 
 
 
438638a
835b445
 
 
 
 
 
 
438638a
835b445
 
 
 
f38d705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835b445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438638a
 
947168d
 
 
62f5212
 
 
 
 
 
f38d705
947168d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d40e519
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07c60fa
 
 
 
d40e519
 
 
 
 
 
 
 
 
94ca139
 
 
71eae78
020d2dc
94ca139
71eae78
 
 
 
 
 
 
 
 
020d2dc
 
94ca139
020d2dc
 
94ca139
020d2dc
94ca139
020d2dc
 
 
 
71eae78
020d2dc
 
 
 
 
 
 
 
9f89772
020d2dc
 
 
 
 
 
 
 
 
 
42ec301
9f89772
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42ec301
9f89772
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
020d2dc
07c60fa
020d2dc
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
9f89772
71eae78
 
44bae7c
 
 
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
d16591c
 
 
71eae78
 
 
 
6742ef0
 
 
 
71eae78
d1e0284
71eae78
db17af1
2428d78
9986f05
db17af1
d16591c
42ec301
0b13ced
9f89772
71eae78
d1e0284
71eae78
6742ef0
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94ca139
 
71eae78
 
 
 
2428d78
 
 
9986f05
71eae78
 
 
d16591c
42ec301
2428d78
 
0b13ced
9f89772
71eae78
d1e0284
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6742ef0
 
 
 
 
 
 
 
 
7d93bf0
 
 
 
 
947168d
 
 
 
 
 
2428d78
 
 
 
 
 
 
71eae78
d570ac9
 
 
 
5e94736
 
 
 
 
d570ac9
5e94736
 
d570ac9
2428d78
 
 
 
 
71eae78
 
 
 
2428d78
 
5e94736
 
2428d78
5e94736
44bae7c
 
 
 
 
 
 
 
835b445
 
 
2428d78
 
 
 
f38d705
 
5e94736
 
 
71eae78
7d93bf0
 
71eae78
 
 
 
 
7d93bf0
71eae78
6f7f7b5
 
45cc183
 
 
5e94736
 
 
 
 
 
 
7d93bf0
 
 
 
5e94736
 
6742ef0
 
 
 
5e94736
32103e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e94736
07c60fa
71eae78
 
 
6742ef0
 
 
 
 
 
 
 
 
 
020d2dc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
// Two passes per frame.
//
// Pass 1 β€” the dark world: background, ground and every character as solid
//          black shapes on flat grey. No interior detail anywhere.
// Pass 2 β€” the lit reveal: the same scene drawn with the lit art on an
//          offscreen canvas, masked to the torch shape with a soft radial
//          falloff, then composited over the dark world.
//
// The offscreen canvas and both gradients are allocated once, at construction.
// Nothing here allocates inside the frame loop.
//
// Pass 2 only ever touches the light's bounding box. At a 300px radius that is
// about a ninth of the screen, so the expensive pass costs a ninth of what a
// full-canvas redraw would.

import { W, H, GROUND_Y } from './scene.js';
import { createDepth } from './depth.js';
import { drawBand } from './band.js';
import { TUNING, TORCH, depthAt, DEPTH, TONE, FLASH } from './tuning.js';
import { armPose } from './torch.js';
import { laneLook } from './drained.js';

const BG_DARK = '#8a8a86';    // spec 6
const BG_LIT = '#a39684';     // warmer; only ever seen inside the torch
const GLOW_RGB = '255,180,90'; // #ffb45a
const GLOW_SPREAD = 1.35;      // the glow reaches past the reveal edge

const lerp = (a, b, t) => a + (b - a) * t;

export function createRenderer(ctx) {
  const depth = createDepth();

  // VIGNETTE. Newly legal, and the single most Limbo-defining cue in the
  // reference frames β€” in both of them the light is a POOL in the middle and
  // every edge falls to near black. It was banned outright before, because
  // dragging a 163 sky down to 0 across a large area is the forbidden zone by
  // definition.
  //
  // Allocated once. The gradient never moves, so rebuilding it per frame would
  // be the same mistake the torch mask avoided.
  const vignette = (() => {
    const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.30,
      W / 2, H / 2, Math.hypot(W, H) * 0.56);
    g.addColorStop(0.00, 'rgba(0,0,0,0)');
    g.addColorStop(0.55, 'rgba(0,0,0,0.30)');
    g.addColorStop(1.00, 'rgba(0,0,0,1)');
    return g;
  })();

  function drawVignette(c) {
    if (TONE.VIGNETTE <= 0) return;
    c.save();
    c.globalAlpha = Math.min(1, TONE.VIGNETTE);
    c.fillStyle = vignette;
    c.fillRect(0, 0, W, H);
    c.restore();
  }
  ctx.imageSmoothingEnabled = true;

  const off = document.createElement('canvas');
  off.width = W;
  off.height = H;
  const octx = off.getContext('2d');
  octx.imageSmoothingEnabled = true;

  // Unit-space gradients: defined once at the origin with radius 1, then moved
  // and scaled into place each frame with setTransform. Canvas gradients live
  // in user space, so they transform with the matrix β€” which is why a moving,
  // breathing light needs no reallocation.
  const maskGradient = octx.createRadialGradient(0, 0, 0, 0, 0, 1);
  maskGradient.addColorStop(0.00, 'rgba(0,0,0,1)');
  maskGradient.addColorStop(0.65, 'rgba(0,0,0,1)');   // spec: full opacity to 65%
  maskGradient.addColorStop(0.78, 'rgba(0,0,0,0.86)');
  maskGradient.addColorStop(0.88, 'rgba(0,0,0,0.55)');
  maskGradient.addColorStop(0.95, 'rgba(0,0,0,0.22)');
  maskGradient.addColorStop(1.00, 'rgba(0,0,0,0)');

  // Darkens everything the torch is NOT lighting. Transparent in the middle,
  // opaque at the rim, and the caller fills the rest of the screen solid.
  const dimGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
  dimGradient.addColorStop(0.00, `rgba(${TORCH.DIM_RGB},0)`);
  dimGradient.addColorStop(TORCH.DIM_START, `rgba(${TORCH.DIM_RGB},0)`);
  dimGradient.addColorStop(0.82, `rgba(${TORCH.DIM_RGB},${TORCH.DIM * 0.55})`);
  dimGradient.addColorStop(1.00, `rgba(${TORCH.DIM_RGB},${TORCH.DIM})`);

  // Halo behind the one kept object, so it is the brightest thing in the circle.
  const keptGlow = octx.createRadialGradient(0, 0, 0, 0, 0, 1);
  keptGlow.addColorStop(0.00, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW})`);
  keptGlow.addColorStop(0.50, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW * 0.72})`);
  keptGlow.addColorStop(0.80, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW * 0.24})`);
  keptGlow.addColorStop(1.00, `rgba(${TORCH.KEPT_RIM},0)`);

  const glowGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
  glowGradient.addColorStop(0.00, `rgba(${GLOW_RGB},0.22)`);
  glowGradient.addColorStop(0.45, `rgba(${GLOW_RGB},0.12)`);
  glowGradient.addColorStop(0.80, `rgba(${GLOW_RGB},0.04)`);
  glowGradient.addColorStop(1.00, `rgba(${GLOW_RGB},0)`);

  // D walks right now, so the debug readout moved to the backtick key.
  //
  // IT MUST BE OFF WHENEVER THE FRAME IS MEASURED. It is antialiased black text
  // on a bright sky, so it sits in the forbidden zone by construction, and it
  // covers enough of the frame to swamp what it is measuring β€” a baseline that
  // read 0.78% was really 0.199%. Worse, the line gets LONGER when a cue is
  // switched on, because it prints that cue's name, so every cue was charged
  // for its own label. setDebug exists so a test can never forget.
  // OFF BY DEFAULT, AND THAT IS NOT A TIDYING-UP.
  //
  // It was on, everywhere, including on the public link β€” and it prints
  // "[E] PIN IT HERE" the moment you are standing at a door holding a page.
  // Which means nobody who was ever sent that link played this game: they were
  // told the one thing the whole build exists to find out whether a person can
  // work out on their own. The person who designed it could not get a clean
  // first impression from anybody, including himself.
  //
  // It is a dev overlay β€” fps, pass timings, world x, canopy percent, clip
  // names β€” and it stays exactly as it was. Backtick toggles it, ?debug=1
  // brings it up from the address bar. It has just stopped being the first
  // thing a stranger sees.
  //
  // (A phone has no backtick key and no address bar worth typing in, so on a
  // handset this is now simply gone, which it should always have been: eleven
  // lines of instrumentation over the top of the game.)
  let showDebug = false;
  if (typeof location !== 'undefined') {
    const want = new URLSearchParams(location.search).get('debug');
    if (want !== null) showDebug = want !== '0' && want !== 'false';
  }
  window.addEventListener('keydown', (e) => {
    if (e.code === 'Backquote') showDebug = !showDebug;
  });

  // Anchor a clip's frame at a world point. The pivot comes from the manifest,
  // so art of any size drops in without touching this.
  //
  // Mirrors only when the character is facing the opposite way to how its art
  // was DRAWN. Not every sprite is drawn facing right: the prince is, the
  // Drained is drawn facing left, and a blanket "mirror when facing is -1" rule
  // drew the Drained backwards every time it moved. The manifest says which way
  // each clip was authored.
  //
  // docs/ART_HANDOFF.md used to promise nothing was mirrored in code. That stopped
  // being true the moment the prince got a facing and could be attacked from
  // either side. The pivot stays on the anchor through the flip.
  function drawClip(c, clip, index, mode, worldX, worldY, camX = 0, facing = 1, dimEye = 0) {
    const f = clip.frames[index];
    if (!f) return;
    const sx = Math.round(worldX - camX);
    const sy = Math.round(worldY);
    // THE EYE IS PAINTED OVER, not painted in. It is already white in both
    // passes; this is a black stencil of exactly those pixels, drawn on top at
    // the alpha the health asks for. At 0 it is not drawn at all, so the
    // ordinary case costs nothing.
    const dim = f.eyes && dimEye > 0.01 ? Math.min(1, dimEye) : 0;
    if (facing !== (clip.authoredFacing ?? 1)) {
      c.save();
      c.translate(sx, sy);
      c.scale(-1, 1);
      c.drawImage(f[mode] ?? f.dark, -clip.pivotX, -clip.pivotY);
      if (dim) {
        c.globalAlpha = dim;
        c.drawImage(f.eyes, -clip.pivotX, -clip.pivotY);
      }
      c.restore();
    } else {
      c.drawImage(f[mode] ?? f.dark, sx - clip.pivotX, sy - clip.pivotY);
      if (dim) {
        c.save();
        c.globalAlpha = dim;
        c.drawImage(f.eyes, sx - clip.pivotX, sy - clip.pivotY);
        c.restore();
      }
    }
  }

  // The one thing each of them kept. Drawn ON the figure in both passes: in
  // silhouette it merges into the black mass and says nothing, and in the light
  // it is the only thing that says who this was.
  function drawKept(c, keptClip, d, mode, worldX, camX, clock = 0) {
    // Eight of the village's twelve kept nothing. A fight that drops nothing is
    // the negative result and it needs no marking β€” but without this the lit
    // pass still painted the halo, so an empty-handed shape glowed at the exact
    // spot where an object it does not have would be.
    if (!keptClip || d.kept == null || d.keptTaken) return;
    const face = d.facing === (d.clip.authoredFacing ?? 1) ? 1 : -1;

    // Two placements, and which one applies is a fact about the body.
    //
    // Still standing, it rides the figure at keptAnchor: part of the black mass,
    // saying nothing until the torch finds it.
    //
    // Once it has been put down, the object is out and on the ground at a point
    // frozen at the moment it fell. It does not travel with the body, and it
    // does not climb back on when the body gets up an hour later.
    let px, oy;
    if (d.keptDropped) {
      px = d.keptX;
      oy = d.keptY;
    } else {
      // One anchor, or one per frame. A trade loop swings its working hand all
      // the way over the head, so a single point would leave the hammer hanging
      // in mid-air while the arm went past it.
      let a = d.clip.keptAnchor;
      if (!a) return;
      if (Array.isArray(a[0])) a = a[Math.min(d.frame, a.length - 1)];
      px = worldX + (a[0] - d.clip.pivotX) * face;
      oy = a[1] - d.clip.pivotY;
    }
    const sx = Math.round(px - camX);
    const sy = Math.round(GROUND_Y + oy);
    // A spear is not the size of a hammer. Per variant, so the one object that
    // has to out-reach a sword in silhouette can, without inflating the rest.
    const kScale = keptClip.variantScale ? (keptClip.variantScale[d.kept] ?? 1) : 1;

    // The halo. Inside the torch it separates the object from the body's own
    // line work. Outside it, on a dropped object only, it is the thing that
    // tells you from across the screen that there is something there at all.
    if (mode === 'lit' || d.keptDropped) {
      const R = (keptClip.width / 2) * TORCH.KEPT_GLOW_R * kScale;
      // Breathing, not steady. Alpha on the pre-allocated gradient rather than
      // rebuilding it, so the pulse costs nothing.
      const pulse = (1 - TORCH.KEPT_PULSE_DEPTH *
        (0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ))) *
        (mode === 'lit' ? 1 : TORCH.KEPT_GLOW_DARK);
      c.save();
      c.globalCompositeOperation = 'lighter';
      c.globalAlpha = pulse;
      // COMPOSED, not set. A page on a figure in the back lane is drawn under
      // that lane's scale and lift, and setTransform threw both away: the
      // page went up with the body and its halo stayed 56px below it.
      c.translate(sx, sy);
      c.scale(R, R);
      c.fillStyle = keptGlow;
      c.fillRect(-1, -1, 2, 2);
      c.restore();
    }
    // The object keeps its own black line work. The colour is all in the halo
    // behind it, so it has a hot pool to sit in and an edge to read against.
    //
    // AND IT TURNS WITH THE HAND. A trade loop swings a hammer through 100
    // degrees; drawn axis-aligned the head stays pointing at the sky while the
    // arm comes down, and the whole thing reads as a shape floating near a man
    // rather than a thing he is holding. Negated when he is mirrored, or the
    // hammer turns the wrong way on half the village.
    // Held, it turns with the hand. Dropped, it keeps the art's own angle.
    const angles = d.keptDropped ? null : d.clip.keptAngle;
    const ang = angles ? angles[Math.min(d.frame, angles.length - 1)] : 0;
    if (ang || kScale !== 1) {
      c.save();
      c.translate(sx, sy);
      if (ang) c.rotate(face >= 0 ? ang : -ang);
      if (kScale !== 1) c.scale(kScale, kScale);
      c.translate(-sx / kScale, -sy / kScale);
      drawClip(c, keptClip, d.kept, mode, px / kScale, (GROUND_Y + oy) / kScale,
        camX / kScale, face * (keptClip.authoredFacing ?? 1));
      c.restore();
    } else {
      drawClip(c, keptClip, d.kept, mode, px, GROUND_Y + oy, camX,
        face * (keptClip.authoredFacing ?? 1));
    }
  }

  /**
   * The tree with the note on it. On the road at his own scale, so it draws
   * with the houses: black in the dark pass, its bark in the lit one.
   *
   * THE NOTE, BOTH WAYS. In the dark the scrap shows pale β€” paper catching
   * what light there is, so a lone tree says "something is pinned here" the
   * way a house's lit doorway does. Under the torch it is the note on its
   * nail, drawn by drawNail like every letter. It stays there, and there is
   * no slot under it: nothing is ever pinned back to a tree.
   */
  function drawTree(c, s, camX, mode) {
    const t = s.tree;
    const clip = s.treeClip;
    if (!t || !clip) return;
    const sx = Math.round(t.x - camX);
    if (sx < -clip.width || sx > W + clip.width) return;
    const x = sx - clip.pivotX;
    const y = GROUND_Y - clip.pivotY;
    c.drawImage(clip.frames[0][mode === 'lit' ? 'lit' : 'dark'], x, y);
    if (mode === 'lit') {
      drawNail(c, s, clip, t, x, y, { slot: false });
    } else if (s.treeNoteDark) {
      const art = s.letterArt;
      const b = nailBox(clip.letterAt?.[t.variant], x, y, art);
      if (b) {
        c.drawImage(s.treeNoteDark, b.px - b.ink.x * b.k, b.py - b.ink.y * b.k,
          art.width * b.k, art.height * b.k);
      }
    }
  }

  /**
   * One Drained, in whichever lane it is standing in.
   *
   * THE BACK LANE IS A TRANSFORM ABOUT ITS FEET. Scaled and lifted around the
   * point it stands on, so the figure, the page it carries and the page's own
   * anchor all go together and nothing below here has to know. On the road it
   * is the identity and costs one branch.
   *
   * In the dark pass it also takes the lane's VALUE: the tinted `back` frames,
   * with the black ones laid over them at the share of the way it has stepped
   * down. Both have the same alpha, so the blend is a value and never a
   * see-through figure. The lit pass keeps its lit art in either lane β€” the
   * torch shows what they are, and value is a thing only a silhouette has.
   */
  function drawDrained(c, s, d, mode, x, camX) {
    // THE STARE, BUILDING. Nothing while the light is off it, and nothing for
    // the first STIR_TREMBLE_FROM of the stare either: that part is the gasp
    // and the turn. Then a shiver, growing to a hard shake by the moment it
    // wakes. Squared, so it starts small. Offset per body so two lit at once
    // do not shake in step.
    const from = TUNING.STIR_TREMBLE_FROM;
    const k = d.stir > from ? (d.stir - from) / (1 - from) : 0;
    if (k > 0) x += Math.sin((s.clock ?? 0) * 71 + d.spawnX * 0.37) * TUNING.STIR_TREMBLE_PX * k * k;
    const L = laneLook(d.lane);
    if (L.lane <= 0) {
      drawClip(c, d.clip, d.frame, mode, x, GROUND_Y, camX, d.facing);
      drawKept(c, s.kept, d, mode, x, camX, s.clock);
      return;
    }
    const sx = x - camX;
    c.save();
    c.translate(sx, GROUND_Y - L.rise);
    c.scale(L.scale, L.scale);
    c.translate(-sx, -GROUND_Y);
    if (mode === 'dark') {
      drawClip(c, d.clip, d.frame, 'back', x, GROUND_Y, camX, d.facing);
      drawKept(c, s.kept, d, 'back', x, camX, s.clock);
      if (L.lane < 1) {
        c.globalAlpha = 1 - L.lane;
        drawClip(c, d.clip, d.frame, 'dark', x, GROUND_Y, camX, d.facing);
        drawKept(c, s.kept, d, 'dark', x, camX, s.clock);
      }
    } else {
      drawClip(c, d.clip, d.frame, mode, x, GROUND_Y, camX, d.facing);
      drawKept(c, s.kept, d, mode, x, camX, s.clock);
    }
    c.restore();
  }

  /**
   * Where the back lane is standing this frame, as screen spans, for the
   * watchers to keep out of. Off each figure's own ink rather than its canvas
   * β€” a loop's canvas is 380 wide to give the swing room, and the body in it
   * is a third of that β€” and mirrored with it. One buffer, reused.
   */
  const laneSpans = new Float32Array(32 * 3);
  function backLaneSpans(s, camX, alpha) {
    let n = 0;
    for (const d of s.drained) {
      if (!(d.lane > 0) || n >= 32) continue;
      const x = lerp(d.prevX, d.x, alpha) - camX;
      if (x < -400 || x > W + 400) continue;
      const L = laneLook(d.lane);
      const ink = d.clip.ink;
      let l = ink ? ink.x - d.clip.pivotX : -60;
      let r = ink ? ink.x + ink.w - d.clip.pivotX : 60;
      if (d.facing !== (d.clip.authoredFacing ?? 1)) { const t = l; l = -r; r = -t; }
      laneSpans[n * 3] = x + l * L.scale;
      laneSpans[n * 3 + 1] = x + r * L.scale;
      laneSpans[n * 3 + 2] = L.lane;
      n++;
    }
    return n;
  }

  /**
   * Every Drained on screen, BACK LANE FIRST. Whatever stands beside the road
   * is behind whatever is on it, and one partway through stepping down is
   * between the two. Three sweeps over a dozen bodies rather than a sort, so
   * the frame still allocates nothing.
   */
  function drawAllDrained(c, s, mode, camX, alpha) {
    for (let pass = 0; pass < 3; pass++) {
      for (const d of s.drained) {
        const lane = d.lane ?? 0;
        if (pass === 0 ? lane < 1 : pass === 1 ? (lane <= 0 || lane >= 1) : lane > 0) continue;
        if (d.x - camX < -400 || d.x - camX > W + 400) continue;
        drawDrained(c, s, d, mode, lerp(d.prevX, d.x, alpha), camX);
      }
    }
  }

  // The village. Five houses and a shut gate, at the actor's own parallax rate
  // because they stand on the same ground he does.
  //
  // V1 IS DELIBERATELY BOXES. A gable and a body, pure black, sitting on the
  // ground line. Art is V2 and the door that lights is the whole of V2, but the
  // question V1 asks β€” is the room the right size, and does a building tell you
  // the mode changed without a word β€” is answerable from a silhouette.
  //
  // Pure black, like every other built thing at this distance. The palette rule
  // lets the BACKGROUND carry tone; anything standing on the ground line stays
  // black, or the prince stops being the darkest shape in his own frame.
  function drawVillage(c, s, camX, mode = 'dark') {
    if (!s.houses) return;
    const clip = s.houseClip;
    if (clip && s.houseArt) {
      for (const h of s.houses) {
        const sx = Math.round(h.x - camX);
        if (sx < -clip.width || sx > W + clip.width) continue;
        const x = sx - clip.pivotX;
        const y = GROUND_Y - clip.pivotY;
        // DARK: the tinted silhouette, one solid mass, saying nothing.
        // LIT: the art itself β€” the wall at the same value, and the door open
        // with a trade standing in it. The torch mask does the rest, so a door
        // only shows where the light actually falls.
        if (mode === 'lit') {
          c.drawImage(clip.frames[h.variant].lit, x, y);
          drawNail(c, s, clip, h, x, y);
          drawRoomKey(c, s, clip, h, x, y, s.clock ?? 0);
        } else {
          c.drawImage(s.houseArt[h.variant], x, y);
          drawDoorGlow(c, s, clip, h, x, y, s.clock ?? 0);
        }
      }
    } else {
      // Before the art exists: boxes, which is enough to judge the size of the
      // room but not the mechanic.
      c.fillStyle = s.houseTint;
      for (const h of s.houses) {
        const sx = Math.round(h.x - camX);
        if (sx < -h.w || sx > W + h.w) continue;
        c.fillRect(sx - h.w / 2, GROUND_Y - 560, h.w, 560);
      }
      c.fillStyle = '#000';
    }
    // The gate. Palace-built, shut, and it does not open until he is carrying
    // four objects he has looked at. V1 is the barrier only β€” the four marks
    // that state its terms, and the opening, are V5.
    //
    // NOT ONE BLACK SLAB. The first pass was, and the gatekeeper standing at
    // his own post disappeared into it exactly the way the prince disappeared
    // into the houses. So it is built like a gate: black posts, a black lintel
    // and black wall running off past the frame, with the shut LEAF between
    // them at the same set-back value the houses use. Everything that stands in
    // front of the gate stands in front of the leaf, and reads.
    const gx = Math.round(s.gateX - camX);
    if (gx > W + 520) return;
    // 420, not 280. At 280 the opening was 560px wide and the prince stopped
    // INSIDE the left post β€” pure black behind a pure black figure again, one
    // layer further in. The gateway has to be wide enough to stand in.
    const half = 420, post = 100;
    const leafTop = 200, leafBot = GROUND_Y + 40;
    // THE LEAF LIFTS. Straight up and out of sight, the way a thing that heavy
    // is raised rather than swung. It is the only moving piece of the village
    // and it moves once.
    const open = s.gate?.open ?? 0;
    const lift = open * (leafBot - leafTop + 40);
    c.save();
    c.beginPath();
    c.rect(gx - half, leafTop, half * 2, leafBot - leafTop);
    c.clip();
    c.fillStyle = s.houseTint;
    c.fillRect(gx - half, leafTop - lift, half * 2, leafBot - leafTop);

    // FOUR MARKS, and this is how the gate states its terms. A door that simply
    // does nothing reads as broken; four empty sockets read as four of
    // something wanted. They are on the GATE rather than in the map because
    // they need no interaction, and because being blocked is the moment the
    // player asks the question.
    //
    // Live, not latched: it counts what he is carrying and has looked at, so
    // putting one down puts a mark out again.
    const filled = s.gate?.filled ?? 0;
    // HIGH ON THE LEAF, well above head height. At chest height the leftmost
    // one sat behind the gatekeeper and the prince standing in front of him,
    // so the gate asked for three things instead of four.
    const mw = 130, mh = 150, mgap = 80;
    // OFF GATE_WANTS, not off a literal four. The leaf and the requirement
    // used to be two separate fours and changing one left the other lying.
    const n = TUNING.GATE_WANTS;
    const row = gx - ((mw * n + mgap * (n - 1)) / 2);
    const my = leafTop + 70 - lift;
    // AN EMPTY SOCKET BREATHES, the same as an empty patch on a door and for
    // the same reason. Four holes in a leaf have always asked their own
    // question β€” but only of somebody who already knows the answer is a key.
    // The first person to reach this gate carrying four of them put one in by
    // accident, pressing the button to see what it would do.
    const sock = 1 - TORCH.KEPT_PULSE_DEPTH *
      (0.5 - 0.5 * Math.cos((s.clock ?? 0) * Math.PI * 2 * TORCH.KEPT_PULSE_HZ));
    for (let i = 0; i < TUNING.GATE_WANTS; i++) {
      const mx = row + i * (mw + mgap);
      if (i >= filled) {
        const scx = mx + mw / 2;
        const scy = my + mh / 2;
        const sr = Math.max(mw, mh) * 0.95;
        const sg = c.createRadialGradient(scx, scy, 0, scx, scy, sr);
        sg.addColorStop(0, `rgba(${TORCH.KEPT_RGB},${(TORCH.KEPT_GLOW * sock * 0.8).toFixed(3)})`);
        sg.addColorStop(1, `rgba(${TORCH.KEPT_RGB},0)`);
        const wasOp = c.globalCompositeOperation;
        c.globalCompositeOperation = 'lighter';
        c.fillStyle = sg;
        c.fillRect(scx - sr, scy - sr, sr * 2, sr * 2);
        c.globalCompositeOperation = wasOp;
      }
      c.fillStyle = '#000';
      c.fillRect(mx, my, mw, mh);
      if (i >= filled) {
        // EMPTY: A KEY-SHAPED HOLE, not a rectangular one.
        //
        // Four rectangles ask a question and do not say what the answer is.
        // Somebody arrived here carrying four keys and put one in by accident,
        // pressing the button to see what it would do. A lock the shape of its
        // key needs no telling β€” and it is the same idea as the mark on a door
        // being the shape of the page.
        if (s.socketArt && s.keyArt) {
          const ka = s.keyArt;
          const kin = ka.ink ?? { x: 0, y: 0, w: ka.width, h: ka.height };
          const kk = Math.min((mw - 30) / kin.w, (mh - 24) / kin.h);
          c.drawImage(s.socketArt,
            mx + (mw - kin.w * kk) / 2 - kin.x * kk,
            my + (mh - kin.h * kk) / 2 - kin.y * kk,
            ka.width * kk, ka.height * kk);
        } else {
          c.fillStyle = s.houseTint;
          c.fillRect(mx + 12, my + 12, mw - 24, mh - 24);
        }
      }
    }
    c.restore();

    c.fillStyle = '#000';
    c.fillRect(gx - half - post, 120, post, leafBot - 120);
    c.fillRect(gx + half, 120, post, leafBot - 120);
    c.fillRect(gx - half - post - 20, 80, (half + post) * 2 + 40, 130);
    c.fillRect(gx + half + post, 0, W + 520 - (gx + half + post), GROUND_Y + 200);
  }

  /**
   * The map, and it is a prop rather than a screen.
   *
   * A sheet anchored at the pose's mapPivot, the same way the flame is anchored
   * at handPivot β€” which is what lets the sheet be LIT while he stays a
   * silhouette inside his own torchlight. In pass 1 it is a black rectangle in
   * his hand, because a sheet of paper is a thing that blocks light. In pass 2
   * it is paper with a village drawn on it.
   *
   * IT RECORDS AND IT NEVER REASONS. Five plots along a street in the order he
   * walks past them; a building appears when he has lit its door, and its
   * object drops into the slot beneath when he has taken it AND looked at it.
   * Anything taken blind sits along the top, filed against nothing.
   *
   * No error state, no completion state, and an empty slot is never flagged.
   * The empty slot is the finding.
   */
  // THE PAGE IN HIS HAND IS GONE, and it used to be drawn here.
  //
  // It came in with the top-page model, to say what he was holding without
  // making him raise anything. It worked, and it looked wrong: a small pale
  // shape at hip height on a black silhouette, and there is exactly one thing
  // a person reads that as.
  //
  // It is also redundant now. When it was added the pile was still called
  // "pages" and keys were an invisible number; the inventory holds everything
  // he has and choosing in it is how he picks what to use. A shape at the hip
  // was answering a question the inventory now answers properly.
  //
  // `state.handPage` stays. It is still the top of the pile, still what E acts
  // on at a door, and still what the wall previews. It is simply not drawn on
  // his body.

  /**
   * THE SKY GOING OFF, and where it is drawn is the whole trick.
   *
   * AFTER the ground and the houses, BEFORE the people. So the sky, the hills,
   * the road and the walls go white and every figure in front of them stays
   * pure black β€” which is the picture this game is already built out of, at
   * maximum contrast, for a quarter of a second. You see the entire street and
   * everyone standing in it, with no torch.
   *
   * A wash over the FINISHED frame would lift the silhouettes with everything
   * else and flatten the one thing that matters. This is the difference
   * between lightning and a camera flash.
   */
  /**
   * THE BOLT, and it is the only thing in this game that is light.
   *
   * Everything else is a black shape stopping light. This is the source, so it
   * is the one drawing that is never silhouetted and never tinted β€” it goes
   * down exactly as it was drawn, pale blue-white on a dark sky.
   *
   * BEFORE THE HOUSES, so a building stands in front of it the way a building
   * stands in front of weather. And before the flash, so the white sky washes
   * it out at the peak rather than the bolt sitting on top of a blown-out sky
   * β€” which is the order these two things happen in.
   *
   * SMOOTHED, which is the opposite of what pixel art usually wants. At the
   * size it has to be to read, nearest-neighbour steps are obvious and there is
   * no other pixel art anywhere in this game. Interpolated, the steps become
   * the soft glow a bolt has anyway.
   */
  function drawBolt(c, s) {
    const clip = s.lightning;
    const ms = s.flashMs ?? -1;
    if (!clip || !(ms >= 0)) return;
    // Number, not NaN. These constants live on FLASH and were read off TUNING
    // once: `ms / undefined` is NaN, every comparison against NaN is false, so
    // it fell through to `frames[NaN]` and returned without drawing and
    // without complaining. A guard that cannot be satisfied looks exactly like
    // an effect that is switched off.
    const i = Math.floor(ms / FLASH.BOLT_FRAME_MS);
    if (!Number.isFinite(i) || i < 0 || i >= clip.count) return;
    const frame = clip.frames[i];
    if (!frame) return;
    const h = FLASH.BOLT_H;
    const w = h * (clip.width / clip.height);
    const was = c.imageSmoothingEnabled;
    c.imageSmoothingEnabled = true;
    c.drawImage(frame.lit, Math.round(W * FLASH.BOLT_AT - w / 2), 30, w, h);
    c.imageSmoothingEnabled = was;
  }

  /**
   * A warm pulse at a door, OUT IN THE DARK, with the torch down β€” for a key
   * whose door somebody opened, and nothing else.
   *
   * The key only ever existed inside the torchlight, because everything in a
   * doorway is drawn in the lit pass. So the street said nothing: you had to
   * already be holding the light on a door to learn there was anything at it,
   * which is the wrong way round β€” the light is what you spend, and something
   * has to be worth spending it on.
   *
   * THE EMPTY SLOT USED TO PULSE HERE TOO, and does not any more. It is a
   * value, not a glow: a mark on a wall that breathes in the dark is UI, and
   * the loom house's slot, which nothing ever fills, would have been asking
   * for the whole level in a voice the game uses for "take this".
   *
   * THE DROPPED SCRAPS HAVE ALWAYS DONE THIS. "A warm pulse out there is a
   * thing you notice from across the screen, and it is the only reason to walk
   * over; the torch is what turns the glowing dot into a page with writing on
   * it." Same tuning value, same rhythm, same bargain β€” the glow says HERE and
   * only the torch says WHAT.
   *
   * Glow only, no art. In the dark a black key on a wall at luminance 26 is
   * nothing; the pool is the whole signal.
   */
  function drawDoorGlow(c, s, clip, house, x, y, clock) {
    // THE SAME GLOW A DROPPED PAGE HAS, off the same gradient and the same
    // maths β€” because the first version was not. It took the rect's LONGEST
    // side, multiplied by KEPT_GLOW_R and then again by 1.6, and drew no object
    // at all: a 350px formless brown smear on a wall. The tuning note says
    // exactly why that is wrong β€” "multiples of the object's HALF-WIDTH.
    // Tighter = hotter" β€” and the pool is meant to have a thing sitting in it.
    //
    // So: half the art's width, one pool, and the black shape inside it. A
    // page-shaped dark mark in a hot spot, which is a dropped scrap with a nail
    // through it.
    const pulse = (1 - TORCH.KEPT_PULSE_DEPTH *
      (0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ))) *
      TORCH.KEPT_GLOW_DARK;

    const at = (rect, art) => {
      if (!rect || !art) return;
      const [rx, ry, rw, rh] = rect;
      const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
      const k = Math.min(rw / ink.w, rh / ink.h);
      const w = ink.w * k;
      const h = ink.h * k;
      const dx = x + rx + (rw - w) / 2;
      const dy = y + ry + (rh - h) / 2;
      const sx = dx + w / 2;
      const sy = dy + h / 2;
      // Half the drawn width, exactly as the dropped page measures it.
      const R = (w / 2) * TORCH.KEPT_GLOW_R;
      c.save();
      c.globalCompositeOperation = 'lighter';
      c.globalAlpha = pulse;
      c.setTransform(R, 0, 0, R, sx, sy);
      c.fillStyle = keptGlow;
      c.fillRect(-1, -1, 2, 2);
      c.restore();
      // And the thing itself, black, sitting in its own pool β€” the object is
      // what makes it a page on a wall rather than a light on a wall.
      const frame = art.frames[0];
      if (frame) {
        c.drawImage(frame.dark, dx - ink.x * k, dy - ink.y * k,
          art.width * k, art.height * k);
      }
    };

    // A key whose door somebody opened.
    if (house.open && !house.keyTaken) at(clip.keyAt?.[house.variant], s.keyArt);
  }

  /**
   * A low mist on the ground: three layers of it, drifting, in front of
   * everybody.
   *
   * THE FIRST VERSION WAS A FLAT LINEAR GRADIENT and it was judged and
   * rejected in one sentence: "it looks like the sky." Which it did, and for
   * the obvious reason β€” a smooth band of even tone that never moves is
   * shading, not weather. Two things separate fog from a wash, and it needs
   * both: it is PATCHY, and it MOVES.
   *
   * So it is built the way everything else in this file is built: one soft
   * blob gradient made once in unit space, then stamped with setTransform at
   * whatever size and place each puff wants β€” the same trick the dropped
   * pages' halo uses, for the same reason, which is that building a gradient
   * per puff per frame is forty gradients a frame for nothing.
   *
   * THREE LAYERS, AND THE PARALLAX IS THE POINT. Each drifts at its own rate
   * against the camera and slides on its own slow clock, so the near mist
   * overtakes the far mist and the bank never reads as one sheet. Position and
   * size come off a hash of the puff's index, so it is patchy in a way that is
   * the same every run and never repeats inside a screen.
   *
   * IN FRONT AND NOT BEHIND. Fog behind everybody is a backdrop; fog in front
   * of them is weather, and only weather takes anybody's feet.
   */
  const fogPuff = octx.createRadialGradient(0, 0, 0, 0, 0, 1);
  fogPuff.addColorStop(0.00, 'rgba(150,168,186,0.42)');
  fogPuff.addColorStop(0.30, 'rgba(142,160,180,0.30)');
  fogPuff.addColorStop(0.58, 'rgba(132,150,170,0.12)');
  fogPuff.addColorStop(0.82, 'rgba(126,144,164,0.03)');
  fogPuff.addColorStop(1.00, 'rgba(126,144,164,0)');

  // LUMPS, NOT A BAND. The first pass spaced the puffs closer together than
  // they were wide, so they merged into one even wash β€” which is the flat
  // gradient again wearing a costume. These are spaced wider than their own
  // radius, so the top edge dips between them and you can see the crests.
  //
  // far, mid, near: [parallax against the camera, drift px/s, puffs across the
  // view, wide, tall, how far the centre sinks below the ground line, weight]
  const FOG_BANDS = [
    [0.30,  7, 4, 0.62, 0.78, 0.10, 0.50],
    [0.62, 16, 5, 0.54, 0.58, 0.18, 0.78],
    [1.00, 34, 7, 0.44, 0.42, 0.26, 1.00],
  ];

  function drawFog(c, camX, clock) {
    const k = TONE.FOG;
    if (!(k > 0)) return;
    const H0 = TONE.FOG_H;
    const was = c.globalAlpha;
    for (let b = 0; b < FOG_BANDS.length; b++) {
      const [par, drift, n, wide, tall, sink, weight] = FOG_BANDS[b];
      const span = W / n;
      const shift = camX * par + clock * drift;
      const period = span * (n + 2);
      for (let i = -1; i <= n; i++) {
        // Deterministic per puff, so the bank is the same bank every run and
        // no two in a band share a size, a height or a spacing.
        const id = i + b * 97;
        const h1 = ((Math.sin(id * 12.9898) * 43758.5453) % 1 + 1) % 1;
        const h2 = ((Math.sin(id * 78.233) * 12345.6789) % 1 + 1) % 1;
        const h3 = ((Math.sin(id * 39.425) * 24634.6345) % 1 + 1) % 1;
        const rx = span * wide * (0.74 + h1 * 0.52);
        const ry = H0 * tall * (0.62 + h2 * 0.76);
        // The extra h3 jitter on x is what stops the crests arriving on a beat.
        const slot = i * span + (h3 - 0.5) * span * 0.55;
        const x = ((slot - shift) % period + period) % period - span;
        const y = GROUND_Y + ry * sink + (h2 - 0.5) * H0 * 0.10;
        c.globalAlpha = k * weight;
        c.save();
        c.setTransform(rx, 0, 0, ry, x, y);
        c.fillStyle = fogPuff;
        c.fillRect(-1, -1, 2, 2);
        c.restore();
      }
    }
    c.globalAlpha = was;
  }

  /**
   * THE ARM THAT HOLDS THE TORCH, and it is a placeholder.
   *
   * Her standing drawing has both arms down, so the flame was pinned to the
   * far hand at hip height and a burning torch sat at his crotch. This draws
   * that same arm jointed, from the geometry the manifest publishes, so it can
   * go up: two tapered bones and a fist, angles lerped by the raise.
   *
   * LIVE RATHER THAN BAKED TO FRAMES, because the raise is a smooth 250ms ramp
   * and six frames would step through it. Delete the whole thing the day the
   * raised-torch pose is drawn.
   *
   * It takes the passes the way the body does: flat black in the dark, and in
   * the lit pass white with an ink line round it, which is how her drawing is
   * built.
   */
  /**
   * A TAPERED LIMB, filled, the way gen_placeholders.js draws every other one.
   *
   * Stamped rather than stroked: a quad between each pair of spine points at
   * their own half-widths, plus a disc at each point. The discs round the
   * joints and paper over the pinch an offset curve gets at a sharp elbow, and
   * since it is all one flat black fill the overlap costs nothing.
   *
   * A constant-width stroke was the first attempt and it read as a stick,
   * because an arm is not a constant width: it is thick at the shoulder, bulges
   * at the bicep, narrows at the elbow, bulges again at the forearm and ends
   * thin at the wrist. That profile IS the difference between a limb and a line.
   */
  function fillLimb(c, spine, w) {
    for (let i = 0; i < spine.length - 1; i++) {
      const [ax, ay] = spine[i];
      const [bx, by] = spine[i + 1];
      const dx = bx - ax, dy = by - ay;
      const len = Math.hypot(dx, dy) || 1;
      const nx = -dy / len, ny = dx / len;
      c.beginPath();
      c.moveTo(ax + nx * w[i], ay + ny * w[i]);
      c.lineTo(bx + nx * w[i + 1], by + ny * w[i + 1]);
      c.lineTo(bx - nx * w[i + 1], by - ny * w[i + 1]);
      c.lineTo(ax - nx * w[i], ay - ny * w[i]);
      c.closePath();
      c.fill();
    }
    for (let i = 0; i < spine.length; i++) {
      c.beginPath();
      c.arc(spine[i][0], spine[i][1], w[i], 0, Math.PI * 2);
      c.fill();
    }
  }

  function drawTorchArm(c, s, mode, px, camX, face, eased) {
    const arm = s.torchArm;
    if (!arm || eased <= 0.001) return;
    const p = armPose(arm, eased);
    const x0 = Math.round(px - camX);
    const y0 = GROUND_Y;
    const at = (o) => [x0 + face * o[0], y0 + o[1]];
    const S = at(p.shoulder), E = at(p.elbow), Wr = at(p.wrist), Fi = at(p.fist);
    // Midpoints carry the bicep and the forearm, bowed a little off the bone so
    // the limb is not two straight runs meeting at a corner.
    const bow = (a, b, k) => {
      const dx = b[0] - a[0], dy = b[1] - a[1];
      const len = Math.hypot(dx, dy) || 1;
      return [(a[0] + b[0]) / 2 - (dy / len) * k * face,
        (a[1] + b[1]) / 2 + (dx / len) * k * face];
    };
    const spine = [S, bow(S, E, 2.5), E, bow(E, Wr, -2), Wr];
    // shoulder, bicep, elbow, forearm, wrist -- HALF-widths, from the manifest.
    const w = arm.widths;
    c.save();
    // FLAT BLACK IN BOTH PASSES. He is never lit by his own torch -- his body
    // goes into the lit pass with its DARK art for exactly that reason -- and
    // an arm painted white with an ink line round it was a pale stick glowing
    // on a black silhouette. `mode` is taken and ignored on purpose, so the
    // call sites read like every other draw in here.
    c.fillStyle = '#000';
    fillLimb(c, spine, w);
    // THE BRAND, drawn here rather than baked into the fire sprite: the fire is
    // a photograph of fire and the stick under it is ours, and they move on
    // different rules -- the stick follows the wrist, the fire just sits on it.
    const B = at(p.butt), Hd = at(p.head);
    const ux = (Hd[0] - B[0]) / (arm.shaftLen + arm.bindLen);
    const uy = (Hd[1] - B[1]) / (arm.shaftLen + arm.bindLen);
    const sh = [B[0] + ux * arm.shaftLen, B[1] + uy * arm.shaftLen];
    fillLimb(c, [B, sh], arm.shaftW);
    fillLimb(c, [sh, Hd], [arm.bindW, arm.bindW]);
    // The fist LAST, so his hand closes over the shaft instead of behind it.
    const gx = Fi[0] - Wr[0], gy = Fi[1] - Wr[1];
    c.translate(Fi[0], Fi[1]);
    c.rotate(Math.atan2(gy, gx));
    c.beginPath();
    c.ellipse(0, 0, arm.hand * 1.3, arm.hand, 0, 0, Math.PI * 2);
    c.fill();
    c.restore();
  }

  /**
   * The sky losing its light as he goes deeper.
   *
   * The canopy already closes on depthAt(). The sky behind it is pixel
   * identical from the first step to the gate, so walking a hundred seconds
   * into something never looks like walking into anything. One wash over the
   * sky band, on the curve that already exists.
   */
  function drawSkySink(c, worldX) {
    const k = TONE.SKY_SINK;
    if (!(k > 0)) return;
    c.fillStyle = `rgba(6,10,18,${(depthAt(worldX) * k * 0.55).toFixed(3)})`;
    c.fillRect(0, 0, W, GROUND_Y);
  }

  function drawFlash(c, v) {
    if (!(v > 0.004)) return;
    c.fillStyle = `rgba(${FLASH.RGB},${v.toFixed(3)})`;
    c.fillRect(0, 0, W, H);
  }

  function drawSheet(c, s, mode, px, camX, face) {
    const sheet = s.sheetClip;
    const pose = s.prince.clip;
    if (!sheet || !pose?.mapPivot || !s.sheaf?.up) return;
    const raise = s.torch.raise;
    if (raise < 0.02) return;

    const ox = (pose.mapPivot[0] - pose.pivotX) * face;
    const oy = pose.mapPivot[1] - pose.pivotY;
    const sx = Math.round(px - camX + ox);
    const sy = Math.round(GROUND_Y + oy);
    // Held up as the arm comes up, rather than appearing whole.
    const w = sheet.width * raise;
    const h = sheet.height * raise;
    // Mirrored with him, so it is always between him and the camera.
    const left = face >= 0 ? sx : sx - w;

    // ALWAYS THE BLANK FACE NOW. The other face has a street and five plots
    // printed on it and is not drawn any more β€” see the note on `sheetFace`.
    const faces = sheet.sheetFace ?? { map: 0, blank: 0 };
    const sheetFrame = sheet.frames[faces.blank] ?? sheet.frames[0];

    c.drawImage(sheetFrame[mode], left, sy - h, w, h);
    if (mode !== 'lit' || raise < 0.98) return;

    const x0 = left;
    const y0 = sy - h;
    const pg = s.page ? s.page() : null;

    // HOW FAR DOWN THE PILE HE IS. Plain "3/7" in the corner, and it replaced a
    // row of little marks along the bottom edge that read as a second index
    // sitting under the first one. A player called that row confusing and was
    // right: two rows of small marks on one sheet, meaning different things.
    if (s.sheaf.pages.length) {
      const fs = Math.round(sheet.height * 0.085);
      c.font = `${fs}px ui-monospace, Menlo, Consolas, monospace`;
      c.textAlign = 'right';
      c.textBaseline = 'top';
      c.fillStyle = 'rgba(0,0,0,0.5)';
      c.fillText(`${s.sheaf.index + 1}/${s.sheaf.pages.length}`,
        x0 + sheet.width - 14, y0 + 12);
      c.textAlign = 'left';
      c.textBaseline = 'alphabetic';
    }

    // AN EMPTY PILE IS BLANK PAPER, which is the true answer to "what am I
    // carrying" before he has picked anything up.
    if (!pg) return;

    // THE SHEET STAYS WHERE IT IS. WHAT IS DRAWN ON IT CHANGES. He is holding
    // up a pile of loose paper in one hand, not opening a book, so the paper
    // does not move β€” only the page on top of it does.
    //
    // The sheet carries the SHAPE and the band carries the WORDS. A letter is a
    // whole page, a fragment is a torn one, and neither is legible at this size
    // on purpose: reading happens in the band and the drawing only has to say
    // which kind of thing he is holding.
    drawSheetPage(c, s, sheet, x0, y0, pg);
    drawFiledLine(c, s, sheet, x0, y0, pg);
    drawLetterHouse(c, s, sheet, x0, y0, pg);
  }

  /**
   * WHICH HOUSE THIS LETTER CAME OFF.
   *
   * There was never a puzzle in hiding it. A letter IS the house's β€” he took it
   * off that wall β€” and the deduction is which FRAGMENT belongs under it. All
   * five letters being one shape only made the player do bookkeeping: take
   * three, and the pile is three identical pages with no way back to the door
   * each came from.
   *
   * The torn pages stay identical, and that is the opposite case: telling those
   * apart by their outline would hand over the answer without reading a word.
   */
  function drawLetterHouse(c, s, sheet, x0, y0, pg) {
    if (!pg || pg.kind !== 'letter') return;
    const house = s.houses?.[pg.house];
    // WHERE IT CAME FROM. The only letter he carries is his orders, and they
    // came from the palace. House letters and the note on the tree stay where
    // they were left, so they are never a page here β€” but a letter with a
    // house behind it would still draw that house.
    const hc = house ? s.houseClip : s.palaceClip;
    if (!hc) return;
    const frame = house ? house.variant : 0;
    const bx = x0 + sheet.width / 2;
    const base = y0 + sheet.height - 14;
    const k = Math.min(58 / hc.width, 38 / (hc.height * 0.86));
    const dw = hc.width * k;
    const dh = hc.height * k;
    c.drawImage(hc.frames[frame].dark, bx - dw / 2, base - 2 - dh, dw, dh);
    c.fillStyle = '#000';
    c.fillRect(Math.round(bx - 32), Math.round(base - 2), 64, 2);
  }

  /**
   * WHETHER THIS PAGE IS FILED, AND WHERE, along the sheet's bottom edge.
   *
   * It used to be visible only on the map page, which meant paging past a
   * fragment told you nothing about what you had already claimed with it. Now
   * it travels with the page, because "have I used this one yet" is a question
   * you ask WHILE scrolling.
   *
   * A filed page shows the house it went to, drawn from that house's own art.
   * An unpinned fragment shows the bare patch β€” the same mark that is waiting
   * beside every door he has taken a letter off. Still no verdict anywhere: it
   * says where he put it, never whether that was right.
   */
  function drawFiledLine(c, s, sheet, x0, y0, pg) {
    if (!pg || pg.kind !== 'fragment') return;
    if (s.canMark) return;              // the destination is being drawn instead
    const house = s.filedUnder ? s.filedUnder(pg) : null;
    const bx = x0 + sheet.width / 2;
    const base = y0 + sheet.height - 14;

    if (house && s.houseClip) {
      const hc = s.houseClip;
      const k = Math.min(58 / hc.width, 38 / (hc.height * 0.86));
      const dw = hc.width * k;
      const dh = hc.height * k;
      c.drawImage(hc.frames[house.variant].dark, bx - dw / 2, base - 2 - dh, dw, dh);
      c.fillStyle = '#000';
      c.fillRect(Math.round(bx - 32), Math.round(base - 2), 64, 2);
      return;
    }
    // NOT PINNED ANYWHERE: the bare patch, exactly as it looks on a wall he
    // has taken a letter off. It was a stroked box while the wall had a frame
    // on it; the wall has a mark now, so this is a mark.
    c.fillStyle = '#6f6553';
    c.fillRect(Math.round(bx - 19), Math.round(base - 36), 38, 34);
    c.fillStyle = '#000';
    c.fillRect(Math.round(bx) - 1, Math.round(base - 36) + 2, 3, 4);
  }

  /**
   * A letter or a fragment, drawn large in the middle of the sheet.
   *
   * A page he has not looked at is drawn as PAPER WITH NOTHING ON IT YET β€” the
   * silhouette of the thing without its contents. It fills in when he has held
   * it in his own light long enough to have read it, which is the same cost
   * every other act of looking in this game charges.
   */
  function drawSheetPage(c, s, sheet, x0, y0, pg) {
    const whole = pg.kind === 'letter' || pg.kind === 'key';
    const known = s.pageSeen ? s.pageSeen(pg) : true;
    // A page he has not turned to is the SHAPE OF THE THING WITHOUT ITS
    // CONTENTS β€” the same contour with nothing written inside it. Drawn from
    // its own art rather than from the silhouette pass, which would put a solid
    // black page on the paper and read as a hole in the sheet.
    // A KEY IS IN HERE TOO. It carries nothing to read and never will, but a
    // thing you are carrying that you cannot look at is a thing you do not
    // know you have β€” which is exactly what happened: somebody collected four
    // and only found out at the gate, by pressing the button to see.
    const art = pg.kind === 'key' ? s.keyArt
      : known ? (whole ? s.letterArt : s.kept) : s.pageBlank;
    if (!art) return;
    const i = pg.kind === 'key' ? 0
      : known ? (pg.house ?? pg.kept ?? 0)
        : (art.pageFace ? (whole ? art.pageFace.whole : art.pageFace.torn) : 0);
    const frame = art.frames[i];
    if (!frame) return;
    // FITTED BY ITS INK, NOT BY ITS CANVAS. The page art uses about half of its
    // 96x96 canvas, so scaling the canvas to the sheet drew a small page in a
    // large empty frame. The loader measures where the drawing actually is.
    //
    // As big as the paper will take, then: it is the page he is holding up, not
    // an icon of one, and the row of marks along the bottom edge is the only
    // thing it has to stay clear of.
    //
    // AND IT MAKES ROOM WHEN THERE IS SOMEWHERE FOR IT TO GO. Stood at a lit
    // door holding a page that can be filed, the sheet shrinks the page and
    // draws that house's plot beside it: the paper itself says "this one,
    // here", with no words and no verdict. It shows what pressing take would
    // do, never whether doing it is right.
    //
    // It is the only moment the affordance can be taught, because it is the
    // only moment the action exists.
    const target = s.canMark && s.markHouse != null ? s.houses[s.markHouse] : null;
    const room = target ? 0.56 : 1;
    // AND IT GIVES UP THE BOTTOM OF THE SHEET when there is a filed line to
    // draw there. Fitted to the full height, the page sat on top of the mark
    // saying where it had gone.
    const foot = !target && (pg.kind === 'fragment' || pg.kind === 'letter') ? 48 : 0;

    const box = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
    const k = Math.min((sheet.width - 30) * room / box.w,
      (sheet.height - 34 - foot) / box.h);
    const dw = art.width * k;
    const dh = art.height * k;
    // Centre the INK in the room it has, which is not the same as centring the
    // canvas when the drawing sits off to one side of it.
    const dx = x0 + 15 + ((sheet.width - 30) * room - box.w * k) / 2 - box.x * k;
    const dy = y0 + (sheet.height - 14 - foot - box.h * k) / 2 - box.y * k;
    c.drawImage(frame.lit, dx, dy, dw, dh);

    if (target) drawDestination(c, s, sheet, x0, y0, target);
  }

  /**
   * The plot this page would go under, drawn small beside it: the building, the
   * street it stands on, and the empty slot below with a page waiting in it.
   *
   * A MINIATURE OF WHAT THE MAP WILL LOOK LIKE AFTERWARDS, which is why it
   * needs no label. Same drawing, same sheet, one step ahead.
   */
  function drawDestination(c, s, sheet, x0, y0, house) {
    const pw = 56, ph = 38;
    const cx = x0 + sheet.width * 0.775;
    const top = y0 + (sheet.height - 14 - (ph + 44)) / 2;

    // The house, from its own art, so it is the building he is standing at.
    if (s.houseClip) {
      const hc = s.houseClip;
      const k = Math.min(pw / hc.width, ph / (hc.height * 0.86));
      const dw = hc.width * k;
      const dh = hc.height * k;
      c.drawImage(hc.frames[house.variant].dark, cx - dw / 2, top + ph - dh, dw, dh);
    }
    c.fillStyle = '#000';
    c.fillRect(Math.round(cx - pw / 2), Math.round(top + ph), pw, 2);

    // And under it, THE BARE PATCH β€” the same mark that is on that house's
    // wall right now, with a page going into it. The sheet and the wall say the
    // same thing in the same drawing, which is what makes either readable.
    const fy = top + ph + 12;
    c.fillStyle = '#6f6553';
    c.fillRect(Math.round(cx - 17), Math.round(fy), 34, 30);
    c.fillStyle = '#000';
    c.fillRect(Math.round(cx) - 1, Math.round(fy) + 2, 3, 4);
    if (s.pageBlank?.pageFace) {
      const torn = s.pageBlank.frames[s.pageBlank.pageFace.torn];
      const ib = s.pageBlank.ink ?? { x: 0, y: 0, w: s.pageBlank.width, h: s.pageBlank.height };
      if (torn) {
        const k = Math.min(24 / ib.w, 22 / ib.h);
        c.globalAlpha = 0.55;
        c.drawImage(torn.lit,
          cx - (ib.w * k) / 2 - ib.x * k,
          fy + 15 - (ib.h * k) / 2 - ib.y * k,
          s.pageBlank.width * k, s.pageBlank.height * k);
        c.globalAlpha = 1;
      }
    }
  }

  /**
   * THE LETTER ON THE DOOR, and it never comes down.
   *
   * Letters stay where they were left; fragments come off bodies. He does not
   * pocket a stranger's mail, and that puts the deduction where it belongs:
   * the question is nailed in front of him, the fragment is in his hand, and
   * the door is where he judges.
   *
   * NO GLOW. The objects on the ground pulse because a dark street gives no
   * other reason to walk over to one; a door is already that reason, and a glow
   * bleeding through an unlit house would break the one rule the whole look
   * rests on β€” that a building is a black mass until he lights it.
   */
  // The same value a lit doorway is drawn at, and it has to be: a page held in
  // that light is the same paper as the light falling out of the room.
  const DOORLIGHT = '#d8c3a0';

  /**
   * THE SLOT, and it is what a deleted frame left behind.
   *
   * Their own paper hung on a second nail under the letter left for them, and
   * kept the weather off the wall behind it. The fragment on a body is that
   * paper, and the lighter patch is where it goes back.
   *
   * IT IS A VALUE, NOT AN OUTLINE. No border, no socket, no glow, and it must
   * never read as UI. The palette makes that a narrow window: the wall sits at
   * 26 and nothing may live between 30 and 90, so a patch that reads at all has
   * to clear 90. This is just above that floor and far under the doorway's 197,
   * which is the whole of the space the rule leaves.
   */
  const PATCH = '#6f6553';        // lum 101 β€” over the forbidden zone, under the door

  /** Where a rect on the wall lands on screen, for whatever is hanging in it. */
  function nailBox(rect, x, y, art) {
    if (!rect || !art) return null;
    const [nx, ny, nw, nh] = rect;
    const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
    const k = Math.min(nw / ink.w, nh / ink.h);
    const w = ink.w * k;
    const h = ink.h * k;
    return { k, w, h, px: x + nx + (nw - w) / 2, py: y + ny + (nh - h) / 2,
      rx: x + nx, ry: y + ny, rw: nw, rh: nh, ink };
  }

  /**
   * WHAT IS ON THE WALL BESIDE A DOOR, top to bottom: the letter somebody
   * left, and under it the slot β€” the patch, the bare nail, and whatever he
   * pins there. The question above the answer, and the eye reads down.
   *
   * LIT PASS ONLY, so all of it lives and dies with the torch like everything
   * else in a doorway.
   *
   * THE TREE HAS NO SLOT (`slot: false`). It carries the note and nothing
   * else: nothing is ever pinned back to a tree.
   */
  function drawNail(c, s, clip, house, x, y, { slot = true } = {}) {
    // 1. THE LETTER. Always: nothing takes it down.
    const paper = s.letterArt;
    const written = paper?.frames[house.index];
    const lb = nailBox(clip.letterAt?.[house.variant], x, y, paper);
    if (lb && written) {
      // PAPER FIRST. The page art is contour and writing on nothing, so on a
      // dark wall it came out dark-on-dark. It is a sheet of paper in
      // torchlight, so it gets the value a lit doorway gets.
      c.fillStyle = DOORLIGHT;
      c.fillRect(Math.round(lb.px - 3), Math.round(lb.py - 3),
        Math.round(lb.w + 6), Math.round(lb.h + 6));
      c.drawImage(written.lit, lb.px - lb.ink.x * lb.k, lb.py - lb.ink.y * lb.k,
        paper.width * lb.k, paper.height * lb.k);
    }
    if (!slot) return;

    // 2. THE SLOT, UNDER IT. Flat, borderless, and slightly bigger than the
    //    page that made it, because paper curls and the weather got in at the
    //    edges. There from the first light, whether or not anything is in it.
    const rect = clip.slotAt?.[house.variant];
    if (!rect) return;
    const [nx, ny, nw, nh] = rect;
    const px0 = Math.round(x + nx);
    const py0 = Math.round(y + ny);

    // IT DOES NOT BREATHE. It used to, while it was empty: the patch only
    // appeared once the letter came off, and nothing walked a player over to a
    // door to find it. It is under the letter from the first light now, and a
    // bare nail under a letter asks its own question. A pulse on a wall reads
    // as UI, and the loom house's empty slot has to read as a letter nobody
    // answered, not as a button nobody pressed.
    //
    // IT IS THE SHAPE OF WHAT GOES IN IT.
    //
    // A rectangle says "a place" without saying what for, and that is exactly
    // how this failed: somebody lit a door, saw a slightly cleaner rectangle,
    // and had no reason to connect it to the torn paper in their pocket. Cut
    // to the page's own outline it says both things at once, with no words.
    //
    // The whole letter and the torn scrap share one contour but for the tear,
    // so this doubles as the mark the letter left AND the hole the scrap fits.
    if (s.patchArt && s.kept) {
      const pk = s.kept;
      const pink = pk.ink ?? { x: 0, y: 0, w: pk.width, h: pk.height };
      const pkk = Math.min(nw / pink.w, nh / pink.h);
      c.drawImage(s.patchArt,
        px0 + (nw - pink.w * pkk) / 2 - pink.x * pkk,
        py0 + (nh - pink.h * pkk) / 2 - pink.y * pkk,
        pk.width * pkk, pk.height * pkk);
    } else {
      c.fillStyle = PATCH;
      c.fillRect(px0, py0, nw, nh);
    }
    // The nail itself, bare now. Four pixels of iron, and the only reason it is
    // drawn at all is that a patch with nothing at the top of it reads as a
    // stain rather than as a place something hung.
    c.fillStyle = '#000';
    c.fillRect(Math.round(px0 + nw / 2) - 2, py0 + 8, 4, 5);

    // 2b. AND IF HE IS STANDING HERE HOLDING A PAGE, THE WALL SHOWS WHERE IT
    //     WOULD GO. A ghost of the exact page in his hand, faint, in the patch.
    //
    // THIS IS WHY IT IS HERE. A ten-year-old played the level, picked the
    // scraps up, and never worked out that they go on a wall β€” and she was
    // right to miss it, because nothing said so. The patch was made
    // deliberately quiet ("a value, not an outline, it must not read as UI")
    // and it succeeded: it is a rectangle of slightly cleaner wall and it
    // asks nothing. The only thing that had ever said "[E] PIN IT HERE" was
    // the debug readout, which was on the whole time anybody tested it.
    //
    // NOT A NEW IDEA, AND NOT A PROMPT. The sheaf sheet has always drawn this
    // exact ghost β€” a page at 0.55 alpha, going into a plot β€” to say "this
    // one, here". It was on the paper in his hand instead of on the wall he
    // was looking at. This puts it where his eyes are.
    //
    // AND IT IS NOT A VERDICT. It appears for the wrong page just as readily,
    // because it is a preview of an act and not an opinion about it.
    if (house.filed == null) {
      if (!s.canPin || s.markHouse !== house.index) return;
      const held = s.handPage;
      const ghostArt = held?.kind === 'fragment' ? s.kept : null;
      const gf = ghostArt?.frames[held.kept];
      const gb = nailBox(rect, x, y, ghostArt);
      if (!gf || !gb) return;
      c.globalAlpha = 0.5;
      c.drawImage(gf.lit, gb.px - gb.ink.x * gb.k, gb.py - gb.ink.y * gb.k,
        ghostArt.width * gb.k, ghostArt.height * gb.k);
      c.globalAlpha = 1;
      return;
    }

    // 3. AND THE PAGE HE PINNED INTO IT.
    //
    // It is the only acknowledgement filing gets and it must stay one. The
    // patch looks exactly the same whichever page went into it and whether or
    // not that was the right one β€” all five pages are one shape by design, so
    // the wall can never be read as a verdict. What answers is a person walking
    // up the street, and that takes as long as it takes.
    const art = s.kept;
    const frame = art?.frames[house.filed];
    const b = nailBox(rect, x, y, art);
    if (!b || !frame) return;
    c.drawImage(frame.lit, b.px - b.ink.x * b.k, b.py - b.ink.y * b.k,
      art.width * b.k, art.height * b.k);
  }

  /**
   * THE KEY ON ITS HOOK, and the whole two-door-states idea is in this one
   * function.
   *
   * LIT is what the torch does: you can see in. OPEN is what the person does:
   * you can reach in. Both states draw the same key in the same place, and the
   * only difference is the halo β€” flat black on the lit doorway while the door
   * is shut, and the warm breathing pool once somebody has come home to it.
   *
   * THAT HALO IS NOT A NEW IDEA. It is what a dropped fragment on the ground
   * has always done, at the same numbers, and it already means exactly one
   * thing in this game: you can pick this up. So a key that starts wearing it
   * needs no message, no prompt and no drawn door β€” a player who has taken one
   * page off the ground has already been taught what it says.
   *
   * WHICH IS ALSO WHY NO DOOR IS DRAWN. A shut leaf across the arch would
   * cover the anvil or the loom or the rack, and the room's trade is the only
   * thing that says which house this is. The picture of "open" is a person
   * standing in the doorway who was not standing there a minute ago.
   */
  function drawRoomKey(c, s, clip, house, x, y, clock) {
    if (house.keyTaken || !clip.keyAt || !s.keyArt) return;
    const rect = clip.keyAt[house.variant];
    const art = s.keyArt;
    const frame = art.frames[0];
    if (!rect || !frame) return;
    const [kx, ky, kw, kh] = rect;
    const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
    const k = Math.min(kw / ink.w, kh / ink.h);
    const w = ink.w * k;
    const h = ink.h * k;
    const px = x + kx + (kw - w) / 2;

    // IT HANGS ON ITS OWN PLATE OF LIGHT, and that is not decoration.
    //
    // The key is black line work hanging inside the doorway β€” where the anvil
    // and the oven and the buckets and the loom also are, and those are black
    // too. Measured: 48% of the key is drawn over the anvil, 87% over a bucket,
    // 96% over the oven dome and over the loom. A black key on a black oven is
    // not a dim key, it is no key, and the first people to play this never saw
    // one at all.
    //
    // THE LETTER SOLVED THIS ALREADY: it paints its own patch of doorway light
    // before drawing the page, "because on a dark wall it came out
    // dark-on-dark". Same fix, same value. The key is now always a black shape
    // on light, whatever it is hanging in front of.
    //
    // AND IT SWINGS WHEN IT IS YOURS. A warm halo is this game's word for "you
    // can pick this up", but a warm halo on an already-bright doorway barely
    // moves the picture β€” the same mistake as a white bolt on a white sky. What
    // reads against any background is motion.
    const swing = house.open
      ? Math.sin(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ) * 4
      : 0;
    const py = y + ky + (kh - h) / 2 + swing;
    c.fillStyle = DOORLIGHT;
    c.fillRect(Math.round(px - 7), Math.round(py - 7),
      Math.round(w + 14), Math.round(h + 14));

    if (house.open) {
      // The same breath the dropped pages take, off the same tuning values, so
      // the two can never drift into meaning different things.
      const pulse = 1 - TORCH.KEPT_PULSE_DEPTH *
        (0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ));
      const cx = px + w / 2;
      const cy = py + h / 2;
      const r = Math.max(w, h) * TORCH.KEPT_GLOW_R;
      const g = c.createRadialGradient(cx, cy, 0, cx, cy, r);
      g.addColorStop(0, `rgba(${TORCH.KEPT_RGB},${(TORCH.KEPT_GLOW * pulse).toFixed(3)})`);
      g.addColorStop(1, `rgba(${TORCH.KEPT_RGB},0)`);
      const was = c.globalCompositeOperation;
      c.globalCompositeOperation = 'lighter';
      c.fillStyle = g;
      c.fillRect(cx - r, cy - r, r * 2, r * 2);
      c.globalCompositeOperation = was;
    }
    c.drawImage(frame.lit, px - ink.x * k, py - ink.y * k, art.width * k, art.height * k);
  }

  // The keys, in the order a hand finds them. Not a narrator: it names buttons,
  // which is the one thing in this game that is allowed to be told rather than
  // discovered, because a button that cannot be found does not exist.
  //
  // "E take" AND NOTHING ELSE. It said "E take/MARK" until MARK stopped being
  // a thing: E is one verb now and what it does is decided by where he is
  // standing, which is the whole point. A line naming a mode that no longer
  // exists is worse than no line.
  const HELP = 'A/D or arrows walk β€” hold SPACE torch β€” F swing β€” E take'
    + ' β€” hold Q inventory, arrows scroll β€” M mute β€” R reset β€” ` readout';
  // Keyboards only. On a touch screen every one of these is a labelled button
  // already, and a line about SPACE and backtick is noise on a device with
  // neither.
  const hasKeyboard = !(typeof matchMedia === 'function'
    && matchMedia('(hover: none) and (pointer: coarse)').matches);

  /**
   * ONE LINE, AT THE FOOT OF THE FRAME, and it gets out of the way of reading.
   *
   * The band is bottom-anchored and grows upward over this, so rather than
   * fight it for the space they take turns: while there is a page open the keys
   * fade out, because a player reading a letter is not looking for the swing
   * button. Everything else in the game is discovered; this is the doorway in.
   */
  function drawHelp(c, bandAlpha) {
    const alpha = 0.55 * (1 - Math.min(1, bandAlpha * 1.6));
    if (!hasKeyboard || alpha <= 0.02) return;
    // MEASURED DOWN TO FIT, never wrapped. It is one line or it is not this
    // line β€” the readout's help row ran 329px off the right edge once and the
    // only part anybody needed was the part past the edge.
    const room = W - 48;
    let size = 21;
    c.font = `${size}px ui-monospace, Menlo, Consolas, monospace`;
    const wide = c.measureText(HELP).width;
    if (wide > room) {
      size = Math.floor(size * room / wide);
      c.font = `${size}px ui-monospace, Menlo, Consolas, monospace`;
    }
    c.save();
    c.globalAlpha = alpha;
    c.fillStyle = 'rgba(226, 208, 178, 1)';
    c.fillText(HELP, 24, H - 16);
    c.restore();
  }

  /**
   * A LINE OF THE READOUT, legible on anything underneath it.
   *
   * It was black at 65%, which is fine over the open sky at the start of the
   * level and invisible by the end of it: the canopy closes as he walks in, and
   * black text on black leaves is no text. White alone has the opposite
   * problem β€” the sky runs to luminance 205 near the road.
   *
   * So: white, cut out of a dark stroke. Costs one extra pass per line, reads
   * over sky, leaves, ground and the black gate alike, and this is the one
   * instrument the game has.
   */
  function readoutLine(c, text, x, y) {
    // Saved, because this is the only place in the renderer that sets a wide
    // lineWidth and everything after it would inherit one.
    c.save();
    // TOP, not baseline, so `y` is the top of the ink and the first line can sit
    // hard against the edge of the frame. Measuring from a baseline leaves a
    // quarter of the font's height of air above it that nobody asked for.
    c.textBaseline = 'top';
    c.lineJoin = 'round';
    c.lineWidth = 4;
    c.strokeStyle = 'rgba(0,0,0,0.85)';
    c.strokeText(text, x, y);
    c.fillStyle = 'rgba(255,255,255,0.94)';
    c.fillText(text, x, y);
    c.restore();
  }

  /** Screen y the ground TILE is drawn at, so its surface lands on GROUND_Y. */
  function groundTop(clip) { return GROUND_Y - (clip.pivotY || 0); }

  function drawGround(c, clip, mode, x0, x1, camX = 0) {
    const tileW = clip.width;
    const top = groundTop(clip);
    // Tile in WORLD space so the torn edge stays pinned to the ground as the
    // camera moves, rather than sliding along with the screen.
    //
    // Integer-aligned on purpose. The camera position is interpolated and so
    // lands on fractions; drawing tiles at fractional x leaves a hairline seam
    // between them where the smoothing does not quite meet. A sub-pixel camera
    // is not perceptible at walking speed, a one-pixel gap in the ground is.
    const cam = Math.round(camX);
    const start = Math.floor((x0 + cam) / tileW) * tileW - cam;
    const img = clip.frames[0][mode];
    for (let x = start; x < x1; x += tileW) {
      c.drawImage(img, x, top);
      // The tile is shorter than the gap below the ground line, so the rest is
      // filled flat. The art only has to carry the torn top edge.
      if (top + clip.height < H) {
        c.fillStyle = '#000';
        c.fillRect(x, top + clip.height, tileW, H - top - clip.height);
      }
    }
  }

  function drawDemo(scene, alpha, stats) {
    const s = scene.state;
    const px = lerp(s.prince.prevX, s.prince.x, alpha);
    const dx = lerp(s.drained.prevX, s.drained.x, alpha);
    const radius = lerp(s.torch.prevRadius, s.torch.radius, alpha);
    const cx = lerp(s.torch.prevCx, s.torch.cx, alpha);
    const cy = lerp(s.torch.prevCy, s.torch.cy, alpha);
    const handX = lerp(s.torch.prevHandX, s.torch.handX, alpha);
    const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha);
    const armEase = lerp(s.torch.prevEased ?? 0, s.torch.eased ?? 0, alpha);
    const fade = lerp(s.prevFade, s.fade, alpha);
    const torchUp = !s.prince.hasSword;

    ctx.setTransform(1, 0, 0, 1, 0, 0);

    // --- Pass 1: the dark world -----------------------------------------
    ctx.fillStyle = BG_DARK;
    ctx.fillRect(0, 0, W, H);
    drawGround(ctx, s.ground, 'dark', 0, W);
    if (s.drained.active) drawClip(ctx, s.drained.clip, s.drained.frame, 'dark', dx, GROUND_Y);
    drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, 0, 1, s.prince.eyeDim);
    drawTorchArm(ctx, s, 'dark', px, 0, 1, armEase);
    if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY);

    // --- Pass 2: the lit reveal ------------------------------------------
    if (radius > 0.5) {
      const x0 = Math.max(0, Math.floor(cx - radius) - 2);
      const y0 = Math.max(0, Math.floor(cy - radius) - 2);
      const x1 = Math.min(W, Math.ceil(cx + radius) + 2);
      const y1 = Math.min(H, Math.ceil(cy + radius) + 2);
      const rw = x1 - x0;
      const rh = y1 - y0;

      if (rw > 0 && rh > 0) {
        octx.setTransform(1, 0, 0, 1, 0, 0);
        octx.save();
        octx.beginPath();
        octx.rect(x0, y0, rw, rh);
        octx.clip();

        octx.clearRect(x0, y0, rw, rh);
        octx.fillStyle = BG_LIT;
        octx.fillRect(x0, y0, rw, rh);
        if (y1 > GROUND_Y) drawGround(octx, s.ground, 'lit', x0, x1);
        // The Drained is the only thing with lit art in v1.
        if (s.drained.active) drawClip(octx, s.drained.clip, s.drained.frame, 'lit', dx, GROUND_Y);
        // The prince is not lit by his own torch. His body stays silhouette for
        // the whole demo. (spec 5.3)
        drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, 0, 1, s.prince.eyeDim);
        drawTorchArm(octx, s, 'lit', px, 0, 1, armEase);
        if (torchUp) drawClip(octx, s.flame.clip, s.flame.frame, 'lit', handX, handY);

        octx.globalCompositeOperation = 'destination-in';
        octx.setTransform(radius, 0, 0, radius, cx, cy);
        octx.fillStyle = maskGradient;
        octx.fillRect(-1, -1, 2, 2);
        octx.setTransform(1, 0, 0, 1, 0, 0);
        octx.globalCompositeOperation = 'source-over';
        octx.restore();

        ctx.drawImage(off, x0, y0, rw, rh, x0, y0, rw, rh);
      }

      // The only warmth in the demo. Keep it subtle.
      ctx.save();
      ctx.globalCompositeOperation = 'lighter';
      ctx.setTransform(radius * GLOW_SPREAD, 0, 0, radius * GLOW_SPREAD, cx, cy);
      ctx.fillStyle = glowGradient;
      ctx.fillRect(-1, -1, 2, 2);
      ctx.restore();
    }

    ctx.setTransform(1, 0, 0, 1, 0, 0);

    if (fade > 0) {
      ctx.fillStyle = `rgba(0,0,0,${fade})`;
      ctx.fillRect(0, 0, W, H);
    }

    if (showDebug) {
      ctx.font = '20px ui-monospace, Menlo, Consolas, monospace';
      const lines = [
        `${stats.fps} fps   draw ${stats.drawMs.toFixed(2)}ms   panics ${stats.panics}`,
        `phase ${s.phase}   loop ${s.loopElapsed.toFixed(1)}s` +
          (s.lastLoopLength ? `   last ${s.lastLoopLength.toFixed(1)}s` : ''),
        `torch ${(s.torch.raise * 100).toFixed(0)}%   r ${radius.toFixed(1)}   sword ${s.prince.hasSword ? 'out' : 'away'}`,
        `drained ${s.drained.revealed ? 'REVEALED' : 'unseen'}   ${s.drained.speed.toFixed(0)}px/s` +
          (s.drained.stun > 0 ? '   knocked back' : '') +
          (s.prince.attacking ? '   swinging' : ''),

        'hold left = torch Β· tap right = swing    (SPACE / F / R reset / backtick hides this)',
      ];
      lines.forEach((line, i) => readoutLine(ctx, line, 24, 8 + i * 26));
    }
  }

  // --- the level -----------------------------------------------------------
  // Same two passes, same gradients, same offscreen. The only new idea is that
  // world coordinates are converted to screen once, here, using the camera.
  function drawLevel(scene, alpha, stats) {
    const s = scene.state;
    const camX = lerp(s.camera.prevX, s.camera.x, alpha);
    const px = lerp(s.prince.prevX, s.prince.x, alpha);
    const radius = lerp(s.torch.prevRadius, s.torch.radius, alpha);
    const cx = lerp(s.torch.prevCx, s.torch.cx, alpha) - camX;
    const cy = lerp(s.torch.prevCy, s.torch.cy, alpha);
    const handX = lerp(s.torch.prevHandX, s.torch.handX, alpha);
    const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha);
    const armEase = lerp(s.torch.prevEased ?? 0, s.torch.eased ?? 0, alpha);
    const face = s.prince.facing;
    const torchUp = !s.prince.hasSword;
    // The weight going out of the bad leg. Half a limp is the rhythm, which the
    // scene does by hurrying one half of the cycle; the other half is this β€”
    // he drops a few px while that foot is down.
    const hurt = Math.max(0, s.prince.hits - TUNING.LIMP_FROM);
    const limpDip = (hurt > 0 && s.prince.moving &&
      s.prince.frame >= s.prince.clip.count / 2)
      ? Math.min(TUNING.LIMP_DIP_PX, TUNING.LIMP_DIP_PX * (hurt / 3)) : 0;

    ctx.setTransform(1, 0, 0, 1, 0, 0);

    // --- Pass 1: the dark world ------------------------------------------
    // Measured separately from pass 2. The dirty-rect win that took the demo
    // from 34 to 60fps was measured on a STATIC pass 1; a scrolling five-layer
    // background redraws the whole frame every tick, so that result does not
    // carry over and this build has to state its own numbers.
    const t1 = performance.now();
    // Before either pass draws a watcher. Both passes read the same spans, so
    // the torch cannot show a watcher the dark pass faded.
    s.background.keepClear?.(camX, laneSpans, backLaneSpans(s, camX, alpha), s.clock ?? 0);
    // The sky IS the background now, not a flat fill. It is the only thing on
    // screen above luminance 160, and without it a silhouette has nothing to
    // read against.
    s.background.drawSky(ctx, camX, W);
    // Shafts sit ON the sky and fade as the canopy eats it β€” the same curve
    // that drives the canopy and the watcher density. The light runs out.
    depth.drawShafts(ctx, camX, W, GROUND_Y, 1 - depthAt(px));
    depth.drawParticlesBehind(ctx, camX, W, H, s.clock);
    stats.bgDrawn = s.background.drawFar(ctx, camX, W, H, 'dark', px);
    drawSkySink(ctx, s.prince.x);
    drawGround(ctx, s.ground, 'dark', 0, W, camX);
    drawBolt(ctx, s);
    drawVillage(ctx, s, camX, 'dark');
    drawTree(ctx, s, camX, 'dark');
    drawFlash(ctx, s.flash ?? 0);
    drawAllDrained(ctx, s, 'dark', camX, alpha);
    drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim);
    drawSheet(ctx, s, 'dark', px, camX, face);
    drawTorchArm(ctx, s, 'dark', px, camX, face, armEase);
    if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face);
    drawFog(ctx, camX, s.clock ?? 0);
    stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px);
    depth.drawParticlesFront(ctx, camX, W, H, s.clock);
    stats.pass1Ms += (performance.now() - t1 - stats.pass1Ms) * 0.1;

    const t2 = performance.now();

    // --- Pass 2: the lit reveal -------------------------------------------
    if (radius > 0.5) {
      const x0 = Math.max(0, Math.floor(cx - radius) - 2);
      const y0 = Math.max(0, Math.floor(cy - radius) - 2);
      const x1 = Math.min(W, Math.ceil(cx + radius) + 2);
      const y1 = Math.min(H, Math.ceil(cy + radius) + 2);
      const rw = x1 - x0;
      const rh = y1 - y0;

      if (rw > 0 && rh > 0) {
        octx.setTransform(1, 0, 0, 1, 0, 0);
        octx.save();
        octx.beginPath();
        octx.rect(x0, y0, rw, rh);
        octx.clip();
        octx.clearRect(x0, y0, rw, rh);
        // Sky first, unchanged, so the circle passing over it does nothing β€”
        // a torch does not light the sky.
        s.background.drawSky(octx, camX, W);
        octx.fillStyle = BG_LIT;
        const gTop = groundTop(s.ground) - 4;
        octx.fillRect(x0, gTop, rw, H - gTop);
        // Each layer decides what it hands the lit pass. The far ones hand over
        // their dark art, so the circle passes across them and changes nothing.
        s.background.drawFar(octx, camX, W, H, 'lit', px);
        if (y1 > GROUND_Y) drawGround(octx, s.ground, 'lit', x0, x1, camX);
        // And here the doors open. This is the one thing in the level the
        // torch reveals that is not a person.
        drawVillage(octx, s, camX, 'lit');
        drawTree(octx, s, camX, 'lit');
        // He is never lit by his own torch, so he goes in with his DARK art β€”
        // the same trick the far layers use.
        // The Drained are the thing the torch is for.
        drawAllDrained(octx, s, 'lit', camX, alpha);
        drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim);
        // He is never lit by his own torch. The SHEET is β€” it is the one thing
        // he carries that the light is for.
        drawSheet(octx, s, 'lit', px, camX, face);
        drawTorchArm(octx, s, 'lit', px, camX, face, armEase);
        if (torchUp) drawClip(octx, s.flame.clip, s.flame.frame, 'lit', handX, handY, camX, face);
        // The same bank of puffs, in the same places, in both passes. Without
        // this the torch punches a clean round hole in the mist, which reads as
        // a bug β€” and a torch is exactly where real mist shows up most.
        drawFog(octx, camX, s.clock ?? 0);
        s.background.drawNear(octx, camX, W, H, 'lit', px);

        octx.globalCompositeOperation = 'destination-in';
        octx.setTransform(radius, 0, 0, radius, cx, cy);
        octx.fillStyle = maskGradient;
        octx.fillRect(-1, -1, 2, 2);
        octx.setTransform(1, 0, 0, 1, 0, 0);
        octx.globalCompositeOperation = 'source-over';
        octx.restore();

        ctx.drawImage(off, x0, y0, rw, rh, x0, y0, rw, rh);
      }

      // Dim everything the light is not on. Four rects for the world outside
      // the circle's box, and the falloff gradient inside it β€” far cheaper than
      // masking a full-screen overlay, and it is the same shape as the light so
      // the two edges agree.
      const dimA = TORCH.DIM * (s.torch.raise);
      if (dimA > 0.002) {
        const R = radius * GLOW_SPREAD;
        const bx0 = cx - R, by0 = cy - R, bx1 = cx + R, by1 = cy + R;
        ctx.fillStyle = `rgba(${TORCH.DIM_RGB},${dimA})`;
        if (by0 > 0) ctx.fillRect(0, 0, W, Math.min(by0, H));
        if (by1 < H) ctx.fillRect(0, Math.max(by1, 0), W, H - Math.max(by1, 0));
        const ty = Math.max(0, by0), th = Math.min(H, by1) - ty;
        if (th > 0) {
          if (bx0 > 0) ctx.fillRect(0, ty, Math.min(bx0, W), th);
          if (bx1 < W) ctx.fillRect(Math.max(bx1, 0), ty, W - Math.max(bx1, 0), th);
        }
        ctx.save();
        ctx.globalAlpha = s.torch.raise;
        ctx.setTransform(R, 0, 0, R, cx, cy);
        ctx.fillStyle = dimGradient;
        ctx.fillRect(-1, -1, 2, 2);
        ctx.restore();
      }

      ctx.save();
      ctx.globalCompositeOperation = 'lighter';
      ctx.setTransform(radius * GLOW_SPREAD, 0, 0, radius * GLOW_SPREAD, cx, cy);
      ctx.fillStyle = glowGradient;
      ctx.fillRect(-1, -1, 2, 2);
      ctx.restore();
    }

    stats.pass2Ms += (performance.now() - t2 - stats.pass2Ms) * 0.1;
    ctx.setTransform(1, 0, 0, 1, 0, 0);

    // Over the finished frame, torch included. This is the cue most likely to
    // spend budget invisibly: it touches every pixel, including the quarter of
    // the frame that is pure black, and noise over black lands some of it
    // above 30.
    // Vignette before grain: the grain is a film artefact and film grain sits on
    // top of the exposure, not under it.
    drawVignette(ctx);
    depth.drawGrain(ctx, W, H);

    // The words. Over the vignette and the grain, because it is a page being
    // held up in front of the world rather than a thing in it β€” and under the
    // fade, because the ending takes everything with it.
    drawBand(ctx, s.band?.text, s.band?.alpha ?? 0);

    // AND THE KEYS, ALWAYS. This was the last line of the debug readout, which
    // meant the one thing a new player actually needs was behind the same
    // toggle as the frame timings β€” and off by default on a phone, where
    // `?debug=0` is the only way to get rid of the rest of it.
    drawHelp(ctx, s.band?.alpha ?? 0);

    // Through the gate and the playthrough is over. No prompt, no score screen,
    // and no mention of the empty slot β€” the gate wanted four and it got four.
    if (s.fade > 0) {
      ctx.fillStyle = `rgba(0,0,0,${Math.min(1, s.fade)})`;
      ctx.fillRect(0, 0, W, H);
    }

    if (showDebug) {
      // LEGIBLE ON A PHONE. The canvas is a fixed 1920 scaled to fit, so a 20px
      // line comes out about four pixels across on a handset β€” which made the
      // one instrument this game has useless on exactly the device that has no
      // console and no backtick key to toggle it with.
      //
      // AND IT HAS TO STAY OUT OF THE PICTURE. Thirteen lines at 20px and 1.3
      // line height reached 40% down the frame β€” the readout was sitting on the
      // thing it was reporting on. 16px at 1.14 keeps the same information in
      // roughly half the height.
      const shrink = ctx.canvas.clientWidth ? W / ctx.canvas.clientWidth : 1;
      const fs = Math.round(16 * Math.min(2.0, Math.max(1, shrink * 0.5)));
      const step = fs * 1.1;
      ctx.font = `${fs}px ui-monospace, Menlo, Consolas, monospace`;
      // Against the GATE, not the road. Measuring him against LEVEL_LENGTH_PX
      // reads past 100% for most of the level now that the road is an approach.
      const pct = (s.prince.x / (s.gateStopX || TUNING.LEVEL_LENGTH_PX) * 100).toFixed(1);
      const standing = s.village ? s.village.filter((d) => d.state !== 'DOWNED').length : 0;
      const doors = (s.houses ?? []).map((h) => (h.seen ? h.kind : 'Β·')).join(' ');
      const nearKept = s.drained.some((d) => d.keptDropped && !d.keptTaken &&
        Math.abs(d.keptX - s.prince.x) <= TUNING.PICKUP_REACH);
      [
        `${stats.fps} fps   pass1 ${stats.pass1Ms.toFixed(2)}ms   pass2 ${stats.pass2Ms.toFixed(2)}ms   trees ${stats.bgDrawn}`,
        `x ${s.prince.x.toFixed(0)} / ${s.gateStopX ?? TUNING.LEVEL_LENGTH_PX}  (${pct}%)   cam ${camX.toFixed(0)}   canopy ${(depthAt(s.prince.x) * 100).toFixed(0)}%`,
        // The numbers V1 is judging, on screen while you judge them.
        // The village's width and crossing time used to be spelled out here every
        // frame. They are constants β€” a fact you read once, not an instrument.
        `${s.prince.x < s.bowlX0 ? 'road' : 'village'}` +
          `   shapes ${standing}/${s.village ? s.village.length : 0} up, ${s.villageGap ?? 0}px apart`,
        // NO HEALTH BAR ON SCREEN. This line is for judging the build, not for
        // playing it β€” the player is meant to read it off how he is standing.
        `hits ${s.prince.hits}/${TUNING.PRINCE_HITS}` +
          (s.prince.hits >= TUNING.LIMP_FROM ? '  limping' : '') +
          `   swing x${TUNING.SWING_SLOW[Math.min(s.prince.hits, TUNING.SWING_SLOW.length - 1)].toFixed(2)}` +
          (s.run ? `   run ${s.run.seconds.toFixed(0)}s  fights ${s.run.fights}` +
            `  took ${s.run.taken}  hit ${s.run.hitsTaken}  deaths ${s.run.deaths}` +
            `   WATCHING ${s.run.watching.toFixed(1)}s` : ''),
        `keys ${s.prince.keys ?? 0} in hand` +
        `   home ${s.houses ? s.houses.filter((h) => h.open).length : 0}/4` +
        `   walking ${s.drained ? s.drained.filter((d) => d.homing).length : 0}   ` +
        `gate ${s.gate ? `${s.gate.filled}/${TUNING.GATE_WANTS}` : 'β€”'}` +
          (s.gate?.open >= 1 ? '  OPEN' : '  shut') +
          (s.gate?.through ? '  β€” through' : '') +
          `   doors lit: ${doors || 'β€”'}` +
          (s.sheaf?.up ? `   INVENTORY ${s.sheaf.index + 1}/${s.sheaf.pages.length}`
            : `   [Q] inventory (${s.sheaf?.pages.length ?? 0})`),
        `${face > 0 ? 'β†’' : '←'} ${s.prince.moving ? 'walking' : 'still'}   sword ${s.prince.hasSword ? 'out' : 'away'}` +
          `   torch ${(s.torch.raise * 100).toFixed(0)}%   r ${radius.toFixed(1)}` +
          `   ${s.prince.clip.name} f${s.prince.frame}` +
          (s.prince.attacking ? '   SWINGING' : ''),
        s.drained.filter((d) => d.answers)
          .map((d) => `${d.state[0]}${d.hits}:${d.answers}`).join('  ') +
          (s.loss.active ? '   β€” LOSS' : ''),
        // An object taken in the dark is listed as "?". He has it; he never
        // looked at it, so he does not know whose it was.
        `carrying ${s.prince.inventory.length}: ` +
          (s.prince.inventory.length
            ? s.prince.inventory.map((it) => (it.seen ? it.answers : '?')).join(', ')
            : 'β€”') +
          (s.canPin ? '   [E] PIN IT HERE'
            : s.canMark ? '   [E] take it back down'
              : s.canPlaceKey ? `   [E] put a key in  (${s.prince.keys} in hand)`
                : s.canTakeKey ? '   [E] take the key'
                  : nearKept ? '   [E] take' : '') +
          // ONLY WHEN IT IS NOT WORKING. Spelled out because a phone has no
          // console and no backtick key β€” "running / playback" and still silent
          // on an iPhone means the ringer switch, not the game. Once it is
          // playing there is nothing to say, and it was the longest line here.
          (s.audio && !(s.audio.enabled && s.audio.ready)
            ? `   sound ${s.audio.enabled ? 'waiting for a keypress' : 'MUTED'}` +
              `  [${s.audio.state}/${s.audio.session}]` : ''),
        // WHAT HE HAS CLAIMED, against what he is carrying. Its own line β€”
        // fillText draws one per array entry and swallows a newline inside one.
        // Five slots, and the empty one is the finding.
        `filed  ${s.houses.map((hh) => (hh.filed == null ? 'β€”' : `${hh.kind}<${hh.filed}`)).join('  ')}`,
        // Only when somebody is actually tuning it from the address bar.
        DEPTH.label ? `depth: ${DEPTH.label}` +
          (DEPTH.OCC > 1 ? `  occ ${DEPTH.OCC}x` : '') +
          (DEPTH.PART > 0 ? `  part ${DEPTH.PART}${DEPTH.PART_HARD ? ' hard' : ' soft'} (${depth.stats.particles} drawn)` : '') +
          (DEPTH.BLUR > 0 ? `  blur ${DEPTH.BLUR}` : '') +
          (DEPTH.GRAIN > 0 ? `  grain ${DEPTH.GRAIN}` : '') +
          (DEPTH.SHAFT > 0 ? `  shafts ${depth.stats.shafts}` : '') : '',
      ]
        // WRAPPED, BECAUSE ONE OF THESE RAN OFF THE SCREEN AND IT WAS THE ONE
        // THAT MATTERED. The help line overflowed the right edge by 329px, so
        // "arrows page" β€” the only thing in the game that says how to turn a
        // page β€” was drawn past the end of the canvas and nobody ever saw it.
        //
        // The readout grows with the build and its font grows on a small
        // display, so a line that fits today does not stay fitting. Wrapping on
        // the separators it already uses costs nothing and closes the whole
        // class of it.
        .flatMap((line) => {
          const max = W - 48;
          if (ctx.measureText(line).width <= max) return [line];
          const out = [];
          let cur = '';
          for (const part of String(line).split(' β€” ')) {
            const next = cur ? `${cur} β€” ${part}` : part;
            if (cur && ctx.measureText(next).width > max) { out.push(cur); cur = part; }
            else cur = next;
          }
          out.push(cur);
          return out;
        })
        .filter((line) => line !== '')
        .forEach((line, i) => readoutLine(ctx, line, 20, 4 + i * step));
    }
  }

  return {
    drawDemo,
    drawLevel,
    depth,
    /** Measuring code calls this. Never leave it off for the player. */
    setDebug(v) { showDebug = !!v; },
    /** For checks.js, which composes a backdrop-only frame. */
    drawVignette,
    get debugShown() { return showDebug; },
  };
}