-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
1349 lines (1269 loc) · 72.2 KB
/
Copy pathindex.html
File metadata and controls
1349 lines (1269 loc) · 72.2 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">
<title>KERNEL — a provably-fair tactical game</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#14161F; --bg2:#1B1E2A; --bg3:#222636; --line:#2E3346;
--ink:#E8EAF2; --mut:#8A90A6; --dim:#5A607A;
--player:#5B8DEF; --player2:#8FB2F6;
--enemy:#E0736B; --enemy2:#EE9C96;
--relay:#E0A340; --seal:#E0A340;
--verify:#38D0C4; --verify2:#7fe6dd;
--danger:#E0736B;
--radius:10px;
--mono:'IBM Plex Mono',ui-monospace,Menlo,monospace;
--disp:'Chakra Petch',system-ui,sans-serif;
--sans:'IBM Plex Sans',system-ui,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0}
body{
background:
radial-gradient(1100px 600px at 78% -8%, #1c2033 0%, transparent 60%),
radial-gradient(900px 500px at 5% 110%, #191c2b 0%, transparent 55%),
var(--bg);
color:var(--ink); font-family:var(--sans);
-webkit-font-smoothing:antialiased; line-height:1.5; min-height:100vh;
}
a{color:var(--verify2)}
.wrap{max-width:1180px; margin:0 auto; padding:26px 20px 60px}
.hidden{display:none!important}
/* header */
header.top{display:flex; align-items:flex-end; justify-content:space-between; gap:20px; flex-wrap:wrap; margin-bottom:22px}
.brand{display:flex; align-items:baseline; gap:14px}
.logo{font-family:var(--disp); font-weight:700; letter-spacing:.14em; font-size:30px; line-height:1}
.logo b{color:var(--verify)}
.tagline{font-family:var(--mono); font-size:11px; color:var(--mut); letter-spacing:.16em; text-transform:uppercase}
.headright{display:flex; gap:10px; align-items:center; flex-wrap:wrap}
.chip{font-family:var(--mono); font-size:11px; color:var(--ink); border:1px solid var(--line); background:var(--bg2); padding:6px 12px; border-radius:999px; letter-spacing:.06em}
.chip span{color:var(--verify)}
.chip.agree.ok{border-color:var(--verify); color:var(--verify)}
.chip.agree.bad{border-color:var(--danger); color:var(--danger)}
/* ---------- HOME ---------- */
.hero{border:1px solid var(--line); background:var(--bg2); border-radius:14px; padding:30px; margin-bottom:22px}
.hero .kicker{font-family:var(--mono); font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--verify)}
.hero h1{font-family:var(--disp); font-weight:700; font-size:34px; letter-spacing:.02em; margin:8px 0 10px; line-height:1.1}
.hero p{color:var(--mut); max-width:64ch; margin:0}
.modes{display:grid; grid-template-columns:repeat(3,1fr); gap:14px; margin-top:22px}
@media(max-width:760px){ .modes{grid-template-columns:1fr} }
.mode{text-align:left; border:1px solid var(--line); background:var(--bg); border-radius:12px; padding:18px; cursor:pointer; transition:.15s; color:var(--ink); font-family:var(--sans)}
.mode:hover{border-color:var(--verify); transform:translateY(-2px)}
.mode .mt{font-family:var(--disp); font-weight:600; font-size:17px; letter-spacing:.03em; margin-bottom:5px}
.mode .md{font-size:12.5px; color:var(--mut); line-height:1.45}
.mode .tagm{display:inline-block; margin-top:11px; font-family:var(--mono); font-size:10px; letter-spacing:.1em; text-transform:uppercase; color:var(--verify2); border:1px solid var(--line); border-radius:5px; padding:2px 7px}
/* ---------- CONNECT ---------- */
.connect{border:1px solid var(--line); background:var(--bg2); border-radius:14px; padding:26px; max-width:640px; margin:0 auto}
.connect h2{font-family:var(--disp); letter-spacing:.03em; margin:0 0 6px; font-size:22px}
.connect p{color:var(--mut); font-size:13.5px; margin:0 0 16px}
.field{margin-bottom:14px}
.field label{display:block; font-family:var(--mono); font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:var(--mut); margin-bottom:6px}
input.text, textarea.code{width:100%; font-family:var(--mono); font-size:12px; color:var(--ink); background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:10px}
textarea.code{min-height:92px; resize:vertical; word-break:break-all}
.row{display:flex; gap:10px; flex-wrap:wrap}
.row > *{flex:1}
button.btn{font-family:var(--disp); font-weight:600; letter-spacing:.06em; font-size:14px; color:#0d0f16; background:var(--verify); border:none; border-radius:8px; padding:11px 16px; cursor:pointer; text-transform:uppercase; transition:.15s}
button.btn:hover{filter:brightness(1.08)}
button.btn.alt{background:var(--seal)}
button.btn.ghost{background:transparent; border:1px solid var(--line); color:var(--mut)}
button.btn.ghost:hover{border-color:var(--dim); color:var(--ink); filter:none}
button.btn:disabled{opacity:.4; cursor:not-allowed; filter:none}
.waiting{font-family:var(--mono); font-size:12px; color:var(--relay); margin-top:8px; letter-spacing:.04em}
.note{font-family:var(--mono); font-size:11px; color:var(--dim); margin-top:10px; line-height:1.5}
.steps{counter-reset:s; margin:6px 0 0; padding:0; list-style:none}
.steps li{counter-increment:s; font-size:12.5px; color:var(--mut); padding:4px 0 4px 26px; position:relative}
.steps li::before{content:counter(s); position:absolute; left:0; top:3px; width:18px; height:18px; border-radius:50%; background:var(--bg3); border:1px solid var(--line); color:var(--verify); font-family:var(--mono); font-size:10px; display:flex; align-items:center; justify-content:center}
/* ---------- scoreboard ---------- */
.scorebar{display:grid; grid-template-columns:1fr auto 1fr; gap:16px; align-items:center; margin-bottom:20px}
.side{border:1px solid var(--line); background:var(--bg2); border-radius:var(--radius); padding:12px 14px}
.side.me{box-shadow:inset 0 0 0 1px #38d0c433}
.side .who{display:flex; justify-content:space-between; align-items:center; font-family:var(--mono); font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--mut)}
.dot{width:9px;height:9px;border-radius:50%; display:inline-block; margin-right:7px; vertical-align:middle}
.dot.p{background:var(--player)} .dot.a{background:var(--enemy)}
.side .val{font-family:var(--disp); font-weight:700; font-size:26px; margin-top:2px}
.side.pp .val{color:var(--player2)} .side.aa .val{color:var(--enemy2)}
.track{height:6px; background:var(--bg3); border-radius:3px; margin-top:9px; overflow:hidden}
.track i{display:block; height:100%; width:0; transition:width .4s ease}
.side.pp .track i{background:var(--player)} .side.aa .track i{background:var(--enemy)}
.mid{text-align:center; font-family:var(--mono); font-size:11px; color:var(--dim); letter-spacing:.1em}
.mid b{display:block; color:var(--mut); font-size:22px; font-family:var(--disp); font-weight:600}
.mo{margin-top:6px; font-family:var(--mono); font-size:10px; letter-spacing:.08em; min-height:13px}
.mo.on{color:var(--relay)}
.cols{display:grid; grid-template-columns:minmax(0,1fr) 340px; gap:20px; align-items:start}
@media(max-width:860px){ .cols{grid-template-columns:1fr} }
.panel{border:1px solid var(--line); background:var(--bg2); border-radius:var(--radius); overflow:hidden}
.panel > h2{margin:0; padding:11px 15px; font-family:var(--mono); font-size:11px; letter-spacing:.15em; text-transform:uppercase; color:var(--mut); border-bottom:1px solid var(--line); display:flex; justify-content:space-between; align-items:center; background:var(--bg3)}
.panel > h2 .hint{color:var(--dim); text-transform:none; letter-spacing:.02em; font-size:10px}
/* board */
.boardwrap{padding:16px; position:relative}
.board-meta{display:flex; justify-content:space-between; align-items:center; font-family:var(--mono); font-size:10px; color:var(--dim); letter-spacing:.1em; margin-bottom:10px; text-transform:uppercase}
.board{--n:7; display:grid; grid-template-columns:repeat(var(--n),1fr); gap:4px; aspect-ratio:1/1; position:relative; padding:8px; border-radius:8px;
background:linear-gradient(var(--bg) 0 0) padding-box, repeating-linear-gradient(90deg, transparent 0 3px, #ffffff05 3px 4px); border:1px dashed var(--line)}
.cell{position:relative; border-radius:6px; background:#171a26; border:1px solid #20243440; display:flex; align-items:center; justify-content:center}
.cell.relay{box-shadow:inset 0 0 0 2px #e0a34055; background:radial-gradient(circle at 50% 45%, #2a2618 0%, #171a26 70%)}
.relay-core{position:absolute; width:44%; height:44%; border-radius:50%; border:2px solid var(--relay); opacity:.75}
.cell.own-P .relay-core{border-color:var(--player); box-shadow:0 0 14px #5b8def66; opacity:1}
.cell.own-A .relay-core{border-color:var(--enemy); box-shadow:0 0 14px #e0736b66; opacity:1}
.cell.contested .relay-core{border-style:dashed; animation:spin 3s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.unit{position:absolute; width:66%; height:66%; border-radius:8px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-weight:600; font-size:clamp(9px,1.7vw,13px); color:#0d0f16; z-index:2; transition:all .35s cubic-bezier(.4,0,.2,1)}
.unit.p{background:linear-gradient(160deg,var(--player2),var(--player)); box-shadow:0 2px 10px #5b8def55}
.unit.a{background:linear-gradient(160deg,var(--enemy2),var(--enemy)); box-shadow:0 2px 10px #e0736b55}
.unit.mine{outline:2px solid var(--verify); outline-offset:1px}
.clash{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; z-index:3; pointer-events:none; font-family:var(--mono); font-size:12px; font-weight:600; color:var(--relay); text-shadow:0 0 8px #000}
.clash::after{content:"✳"; animation:pop .5s ease}
@keyframes pop{0%{transform:scale(.2);opacity:0}60%{transform:scale(1.3)}100%{transform:scale(1);opacity:1}}
/* orders */
.orders{padding:14px}
.urow{border:1px solid var(--line); border-radius:8px; padding:10px; margin-bottom:9px; background:var(--bg)}
.uhead{display:flex; align-items:center; justify-content:space-between; margin-bottom:8px}
.utag{font-family:var(--mono); font-size:11px; letter-spacing:.1em; display:flex; align-items:center; gap:7px}
.utag.p{color:var(--player2)} .utag.a{color:var(--enemy2)}
.utag .cc{width:16px;height:16px;border-radius:5px; color:#0d0f16; font-size:9px; display:flex; align-items:center; justify-content:center; font-weight:700}
.utag.p .cc{background:linear-gradient(160deg,var(--player2),var(--player))}
.utag.a .cc{background:linear-gradient(160deg,var(--enemy2),var(--enemy))}
.uloc{font-family:var(--mono); font-size:10px; color:var(--dim)}
.seg{display:flex; gap:4px; margin-top:6px}
.seg .lab{font-family:var(--mono); font-size:9px; color:var(--dim); width:44px; align-self:center; letter-spacing:.08em; text-transform:uppercase}
.seg .opts{display:flex; gap:4px; flex-wrap:wrap; flex:1}
button.opt{font-family:var(--mono); font-size:11px; color:var(--mut); background:var(--bg3); border:1px solid var(--line); border-radius:6px; padding:5px 0; min-width:34px; flex:1; cursor:pointer; transition:.12s}
button.opt:hover{border-color:var(--dim); color:var(--ink)}
button.opt.sel{background:var(--player); border-color:var(--player); color:#0d0f16; font-weight:600}
button.opt.sel.a{background:var(--enemy); border-color:var(--enemy)}
button.opt.stance.sel{background:var(--relay); border-color:var(--relay)}
.stancekey{font-family:var(--mono); font-size:9.5px; color:var(--dim); padding:2px 4px 8px; line-height:1.6}
.stancekey b{color:var(--relay)}
/* sealed */
.sealed{padding:14px}
.sealrow{display:grid; grid-template-columns:1fr 1fr; gap:9px; margin-bottom:12px}
.sealcol h3{margin:0 0 7px; font-family:var(--mono); font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:var(--mut)}
.sealcol.you h3{color:var(--player2)} .sealcol.foe h3{color:var(--enemy2)}
.seal{position:relative; border-radius:7px; padding:9px 10px; margin-bottom:6px; border:1px solid var(--seal); background:linear-gradient(160deg,#241f12,#1c1a16); font-family:var(--mono); font-size:10px; overflow:hidden; min-height:46px}
.seal .lockline{display:flex; align-items:center; gap:6px; color:var(--seal); font-size:10px; letter-spacing:.1em}
.seal .hash{color:var(--dim); word-break:break-all; margin-top:4px; font-size:9.5px}
.seal.revealed{border-color:var(--verify); background:linear-gradient(160deg,#12241f,#161c1b); animation:crack .5s ease}
@keyframes crack{0%{transform:scale(.97)}50%{transform:scale(1.02)}100%{transform:scale(1)}}
.seal .order{color:var(--ink); font-size:11px; letter-spacing:.04em}
.seal .stamp{position:absolute; top:7px; right:8px; font-size:9px; color:var(--verify); border:1px solid var(--verify); border-radius:4px; padding:1px 5px; letter-spacing:.1em}
.seal .order small{color:var(--relay)}
.seal.pending{border-color:var(--line); background:var(--bg); color:var(--dim)}
.actions{padding:0 14px 16px}
button.primary{width:100%; font-family:var(--disp); font-weight:600; letter-spacing:.08em; font-size:14px; color:#0d0f16; background:var(--verify); border:none; border-radius:8px; padding:12px; cursor:pointer; transition:.15s; text-transform:uppercase}
button.primary:hover{filter:brightness(1.08)}
button.primary.seal{background:var(--seal)}
button.primary:disabled{opacity:.4; cursor:not-allowed; filter:none}
button.ghostbtn{width:100%; font-family:var(--mono); font-size:11px; letter-spacing:.08em; margin-top:8px; color:var(--mut); background:transparent; border:1px solid var(--line); border-radius:8px; padding:9px; cursor:pointer; transition:.15s; text-transform:uppercase}
button.ghostbtn:hover{border-color:var(--dim); color:var(--ink)}
.statusline{font-family:var(--mono); font-size:11px; color:var(--relay); text-align:center; padding:6px 0 2px; min-height:16px; letter-spacing:.04em}
.log{padding:12px 14px; max-height:150px; overflow:auto; font-family:var(--mono); font-size:11px}
.log .ev{padding:3px 0; color:var(--mut); border-bottom:1px solid #ffffff08}
.log .ev b{color:var(--ink)}
.log .ev.win{color:var(--verify2)}
.log .ev .p{color:var(--player2)} .log .ev .a{color:var(--enemy2)} .log .ev .r{color:var(--relay)}
.ledger-wrap{margin-top:20px}
.headhash{padding:12px 15px; font-family:var(--mono); font-size:11px; color:var(--mut); border-bottom:1px solid var(--line); display:flex; gap:10px; align-items:center; flex-wrap:wrap; background:var(--bg3)}
.headhash b{color:var(--verify)}
.headhash .hx{color:var(--verify2); word-break:break-all}
.ledger{overflow:auto}
table{width:100%; border-collapse:collapse; font-family:var(--mono); font-size:11px}
th,td{padding:8px 12px; text-align:left; border-bottom:1px solid var(--line); white-space:nowrap}
th{color:var(--dim); font-weight:500; letter-spacing:.08em; text-transform:uppercase; font-size:9.5px; position:sticky; top:0; background:var(--bg2)}
td{color:var(--mut)} td.tk{color:var(--ink)} td .ok{color:var(--verify)}
.verifybar{padding:12px 15px; border-top:1px solid var(--line); display:flex; gap:10px; align-items:center; flex-wrap:wrap}
.verifybar button{font-family:var(--mono); font-size:11px; letter-spacing:.06em; color:var(--ink); background:var(--bg3); border:1px solid var(--line); border-radius:7px; padding:8px 14px; cursor:pointer; text-transform:uppercase}
.verifybar button:hover{border-color:var(--verify); color:var(--verify)}
#exportBtn{border-color:var(--seal)}
#exportBtn:hover{border-color:var(--seal); color:var(--seal)}
.verifybar .res{font-family:var(--mono); font-size:11px; color:var(--dim)}
.verifybar .res.ok{color:var(--verify)} .verifybar .res.bad{color:var(--danger)}
.overlay{position:fixed; inset:0; background:#0b0d13d9; display:none; align-items:center; justify-content:center; z-index:50; padding:20px}
.overlay.show{display:flex}
.modal{max-width:440px; text-align:center; border:1px solid var(--line); background:var(--bg2); border-radius:14px; padding:28px}
.modal h2{font-family:var(--disp); letter-spacing:.06em; margin:0 0 6px}
.modal p{color:var(--mut); font-size:14px; margin:0 0 18px}
.modal .big{font-family:var(--disp); font-weight:700; font-size:34px; letter-spacing:.06em}
.modal button{font-family:var(--disp); font-weight:600; letter-spacing:.08em; color:#0d0f16; background:var(--verify); border:none; border-radius:8px; padding:12px 22px; cursor:pointer; text-transform:uppercase}
details.how{margin-top:22px; border:1px solid var(--line); border-radius:var(--radius); background:var(--bg2); overflow:hidden}
details.how summary{cursor:pointer; padding:13px 16px; font-family:var(--mono); font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:var(--mut); list-style:none}
details.how summary::-webkit-details-marker{display:none}
details.how summary::before{content:"▸ "; color:var(--verify)}
details.how[open] summary::before{content:"▾ "}
.howbody{padding:4px 18px 18px; font-size:13.5px; color:var(--mut); max-width:72ch}
.howbody h4{font-family:var(--disp); color:var(--ink); letter-spacing:.04em; margin:16px 0 5px; font-size:14px}
.howbody code{font-family:var(--mono); font-size:12px; color:var(--verify2); background:#0d0f16; padding:1px 5px; border-radius:4px}
.howbody b{color:var(--ink)}
.howbody .cav{color:var(--dim); font-size:12.5px; border-left:2px solid var(--line); padding-left:12px; margin-top:10px}
:focus-visible{outline:2px solid var(--verify); outline-offset:2px}
@media(prefers-reduced-motion:reduce){*{animation:none!important; transition:none!important}}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div class="brand">
<div class="logo">KER<b>N</b>EL</div>
<div class="tagline">provably-fair tactical duel</div>
</div>
<div class="headright" id="headright"></div>
</header>
<!-- ============ HOME ============ -->
<section id="homeScreen">
<div class="hero">
<div class="kicker">no aim · no hidden state · no riggable RNG · no dominant strategy</div>
<h1>A game where cheating can't exist by construction.</h1>
<p>Every tick, both sides seal their orders as a cryptographic commitment before either reveals. Ties are broken by a hash both players can recompute. The whole match is an integer-only, deterministic function of committed inputs, chained into a tamper-evident ledger. Pick an opponent — the security holds the same way against all three.</p>
<div class="modes">
<button class="mode" data-mode="ai">
<div class="mt">Play the KERNEL AI</div>
<div class="md">A local opponent that commits from board state only and plays the unexploitable uniform-random stance mix. Instant, offline, single device.</div>
<span class="tagm">recommended first</span>
</button>
<button class="mode" data-mode="local">
<div class="mt">2 players · this device</div>
<div class="md">Two browser tabs pair over a channel and run the real adversarial protocol against each other — each tab a separate, untrusting client.</div>
<span class="tagm">true two-party</span>
</button>
<button class="mode" data-mode="rtc">
<div class="mt">2 players · remote</div>
<div class="md">A direct peer-to-peer link between two machines. One paste each way to connect — then no server ever sees your game.</div>
<span class="tagm">serverless p2p</span>
</button>
</div>
</div>
<details class="how" id="homeHow"></details>
<div style="text-align:center;margin-top:16px">
<button class="btn ghost" id="verifyProofLink" style="max-width:340px;margin:0 auto">Verify a saved match proof →</button>
</div>
</section>
<!-- ============ VERIFY PROOF ============ -->
<section id="verifyScreen" class="hidden">
<div class="connect" style="max-width:760px">
<h2>Verify a match proof</h2>
<p>Load a proof exported from any KERNEL match — even one you never played. This re-runs the entire deterministic game from the opening position, recomputes every commitment, fairness seed, per-tick state hash and the full hash chain, and reports whether the record is authentic and untampered. No trust in the players required.</p>
<div class="field"><label>load proof file</label><input type="file" id="proofFile" accept="application/json,.json"></div>
<div class="field"><label>…or paste proof JSON</label><textarea class="code" id="proofPaste" placeholder="paste exported proof JSON"></textarea></div>
<div class="row" style="max-width:420px">
<button class="btn" id="runVerify">Verify proof</button>
<button class="btn ghost" id="verifyBack">Back</button>
</div>
<div id="verifyReport" style="margin-top:18px"></div>
</div>
</section>
<!-- ============ CONNECT ============ -->
<section id="connectScreen" class="hidden">
<div class="connect" id="connectBox"></div>
</section>
<!-- ============ GAME ============ -->
<section id="gameScreen" class="hidden">
<div class="scorebar">
<div class="side pp" id="cardP">
<div class="who"><span><span class="dot p"></span><span id="nameP">BLUE</span></span><span id="pRelays">0 relays</span></div>
<div class="val" id="pScore">0</div>
<div class="track"><i id="pTrack"></i></div>
<div class="mo" id="pMo"></div>
</div>
<div class="mid">HOLD TO<b id="targetNo">10</b>CONTROL</div>
<div class="side aa" id="cardA">
<div class="who"><span><span class="dot a"></span><span id="nameA">CORAL</span></span><span id="aRelays">0 relays</span></div>
<div class="val" id="aScore">0</div>
<div class="track"><i id="aTrack"></i></div>
<div class="mo" id="aMo"></div>
</div>
</div>
<div class="cols">
<div class="panel">
<h2>TOROIDAL FIELD <span class="hint">edges wrap — no corners, no edge advantage</span></h2>
<div class="boardwrap">
<div class="board-meta"><span>7 × 7 · vertex-transitive</span><span id="phaseTag">PLAN YOUR TICK</span></div>
<div class="board" id="board"></div>
</div>
<h2 style="border-top:1px solid var(--line)">RESOLUTION LOG</h2>
<div class="log" id="log"></div>
</div>
<div class="panel" id="controlPanel">
<h2 id="controlTitle">ORDERS <span class="hint">move + stance, per unit</span></h2>
<div id="ordersView" class="orders"></div>
<div id="sealedView" class="sealed" style="display:none"></div>
<div class="statusline" id="statusLine"></div>
<div class="actions">
<button class="primary seal" id="commitBtn">Seal & commit</button>
<button class="primary" id="revealBtn" style="display:none">Reveal & resolve</button>
<button class="ghostbtn" id="quitBtn">Leave match</button>
</div>
</div>
</div>
<div class="panel ledger-wrap">
<h2>SECURITY LEDGER <span class="hint">tamper-evident hash chain of every committed tick</span></h2>
<div class="headhash"><b>CHAIN HEAD</b> <span class="hx" id="headHash">— genesis —</span></div>
<div class="ledger">
<table>
<thead><tr><th>Tick</th><th>Blue commit</th><th>Coral commit</th><th>Verified</th><th>Fairness seed</th><th>State hash</th></tr></thead>
<tbody id="ledgerBody"><tr><td colspan="6" style="color:var(--dim)">No ticks resolved yet.</td></tr></tbody>
</table>
</div>
<div class="verifybar">
<button id="verifyBtn">Re-simulate & verify match</button>
<button id="exportBtn">Export match proof</button>
<span class="res" id="verifyRes">Replays every committed tick from scratch and checks the chain.</span>
</div>
</div>
<details class="how" id="gameHow"></details>
</section>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<div class="big" id="modalBig">◆</div>
<h2 id="modalTitle">Match over</h2>
<p id="modalMsg"></p>
<button id="modalBtn">Back to menu</button>
</div>
</div>
<script>
"use strict";
// ==ENGINE_START==
const G = 7, UNITS = 3, TARGET = 10, MAXTICK = 40;
const STANCES = ["PIERCE","SHIELD","SWEEP"];
const MOVES = { H:[0,0], N:[0,-1], S:[0,1], E:[1,0], W:[-1,0] };
const MOVE_KEYS = ["H","N","S","E","W"];
function beats(a,b){ return (a+1)%3 === b; }
function wrap(v){ return ((v % G) + G) % G; }
function torDist(ax,ay,bx,by){ let dx=Math.abs(ax-bx); dx=Math.min(dx,G-dx); let dy=Math.abs(ay-by); dy=Math.min(dy,G-dy); return dx+dy; }
function canon(orders){ return orders.map(o=>o.m+o.s).join(","); }
function tierFor(gap){ if(gap<=2) return 0; if(gap<=4) return 1; return 2; }
function initState(){
return {
tick: 1,
P: [ {x:0,y:2}, {x:0,y:3}, {x:0,y:4} ],
A: [ {x:6,y:2}, {x:6,y:3}, {x:6,y:4} ],
relays: [ {x:3,y:1}, {x:3,y:3}, {x:3,y:5} ],
control: [null,null,null],
scoreP: 0, scoreA: 0,
lastClashes: [],
};
}
// AI: uses PUBLIC board state only. Assigns each unit to a DISTINCT relay so it
// spreads to cover all three (brute-force the 3!=6 assignments, minimise total
// toroidal distance, lightly preferring relays it already holds), then steps
// toward its target. Stance stays uniform-random — that mixture is the
// unexploitable equilibrium and deliberately must NOT be "optimised".
function chooseAI(state){
const R = state.relays;
const perms = [[0,1,2],[0,2,1],[1,0,2],[1,2,0],[2,0,1],[2,1,0]];
let bestPerm=perms[0], bestCost=Infinity;
for(const p of perms){
let cost=0;
for(let i=0;i<UNITS;i++){
const r=R[p[i]];
cost += torDist(state.A[i].x,state.A[i].y,r.x,r.y);
if(state.control[p[i]]==="A") cost -= 2; // hold/reinforce what we already own
}
if(cost<bestCost){ bestCost=cost; bestPerm=p; }
}
const orders=[];
for(let i=0;i<UNITS;i++){
const u=state.A[i], tgt=R[bestPerm[i]];
let bestMove="H", bestD=torDist(u.x,u.y,tgt.x,tgt.y);
if(!(u.x===tgt.x && u.y===tgt.y)){
for(const k of ["N","S","E","W"]){
const nx=wrap(u.x+MOVES[k][0]), ny=wrap(u.y+MOVES[k][1]);
const d=torDist(nx,ny,tgt.x,tgt.y);
if(d<bestD){ bestD=d; bestMove=k; }
}
}
orders.push({ m:bestMove, s:Math.floor(Math.random()*3) });
}
return orders;
}
function resolveSide(units, orders){
const claimed = new Map();
const finalPos = units.map(u=>({x:u.x,y:u.y}));
const key=(p)=>p.x+","+p.y;
orders.forEach((o,i)=>{ if(o.m==="H") claimed.set(key(units[i]), i); });
orders.forEach((o,i)=>{
if(o.m==="H") return;
const nx=wrap(units[i].x+MOVES[o.m][0]), ny=wrap(units[i].y+MOVES[o.m][1]);
const tk=nx+","+ny;
if(!claimed.has(tk)){ claimed.set(tk,i); finalPos[i]={x:nx,y:ny}; }
else { const ok=key(units[i]); claimed.set(ok,i); finalPos[i]={x:units[i].x,y:units[i].y}; }
});
return finalPos;
}
function resolveTick(state, pOrders, aOrders, seed){
const ev = [];
const clashes = [];
const gap = Math.abs(state.scoreP - state.scoreA);
const tier = tierFor(gap);
const trailing = state.scoreP < state.scoreA ? "P" : (state.scoreA < state.scoreP ? "A" : null);
let seedIdx = 0;
const pProv = resolveSide(state.P, pOrders);
const aProv = resolveSide(state.A, aOrders);
const pOrigin = state.P.map(u=>({x:u.x,y:u.y}));
const aOrigin = state.A.map(u=>({x:u.x,y:u.y}));
function decideTie(){
if(tier>=1 && trailing){ return { win:trailing, reason:"momentum" }; }
const byte = parseInt(seed.substr((seedIdx*2)%64,2),16); seedIdx++;
return { win:(byte&1)?"P":"A", reason:"rng", byte };
}
for(let i=0;i<UNITS;i++){
for(let j=0;j<UNITS;j++){
const swap = pProv[i].x===aOrigin[j].x && pProv[i].y===aOrigin[j].y &&
aProv[j].x===pOrigin[i].x && aProv[j].y===pOrigin[i].y &&
!(pOrigin[i].x===aOrigin[j].x && pOrigin[i].y===aOrigin[j].y);
if(swap){
const pS=pOrders[i].s, aS=aOrders[j].s;
let out;
if(beats(pS,aS)) out={win:"P",reason:"stance"};
else if(beats(aS,pS)) out={win:"A",reason:"stance"};
else out=decideTie();
pProv[i]={x:pOrigin[i].x,y:pOrigin[i].y};
aProv[j]={x:aOrigin[j].x,y:aOrigin[j].y};
clashes.push({x:pProv[i].x,y:pProv[i].y});
ev.push({t:"clash", i, j, pS, aS, out, kind:"head-on"});
}
}
}
function cellFree(x,y,exI,exJ){
for(let k=0;k<UNITS;k++){
if(k!==exI && pProv[k].x===x && pProv[k].y===y) return false;
if(k!==exJ && aProv[k].x===x && aProv[k].y===y) return false;
}
return true;
}
for(let i=0;i<UNITS;i++){
for(let j=0;j<UNITS;j++){
if(pProv[i].x===aProv[j].x && pProv[i].y===aProv[j].y){
const pS=pOrders[i].s, aS=aOrders[j].s;
let out;
if(beats(pS,aS)) out={win:"P",reason:"stance"};
else if(beats(aS,pS)) out={win:"A",reason:"stance"};
else out=decideTie();
const cx=pProv[i].x, cy=pProv[i].y;
const pHolds = pOrders[i].m==="H", aHolds = aOrders[j].m==="H";
if(out.win==="P"){
if(aHolds){
const d=MOVES[pOrders[i].m];
const tx=wrap(aOrigin[j].x+d[0]), ty=wrap(aOrigin[j].y+d[1]);
if(cellFree(tx,ty,i,j)) aProv[j]={x:tx,y:ty};
else { pProv[i]={x:pOrigin[i].x,y:pOrigin[i].y}; aProv[j]={x:aOrigin[j].x,y:aOrigin[j].y}; out.reason="repelled"; }
} else { aProv[j]={x:aOrigin[j].x,y:aOrigin[j].y}; }
} else {
if(pHolds){
const d=MOVES[aOrders[j].m];
const tx=wrap(pOrigin[i].x+d[0]), ty=wrap(pOrigin[i].y+d[1]);
if(cellFree(tx,ty,i,j)) pProv[i]={x:tx,y:ty};
else { aProv[j]={x:aOrigin[j].x,y:aOrigin[j].y}; pProv[i]={x:pOrigin[i].x,y:pOrigin[i].y}; out.reason="repelled"; }
} else { pProv[i]={x:pOrigin[i].x,y:pOrigin[i].y}; }
}
clashes.push({x:cx,y:cy});
ev.push({t:"combat", i, j, pS, aS, out, x:cx, y:cy});
}
}
}
function occupied(x,y,skipSide,skipIdx){
for(let k=0;k<UNITS;k++){
if(!(skipSide==="P"&&k===skipIdx) && pProv[k].x===x && pProv[k].y===y) return true;
if(!(skipSide==="A"&&k===skipIdx) && aProv[k].x===x && aProv[k].y===y) return true;
}
return false;
}
function relocate(side,idx){
const u = side==="P"?pProv[idx]:aProv[idx];
const o = side==="P"?pOrigin[idx]:aOrigin[idx];
const cand=[[o.x,o.y],[wrap(u.x),wrap(u.y-1)],[wrap(u.x),wrap(u.y+1)],[wrap(u.x+1),wrap(u.y)],[wrap(u.x-1),wrap(u.y)]];
for(const [cx,cy] of cand){ if(!occupied(cx,cy,side,idx)){ (side==="P"?pProv:aProv)[idx]={x:cx,y:cy}; return; } }
for(let y=0;y<G;y++) for(let x=0;x<G;x++){ if(!occupied(x,y,side,idx)){ (side==="P"?pProv:aProv)[idx]={x,y}; return; } }
}
function anyCollision(){
const all=[];
for(let k=0;k<UNITS;k++){ all.push(["P",k,pProv[k]]); all.push(["A",k,aProv[k]]); }
for(let a=0;a<all.length;a++) for(let b=a+1;b<all.length;b++){
if(all[a][2].x===all[b][2].x && all[a][2].y===all[b][2].y) return [all[a],all[b]];
}
return null;
}
let guard=0;
while(guard++<40){
const c=anyCollision(); if(!c) break;
const byte=parseInt(seed.substr((seedIdx*2)%64,2),16); seedIdx++;
const pick=(byte&1)?c[0]:c[1];
relocate(pick[0],pick[1]);
}
state.P = pProv.map(p=>({x:p.x,y:p.y}));
state.A = aProv.map(p=>({x:p.x,y:p.y}));
const control = state.relays.map((r,ri)=>{
const pOn = state.P.some(u=>u.x===r.x&&u.y===r.y);
const aOn = state.A.some(u=>u.x===r.x&&u.y===r.y);
if(pOn && !aOn) return "P";
if(aOn && !pOn) return "A";
if(pOn && aOn){ const d=decideTie(); ev.push({t:"relaytie", ri, out:d}); return d.win; }
return null;
});
state.control = control;
const pGain = control.filter(c=>c==="P").length;
const aGain = control.filter(c=>c==="A").length;
state.scoreP += pGain; state.scoreA += aGain;
state.lastClashes = clashes;
ev.push({t:"score", pGain, aGain, tier, trailing});
return { ev, tier, trailing };
}
function serializeState(s){ return JSON.stringify({t:s.tick,P:s.P,A:s.A,c:s.control,sp:s.scoreP,sa:s.scoreA}); }
function winnerOf(s){
if(s.scoreP>=TARGET && s.scoreP>s.scoreA) return "P";
if(s.scoreA>=TARGET && s.scoreA>s.scoreP) return "A";
if(s.tick>MAXTICK) return s.scoreP>=s.scoreA ? "P":"A";
return null;
}
// Structural validity of an orders array (defends the engine against a
// malicious peer or a corrupt proof feeding it garbage).
function validateOrders(o){
return Array.isArray(o) && o.length===UNITS &&
o.every(x=> x && MOVE_KEYS.includes(x.m) && Number.isInteger(x.s) && x.s>=0 && x.s<=2);
}
// Independent, self-contained verifier. Given a match proof and a SHA-256
// function, it re-executes the ENTIRE deterministic game from the initial
// state, recomputing every commitment, fairness seed, per-tick state hash and
// the whole hash chain, and confirms they match the record. A third party who
// never played can run this to prove the match was fair and untampered.
async function verifyProof(proof, sha256){
const report = { ok:false, reason:"", ticks:[], scoreP:0, scoreA:0, winner:null, chainHead:null };
try{
if(!proof || !Array.isArray(proof.ticks)){ report.reason="not a KERNEL match proof"; return report; }
let st=initState(), head=(proof.genesis||"genesis"), allOK=true, expect=1;
for(const e of proof.ticks){
const row={ tick:e.tick, ok:true, problems:[] };
if(e.tick!==expect){ row.ok=false; row.problems.push("tick out of sequence"); }
if(!validateOrders(e.pOrders)||!validateOrders(e.aOrders)){
row.ok=false; row.problems.push("malformed orders");
} else {
const pc=await sha256(canon(e.pOrders)+"|"+e.pNonce);
const ac=await sha256(canon(e.aOrders)+"|"+e.aNonce);
if(pc!==e.pCommit){ row.ok=false; row.problems.push("blue commitment forged"); }
if(ac!==e.aCommit){ row.ok=false; row.problems.push("coral commitment forged"); }
const seed=await sha256(e.pNonce+e.aNonce+":"+st.tick);
if(seed!==e.seed){ row.ok=false; row.problems.push("fairness seed altered"); }
resolveTick(st, e.pOrders, e.aOrders, seed);
const sh=await sha256(serializeState(st));
if(sh!==e.stateHash){ row.ok=false; row.problems.push("state hash mismatch"); }
head=await sha256(head+"|"+e.pCommit+"|"+e.aCommit+"|"+seed+"|"+sh);
if(head!==e.headHash){ row.ok=false; row.problems.push("chain broken"); }
st.tick++;
}
if(!row.ok) allOK=false;
report.ticks.push(row);
expect++;
}
if(head!==proof.headHash){ allOK=false; }
report.scoreP=st.scoreP; report.scoreA=st.scoreA;
report.winner = st.scoreP>st.scoreA?"P":(st.scoreA>st.scoreP?"A":"draw");
report.chainHead=head;
report.ok=allOK;
report.reason = allOK ? "authentic — every tick reproduced exactly" : "invalid — the record does not match a fair replay";
return report;
}catch(err){ report.reason="verifier error: "+err; return report; }
}
// ==ENGINE_END==
// ==CONTROLLER_START==
// Transport-agnostic per-match protocol controller. DOM-free: everything
// external is a callback so the same code runs in the browser and in tests.
function makeController(opts){
// opts: { localSide:'P'|'A', isAI, sha256(str)->hex, nonce()->hex, send(msg),
// revealTimeoutMs, hooks:{onPhase,onResolved,onEnd,onChain} }
const c = {
S: initState(),
ledger: [],
headHash: "genesis",
localSide: opts.localSide,
isAI: !!opts.isAI,
phase: "plan", // plan | sealed | over
over: null,
// per-tick protocol slots
t: null,
revealTimer: null,
};
const otherSide = c.localSide==="P" ? "A" : "P";
function freshTick(){ c.t = { localOrders:null, localNonce:null, localCommit:null, localRevealSent:false,
peerCommit:null, peerOrders:null, peerNonce:null, peerRevealGot:false, resolving:false }; }
freshTick();
function clearRevealTimer(){ if(c.revealTimer){ clearTimeout(c.revealTimer); c.revealTimer=null; } }
// Local player seals & commits their orders for this tick.
async function commitLocal(localOrders){
if(c.phase!=="plan" || c.over) return;
const nonce = opts.nonce();
const commit = await opts.sha256(canon(localOrders)+"|"+nonce);
c.t.localOrders = localOrders; c.t.localNonce = nonce; c.t.localCommit = commit;
c.phase = "sealed";
opts.hooks.onPhase && opts.hooks.onPhase();
opts.send({ t:"commit", tick:c.S.tick, commit });
await maybeReveal();
}
async function maybeReveal(){
if(c.t.localCommit && c.t.peerCommit && !c.t.localRevealSent){
c.t.localRevealSent = true;
opts.send({ t:"reveal", tick:c.S.tick, orders:c.t.localOrders, nonce:c.t.localNonce });
// start the reveal-timeout only once BOTH sides are committed — the
// exact point at which refusing to reveal would be an attack.
if(!c.isAI && isFinite(opts.revealTimeoutMs)){
clearRevealTimer();
c.revealTimer = setTimeout(()=>{
if(!c.t.peerRevealGot && !c.over){
endMatch(c.localSide, "reveal timeout — opponent forfeited");
}
}, opts.revealTimeoutMs);
}
await tryResolve();
}
}
async function receive(msg){
if(c.over || !msg || typeof msg!=="object") return;
try{
if(msg.t==="commit"){
if(msg.tick!==c.S.tick) return; // wrong tick → ignore
if(c.t.peerCommit!==null) return; // duplicate commit → ignore (can't be changed after the fact)
if(typeof msg.commit!=="string" || msg.commit.length!==64) return; // malformed → ignore
c.t.peerCommit = msg.commit;
opts.hooks.onPhase && opts.hooks.onPhase(); // foe seal now renders as sealed
await maybeReveal();
} else if(msg.t==="reveal"){
if(msg.tick!==c.S.tick) return;
if(c.t.peerRevealGot) return; // duplicate reveal → ignore
if(c.t.peerCommit===null) return; // reveal before commit → ignore (ordered channels never do this)
if(!validateOrders(msg.orders) || typeof msg.nonce!=="string"){
endMatch(c.localSide, "opponent submitted malformed orders"); return;
}
// verify the reveal against the earlier commitment — cheat detection.
const check = await opts.sha256(canon(msg.orders)+"|"+msg.nonce);
if(check !== c.t.peerCommit){
endMatch(c.localSide, "opponent's reveal did not match their commitment (cheat detected)");
return;
}
c.t.peerOrders = msg.orders; c.t.peerNonce = msg.nonce; c.t.peerRevealGot = true;
clearRevealTimer();
await tryResolve();
} else if(msg.t==="chain"){
// cross-client agreement check on a resolved tick
const rec = c.ledger.find(r=>r.tick===msg.tick);
if(rec) opts.hooks.onChain && opts.hooks.onChain(msg.tick, rec.headHash===msg.head);
}
// unknown message types are ignored
}catch(_){ /* a malformed peer must never crash the match */ }
}
async function tryResolve(){
if(c.t.localRevealSent && c.t.peerRevealGot && !c.t.resolving){
c.t.resolving = true;
opts.hooks.onRevealReady && opts.hooks.onRevealReady();
if(opts.resolveDelayMs>0){ setTimeout(()=>{ doResolve(); }, opts.resolveDelayMs); }
else { await doResolve(); } // instant path keeps tests deterministic
}
}
async function doResolve(){
const localIsP = c.localSide==="P";
const pOrders = localIsP ? c.t.localOrders : c.t.peerOrders;
const aOrders = localIsP ? c.t.peerOrders : c.t.localOrders;
const pNonce = localIsP ? c.t.localNonce : c.t.peerNonce;
const aNonce = localIsP ? c.t.peerNonce : c.t.localNonce;
const pCommit = localIsP ? c.t.localCommit : c.t.peerCommit;
const aCommit = localIsP ? c.t.peerCommit : c.t.localCommit;
// Both peers derive the identical seed: P-nonce first, A-nonce second.
const seed = await opts.sha256(pNonce + aNonce + ":" + c.S.tick);
const tickNo = c.S.tick;
const { ev } = resolveTick(c.S, pOrders, aOrders, seed);
const stateHash = await opts.sha256(serializeState(c.S));
c.headHash = await opts.sha256(c.headHash+"|"+pCommit+"|"+aCommit+"|"+seed+"|"+stateHash);
const rec = { tick:tickNo, pOrders, aOrders, pNonce, aNonce, pCommit, aCommit,
seed, verified:true, stateHash, headHash:c.headHash };
c.ledger.push(rec);
// tell the peer our chain head for this tick so both can confirm agreement
opts.send({ t:"chain", tick:tickNo, head:c.headHash });
opts.hooks.onResolved && opts.hooks.onResolved(ev, rec);
c.S.tick++;
freshTick();
c.phase = "plan";
const w = winnerOf(c.S);
if(w) endMatch(w, "control target reached");
else opts.hooks.onPhase && opts.hooks.onPhase();
}
function endMatch(side, reason){
if(c.over) return;
clearRevealTimer();
c.over = { side, reason };
c.phase = "over";
opts.hooks.onEnd && opts.hooks.onEnd(side, reason);
}
c.commitLocal = commitLocal;
c.receive = receive;
c.endMatch = endMatch;
c.freshTick = freshTick;
return c;
}
// ==CONTROLLER_END==
/* ===================== crypto helpers (browser) ===================== */
async function sha256hex(str){
const buf = new TextEncoder().encode(str);
const h = await crypto.subtle.digest("SHA-256", buf);
return [...new Uint8Array(h)].map(x=>x.toString(16).padStart(2,"0")).join("");
}
function randNonce(){
const a = new Uint8Array(8); crypto.getRandomValues(a);
return [...a].map(x=>x.toString(16).padStart(2,"0")).join("");
}
const trunc = h => h.slice(0,10);
/* ===================== transports ===================== */
// AI transport: synthesises the opponent locally so the SAME protocol runs.
function makeAITransport(getController, getState){
let aiOrders=null, aiNonce=null, aiCommit=null;
return {
kind:"ai", role:"host",
async handleOutgoing(msg){
const ctrl = getController();
if(msg.t==="commit"){
aiOrders = chooseAI(getState()); // board state only
aiNonce = randNonce();
aiCommit = await sha256hex(canon(aiOrders)+"|"+aiNonce);
queueMicrotask(()=>ctrl.receive({ t:"commit", tick:msg.tick, commit:aiCommit }));
} else if(msg.t==="reveal"){
queueMicrotask(()=>ctrl.receive({ t:"reveal", tick:msg.tick, orders:aiOrders, nonce:aiNonce }));
}
// 'chain' messages: single machine, always agrees — ignore.
}
};
}
// BroadcastChannel transport: two tabs of this page, same origin.
function makeBroadcastTransport(room, onReady, onPeerGone){
const ch = new BroadcastChannel("kernel-"+room);
const myId = Math.random().toString(36).slice(2)+Date.now().toString(36);
let peerId=null, role=null, started=false, ctrl=null;
ch.onmessage = (e)=>{
const d=e.data; if(!d || d.from===myId) return;
if(d.t==="hello"){
if(!peerId){ peerId=d.id; }
// always answer so a late joiner learns us
ch.postMessage({from:myId, t:"hello", id:myId});
if(peerId && !started){
started=true;
role = (myId < peerId) ? "host" : "guest"; // deterministic on both
onReady(role, api);
}
return;
}
if(d.t==="bye"){ onPeerGone && onPeerGone(); return; }
if(started && ctrl) ctrl.receive(d);
};
const api = {
kind:"local",
setController(c){ ctrl=c; },
async handleOutgoing(msg){ ch.postMessage({from:myId, ...msg}); },
announce(){ ch.postMessage({from:myId, t:"hello", id:myId}); },
close(){ try{ch.postMessage({from:myId,t:"bye"}); ch.close();}catch(_){} }
};
return api;
}
// WebRTC transport: direct peer-to-peer, manual (copy-paste) signalling.
function makeRTCTransport(){
const pc = new RTCPeerConnection({ iceServers:[{urls:"stun:stun.l.google.com:19302"}] });
let dc=null, ctrl=null, onOpen=null;
function wire(channel){
dc=channel;
dc.onopen = ()=>{ onOpen && onOpen(); };
dc.onmessage = (e)=>{ if(ctrl){ try{ ctrl.receive(JSON.parse(e.data)); }catch(_){} } };
}
pc.ondatachannel = (e)=> wire(e.channel);
function iceComplete(){
return new Promise(res=>{
if(pc.iceGatheringState==="complete") return res();
const to=setTimeout(res, 2500); // proceed with whatever we have
pc.onicegatheringstatechange = ()=>{ if(pc.iceGatheringState==="complete"){ clearTimeout(to); res(); } };
});
}
const api = {
kind:"rtc", pc,
setController(c){ ctrl=c; },
setOnOpen(f){ onOpen=f; },
async handleOutgoing(msg){ if(dc && dc.readyState==="open") dc.send(JSON.stringify(msg)); },
async createOffer(){
wire(pc.createDataChannel("kernel"));
await pc.setLocalDescription(await pc.createOffer());
await iceComplete();
return btoa(JSON.stringify(pc.localDescription));
},
async acceptOfferMakeAnswer(code){
await pc.setRemoteDescription(JSON.parse(atob(code)));
await pc.setLocalDescription(await pc.createAnswer());
await iceComplete();
return btoa(JSON.stringify(pc.localDescription));
},
async acceptAnswer(code){ await pc.setRemoteDescription(JSON.parse(atob(code))); },
close(){ try{ pc.close(); }catch(_){} }
};
return api;
}
/* ===================== app / UI ===================== */
const $ = id => document.getElementById(id);
let ctrl=null, transport=null, mode=null, draft=null, agreeState=null;
const HOW_HTML = `
<summary>Why this is secure — the math, honestly</summary>
<div class="howbody">
<p>KERNEL is built so that whole <b>classes of cheating cannot exist</b>, rather than being patrolled after the fact. Every mechanism below runs for real in this page.</p>
<h4>Commit–reveal (binding + hiding)</h4>
<p>Each tick, both sides submit <code>SHA-256(orders ‖ nonce)</code> before either reveals. Against another person this is genuinely adversarial: neither client sends its orders until both commitments are exchanged, and each client verifies the other's reveal against the commitment it already holds. <b>Aimbots and reaction-exploits have nothing to attack — there's no aim and no early information.</b></p>
<h4>No leaked hidden state</h4>
<p>Nothing secret is streamed to your machine ahead of time, so <b>wallhacks/maphacks read nothing that isn't already fair to know.</b></p>
<h4>Verifiable randomness</h4>
<p>Tie-breaks use <code>seed = SHA-256(nonce_blue ‖ nonce_coral ‖ tick)</code>. If even one side is honest, neither can bias it, and anyone can recompute the exact byte that decided a coin flip.</p>
<h4>Intransitive combat = unsolvable meta</h4>
<p>Pierce > Shield > Sweep > Pierce is a symmetric cyclic zero-sum game: no dominant strategy, one mixed equilibrium (⅓ each). The AI plays exactly that, so it is mathematically unexploitable.</p>
<h4>Tamper-evident ledger + deterministic replay</h4>
<p>The match is a deterministic, <b>integer-only</b> function of committed inputs, so it reproduces identically on any machine — which is why both remote clients compute the same result independently and can compare chain heads live. Each tick chains into <code>SHA-256(prev ‖ commits ‖ seed ‖ state)</code>. <b>Re-simulate & verify</b> replays the whole match and checks every hash.</p>
<h4>The forfeit rule</h4>
<p>Commit–reveal's one residual attack is refusing to reveal to dodge a bad outcome. Here that's real: once both sides are committed, a reveal timeout hands the match to the honest player, and a reveal that doesn't match its commitment ends the match as a detected cheat.</p>
<p class="cav">What the math proves is the <i>fairness and reproducibility</i>, not that the game is <i>fun</i> — that part is design, and it's yours to tune.</p>
</div>`;
$("homeHow").innerHTML = HOW_HTML;
$("gameHow").innerHTML = HOW_HTML;
/* ---------- screen switching ---------- */
function show(screen){
$("homeScreen").classList.toggle("hidden", screen!=="home");
$("connectScreen").classList.toggle("hidden", screen!=="connect");
$("verifyScreen").classList.toggle("hidden", screen!=="verify");
$("gameScreen").classList.toggle("hidden", screen!=="game");
}
function setHeadRight(html){ $("headright").innerHTML = html; }
/* ---------- home ---------- */
document.querySelectorAll(".mode").forEach(btn=>{
btn.addEventListener("click", ()=>{
mode = btn.dataset.mode;
if(mode==="ai") startAI();
else if(mode==="local") connectLocal();
else connectRTC();
});
});
/* ---------- vs AI ---------- */
function startGame(localSide, isAI, revealTimeoutMs){
agreeState=null;
ctrl = makeController({
localSide, isAI,
sha256: sha256hex, nonce: randNonce,
send: (m)=> transport.handleOutgoing(m),
revealTimeoutMs,
resolveDelayMs: 520, // beat to play the seal-crack animation before the board updates
hooks: {
onPhase: renderGame,
onRevealReady: revealSealsUI, // both sides revealed → crack the seals open
onResolved: (ev, rec)=>{ logEvents(ev, rec.tick); pushLedgerRow(rec); renderGame(); },
onEnd: (side, reason)=> showEnd(side, reason),
onChain: (tick, ok)=>{ agreeState = ok; renderAgree(); }
}
});
draft = [ {m:"H",s:0}, {m:"H",s:0}, {m:"H",s:0} ];
resetGameUI();
show("game");
renderGame();
}
function startAI(){
transport = makeAITransport(()=>ctrl, ()=>ctrl.S);
startGame("P", true, Infinity);
setHeadRight(`<div class="chip">MODE <span>vs AI</span></div><div class="chip">YOU <span>blue</span></div>`);
}
/* ---------- 2 players, same device ---------- */
function connectLocal(){
const room = "main";
show("connect");
$("connectBox").innerHTML = `
<h2>Two players · this device</h2>
<p>This runs the real two-party protocol between two separate browser tabs. Open this same page in a second tab and press the same button — the tabs pair automatically over a private channel.</p>
<div class="field"><label>room</label><input class="text" id="roomInput" value="${room}"></div>
<div class="row">
<button class="btn" id="pairBtn">Pair with second tab</button>
<button class="btn ghost" id="backBtn">Back</button>
</div>
<div class="waiting hidden" id="waitMsg">◴ waiting for the other tab to join this room…</div>
<ul class="steps" style="margin-top:14px">
<li>Duplicate this browser tab (or open the artifact in a new tab).</li>
<li>In both tabs choose “2 players · this device”, keep the same room name.</li>
<li>Press “Pair” in both. Whoever has the lower client id plays blue.</li>
</ul>
<div class="note">No server is involved — pairing and every commit/reveal travel over a same-origin BroadcastChannel. For two <i>different</i> machines, use “2 players · remote”.</div>`;
$("backBtn").onclick = ()=> show("home");
$("pairBtn").onclick = ()=>{
const r = ($("roomInput").value||"main").trim();
$("pairBtn").disabled=true; $("roomInput").disabled=true;
$("waitMsg").classList.remove("hidden");
transport = makeBroadcastTransport(r, (role)=>{
const localSide = role==="host" ? "P" : "A";
startGame(localSide, false, 45000);
transport.setController(ctrl); // wire incoming messages once the controller exists
const col = localSide==="P" ? "blue" : "coral";
setHeadRight(`<div class="chip">MODE <span>2P local</span></div><div class="chip">YOU <span>${col}</span></div><div class="chip agree" id="agreeChip">peers —</div>`);
renderAgree();
}, ()=> onPeerGone());
transport.announce();
};
}
/* ---------- 2 players, remote (WebRTC) ---------- */
function connectRTC(){
show("connect");
$("connectBox").innerHTML = `
<h2>Two players · remote</h2>
<p>A direct peer-to-peer link. One player creates the game and sends the invite code; the other pastes it back an answer code. After that, no server ever sees your match.</p>
<div class="row">
<button class="btn" id="hostBtn">Create game</button>
<button class="btn alt" id="joinBtn">Join game</button>
<button class="btn ghost" id="backBtn2">Back</button>
</div>
<div id="rtcArea" style="margin-top:16px"></div>
<div class="note">Uses WebRTC data channels with a public STUN server for address discovery. Some restrictive networks block direct connections; if it won't connect, the “this device” mode always works.</div>`;
$("backBtn2").onclick = ()=> show("home");
$("hostBtn").onclick = rtcHost;
$("joinBtn").onclick = rtcJoin;
}
async function rtcHost(){
transport = makeRTCTransport();
$("rtcArea").innerHTML = `<div class="waiting">◴ generating invite code…</div>`;
const offer = await transport.createOffer();
$("rtcArea").innerHTML = `
<div class="field"><label>1 · send this invite code to your opponent</label><textarea class="code" id="offerOut" readonly>${offer}</textarea></div>
<button class="btn ghost" id="copyOffer" style="margin-bottom:12px">Copy invite code</button>
<div class="field"><label>2 · paste their answer code here</label><textarea class="code" id="answerIn" placeholder="paste answer code"></textarea></div>
<button class="btn" id="connectHost">Connect</button>
<div class="waiting hidden" id="rtcWait">◴ establishing peer link…</div>`;
$("copyOffer").onclick = ()=>{ navigator.clipboard && navigator.clipboard.writeText(offer); $("copyOffer").textContent="Copied ✓"; };
transport.setOnOpen(()=> onRTCOpen("host"));
$("connectHost").onclick = async ()=>{
const ans = $("answerIn").value.trim(); if(!ans) return;