From 297cb80662f4a99e373bdcdff527c3f68753e4e7 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Mon, 19 Oct 2026 16:49:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20p=C5=99ep=C3=ADn=C3=A1n=C3=AD=20re?= =?UTF-8?q?=C5=BEim=C5=AF=20a=20syst=C3=A9mu=20v=C3=ADt=C4=9Bzstv=C3=AD=20?= =?UTF-8?q?v=20Tetrisu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/TetrisGame.scss | 16 ++++ client/src/components/TetrisGame.tsx | 130 ++++++++++++++++++++++---- server/changelogs/2026-10-19.json | 4 - server/src/tests/tetrisGame.test.ts | 69 +++++++++++++- server/src/tetrisGame.ts | 97 ++++++++++++++++--- 5 files changed, 277 insertions(+), 39 deletions(-) delete mode 100644 server/changelogs/2026-10-19.json diff --git a/client/src/components/TetrisGame.scss b/client/src/components/TetrisGame.scss index c515fa0..d6d1ca1 100644 --- a/client/src/components/TetrisGame.scss +++ b/client/src/components/TetrisGame.scss @@ -117,3 +117,19 @@ columns: 2; } } + +.tetris-game-modal .tetris-settings { + display: flex; + flex-wrap: wrap; + gap: 16px; + max-width: 640px; + + > div { + flex: 1 1 180px; + } + + .form-label { + margin-bottom: 2px; + font-weight: 600; + } +} diff --git a/client/src/components/TetrisGame.tsx b/client/src/components/TetrisGame.tsx index 9d5fa2e..77fb8f2 100644 --- a/client/src/components/TetrisGame.tsx +++ b/client/src/components/TetrisGame.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Modal, Table } from "react-bootstrap"; +import { Button, Form, Modal, Table } from "react-bootstrap"; import { socket } from "../context/socket"; import { useAuth } from "../context/auth"; import { getToken } from "../Utils"; @@ -10,6 +10,9 @@ import "./TetrisGame.scss"; // Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI) type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished'; type TetrisPlayer = { login: string, board: string, score: number, lines: number, alive: boolean, place?: number }; +type TetrisTargeting = 'random' | 'even' | 'payback'; +type TetrisWinMode = 'survival' | 'score'; +type TetrisSettings = { targeting: TetrisTargeting, winMode: TetrisWinMode, timeLimit: number }; type TetrisState = { phase: TetrisPhase, host?: string, @@ -17,11 +20,30 @@ type TetrisState = { seed?: number, countdown?: number, minPlayers: number, + settings: TetrisSettings, + remainingMs?: number, }; type GarbageEvent = { target: string, from: string, lines: number }; const OPEN_EVENT = 'luncher:open-tetris'; +const TARGETING_LABELS: Record = { + random: { label: 'Náhodně (Random)', help: 'útok jde náhodnému soupeři' }, + even: { label: 'Rovnoměrně (Even)', help: 'útok jde soupeři, který zatím dostal nejméně odpadu' }, + payback: { label: 'Oplácení (Payback)', help: 'útok jde tomu, kdo vás naposledy napadl, jinak rovnoměrně' }, +}; +const WIN_MODE_LABELS: Record = { + survival: 'Poslední ve hře (Last man standing)', + score: 'Nejvyšší skóre', +}; +/** Musí odpovídat TIME_LIMITS na serveru (s). */ +const TIME_LIMITS = [120, 180, 300]; + +const formatTime = (ms: number) => { + const total = Math.ceil(ms / 1000); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; +}; + /** Otevře okno Tetrisu. */ export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT)); @@ -115,6 +137,9 @@ export default function TetrisGame() { const boardRef = useRef(null); const nextRef = useRef(null); const prevPhase = useRef('idle'); + /** Konec hry na skóre v čase prohlížeče (ze zbývajícího času, ať nevadí rozdíl hodin se serverem). */ + const deadline = useRef(undefined); + const [now, setNow] = useState(() => Date.now()); const icon = useGameIcon('tetris'); const send = useCallback((event: string, extra?: object) => { @@ -128,7 +153,10 @@ export default function TetrisGame() { }, [send]); useEffect(() => { - const onState = (s: TetrisState) => setState(s); + const onState = (s: TetrisState) => { + deadline.current = s.remainingMs !== undefined ? Date.now() + s.remainingMs : undefined; + setState(s); + }; const onError = (msg: string) => setError(msg); const onOpen = () => setOpen(true); const sync = () => socket.emit('tetris:sync'); @@ -226,6 +254,13 @@ export default function TetrisGame() { return () => cancelAnimationFrame(raf); }, [practice, phase, open, sendSnapshot, state?.seed]); + // Odpočet zbývajícího času hry na skóre + useEffect(() => { + if (phase !== 'playing' || state?.settings.winMode !== 'score') return; + const id = window.setInterval(() => setNow(Date.now()), 500); + return () => window.clearInterval(id); + }, [phase, state?.settings.winMode]); + // Heartbeat pro server (viz HEARTBEAT_MS) useEffect(() => { if (practice || phase !== 'playing' || !isPlayer) return; @@ -287,6 +322,46 @@ export default function TetrisGame() { const showReturn = !open && isPlayer && inGame; const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login)); const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99)); + const settings = state?.settings; + const isHost = login === state?.host; + const remaining = !practice && phase === 'playing' && deadline.current !== undefined + ? Math.max(0, deadline.current - now) : undefined; + const configure = (change: Partial) => send('tetris:configure', change); + + const renderSettings = () => settings && ( +
+ + Míření odpadu + {isHost ? ( + configure({ targeting: e.target.value as TetrisTargeting })}> + {Object.entries(TARGETING_LABELS).map(([value, { label }]) => )} + + ) :
{TARGETING_LABELS[settings.targeting].label}
} + {TARGETING_LABELS[settings.targeting].help} +
+ + Vítěz + {isHost ? ( + configure({ winMode: e.target.value as TetrisWinMode })}> + {Object.entries(WIN_MODE_LABELS).map(([value, label]) => )} + + ) :
{WIN_MODE_LABELS[settings.winMode]}
} +
+ {settings.winMode === 'score' && ( + + Délka hry + {isHost ? ( + configure({ timeLimit: Number(e.target.value) })}> + {TIME_LIMITS.map(t => )} + + ) :
{settings.timeLimit / 60} min
} +
+ )} +
+ ); const renderPlaying = () => (
@@ -299,6 +374,12 @@ export default function TetrisGame() { {flash &&
{flash}
}
+ {remaining !== undefined && ( + <> +
Zbývá
+
{formatTime(remaining)}
+ + )}
Další
Skóre
@@ -335,8 +416,9 @@ export default function TetrisGame() { return (

Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz, - pošle 1, 2 nebo 4 odpadní řádky soupeři, který jich zatím dostal nejméně. Smazáním řádků - lze zároveň odrazit odpad, který na vás čeká. Vyhrává poslední hráč ve hře. + pošle soupeři 1, 2 nebo 4 odpadní řádky. Smazáním řádků lze zároveň odrazit odpad, + který na vás čeká. Zakladatel lobby volí, komu útoky půjdou a zda vyhrává poslední + hráč ve hře, nebo nejvyšší skóre po časovém limitu. Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.

); @@ -347,6 +429,7 @@ export default function TetrisGame() {
    {state?.players.map(p =>
  • {p.login}{p.login === state.host && ' 👑'}
  • )}
+ {renderSettings()} ); case 'countdown': @@ -354,21 +437,30 @@ export default function TetrisGame() { return renderPlaying(); case 'finished': return ( - - - - - - {ranked.map((p, i) => ( - - - - - - - ))} - -
#HráčSkóreŘádky
{i < 3 ? MEDALS[i] : i + 1}{p.login}{p.login === login && ' (vy)'}{p.score}{p.lines}
+ <> + {settings && ( +

+ Hodnocení: {WIN_MODE_LABELS[settings.winMode]} + {settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`} + {' · '}míření: {TARGETING_LABELS[settings.targeting].label} +

+ )} + + + + + + {ranked.map((p, i) => ( + + + + + + + ))} + +
#HráčSkóreŘádky
{i < 3 ? MEDALS[i] : i + 1}{p.login}{p.login === login && ' (vy)'}{p.score}{p.lines}
+ ); } }; diff --git a/server/changelogs/2026-10-19.json b/server/changelogs/2026-10-19.json deleted file mode 100644 index 62e9575..0000000 --- a/server/changelogs/2026-10-19.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - "Tetris: odpadní řádky se mezi soupeře rozdělují rovnoměrně místo náhodně", - "Tetris: smazáním řádků lze odrazit odpad, který na vás čeká" -] diff --git a/server/src/tests/tetrisGame.test.ts b/server/src/tests/tetrisGame.test.ts index fe11b50..b0ccb4c 100644 --- a/server/src/tests/tetrisGame.test.ts +++ b/server/src/tests/tetrisGame.test.ts @@ -1,4 +1,4 @@ -import { TetrisGame, TetrisState, GarbageEvent, COUNTDOWN_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS } from '../tetrisGame'; +import { TetrisGame, TetrisState, TetrisSettings, GarbageEvent, COUNTDOWN_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS, DEFAULT_SETTINGS } from '../tetrisGame'; import { GameError } from '../minigames'; import { fakeClock } from './helpers/fakeClock'; @@ -11,12 +11,14 @@ function setup() { const garbage: GarbageEvent[] = []; const game = new TetrisGame(s => all.push(s), s => watched.push(s), e => garbage.push(e), 1, clock); const last = () => all[all.length - 1]; - const startGame = (...logins: string[]) => { + const startWith = (settings: Partial, ...logins: string[]) => { logins.forEach(l => game.join(l)); + game.configure(logins[0], settings); game.start(logins[0]); clock.advance(COUNTDOWN_SECONDS * 1000); }; - return { game, clock, all, watched, garbage, last, startGame }; + const startGame = (...logins: string[]) => startWith({}, ...logins); + return { game, clock, all, watched, garbage, last, startGame, startWith }; } describe('tetris', () => { @@ -127,6 +129,67 @@ describe('tetris', () => { expect(ctx.last().players.find(p => p.login === 'alice')?.place).toBe(1); }); + test('nastavení mění jen zakladatel v lobby, neplatné hodnoty se ignorují', () => { + const ctx = setup(); + ctx.game.join('alice'); + ctx.game.join('bob'); + expect(ctx.last().settings).toEqual(DEFAULT_SETTINGS); + expect(() => ctx.game.configure('bob', { targeting: 'random' })).toThrow(GameError); + ctx.game.configure('alice', { targeting: 'payback', winMode: 'nesmysl', timeLimit: 7 }); + expect(ctx.last().settings).toEqual({ ...DEFAULT_SETTINGS, targeting: 'payback' }); + ctx.game.start('alice'); + expect(() => ctx.game.configure('alice', { winMode: 'score' })).toThrow(GameError); + }); + + test('random míří náhodně bez ohledu na obdržený odpad', () => { + const ctx = setup(); + ctx.startWith({ targeting: 'random' }, 'alice', 'bob', 'carol'); + ctx.game.cleared('alice', 4); + ctx.clock.advance(MIN_ATTACK_GAP_MS); + ctx.game.cleared('alice', 4); // random = 0 → pořád první soupeř + expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'bob']); + }); + + test('payback vrací útok poslednímu útočníkovi, jinak míří jako even', () => { + const ctx = setup(); + ctx.startWith({ targeting: 'payback' }, 'alice', 'bob', 'carol'); + ctx.game.cleared('carol', 2); // carol nikdo nenapadl → even → alice + ctx.game.cleared('alice', 2); // alice napadla carol → carol + ctx.game.cleared('bob', 2); // bob nikdo nenapadl → even (alice 1, carol 1) → alice + ctx.clock.advance(MIN_ATTACK_GAP_MS); + ctx.game.cleared('alice', 2); // naposledy ji napadl bob → bob + ctx.game.died('bob'); + ctx.game.cleared('bob', 2); // mrtvý neútočí + ctx.game.cleared('carol', 2); // naposledy ji napadla alice → alice + expect(ctx.garbage.map(g => `${g.from}>${g.target}`)).toEqual(['carol>alice', 'alice>carol', 'bob>alice', 'alice>bob', 'carol>alice']); + }); + + test('hra na skóre končí časovým limitem a pořadí je podle skóre', () => { + const ctx = setup(); + ctx.startWith({ winMode: 'score', timeLimit: 120 }, 'alice', 'bob', 'carol'); + expect(ctx.last().remainingMs).toBe(120_000); + ctx.game.update('bob', { board: BOARD, score: 900, lines: 8 }); + ctx.game.died('bob'); + ctx.game.died('alice'); // zbývá jediný hráč, ale hraje se dál + expect(ctx.last().phase).toBe('playing'); + ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 }); // mrtvému se skóre už nemění + ctx.game.update('carol', { board: BOARD, score: 500, lines: 4 }); + ctx.clock.advance(120_000); + const state = ctx.last(); + expect(state.phase).toBe('finished'); + const place = (l: string) => state.players.find(p => p.login === l)?.place; + expect([place('bob'), place('carol'), place('alice')]).toEqual([1, 2, 3]); + }); + + test('hra na skóre skončí dřív, když vypadnou všichni', () => { + const ctx = setup(); + ctx.startWith({ winMode: 'score' }, 'alice', 'bob'); + ctx.game.died('alice'); + expect(ctx.last().phase).toBe('playing'); + ctx.game.died('bob'); + expect(ctx.last().phase).toBe('finished'); + }); + test('do běžící hry se nelze připojit', () => { const ctx = setup(); ctx.startGame('alice'); diff --git a/server/src/tetrisGame.ts b/server/src/tetrisGame.ts index 14b902c..97496d3 100644 --- a/server/src/tetrisGame.ts +++ b/server/src/tetrisGame.ts @@ -6,8 +6,9 @@ import { Clock, defaultClock, GameError, gameHandler } from "./minigames"; * * Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje: * lobby a odpočet, společný seed pro pořadí kostek, přeposílání desek soupeřům, - * útoky (smazané řádky → odpadní řádky soupeři, který zatím dostal nejméně odpadu) a vyřazování. - * Vyhrává poslední hráč ve hře, sólo hra končí prohrou. + * útoky (smazané řádky → odpadní řádky soupeři podle zvoleného režimu míření) a vyřazování. + * Zakladatel lobby volí režim míření a způsob hodnocení: buď vyhrává poslední hráč ve hře + * (sólo hra končí prohrou), nebo nejvyšší skóre po uplynutí časového limitu. * * Klientovi se věří (skóre, smazané řádky) — jde o kancelářskou hru; útoky jsou ale * omezené co do velikosti i frekvence. Stav je v paměti, předpokládá jednu repliku. @@ -36,6 +37,29 @@ export const GARBAGE_FOR_LINES: Record = { 1: 0, 2: 1, 3: 2, 4: export type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished'; +/** + * Komu jde útok: + * - random — náhodnému živému soupeři, + * - even — soupeři, který zatím dostal nejméně odpadu, + * - payback — tomu, kdo útočníka naposledy napadl (jinak jako even). + */ +export type TetrisTargeting = 'random' | 'even' | 'payback'; +/** survival — vyhrává poslední ve hře; score — nejvyšší skóre po časovém limitu. */ +export type TetrisWinMode = 'survival' | 'score'; + +export type TetrisSettings = { + targeting: TetrisTargeting, + winMode: TetrisWinMode, + /** Délka hry v sekundách (jen pro winMode = score). */ + timeLimit: number, +}; + +export const TARGETING_MODES: TetrisTargeting[] = ['random', 'even', 'payback']; +export const WIN_MODES: TetrisWinMode[] = ['survival', 'score']; +/** Povolené délky hry na skóre (s). */ +export const TIME_LIMITS = [120, 180, 300]; +export const DEFAULT_SETTINGS: TetrisSettings = { targeting: 'even', winMode: 'survival', timeLimit: 180 }; + export type TetrisPlayer = { login: string, /** Deska po řádcích shora, znak 0 = prázdno, 1–8 = barva (8 = odpad). */ @@ -54,6 +78,9 @@ export type TetrisState = { seed?: number, countdown?: number, minPlayers: number, + settings: TetrisSettings, + /** Zbývající čas hry na skóre (ms), jen ve fázi playing. */ + remainingMs?: number, }; export type GarbageEvent = { target: string, from: string, lines: number }; @@ -62,8 +89,10 @@ type InternalPlayer = TetrisPlayer & { lastAttack: number, lastSeen: number, deathOrder?: number, - /** Kolik odpadních řádků už hráč od soupeřů dostal — podle toho se volí cíl útoku. */ + /** Kolik odpadních řádků už hráč od soupeřů dostal (režim even). */ garbageReceived: number, + /** Kdo hráče naposledy napadl (režim payback). */ + lastAttacker?: string, }; const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT); @@ -76,6 +105,9 @@ export class TetrisGame { private seed?: number; private countdown = 0; private deaths = 0; + private settings: TetrisSettings = { ...DEFAULT_SETTINGS }; + /** Kdy skončí hra na skóre (čas z clock.now()). */ + private endsAt?: number; private timer: unknown; /** Čeká už odeslání průběžného stavu sledujícím? */ private flushPending = false; @@ -103,6 +135,9 @@ export class TetrisGame { seed: this.seed, countdown: this.phase === 'countdown' ? this.countdown : undefined, minPlayers: this.minPlayers, + settings: { ...this.settings }, + remainingMs: this.phase === 'playing' && this.endsAt !== undefined + ? Math.max(0, this.endsAt - this.clock.now()) : undefined, }; } @@ -136,6 +171,21 @@ export class TetrisGame { this.emitAll(); } + /** Nastavení hry — jen zakladatel a jen v lobby; neplatné hodnoty se ignorují. */ + configure(login: string, payload: unknown) { + if (this.phase !== 'lobby') { + throw new GameError('Nastavení lze měnit jen v lobby.'); + } + if (login !== this.host) { + throw new GameError('Nastavení může měnit jen ten, kdo lobby založil.'); + } + const { targeting, winMode, timeLimit } = (payload ?? {}) as Partial>; + if (TARGETING_MODES.includes(targeting as TetrisTargeting)) this.settings.targeting = targeting as TetrisTargeting; + if (WIN_MODES.includes(winMode as TetrisWinMode)) this.settings.winMode = winMode as TetrisWinMode; + if (TIME_LIMITS.includes(timeLimit as number)) this.settings.timeLimit = timeLimit as number; + this.emitAll(); + } + start(login: string) { if (this.phase !== 'lobby') { throw new GameError('Není otevřené žádné lobby.'); @@ -167,8 +217,7 @@ export class TetrisGame { } /** - * Hráč smazal řádky — případný útok jde živému soupeři, který zatím dostal nejméně odpadu - * (při shodě náhodně), aby se útoky rozkládaly rovnoměrně. + * Hráč smazal řádky — případný útok jde živému soupeři podle režimu míření (viz TetrisTargeting). * * @param sent kolik řádků po odražení vlastního čekajícího odpadu zbylo na útok (počítá klient); * bez něj se pošle plný útok podle tabulky, víc než tabulka ale nikdy @@ -183,13 +232,24 @@ export class TetrisGame { p.lastAttack = now; const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login); if (opponents.length === 0) return; - const least = Math.min(...opponents.map(t => t.garbageReceived)); - const targets = opponents.filter(t => t.garbageReceived === least); - const target = targets[Math.floor(this.clock.random() * targets.length)]; + const target = this.pickTarget(p, opponents); target.garbageReceived += garbage; + target.lastAttacker = login; this.sendGarbage({ target: target.login, from: login, lines: garbage }); } + private pickTarget(attacker: InternalPlayer, opponents: InternalPlayer[]): InternalPlayer { + const pick = (list: InternalPlayer[]) => list[Math.floor(this.clock.random() * list.length)]; + if (this.settings.targeting === 'random') return pick(opponents); + if (this.settings.targeting === 'payback') { + const revenge = opponents.find(t => t.login === attacker.lastAttacker); + if (revenge) return revenge; + } + // even (a payback, dokud útočníka nikdo nenapadl): kdo dostal nejméně, při shodě náhodně + const least = Math.min(...opponents.map(t => t.garbageReceived)); + return pick(opponents.filter(t => t.garbageReceived === least)); + } + /** Hráči přetekla deska. */ died(login: string) { const p = this.players.get(login); @@ -197,7 +257,9 @@ export class TetrisGame { p.alive = false; p.deathOrder = ++this.deaths; const alive = [...this.players.values()].filter(x => x.alive).length; - if (alive === 0 || (this.players.size > 1 && alive <= 1)) { + // Ve hře na skóre se hraje do limitu, dokud je ve hře aspoň jeden hráč + const lastStanding = this.settings.winMode === 'survival' && this.players.size > 1 && alive <= 1; + if (alive === 0 || lastStanding) { this.finish(); return; } @@ -214,8 +276,10 @@ export class TetrisGame { this.phase = 'playing'; const now = this.clock.now(); this.players.forEach(p => { p.lastSeen = now; }); + const duration = this.settings.winMode === 'score' ? this.settings.timeLimit * 1000 : MAX_GAME_MS; + this.endsAt = this.settings.winMode === 'score' ? now + duration : undefined; this.emitAll(); - this.schedule(() => this.finish(), MAX_GAME_MS); + this.schedule(() => this.finish(), duration); this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS); } @@ -229,10 +293,15 @@ export class TetrisGame { if (this.phase === 'playing') this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS); } - /** Pořadí: živí první (podle skóre), pak vyřazení od posledního. */ + /** + * Pořadí — survival: živí první (podle skóre), pak vyřazení od posledního; + * score: podle skóre, při shodě déle hrající. + */ private finish() { - const ranked = [...this.players.values()].sort((a, b) => - (b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity) || b.score - a.score); + const survival = (a: InternalPlayer, b: InternalPlayer) => (b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity); + const ranked = [...this.players.values()].sort(this.settings.winMode === 'score' + ? (a, b) => b.score - a.score || survival(a, b) + : (a, b) => survival(a, b) || b.score - a.score); ranked.forEach((p, i) => { p.place = i + 1; }); this.phase = 'finished'; this.clearTimer(); @@ -251,6 +320,7 @@ export class TetrisGame { this.players.clear(); this.seed = undefined; this.deaths = 0; + this.endsAt = undefined; } private schedule(fn: () => void, ms: number) { @@ -302,6 +372,7 @@ export function registerTetrisGame(io: Server) { socket.on('tetris:unwatch', () => socket.leave(WATCH_ROOM)); socket.on('tetris:join', handle(login => game.join(login))); socket.on('tetris:leave', handle(login => game.leave(login))); + socket.on('tetris:configure', handle((login, payload) => game.configure(login, payload))); socket.on('tetris:start', handle(login => game.start(login))); socket.on('tetris:update', handle((login, payload) => game.update(login, payload))); socket.on('tetris:cleared', handle((login, payload) => {