This commit is contained in:
Chris Wanstrath 2026-01-17 07:25:08 -08:00
commit 1557a14879
25 changed files with 1186 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

5
CLAUDE.md Normal file
View File

@ -0,0 +1,5 @@
Rules for Claude:
1. Don't write any comments.
That's it.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# 🔥 pyre
Cheeky web-based multiplayer game engine.

36
bun.lock Normal file
View File

@ -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=="],
}
}

34
example/bun.lock Normal file
View File

@ -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=="],
}
}

11
example/package.json Normal file
View File

@ -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"
}
}

View File

@ -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]
})
}

View File

@ -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>(game => {
render(game ? <Board game={game} /> : <h1>Loading...</h1>, root)
})

View File

@ -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 (
<Root>
<Indicator />
<Symbol>{props.symbol}</Symbol>
<Name
contenteditable={props.editable ? "true" : "false"}
spellcheck="false"
onKeyDown={props.editable ? onKeyDown : undefined}
>
{props.name}
</Name>
</Root>
)
},
})
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 (
<Root>
<Title>{props.title}</Title>
<Button onClick={() => window.location.href = '/'}>New Game</Button>
</Root>
)
},
})
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 <Root data-theme={currentTheme}>{props.children}</Root>
},
})
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 }) =>
<Player symbol="⭕️" game={game} name={game.names.o} player="o" active={game.turn === 'o'} editable={myPlayer === 'o'} />
const PlayerX = ({ game, myPlayer }: { game: Game, myPlayer: string }) =>
<Player symbol="❌" game={game} name={game.names.x} player="x" active={game.turn === 'x'} editable={myPlayer === 'x'} />
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 (
<Phone>
<GameContainer>
{game.winLine && (
<WinBanner
title={`${winnerName} wins!`}
player={game.winLine.player}
/>
)}
{player === 'x' && <PlayerO game={game} myPlayer={player} />}
{player === 'o' && <PlayerX game={game} myPlayer={player} />}
<BoardWrapper>
<Board>
<Tile onClick={tryMove(game, player, 0, 0)} player={game.board[0][0]}>{symbol(game.board[0][0])}</Tile>
<Tile onClick={tryMove(game, player, 0, 1)} player={game.board[0][1]}>{symbol(game.board[0][1])}</Tile>
<Tile onClick={tryMove(game, player, 0, 2)} col="last" player={game.board[0][2]}>{symbol(game.board[0][2])}</Tile>
<Tile onClick={tryMove(game, player, 1, 0)} player={game.board[1][0]}>{symbol(game.board[1][0])}</Tile>
<Tile onClick={tryMove(game, player, 1, 1)} player={game.board[1][1]}>{symbol(game.board[1][1])}</Tile>
<Tile onClick={tryMove(game, player, 1, 2)} col="last" player={game.board[1][2]}>{symbol(game.board[1][2])}</Tile>
<Tile onClick={tryMove(game, player, 2, 0)} row="last" player={game.board[2][0]}>{symbol(game.board[2][0])}</Tile>
<Tile onClick={tryMove(game, player, 2, 1)} row="last" player={game.board[2][1]}>{symbol(game.board[2][1])}</Tile>
<Tile onClick={tryMove(game, player, 2, 2)} row="last" col="last" player={game.board[2][2]}>{symbol(game.board[2][2])}</Tile>
</Board>
{game.winLine && (
<WinLine
direction={game.winLine.direction}
position={game.winLine.position}
player={game.winLine.player}
/>
)}
</BoardWrapper>
{player === 'x' && <PlayerX game={game} myPlayer={player} />}
{player === 'o' && <PlayerO game={game} myPlayer={player} />}
</GameContainer>
</Phone>
)
}

84
example/src/game.ts Normal file
View File

@ -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
}

22
example/src/types.ts Normal file
View File

@ -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<string, xo>
names: Record<xo, string>
winLine?: WinLine
}
type Phase =
| 'play'
| 'gameOver'
export type WinLine = {
direction: 'horizontal' | 'vertical' | 'diagonal' | 'diagonal-reverse'
position: 'top' | 'middle' | 'bottom' | 'left' | 'center' | 'right'
player: xo
}

20
example/tsconfig.json Normal file
View File

@ -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
}
}

22
package.json Normal file
View File

@ -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"
}
}

3
src/client.ts Normal file
View File

@ -0,0 +1,3 @@
export { setup } from '#setup'
export { send, gameId } from '#websocket'
export { sessionId } from '#session'

1
src/client/session.ts Normal file
View File

@ -0,0 +1 @@
export const sessionId = crypto.randomUUID()

13
src/client/setup.ts Normal file
View File

@ -0,0 +1,13 @@
import { initWebsocket, onUpdate } from '#websocket'
export function setup<G>(onRender: (game: G | undefined) => void) {
let game: G | undefined = undefined
onUpdate((newGame: G) => {
game = newGame
onRender(game)
})
initWebsocket()
onRender(game)
}

81
src/client/websocket.ts Normal file
View File

