UNO Multiplayer Mini‑Game

First Post:

Last Update:

Word Count:
6.4k

Read Time:
38 min

Page View: loading...

我自己都没看懂 感觉运行完有些bug

UNO Multiplayer Mini‑Game (Browser + Node/WebSocket)

This project gives you a lightweight, self‑hostable UNO game that you can play with friends in real time. It uses:

  • Node.js + Express + ws for a simple WebSocket backend (in‑memory rooms).
  • Vanilla HTML/CSS/JS on the client (no build step required).
  • Room code invite: Share the room URL or 6‑char room code.
  • Standard UNO rules (core set) with user‑selectable house rules.

Good to know: This is a teaching/demo implementation — easy to read, hack, and extend. It is not production hardened (no auth, persistence, or cheating prevention beyond the bare minimum). Perfect for LAN play, study groups, or small friend circles.


1. Features at a Glance

Feature Included Notes
108‑card official deck 0×1 per color; 1‑9×2; Skip/Reverse/Draw2×2 each; 4 Wild; 4 Wild Draw4
Draw pile & discard pile Auto reshuffle when draw pile empty
Turn order & direction Reverse flips direction (2 players: Reverse = Skip)
Skip Next player skipped
Draw Two Next player draws 2 & skips turn
Wild Player chooses color
Wild Draw Four Player chooses color, next draws 4 & skips turn (no challenge in core version)
Call UNO Must click before confirming play when 1 card left; auto‑penalty if missed
Auto penalty for failure to call UNO +2 cards if next player plays and you hadn’t called UNO
House rules (optional toggles) ⚙️ Allow stacking Draws, 7‑swap, 0‑rotate — off by default
In‑game chat Quick text chat per room
Spectator mode Join as spectator (no hand)

2. Quick Start

Prereqs

  • Node.js >= 18
  • npm (comes with Node)

Install

1
2
3
git clone <your-fork-or-download> uno-multiplayer
cd uno-multiplayer
npm install

Run the Server

1
npm start

Default runs on http://localhost:3000.

Invite Friends

  1. Start a room: open the site, enter a Display Name, click Create Room.
  2. Copy the Room Link (URL) or Room Code.
  3. Share with friends — they join, pick names, click Join.
  4. When all ready, host clicks Start Game.

You can also open multiple browser tabs/windows to simulate multiple players when testing.


3. Standard UNO Rules Implemented (Core)

Goal: Be first to shed all your cards. Optional scoring after each round (sum of opponents’ card points) — included as a post‑round summary, not cross‑round tally by default.

Setup

  • Shuffle full UNO deck.
  • Deal 7 cards each.
  • Flip top card to start discard pile; if first card is Action/Wild Draw4, special handling (see implementation notes below).

On Your Turn

You may play a card from your hand that matches color or number/symbol of the top discard. Or play a Wild (any time). If you cannot play, click Draw: you draw 1; if playable, you may immediately play it (one chance). Otherwise turn ends.

Action Cards

  • Skip → Next player’s turn is skipped.
  • Reverse → Direction flips. With 2 players, Reverse works like Skip.
  • Draw Two → Next player draws 2 and loses turn.
  • Wild → Playable anytime; you pick new color.
  • Wild Draw Four → Choose color; next player draws 4 & skips. No challenge check in core game; treat as always legal.

Calling UNO

If you will have 1 card left after playing, you must click the UNO! checkbox/button before confirming play. If you fail and another player (or system auto‑check) catches before next player completes a valid action, you draw 2 penalty cards.

Round End & Scoring (optional)

If enabled, winner gets sum of point values in others’ hands (standard UNO values: numbers face value; action cards 20; wilds 50). Otherwise just show winner and reset.


4. House Rules (Toggle in Lobby Before Game Starts)

Rule Default Description
Draw Stacking Off Allow stacking Draw2 on Draw2 (and optionally Draw4 on Draw4) so penalty accumulates until someone draws.
Seven Swap Off Playing a 7 lets you swap hands with another player.
Zero Rotate Off Playing a 0 rotates all hands in direction of play.
Force Play After Draw On If drawn card playable, auto‑offer immediate play.

5. Project Structure

1
2
3
4
5
6
7
8
9
10
uno-multiplayer/
├─ package.json
├─ server.js # Node server + ws game engine
├─ game-logic.js # Core rules & state transitions (server side)
├─ public/
│ ├─ index.html # Lobby + game UI
│ ├─ client.js # WebSocket client, UI handlers
│ ├─ style.css # Basic styling
│ └─ assets/ # (optional) card SVG/PNG art
└─ README.md (this doc)

