Commit Graph
11 Commits
Author SHA1 Message Date
tiennm99 0003a4510b feat(web): show why a word scored what it did, and let a dead end be claimed
The chain shows each word's score breakdown as small chips beside the total.
A "Bí từ" button next to the input claims a dead end on the player's own
turn, armed the same way resign is; a false claim is answered inline near
the input rather than in the general error banner. A rejection for a word
not in the dictionary offers the one real word a diacritic typo differs
from, filling and focusing the field on click, and a button to report the
word as real, acknowledged with a confirmation once the server has heard it.
2026-09-21 01:55:04 +07:00
tiennm99 90cd679639 feat(web): state the rules, reach the chat from the top of the board, lint the tree
A /rules page says, in one place, what nothing in the app said before:
the chain rule, the clock, what a dead end costs, elimination and the last
player standing, how a word is scored and the reconnect window. Linked
from the landing page and from the board and lobby headers.

The chat control moves above the chain as a pill with the unread count,
where it can be reached on a phone mid-game, and the lobby's folded chat
now carries an unread badge too.

ESLint with the Svelte and JSDoc plugins, run in CI; the real findings
it turned up (missing each keys, untyped timer handles) are fixed.
2026-09-21 01:17:52 +07:00
tiennm99 644379fa06 style(web): fit a finished game and its lobby on one screen
After a game the buttons that start the next one were under the fold on
a 1080p screen: the result panel needed 206px more than the column had,
and 300 with four players and a list of words the loser missed. Every
game ended with a scroll to find "Sẵn sàng".

The duplication went first. The board's scoreboard and the result's
standings are the same names and the same scores, so the board's stops
at the final whistle and the standings are shown for two players as well
as for four. Under a result the lobby lists the seats that exist rather
than three "Còn trống" rows the count line above already accounts for,
the two stat tiles are one line of small type — the score is in the
table now — and the words the loser could have played sit on the line of
their own label rather than under it.

The rest is chrome giving back pixels: a shorter header and footer, a
40px theme button, tighter seat rows and card padding, and the panel's
"Về trang chủ" and "Tải chuỗi từ" side by side instead of stacked, which
wrap back to one each on a phone. Readying, starting and playing a word
all keep their 44px.

Four seats, a loser's suggestions and a full lobby now come to exactly
the height of a 1080p column, with the chain scrolling inside what is
left. A phone still scrolls, which is what a phone is for.
2026-09-10 15:16:25 +07:00
tiennm99 a0029ba5aa style(web): shrink the resign button to secondary weight
At 44px and the same weight as "Gửi" it read as one of the two things to
do on your turn. It is neither: nobody is aiming for it, and it takes
two presses to do anything. Smaller and lighter, still its own outlined
block in danger colour on the right, so it stays easy to find.
2026-09-10 15:02:45 +07:00
tiennm99 11d716e68a feat(game): hold every player to their own turn, and let them leave
A turn belonged to one player, but the screen and the room both let
somebody else act inside it.

The word field took text out of turn — never disabled, so the on-screen
keyboard survives the handover — so a player could spend an opponent's
turn typing a word the submit button was never going to send. It now
refuses the text instead: beforeinput is cancelled, and the composed
character an input method lands past it is taken straight back out.

Giving up was open to any seat at any time. It is a move, played instead
of a word, so only the player to act may spend one; the board offers the
button on their turn and no other, and the room answers not_your_turn.
Somebody who wants out of a game they are not on turn in leaves the
room, which is now allowed mid-game: the seat is freed and the others
are told somebody left, exactly as a reconnect window running out
reports it. Going back to the homepage does that, taking the stored
session with it, so the player arrives back a stranger who can join
again rather than being resumed into the room they walked out of — and
a ready player's readiness is taken back first, or the seat would sit
in the lobby ready and empty until its window ran out.

Two things one player's exit used to cost the player still thinking: the
turn sequence moved even when the turn had not, refusing the word
already in flight, and a wordless turn update wiped the rejection they
were reading. The sequence now moves only when the turn does, and only
an accepted move answers a rejection.
2026-09-10 14:49:25 +07:00
tiennm99 db7093bbe5 fix(web): make the game comfortable to play on a phone and in both themes
Applies the findings of the three UI/UX reviews. The two that cost a
player something every single turn:

- The countdown reddened during the opponent's turn. It read the deadline,
  which the server rewrites on every handover, and never asked whose turn
  it was — a panic spike, every turn, about nothing the player could act
  on. It now mutes when the clock is not theirs, and signals urgency by
  thickening as well as reddening, because green to red is the pair a
  red-green colour blindness cannot separate.

