I've been debugging the web version of my board game for three months. "Release Any Price" is a card game about chaos in IT development, and I already wrote about the first version of its board in August: then the board was just learning to play scenes from the playground, and a colleague had just merged the release and the protection. Since then, the game has reached beta, and you can play it via the link: https://mythhand.github.io/ReleaseBoardGameP2P/.

Meanwhile, I come across posts on social media about how people are building games over the weekend with AI: 3D, multiplayer, published, and you can play them. We have the same tools, and I don't understand why they have weekends, while I have months.

We are still two people plus models in the project. The developer colleague maintains the engine and the network, while I handle everything visible on the screen and the game rules. The code is written by the models: I use Opus, and the colleague uses Opus and Astra. I can read the code, but I can't understand it, so the analysis will start from what I see on the screen for three months straight, like a day in the life of a groundhog.

Without the device, the article can't be complete: how the board communicates with the host or where everyone gets the same table image, you can't see from the screen. In such places, I'll insert quotes from colleagues or from the model description; I won't try to retell it myself.

Card movements

I choose a card from my hand, it lifts up and shows a preview. I take it with the mouse and place it on the table, and the fan closes behind it. When I release it, the card lands in the center, and the same card is laid out in the same place for the opponents. One second of the game, and I've long stopped seeing the card, only the seams between movements.

Розыгрыш релиза с оплатой
Release auction with payment

I wanted it like at the table

I've played a lot with my board game, and I wanted the browser to give the same feeling as holding cards at the table. I decided this early on, and from the first movements in the play area: the map on the screen flows from place to place along with the rotation and scale, without teleporting, so that the animations on the screen clearly show what is happening. The target arrow and the map with a pulsating path I saw in big PC card games. And no linear animations, they look unnatural.

Not everything from the table was worth bringing to the screen. I didn't animate the shuffling of the deck, it would only slow down the game. And lengthy game moments at the table got a quick playthrough on the screen.

What had to be invented for the browser

I didn't find a ready library that would move cards in the browser as they do on the table. I laid out the cards with layout, and all the paths were planned in advance: from where, to where, along which curve, through which intermediate states, and what the neighboring cards do during this time. For this purpose, an animation dictionary was created: dozens of modules from which each movement on the table is assembled.

Some things immediately stood out: card flipping, target arrow. But what you don't think about when playing at a physical table had to be worked out. A played card stands at a random angle so that the plays don't look like the same animation. For the discard, I worked out the view as a pile. A separate story is the entry of a card into the fan. At first, the card was inserted straight and then teleported behind the neighboring card that covers it. Then a curve appeared: the card comes from the side, over the fan, and dives under it at the top of the curve. The last card has its own exception: there's nothing for it to dive under, and the curve becomes straight.

Вход карты в веер с визуализацией пути
Entry of a card into the fan with path visualization
Code

The path of entry into the fan is calculated by one geometry-related function. The control point of the quadratic curve is positioned one interval to the left of the position, the angle of ascent is determined by how much higher the position is than the map, and is limited to 40 degrees. At the extreme position, there is no neighboring map to the right, the offset of the control point is zero, and the same formula gives a straight line.

const STEP_ANCHORS: [number, number][] = [ [2, 136], [8, 92], [20, 54], ] export function handStep(n: number): number { const [[x0, y0], [x1, y1], [x2, y2]] = STEP_ANCHORS const l0 = ((n - x1) * (n - x2)) / ((x0 - x1) * (x0 - x2)) const l1 = ((n - x0) * (n - x2)) / ((x1 - x0) * (x1 - x2)) const l2 = ((n - x0) * (n - x1)) / ((x2 - x0) * (x2 - x1)) return y0 * l0 + y1 * l1 + y2 * l2 } const APPROACH_REACH = 1 const APPROACH_RISE_DEG = 40 const PATH_STEPS = 24 export function insertPath(from: Point, to: Point, slot: number, total: number): Point[] { const reach = slot === total - 1 ? 0 : handStep(total) * APPROACH_REACH const rise = Math.min( Math.max(Math.atan2(to.y - from.y, Math.max(reach, 1)), 0), (APPROACH_RISE_DEG * Math.PI) / 180, ) const cx = to.x - reach * Math.cos(rise) const cy = to.y - reach * Math.sin(rise) const path: Point[] = [] for (let i = 0; i <= PATH_STEPS; i += 1) { const t = i / PATH_STEPS const u = 1 - t path.push({ x: u * u * from.x + 2 * u * t * cx + t * t * to.x, y: u * u * from.y + 2 * u * t * cy + t * t * to.y, }) } return path }

The layer switches the landing module into the fan: the first 140 milliseconds out of 480, the map flies over the fan, then it receives the layer of its position. Maps in the fan overlap, the layer determines which map covers which. Switching occurs at the point in the flight when the map is farthest from the neighboring map on the right, which will cover it. If the layer is switched when the map has already arrived at the fan, the layer change will look like a sudden transition.

Dealing with opponents was simpler. The opponent on the screen is a block, their cards are hidden, and it's meaningless to see the hidden fan. The card that goes to them flies into this block and disappears: it was taken from the table, and who took it is visible from the block.

Technical side

