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.

EventArgumentsRefusable
tabletopSittable, player, seatyes
tabletopSeatedtable, player, seatno
tabletopLeavetable, player, seat, reasonno
tabletopCellClicktable, player, cellyes: false takes the click
tabletopPieceSelecttable, player, pieceyes
tabletopMoveRequesttable, player, piece, cellyes; otherwise the piece moves there
tabletopStarttableno
tabletopTurntable, seat, playerno
tabletopTurnTimeouttable, seat, playeryes: false keeps the turn
tabletopForfeittable, player, seat, reasonno
tabletopEndtable, winner, reasonno

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:

styleLooks 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.