-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
5493 lines (5151 loc) · 209 KB
/
Copy pathindex.html
File metadata and controls
5493 lines (5151 loc) · 209 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">
<title>CAT SKI // Retro Downhill 1989</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Canonical URL — explicit so search engines don't conflate www/non-www
or any tracking-parameter variants. -->
<link rel="canonical" href="https://ski.mike-lee.me/">
<!-- Crawler hints — explicit index/follow + allow large image previews
(lets search engines pull preview.png into rich-result cards). -->
<meta name="robots" content="index, follow, max-image-preview:large">
<!-- Performance hints: warm up DNS + TLS before the origin is needed. -->
<link rel="dns-prefetch" href="https://www.googletagmanager.com">
<!-- Fonts are same-origin, so they can be preloaded directly rather than
waiting on a third-party stylesheet to parse first. -->
<link rel="preload" as="font" type="font/woff2" href="fonts/press-start-2p.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="fonts/vt323.woff2" crossorigin>
<!-- Search Console verification -->
<meta name="google-site-verification" content="Blnw1PeynoNtYxKYh57M27xYxnoz3UKCrqQ4a3mvxt8">
<!-- Google Analytics 4 with Consent Mode v2.
All storage defaults to denied so GA fires no cookies until the
visitor explicitly opts in via the cookie banner (see body).
Stored consent is read from localStorage on subsequent visits so
the banner only appears once per device. -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-MSN465FL9R"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
// Default-deny everything until the user explicitly accepts.
gtag('consent', 'default', {
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'analytics_storage': 'denied',
'wait_for_update': 500
});
// Restore previously-granted consent on subsequent visits.
try {
if (localStorage.getItem('skior_cookies') === 'accepted') {
gtag('consent', 'update', { 'analytics_storage': 'granted' });
}
} catch (_) {}
gtag('js', new Date());
gtag('config', 'G-MSN465FL9R');
</script>
<!-- Mixpanel product analytics, gated behind the same cookie banner as
GA above. The SDK loads opted-out; a run only reaches Mixpanel once
the visitor accepts (or has accepted before). No event fires before
consent. Anonymous only — the game has no accounts, so identity is
Mixpanel's auto device id. -->
<script>
(function(f,b){if(!b.__SV){var e,g,i,h;window.mixpanel=b;b._i=[];b.init=function(e,f,c){function g(a,d){var b=d.split(".");2==b.length&&(a=a[b[0]],d=b[1]);a[d]=function(){a.push([d].concat(Array.prototype.slice.call(arguments,0)))}}var a=b;"undefined"!==typeof c?a=b[c]=[]:c="mixpanel";a.people=a.people||[];a.toString=function(a){var d="mixpanel";"mixpanel"!==c&&(d+="."+c);a||(d+=" (stub)");return d};a.people.toString=function(){return a.toString(1)+".people (stub)"};i="disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove".split(" ");for(h=0;h<i.length;h++)g(a,i[h]);var j="set set_once union unset remove delete".split(" ");a.get_group=function(){function b(c){d[c]=function(){call2_args=arguments;call2=[c].concat(Array.prototype.slice.call(call2_args,0));a.push([e,call2])}}for(var d={},e=["get_group"].concat(Array.prototype.slice.call(arguments,0)),c=0;c<j.length;c++)b(j[c]);return d};b._i.push([e,f,c])};b.__SV=1.2;e=f.createElement("script");e.type="text/javascript";e.async=!0;e.src="undefined"!==typeof MIXPANEL_CUSTOM_LIB_URL?MIXPANEL_CUSTOM_LIB_URL:"file:"===f.location.protocol&&"//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js".match(/^\/\//)?"https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js":"//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js";g=f.getElementsByTagName("script")[0];g.parentNode.insertBefore(e,g)}})(document,window.mixpanel||[]);
mixpanel.init('8d35acd5df03b804face1caa86c659b4', {
// Project is on EU data residency (eu.mixpanel.com). Without this the
// SDK ships to the US cluster and events never reach the project.
api_host: 'https://api-eu.mixpanel.com',
opt_out_tracking_by_default: true,
persistence: 'localStorage',
track_pageview: false,
ignore_dnt: false,
});
// Returning visitor who already accepted: opt in now. First-time accept
// is handled by the cookie banner's OK button (see body).
try {
if (localStorage.getItem('skior_cookies') === 'accepted') mixpanel.opt_in_tracking();
} catch (_) {}
</script>
<meta name="description" content="A free browser game inspired by SkiFree (1991). Play as a cat skiing down an endless mountain. Three regional themes (Alps, Rockies, Himalayas), four cat color variants, mid-air tricks, dog and snowman chasers, tunable difficulty. Installable as a Progressive Web App.">
<!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
<meta name="theme-color" content="#0b1320">
<!-- Structured data: VideoGame -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "VideoGame",
"name": "Cat Ski 1989",
"alternateName": "cat-ski",
"description": "A free browser game inspired by SkiFree (1991). Play as a cat skiing down an endless mountain. Three regional themes (Alps, Rockies, Himalayas), four cat color variants, mid-air tricks, dog and snowman chasers, tunable difficulty, and a 9-lives mode. Installable as a Progressive Web App for offline play.",
"url": "https://ski.mike-lee.me/",
"image": "https://ski.mike-lee.me/preview.png",
"inLanguage": ["en", "es"],
"applicationCategory": "GameApplication",
"gamePlatform": ["Web Browser", "PWA"],
"operatingSystem": "Any",
"genre": ["Arcade", "Retro", "Endless Runner"],
"isAccessibleForFree": true,
"playMode": "SinglePlayer",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Person",
"name": "Mike Lee",
"alternateName": "Whisker Leaks",
"url": "https://www.linkedin.com/in/mikelee89/"
},
"publisher": {
"@type": "Person",
"name": "Mike Lee",
"url": "https://go.mike-lee.me/"
},
"datePublished": "2026-04-24",
"dateModified": "2026-04-26",
"softwareVersion": "1.0"
}
</script>
<!-- PWA manifest (makes the game installable to home screen) -->
<link rel="manifest" href="/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Cat Ski">
<!-- Open Graph (iMessage, Slack, Discord, Facebook, LinkedIn, WhatsApp) -->
<meta property="og:title" content="CAT SKI // 1989">
<meta property="og:description" content="A free browser game inspired by SkiFree (1991). Play as a cat down an endless mountain. Three themes, mid-air tricks, dog and snowman chasers, tunable difficulty.">
<meta property="og:image" content="https://ski.mike-lee.me/preview.png">
<meta property="og:image:secure_url" content="https://ski.mike-lee.me/preview.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Pixel-art downhill skiing scene with a cat, a dog, a yeti, and a skier on a peak">
<meta property="og:url" content="https://ski.mike-lee.me/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="cat-ski">
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="CAT SKI // 1989">
<meta name="twitter:description" content="A free browser game inspired by SkiFree (1991). Play as a cat down an endless mountain. Three themes, mid-air tricks, dog and snowman chasers, tunable difficulty.">
<meta name="twitter:image" content="https://ski.mike-lee.me/preview.png">
<meta name="twitter:image:alt" content="Pixel-art downhill skiing scene with a cat, a dog, a yeti, and a skier on a peak">
<style>
/* ---------- RETRO PIXEL AESTHETIC ----------
Chunky HUD with a CRT vibe. Cool blue snow palette with a single
warm accent for flags and warnings. Pixel-perfect rendering on the
canvas (image-rendering: pixelated) so scaled shapes stay crisp. */
/* Self-hosted so an installed PWA has type when offline, and so the
first paint isn't blocked on a third-party stylesheet. Both faces
are SIL Open Font License. */
@font-face {
font-family: 'Press Start 2P';
src: url('fonts/press-start-2p.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'VT323';
src: url('fonts/vt323.woff2') format('woff2');
font-display: swap;
}
:root {
/* Core palette — snow whites, ice blues, the warm accent pair. */
--snow: #eaf4ff;
--snow-dim: #cfe2f5;
--shadow: #9fb6cc;
--ice: #7aa7c7;
--deep: #1a2838;
--ink: #0b1320;
--accent: #ff3b30;
--accent-2: #ffcc00;
/* Semantic tones — referenced from many places. Centralizing here
so a future palette change stays one-file. */
--success: #6ed26b; /* PHEW! floater, share-success, positives */
--pink-life: #ff6b8a; /* cat-face nose accent + life-loss floater */
--greyed: #617083; /* lost-life icon body, disabled / muted */
--text-dim: #798fa6; /* footer + ghost links like [reset] */
--accent-2-hi: #fff088; /* gold specular stop */
--accent-2-dk: #c89800; /* gold shaded stop */
/* Chrome surfaces, deepest to lightest. Controls carry their own
rest/hover/active trio. */
--void: #050910; /* outer vignette edge, page backdrop */
--sunken: #0a111c; /* recessed wells (canvas surround) */
--sunken-hi: #111a27; /* top stop of the sunken gradient */
--panel: #0e1826; /* panel + tooltip body */
--panel-border: #1d2a3c; /* panel edge, focus outline base */
--bezel-hi: #24384f; /* bezel inner rim, vignette crown */
--ctl: #182638; /* button / swatch at rest */
--ctl-hover: #1d2c42;
--ctl-active: #1f2e44;
--ctl-pressed: #162232; /* touch :active, darker than rest */
--ctl-border: #2b3c54;
/* Press Start 2P is drawn on an 8px grid, so it only renders crisp
at whole multiples of 8. Every size below is one; nothing else
should set a pixel-font size. */
--font-display: 'Press Start 2P', monospace;
--font-body: 'VT323', monospace;
--type-sm: 8px;
--type-md: 16px;
--type-lg: 24px;
/* Translucent ice variants used in chrome borders + glow shadows.
Using rgba derived from --ice (#7aa7c7 = 122,167,199). */
--ice-15: rgba(122,167,199,0.15);
--ice-22: rgba(122,167,199,0.22);
--ice-35: rgba(122,167,199,0.35);
--ice-60: rgba(122,167,199,0.60);
}
/* Respect OS-level reduced-motion preference. Disables the blink
keyframe (used on hint cursors and the bezel-title dot) and any
CSS transitions that would otherwise flash. The game canvas is
core gameplay and stays animated regardless. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
/* Visible focus ring for keyboard navigators. :focus-visible only
activates for keyboard / programmatic focus, not mouse clicks,
so the outline never appears for pointer users. The accent-2
gold keeps the focus indicator on-brand. Buttons and links both
get the same treatment for consistency. */
:focus { outline: none; }
button:focus-visible,
a:focus-visible,
input:focus-visible,
[role="button"]:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
/* A plain display rule on a class beats the UA [hidden] rule, so the
attribute silently fails to hide without this. Make it authoritative
everywhere rather than per-element. */
[hidden] { display: none !important; }
html, body {
background: var(--ink);
color: var(--snow);
font-family: var(--font-body);
}
body {
/* min-height uses dvh (dynamic viewport) so the body resizes with
iOS Safari's address bar collapse/expand instead of jumping.
100vh fallback for browsers that don't support dvh yet. */
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 12px 0;
background:
radial-gradient(ellipse at 50% 0%, var(--bezel-hi) 0%, var(--ink) 60%, var(--void) 100%);
overflow-x: hidden;
}
.frame {
position: relative;
padding: 14px 14px 18px;
border-radius: 6px;
background:
linear-gradient(var(--sunken-hi), var(--sunken));
box-shadow:
0 0 0 2px var(--panel-border),
0 0 0 4px #000,
0 30px 60px rgba(0,0,0,.6),
inset 0 0 40px rgba(0,0,0,.6);
}
.bezel-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 2px;
color: var(--snow-dim);
margin-bottom: 10px;
padding: 0 4px;
text-shadow: 0 0 6px var(--ice-60);
}
.bezel-center {
flex: 1;
text-align: center;
}
/* Equal flex on both sides keeps the title dead-centre even though the
right side is now empty. */
.bezel-left,
.bezel-right {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.bezel-right { justify-content: flex-end; }
.bezel-title .dot {
color: var(--accent);
animation: blink 1.2s steps(2,end) infinite;
}
.bezel-link {
font-size: 8px;
letter-spacing: 2px;
color: var(--snow-dim);
text-decoration: underline;
text-underline-offset: 3px;
padding: 4px 2px;
text-shadow: none;
transition: color 120ms;
}
.bezel-link:hover { color: var(--accent-2); }
.bezel-link:active { color: var(--accent); }
@keyframes blink { 50% { opacity: 0; } }
/* Top-3 high score strip. Sits between the bezel title and the game
canvas. Three flex cells, each laid out as | RANK NAME SCORE |.
Same Press Start 2P font as the bezel, dimmer than the HUD, no
animation, minimum vertical real-estate. Each entry is fixed-width
so rank/name/score columns line up vertically across the three
entries — reads as a tiny arcade leaderboard, not a run-on string. */
.leaderboard {
display: flex;
justify-content: center;
gap: 28px;
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 1.5px;
color: var(--snow-dim);
margin-bottom: 6px;
padding: 6px 8px 7px;
border-top: 1px solid var(--ice-22);
border-bottom: 1px solid var(--ice-22);
text-shadow: 0 0 4px var(--ice-35);
flex-wrap: wrap;
}
.leaderboard .lb-row {
display: inline-flex;
align-items: baseline;
gap: 10px;
min-width: 130px;
justify-content: flex-start;
}
.leaderboard .lb-rank { color: var(--accent-2); min-width: 18px; }
.leaderboard .lb-name { color: var(--snow); min-width: 50px; letter-spacing: 2px; }
.leaderboard .lb-score { color: var(--snow-dim); min-width: 38px; text-align: right; margin-left: auto; }
.leaderboard .lb-empty { opacity: 0.45; }
/* Mode label tells the player which of the two boards is on
screen (9 LIVES vs 1 LIFE). Lives in accent-2 so it visually
belongs with the rank numerals; small enough to read as chrome. */
.leaderboard .lb-mode {
color: var(--accent-2);
letter-spacing: 2px;
font-size: var(--type-sm);
align-self: center;
padding-right: 4px;
border-right: 1px solid var(--ice-22);
margin-right: 4px;
}
/* Trophy bookends. Pixel-art SVGs flank the strip so it reads
instantly as a high-score table even before the labels parse.
currentColor follows --accent-2 so they glow in the same gold as
the rank numerals. The drop-shadow filter is kept light — the
bezel chrome already has its own glow language. */
.leaderboard .lb-trophy {
width: 14px;
height: 14px;
color: var(--accent-2);
align-self: center;
flex-shrink: 0;
filter: drop-shadow(0 0 2px var(--ice-35));
}
@media (max-width: 540px) {
.leaderboard { gap: 12px; font-size: var(--type-sm); padding: 5px 4px; }
.leaderboard .lb-row { gap: 6px; min-width: 0; }
.leaderboard .lb-name { min-width: 38px; letter-spacing: 1.5px; }
.leaderboard .lb-score { min-width: 30px; }
.leaderboard .lb-trophy { width: 12px; height: 12px; }
}
/* Under 380px the trophies drop so three entries still fit on one line. */
@media (max-width: 380px) {
.leaderboard { gap: 8px; font-size: var(--type-sm); }
.leaderboard .lb-trophy { display: none; }
.leaderboard .lb-mode { font-size: var(--type-sm); padding-right: 3px; margin-right: 3px; }
.leaderboard .lb-name { min-width: 32px; letter-spacing: 1px; }
.leaderboard .lb-score { min-width: 24px; }
}
/* Name-entry block inside the game-over panel. */
.lb-entry {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed rgba(234,244,255,0.25);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
/* Site's `.hidden` rule only matches `.overlay.hidden`, so this scoped variant is needed. */
.lb-entry.hidden { display: none; }
.lb-entry-title {
font-family: var(--font-display);
font-size: var(--type-sm);
color: var(--accent-2);
letter-spacing: 2px;
text-shadow: 0 0 6px rgba(255,204,0,0.4);
}
.lb-entry-row {
display: flex;
align-items: center;
gap: 8px;
}
.lb-name-input {
font-family: var(--font-display);
font-size: var(--type-md);
letter-spacing: 4px;
/* 5 chars * (~14px glyph + 4px letter-spacing) = ~90px, plus the
trailing letter-spacing slot, plus padding + border. ~140px keeps
all five visible without crowding the cursor. */
width: 140px;
padding: 8px 10px;
background: var(--ink);
color: var(--snow);
border: 2px solid var(--snow-dim);
text-align: center;
text-transform: uppercase;
outline: none;
caret-color: var(--accent-2);
}
.lb-name-input:focus {
border-color: var(--accent-2);
box-shadow: 0 0 0 2px rgba(255,204,0,0.3);
}
.lb-submit {
font-family: var(--font-display);
font-size: var(--type-sm);
padding: 8px 12px;
background: var(--accent-2);
color: var(--ink);
border: 2px solid var(--accent-2);
cursor: pointer;
letter-spacing: 2px;
box-shadow: 2px 2px 0 #000;
}
.lb-submit:active {
transform: translate(1px, 1px);
box-shadow: 1px 1px 0 #000;
}
.screen-wrap {
position: relative;
/* Responsive: scale the 4:3 game area to the viewport with sensible
bounds on both sides.
85vw -> cap to 85% viewport width (room for vignette)
70vh * 4 / 3 -> never taller than 70% viewport height (room
for bezel-title + pickers below)
960px -> cap so pixel art doesn't blow out too much
(internal canvas is 360x270; 960 is ~2.67x)
aspect-ratio locks the 4:3 shape, height auto computes from it.
The game scales smoothly across monitor sizes AND browser zoom. */
width: min(85vw, calc(70vh * 4 / 3), 960px);
aspect-ratio: 4 / 3;
height: auto;
max-width: 100%;
border-radius: 4px;
overflow: hidden;
background: #000;
box-shadow: inset 0 0 0 2px #000, inset 0 0 0 4px var(--bezel-hi);
}
canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* CRT overlay. Layered CSS-only effect targeting an authentic shadow-
mask CRT look without per-frame cost.
1. Horizontal scanlines (period 4px, dark gap every 4th line)
2. Vertical R-G-B phosphor stripes (the actual mechanism that
makes a CRT look like a CRT — sub-pixel mask, period 6px)
3. Corner vignette
4. Inset shadow on the overlay itself fakes the tube's curvature
(real CRTs darken at the very edges of the visible image
where the electron beam can't quite reach)
A ::before pseudo adds a diagonal glass sheen, like light hitting
the front of the tube. mix-blend-mode is deliberately not used —
it forces a full-canvas re-composite every frame. */
.crt {
pointer-events: none;
position: absolute;
inset: 0;
background:
/* horizontal scanlines */
repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,.12) 3px,
rgba(0,0,0,0) 4px
),
/* vertical RGB phosphor mask */
repeating-linear-gradient(
to right,
rgba(255,80,80,0.06) 0px,
rgba(255,80,80,0.06) 2px,
rgba(80,255,80,0.06) 2px,
rgba(80,255,80,0.06) 4px,
rgba(80,80,255,0.06) 4px,
rgba(80,80,255,0.06) 6px
),
/* corner vignette */
radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,.35) 100%);
/* Tube curvature illusion — edges of visible image darken inward */
box-shadow:
inset 0 0 24px rgba(0,0,0,0.45),
inset 0 0 70px rgba(15,25,40,0.25);
}
/* Glass sheen — subtle diagonal highlight as if light is reflecting
off the front of the tube. Pinned to the overlay so it scales with
the screen-wrap. */
.crt::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
135deg,
rgba(255,255,255,0) 0%,
rgba(255,255,255,0.05) 28%,
rgba(255,255,255,0) 55%
);
}
/* HUD */
.hud {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
justify-content: space-between;
padding: 10px 14px;
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 1px;
color: var(--snow);
text-shadow: 2px 2px 0 #000;
pointer-events: none;
}
.hud .label { color: var(--snow-dim); }
.hud .value { color: var(--accent-2); }
.hud .danger { color: var(--accent); animation: blink 0.6s steps(2,end) infinite; }
/* Overlays */
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(5, 10, 20, 0.78);
backdrop-filter: blur(2px);
text-align: center;
padding: 20px;
z-index: 5;
}
.overlay.hidden { display: none; }
/* Start screen uses the social preview image as its backdrop for
visual continuity with shared links. A dark gradient on top keeps
the panel legible. Image scaled with crisp-edges to keep its
pixel-art character at the rendered size. */
#overlay-start {
background:
linear-gradient(rgba(5,10,20,0.55), rgba(5,10,20,0.85)),
url('preview.png') center/cover no-repeat;
image-rendering: pixelated;
image-rendering: crisp-edges;
backdrop-filter: none;
}
.panel {
border: 2px solid var(--snow-dim);
background: var(--ink);
padding: 22px 28px;
box-shadow: 6px 6px 0 #000, 0 0 0 2px #000 inset;
max-width: 520px;
}
.panel h1 {
font-family: var(--font-display);
font-size: var(--type-lg);
color: var(--snow);
line-height: 1.4;
margin-bottom: 8px;
letter-spacing: 2px;
}
.panel h1 .alt { color: var(--accent); }
.panel h2 {
font-family: var(--font-display);
font-size: var(--type-md);
color: var(--accent-2);
margin-bottom: 14px;
}
.panel p {
font-size: 22px;
line-height: 1.25;
color: var(--snow-dim);
margin: 8px 0;
}
.panel .keys {
display: inline-grid;
grid-template-columns: auto auto;
gap: 4px 14px;
margin: 14px 0 8px;
font-family: var(--font-display);
font-size: var(--type-sm);
color: var(--snow);
text-align: left;
}
.panel .keys kbd {
display: inline-block;
padding: 4px 7px;
border: 1px solid var(--snow-dim);
background: var(--ctl);
color: var(--snow);
font-family: var(--font-display);
font-size: var(--type-sm);
box-shadow: 2px 2px 0 #000;
}
.panel .hint {
font-family: var(--font-display);
font-size: var(--type-sm);
color: var(--snow);
margin-top: 16px;
letter-spacing: 1px;
}
.panel .hint .blink { color: var(--accent-2); animation: blink 1s steps(2,end) infinite; }
/* Secondary link below the start prompt — points players at the
readme for a deeper TLDR + controls reference. Subtle on purpose:
SPACE-to-start is the primary CTA, this is the tertiary "help me"
escape hatch. Sized smaller than the hint, ghosted color, dotted
underline so it reads as a link without competing for attention. */
.readme-link {
display: inline-block;
margin-top: 10px;
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 1px;
color: var(--text-dim);
text-decoration: none;
border-bottom: 1px dotted var(--text-dim);
padding-bottom: 1px;
transition: color 120ms, border-color 120ms;
}
.readme-link:hover { color: var(--accent-2); border-bottom-color: var(--accent-2); }
.readme-link:active { color: var(--accent); }
/* Cookie consent banner. Fixed to the bottom of the viewport,
dismissible, persists the decision in localStorage. Sized to be
unobtrusive but readable; matches the bezel-title chrome
language so it doesn't feel like a third-party widget. */
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
background: var(--ink);
border-top: 2px solid var(--ice-22);
padding: 12px 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
font-family: var(--font-body);
font-size: 16px;
color: var(--snow-dim);
box-shadow: 0 -4px 16px rgba(0,0,0,0.5);
}
.cookie-banner .cb-text { flex: 1 1 280px; min-width: 0; }
.cookie-banner .cb-text a {
color: var(--accent-2);
text-decoration: underline;
text-underline-offset: 2px;
}
.cookie-banner .cb-btn {
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 1px;
padding: 8px 12px;
background: transparent;
color: var(--snow-dim);
border: 2px solid var(--ice);
cursor: pointer;
transition: color 120ms, border-color 120ms;
}
.cookie-banner .cb-btn:hover { color: var(--accent-2); border-color: var(--accent-2); }
.cookie-banner .cb-btn.cb-accept {
background: var(--accent-2);
color: var(--ink);
border-color: var(--accent-2);
box-shadow: 2px 2px 0 #000;
}
.cookie-banner .cb-btn.cb-accept:hover { color: var(--ink); }
.cookie-banner .cb-btn:active { transform: translate(1px, 1px); }
/* While the cookie banner is up, hide the touch joystick and trick
pad so they don't get covered or get tapped through the banner.
The banner is one-time per device, so this is a brief gate. */
body.cookie-banner-active .stick-area,
body.cookie-banner-active .trick-pad { display: none !important; }
.panel .stat-row {
display: flex; justify-content: space-between; gap: 30px;
font-family: var(--font-display);
font-size: var(--type-sm);
color: var(--snow);
margin: 6px 0;
}
.panel .stat-row .v { color: var(--accent-2); }
.reset-best {
font-family: var(--font-body);
font-size: 14px;
color: var(--text-dim);
text-decoration: none;
margin-left: 6px;
letter-spacing: 0;
}
.reset-best:hover { color: var(--accent); }
.share-btn {
display: block;
margin: 14px auto 0;
padding: 6px 14px;
background: var(--ctl);
border: 2px solid var(--accent-2);
color: var(--accent-2);
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 2px;
cursor: pointer;
box-shadow: 2px 2px 0 #000;
transition: background 80ms;
}
.share-btn:hover { background: rgba(255,204,0,0.12); }
.share-btn:active { transform: translate(1px, 1px); box-shadow: 1px 1px 0 #000; }
.share-btn.copied { color: var(--success); border-color: var(--success); }
.footer {
margin-top: 10px;
text-align: center;
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 2px;
color: var(--text-dim);
}
.cat-picker {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 12px;
padding: 8px 10px;
background: var(--panel);
border: 2px solid var(--panel-border);
box-shadow: inset 0 0 0 2px #000;
}
.cat-picker-label {
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 2px;
color: var(--snow-dim);
margin-right: 4px;
}
.cat-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 5px 6px 4px;
background: var(--ctl);
border: 2px solid var(--ctl-border);
cursor: pointer;
box-shadow: 2px 2px 0 #000;
transition: transform 80ms, border-color 80ms, background 80ms;
}
.cat-swatch canvas {
display: block;
width: 48px;
height: 48px;
image-rendering: auto;
}
.cat-swatch .cat-name {
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 1px;
color: var(--snow-dim);
}
.cat-swatch:hover {
transform: translate(-1px, -1px);
border-color: var(--snow-dim);
background: var(--ctl-hover);
}
.cat-swatch.active {
border-color: var(--accent-2);
box-shadow: 2px 2px 0 #000, 0 0 0 2px var(--accent-2) inset;
background: var(--ctl-active);
}
.cat-swatch.active .cat-name {
color: var(--accent-2);
}
/* Difficulty picker: text-only swatches, same chassis as cat-picker */
.diff-picker {
margin-top: 8px;
}
.diff-swatch {
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 2px;
color: var(--snow-dim);
padding: 8px 10px;
background: var(--ctl);
border: 2px solid var(--ctl-border);
cursor: pointer;
box-shadow: 2px 2px 0 #000;
transition: transform 80ms, border-color 80ms, background 80ms, color 80ms;
}
.diff-swatch:hover {
transform: translate(-1px, -1px);
border-color: var(--snow-dim);
background: var(--ctl-hover);
color: var(--snow);
}
.diff-swatch.active {
border-color: var(--accent-2);
box-shadow: 2px 2px 0 #000, 0 0 0 2px var(--accent-2) inset;
background: var(--ctl-active);
color: var(--accent-2);
}
.preset-btn[data-preset="insta"].active {
border-color: var(--accent);
box-shadow: 2px 2px 0 #000, 0 0 0 2px var(--accent) inset;
color: var(--accent);
}
/* Retro-styled range slider (difficulty + dog count).
Custom track and chunky square thumb to match the pixel aesthetic. */
.retro-slider {
-webkit-appearance: none;
appearance: none;
width: 140px;
height: 14px;
background: var(--ctl);
border: 2px solid var(--ctl-border);
box-shadow: inset 0 0 0 1px #000;
outline: none;
cursor: pointer;
margin: 0 4px;
}
.retro-slider:hover { border-color: var(--snow-dim); }
.retro-slider:focus { border-color: var(--accent-2); }
.retro-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 18px;
background: var(--accent-2);
border: 2px solid var(--ink);
box-shadow: 1px 1px 0 #000;
cursor: pointer;
border-radius: 0;
}
.retro-slider::-moz-range-thumb {
width: 12px;
height: 18px;
background: var(--accent-2);
border: 2px solid var(--ink);
box-shadow: 1px 1px 0 #000;
cursor: pointer;
border-radius: 0;
}
.slider-value {
font-family: var(--font-display);
font-size: var(--type-sm);
letter-spacing: 1px;
color: var(--accent-2);
min-width: 30px;
text-align: left;
}
/* 9-lives toggle: button labeled "9", highlights yellow when active.
Hover/focus mirror the .diff-swatch tactile feedback so it doesn't
feel like a different control type than its neighbors. */
.lives-toggle {
font-size: var(--type-sm);
padding: 6px 10px;
min-width: 32px;
}
.lives-toggle:hover { color: var(--accent-2); }
.lives-toggle:active { transform: translate(1px, 1px); }
.lives-toggle.active {
background: var(--accent-2);
color: var(--ink);
border-color: var(--accent-2);
box-shadow: 2px 2px 0 #000, inset 0 -2px 0 rgba(0,0,0,0.25);
}
.lives-toggle.active:hover { color: var(--ink); }
/* Lives HUD: row of cat-face SVG icons in the top-right of the game.
Hidden in 1-life mode. Active lives show yellow; lost lives go grey. */
.lives-hud {
display: none;
margin-top: 4px;
gap: 2px;
justify-content: flex-end;
}
body.lives-mode .lives-hud { display: flex; }
.life-icon {
width: 14px;
height: 12px;
color: var(--accent-2);
transition: color 200ms, opacity 200ms;
}
.life-icon.lost {
color: var(--greyed);
opacity: 0.55;
}
.icon-label {
font-size: 18px;
line-height: 1;
margin-right: 4px;
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.icon-btn {
font-family: var(--font-body);
font-size: 18px;
letter-spacing: 0;
padding: 4px 12px;
line-height: 1;
min-width: 40px;
text-align: center;
}
/* Binary slider — narrower than the difficulty slider since it only
has two positions. */
.toggle-slider { width: 50px; }
/* Combo picker: two sub-groups (e.g. KEYS + TOUCH) on one line */
.combo-picker { gap: 18px; flex-wrap: wrap; }
.sub-picker {
display: flex;
align-items: center;
gap: 8px;
}
/* Action buttons (PAUSE / RESTART) — same chassis as diff-swatch,
restart gets a red accent because it throws away progress. */
#btn-pause.paused {
border-color: var(--accent-2);
box-shadow: 2px 2px 0 #000, 0 0 0 2px var(--accent-2) inset;
color: var(--accent-2);
}
#btn-restart:hover {
border-color: var(--accent);
color: var(--accent);
}
/* Install button — yellow accent border so it's noticeable without
screaming. Hidden by default, revealed by JS when installable. */
.install-btn {
border-color: var(--accent-2) !important;
color: var(--accent-2) !important;
}
.install-btn:hover {
background: rgba(255, 204, 0, 0.15);
}
/* ---------- Virtual thumbstick (mobile) ----------