- On a phone the keyboard closed on every turn and could not be reopened.
  `disabled` on the focused word field blurs it, and the recovering
  focus() ran on a WebSocket message, which iOS will not open a keyboard
  for. The field stays editable and the submit button carries the turn
  instead; focus is asked for inside the gesture that submitted. A draft
  aimed at a syllable the game has moved past is now corrected rather
  than kept.

The board was also not holding together as the game grew. `min-height`
on the shell left nothing with a definite height to shrink against, so
the chain kept its full height and the page got taller every turn,
walking the word field and the chat off the bottom of the screen. The
shell now has a real height, the chain gives up its space first with a
floor so it cannot vanish, and `main` scrolls only as a last resort.

Online play had one true dead end: nothing on the pre-room screen showed
the connection at all, so a server that was down left an enabled button
and a screen that never changed — while each impatient tap sent a real
CreateRoom until the rate limiter answered. The badge is there now,
requests disable their button, a stall says so, and the lobby draws the
room's refusals beside the button that produced them instead of in a
chat panel below the fold. A dropped player gets a retry rather than
waiting out the backoff. An invite link asks a first-time guest for a
name instead of seating them as "Người chơi" with no way to fix it.

Accessibility and palette:

- The turn change is announced. It is the state the whole game hangs on
  and it reached nobody who was not looking at that line.
- --accent darkened to #12692f: accent text on its own tint measured
  4.29:1. Every existing pair improves, white-on-accent included.
- --border-strong added for the boundary of anything you type into or
  press, which needs 3:1 and had 1.47:1 light, 1.78:1 dark.
- One focus ring, one 44px icon-button recipe, spacing and type scales,
  safe-area gutters, per-route titles, a heading per screen, a skip
  link, and reduced motion that stops the connection dot pulsing rather
  than flickering it a hundred thousand times a frame.

Tests follow the two interactions that changed shape: resign and kick
confirm inline with a second press, because confirm() blocks the frame
loop the countdown runs on and hesitating could cost the turn it was
protecting. joinRoomSeated waits for the seat before anybody speaks —
the server replays a seat only the conversation from when it joined, so
the old helper was racing the handshake.

Verified: 189 unit tests, 40 end-to-end, no type errors, and the
palette and layout measured at 360/400/768/1280 in both themes.
2026-09-10 13:04:13 +07:00
tiennm99 f49b906519 fix(web): keep the whole board on screen as the chain grows
Giving up was a button under the chain, and the chain is the one part of
the board with no ceiling: the longer a game ran, the further off the
bottom of the screen it went — worst exactly when a player wants it.

It sits above the list now, on the right, away from the field a player
is typing into, in the danger colour and at a full 44px. The chain takes
the height that is left and scrolls inside itself, so the syllable, the
clock and the word field stay where they are however long it gets.

The error banner's dismiss cross gets the same 44px, negative-margined
back out so the banner keeps its height.
2026-09-09 00:36:04 +07:00
tiennm99 e19b083dc0 feat(online): seat the chat beside the game and score the series
A room screen wide enough for it is two columns now: the game or the
lobby on one side and the room's conversation on the other, so neither
has to be scrolled past to reach the other. A phone keeps one column,
the game first, with the chat folded behind an unread count while a game
is on. One chat panel spans both phases by staying in the same place in
the markup — a remounted panel reopened having read nothing, and handed
the reader the lobby's conversation back as unread mail.

The room also keeps the score of the series. PlayerSlot carries the
games a seat has won since the room opened, credited before the lobby is
broadcast so the players see it the moment a game ends, and shown in the
lobby and beside each player's score on the board. It belongs to the
seat rather than to the room, because vacating one is exactly when the
name on it stops meaning the same person.

The conversation reads as a log — one line per message, "name: text",
each seat in its own colour — rather than as a stack of bubbles, which
stops being legible once four people are talking. ChatMessage carries
the seat that spoke, so a line is coloured by what the server said
rather than by matching display names, and a line whose seat has been
vacated is in nobody's colour.

The board's word button gets a test id of its own: it and the chat's
send button both read "Gửi", and the two are now on screen together.
2026-09-08 11:28:19 +07:00
tiennm99 4e2e9e1433 feat(online)!: seat two to four players in a room
A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.