For simplicity in this teaching version, all code appears in this single document below. When you set up the project, create the files as shown.


6. package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name": "uno-multiplayer-mini-game",
"version": "1.0.0",
"description": "Lightweight WebSocket-based UNO game for friends.",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "NODE_ENV=development node server.js"
},
"dependencies": {
"express": "^4.19.2",
"ws": "^8.18.0",
"nanoid": "^5.0.4"
}
}

7. game-logic.js

Pure server‑side state + rules. No network calls here; just functions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
// game-logic.js
// -----------------------------------------------------------------------------
// Core data structures & rule resolution for UNO.
// State is kept in-memory in server.js; these helpers mutate state.
// -----------------------------------------------------------------------------

const COLORS = ["red", "yellow", "green", "blue"]; // canonical lowercase
const NUMBERS = [0,1,2,3,4,5,6,7,8,9];
const ACTIONS = ["skip", "reverse", "draw2"]; // per color x2 (except 0 card x1)

function buildDeck() {
const deck = [];
// Colored cards
COLORS.forEach(color => {
// One 0
deck.push({type:"number", color, value:0, id:cid()});
// Two each 1-9
NUMBERS.slice(1).forEach(n => {
deck.push({type:"number", color, value:n, id:cid()});
deck.push({type:"number", color, value:n, id:cid()});
});
// Two each action
ACTIONS.forEach(a => {
deck.push({type:a, color, id:cid()});
deck.push({type:a, color, id:cid()});
});
});
// Wilds (color null)
for (let i=0;i<4;i++) deck.push({type:"wild", color:null, id:cid()});
for (let i=0;i<4;i++) deck.push({type:"wild4", color:null, id:cid()});
return shuffle(deck);
}

// lightweight uid per card
let _cid = 0;
function cid(){return "c"+(_cid++).toString(36);}

function shuffle(a){
for (let i=a.length-1;i>0;i--){
const j=Math.floor(Math.random()* (i+1));
[a[i],a[j]]=[a[j],a[i]];
}
return a;
}

// Score values per UNO rules
function cardPoints(card){
switch(card.type){
case "number": return card.value;
case "skip":
case "reverse":
case "draw2": return 20;
case "wild":
case "wild4": return 50;
default: return 0;
}
}

// Initialize round state
function initRound(room){
room.drawPile = buildDeck();
room.discardPile = [];
// deal 7
room.players.forEach(p=>{p.hand=[]; for(let i=0;i<7;i++) p.hand.push(room.drawPile.pop());});
// flip start card (cannot be wild4)
let first = room.drawPile.pop();
while(first.type === "wild4"){
room.drawPile.unshift(first); // put back bottom
first = room.drawPile.pop();
}
// If first is wild, force color choose = random color
if(first.type === "wild"){
first.color = COLORS[Math.floor(Math.random()*COLORS.length)];
}
room.discardPile.push(first);
room.currentColor = first.color; // top color context
room.currentValue = first.type === "number" ? first.value : first.type;

room.turnIndex = 0; // player 0 starts
room.direction = 1; // 1=clockwise, -1=counter
room.awaitingColorChoice = null; // player id if must choose
room.pendingDraw = 0; // stackable draw penalty
room.mustSayUNO = new Set(); // players who should have declared UNO this turn
room.gamePhase = "playing";

// If first is action, resolve immediate effect as if played by a phantom player BEFORE P0 turn
resolveStartCardEffect(room, first);
}

function resolveStartCardEffect(room, card){
// Per classic UNO: apply if first is action (skip, reverse, draw2)
switch(card.type){
case "skip":
advanceTurn(room); // skip first real player (player0 loses first turn)
break;
case "reverse":
if(room.players.length === 2){
advanceTurn(room); // acts as skip in 2p
} else {
room.direction *= -1; // flip direction; still player0 to start? Official rules: treat reverse as skip? Actually next player becomes last.
// We'll just advance once so effective skip of P0's next.
advanceTurn(room);
}
break;
case "draw2":
drawCards(room, room.players[0], 2);
advanceTurn(room); // skip
break;
default: break;
}
}

function drawCards(room, player, n){
for(let i=0;i<n;i++){
if(room.drawPile.length === 0) reshuffle(room);
player.hand.push(room.drawPile.pop());
}
}

function reshuffle(room){
// keep top discard; reshuffle rest into draw pile
const top = room.discardPile.pop();
room.drawPile = shuffle(room.discardPile);
room.discardPile = [top];
}

function topCard(room){
return room.discardPile[room.discardPile.length-1];
}

