├── .gitignore ├── bun.lockb ├── src ├── layouts │ ├── index.css │ └── root.tsx ├── styles.d.ts ├── pages │ ├── board.tsx │ └── home.tsx ├── index.tsx └── snake-logic.ts ├── postcss.config.js ├── tsconfig.json ├── tailwind.config.js ├── vite.config.ts ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petter/htmx-snake/HEAD/bun.lockb -------------------------------------------------------------------------------- /src/layouts/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/styles.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css?url" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "hono/jsx" 6 | } 7 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{js,jsx,ts,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import devServer from "@hono/vite-dev-server"; 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | devServer({ 7 | entry: "src/index.tsx", 8 | }), 9 | ], 10 | }); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "dev": "vite" 5 | }, 6 | "dependencies": { 7 | "hono": "^4.2.2" 8 | }, 9 | "devDependencies": { 10 | "@hono/vite-dev-server": "^0.10.0", 11 | "@types/bun": "latest", 12 | "autoprefixer": "^10.4.19", 13 | "postcss": "^8.4.38", 14 | "tailwindcss": "^3.4.3", 15 | "vite": "^5.2.8" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTMX + Bun + Hono + TailwindCSS 2 | 3 | To get started with this template run the following command 4 | 5 | ```shell 6 | bun x degit github:petter/htmx-bun-hono-template 7 | ``` 8 | 9 | ## Develop 10 | 11 | To install dependencies: 12 | 13 | ```sh 14 | bun install 15 | ``` 16 | 17 | To run: 18 | 19 | ```sh 20 | bun run dev 21 | ``` 22 | 23 | open http://localhost:3000 24 | -------------------------------------------------------------------------------- /src/layouts/root.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./index.css?url"; 2 | 3 | export function RootLayout({ 4 | children, 5 | }: { 6 | children: JSX.Element | Iterable; 7 | }) { 8 | return ( 9 | 10 | 11 | My App 12 | 13 | 14 | 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/board.tsx: -------------------------------------------------------------------------------- 1 | import type { Snake } from "../snake-logic"; 2 | 3 | interface Props { 4 | snakes: Array; 5 | boardSize: number; 6 | } 7 | 8 | export function Board({ snakes, boardSize }: Props) { 9 | const snakePositions = Object.fromEntries( 10 | Object.values(snakes).flatMap((s) => 11 | s.snake.map(([x, y]) => [`${x},${y}`, s.color]) 12 | ) 13 | ); 14 | 15 | return ( 16 |
17 | {new Array(boardSize).fill(0).map((_, y) => ( 18 |
19 | {new Array(boardSize).fill(0).map((_, x) => ( 20 |
26 | ))} 27 |
28 | ))} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/pages/home.tsx: -------------------------------------------------------------------------------- 1 | import { RootLayout } from "../layouts/root"; 2 | 3 | export function HomePage() { 4 | return ( 5 | 6 |
7 |
8 |
9 |
10 |
11 | {[ 12 | { dir: "Up", icon: "^" }, 13 | { dir: "Down", icon: "v" }, 14 | { dir: "Left", icon: "<" }, 15 | { dir: "Right", icon: ">" }, 16 | ].map(({ dir, icon }) => ( 17 | 27 | ))} 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { streamSSE } from "hono/streaming"; 3 | 4 | import { HomePage } from "./pages/home"; 5 | import { Board } from "./pages/board"; 6 | import { SnakeEngine } from "./snake-logic"; 7 | 8 | const app = new Hono(); 9 | 10 | const engine = new SnakeEngine(); 11 | 12 | async function gameLoop() { 13 | while (true) { 14 | engine.step(); 15 | await new Promise((r) => setTimeout(r, 150)); 16 | } 17 | } 18 | 19 | gameLoop(); 20 | 21 | app.get("/", (c) => { 22 | return c.html(); 23 | }); 24 | 25 | (["up", "down", "left", "right"] as const).forEach((dir) => { 26 | app.post(`/${dir}`, async (c) => { 27 | const body = await c.req.parseBody(); 28 | const snakeId = +body["snakeId"]; 29 | engine.registerKey(snakeId, dir); 30 | return c.text("ok"); 31 | }); 32 | }); 33 | 34 | app.get("/snake", (c) => { 35 | let tick = 0; 36 | let dead = false; 37 | 38 | const colors = ["bg-red-500", "bg-blue-500", "bg-green-500", "bg-yellow-500"]; 39 | const mySnake = engine.addSnake( 40 | colors[Math.floor(Math.random() * colors.length)] 41 | ); 42 | 43 | return streamSSE(c, async (stream) => { 44 | function subscriber() { 45 | stream.writeSSE({ 46 | event: "tick", 47 | data: String( 48 | <> 49 | 50 | 54 | 55 | ), 56 | id: String(tick++), 57 | }); 58 | } 59 | 60 | stream.onAbort(() => { 61 | engine.unsubscribe(subscriber); 62 | engine.removeSnake(mySnake); 63 | }); 64 | 65 | engine.subscribe(subscriber); 66 | }); 67 | }); 68 | 69 | export default app; 70 | -------------------------------------------------------------------------------- /src/snake-logic.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "node:events"; 2 | 3 | type Dir = "up" | "down" | "left" | "right"; 4 | type Pos = [number, number]; 5 | 6 | export interface Snake { 7 | id: number; 8 | color: string; 9 | dir: Pos; 10 | snake: Array; 11 | keyBuffer: Array; 12 | } 13 | 14 | export class SnakeEngine { 15 | gridSize = 20; 16 | initialSnakeLength = 3; 17 | private emitter = new EventEmitter(); 18 | private nextSnakeId = 0; 19 | private _snakes: Record = {}; 20 | get snakes() { 21 | return this._snakes; 22 | } 23 | 24 | subscribe = (cb: () => void) => { 25 | this.emitter.on("tick", cb); 26 | }; 27 | 28 | unsubscribe = (cb: () => void) => { 29 | this.emitter.off("tick", cb); 30 | }; 31 | 32 | addSnake = (color: string): number => { 33 | const id = this.nextSnakeId++; 34 | const newSnake = { 35 | id, 36 | color, 37 | dir: [1, 0] as Pos, 38 | snake: Array(this.initialSnakeLength) 39 | .fill(0) 40 | .map( 41 | (_, i) => [this.initialSnakeLength - i - 1, 0] as Pos 42 | ) as Array, 43 | keyBuffer: [], 44 | }; 45 | this._snakes[id] = newSnake; 46 | return id; 47 | }; 48 | 49 | removeSnake = (id: number) => { 50 | delete this._snakes[id]; 51 | }; 52 | 53 | registerKey = (id: number, dir: Dir) => { 54 | this._snakes[id].keyBuffer.push(dir); 55 | }; 56 | 57 | step = () => { 58 | Object.entries(this._snakes).map(([id, snake]) => { 59 | const pressedKey = snake.keyBuffer.shift(); 60 | snake.dir = this.calcNextSnakeDir(snake, pressedKey); 61 | snake.snake = this.calcNextSnake(snake); 62 | }); 63 | 64 | this.emitter.emit("tick"); 65 | }; 66 | 67 | private calcNextSnakeDir = ( 68 | snake: Snake, 69 | requestDir: Dir | undefined 70 | ): Pos => { 71 | const curDir = snake.dir; 72 | if (!requestDir) { 73 | return curDir; 74 | } 75 | 76 | const curMoveAxis = curDir[0] === 0 ? "vertical" : "horizontal"; 77 | const requestMoveAxis = 78 | requestDir === "up" || requestDir === "down" ? "vertical" : "horizontal"; 79 | if (curMoveAxis === requestMoveAxis) { 80 | return curDir; 81 | } 82 | 83 | switch (requestDir) { 84 | case "up": 85 | return [0, -1]; 86 | case "down": 87 | return [0, 1]; 88 | case "left": 89 | return [-1, 0]; 90 | case "right": 91 | return [1, 0]; 92 | } 93 | }; 94 | 95 | private calcNextSnake = (snake: Snake): Array => { 96 | const [dx, dy] = snake.dir; 97 | const [hx, hy] = snake.snake[0]; 98 | const newHead: [number, number] = [ 99 | (hx + dx) % this.gridSize, 100 | (hy + dy) % this.gridSize, 101 | ]; 102 | if (newHead[0] < 0) newHead[0] = this.gridSize - 1; 103 | if (newHead[1] < 0) newHead[1] = this.gridSize - 1; 104 | return [newHead, ...snake.snake.slice(0, -1)]; 105 | }; 106 | } 107 | --------------------------------------------------------------------------------