Alt text: The phone trading screen before (1.36) and after (1.37)
Cá Viên Chiên runs in the browser and is mostly played on phones. After a month of adding features, the trading screen had become crowded, so I ran an audit at 375 px. The numbers weren't kind:
| What | At 375 px |
|---|---|
| HUD labels (Day / Money / Rating) | 10 px |
| Morning tabs | 11 px, 55 × 40 |
| HUD buttons | 32 × 32 (30 × 30 at 320) |
| Heat buttons Low / Mid / High | 75 × 34 |
| "From day 10" on a locked row | clipped: needs 78 px, has 76 |
The usual minimums are 12 px for text and 44 × 44 for touch targets. The constraint that made it hard: the trading screen already uses every pixel of a 375 × 820 phone, so nothing could simply get bigger.
A mock you can click, not a picture
I didn't draw this. The mock was a page that loads the real game twice, side by side: the live build and the proposal, both on the same saved day. A bar switches the width (320 / 375 / 414), the screen and a few variants. A "Measure both" button lists every text node under 12 px, every tap area under 44 px and every clipped label in each frame.
That turned design review into a conversation with numbers. Every decision got made on the actual screen at the actual width, and many of them were reversed after a first try (more on that below). By the end, every morning tab went from 4–38 small texts, 9–39 small tap areas and up to 6 clipped labels to 0 / 0 / 0, with the stage the same size as before.
Heat: three words became three flames
The heat control was a row of three word buttons under the woks. On a phone it is now a column of square buttons beside the woks, each with a single flame in three sizes (41 / 59 / 79 % of the button's height). The chosen one flickers (transform only). The words stay for screen readers:
Alt text: Eight evenly spaced woks with the heat column on the right: large, medium and small flame, then the delivery phone
<button class="heat-btn" data-arg="2">
<span class="flame f3"><svg viewBox="2 1 12 19">…</svg></span>
<span class="sr-only">High</span>
</button>
One lesson I had to learn twice here: check icon centring by measuring, not by eye. My first version hid the word with display: none in one place and with a visually-hidden class elsewhere. In one case the hidden span still took a grid row, so the flame sat a few pixels high. The flame's SVG also had a loose viewBox, which added extra padding on one side. The fix was a truly out-of-flow .sr-only (absolute, 1 × 1, clipped) and a viewBox cropped tight to the path. After that, the measured gaps on all four sides were equal.
Small buttons got a bigger tap area without changing how they look:
.cvc .screen-play .heat :is(.heat-btn, .phone-btn)::before {
content: '';
position: absolute;
inset: -1.5px -5px; /* a 34 px square taps like 44 wide */
}
Evenly spaced woks in pure CSS
What I wanted for the woks was precise: the gap between woks should equal the gap to the panel's edges, both across and down, and the woks should fill whatever space is left. The number of woks changes with upgrades (4 to 10, in two rows).
With the panel as a container, that is one min():
.cvc .screen-play .fryer-wrap {
--hb: 44px; /* heat column */
--g: 10px; /* gap */
--hcap: clamp(145px, 26.5cqh, 210px);
--wa: calc(100cqw - 2 * var(--padx) - 12px - var(--hb) - 8px); /* width left for woks */
}
.cvc .screen-play .fryer {
/* the largest wok that fits both across and down */
--d: min(
calc((var(--wa) - (var(--cols) + 1) * var(--g)) / (var(--cols) + 0.04)),
calc((var(--hcap) - 3 * var(--g)) / 2.04)
);
grid-template-columns: repeat(var(--cols), var(--d));
grid-template-rows: repeat(2, var(--d));
justify-content: space-evenly; /* equal gaps, edges included */
row-gap: var(--g);
}
--cols comes from JavaScript (Math.max(2, Math.ceil(n / 2))). The 0.04 covers the progress ring, which sticks out 2 % on each side. space-evenly does the rest: whatever width the woks don't use is shared equally between the gaps and the edges.
The hint that should take no space
There was a line under the customers for tips like "Tap a tray item to start frying". It had its own row, and I wanted that row gone. The hint should float between the customers and the woks, appear only when it helps, and give its space to the customers.
My first try removed the row and broke everything. The play screen is a flex column with justify-content: space-between, so the freed height didn't go to the customers. It spread evenly into every gap on the screen, and all the rows below drifted. The second try measured continuously and packed the rows together.
What worked:
- Measure the gap between rows once, with the hint row still in place.
- Pin that gap (
row-gap) and switch offspace-between. - Give the customers' street
flex: 1so it takes exactly the freed height. - Position the hint absolutely, centred on the seam between the customers and the woks.
function layoutHint() {
play.classList.remove('hint-float'); // measure with the row in place
if (getComputedStyle(play).display === 'grid') return; // the wide layout keeps its row
const gap = hint.getBoundingClientRect().top - street.getBoundingClientRect().bottom;
play.style.setProperty('--row-gap', `${Math.max(0, gap).toFixed(2)}px`);
play.classList.add('hint-float');
const mid = (street.offsetTop + street.offsetHeight + woks.offsetTop) / 2;
play.style.setProperty('--pill-top', `${Math.round(mid - hint.offsetHeight)}px`);
}
.cvc .screen-play.hint-float { justify-content: flex-start; row-gap: var(--row-gap); }
.cvc .screen-play.hint-float > .customers { flex: 1 1 auto; }
.cvc .screen-play.hint-float > .hint { position: absolute; top: var(--pill-top); left: 50%; transform: translateX(-50%); }
.cvc .screen-play.hint-float > .hint[data-on='0'] { opacity: 0; visibility: hidden; }
It runs before first paint and again from a ResizeObserver keyed on the stage size, never on a timer. Showing and hiding only toggles opacity and visibility, so the pill never moves anything.
The rule I wrote down afterwards: when you remove a row, its space goes only where you decided, and you measure every other gap before and after.
Telling drinks apart at 22 px
On a phone, an order card shows the drink as a tiny cup, and five drinks in five similar cups were impossible to tell apart. Numbers (1–5) looked like quantities ("two cups?"). Initials worked: Milk tea → MT, Kumquat → K.
Alt text: Phone order cards: dishes on the left, the patience ring along the border, and a teal chip with the drink's initials (H, K, S) by each cup
The catch is Vietnamese: Sữa, Sâm and Soda all start with S. So the code checks for clashes per language and falls back to two letters only where needed:
const first = (n: string) => n.split(/\s+/).map((w) => w[0]).join('').toLocaleUpperCase(lang);
for (const [id, name] of drinks) {
const clash = drinks.filter(([, other]) => first(other) === first(name)).length > 1;
initials[id] = clash ? name.replace(/\s+/g, '').slice(0, 2).toLocaleUpperCase(lang) : first(name);
}
// en: K, MT, H, LT, S vi: T, SỮ, SÂ, C, SO
toLocaleUpperCase('vi') keeps the diacritics, so the two S drinks read SỮ and SÂ.
The rule that held it together
Every change followed one rule: a state change may repaint, but it may never move or resize anything. The chosen flame, the hint pill, the weather chip, a locked row unlocking: all of them swap content inside a box whose size is fixed by its role.
A screenshot can't prove that, because layout shift happens between two states. The browser walk did it by measurement instead. For 7 sizes × 2 languages × 2 engines, it captures getBoundingClientRect() of every row before and after each interaction and fails on any difference. It also checks text under 12 px, tap areas under 44 px, clipped labels and sideways scroll. Its last run, on production, was 438 / 438 checks in Chromium and 430 / 430 in WebKit.
Alt text: The wide layout before and after: the heat buttons moved from a row under the woks to a column beside them
The wide layout got the same heat column, so the game looks the same on every device. The previous post covers the two invisible layout shifts I found along the way. You can play it at cavienchien.net.




Top comments (0)