function isPlayable(room, card){
const top = topCard(room);
if(room.pendingDraw>0){
// If draw stacking allowed and pending, only matching draw cards allowed
if(room.houseRules.drawStacking){
if(top.type === "draw2" && card.type === "draw2") return true;
if(top.type === "wild4" && card.type === "wild4") return true;
return false;
} else {
return false; // must draw
}
}
if(card.type === "wild" || card.type === "wild4") return true;
if(card.color === room.currentColor) return true;
if(card.type === "number" && top.type === "number" && card.value === top.value) return true;
if(card.type !== "number" && card.type === top.type) return true; // match action symbol
return false;
}

function advanceTurn(room){
const len = room.players.length;
room.turnIndex = (room.turnIndex + room.direction + len) % len;
}

function enforceUNO(room, player){
// called when player ends action w/1 card; mark mustSayUNO
room.mustSayUNO.add(player.id);
}

function playerSaidUNO(room, player){
room.mustSayUNO.delete(player.id);
}

function checkUNOTimeout(room){
// Called at start of next player's turn; any in mustSayUNO draws 2.
if(room.mustSayUNO.size>0){
room.players.forEach(p=>{
if(room.mustSayUNO.has(p.id)){
drawCards(room,p,2);
}
});
room.mustSayUNO.clear();
}
}

function applyPlayedCard(room, player, card, chosenColor=null, chosenTargetId=null){
// Remove from hand
const idx = player.hand.findIndex(c=>c.id===card.id);
if(idx===-1) return {error:"Card not in hand"};
player.hand.splice(idx,1);
// push to discard
// For wild chosen color may override card.color
const played = {...card};
if(card.type === "wild" || card.type === "wild4"){played.color = chosenColor || COLORS[0];}
room.discardPile.push(played);
room.currentColor = played.color;
room.currentValue = played.type === "number"? played.value : played.type;

// Special house rules before default actions
if(room.houseRules.sevenSwap && card.type === "number" && card.value === 7){
if(chosenTargetId && chosenTargetId !== player.id){
const target = room.players.find(p=>p.id===chosenTargetId);
[player.hand, target.hand] = [target.hand, player.hand];
}
}
if(room.houseRules.zeroRotate && card.type === "number" && card.value === 0){
rotateHands(room);
}

// Core actions
switch(card.type){
case "skip":
advanceTurn(room); // skip next
break;
case "reverse":
if(room.players.length === 2){
advanceTurn(room); // counts as skip
} else {
room.direction *= -1;
// after reverse, no extra skip (official: direction change only). But because we advanced after card outside? Actually we haven't advanced yet.
// We'll advance once below outside switch.
}
break;
case "draw2":
if(room.houseRules.drawStacking){
room.pendingDraw += 2; // next player may stack
} else {
const next = getNextPlayer(room);
drawCards(room,next,2);
advanceTurn(room); // skip
}
break;
case "wild":
// nothing extra
break;
case "wild4":
if(room.houseRules.drawStacking){
room.pendingDraw += 4;
} else {
const next = getNextPlayer(room);
drawCards(room,next,4);
advanceTurn(room); // skip
}
break;
default: break; // number card
}

// UNO check: if player now has 1 card, require UNO
if(player.hand.length === 1){
enforceUNO(room, player);
}

// If player exhausted hand -> round end
if(player.hand.length === 0){
room.gamePhase = "roundEnd";
room.winnerId = player.id;
room.roundScores = tallyScores(room, player.id);
return {roundEnd:true};
}

// Advance to next player (unless skip/draw logic already advanced inside those branches incorrectly?)
// For clean logic we will unify: we always advance exactly once at end of function *unless* we already advanced due to skip/draw.
// Approach: track a flag.
// We'll restructure: return something telling server whether turn advanced already.
return {roundEnd:false};
}

function getNextPlayer(room){
const len = room.players.length;
const idx = (room.turnIndex + room.direction + len) % len;
return room.players[idx];
}

function rotateHands(room){
const dir = room.direction;
const hands = room.players.map(p=>p.hand);
const len = hands.length;
const newHands = new Array(len);
for(let i=0;i<len;i++){
const j = (i+dir+len)%len;
newHands[j] = hands[i];
}
room.players.forEach((p,i)=>{p.hand=newHands[i];});
}

function tallyScores(room, winnerId){
const winner = room.players.find(p=>p.id===winnerId);
let points = 0;
room.players.forEach(p=>{if(p.id!==winnerId){p.hand.forEach(c=>points+=cardPoints(c));}});
return {[winnerId]: points};
}

module.exports = {
COLORS,
buildDeck,
initRound,
drawCards,
reshuffle,
topCard,
isPlayable,
advanceTurn,
applyPlayedCard,
getNextPlayer,
checkUNOTimeout,
playerSaidUNO,
cardPoints
};

