The Two-Map Party Game Server: Building GameNight Without a Database

작성자

카테고리:

← 피드로
DEV Community · Abhijat Chaturvedi · 2026-07-26 개발(SW)

Every party game app I’d used before building this one wanted an account, a lobby website, or a subscription. I wanted the opposite: plug a laptop into the TV, run one command, and have everyone’s phone connected in under thirty seconds — no internet required once the LAN is up.

That constraint ends up dictating almost every architectural decision in GameNight: a Node/Express/Socket.io server that runs five real-time party games — a Mafia-style social deduction game I call Mongolpuri, UNO, a trivia quiz, Scribble, and Tic-Tac-Toe with tournament brackets — entirely from two in-memory Maps, no database, no auth, no build step on the frontend.

Decision 1: A room is a plain object, not a schema

const rooms = new Map();          // roomCode -> room
const playerRooms = new Map();    // socketId -> roomCode

const room = {
  code, gameType,
  host: socket.id,
  players: new Map([[socket.id, { id: socket.id, name, avatar }]]),
  status: 'lobby',
  gameState: null,
  timers: [],
  settings: defaultSettings(gameType),
  sessionStats: {},
};

Enter fullscreen mode Exit fullscreen mode

Every game’s state — the UNO deck, the Killer/Doctor night phase, the Scribble canvas buffer — lives in room.gameState, an untyped bag shaped differently per gameType. There’s no ORM, no room class hierarchy, no GameEngine interface every game implements. Each game gets its own set of top-level functions (startKD, kdResolveNight, startUno, unoPlayCard, …) that read and mutate room.gameState directly, dispatched through one handleAction switch:

function handleAction(room, socket, data) {
  const gs = room.gameState; if (!gs) return;
  switch (room.gameType) {
    case 'tictactoe':    /* ... */ break;
    case 'killerdoctor': kdAction(room, socket, data); break;
    case 'scribble':     scribbleAction(room, socket, data); break;
    case 'uno':          unoAction(room, socket, data); break;
    case 'quiz':         quizAction(room, socket, data); break;
  }
}

Enter fullscreen mode Exit fullscreen mode

For a five-game server built by one person, this is the right amount of abstraction: zero. A shared GameEngine interface across UNO’s card-matching rules and Mongolpuri’s night/day phase machine would have forced an artificial common shape onto two games that don’t actually share behavior — and every future game (I’d like to add a sixth eventually) gets to invent its own state shape too, at the cost of one new switch case in four places.

Decision 2: Timers are room-scoped, not global

Every phase transition — night falling in Mongolpuri, a Scribble round ending, a quiz question closing — is a setTimeout. With five concurrent games potentially running across multiple rooms, a naive implementation leaks timers the moment a game ends early (all-but-one players disconnect, host force-restarts, tie-breaks resolve early).

function clearTimers(room) { (room.timers||[]).forEach(clearTimeout); room.timers = []; }
function addTimer(room, fn, ms) {
  if (!room.timers) room.timers = [];
  const t = setTimeout(fn, ms);
  room.timers.push(t);
  return t;
}

Enter fullscreen mode Exit fullscreen mode

Every scheduled callback in the server goes through addTimer, and every phase transition that can be short-circuited (a game:restart, a host kicking a player, a win condition resolving early) calls clearTimers first. It’s a two-function pattern, but it’s applied with total consistency — there’s no path in the codebase that calls raw setTimeout and skips the room’s timer bookkeeping. That consistency is what makes it trustworthy; a timer helper only two of five games remember to use is worse than no helper at all.

Decision 3: Randomized resolution delay as a social-engineering tool

Mongolpuri (my take on Mafia/Werewolf) has a real UX problem shared by every social deduction game: if the night phase resolves the instant the last player acts, whoever acts last effectively announces “I was the one making the final decision” through pure timing. Over several rounds, alert players correlate resolution speed with who’s still deciding — deanonymizing the Killer through nothing but network timing.

function checkNightDone(room) {
  const gs = room.gameState; if (gs?.phase !== 'night') return;
  if (!kdAlive(gs).every(p => p.hasActedNight)) return;
  clearTimers(room);
  const delay = 1000 + Math.random() * 4000;
  addTimer(room, () => kdResolveNight(room), delay);
}

Enter fullscreen mode Exit fullscreen mode

A random 1–5 second pause between “everyone has acted” and “night resolves” breaks that correlation. It costs nothing in gameplay terms — nobody is waiting on a meaningful decision during that window — and it closes a side channel that would otherwise undermine the entire hidden-role mechanic. It’s a small line, but it’s the single most important line in the file for keeping the game actually fair.

Villagers also get a decoy: every living player, not just the Killer and Doctors, submits a night pick.