@ -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)
}

30
src/pages/index.tsx Normal file
View File

@ -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 () => <>
<html lang="en">
<head>
<title>hype</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<script dangerouslySetInnerHTML={{
__html: `
window.GIT_HASH = '${GIT_HASH}';
${(process.env.NODE_ENV !== 'production' || process.env.IS_PULL_REQUEST === 'true') ? 'window.DEBUG = true;' : ''}
`
}} />
</head>
<body>
<div id="viewport">
<main>
<div id="root" />
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
</main>
</div>
</body>
</html>
</>

122
src/server/action.ts Normal file
View File

@ -0,0 +1,122 @@
import type { Message } from '@types'
import { send, sendTo } from '$websocket'
// set by setup, called by server
export let dispatch = (ws: any, msg: Message) => { }
export let onPart = (session: string) => { }
type ActionFn<G> = (ctx: Context<G>, ...args: any[]) => G
export type Context<G> = {
session: string
gameId: string
game: G
}
type Hooks<G> = {
onJoin?: (game: G, session: string) => void
onPart?: (game: G, session: string) => void
}
export function setup<G>(template: G, hooks?: Hooks<G>) {
const actions = new Map<string, ActionFn<G>>()
const games = new Map<string, G>()
const players = new Map<string, Set<string>>() // gameId → sessionIds
const sessionToGame = new Map<string, string>() // session → gameId
const getGame = (gameId: string): G => {
if (!games.has(gameId)) {
games.set(gameId, structuredClone(template))
}
return games.get(gameId)!
}
const getPlayers = (gameId: string): Set<string> => {
if (!players.has(gameId)) {
players.set(gameId, new Set())
}
return players.get(gameId)!
}
const sendToPlayers = (gameId: string, msg: Message) =>
getPlayers(gameId).forEach(session => sendTo(session, msg))
const join = (ws: any, gameId: string, session: string) => {
sessionToGame.set(session, gameId)
getPlayers(gameId).add(session)
const game = getGame(gameId)
hooks?.onJoin?.(game, session)
send(ws, { type: 'game', game })
}
onPart = (session: string) => {
const gameId = sessionToGame.get(session)
if (!gameId) return
sessionToGame.delete(session)
getPlayers(gameId).delete(session)
hooks?.onPart?.(getGame(gameId), session)
}
const action = (name: string, fn: ActionFn<G>) => {
name = normalize(name)
if (actions.has(name))
throw new Error(`Action already defined: ${name}`)
actions.set(name, fn)
}
dispatch = (ws: any, msg: Message) => {
if (!msg.session) {
console.error('Message missing session:', msg)
return
}
if (msg.type === 'error') {
console.error(msg.msg)
return
}
if (msg.type === 'join') {
join(ws, msg.game, msg.session)
return
}
if (msg.type === 'action') {
const gameId = sessionToGame.get(msg.session)
if (!gameId) {
console.error('Session not in a game:', msg.session)
return
}
const fn = actions.get(normalize(msg.action))
if (!fn) {
console.error('Unknown action:', msg.action)
return
}
const game = getGame(gameId)
const ctx = {
session: msg.session,
gameId,
game,
}
sendToPlayers(gameId, {
type: 'game',
game: fn(ctx, ...msg.args)
})
}
}
return action
}
// turn:end -> turnEnd
export function normalize(name: string): string {
return name
.split(':')
.map((part, i) => i === 0 ? part : part[0]!.toUpperCase() + part.slice(1))
.join('')
}

View File

@ -0,0 +1,83 @@
import { writeFileSync as writeFile, readFileSync as readFile, existsSync as exists } from 'fs'
import { watch } from 'fs'
import { join } from 'path'
import { parseActions } from '$actionParser'
import { normalize } from '$action'
const srcDir = join(process.cwd(), 'src')
generateActions()
let timeout: Timer | null = null
const watcher = watch(srcDir, (event, filename) => {
if (filename === 'game.ts' && event === 'change') {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
generateActions()
timeout = null
}, 1000)
}
})
import.meta.hot?.dispose(() => {
watcher.close()
if (timeout) clearTimeout(timeout)
})
export function generateActions() {
const actions = parseActions(join(srcDir, 'game.ts'))
const generated: string[] = []
generated.push(`// DO NOT MODIFY!\n`)
generated.push(`// This file is generated by pyre.\n`)
generated.push(`// Generated: ${new Date().toLocaleString('sv-SE')}.\n`)
generated.push(`\n`)
generated.push(`import { send } from 'pyre/client'\n`)
for (const [name, params] of Object.entries(actions)) {
generated.push(`
export const ${normalize(name)} = (${paramsToString(params)}) => {
send({
type: 'action',
action: '${name}',
args: [${argsToString(params)}]
})
}
`)
}
const generatedActions = generated.join('')
const actionsPath = join(srcDir, 'client', 'actions.ts')
if (exists(actionsPath)) {
const existing = readFile(actionsPath, 'utf-8')
if (stripTimestamp(existing) === stripTimestamp(generatedActions)) return
}
writeFile(actionsPath, generatedActions)
console.log('Wrote', actionsPath)
}
function stripTimestamp(content: string): string {
return content.replace(/^\/\/ Generated:.*\n/m, '')
}
function paramsToString(params: [string, string][]): string {
const out: string[] = []
for (const [param, typeName] of params)
out.push(`${param}: ${typeName}`)
return out.join(', ')
}
function argsToString(params: [string, string][]): string {
const out: string[] = []
for (const [param] of params)
out.push(param)
return out.join(', ')
}