8. server.js

WebSocket room management + turn enforcement + broadcast state.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
// server.js
const express = require('express');
const { WebSocketServer } = require('ws');
const { nanoid } = require('nanoid');
const http = require('http');
const path = require('path');

const {
COLORS,
initRound,
drawCards,
topCard,
isPlayable,
advanceTurn,
applyPlayedCard,
getNextPlayer,
checkUNOTimeout,
playerSaidUNO,
cardPoints
} = require('./game-logic');

// ------------------------------ Setup Express ------------------------------
const app = express();
app.use(express.static(path.join(__dirname,'public')));
const server = http.createServer(app);

// ------------------------------ WebSocket ------------------------------
const wss = new WebSocketServer({ server });

// Rooms in-memory: { [roomId]: {...} }
const rooms = {};

function createRoom(opts={}){
const id = generateRoomCode();
rooms[id] = {
id,
created: Date.now(),
players: [], // {id, name, ws, hand:[], isSpectator:false}
hostId: null,
gamePhase: 'lobby', // lobby|playing|roundEnd
houseRules: {
drawStacking: !!opts.drawStacking,
sevenSwap: !!opts.sevenSwap,
zeroRotate: !!opts.zeroRotate,
forcePlayAfterDraw: opts.forcePlayAfterDraw!==undefined ? opts.forcePlayAfterDraw : true,
},
drawPile: [],
discardPile: [],
currentColor: null,
currentValue: null,
turnIndex: 0,
direction: 1,
mustSayUNO: new Set(),
pendingDraw: 0,
winnerId: null,
roundScores: {},
};
return rooms[id];
}

function generateRoomCode(){
// 6 uppercase letters
const chars='ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code='';
for(let i=0;i<6;i++) code+=chars[Math.floor(Math.random()*chars.length)];
return code;
}

function broadcast(room){
const snapshot = roomSnapshot(room);
room.players.forEach(p=>{
if(p.ws.readyState===1){
p.ws.send(JSON.stringify({type:'state', data:personalizeSnapshot(snapshot,p.id)}));
}
});
}

function roomSnapshot(room){
return {
id: room.id,
gamePhase: room.gamePhase,
hostId: room.hostId,
players: room.players.map(p=>({
id:p.id,
name:p.name,
cardCount:p.hand.length,
isSpectator:p.isSpectator||false,
})),
topCard: topCard(room),
currentColor: room.currentColor,
currentValue: room.currentValue,
turnIndex: room.turnIndex,
direction: room.direction,
pendingDraw: room.pendingDraw,
winnerId: room.winnerId,
roundScores: room.roundScores,
houseRules: room.houseRules,
};
}

function personalizeSnapshot(snapshot, playerId){
const room = rooms[snapshot.id];
const me = room.players.find(p=>p.id===playerId);
return {
...snapshot,
me: {
id:me.id,
name:me.name,
hand: me.isSpectator? [] : me.hand,
isSpectator:me.isSpectator||false,
}
};
}

wss.on('connection', ws => {
ws.on('message', msg => {
let data;
try{data = JSON.parse(msg);}catch(e){return;}
const {type} = data;
switch(type){
case 'createRoom': return handleCreateRoom(ws, data);
case 'joinRoom': return handleJoinRoom(ws, data);
case 'startGame': return handleStartGame(ws, data);
case 'playCard': return handlePlayCard(ws, data);
case 'drawCard': return handleDrawCard(ws, data);
case 'sayUNO': return handleSayUNO(ws, data);
case 'chat': return handleChat(ws, data);
case 'nextRound': return handleNextRound(ws, data);
default: break;
}
});
});

function findPlayer(ws){
for(const roomId in rooms){
const room = rooms[roomId];
const p = room.players.find(p=>p.ws===ws);
if(p) return {room, player:p};
}
return null;
}

function handleCreateRoom(ws, data){
const { name, opts } = data;
const room = createRoom(opts||{});
const player = {id:nanoid(), name:name||'Player', ws, hand:[], isSpectator:false};
room.players.push(player);
room.hostId = player.id;
broadcast(room);
}

function handleJoinRoom(ws, data){
const { roomId, name, spectator=false } = data;
const room = rooms[roomId];
if(!room){
ws.send(JSON.stringify({type:'error', message:'Room not found'}));
return;
}
const player = {id:nanoid(), name:name||'Player', ws, hand:[], isSpectator:!!spectator};
room.players.push(player);
broadcast(room);
}

function handleStartGame(ws, data){
const ctx = findPlayer(ws); if(!ctx) return;
const {room, player} = ctx;
if(player.id !== room.hostId){return;}
initRound(room);
broadcast(room);
}

