Tabletop games
A kcds.world.Tabletop is a table for a board or card game: checkers, nine men's morris, tic-tac-toe, a
hand of cards. You write only the game's rules. The table puts a board of rows and columns in the world,
stands the pieces on its cells and moves them smoothly, seats the players, keeps the turn order and its
timer, lets a seated player point at the board in their own game, and keeps everyone's view in step,
including players who join later.
The table runs on the server. Players need no client script of yours: their game shows a Sit down hint (U) near a free seat and Stand up while they sit, and a seated player points at a cell with the crosshair and clicks with mouse 1 or Enter. With a camera shot (the table's or the seat's), the view stays on the board and the mouse pointer shows: the cell under it is the pointed one, as the game's dice players pick their dice. The arrow keys move the pointed cell as the player sees the board, up being away from them. While seated, the game's fighting is held off, so a click never swings a weapon. Their game only sends the click. Your handlers decide what it does, so a player cannot cheat.
controls changes how a table is played: pointer is 'cursor', 'crosshair' or 'keys' (only the
arrows), and block is the list of the game's actions held off while seated (by default the combat
ones, and the mouse look with the cursor), for example controls: { pointer: 'keys', block: [] }.
A table
const { Tabletop } = kcds.world; const board = Tabletop.create({ position: { x: 1650.2, y: 2160.5, z: 36.1 }, // the middle of the board's top rotation: 90, // degrees about the vertical axis board: { model: 'dice_board', rows: 8, columns: 8, cellSize: 0.07, height: 0.06 }, seats: [ { position: { x: 1650.2, y: 2161.1, z: 35.6 }, rotation: 180, side: 'white' }, { position: { x: 1650.2, y: 2159.9, z: 35.6 }, rotation: 0, side: 'black' }, ], turnSeconds: 60, // a turn passes after a minute; 0 for no limit });
Cells are { row, column } (or [row, column]), both from 0: rows run along the table's y axis and columns
along its x axis. cellPosition(cell) gives a cell's middle in the world and cellAt(position) the cell
under a position. height is how far above position the pieces stand: the board model's top. The game's
dice_board has its top 6 cm above its middle, and 8 cells of 7 cm fill its playing area. A seat can also have an animation, { fragment, tags }, which plays while the player
sits, and the table a camera (a shot as Camera.startShot takes it) that a seated player sees; with a
camera, they point with the arrow keys. A seat's own camera comes before the table's, so each player
looks at the board from their side. Give the shot a look, { yaw, up, down } in degrees, and the
mouse turns the view that far, as the game's dice players look about their table: the crosshair then
points, and the game's own mouse look is held off. A table belongs to its resource and goes when it
stops.
A seat with chair: true is one of the game's own seats: a chair or bench you place with
SmartObject.spawn (or the level's own) at the seat's position. Players sit on it with the
game's E, with its walk up and sit-down, and the table seats a player when their game reports them
sitting within 0.9 m of position. While they sit there, W, A, S and D move the pointed cell like
the arrows, instead of getting them up; Stand up (U) gives the keys back, and W then gets them
up from the chair, which stands them up from the table. Such a seat shows no Sit hint and takes no
animation:
const { SmartObject, Tabletop } = kcds.world; // The game's bench for one: its seat is at the prefab's origin, the sitter facing along its +y // (here turned to face -y, the board). 'Bench/bench_1place_low_table' sits with both hands on the // table, over a board. SmartObject.spawn({ blueprint: 'Bench/bench_1place_low', position: [1650.2, 2161.1, 35.0], rotation: [0, 0, 1, 0], }); Tabletop.create({ position: { x: 1650.2, y: 2160.5, z: 35.77 }, board: { rows: 8, columns: 8, cellSize: 0.058 }, seats: [ { position: { x: 1650.2, y: 2161.1, z: 35.0 }, chair: true, camera: { position: { x: 1650.2, y: 2160.95, z: 36.27 }, lookAt: { x: 1650.2, y: 2160.53, z: 35.77 }, fov: 55, look: { yaw: 35, up: 20, down: 25 }, }, }, ], });
Pieces
const stone = board.addPiece({ model: 'dicebadge_balatro_badge_silver', cell: [0, 1], side: 'white' }); await stone.moveTo([1, 2]); // a hop, in 0.4 s await stone.moveTo([3, 4], { path: 'slide', ms: 600 }); console.info(board.pieceAt([3, 4]) === stone, stone.cell); stone.remove();
A piece is a prop, so every player of the table's dimension sees it, and its side and data are yours:
whose piece it is, a king's crown, a card's value. Pieces on one cell stack, bottom first in piecesAt(cell).
moveTo moves along a hop (the default), a slide or at once ('jump'). The board's model and the pieces'
are props: the game's own models, such as the dice boards and badges, or meshes your resource ships under
objects/kcds/... (Props, Audio and assets).
Seats, turns and moves
const { Events, Chat } = kcds.core; // Start once both seats are taken. Events.on('tabletopSeated', (table) => { if (table.state === 'idle' && table.seats.every((seat) => seat.player)) { table.start(); } }); Events.on('tabletopTurn', (table, seat, player) => { Chat.sendToPlayer(player, 'Your move.'); }); // A player may pick only their own pieces, and move one only to an empty cell. Events.on('tabletopPieceSelect', (table, player, piece) => piece.side === table.seats[table.seatOf(player)].side); Events.on('tabletopMoveRequest', (table, player, piece, cell) => { if (table.pieceAt(cell) !== null) { return false; } table.nextTurn(); return true; }); Events.on('tabletopEnd', (table, winner, reason) => { Chat.sendToAll(winner ? `${winner.nickname} wins (${reason}).` : 'No winner.'); });
While a game is on (start to finish), only the player whose turn it is can pick and move. Picking a piece
raises tabletopPieceSelect and marks it for that player; clicking a cell then raises tabletopMoveRequest,
and unless a handler answers false the piece hops there. The turn passes when you call nextTurn(), so a
move that captures can keep it, and when turnSeconds run out (tabletopTurnTimeout, which can keep it).
tabletopCellClick comes before all this for every click: answer false to take the click yourself, as a
game that places pieces does. A player who stands up or leaves during a game forfeits it
(tabletopForfeit), and with one player left the game ends with them as the winner.
| Event | Arguments | Refusable |
|---|---|---|
tabletopSit | table, player, seat | yes |
tabletopSeated | table, player, seat | no |
tabletopLeave | table, player, seat, reason | no |
tabletopCellClick | table, player, cell | yes: false takes the click |
tabletopPieceSelect | table, player, piece | yes |
tabletopMoveRequest | table, player, piece, cell | yes; otherwise the piece moves there |
tabletopStart | table | no |
tabletopTurn | table, seat, player | no |
tabletopTurnTimeout | table, seat, player | yes: false keeps the turn |
tabletopForfeit | table, player, seat, reason | no |
tabletopEnd | table, winner, reason | no |
highlight(cells, { players, color, style }) marks cells for some players or everyone, for example the
moves a picked piece can make, and clearHighlight(players) takes the marks away. The players' games
draw the marks flat on the board in the color (0xAARRGGBB, a soft gold by default) and the style:
style | Looks like |
|---|---|
'frame' | a border round the cell with a faint inside (the default) |
'fill' | the whole cell tinted, as for the last move's two cells |
'dot' | a round dot in the cell's middle, as for where a piece may go |
The pointed cell is a light blue frame of its own, and the table marks a player's picked piece with a
'frame' by itself.
Hands and bets
// A card only its holder sees, and a stake from everyone seated. const card = board.addPiece({ model: 'dicebadge_extravalue', cell: [7, 0], side: 'black', visibleTo: player }); if (await board.bet(20)) { console.info(`${board.pot} groschen in the pot`); } card.showOnlyTo(null); // turned face up for all
bet(amount, { item }) takes the stake ('money' by default) from every seated player, all or nothing. When
the game ends, finish(winner) gives the pot to the winner, and a game without one gives everyone back what
they put in.
Saving the board
const saved = JSON.stringify(board.save()); // pieces, their data and the turn board.load(JSON.parse(saved));
Store what save() gives where your resource keeps its data, and load it after a restart.
Tic-tac-toe
A whole game: two players sit, each click on an empty cell places their mark, three in a row wins.
// tic-tac-toe const { Tabletop } = kcds.world; const { Events, Chat } = kcds.core; const MARKS = { x: 'dicebadge_balatro_badge_gold', o: 'dicebadge_balatro_badge_silver' }; const LINES = [ [[0, 0], [0, 1], [0, 2]], [[1, 0], [1, 1], [1, 2]], [[2, 0], [2, 1], [2, 2]], [[0, 0], [1, 0], [2, 0]], [[0, 1], [1, 1], [2, 1]], [[0, 2], [1, 2], [2, 2]], [[0, 0], [1, 1], [2, 2]], [[0, 2], [1, 1], [2, 0]], ]; const board = Tabletop.create({ position: { x: 1650.2, y: 2160.5, z: 36.1 }, board: { model: 'dice_board', rows: 3, columns: 3, cellSize: 0.12, height: 0.06 }, seats: [ { position: { x: 1650.2, y: 2161.1, z: 35.6 }, rotation: 180, side: 'x' }, { position: { x: 1650.2, y: 2159.9, z: 35.6 }, rotation: 0, side: 'o' }, ], }); function wins(side) { return LINES.some((line) => line.every((cell) => board.pieceAt(cell)?.side === side)); } Events.on('tabletopSeated', (table) => { if (table === board && board.state === 'idle' && board.seats.every((seat) => seat.player)) { board.clear(); board.start(); } }); Events.on('tabletopCellClick', (table, player, cell) => { if (table !== board) { return; } const side = board.seats[board.seatOf(player)].side; if (board.pieceAt(cell) === null) { board.addPiece({ model: MARKS[side], cell, side }); if (wins(side)) { board.finish(player, 'three in a row'); } else if (board.pieces.length === 9) { board.finish(null, 'draw'); } else { board.nextTurn(); } } return false; }); Events.on('tabletopEnd', (table, winner, reason) => { if (table === board) { Chat.sendToAll(winner ? `${winner.nickname} wins at tic-tac-toe: ${reason}` : 'Tic-tac-toe: a draw'); } });
Checkers
The full game on an 8 by 8 board: men move one step diagonally forward, capture by jumping over a rival piece, keep the turn to jump again, and become kings (which move both ways) on the far row. A side with no pieces left loses, and a player who walks away forfeits.
// checkers const { Tabletop } = kcds.world; const { Events, Chat } = kcds.core; const MEN = { white: 'dicebadge_balatro_badge_silver', black: 'dicebadge_balatro_badge_plumb' }; const board = Tabletop.create({ position: { x: 1652.4, y: 2158.3, z: 36.1 }, board: { model: 'dice_board', rows: 8, columns: 8, cellSize: 0.07, height: 0.06 }, seats: [ { position: { x: 1652.4, y: 2158.9, z: 35.6 }, rotation: 180, side: 'white' }, { position: { x: 1652.4, y: 2157.7, z: 35.6 }, rotation: 0, side: 'black' }, ], turnSeconds: 90, }); // The piece that jumps again in the middle of a capture run. let jumping = null; function sideOf(player) { return board.seats[board.seatOf(player)].side; } // What a step of `piece` from `from` to `to` is: 'move', 'capture' or null for neither. function stepKind(piece, from, to) { const rows = to.row - from.row; const columns = to.column - from.column; const forward = piece.side === 'white' ? 1 : -1; if (to.row < 0 || to.row > 7 || to.column < 0 || to.column > 7 || Math.abs(rows) !== Math.abs(columns)) { return null; } if (board.pieceAt(to) !== null || (!piece.data.king && Math.sign(rows) !== forward)) { return null; } if (Math.abs(rows) === 1) { return 'move'; } const jumped = Math.abs(rows) === 2 ? board.pieceAt({ row: from.row + rows / 2, column: from.column + columns / 2 }) : null; return jumped !== null && jumped.side !== piece.side ? 'capture' : null; } function canCapture(piece, from) { return [-2, 2].some((rows) => [-2, 2].some((columns) => stepKind(piece, from, { row: from.row + rows, column: from.column + columns }) === 'capture'), ); } function newGame() { board.clear(); jumping = null; for (let row = 0; row < 8; row++) { for (let column = 0; column < 8; column++) { if ((row + column) % 2 === 1 && (row < 3 || row > 4)) { const side = row < 3 ? 'white' : 'black'; board.addPiece({ model: MEN[side], cell: { row, column }, side, data: { king: false } }); } } } board.start(); } Events.on('tabletopSeated', (table) => { if (table === board && board.state === 'idle' && board.seats.every((seat) => seat.player)) { newGame(); } }); Events.on('tabletopTurn', (table) => { if (table === board) { jumping = null; } }); Events.on('tabletopPieceSelect', (table, player, piece) => { return table !== board || (piece.side === sideOf(player) && (jumping === null || piece === jumping)); }); Events.on('tabletopMoveRequest', (table, player, piece, cell) => { if (table !== board) { return; } const from = piece.cell; const kind = stepKind(piece, from, cell); if (kind === null || (jumping !== null && kind !== 'capture')) { return false; } if (kind === 'capture') { board.pieceAt({ row: (from.row + cell.row) / 2, column: (from.column + cell.column) / 2 }).remove(); } if (cell.row === (piece.side === 'white' ? 7 : 0)) { piece.data.king = true; } if (!board.pieces.some((other) => other.side !== piece.side)) { board.finish(player, 'all pieces taken'); return true; } if (kind === 'capture' && canCapture(piece, cell)) { jumping = piece; } else { board.nextTurn(); } return true; }); Events.on('tabletopEnd', (table, winner, reason) => { if (table === board) { Chat.sendToAll(winner ? `${winner.nickname} wins at checkers: ${reason}` : 'Checkers: no winner'); } });
The piece and pointing layer works without a whole game too: any script can put pieces on a table and move
them, and use cellAt and highlight for its own kind of play.