尚未收集儲存庫資訊。
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗A multiplayer air hockey game for the browser, written in TypeScript with no game library and no runtime dependency. Players join with a name, get a colour of their own, and play in teams through three scenes, one per round: an ice rink with icebergs and sliding penguins, the sea with vortices and sharks, and an Arabian Nights desert with whirlwinds and genies.
airHockey() mounts the game into any element, inside its own Shadow DOM: React, Vue, Angular, Svelte or a plain page, nothing leaks in or out. Everything is drawn on one canvas, with no images, fonts or assets to serve.
| Entry | What it is | Size (gzip) |
|---|---|---|
air-hockey | The game client: rendering, input, prediction | ~15.7 KB |
air-hockey/local | Same-browser multiplayer, no server | ~6.6 KB |
air-hockey/server | Node WebSocket server (needs ws) | Node only |
The budgets (20 KB and 8 KB) are checked in CI on every pull request.
https://korial29.github.io/air-hockey/
Open the page in two or more tabs of the same browser, pick a name in each, and play. Add ?goals=2 to the first tab's URL for short rounds.
npm install air-hockey
The server entry also needs ws, an optional peer dependency:
npm install ws
import { airHockey } from 'air-hockey';
import { localSocket } from 'air-hockey/local';
const game = airHockey(document.getElementById('game')!, {
name: 'Ana',
socket: localSocket('my-room'),
});
Every tab that opens localSocket('my-room') joins the same game over a BroadcastChannel. One tab hosts the match, elected with navigator.locks; when it closes, another tab takes over. navigator.locks needs a secure context: on http:// from a LAN address, each tab plays alone.
// server.ts (Node)
import { serve } from 'air-hockey/server';
const stop = serve({ port: 8787 }); // or serve({ server: existingHttpServer })
// in the browser
import { airHockey } from 'air-hockey';
airHockey(document.getElementById('game')!, {
name: 'Ana',
url: 'wss://your-host.example/my-room',
});
One room per URL path: /my-room and /other are two separate games. A ready-made server ships as a command:
PORT=8787 npx air-hockey-server
The game fills the width of its container and keeps its aspect ratio (4:3, score band and rosters included). Give the container a width; to fit a screen, cap it by the height too:
#game {
max-width: calc((100dvh - 100px) * 4 / 3);
margin: 0 auto;
}
A full-screen button sits in the top-right corner of the game.
airHockey(container, options)Mounts the game and joins. Returns { on, destroy }.
| Option | Type | Default | Description |
|---|---|---|---|
name | string | — | Player name shown above the paddle (16 characters max). A taken name gets a suffix (Bob 2). |
url | string | — | WebSocket URL, room in the path. Ignored when socket is given. |
socket | SocketLike | — | Any WebSocket-like object, e.g. localSocket('room'). |
fullscreen | boolean | true | Show the full-screen button (hidden anyway where the browser does not allow it). |
on(event, callback) returns a function that removes the listener.
| Event | Payload | When |
|---|---|---|
open | — | Connected. |
close | — | Disconnected. |
roster | { you, players } | A player joined or left. players are { id, name, color, team }. |
score | [left, right] | The score of the round changed. |
goal | { team, score } | A goal was scored. |
match | MatchState | The phase changed: wait, ready (countdown), play, break, end. break and end carry the winner and the full ranking. |
game.on('match', m => {
if (m.phase === 'end') saveResults(m.ranking);
});
destroy()Leaves the game, stops rendering, removes every listener and the game's DOM. Safe to call twice.
localSocket(room, rules?) — air-hockey/localA same-browser socket for airHockey. rules apply to the match the tab hosts: pass the same ones from every tab.
serve({ port | server, rules? }) — air-hockey/serverStarts the WebSocket server and returns an async stop(). rules apply to every room.
Every field is optional; missing ones keep their default.
| Rule | Default | Description |
|---|---|---|
arena | ['ice', 'sea', 'desert'] | Scene, or scenes taking turns from one round to the next: 'ice', 'sea', 'desert', 'classic' (empty rink). |
rounds | 3 | Rounds in a match, all of them played, one scene each: the team that wins the most wins the match. |
goals | 5 | Goals that win a round. |
hit | 1 | Points for a hit, when the previous toucher was someone else. |
goal | 20 | Points for the scorer of a goal (own goals credit nobody). |
roundWin | 10 | Points for each player of the team that wins a round. |
matchWin | 30 | Points for each player of the team that wins the match. |
headStart | 1 | Uneven teams: goals the smaller team starts each round with, per missing player. |
readySec | 3 | "3, 2, 1, PLAY!" countdown before each round (0 turns it off). |
breakSec | 3 | Pause between rounds. |
endSec | 15 | Time the final podium stays on screen. |
serve({ port: 8787, rules: { arena: 'sea', goals: 5, rounds: 1 } });
Node 24 (see .nvmrc).
npm install
npm start # demo with hot reload (Vite)
npm run dev:server # WebSocket server on :8787
npm test # Vitest
npm run lint # Prettier, ESLint, tsc
npm run build # dist/: client, local, server, types
npm run size # gzip budgets
The demo reads ?name=, ?mode=local|server, ?url=, ?goals=, ?rounds= and ?arena= (for example desert, or ice,sea) from its URL, which makes testing several players in several tabs quick.
Pull requests need one release label (release:major, release:minor, release:patch or release:skip); merging one into master proposes the matching npm release. See .github/CI.md.