function handlePlayCard(ws, data){
const ctx = findPlayer(ws); if(!ctx) return;
const {room, player} = ctx;
if(room.gamePhase!=="playing") return;
if(player.isSpectator) return;
if(room.players[room.turnIndex].id !== player.id) return; // not your turn

const { cardId, chosenColor, chosenTargetId } = data;
const card = player.hand.find(c=>c.id===cardId);
if(!card) return;
if(!isPlayable(room, card)) return;

// Resolve pending draw stacking if applicable
const top = topCard(room);
let alreadyAdvanced = false;
if(room.pendingDraw>0){
if( (top.type==='draw2' && card.type==='draw2') || (top.type==='wild4' && card.type==='wild4') ){
// stack: add to pending inside apply
} else {
// shouldn't be allowed due to isPlayable check
}
}

const result = applyPlayedCard(room, player, card, chosenColor, chosenTargetId);

// If pendingDraw >0 after play and next player cannot stack, they must draw when their turn starts (handled in drawCard).

// Advance turn depending on action type
// For clarity, we re-calc because applyPlayedCard didn't change turnIndex except for some within; we unify here.
// We'll replicate official: After playing, unless action already consumed next (draw/skip), we advance once.
if(card.type === 'skip' || (card.type==='reverse' && room.players.length===2)){
advanceTurn(room); // skip effected in apply? we didn't. so do now.
advanceTurn(room); // move to next after skipped
} else if(card.type === 'draw2' && !room.houseRules.drawStacking){
// apply drew+skip in apply; we advanced once there? Actually apply drew & advance
// To prevent double advance, we won't advance again if non-stacking. Let's trust apply.
} else if(card.type === 'wild4' && !room.houseRules.drawStacking){
// same
} else if(card.type === 'reverse' && room.players.length>2){
advanceTurn(room); // after reversing direction, move to next player in new direction
} else {
advanceTurn(room);
}

// At start of new current player's turn, check UNO penalty
checkUNOTimeout(room);

// Auto force draw resolution? We'll rely on client.

broadcast(room);

if(result.roundEnd){
// do nothing extra; snapshot includes winner
}
}

function handleDrawCard(ws, data){
const ctx = findPlayer(ws); if(!ctx) return;
const {room, player} = ctx;
if(room.gamePhase!=="playing") return;
if(player.isSpectator) return;
if(room.players[room.turnIndex].id !== player.id) return; // not your turn

// Pending draw stack?
if(room.pendingDraw>0){
drawCards(room, player, room.pendingDraw);
room.pendingDraw = 0;
advanceTurn(room);
checkUNOTimeout(room);
broadcast(room);
return;
}

// Normal draw 1
const before = player.hand.length;
drawCards(room, player, 1);
const drawn = player.hand[player.hand.length-1];

if(room.houseRules.forcePlayAfterDraw && isPlayable(room, drawn)){
// tell client they can play drawn card this turn
player.ws.send(JSON.stringify({type:'drawnPlayable', card:drawn}));
// Turn does not auto advance until player chooses to play or pass.
} else {
advanceTurn(room);
checkUNOTimeout(room);
broadcast(room);
}
}

function handleSayUNO(ws, data){
const ctx = findPlayer(ws); if(!ctx) return;
const {room, player} = ctx;
playerSaidUNO(room, player);
broadcast(room);
}

function handleChat(ws, data){
const ctx = findPlayer(ws); if(!ctx) return;
const {room, player} = ctx;
const msg = {
type:'chat',
from:player.name,
text:data.text||'',
ts:Date.now()
};
room.players.forEach(p=>{if(p.ws.readyState===1)p.ws.send(JSON.stringify(msg));});
}

function handleNextRound(ws, data){
const ctx = findPlayer(ws); if(!ctx) return;
const {room, player} = ctx;
if(player.id !== room.hostId) return;
room.gamePhase='lobby';
room.winnerId=null;
room.roundScores={};
// keep players; allow host to toggle rules or start again
broadcast(room);
}

// ------------------------------ Start HTTP Server ------------------------------
const PORT = process.env.PORT || 3000;
server.listen(PORT, ()=>{
console.log(`UNO server running on http://localhost:${PORT}`);
});

9. public/index.html

