-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplay.html
More file actions
1015 lines (921 loc) · 48.4 KB
/
Copy pathplay.html
File metadata and controls
1015 lines (921 loc) · 48.4 KB
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PLAY — Project 64 demos in your browser</title>
<meta name="description" content="Play the Project 64 demos — Snake, Invaders, Ms. Muncher, La Galaxia and 1812 — on an emulated Commodore 64, in the browser. Every one of them was written by an AI agent.">
<style>
/* Palette and motifs are index.html's, unchanged — same room, same screen,
same phosphor, same Commodore rainbow. Nothing new is invented here. */
:root{
--room:#08061a;
--screen:#40318d; /* C64 power-on blue */
--screen-2:#3a2c82;
--p:#8a7ce0; /* light blue text */
--p-bright:#cfc8f7; /* highlight */
--p-dim:#6a5cc4; /* muted */
--p-faint:#5041a5; /* faintest */
--glow:0 0 2px rgba(138,124,224,.55), 0 0 10px rgba(138,124,224,.22);
--mono:ui-monospace,"Cascadia Mono","Menlo","Consolas","Courier New",monospace;
--maxw:1080px;
--c64-red:#ff4b3b;
--c64-orange:#ff8f2e;
--c64-yellow:#ffd42e;
--c64-green:#5fdd7a;
--c64-cyan:#5bc8ff;
--c64-purple:#b98bff;
}
*{box-sizing:border-box}
body{
margin:0;background:var(--room);color:var(--p);
font-family:var(--mono);font-size:16px;line-height:1.5;
letter-spacing:.02em;text-shadow:var(--glow);
-webkit-font-smoothing:none;
}
body::before{
content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
background:radial-gradient(ellipse 80% 70% at 50% 30%, rgba(138,124,224,.06), transparent 70%);
}
a{color:var(--p-bright);text-decoration:none;border-bottom:1px solid var(--p-dim)}
a:hover,a:focus-visible{background:var(--p);color:var(--room);border-bottom-color:var(--p);outline:none;text-shadow:none}
:focus-visible{outline:2px solid var(--p-bright);outline-offset:2px}
.screen{
position:relative;z-index:1;max-width:var(--maxw);margin:0 auto;
min-height:100vh;background:linear-gradient(180deg,var(--screen),var(--screen-2));
padding:clamp(14px,3vw,34px);
box-shadow:0 0 0 2px #2a2168, 0 0 40px rgba(138,124,224,.10) inset, 0 0 60px rgba(0,0,0,.6);
border-radius:14px;
}
.screen::after{
content:"";position:fixed;inset:0;z-index:40;pointer-events:none;
background:
repeating-linear-gradient(0deg, rgba(0,0,0,.16) 0 1px, transparent 1px 3px),
radial-gradient(120% 120% at 50% 50%, transparent 62%, rgba(0,0,0,.55) 100%);
mix-blend-mode:multiply;
animation:flicker 5s steps(60) infinite;
}
@keyframes flicker{0%,100%{opacity:.96}48%{opacity:.99}50%{opacity:.9}52%{opacity:.99}}
.wrap{max-width:920px;margin:0 auto}
.panel{border:2px solid var(--p-dim);padding:clamp(14px,2.4vw,26px);margin:26px 0;position:relative;background:rgba(38,28,110,.35)}
.panel > .cap{position:absolute;top:-.75em;left:14px;background:var(--screen-2);padding:0 .5em;color:var(--p-bright);font-size:1rem;letter-spacing:.18em}
header{padding:6px 0 2px}
.backlink{display:inline-block;letter-spacing:.1em;font-size:1rem;border-bottom:none;color:var(--p-dim)}
.backlink:hover,.backlink:focus-visible{color:var(--room)}
.title{
font-weight:700;line-height:.98;margin:.15em 0 0;
font-size:clamp(1.9rem,7vw,3.6rem);letter-spacing:.02em;color:var(--p-bright);
text-shadow:0 0 4px rgba(207,200,247,.6),0 0 22px rgba(138,124,224,.45);
}
h2{font-size:clamp(1.05rem,3.2vw,1.35rem);color:var(--p-bright);letter-spacing:.12em;margin:.2em 0 .8em}
h2 .mk{color:var(--p-dim)}
p{margin:.55em 0}
small{font-size:1rem}
/* keys, rendered from the registry's backtick spans */
kbd{
font-family:var(--mono);font-size:.92em;letter-spacing:.04em;
padding:.05em .38em;border:1px solid var(--p-dim);
background:rgba(138,124,224,.12);color:var(--p-bright);
text-shadow:none;white-space:nowrap;
}
/* ── the demo strip: one tile per registry row, in registry order ───────── */
.demo-strip{
/* 160px, not 190: five tiles plus four gaps is 848px inside the 920px
column, so the whole roster sits on one row instead of 4 + 1. */
display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));
margin:6px 0 8px;
}
.demo-tile{
--accent:var(--p-bright);
display:block;width:100%;padding:0;cursor:pointer;
font-family:var(--mono);font-size:1rem;color:var(--p-bright);
border:2px solid var(--p-faint);border-bottom-width:5px;
background:color-mix(in srgb, var(--accent) 10%, rgba(30,22,92,.78));
text-shadow:var(--glow);
transition:border-color .15s,transform .15s,box-shadow .15s,background .15s;
}
.demo-tile img{
display:block;width:100%;height:auto;aspect-ratio:8/5;object-fit:contain;
background:#000;image-rendering:pixelated;
border-bottom:1px solid var(--p-faint);
}
.demo-tile .tile-name{
display:block;padding:.45em .6em;letter-spacing:.1em;color:var(--accent);
text-shadow:0 0 10px color-mix(in srgb, var(--accent) 55%, transparent), 0 1px 0 rgba(0,0,0,.5);
}
.demo-tile:hover,.demo-tile:focus-visible{
border-color:var(--accent);transform:translateY(-2px);
box-shadow:0 0 20px -5px var(--accent);outline:none;
}
.demo-tile[aria-pressed="true"]{
border-color:var(--accent);
background:color-mix(in srgb, var(--accent) 24%, rgba(30,22,92,.78));
box-shadow:0 0 22px -4px var(--accent), 0 0 0 1px var(--accent) inset;
}
/* ── the player ────────────────────────────────────────────────────────── */
#player-frame{margin:14px 0 8px;border:2px solid var(--p-faint);background:#000;padding:0}
#player-slot{display:block}
.preview{
position:relative;display:block;width:100%;padding:0;border:0;
background:#000;cursor:pointer;font-family:var(--mono);color:var(--p-bright);
}
.preview img{
display:block;width:100%;height:auto;aspect-ratio:8/5;object-fit:contain;
background:#000;image-rendering:pixelated;opacity:.55;transition:opacity .15s;
}
.preview:hover img,.preview:focus-visible img{opacity:.8}
/* The affordance is centred over the whole still (the overlay is inset:0), so
on four of the five stills it does land on the screen's own text —
invaders' score advance table, ms-muncher's cast row, la-galaxia's
ESPACIO UN JUGADOR, snake's PRESS ANY KEY TO PLAY; only 1812's evidence
frame, which carries no text, is unaffected. That is accepted: the still is an
invitation, not something to read, and the real screen replaces it on the
first click. What is defended is the affordance's own legibility over
whatever it lands on — the still is dimmed to .55 and the chip carries its
own near-opaque backing. */
.preview .play-overlay{
position:absolute;inset:0;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:.5em;pointer-events:none;
}
.preview .play-badge{
font-size:clamp(2.6rem,9vw,4.6rem);line-height:1;color:var(--p-bright);
text-shadow:0 0 10px rgba(8,6,26,.95),0 0 26px rgba(138,124,224,.7);
}
.preview .play-cta{
letter-spacing:.22em;font-size:1rem;color:var(--p-bright);
padding:.35em .9em;border:1px solid var(--p-dim);background:rgba(8,6,26,.82);
text-shadow:none;
}
.preview:hover .play-badge,.preview:focus-visible .play-badge{color:#fff}
.preview:hover .play-cta,.preview:focus-visible .play-cta{border-color:var(--p);color:#fff}
.slot-msg{
padding:clamp(18px,4vw,34px);text-align:center;color:var(--p-bright);background:#000;
}
.slot-msg .msg-head{display:block;letter-spacing:.14em;margin-bottom:.5em}
.slot-msg p{max-width:56ch;margin:.5em auto;color:var(--p);font-size:1rem}
.slot-msg .dl{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin-top:1em}
.btn{display:inline-block;padding:.5em 1em;border:2px solid var(--p);background:transparent;color:var(--p-bright);
font-family:var(--mono);font-size:1rem;letter-spacing:.08em;cursor:pointer;text-shadow:var(--glow)}
.btn:hover,.btn:focus-visible{background:var(--p);color:var(--room);text-shadow:none;outline:none;border-color:var(--p)}
.controls-legend{margin:.7em 0 .2em;color:var(--p-bright);font-size:1rem}
.controls-legend .lbl{color:var(--p-dim);letter-spacing:.16em;margin-right:.6em}
.sound-hint{margin:.2em 0 .6em;color:var(--c64-yellow);font-size:1rem;text-shadow:0 0 10px rgba(255,212,46,.35)}
.sound-hint[hidden]{display:none}
.now-playing{margin:.2em 0 0;color:var(--p-dim);font-size:1rem}
/* the player's own CSS hooks, dressed for a dark page */
#player_container{background:#000;max-width:100%}
#vc64web{border:0;display:block;background:#000}
.player_icon_btn{color:var(--p-bright);fill:currentColor;background:transparent;cursor:pointer;opacity:.85}
.player_icon_btn:hover{color:#fff;opacity:1}
.btn_play{color:var(--p-bright);fill:currentColor;background:transparent;opacity:1}
/* White, not a palette token: this line is the page's one piece of copy
about the thing you are about to run, so it outranks the body text. */
.demo-desc{margin:0 0 12px;color:#fff;font-size:1rem;line-height:1.5}
.attribution-note b{color:var(--p-bright)}
.attribution-note .links{margin-top:.6em}
/* Three clear blocks of air above the receipts panel, so the page's own
story is visibly separate from the demos above it. */
.receipts-gap{margin-top:3em}
/* Credits: one block per upstream project — name and role on one line, who
made it under that, licence last. The play page runs other people's work,
so it names them here rather than leaving a licence clause to stand in
for a credit. */
.credits .who{margin:0 0 1.1em}
.credits .who:last-of-type{margin-bottom:0}
.credits .name{display:block;color:var(--p-bright)}
.credits .role{color:var(--p-dim)}
.credits .by{display:block;margin-top:.25em}
.credits .lic{display:block;margin-top:.15em;color:var(--p-dim);font-size:.92em}
.credits .note{margin:1.4em 0 0;color:var(--p-dim);font-size:.92em;line-height:1.6}
noscript{display:block}
.noscript-box ul{list-style:none;padding:0;margin:.6em 0}
.noscript-box li{margin:.3em 0}
footer{margin:30px 0 8px;color:var(--p-dim);font-size:1rem}
footer .links{display:flex;flex-wrap:wrap;gap:8px 22px;margin:.6em 0}
.ready{color:var(--p-bright);margin-top:20px;font-size:1.05rem}
.cursor{display:inline-block;width:.62em;height:1.05em;background:var(--p);vertical-align:-.16em;animation:blink 1.06s steps(1) infinite;box-shadow:var(--glow)}
@keyframes blink{50%{opacity:0}}
@media (max-width:560px){
.screen{border-radius:0}
}
@media (prefers-reduced-motion:reduce){
*{animation:none!important;transition:none!important}
.cursor{opacity:1}
}
</style>
</head>
<body>
<div class="screen">
<div class="wrap">
<header>
<a class="backlink" href="index.html">◂ PROJECT 64</a>
<h1 class="title">PLAY</h1>
</header>
<nav class="demo-strip" id="demo-strip" aria-label="Choose a demo"></nav>
<main>
<!-- The selected demo's description, verbatim from index.html's demo
table (tests/test_docs_demos.py holds the two in step). It sits
between the strip and the screen so the choice you just made is
described where you are looking. It PERSISTS and only its text
changes, which is what lets it carry a live region. -->
<p class="demo-desc" id="demo-desc" aria-live="polite"></p>
<div id="player-frame">
<div id="player-slot"></div>
</div>
<!-- Both of these appear or change under the visitor rather than on a
page load — the legend when a tile is picked, the hint when an
auto-boot starts — so both announce when that happens. They are the
right nodes to carry it because both PERSIST: only their contents,
or the `hidden` attribute, change, and a live region announces
reliably only if it was already in the document. -->
<p class="controls-legend" id="controls-legend"><span class="lbl">CONTROLS</span><span id="controls-text" aria-live="polite"></span></p>
<p class="sound-hint" id="sound-hint" role="status" hidden>the C64 is muted until you click — click the screen for sound</p>
</main>
<section class="panel attribution-note receipts-gap">
<span class="cap">RECEIPTS</span>
<p><b>Every demo here was written by Claude.</b> Each demo folder holds a single
prompt. An AI agent was given that prompt and wrote, assembled, debugged,
and verified the entire program on an emulated Commodore 64 — no
human-written code. The receipts — prompt, audit, and evidence — are in
each demo's folder:</p>
<p class="links" id="attribution-links"></p>
</section>
<noscript>
<section class="panel noscript-box">
<span class="cap">NO JAVASCRIPT</span>
<p>The in-browser C64 needs JavaScript — but every demo is a plain file
you can download and run in <a href="https://vice-emu.sourceforge.io/">VICE</a>
(<code>x64sc -ntsc <file></code>). The <code>.prg</code> is the program
itself — the one this page runs; the <code>.d64</code> is the same demo
as a disk image, for a real drive:</p>
<ul>
<li>SNAKE —
<a href="demos/snake/snake.prg">snake.prg</a> ·
<a href="demos/snake/snake.d64">snake.d64</a></li>
<li>INVADERS —
<a href="demos/invaders/invaders.prg">invaders.prg</a> ·
<a href="demos/invaders/invaders.d64">invaders.d64</a></li>
<li>MS. MUNCHER —
<a href="demos/ms-muncher/ms-muncher.prg">ms-muncher.prg</a> ·
<a href="demos/ms-muncher/ms-muncher.d64">ms-muncher.d64</a></li>
<li>LA GALAXIA —
<a href="demos/la-galaxia/la-galaxia.prg">la-galaxia.prg</a> ·
<a href="demos/la-galaxia/la-galaxia.d64">la-galaxia.d64</a></li>
<li>1812 —
<a href="demos/1812/1812.prg">1812.prg</a> ·
<a href="demos/1812/1812.d64">1812.d64</a></li>
<li>FUGUE IN C MINOR —
<a href="demos/fugue/fugue.prg">fugue.prg</a> ·
<a href="demos/fugue/fugue.d64">fugue.d64</a></li>
<li>AMIGA BALL —
<a href="demos/amiga_ball/amiga_ball.prg">amiga_ball.prg</a> ·
<a href="demos/amiga_ball/amiga_ball.d64">amiga_ball.d64</a></li>
</ul>
</section>
</noscript>
<section class="panel credits">
<span class="cap">CREDITS</span>
<p>The demos are ours. The Commodore 64 they run on is not — it is three
pieces of other people’s work, loaded at runtime and not bundled here.</p>
<p class="who">
<span class="name"><a href="https://vc64web.github.io/">vc64web</a>
<span class="role">— the emulator in the frame above</span></span>
<span class="by">A WebAssembly port by <b>mithrendal</b>, served from a
<a href="https://github.com/nschneir/vc64web.github.io">fork</a> so this
page and the emulator share an origin.</span>
</p>
<p class="who">
<span class="name"><a href="https://github.com/dirkwhoffmann/virtualC64">VirtualC64</a>
<span class="role">— the emulator vc64web is a port of</span></span>
<span class="by">By <b>Dirk W. Hoffmann</b>.</span>
<span class="lic">GPL-3.0</span>
</p>
<p class="who">
<span class="name"><a href="https://github.com/MEGA65/open-roms">MEGA65 open-roms</a>
<span class="role">— the KERNAL, BASIC and character ROMs it boots on</span></span>
<span class="by">A free re-implementation by <b>Paul Gardner-Stephen</b>
and <b>Roman Standzikowski</b> (FeralChild64). The <b>PXL</b> character font
is by <b>Retrofan</b>.</span>
<span class="lic">LGPL-3.0-or-later · parts of BASIC additionally
MIT © Microsoft Corporation</span>
</p>
<p class="note">These are not Commodore’s ROMs. No Commodore ROM image is
hosted or distributed by either repository.</p>
</section>
<footer>
<div class="links">
<a href="index.html">▸ Home</a>
<a href="https://github.com/nschneir/Project64">▸ GitHub</a>
<a href="https://github.com/nschneir/Project64/tree/main/demos">▸ Demos</a>
<a href="https://github.com/nschneir/Project64/blob/main/LICENSE">▸ License</a>
</div>
<p class="ready">READY.<span class="cursor" aria-hidden="true"></span></p>
</footer>
</div>
</div>
<script>
(function(){
"use strict";
/* ══════════════════════════════════════════════════════════════════════════
CONSTANTS — the one and only place these values appear.
The emulator boots on the MEGA65 open-roms KERNAL + BASIC + CHARGEN, with
no 1541 drive ROM at all: not a compromise but the better configuration —
no Commodore ROM image is hosted or distributed by this repository or the
emulator fork, and every demo on the strip was verified reaching a
playable frame on it before ship. The standing caveats — chiefly that the demos' own test
suites still run on Commodore ROMs — are in docs/todo.md. Nothing below
hard-codes a URL, a ROM name, or the choice of medium.
The ROMs themselves are an UNTAGGED open-roms dev build (they announce
RELEASE DEV.210823.FC.1 at boot), vendored into the fork's roms/ and
pinned by sha256 in its roms/README.md — upstream has no tagged
releases to move to. Refreshing them is done as a MATCHED PAIR only
(open-roms' bin/README.md forbids mixing BASIC and KERNAL from
different builds), re-recording the sha256 pins and the on-screen build
string in the fork's roms/README.md. If the banner a visitor boots ever
disagrees with the string recorded there, the pin was bypassed.
The demo tiles below were captured under VICE on Commodore's CHARGEN,
while this page boots open-roms' PXL font — so prose text in a tile
shows letterforms the visitor will not see once the machine boots.
Accepted deliberately (2026-08-14): evidence/ is each demo's
proof-of-work from the reference machine and is not re-shot to flatter
a web page; cells, colours and layout are identical, so nothing about
the games misleads. (la-galaxia is unaffected — it builds its charset
from scratch.)
══════════════════════════════════════════════════════════════════════════ */
var EMU_BASE = "https://nschneir.github.io/vc64web.github.io/";
var PLAYER_SCRIPT = EMU_BASE + "js/vc64web_player.js";
/* The player lazy-loads this itself if it is missing (vc64web_player.js:117),
and that lazy load is what makes the first mount asynchronous. We fetch it
up front instead — see loadPlayerScript(). Tracked in the fork beside the
player script. */
var JQUERY_SCRIPT = EMU_BASE + "js/jquery-3.7.1.min.js";
var ROM_BASE = EMU_BASE + "roms/";
/* Three ROMs, not four. There is deliberately no floppy/1541 ROM: a .prg is
flashed straight into RAM and never touches the drive, and the only free
drive ROM would be a Commodore one. */
var KERNAL_ROM_URL = ROM_BASE + "kernal.rom";
var BASIC_ROM_URL = ROM_BASE + "basic.rom";
var CHARSET_ROM_URL = ROM_BASE + "chargen.rom";
/* Which registry field the player is handed. ".prg" is load-bearing: with no
drive ROM installed vc64web disables the insert button for .d64/.g64 and
warns instead of loading (vc64_ui.js:891-895), while a .prg takes the
auto_run + reset_before_load path and needs no drive. The demos' own
`10 SYS 2061` BASIC stub is what the auto-typed RUN fires, so there is no
autostart script to write — the player does it. */
var BOOT_MEDIUM = "prg";
/* Settings that must never reach the player, swept immediately before load().
A denylist and not an allowlist on purpose: an allowlist — JSON.stringify's
array replacer is the tempting one-liner — would also silently drop a
legitimate setting added here later, trading one silent failure for
another. A key listed here is deleted and announced on the console, so a
well-meaning re-addition is both inert and audible.
dialog_on_disk: execute_load() reads it as "loading is probably done by
scripting" and then skips auto-run entirely (vc64_ui.js:3507-3510). The
program is flashed into RAM correctly but RUN is never typed, so the
machine sits at a READY. prompt that looks exactly like an open-ROM
incompatibility — it cost the ROM investigation its one failed run. A .prg
raises no disk dialog, so setting it buys nothing even where it is
harmless. */
var FORBIDDEN_CONFIG = {
dialog_on_disk: "it silently disables the player's auto-run (vc64_ui.js:3507-3510)"
};
var REPO_TREE = "https://github.com/nschneir/Project64/tree/main/demos/";
/* ══════════════════════════════════════════════════════════════════════════
DEMO REGISTRY — the roster, in order. `controls` is authored with the key
names in `backticks`; renderKeys() turns those into <kbd> elements.
══════════════════════════════════════════════════════════════════════════ */
var DEMOS = [
{
id: "snake",
title: "SNAKE",
accent: "var(--c64-green)",
tagline: "Eat apples, don't eat yourself.",
description: "Arcade Snake on a custom hires charset — held-key steering read off the keyboard matrix, SID sound, nine speeding-up levels",
d64: "demos/snake/snake.d64",
prg: "demos/snake/snake.prg",
image: "demos/snake/evidence/title.png",
alt: "Snake title screen: SNAKE in large block letters in five colours, a green snake reaching for a red apple, and PRESS ANY KEY TO PLAY",
controls: "Any key starts. `W`/`A`/`S`/`D` steer; `SPACE` plays again after game over."
},
{
id: "invaders",
title: "INVADERS",
accent: "var(--c64-cyan)",
tagline: "The classic descending formation.",
description: "The 1978 arcade original — sprites and custom charset, the one-invader-per-tick march, three-voice SID",
d64: "demos/invaders/invaders.d64",
prg: "demos/invaders/invaders.prg",
image: "demos/invaders/evidence/title.png",
alt: "Invaders attract screen: INVADERS in large block letters above a score advance table",
controls: "Any key starts. Hold `A`/`D` to move, `SPACE` to fire."
},
{
id: "ms-muncher",
title: "MS. MUNCHER",
accent: "var(--c64-yellow)",
tagline: "Four rotating mazes, and every ghost hunts differently.",
description: "A maze chase — four rotating mazes, per-ghost targeting AI, six sprites, animated cut scenes",
d64: "demos/ms-muncher/ms-muncher.d64",
prg: "demos/ms-muncher/ms-muncher.prg",
image: "demos/ms-muncher/evidence/title.png",
alt: "Ms. Muncher attract screen: MS MUNCHER in large block letters above the four named ghosts and a table of top scores",
controls: "`SPACE` starts. `W`/`A`/`S`/`D` steer; `SPACE` also skips an intermission."
},
{
id: "la-galaxia",
title: "LA GALAXIA",
accent: "var(--c64-red)",
tagline: "A fixed shooter in Spanish, with the full 40-enemy formation.",
description: "An old school shooter in Spanish with a deliberately off-kilter sound track — a 40-enemy formation in character RAM and raster-IRQ sprite multiplexing",
d64: "demos/la-galaxia/la-galaxia.d64",
prg: "demos/la-galaxia/la-galaxia.prg",
image: "demos/la-galaxia/evidence/title.png",
alt: "La Galaxia attract screen: the game name in yellow over a starfield, framed by a bezel with the Spanish HUD down both sides",
controls: "`SPACE` starts one player, `X` starts two. Hold `A`/`D` to move, `SPACE` to fire."
},
{
/* Not a game — the one demo here you watch rather than play, which is
why its tile art is an evidence frame: 1812 is the only demo in the
tree with no `evidence/title.png`, having no title screen to shoot. */
id: "1812",
title: "1812",
accent: "var(--c64-purple)",
tagline: "Tchaikovsky, painted as it plays.",
description: "Shapes painted to Tchaikovsky's 1812 Overture — bitmap mode, a rotating polygon rasterizer, three-voice SID",
d64: "demos/1812/1812.d64",
prg: "demos/1812/1812.prg",
image: "demos/1812/evidence/sec1.png",
alt: "The 1812 canvas at the end of the Marseillaise: large blue and red polygons over dithered blue, red and white fills",
controls: "Nothing to press — it paints itself for 2:50, then holds the finished canvas. Any key restarts it with a fresh seed."
},
{
/* Not a game either — and the second demo with no title screen to
shoot, so its tile art is the frame the whole thing is about: the
third voice entering, with all three backlights lit. */
id: "fugue",
title: "FUGUE IN C MINOR",
accent: "var(--c64-cyan)",
tagline: "Bach's score, scrolling as it plays.",
description: "Bach's BWV 847 on three SID voices while its score scrolls past — custom charset staves, pitch-class note colors, a sprite backlighting the sounding note",
d64: "demos/fugue/fugue.d64",
prg: "demos/fugue/fugue.prg",
image: "demos/fugue/evidence/entry3.png",
alt: "A grand staff scrolling right to left: coloured note heads with flats and sharps beside them across both staves, a bar line joining the two, and three sprite glows lit behind the notes sounding at the fixed now column",
controls: "Nothing to press — it plays once for 66 seconds and stops on a held C major chord."
},
{
id: "amiga_ball",
title: "AMIGA BALL",
accent: "var(--c64-purple)",
tagline: "The 1984 Boing Ball, bouncing on a C64.",
description: "The Amiga's 1984 Boing Ball on four multicolor sprites — a precomputed sphere texture, a custom-charset grid room, and a SID impact thump",
d64: "demos/amiga_ball/amiga_ball.d64",
prg: "demos/amiga_ball/amiga_ball.prg",
image: "demos/amiga_ball/evidence/contact.png",
alt: "The Boing Ball at floor contact: a red-and-white checkered sphere with a black rim resting on a light-blue perspective floor grid, a grey elliptical shadow beneath it with a floor grid line drawn across it, and a purple wall grid above the horizon",
controls: "Nothing to press — the ball bounces and spins on its own, reversing its spin at each side wall."
}
];
/* ── small helpers ─────────────────────────────────────────────────────── */
var strip = document.getElementById("demo-strip");
var frame = document.getElementById("player-frame");
var legend = document.getElementById("controls-text");
var blurb = document.getElementById("demo-desc");
var hint = document.getElementById("sound-hint");
var attrLinks= document.getElementById("attribution-links");
var activeDemo = null; // the selected registry entry
var playerUp = false; // has a mount been sanctioned and not torn down?
var bootGen = 0; // bumped by every teardown; a boot that started under
// an older number has been overtaken and must not mount
var scriptLoad = null; // memoised Promise<boolean> for the player + jQuery
/* The wiki's ontouchstart listener still upgrades this, but it cannot have
fired before a Task 3 auto-boot, which would then hand the emulator
touch:false on a phone. Seed it from the primary pointer instead: coarse is
a phone or a tablet, and — unlike maxTouchPoints — not a laptop that merely
has a touchscreen. */
var isTouch = !!(window.matchMedia && window.matchMedia("(pointer: coarse)").matches);
document.addEventListener("touchstart", function(){ isTouch = true; },
{once:true, passive:true});
function demoById(id){
for(var i=0;i<DEMOS.length;i++){ if(DEMOS[i].id === id) return DEMOS[i]; }
return null;
}
function fileName(path){ return path.slice(path.lastIndexOf("/") + 1); }
function esc(s){
return String(s).replace(/&/g,"&").replace(/</g,"<")
.replace(/>/g,">").replace(/"/g,""");
}
/* `X` -> <kbd>X</kbd>, everything else escaped. Lets the registry hold the
controls copy verbatim instead of as markup. */
function renderKeys(text){
return esc(text).replace(/`([^`]+)`/g, function(_, k){ return "<kbd>" + k + "</kbd>"; });
}
function el(tag, cls, text){
var n = document.createElement(tag);
if(cls) n.className = cls;
if(text != null) n.textContent = text;
return n;
}
function downloadLink(href, label){
var a = el("a", "btn", label);
a.href = href;
a.setAttribute("download", "");
return a;
}
/* ── the tile strip ────────────────────────────────────────────────────── */
function buildStrip(){
DEMOS.forEach(function(demo){
var b = el("button", "demo-tile");
b.type = "button";
b.id = "tile-" + demo.id;
b.dataset.demo = demo.id;
b.style.setProperty("--accent", demo.accent);
b.setAttribute("aria-pressed", "false");
/* The name is the button's label; the screenshot keeps its own real
description rather than being flattened into the label. */
b.setAttribute("aria-label", demo.title + " — " + demo.tagline);
var img = document.createElement("img");
img.src = demo.image;
img.alt = demo.alt;
img.loading = "lazy";
img.decoding = "async";
b.appendChild(img);
b.appendChild(el("span", "tile-name", demo.title));
b.addEventListener("click", function(){ selectDemo(demo.id, false); });
strip.appendChild(b);
});
}
function markTiles(){
DEMOS.forEach(function(demo){
var b = document.getElementById("tile-" + demo.id);
if(b) b.setAttribute("aria-pressed", demo === activeDemo ? "true" : "false");
});
}
function buildAttribution(){
DEMOS.forEach(function(demo, i){
if(i) attrLinks.appendChild(document.createTextNode(" · "));
var a = el("a", null, demo.title);
a.href = REPO_TREE + demo.id;
attrLinks.appendChild(a);
});
}
/* ── the player slot ───────────────────────────────────────────────────── */
/* The player replaces #player-slot's PARENT's innerHTML, and its own
stop_emu_view() puts back whatever was there. Either way the slot node is
a new element afterwards, so never cache it — ask for it. */
function ensureSlot(){
var stray = frame.querySelector("#player_container");
if(stray && stray.parentNode) stray.parentNode.removeChild(stray);
var slot = document.getElementById("player-slot");
if(!slot || !frame.contains(slot)){
frame.innerHTML = "";
slot = document.createElement("div");
slot.id = "player-slot";
frame.appendChild(slot);
}
return slot;
}
/* stop_emu_view() clears state_poller but never forgets the id, and it only
runs at all if the player thinks something is mounted. A poller started by
a mount we did not sanction would otherwise postMessage at a dead iframe
every 900 ms, for ever. */
function stopPoller(){
var player = window.vc64web_player;
if(player && player.state_poller != null){
clearInterval(player.state_poller);
player.state_poller = null;
}
}
function teardownPlayer(){
bootGen++; // orphan any boot still in flight
var player = window.vc64web_player;
if(player){
try{
/* saved_pic_html is the player's own "I am mounted" flag — trust it as
well as ours, so a mount we did not sanction is still unwound. */
if(playerUp || player.saved_pic_html != null) player.stop_emu_view();
}catch(e){ /* best-effort; ensureSlot() is the guarantee */ }
}
stopPoller();
playerUp = false;
return ensureSlot();
}
/* ── focus, across a slot swap ─────────────────────────────────────────── */
/* Every swap of the slot destroys the element the visitor is standing on —
the preview button that starts the boot, the BACK button, the emulator's
own iframe — and focus then falls to <body>, which costs a keyboard or
screen-reader visitor their place mid-interaction. So a swap hands focus to
whatever replaced what it destroyed. Only when the focus really was in the
player area, though: on first paint, and on a tile click, focus belongs
exactly where it already is, and moving it would be the same rudeness from
the other side. Read BEFORE the swap — afterwards the answer is always
<body>. */
function slotHasFocus(){
var a = document.activeElement;
return !!a && a !== document.body && frame.contains(a);
}
function takeFocus(node){
if(!node || !node.focus) return;
/* preventScroll where it exists: the visitor is already looking at this
region, so there is nothing to scroll to and a jump would be noise. */
try{ node.focus({preventScroll:true}); }catch(e){ node.focus(); }
}
/* `focusIt` forces the move for a caller that knows the focus was carried off
by something already gone — the emulator iframe, removed before this page
could see it hold the focus. */
function renderPreview(demo, focusIt){
var refocus = focusIt || slotHasFocus();
var slot = ensureSlot();
slot.innerHTML = "";
var b = el("button", "preview");
b.type = "button";
b.setAttribute("aria-label", "Play " + demo.title);
var img = document.createElement("img");
img.src = demo.image;
img.alt = demo.alt;
img.decoding = "async";
var overlay = el("span", "play-overlay");
overlay.appendChild(el("span", "play-badge", "▶"));
overlay.appendChild(el("span", "play-cta", "CLICK TO PLAY"));
b.appendChild(img);
b.appendChild(overlay);
b.addEventListener("click", function(){ bootPlayer(demo); });
slot.appendChild(b);
if(refocus) takeFocus(b);
}
function renderMessage(demo, head, body, withDownloads){
var refocus = slotHasFocus();
var slot = ensureSlot();
slot.innerHTML = "";
var box = el("div", "slot-msg");
/* The only two things this renders are state — the boot in progress, and
the boot that failed — so the box announces itself, and is focusable so
that the swap above can land the visitor on the news rather than on
<body>. role="status" here rather than a live region around the whole
slot on purpose: the slot also holds the preview, whose alt text is a
paragraph of screen description, and a live region there would re-read
that paragraph on every tile switch. */
box.setAttribute("role", "status");
box.tabIndex = -1;
box.appendChild(el("span", "msg-head", head));
box.appendChild(el("p", null, body));
if(withDownloads){
var dl = el("div", "dl");
dl.appendChild(downloadLink(demo[BOOT_MEDIUM], fileName(demo[BOOT_MEDIUM]).toUpperCase()));
var other = BOOT_MEDIUM === "d64" ? "prg" : "d64";
dl.appendChild(downloadLink(demo[other], fileName(demo[other]).toUpperCase()));
box.appendChild(dl);
var back = el("button", "btn", "▸ BACK TO THE PREVIEW");
back.type = "button";
back.addEventListener("click", function(){ renderPreview(demo); });
var row = el("div", "dl");
row.appendChild(back);
box.appendChild(row);
}
slot.appendChild(box);
if(refocus) takeFocus(box);
}
/* ── loading the player script (and failing honestly if it will not) ───── */
function loadScript(url){
return new Promise(function(resolve){
var s = document.createElement("script");
s.src = url;
s.async = true;
s.onload = function(){ resolve(true); };
s.onerror = function(){ resolve(false); };
document.head.appendChild(s);
});
}
/* Resolves true only when BOTH the player and jQuery are in the page.
Requiring jQuery up front is the point: with window.jQuery already set,
vc64web_player.load() calls load_into() inline (vc64web_player.js:111-119)
and the iframe is mounted before load() returns. Left to itself the player
fetches jQuery lazily and mounts whenever that lands — a window in which
the visitor can pick another demo, and demo A ends up under demo B's
highlight. Everything after this promise runs under a generation check. */
function loadPlayerScript(){
if(scriptLoad) return scriptLoad;
scriptLoad = Promise.all([
window.vc64web_player ? Promise.resolve(true) : loadScript(PLAYER_SCRIPT),
window.jQuery ? Promise.resolve(true) : loadScript(JQUERY_SCRIPT)
]).then(function(){
return !!window.vc64web_player && !!window.jQuery;
});
return scriptLoad;
}
/* ── the two exported behaviours ───────────────────────────────────────── */
function bootPlayer(demo){
var gen = bootGen; // the generation this boot belongs to
renderMessage(demo, "STARTING THE C64…",
"Fetching the emulator and the system ROMs.", false);
loadPlayerScript().then(function(ok){
/* Overtaken while the scripts were in flight: another tile was picked,
so this boot is stale and must not touch the DOM at all. */
if(gen !== bootGen) return;
if(!ok || !window.vc64web_player || !window.jQuery){
/* A deep link shows the sound hint before the boot is attempted, so on
this path the yellow "click the screen for sound" line would sit under
a message saying there is no screen to click. Nothing will unlock
audio now, so the hint is retracted with the boot. */
hideSoundHint();
renderMessage(demo, "THE EMULATOR FAILED TO LOAD.",
"The in-browser C64 could not be reached, so " + demo.title +
" cannot run here right now. It still runs anywhere else — " +
"download the program (or the disk image) and open it in VICE with " +
"x64sc -ntsc.", true);
return;
}
var player = window.vc64web_player;
var media = demo[BOOT_MEDIUM];
player.vc64web_url = EMU_BASE;
/* Three ROM keys and the program. `name` is what vc64web branches on —
the .prg extension is what selects the no-drive load path — so it must
carry the real filename, not the demo id. */
player.samesite_file = {
kernal_rom_url: KERNAL_ROM_URL,
basic_rom_url: BASIC_ROM_URL,
charset_rom_url: CHARSET_ROM_URL,
url: media,
name: fileName(media)
};
var config = {
/* Keeps the SYSTEM ROMS modal off the first paint. Not cosmetic: the
ROMs arrive by postMessage just after boot, and if that modal is open
when the program message lands, configure_file_dialog() treats the
incoming .prg as a ROM file instead of running it. */
dialog_on_missing_roms: false,
/* dialog_on_disk is absent on purpose — it looks like the obvious
companion to the line above and it silently breaks auto-run. It is on
the FORBIDDEN_CONFIG denylist, so adding it back here has no effect
beyond a console warning; the full reason is at that constant. */
navbar: false,
wide: false,
border: 0.3,
touch: isTouch
/* No `buttons` autostart script: vc64web auto-runs a .prg itself —
flash to the load address, type RUN — and every demo carries the
`10 SYS 2061` stub that RUN fires. */
};
/* The denylist sweep — see FORBIDDEN_CONFIG for why each key is on it.
Deliberately after the literal above, so it catches a key however it
got there. */
Object.keys(FORBIDDEN_CONFIG).forEach(function(key){
if(key in config){
delete config[key];
if(window.console && console.warn){
console.warn("play.html: refusing config." + key + " — " +
FORBIDDEN_CONFIG[key]);
}
}
});
playerUp = true;
/* encodeURIComponent is required, not tidiness: load_into() builds the
iframe from an HTML string (src="...#${address}"), so a raw quote in the
JSON truncates the attribute and every setting is silently dropped.
get_parameter_link() decodes on the way in. */
player.load(ensureSlot(), encodeURIComponent(JSON.stringify(config)));
});
}
/* ── the sound hint ────────────────────────────────────────────────────── */
/* Shown only for an auto-boot: a visitor who clicked the screen themselves
has already made the gesture that unlocks audio, so the hint would be
telling them about something they just did. */
var hintShown = false;
function hideSoundHint(){
if(!hintShown) return;
hintShown = false;
hint.hidden = true;
frame.removeEventListener("click", hideSoundHint);
window.removeEventListener("message", onPlayerMessage);
}
/* The brief's trigger is "the first click anywhere in the player area", and
the frame listener below is exactly that. But once the emulator is up, the
player area IS a cross-origin-style iframe: a click that lands on the C64
screen — the click the hint is asking for — never bubbles to this document,
so the frame listener alone would leave the hint up for precisely the
visitor it is aimed at. The player relays the emulator's audio state to the
parent (vc64web_player.js:104-107) and 'running' is the unlocked state
(:351). That is the moment the hint stops being true, so it is the second
trigger. */
function onPlayerMessage(e){
/* Only the player's origin may retract the sound hint. The stake is tiny
— a line of advice, no state — but an unchecked `message` listener is
not a pattern to leave in the tree. EMU_BASE is where the player
iframe is served from; `new URL(...).origin` beats string-prefixing
the constant, whose trailing slash would never equal an origin. Scope:
this drops CROSS-origin posts (a hostile opener or embedded frame).
This page happens to share the player's origin, and a same-origin
script needs no spoofing — it could call hideSoundHint() itself — so
same-origin is the boundary, and e.source pinning would buy nothing
against an attacker that is already inside it. */
if(!e || e.origin !== new URL(EMU_BASE).origin) return;
if(!e.data || e.data.msg !== "render_current_audio_state") return;
if(e.data.value === "running") hideSoundHint();
}
function showSoundHint(){
if(hintShown) return;
hintShown = true;
hint.hidden = false;
frame.addEventListener("click", hideSoundHint);
window.addEventListener("message", onPlayerMessage);
}
function selectDemo(id, boot){
var demo = demoById(id);
if(!demo) return;
teardownPlayer();
activeDemo = demo;
markTiles();
legend.innerHTML = renderKeys(demo.controls);
/* textContent, not innerHTML: the description is prose copied from
index.html and carries no markup of its own. */
blurb.textContent = demo.description;
renderPreview(demo);
if(boot) bootPlayer(demo);
}
/* The frame and this page's idea of the frame can disagree in two opposite
directions, and the player can cause either one without telling us. Both
are read off the live DOM rather than off the mutation records: what
matters is the state the visitor is left in, not which edit produced it.
Because these run in a MutationObserver callback — a microtask delivered
after the task that mutated — neither can fire mid-mount. bootPlayer() sets
playerUp and calls player.load() in one synchronous run, and load() mounts
inline (jQuery is required up front for exactly this reason), so by the
time either branch is reached the two are already consistent. */
if(window.MutationObserver){
new MutationObserver(function(){
var mounted = !!frame.querySelector("#player_container");
/* THE PLAYER LEFT BY ITS OWN HAND. load_into() saves this frame's HTML at
MOUNT time (vc64web_player.js:150) and stop_emu_view() puts exactly
that back (:362-368) — and what it saved is the "STARTING THE C64…"
panel bootPlayer() painted a moment earlier, because the mount happens
while that panel is on screen. It tells nobody. The ⏹ that calls it is
a control this page deliberately styles (.player_icon_btn), so it is
offered rather than incidental, and the player's "open in a new tab"
link calls it too. Left alone, playerUp stays true over a dead frame
and the visitor sits on a boot message for a boot that is over, with no
way back but re-picking a tile. */
if(playerUp){
if(mounted) return;
teardownPlayer(); // playerUp, the generation counter, the poller
hideSoundHint(); // no screen left to click for sound
/* The focus went with the iframe, so it is already <body> by now and
slotHasFocus() cannot see what was lost — hence the forced move. */
if(activeDemo) renderPreview(activeDemo, true);
return;
}
/* A MOUNT NOBODY SANCTIONED — last line of defence, against future
versions rather than this one. Requiring jQuery above means the player
mounts inline, so today a mount cannot land after its generation has
passed. That is settled — but it is settled about a dependency we do
not control, and if a mount ever did land late it would put the wrong
demo under the right tile, which is worse than a leak. It is removed,
the poller it started is stopped, and the current demo's preview goes
back. */
if(!mounted) return;
stopPoller();
ensureSlot();
if(activeDemo) renderPreview(activeDemo);
}).observe(frame, {childList:true, subtree:true});
}
/* ── deep links: play.html?demo=<id> ───────────────────────────────────── */
/* A known id boots unprompted and gets the sound hint; anything else — an
unknown id, an empty value, no query at all — falls back to the first demo,
selected but idle. A bad link lands somewhere sensible rather than on an
error, which is the whole point of it being an entry point and not state.
Nothing here touches history: switching tiles afterwards leaves the URL
alone, so the query is where the visitor came in, not where they are. */
function initFromQuery(){
var requested = null;
try{
requested = new URLSearchParams(location.search).get("demo");
}catch(e){ requested = null; }
var demo = requested ? demoById(requested) : null;
if(demo){
showSoundHint();
selectDemo(demo.id, true);
}else{
selectDemo(DEMOS[0].id, false);
}
}
/* Tasks 4-5 link to play.html?demo=<id>; this is the contract they rely on. */
window.playPage = {
DEMOS: DEMOS,
selectDemo: selectDemo,
bootPlayer: bootPlayer,
activeDemo: function(){ return activeDemo; }
};