From 1557a148794b1751a7d7efc99ab391461655768f Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 17 Jan 2026 07:25:08 -0800 Subject: [PATCH] FIRE --- .gitignore | 1 + CLAUDE.md | 5 + README.md | 3 + bun.lock | 36 ++++ example/bun.lock | 34 ++++ example/package.json | 11 + example/src/client/actions.ts | 21 ++ example/src/client/app.tsx | 10 + example/src/client/board.tsx | 373 ++++++++++++++++++++++++++++++++++ example/src/game.ts | 84 ++++++++ example/src/types.ts | 22 ++ example/tsconfig.json | 20 ++ package.json | 22 ++ src/client.ts | 3 + src/client/session.ts | 1 + src/client/setup.ts | 13 ++ src/client/websocket.ts | 81 ++++++++ src/pages/index.tsx | 30 +++ src/server/action.ts | 122 +++++++++++ src/server/actionCodegen.ts | 83 ++++++++ src/server/actionParser.ts | 56 +++++ src/server/index.tsx | 65 ++++++ src/server/websocket.ts | 31 +++ src/shared/types.ts | 30 +++ tsconfig.json | 29 +++ 25 files changed, 1186 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 example/bun.lock create mode 100644 example/package.json create mode 100644 example/src/client/actions.ts create mode 100644 example/src/client/app.tsx create mode 100644 example/src/client/board.tsx create mode 100644 example/src/game.ts create mode 100644 example/src/types.ts create mode 100644 example/tsconfig.json create mode 100644 package.json create mode 100644 src/client.ts create mode 100644 src/client/session.ts create mode 100644 src/client/setup.ts create mode 100644 src/client/websocket.ts create mode 100644 src/pages/index.tsx create mode 100644 src/server/action.ts create mode 100644 src/server/actionCodegen.ts create mode 100644 src/server/actionParser.ts create mode 100644 src/server/index.tsx create mode 100644 src/server/websocket.ts create mode 100644 src/shared/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..191dd1c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Rules for Claude: + +1. Don't write any comments. + +That's it. diff --git a/README.md b/README.md new file mode 100644 index 0000000..358cb10 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# 🔥 pyre + +Cheeky web-based multiplayer game engine. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..b1177c0 --- /dev/null +++ b/bun.lock @@ -0,0 +1,36 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "pyre", + "dependencies": { + "hype": "git+https://git.nose.space/defunkt/hype", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "hono": "*", + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + + "hype": ["hype@git+https://git.nose.space/defunkt/hype#33d228c9ac6a01fad570e0ac2ba836a100dde623", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "33d228c9ac6a01fad570e0ac2ba836a100dde623"], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/example/bun.lock b/example/bun.lock new file mode 100644 index 0000000..bc09c53 --- /dev/null +++ b/example/bun.lock @@ -0,0 +1,34 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "pyre-example-tictactoe", + "dependencies": { + "forge": "git+https://git.nose.space/defunkt/forge", + "pyre": "file:..", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "forge": ["forge@git+https://git.nose.space/defunkt/forge#e772e0e7115750bcd11a6a1daae171ebd89a17bb", { "peerDependencies": { "typescript": "^5" } }, "e772e0e7115750bcd11a6a1daae171ebd89a17bb"], + + "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + + "hype": ["hype@git+https://git.nose.space/defunkt/hype#52086f4eb94cc36ecd9470d9a101ff01002687c8", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "52086f4eb94cc36ecd9470d9a101ff01002687c8"], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "pyre": ["pyre@file:..", { "dependencies": { "hype": "git+https://git.nose.space/defunkt/hype" }, "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "hono": "*", "typescript": "^5" } }], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/example/package.json b/example/package.json new file mode 100644 index 0000000..3226f41 --- /dev/null +++ b/example/package.json @@ -0,0 +1,11 @@ +{ + "name": "pyre-example-tictactoe", + "type": "module", + "scripts": { + "dev": "bun run --hot src/game.ts" + }, + "dependencies": { + "pyre": "file:..", + "forge": "git+https://git.nose.space/defunkt/forge" + } +} diff --git a/example/src/client/actions.ts b/example/src/client/actions.ts new file mode 100644 index 0000000..6e2c34c --- /dev/null +++ b/example/src/client/actions.ts @@ -0,0 +1,21 @@ +// DO NOT MODIFY! +// This file is generated by pyre. +// Generated: 2026-01-20 12:25:24. + +import { send } from 'pyre/client' + +export const nameChange = (name: string) => { + send({ + type: 'action', + action: 'name:change', + args: [name] + }) +} + +export const move = (x: number, y: number) => { + send({ + type: 'action', + action: 'move', + args: [x, y] + }) +} diff --git a/example/src/client/app.tsx b/example/src/client/app.tsx new file mode 100644 index 0000000..423fb09 --- /dev/null +++ b/example/src/client/app.tsx @@ -0,0 +1,10 @@ +import { render } from 'hono/jsx/dom' +import { setup } from 'pyre/client' +import type { Game } from '../types' +import Board from './board' + +const root = document.getElementById('root')! + +setup(game => { + render(game ? :

