From ba2405b7986837e44426285a31e94642f25f0696 Mon Sep 17 00:00:00 2001
From: Florian <45694132+flo-bit@users.noreply.github.com>
Date: Tue, 17 Feb 2026 14:57:47 +0100
Subject: [PATCH] add snake game card
---
.../games/SnakeGameCard/SnakeGameCard.svelte | 371 ++++++++++++++++++
src/lib/cards/games/SnakeGameCard/index.ts | 22 ++
.../cards/games/SnakeGameCard/snakeGame.ts | 214 ++++++++++
src/lib/cards/index.ts | 2 +
4 files changed, 609 insertions(+)
create mode 100644 src/lib/cards/games/SnakeGameCard/SnakeGameCard.svelte
create mode 100644 src/lib/cards/games/SnakeGameCard/index.ts
create mode 100644 src/lib/cards/games/SnakeGameCard/snakeGame.ts
diff --git a/src/lib/cards/games/SnakeGameCard/SnakeGameCard.svelte b/src/lib/cards/games/SnakeGameCard/SnakeGameCard.svelte
new file mode 100644
index 0000000..5455056
--- /dev/null
+++ b/src/lib/cards/games/SnakeGameCard/SnakeGameCard.svelte
@@ -0,0 +1,371 @@
+
+
+
+
+
+
+
+
+
+ SCORE {state.score}
+
+
+ {#if state.phase === 'idle' || state.phase === 'gameover'}
+
+ {/if}
+
+
+
.
+
diff --git a/src/lib/cards/games/SnakeGameCard/index.ts b/src/lib/cards/games/SnakeGameCard/index.ts
new file mode 100644
index 0000000..2cf9489
--- /dev/null
+++ b/src/lib/cards/games/SnakeGameCard/index.ts
@@ -0,0 +1,22 @@
+import type { CardDefinition, ContentComponentProps } from '$lib/cards/types';
+import type { Component } from 'svelte';
+import SnakeGameCard from './SnakeGameCard.svelte';
+
+export const SnakeGameCardDefinition = {
+ type: 'snake-game',
+ contentComponent: SnakeGameCard as unknown as Component,
+ allowSetColor: true,
+ createNew: (card) => {
+ card.w = 4;
+ card.h = 3;
+ card.mobileW = 8;
+ card.mobileH = 8;
+ card.cardData = {};
+ },
+ canHaveLabel: false,
+
+ keywords: ['snake', 'arcade', 'retro', 'game'],
+ groups: ['Games'],
+ name: 'Snake',
+ icon: ``
+} as CardDefinition & { type: 'snake-game' };
diff --git a/src/lib/cards/games/SnakeGameCard/snakeGame.ts b/src/lib/cards/games/SnakeGameCard/snakeGame.ts
new file mode 100644
index 0000000..bbd478c
--- /dev/null
+++ b/src/lib/cards/games/SnakeGameCard/snakeGame.ts
@@ -0,0 +1,214 @@
+export type SnakeDirection = 'up' | 'down' | 'left' | 'right';
+export type SnakePhase = 'idle' | 'playing' | 'gameover';
+
+export type SnakePosition = {
+ x: number;
+ y: number;
+};
+
+export type SnakeGameConfig = {
+ cols?: number;
+ rows?: number;
+ initialLength?: number;
+};
+
+export type SnakeGameState = {
+ cols: number;
+ rows: number;
+ initialLength: number;
+ snake: SnakePosition[];
+ direction: SnakeDirection;
+ queuedDirection: SnakeDirection | null;
+ food: SnakePosition | null;
+ score: number;
+ tick: number;
+ phase: SnakePhase;
+};
+
+type RandomFn = () => number;
+
+const DEFAULT_COLS = 16;
+const DEFAULT_ROWS = 16;
+const DEFAULT_INITIAL_LENGTH = 3;
+
+const OPPOSITES: Record = {
+ up: 'down',
+ down: 'up',
+ left: 'right',
+ right: 'left'
+};
+
+const DIRECTION_VECTORS: Record = {
+ up: { x: 0, y: -1 },
+ down: { x: 0, y: 1 },
+ left: { x: -1, y: 0 },
+ right: { x: 1, y: 0 }
+};
+
+function clampRandom(value: number): number {
+ if (!Number.isFinite(value)) return 0;
+ if (value <= 0) return 0;
+ if (value >= 1) return 0.999999999999;
+ return value;
+}
+
+function positionKey(pos: SnakePosition): string {
+ return `${pos.x}:${pos.y}`;
+}
+
+function isSamePosition(a: SnakePosition, b: SnakePosition): boolean {
+ return a.x === b.x && a.y === b.y;
+}
+
+function buildInitialSnake(cols: number, rows: number, length: number): SnakePosition[] {
+ const safeLength = Math.max(2, Math.min(length, cols));
+ const headX = Math.min(cols - 1, Math.max(safeLength - 1, Math.floor(cols / 2)));
+ const y = Math.max(0, Math.floor(rows / 2));
+
+ const snake: SnakePosition[] = [];
+ for (let i = 0; i < safeLength; i++) {
+ snake.push({ x: headX - i, y });
+ }
+
+ return snake;
+}
+
+function spawnFood(
+ snake: SnakePosition[],
+ cols: number,
+ rows: number,
+ rng: RandomFn
+): SnakePosition | null {
+ const occupied = new Set(snake.map(positionKey));
+ const freeCells: SnakePosition[] = [];
+
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ const key = `${x}:${y}`;
+ if (!occupied.has(key)) {
+ freeCells.push({ x, y });
+ }
+ }
+ }
+
+ if (freeCells.length === 0) {
+ return null;
+ }
+
+ const index = Math.floor(clampRandom(rng()) * freeCells.length);
+ return freeCells[index];
+}
+
+function createBaseState(
+ {
+ cols = DEFAULT_COLS,
+ rows = DEFAULT_ROWS,
+ initialLength = DEFAULT_INITIAL_LENGTH
+ }: SnakeGameConfig,
+ rng: RandomFn,
+ phase: SnakePhase
+): SnakeGameState {
+ const safeCols = Math.max(4, cols);
+ const safeRows = Math.max(4, rows);
+ const safeInitialLength = Math.max(2, Math.min(initialLength, safeCols));
+ const snake = buildInitialSnake(safeCols, safeRows, safeInitialLength);
+
+ return {
+ cols: safeCols,
+ rows: safeRows,
+ initialLength: safeInitialLength,
+ snake,
+ direction: 'right',
+ queuedDirection: null,
+ food: spawnFood(snake, safeCols, safeRows, rng),
+ score: 0,
+ tick: 0,
+ phase
+ };
+}
+
+export function createSnakeGame(
+ config: SnakeGameConfig = {},
+ rng: RandomFn = Math.random
+): SnakeGameState {
+ return createBaseState(config, rng, 'idle');
+}
+
+export function startSnakeGame(state: SnakeGameState): SnakeGameState {
+ if (state.phase === 'playing' || state.phase === 'gameover') return state;
+ return { ...state, phase: 'playing' };
+}
+
+export function restartSnakeGame(
+ state: SnakeGameState,
+ rng: RandomFn = Math.random
+): SnakeGameState {
+ return createBaseState(
+ { cols: state.cols, rows: state.rows, initialLength: state.initialLength },
+ rng,
+ 'playing'
+ );
+}
+
+export function setSnakeDirection(
+ state: SnakeGameState,
+ direction: SnakeDirection
+): SnakeGameState {
+ if (state.phase !== 'playing') return state;
+ if (direction === state.direction || OPPOSITES[state.direction] === direction) {
+ return state;
+ }
+ return { ...state, queuedDirection: direction };
+}
+
+export function tickSnakeGame(state: SnakeGameState, rng: RandomFn = Math.random): SnakeGameState {
+ if (state.phase !== 'playing') return state;
+
+ const nextDirection = state.queuedDirection ?? state.direction;
+ const vector = DIRECTION_VECTORS[nextDirection];
+ const head = state.snake[0];
+ const nextHead = {
+ x: (head.x + vector.x + state.cols) % state.cols,
+ y: (head.y + vector.y + state.rows) % state.rows
+ };
+
+ const isEating = Boolean(state.food && isSamePosition(nextHead, state.food));
+ const collisionBody = isEating ? state.snake : state.snake.slice(0, -1);
+
+ if (collisionBody.some((part) => isSamePosition(part, nextHead))) {
+ return {
+ ...state,
+ direction: nextDirection,
+ queuedDirection: null,
+ phase: 'gameover'
+ };
+ }
+
+ const snake = [nextHead, ...state.snake];
+ if (!isEating) {
+ snake.pop();
+ }
+
+ let food = state.food;
+ let score = state.score;
+ let phase: SnakeGameState['phase'] = state.phase;
+
+ if (isEating) {
+ score += 1;
+ food = spawnFood(snake, state.cols, state.rows, rng);
+ if (!food) {
+ phase = 'gameover';
+ }
+ }
+
+ return {
+ ...state,
+ snake,
+ food,
+ score,
+ tick: state.tick + 1,
+ direction: nextDirection,
+ queuedDirection: null,
+ phase
+ };
+}
diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts
index 1f915c5..a374131 100644
--- a/src/lib/cards/index.ts
+++ b/src/lib/cards/index.ts
@@ -6,6 +6,7 @@ import { BlueskyPostCardDefinition } from './social/BlueskyPostCard';
import { BlueskyFeedCardDefinition } from './social/BlueskyFeedCard';
import { LatestBlueskyPostCardDefinition } from './social/LatestBlueskyPostCard';
import { DinoGameCardDefinition } from './games/DinoGameCard';
+import { SnakeGameCardDefinition } from './games/SnakeGameCard';
import { EmbedCardDefinition } from './media/EmbedCard';
import { TetrisCardDefinition } from './games/TetrisCard';
import { ImageCardDefinition } from './core/ImageCard';
@@ -71,6 +72,7 @@ export const AllCardDefinitions = [
SectionCardDefinition,
BlueskyMediaCardDefinition,
DinoGameCardDefinition,
+ SnakeGameCardDefinition,
BlueskyProfileCardDefinition,
GithubProfileCardDefitition,
TetrisCardDefinition,
--
2.51.2