WIP: Add multiplayer Snake game

N-player (2-8) real-time Snake game alongside Connect 4.
Lobby has tabs to switch between games. Players join via
invite link with 10-second countdown. Game loop runs at
tick-based intervals with NATS pub/sub for state sync.

Keyboard input not yet working (Datastar keydown binding
issue still under investigation).
This commit is contained in:
Ryan Hamamura
2026-02-02 07:26:28 -10:00
parent a6b5a46a8a
commit 7e78664534
18 changed files with 2289 additions and 40 deletions

View File

@@ -61,3 +61,27 @@
.player-chip { width: 20px; height: 20px; border-radius: 50%; background: #666; }
.player-chip.red { background: #dc2626; }
.player-chip.yellow { background: #facc15; }
/* Snake game */
.snake-board {
display: inline-grid;
gap: 0;
background: #1a1a2e;
border-radius: 8px;
overflow: hidden;
border: 3px solid #333;
}
.snake-row { display: contents; }
.snake-cell {
background: #16213e;
border: 1px solid rgba(255,255,255,0.03);
transition: background 0.05s;
}
.snake-cell.snake-food {
background: #ff6b6b;
border-radius: 50%;
box-shadow: 0 0 6px rgba(255,107,107,0.6);
}
.snake-cell.snake-head { border-radius: 4px; }
.snake-cell.snake-dead { opacity: 0.35; }
.snake-wrapper:focus { outline: none; }

View File

@@ -12,6 +12,7 @@
--color-white: #fff;
--spacing: 0.25rem;
--container-sm: 24rem;
--container-md: 28rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem;
@@ -179,6 +180,93 @@
}
}
@layer utilities {
.tab {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
cursor: pointer;
appearance: none;
flex-wrap: wrap;
align-items: center;
justify-content: center;
text-align: center;
webkit-user-select: none;
user-select: none;
&:hover {
@media (hover: hover) {
color: var(--color-base-content);
}
}
--tab-p: 0.75rem;
--tab-bg: var(--color-base-100);
--tab-border-color: var(--color-base-300);
--tab-radius-ss: 0;
--tab-radius-se: 0;
--tab-radius-es: 0;
--tab-radius-ee: 0;
--tab-order: 0;
--tab-radius-min: calc(0.75rem - var(--border));
--tab-radius-limit: min(var(--radius-field), var(--tab-radius-min));
--tab-radius-grad: #0000 calc(69% - var(--border)),
var(--tab-border-color) calc(calc(69% - var(--border)) + 0.25px),
var(--tab-border-color) calc(calc(69% - var(--border)) + var(--border)),
var(--tab-bg) calc(calc(69% - var(--border)) + var(--border) + 0.25px);
border-color: #0000;
order: var(--tab-order);
height: var(--tab-height);
font-size: 0.875rem;
padding-inline-start: var(--tab-p);
padding-inline-end: var(--tab-p);
&:is(input[type="radio"]) {
min-width: fit-content;
&:after {
--tw-content: attr(aria-label);
content: var(--tw-content);
}
}
&:is(label) {
position: relative;
input {
position: absolute;
inset: calc(0.25rem * 0);
cursor: pointer;
appearance: none;
opacity: 0%;
}
}
&:checked, &:is(label:has(:checked)), &:is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]) {
& + .tab-content {
display: block;
}
}
&:not( :checked, label:has(:checked), :hover, .tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"] ) {
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
}
}
&:not(input):empty {
flex-grow: 1;
cursor: default;
}
&:focus {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
outline: 2px solid currentColor;
outline-offset: -5px;
}
&[disabled] {
pointer-events: none;
opacity: 40%;
}
}
}
.btn {
:where(&) {
@layer daisyui.l1.l2.l3 {
@@ -311,6 +399,58 @@
}
}
}
.countdown {
&.countdown {
line-height: 1em;
}
@layer daisyui.l1.l2.l3 {
display: inline-flex;
& > * {
visibility: hidden;
position: relative;
display: inline-block;
overflow-y: clip;
transition: width 0.4s ease-out 0.2s;
height: 1em;
--value-v: calc(mod(max(0, var(--value)), 1000));
--value-hundreds: calc(round(to-zero, var(--value-v) / 100, 1));
--value-tens: calc(round(to-zero, mod(var(--value-v), 100) / 10, 1));
--value-ones: calc(mod(var(--value-v), 100));
--show-hundreds: clamp(clamp(0, var(--digits, 1) - 2, 1), var(--value-hundreds), 1);
--show-tens: clamp(
clamp(0, var(--digits, 1) - 1, 1),
var(--value-tens) + var(--show-hundreds),
1
);
--first-digits: calc(round(to-zero, var(--value-v) / 10, 1));
width: calc(1ch + var(--show-tens) * 1ch + var(--show-hundreds) * 1ch);
direction: ltr;
&:before, &:after {
visibility: visible;
position: absolute;
overflow-x: clip;
--tw-content: "00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";
content: var(--tw-content);
font-variant-numeric: tabular-nums;
white-space: pre;
text-align: end;
direction: rtl;
transition: all 1s cubic-bezier(1, 0, 0, 1), width 0.2s ease-out 0.2s, opacity 0.2s ease-out 0.2s;
}
&:before {
width: calc(1ch + var(--show-hundreds) * 1ch);
top: calc(var(--first-digits) * -1em);
inset-inline-end: 0;
opacity: var(--show-tens);
}
&:after {
width: 1ch;
top: calc(var(--value-ones) * -1em);
inset-inline-start: 0;
}
}
}
}
.input {
@layer daisyui.l1.l2.l3 {
cursor: text;
@@ -797,12 +937,46 @@
}
}
}
.tabs-box {
@layer daisyui.l1.l2 {
background-color: var(--color-base-200);
padding: calc(0.25rem * 1);
--tabs-box-radius: calc(3 * var(--radius-field));
border-radius: calc( min(calc(var(--tab-height) / 2), var(--radius-field)) + min(0.25rem, var(--tabs-box-radius)) );
box-shadow: 0 -0.5px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 0.5px oklch(0% 0 0 / calc(var(--depth) * 0.05)) inset;
> .tab {
border-radius: var(--radius-field);
border-style: none;
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
outline-offset: 2px;
}
}
> :is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]):not( .tab-disabled, [disabled] ), > :is(input:checked), > :is(label:has(:checked)) {
background-color: var(--tab-bg, var(--color-base-100));
box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px var(--color-neutral), 0 1px 6px -4px var(--color-neutral);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
}
@media (forced-colors: active) {
border: 1px solid;
}
}
> .tab-content {
margin-top: calc(0.25rem * 1);
height: calc(100% - var(--tab-height) + var(--border) - 0.5rem);
border-radius: calc( min(calc(var(--tab-height) / 2), var(--radius-field)) + min(0.25rem, var(--tabs-box-radius)) - var(--border) );
}
}
}
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
.mt-6 {
margin-top: calc(var(--spacing) * 6);
}
.mt-8 {
margin-top: calc(var(--spacing) * 8);
}
@@ -812,6 +986,9 @@
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.ml-4 {
margin-left: calc(var(--spacing) * 4);
}
@@ -840,6 +1017,17 @@
}
}
}
.tabs {
@layer daisyui.l1.l2.l3 {
display: flex;
flex-wrap: wrap;
--tabs-height: auto;
--tabs-direction: row;
--tab-height: calc(var(--size-field, 0.25rem) * 10);
height: var(--tabs-height);
flex-direction: var(--tabs-direction);
}
}
.alert {
border-width: var(--border);
border-color: var(--alert-border-color, var(--color-base-200));
@@ -947,6 +1135,9 @@
.flex {
display: flex;
}
.grid {
display: grid;
}
.btn-square {
@layer daisyui.l1.l2 {
padding-inline: calc(0.25rem * 0);
@@ -957,6 +1148,9 @@
.w-full {
width: 100%;
}
.max-w-md {
max-width: var(--container-md);
}
.max-w-sm {
max-width: var(--container-sm);
}
@@ -984,6 +1178,9 @@
.flex-col {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.items-center {
align-items: center;
}
@@ -1021,6 +1218,9 @@
.p-2 {
padding: calc(var(--spacing) * 2);
}
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
@@ -1073,6 +1273,13 @@
--alert-color: var(--color-error);
}
}
.alert-info {
@layer daisyui.l1.l2 {
color: var(--color-info-content);
--alert-border-color: var(--color-info);
--alert-color: var(--color-info);
}
}
.alert-success {
@layer daisyui.l1.l2 {
color: var(--color-success-content);
@@ -1099,6 +1306,9 @@
.no-underline {
text-decoration-line: none;
}
.opacity-40 {
opacity: 40%;
}
.opacity-60 {
opacity: 60%;
}
@@ -1226,6 +1436,36 @@
.player-chip.yellow {
background: #facc15;
}
.snake-board {
display: inline-grid;
gap: 0;
background: #1a1a2e;
border-radius: 8px;
overflow: hidden;
border: 3px solid #333;
}
.snake-row {
display: contents;
}
.snake-cell {
background: #16213e;
border: 1px solid rgba(255,255,255,0.03);
transition: background 0.05s;
}
.snake-cell.snake-food {
background: #ff6b6b;
border-radius: 50%;
box-shadow: 0 0 6px rgba(255,107,107,0.6);
}
.snake-cell.snake-head {
border-radius: 4px;
}
.snake-cell.snake-dead {
opacity: 0.35;
}
.snake-wrapper:focus {
outline: none;
}
@layer base {
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
color-scheme: light;