case 'night_kill':
  // Any living player may submit a night pick; the screen is identical for all.
  // The pick is stored per-player and only matters for the killer (kill) and
  // doctor (save) at resolution — a villager's pick is an ignored decoy.

Enter fullscreen mode Exit fullscreen mode

Villagers’ picks are never read at resolution time. The point isn’t game logic — it’s that every player’s client looks and behaves identically during the night phase, so nobody can infer a role from which players even see a target-selection screen in the first place.

Decision 4: Fully declarative win detection

Doctor coverage scales with room size instead of being fixed:

const maxDoctors = Math.max(1, Math.floor(players.length / 5));
const numDoctors = Math.floor(Math.random() * maxDoctors) + 1;

Enter fullscreen mode Exit fullscreen mode

Roughly one Doctor per five players, randomized within that ceiling each game, keeps the Killer-to-protection ratio sane whether four people or fifteen are playing — without a lookup table of “at N players, use M doctors” to maintain. And the whole win-condition check is two lines with no hidden state to keep in sync:

function kdCheckWin(gs) {
  const killer = kdRole(gs,'killer');
  if (!killer?.alive) return { winner: 'villagers', reason: 'The Killer has been eliminated!' };
  if (kdAlive(gs).length <= 2) return { winner: 'killer', reason: 'The Killer cannot be outvoted!' };
  return null;
}

Enter fullscreen mode Exit fullscreen mode

It’s called after every night resolution and every vote resolution — never cached, never tracked incrementally. Deriving it fresh from gs.playerData each time means there’s no “did I forget to update the win-check after this state change” class of bug, because there’s nothing to forget.

The honest gap: reconnect works by name, and one game exposes the seam

The README advertises “refresh the page and jump back in,” and for four of the five games that’s true because of how mid-game rejoin works:

if (room.status === 'playing') {
  const existing = [...room.players.values()].find(p => p.name === playerName?.trim());
  if (!existing) { socket.emit('room:error', { msg: 'Game in progress — cannot join now.' }); return; }
  room.players.delete(existing.id);
  if (room.host === existing.id) room.host = socket.id;
  existing.id = socket.id;
  room.players.set(socket.id, existing);
  ...
  sendReconnectState(room, socket);

Enter fullscreen mode Exit fullscreen mode

There’s no session token, no cookie, no persisted connection ID — Socket.io hands out a fresh socket.id on every reconnect, and GameNight re-identifies a returning player purely by matching their typed name against the room roster. room.players gets its key swapped from the old socket ID to the new one, and sendReconnectState replays whatever that game needs: the UNO hand, the Scribble canvas buffer, the quiz question in flight.

Here’s the seam: for UNO, a disconnect during play evicts the player from playerOrder and deletes their hand outright —

gs.playerOrder.splice(idx, 1);
delete gs.hands[sid];

Enter fullscreen mode Exit fullscreen mode

— so by the time they rejoin, there’s nothing stale to reconcile; they simply re-enter as a fresh observer of unoPublic(gs). But Killer/Doctor doesn’t evict on disconnect — it marks the player alive: false and leaves their gs.playerData[oldSocketId] entry in place, keyed to the old socket ID:

case 'killerdoctor':
  if (gs.playerData?.[sid]) gs.playerData[sid].alive = false;

Enter fullscreen mode Exit fullscreen mode

room.players gets re-keyed to the new socket ID on rejoin, but gs.playerData never does — nothing in the file rewrites that dictionary’s keys. So sendReconnectState‘s Killer/Doctor branch, which looks up gs.playerData?.[socket.id] using the new ID, finds nothing, and the if (pd) guard silently skips sending any reconnect payload at all. In practice this is close to moot — a disconnected player is already marked dead, so there’s little “state” left worth restoring — but it’s a real inconsistency between what four of the five games guarantee and what the fifth actually delivers, and it only shows up in the one game where identity (who’s the Killer, who already died) matters most. I’d rather say that plainly than let the README’s blanket “reconnect support” line imply more than the code backs up.

What I’d take away

  1. Match your abstraction to your team size, not your ambition. Five games, one developer, zero shared GameEngine interface — and that’s correct, not lazy, because the games don’t actually share behavior worth abstracting.
  2. A consistently-applied simple pattern beats an inconsistently-applied clever one. The addTimer/clearTimers pair isn’t sophisticated; its value is that literally every timer in the file goes through it.
  3. In games with hidden information, network timing is a side channel. A random resolution delay isn’t cosmetic — it’s closing a leak that would otherwise let attentive players deduce roles from server latency alone.

The full server — all five games, the tournament bracket logic, the Bonjour/mDNS LAN discovery, the settings validation — is at github.com/abhijatchaturvedi/gamenight. If you want to see the reconnect seam yourself: start a Killer/Doctor game, let the Killer’s tab close mid-round, then reopen it with the same name and watch what does (and doesn’t) come back.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다