Loading...

, root) +}) diff --git a/example/src/client/board.tsx b/example/src/client/board.tsx new file mode 100644 index 0000000..7908881 --- /dev/null +++ b/example/src/client/board.tsx @@ -0,0 +1,373 @@ +import { define, createThemes } from 'forge' +import { move, nameChange } from './actions' +import type { Game } from '../types' +import { sessionId } from 'pyre/client' + +const theme = createThemes({ + light: { + bg: '#fff', + fg: '#333', + fgActive: '#000', + border: '#333', + hoverBg: 'rgba(0, 0, 0, 0.05)', + inputBg: '#fff', + inputBorder: '#999', + }, + dark: { + bg: '#1a1a1a', + fg: '#aaa', + fgActive: '#fff', + border: '#555', + hoverBg: 'rgba(255, 255, 255, 0.1)', + inputBg: '#333', + inputBorder: '#666', + }, +}) + +const Player = define('Player', { + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '12px 20px', + + parts: { + Symbol: { + fontSize: 32, + }, + Indicator: { + width: 8, + height: 8, + borderRadius: '50%', + background: '#4ade80', + opacity: 0, + transition: 'opacity 0.2s ease', + }, + Name: { + base: 'span', + fontSize: 18, + fontWeight: 500, + color: theme('fg'), + padding: '2px 6px', + borderRadius: 4, + border: '1px solid transparent', + outline: 'none', + minWidth: 60, + }, + }, + + variants: { + active: { + parts: { + Name: { + color: theme('fgActive'), + }, + Indicator: { + opacity: 1, + }, + }, + }, + editable: { + parts: { + Name: { + cursor: 'text', + states: { + hover: { + background: theme('hoverBg'), + }, + focus: { + border: `1px solid ${theme('inputBorder')}`, + background: theme('inputBg'), + }, + }, + }, + }, + }, + player: { + o: { + parts: { + Symbol: { + filter: 'sepia(1) saturate(5) hue-rotate(180deg) brightness(0.9)', + }, + }, + }, + }, + }, + + render({ props, parts: { Root, Symbol, Indicator, Name } }) { + const onKeyDown = (e: KeyboardEvent) => { + if (!e.target) return + const target = (e.target as HTMLSpanElement) + + if (e.key === 'Enter') { + e.preventDefault() + const newName = target.textContent || `Player ${props.player.toUpperCase()}` + nameChange(newName) + target.blur() + } else if (e.key === 'Escape') { + e.preventDefault() + target.blur() + } + } + + return ( + + + {props.symbol} + + {props.name} + + + ) + }, +}) + +const Board = define('Board', { + display: 'inline-grid', + gridTemplateColumns: 'repeat(3, 1fr)', + position: 'relative', +}) + +const Tile = define('Tile', { + base: 'button', + width: 120, + height: 120, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 60, + background: 'transparent', + border: 'none', + borderRight: `3px solid ${theme('border')}`, + borderBottom: `3px solid ${theme('border')}`, + cursor: 'pointer', + transition: 'background 0.15s ease', + + states: { + hover: { + background: theme('hoverBg'), + }, + }, + + variants: { + col: { + last: { borderRight: 'none' }, + }, + row: { + last: { borderBottom: 'none' }, + }, + player: { + o: { filter: 'sepia(1) saturate(5) hue-rotate(180deg) brightness(0.9)' }, + }, + }, +}) + +const WinLine = define('WinLine', { + position: 'absolute', + borderRadius: 4, + + variants: { + direction: { + diagonal: { + width: '140%', + height: 6, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%) rotate(45deg)', + }, + 'diagonal-reverse': { + width: '140%', + height: 6, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%) rotate(-45deg)', + }, + horizontal: { + width: '100%', + height: 6, + left: 0, + transform: 'translateY(-50%)', + }, + vertical: { + width: 6, + height: '100%', + top: 0, + transform: 'translateX(-50%)', + }, + }, + position: { + top: { top: 60 }, + middle: { top: '50%' }, + bottom: { top: 300 }, + left: { left: 60 }, + center: { left: '50%' }, + right: { left: 300 }, + }, + player: { + x: { background: '#e74c3c' }, + o: { background: '#3498db' }, + }, + }, +}) + +const WinBanner = define('WinBanner', { + position: 'absolute', + top: 24, + left: '50%', + transform: 'translateX(-50%)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 12, + padding: '16px 32px', + borderRadius: 12, + background: theme('bg'), + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + zIndex: 10, + + parts: { + Title: { + fontSize: 20, + fontWeight: 'bold', + color: theme('fgActive'), + }, + Button: { + base: 'button', + fontSize: 14, + fontWeight: 600, + padding: '8px 20px', + borderRadius: 6, + border: 'none', + cursor: 'pointer', + }, + }, + + variants: { + player: { + x: { + parts: { + Button: { + background: '#e74c3c', + color: 'white', + }, + }, + }, + o: { + parts: { + Button: { + background: '#3498db', + color: 'white', + }, + }, + }, + }, + }, + + render({ props, parts: { Root, Title, Button } }) { + return ( + + {props.title} + + + ) + }, +}) + +const darkQuery = window.matchMedia('(prefers-color-scheme: dark)') +const getTheme = () => darkQuery.matches ? 'dark' : 'light' +let currentTheme = getTheme() + +darkQuery.addEventListener('change', () => { + currentTheme = getTheme() + document.querySelector('[data-theme]')?.setAttribute('data-theme', currentTheme) +}) + +const Phone = define('Phone', { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 'min(100vw - 40px, (100vh - 40px) * 9 / 19.5)', + height: 'min(100vh - 40px, (100vw - 40px) * 19.5 / 9)', + overflow: 'hidden', + + render({ props, parts: { Root } }) { + return {props.children} + }, +}) + +const GameContainer = define('Game', { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 16, + width: '100%', + height: '100%', +}) + +const BoardWrapper = define('BoardWrapper', { + position: 'relative', +}) + +const symbol = (tile: string): string => + tile === 'x' ? '❌' : tile === 'o' ? '⭕️' : '' + +const PlayerO = ({ game, myPlayer }: { game: Game, myPlayer: string }) => + + +const PlayerX = ({ game, myPlayer }: { game: Game, myPlayer: string }) => + + +const tryMove = (game: Game, player: 'x' | 'o', x: number, y: number) => { + return game.turn === player && game.phase === 'play' ? () => move(x, y) : () => { } +} + +export default ({ game }: { game: Game }) => { + const player = game.players[sessionId] + if (!player) throw new Error(`no player for session: ${sessionId}`) + + const winnerName = game.winLine?.player === 'x' ? game.names.x : game.names.o + + return ( + + + {game.winLine && ( + + )} + {player === 'x' && } + {player === 'o' && } + + + {symbol(game.board[0][0])} + {symbol(game.board[0][1])} + {symbol(game.board[0][2])} + + {symbol(game.board[1][0])} + {symbol(game.board[1][1])} + {symbol(game.board[1][2])} + + {symbol(game.board[2][0])} + {symbol(game.board[2][1])} + {symbol(game.board[2][2])} + + {game.winLine && ( + + )} + + {player === 'x' && } + {player === 'o' && } + + + ) +} diff --git a/example/src/game.ts b/example/src/game.ts new file mode 100644 index 0000000..ffc1326 --- /dev/null +++ b/example/src/game.ts @@ -0,0 +1,84 @@ +import { setup } from 'pyre/server' +export { default } from 'pyre/server' + +import type { Game, WinLine, xo } from './types' + +const game: Game = { + phase: 'play', + turn: 'x', + players: {}, + names: { x: 'Player X', o: 'Player O' }, + board: [ + ['', '', ''], + ['', '', ''], + ['', '', ''], + ] +} + +const action = setup(game, { + onJoin(game, session) { + if (game.players[session]) return + + const player = Object.values(game.players).length === 0 + ? 'x' + : 'o' + + game.players[session] = player + }, + + onPart(game, session) { + delete game.players[session] + } +}) + +action('name:change', (ctx, name: string) => { + const game = ctx.game as Game + const player = game.players[ctx.session] + + if (!player) throw new Error(`no player for session: ${ctx.session}`) + + game.names[player] = name + + return game +}) + +action('move', (ctx, x: number, y: number) => { + const game = ctx.game as Game + if (game.phase !== 'play') return game + const player = game.players[ctx.session] + + if (!player) throw new Error(`no player for session: ${ctx.session}`) + + if (game.board[x] === undefined || game.board[x][y] === undefined) + throw new Error(`x,y not in board: ${x},${y}`) + + if (game.turn !== player) + throw new Error('not your turn!') + + game.board[x][y] = player + game.turn = game.turn === 'x' ? 'o' : 'x' + game.winLine = checkWin(game.board, player) + if (game.winLine) game.phase = 'gameOver' + + return game +}) + +const rowPositions = ['top', 'middle', 'bottom'] as const +const colPositions = ['left', 'center', 'right'] as const + +function checkWin(board: string[][], player: xo): WinLine | undefined { + for (let i = 0; i < 3; i++) + if (board[i]![0] === player && board[i]![1] === player && board[i]![2] === player) + return { direction: 'horizontal', position: rowPositions[i]!, player } + + for (let i = 0; i < 3; i++) + if (board[0]![i] === player && board[1]![i] === player && board[2]![i] === player) + return { direction: 'vertical', position: colPositions[i]!, player } + + if (board[0]![0] === player && board[1]![1] === player && board[2]![2] === player) + return { direction: 'diagonal', position: 'middle', player } + if (board[0]![2] === player && board[1]![1] === player && board[2]![0] === player) + return { direction: 'diagonal-reverse', position: 'middle', player } + + return undefined +} diff --git a/example/src/types.ts b/example/src/types.ts new file mode 100644 index 0000000..56d7005 --- /dev/null +++ b/example/src/types.ts @@ -0,0 +1,22 @@ +export type xo = 'x' | 'o' +type Tile = xo | '' +type Row = [Tile, Tile, Tile] + +export type Game = { + phase: Phase + turn: xo + board: [Row, Row, Row] + players: Record + names: Record + winLine?: WinLine +} + +type Phase = + | 'play' + | 'gameOver' + +export type WinLine = { + direction: 'horizontal' | 'vertical' | 'diagonal' | 'diagonal-reverse' + position: 'top' | 'middle' | 'bottom' | 'left' | 'center' | 'right' + player: xo +} diff --git a/example/tsconfig.json b/example/tsconfig.json new file mode 100644 index 0000000..a560c69 --- /dev/null +++ b/example/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d553e1c --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "pyre", + "module": "src/index.ts", + "type": "module", + "exports": { + "./client": "./src/client.ts", + "./server": "./src/server/index.tsx" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hype": "git+https://git.nose.space/defunkt/hype" + }, + "scripts": { + "start": "bun run src/server/index.tsx", + "dev": "bun run --hot src/server/index.tsx" + } +} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..5111dc7 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,3 @@ +export { setup } from '#setup' +export { send, gameId } from '#websocket' +export { sessionId } from '#session' diff --git a/src/client/session.ts b/src/client/session.ts new file mode 100644 index 0000000..5e7139c --- /dev/null +++ b/src/client/session.ts @@ -0,0 +1 @@ +export const sessionId = crypto.randomUUID() diff --git a/src/client/setup.ts b/src/client/setup.ts new file mode 100644 index 0000000..b312919 --- /dev/null +++ b/src/client/setup.ts @@ -0,0 +1,13 @@ +import { initWebsocket, onUpdate } from '#websocket' + +export function setup(onRender: (game: G | undefined) => void) { + let game: G | undefined = undefined + + onUpdate((newGame: G) => { + game = newGame + onRender(game) + }) + + initWebsocket() + onRender(game) +} diff --git a/src/client/websocket.ts b/src/client/websocket.ts new file mode 100644 index 0000000..f5b436e --- /dev/null +++ b/src/client/websocket.ts @@ -0,0 +1,81 @@ +import type { Message } from '@types' +import { sessionId } from '#session' + +const MAX_RETRIES = 5 +let retries = 0 +let connected = false +let msgQueue: Message[] = [] + +let ws: WebSocket | null = null + +export const gameId = location.pathname.slice(1) + +export function initWebsocket() { + const url = new URL(`/ws?session=${sessionId}`, location.href) + url.protocol = url.protocol.replace('http', 'ws') + ws = new WebSocket(url) + + ws.onmessage = receive + ws.onclose = retryConnection + ws.onerror = () => ws?.close() + ws.onopen = () => { + connected = true + send({ type: 'join', game: gameId }) + msgQueue.forEach(send) + msgQueue.length = 0 + } +} + +export function send(msg: Message) { + if (!connected) { + msgQueue.push(msg) + initWebsocket() + return + } + + if (!(msg as any).session) (msg as any).session = sessionId + + if (ws?.readyState === 1) ws.send(JSON.stringify(msg)) + console.log("-> send", msg) +} + +async function receive(e: MessageEvent) { + const data = JSON.parse(e.data) as Message + console.log("<- receive", data) + dispatch(data) +} + +export function close() { + ws?.close(1000, 'bye') +} + +function retryConnection() { + connected = false + + if (retries >= MAX_RETRIES) { + console.error(`Failed to reconnect ${retries} times. Server is down.`) + if (ws) ws.onclose = () => { } + return + } + retries++ + console.error(`Connection lost. Retrying...`) + setTimeout(initWebsocket, 2000) +} + +function dispatch(msg: Message) { + switch (msg.type) { + case 'game': + onUpdates.forEach(cb => cb(msg.game as any)) + break + default: + console.error('dispatch: unknown message type:', msg.type) + + } +} + +const onUpdates: UpdateCallback[] = [] +type UpdateCallback = (game: any) => void + +export function onUpdate(cb: UpdateCallback) { + onUpdates.push(cb) +} \ No newline at end of file diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..0df9781 --- /dev/null +++ b/src/pages/index.tsx @@ -0,0 +1,30 @@ +import { $ } from 'bun' + +const GIT_HASH = process.env.RENDER_GIT_COMMIT?.slice(0, 7) + || await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'unknown') + +export default () => <> + + + hype + + + + +