View File

@ -0,0 +1,56 @@
import ts from 'typescript'
export type ActionParams = [name: string, type: string][]
export type ActionMap = Record<string, ActionParams>
let prevProgram: ts.Program | undefined
export function parseActions(filePath: string): ActionMap {
const program = ts.createProgram([filePath], {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
noLib: true,
types: [],
skipDefaultLibCheck: true,
skipLibCheck: true
}, undefined, prevProgram)
prevProgram = program
const sourceFile = program.getSourceFile(filePath)
if (!sourceFile) return {}
const actions: ActionMap = {}
ts.forEachChild(sourceFile, (node) => {
if (!ts.isExpressionStatement(node)) return
if (!ts.isCallExpression(node.expression)) return
const call = node.expression
if (!ts.isIdentifier(call.expression)) return
if (call.expression.text !== 'action') return
const [nameArg, callbackArg] = call.arguments
if (!nameArg || !ts.isStringLiteral(nameArg)) return
if (!callbackArg) return
const actionName = nameArg.text
let fn: ts.FunctionLikeDeclaration | undefined
if (ts.isFunctionExpression(callbackArg) || ts.isArrowFunction(callbackArg)) {
fn = callbackArg
}
if (!fn) return
// Skip first param (ctx: Context)
const params = fn.parameters.slice(1)
actions[actionName] = params.map((p) => {
const name = p.name.getText(sourceFile)
const type = p.type ? p.type.getText(sourceFile) : 'unknown'
return [name, type]
})
})
return actions
}

65
src/server/index.tsx Normal file
View File

@ -0,0 +1,65 @@
import { upgradeWebSocket, websocket } from 'hono/bun'
import { Hype } from 'hype'
import type { Message } from '@types'
import { send, addWebsocket, removeWebsocket, closeWebsockets } from '$websocket'
import { dispatch, onPart } from '$action'
import IndexPage from '../pages/index'
export { setup } from '$action'
export type { Context } from '$action'
import '$actionCodegen'
const app = new Hype({ layout: false })
// GET / → redirect to new game
app.get('/', c => {
const gameId = crypto.randomUUID().slice(0, 8)
return c.redirect(`/${gameId}`)
})
app.get('/ws', c => {
const sessionId = c.req.query('session')
return upgradeWebSocket(c, {
async onOpen(_e, ws) {
if (!sessionId) throw new Error('missing sessionId')
addWebsocket(sessionId, ws)
},
async onMessage(event, ws) {
if (!sessionId) throw new Error('missing sessionId')
let data: Message | undefined
try {
data = JSON.parse(event.data.toString())
} catch (e) {
console.error('JSON parsing error', e)
send(ws, { type: 'error', msg: 'json parsing error' })
return
}
if (!data) return
console.log('<- receive', data)
dispatch(ws, data)
},
onClose: () => {
if (sessionId) {
onPart(sessionId)
removeWebsocket(sessionId)
}
}
})
})
app.get('/:gameId', c => c.html(<IndexPage />))
import.meta.hot.dispose(() => {
closeWebsockets()
})
export default {
websocket,
...app.defaults,
}

31
src/server/websocket.ts Normal file
View File

@ -0,0 +1,31 @@
import type { Message } from '@types'
const wsConnections = new Map<string, any>()
export function send(ws: any, msg: Message) {
console.log("-> send", msg)
ws.send(JSON.stringify(msg))
}
export function sendTo(session: string, msg: Message) {
const ws = wsConnections.get(session)
if (ws) send(ws, msg)
else console.error('sendTo session not found:', session)
}
export function sendAll(msg: Message) {
wsConnections.forEach(ws => send(ws, msg))
}
export function addWebsocket(session: string, ws: any) {
wsConnections.set(session, ws)
}
export function removeWebsocket(session: string) {
wsConnections.delete(session)
}
export function closeWebsockets() {
wsConnections.forEach(conn => conn.close())
wsConnections.clear()
}

30
src/shared/types.ts Normal file
View File

@ -0,0 +1,30 @@
export type Message =
| ErrorMessage
| JoinMessage
| ActionMessage
| GameMessage
export type ErrorMessage = {
type: 'error'
session?: string
msg: string
}
export type JoinMessage = {
type: 'join'
session?: string
game: string
}
export type ActionMessage = {
type: 'action'
session?: string
action: string
args: any[]
}
export type GameMessage = {
type: 'game'
session?: string
game: unknown
}

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"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,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}