Failing a turn eliminates that player rather than ending the game. The
syllable and the used words survive them, the turn passes to whoever is
next, and the last player standing wins. Two seats is that same rule seen
from close up, which is why there is one implementation of it and not two.

A dead end still costs the first player to face it their own clock, as
before: they get their turn, and lose it. Everyone behind them has already
seen that board, so they go out together rather than each sitting out a turn
limit they cannot use — which leaves the player who closed the position
standing, the same outcome two players get.

A player who is knocked out keeps their seat. They watch the rest of the
game, chat included, with only the word input gone, and everybody lands back
in the same lobby when it ends. The result screen is the whole table, ranked
by who outlasted whom, with each score reported beside the place rather than
deciding it.

The turn clock is deliberately not paused for a seat that has dropped, so a
player who loses their connection on their own turn loses it the way anybody
else would. Their reconnect window decides only whether they are still in the
game afterwards. Any number of windows can be open at once, settled by one
timer armed for the nearest of them.

Starting waits for every guest, not merely the first: a room of four that
began on one yes would have dealt three people a turn they never agreed to.
Kicking names a seat and is still refused on a player who is ready, and on
the owner's own — leaving is what an owner who wants out does, and it hands
the room on. Joining stays a lobby thing: a room with a game running turns a
latecomer away even with seats going spare, because there is no way to hand
somebody a game already in progress.

BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that
could only ever describe a second player, OpponentLeft is retired in favour
of presence on RoomState, and suggestions move to the new PlayerEliminated —
they describe the position that beat a player, which by the end of a longer
game is nobody else's position. ProtocolVersion goes to 2, so a client built
against 1 is refused with a readable error rather than decoding a frame that
now means something else.
2026-09-08 10:56:14 +07:00
tiennm99 0b507c3537 feat(online): let the two people in a room talk to each other
Chat belongs to the room rather than to a game, so it works in the lobby
while they agree on one, during it, and in the lobby it ends in.

A player is replayed what was said while they held their seat. That is what
a refresh restores, and it is also the boundary: a room code is pasted into
group chats by design, so somebody who redeems one starts at silence rather
than reading what the last two people said. A seat records where the
conversation stood when it was filled; the room keeps twenty lines and no
more, so a room that lives all day cannot grow.

Text is untrusted input rendered in a stranger's browser, so it goes through
the filter nicknames already used — now with a cap on stacked combining
marks, which that filter admitted. Twenty runes made mark stacking a
curiosity; two hundred make it a glyph cluster tall enough to cover the
board, and it would sit in the history being replayed to everyone who
followed.

Talking is not playing. A chat message does not reset the room's idle clock,
or one open tab could hold a room and its code for the life of the process
by typing into it once every nine minutes. It does not spend the move budget
either, and a line to a player who cannot keep up is dropped rather than
allowed to close their session — losing a line is recoverable, losing a
session mid-game costs them the game. A history is not droppable that way:
it is the frame that corrects a whole panel, and there is nothing behind it.

When a seat is vacated its words stay and its author goes, name included,
and the player who stayed is re-synced rather than left holding a name that
the next person through the door could ask for.
2026-09-08 09:49:54 +07:00
tiennm99 ffa13a0f6e feat(web): add online 1v1 play
Create a room and share a six-character code or an invite link; join by either.
The waiting room shows the code until an opponent arrives. During a game the
opponent's disconnection raises a banner with the grace countdown, and their
return clears it. When the game ends, the rematch prompt appears only while an
offer is actually open, so there is never a button that can only produce an
error.

The board is now one component both modes render, which is what makes online
play the same screen as the bot game rather than a second implementation of it.

Room codes are normalized by stripping separators and reading the code out of a
pasted invite link, and nothing else. Filtering out every character outside the
alphabet would turn any sentence into six plausible letters and send the player
to a room that was never theirs.

The socket now opens when the player asks for a room, not on arrival: the
handshake carries the nickname once, and a socket opened before they typed it
introduced them under whatever was stored beforehand. A tab that already holds
a session reconnects immediately instead, so refreshing mid-game returns to the
game. A resume the server cannot honour is dropped quietly rather than opening
the lobby with an error the player did nothing to cause.

The client also notices a socket that fails without closing. Nothing did
before, so the page kept showing a live connection and a running countdown over
a socket nothing could reach. A tick that fired late is ignored, because a
backgrounded tab has its timers throttled and would otherwise close a healthy
connection every time the player switched away.
2026-09-05 14:18:03 +07:00