The table's image is defined by the projection of the game state from the host. For each action, the engine responds with a batch of events. The board planner reads the batch together with the projection of what it was before this batch, and collects a tact from the modules dictionary for each event that has a movement on the table. Tacts are played one after another in sequence: while a tact is playing, the board shows the projection it started with, and when the sequence is empty, it shows the current projection from the host. The last frame of a tact coincides with the beginning of the next. Events arrive in each browser without information that this player cannot see, for example, in someone else's draw, there is no card name.

It wasn't possible to consider everything in advance

Scenes moved onto the board using the same modules, and the first games showed how much the playfield didn't know about the table. In the playfield, the scene ended with the card landing. On the board, each place where a card lands requires its own state, which the game tracks and shows to everyone the same way.

Center of the table. I release the card, choose the card for which I pay, and on the opponents' screens nothing happens: as if I do nothing. At the same time, everything on my screen looked normal. It turned out that the table didn't know that the card was already in the center, and it was counting it in the deck: when I played a pair, the laid-out card teleported back into the deck and then came out to the center again. The same issue happened with the host, its "play the card" notification came to a card that was already in place. This was resolved by a fix in the engine: it now tracks cards laid out in the center so that the game maintains the table state and shows the same thing to everyone. I rarely dive into the engine, and this was one of those cases.

Technical side

The center of the table is part of the game state: everything that all browsers should converge on is stored in the engine's state by the host and sent as projections. The card laid out in wait for a pair didn't follow this rule: it was only in the interface of the one who laid it out, and the engine considered it in hand, with each browser having its own center. Laying out and returning the card became engine actions. The engine marks the card as laid out in the state of the place, so it doesn't leave the hand. The event of laying out a card is open, and the projection carries laid-out cards from each place, with all browsers drawing from the same state. The board scheduler skips the event of its own lay-out: the card is on the place since the player laid it down, and the beat doesn't build upon confirmation from the host.

Turn state panel. It worked until the game started encountering situations that the panel didn't know about, and these states had to be added manually.

Discard pile. Cards in the discard pile changed their order or orientation, and the discard pile was reassembled through teleporting layers and positions.

DDoS via AI card. I attack DDoS on the AI release in the opponent's release zone, and the card gets discarded, although according to the rules, the AI card shouldn't be discarded and should return to the event deck. By the game's logic, the card was added to the event deck, while also lying in the discard pile. The table didn't count cards and didn't catch such duplicates, leading to more checks.

And a lot of other things.

DDoS по AI-релизу и AI-мониторингу соперника на стенде
DDoS via AI release and AI opponent monitoring on the table

Where did the third month come from

The project is divided into three parts based on its commit history. In June, I was working on the style and playfield: cards, hand, table, and animation dictionary. A colleague was setting up the monorepo, network, and lobby on the same days. In July and early August, the engine and table appeared, and on August 10th, the first playable game session took place, still without animations. From mid-August, the table started receiving animations from the playfield scene by scene: dealing, playing, drawing, arrow, pair, release, and defense. From this point on, the animations, logic, and their integration were being debugged to make everything fit seamlessly.

Третий месяц вайб-дебажу настолку в браузере. На что уходит столько времени &#x2F; Хабр

Screen check

I debug on the table: the same table and engine, only the game state is built from a preset, and one scene can be played as much as needed. I play a card, see what's wrong, describe the models, she fixes it, and I check again. I described what I saw to her for a long time. Then realized that interface animations can be written to the log: sent the log, and the model saw the same frames as I did with my eyes, and under long logs, it made its own analyzer that names problematic areas.

Log and analyzer response: DDoS attack

From the stand's log

{"t": 7705.6, "from": "stand", "kind": "action", "data": {"action": {"type": "PLAY", "player": "you", "card": "attack-ddos#2", "target": {"kind": "release", "player": "p2", "slot": "backend"}, "at": 1791130483830}, "events": [{"type": "attacked", "attacker": "you", "card": "attack-ddos", "sudo": false, "target": "p2", "id": 106}, {"type": "discarded", "player": "you", "card": "attack-ddos", "reason": "attackSpent", "id": 107, "parent": 106}, {"type": "releaseReturned", "player": "p2", "slot": "backend", "card": "release-backend", "id": 108, "parent": 106}, {"type": "discarded", "player": "p2", "card": "support-code-review", "reason": "destroyed", "id": 109, "parent": 108}], "table": {"turn": "you", "pending": null, "window": null, "shown": {"you": [], "p2": [], "p3": []}}}}{"t": 7725.2, "from": "board", "kind": "beat", "data": {"key": "attack:106", "exclusive": false, "base": {"hand": ["DDoS attack-ddos#2", "DDoS attack-ddos#4", "DDoS attack-ddos#3", "DDoS attack-ddos#5"], "shown": ["you:DDoS"], "pending": null, "centreAttack": null}}} {"t": 9940.6, "from": "board", "kind": "beatEnd", "data": {"key": "attack:106", "ended": {"hand": ["DDoS attack-ddos#4", "DDoS attack-ddos#3", "DDoS attack-ddos#5"], "shown": [], "pending": null, "centreAttack": null}}}

From the analyzer's analysis

attack-ddos 7741 faceWrap