Minimal single‑page UI. Uses DOM views: Lobby, Game, RoundEnd.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>UNO Multiplayer</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<!-- Lobby -->
<section id="lobby-view" class="view">
<h1>UNO 在线房间</h1>
<div class="card">
<h2>创建房间</h2>
<input id="create-name" placeholder="昵称" />
<label><input type="checkbox" id="rule-drawstack"> 叠加罚牌</label><br>
<label><input type="checkbox" id="rule-seven"> 7换手牌</label><br>
<label><input type="checkbox" id="rule-zero"> 0旋转手牌</label><br>
<button id="btn-create">创建</button>
</div>
<div class="card">
<h2>加入房间</h2>
<input id="join-name" placeholder="昵称" />
<input id="join-room" placeholder="房间码" />
<label><input type="checkbox" id="join-spectator"> 旁观模式</label><br>
<button id="btn-join">加入</button>
</div>
</section>

<!-- Game -->
<section id="game-view" class="view hidden">
<div id="top-bar">
<span>房间: <span id="room-id-display"></span></span>
<span>当前颜色: <span id="current-color-display"></span></span>
<span>方向: <span id="direction-display"></span></span>
<span>轮到: <span id="turn-player-display"></span></span>
<button id="btn-say-uno">UNO!</button>
</div>

<div id="table-area">
<div id="discard-pile"></div>
<div id="draw-pile"><button id="btn-draw">摸牌</button></div>
</div>

<div id="players-area"></div>
<h3>我的手牌</h3>
<div id="hand-area"></div>

<div id="chat-area" class="chat">
<div id="chat-log"></div>
<input id="chat-input" placeholder="聊天..." />
</div>
</section>

<!-- Round End -->
<section id="roundend-view" class="view hidden">
<h1>本局结束</h1>
<p>获胜者: <span id="winner-name"></span></p>
<div id="score-breakdown"></div>
<button id="btn-next-round">返回大厅</button>
</section>
</div>

<script src="client.js"></script>
</body>
</html>

10. public/style.css

Basic layout. Customize freely.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
body {
margin: 0;
font-family: sans-serif;
background:#0d1117;
color:#e6edf3;
}
.view { padding: 1rem; }
.hidden { display:none; }
.card { border:1px solid #30363d; padding:1rem; margin-bottom:1rem; border-radius:8px; }
button { cursor:pointer; margin-top:0.5rem; }
#top-bar { display:flex; gap:1rem; align-items:center; margin-bottom:1rem; }
#table-area { display:flex; gap:2rem; align-items:center; margin:1rem 0; }
#discard-pile, #draw-pile { min-width:80px; min-height:120px; border:2px dashed #30363d; display:flex; align-items:center; justify-content:center; }
#hand-area { display:flex; gap:0.25rem; flex-wrap:wrap; }
.card-tile { width:60px; height:90px; border-radius:6px; display:flex; align-items:center; justify-content:center; font-weight:bold; font-size:1.25rem; position:relative; }
.card-red { background:#d32f2f; }
.card-yellow { background:#fdd835; color:#000; }
.card-green { background:#388e3c; }
.card-blue { background:#1976d2; }
.card-wild { background:linear-gradient(45deg,#d32f2f,#fdd835,#388e3c,#1976d2); font-size:0.9rem; text-align:center; }
.card-action::after { content:""; position:absolute; inset:0; border:2px solid #fff; border-radius:6px; }
.turn-highlight { outline:3px solid #fff; }
.chat { position:fixed; bottom:0; right:0; width:240px; max-height:40vh; background:#161b22; border:1px solid #30363d; display:flex; flex-direction:column; }
#chat-log { flex:1; overflow-y:auto; padding:0.5rem; font-size:0.85rem; }
#chat-input { border:none; padding:0.25rem; width:100%; box-sizing:border-box; background:#0d1117; color:#e6edf3; }
.player-badge { margin:0.25rem 0; font-size:0.9rem; }
.player-badge.me { font-weight:bold; }

11. public/client.js

Handles UI, connects to server, sends actions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
// client.js
let ws;
let state = null; // last room snapshot personalized

const lobbyView = document.getElementById('lobby-view');
const gameView = document.getElementById('game-view');
const roundEndView = document.getElementById('roundend-view');

const createNameEl = document.getElementById('create-name');
const btnCreate = document.getElementById('btn-create');
const ruleDrawstackEl = document.getElementById('rule-drawstack');
const ruleSevenEl = document.getElementById('rule-seven');
const ruleZeroEl = document.getElementById('rule-zero');

const joinNameEl = document.getElementById('join-name');
const joinRoomEl = document.getElementById('join-room');
const joinSpectatorEl = document.getElementById('join-spectator');
const btnJoin = document.getElementById('btn-join');

const roomIdDisplay = document.getElementById('room-id-display');
const currentColorDisplay = document.getElementById('current-color-display');
const directionDisplay = document.getElementById('direction-display');
const turnPlayerDisplay = document.getElementById('turn-player-display');
const btnSayUNO = document.getElementById('btn-say-uno');

const discardPileEl = document.getElementById('discard-pile');
const drawPileBtn = document.getElementById('btn-draw');

const playersArea = document.getElementById('players-area');
const handArea = document.getElementById('hand-area');

const chatLog = document.getElementById('chat-log');
const chatInput = document.getElementById('chat-input');

const winnerNameEl = document.getElementById('winner-name');
const scoreBreakdownEl = document.getElementById('score-breakdown');
const btnNextRound = document.getElementById('btn-next-round');

function init(){
const proto = (location.protocol === 'https:') ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}`);
ws.addEventListener('message', onMessage);

btnCreate.addEventListener('click', ()=>{
ws.send(JSON.stringify({
type:'createRoom',
name:createNameEl.value.trim()||'玩家',
opts:{
drawStacking:ruleDrawstackEl.checked,
sevenSwap:ruleSevenEl.checked,
zeroRotate:ruleZeroEl.checked,
}
}));
});

btnJoin.addEventListener('click', ()=>{
ws.send(JSON.stringify({
type:'joinRoom',
roomId:joinRoomEl.value.trim().toUpperCase(),
name:joinNameEl.value.trim()||'玩家',
spectator:joinSpectatorEl.checked
}));
});

drawPileBtn.addEventListener('click', ()=>{
ws.send(JSON.stringify({type:'drawCard'}));
});

btnSayUNO.addEventListener('click', ()=>{
ws.send(JSON.stringify({type:'sayUNO'}));
});

chatInput.addEventListener('keydown', e=>{
if(e.key==='Enter'&&chatInput.value.trim()!==''){
ws.send(JSON.stringify({type:'chat', text:chatInput.value.trim()}));
chatInput.value='';
}
});

btnNextRound.addEventListener('click', ()=>{
ws.send(JSON.stringify({type:'nextRound'}));
});
}

function onMessage(ev){
const msg = JSON.parse(ev.data);
switch(msg.type){
case 'state':
state = msg.data;
renderState();
break;
case 'drawnPlayable':
// highlight playable drawn card in UI via toast? We'll just alert.
alert('你摸到一张可出的牌!点击手牌来出牌。');
break;
case 'chat':
addChat(msg);
break;
case 'error':
alert(msg.message||'错误');
break;
default: break;
}
}

function renderState(){
if(!state) return;
const phase = state.gamePhase;
showView(phase==='lobby'?lobbyView:phase==='playing'?gameView:roundEndView);

if(phase==='lobby'){
renderLobby();
} else if(phase==='playing'){
renderGame();
} else if(phase==='roundEnd'){
renderRoundEnd();
}
}

function renderLobby(){
// Replace join section with room ID + start button if I am host
lobbyView.innerHTML = '';
const h1 = document.createElement('h1'); h1.textContent='UNO 房间大厅'; lobbyView.appendChild(h1);
const rid = document.createElement('p'); rid.textContent=`房间码: ${state.id}`; lobbyView.appendChild(rid);

const ul = document.createElement('ul');
state.players.forEach(p=>{
const li=document.createElement('li');
li.textContent=`${p.name}${p.isSpectator?' (旁观)':''}`;
ul.appendChild(li);
});
lobbyView.appendChild(ul);

if(state.hostId===state.me.id){
const btn=document.createElement('button'); btn.textContent='开始游戏';
btn.addEventListener('click',()=>{ws.send(JSON.stringify({type:'startGame'}));});
lobbyView.appendChild(btn);
} else {
const wait=document.createElement('p'); wait.textContent='等待房主开始游戏...'; lobbyView.appendChild(wait);
}
}

function renderGame(){
roomIdDisplay.textContent = state.id;
currentColorDisplay.textContent = state.currentColor || '-';
directionDisplay.textContent = state.direction===1?'顺时针':'逆时针';
const currentPlayer = state.players[state.turnIndex];
turnPlayerDisplay.textContent = currentPlayer?currentPlayer.name:'';

discardPileEl.innerHTML='';
if(state.topCard){
discardPileEl.appendChild(renderCardTile(state.topCard));
}

// players badges
playersArea.innerHTML='';
state.players.forEach((p,i)=>{
const div=document.createElement('div');
div.className='player-badge'+(p.id===state.me.id?' me':'');
if(i===state.turnIndex) div.classList.add('turn-highlight');
div.textContent=`${p.name}: ${p.cardCount}`;
playersArea.appendChild(div);
});

// my hand
handArea.innerHTML='';
if(!state.me.isSpectator){
state.me.hand.forEach(card=>{
const el=renderCardTile(card,true);
el.addEventListener('click',()=>{
// For wilds ask color
if(card.type==='wild' || card.type==='wild4'){
const color = prompt('选择颜色: red / yellow / green / blue','red');
if(!color) return;
ws.send(JSON.stringify({type:'playCard', cardId:card.id, chosenColor:color}));
} else {
ws.send(JSON.stringify({type:'playCard', cardId:card.id}));
}
});
handArea.appendChild(el);
});
}
}

function renderRoundEnd(){
const winner = state.players.find(p=>p.id===state.winnerId);
winnerNameEl.textContent = winner?winner.name:'?';
// show points (simple)
scoreBreakdownEl.innerHTML='';
const ul=document.createElement('ul');
Object.entries(state.roundScores||{}).forEach(([pid,pts])=>{
const p=state.players.find(x=>x.id===pid);
const li=document.createElement('li');
li.textContent=`${p?p.name:pid}: ${pts} 分`;
ul.appendChild(li);
});
scoreBreakdownEl.appendChild(ul);
}

function renderCardTile(card, clickable=false){
const div=document.createElement('div');
div.className='card-tile';
let cls='';
switch(card.color){
case 'red': cls='card-red'; break;
case 'yellow': cls='card-yellow'; break;
case 'green': cls='card-green'; break;
case 'blue': cls='card-blue'; break;
default: cls='card-wild'; break;
}
div.classList.add(cls);
if(card.type!=='number') div.classList.add('card-action');
div.title=card.type;
div.textContent = card.type==='number'?card.value:symbolFor(card.type);
if(clickable) div.style.cursor='pointer';
return div;
}

function symbolFor(type){
switch(type){
case 'skip': return '⦸';
case 'reverse': return '↺';
case 'draw2': return '+2';
case 'wild': return 'W';
case 'wild4': return '+4';
default: return '?';
}
}

function addChat(msg){
const line=document.createElement('div');
const ts=new Date(msg.ts).toLocaleTimeString();
line.textContent=`[${ts}] ${msg.from}: ${msg.text}`;
chatLog.appendChild(line);
chatLog.scrollTop=chatLog.scrollHeight;
}

function showView(v){
[lobbyView,gameView,roundEndView].forEach(el=>el.classList.add('hidden'));
v.classList.remove('hidden');
}

window.addEventListener('load', init);

12. Running on Local Network (LAN)

  1. Make sure all players are on the same Wi‑Fi.
  2. Start the server on one machine: npm start.
  3. Determine that machine’s LAN IP (e.g., 192.168.1.23).
  4. Others browse to http://192.168.1.23:3000.
  5. Host creates room; others join using the 6‑char room code.

13. Deploying to the Internet (Optional)

  • Use a small VPS (e.g., DigitalOcean, Lightsail).
  • Install Node, copy project, run behind nginx reverse proxy with HTTPS.
  • Or deploy via services like Render / Railway / Fly.io (all support Node + WebSocket).

14. How to Play Walkthrough (Step‑by‑Step)

A. Host Steps

  1. 打开站点 → 输入昵称 → 勾选或不勾选自定义规则 → 点击 创建
  2. 复制房间码并发送给好友。
  3. 等所有人加入后点击 开始游戏

B. 玩家步骤

  1. 打开链接或输入房间码加入。
  2. 选择是否旁观;点击 加入
  3. 等待房主开始。

C. 游戏回合

  • 轮到你时,点击一张可出的牌。如果是 Wild / +4,会弹出提示让你选择颜色。
  • 没牌可出?点 摸牌
  • 出完倒数第二张牌时记得点 **UNO!**(或先点 UNO 再出牌)。
  • 聊天框可发消息。

15. Extending the Game

  • 断线重连(存储房间状态并按 playerId 重新连接)
  • Github OAuth 登录 & 头像
  • 观战界面显示所有牌(教学模式)
  • 自动计分多轮比赛到 500 分
  • 牌面 SVG 美化(当前使用文字简化)

16. Troubleshooting

问题 可能原因 解决
加不进房间 房间码错 / 服务端未运行 确认终端里 server 正在运行;复制房间码重新输入
出不了牌 不匹配颜色/数字;轮次不对 确认是否轮到你;检查提示颜色
Wild 没换色 浏览器弹窗被拦截? 关闭弹窗拦截;或改成内嵌颜色选择 UI
UNO 罚牌 忘记点 UNO 按钮 出倒数第二张牌前养成点 UNO 的习惯

祝你和朋友玩得开心!

如果你希望我帮你:打包 zip、改中文界面、添加动画、或用 React 重构,请告诉我!

reward
支付宝 | Alipay
微信 | Wechat