// The nối từ wire contract. // // This file is the single source of truth for everything crossing the // WebSocket. Both the Go server and the JavaScript client are generated from // it; neither side hand-writes a message type. Every frame is a binary // protobuf message — there is no JSON fallback and no text frame. // // Compatibility rules, because a deployed client outlives a deploy: // - changes are additive only; // - a removed field's tag goes to `reserved`, never to another field; // - Hello.protocol_version lets the server refuse an incompatible client // with a readable error instead of failing to decode. syntax = "proto3"; package noitu.v1; option go_package = "github.com/tiennm99dev/noitu/server/gen/noitu/v1;noituv1"; // Difficulty selects the bot's strategy in a vs-bot game. enum Difficulty { DIFFICULTY_UNSPECIFIED = 0; DIFFICULTY_EASY = 1; DIFFICULTY_MEDIUM = 2; DIFFICULTY_HARD = 3; } // RejectReason says why a submitted word was not accepted. It is deliberately // a separate type from the engine's internal game.RejectReason: the wire // contract must not change every time the engine is refactored. The mapping // lives in server/internal/wsapi/convert.go. enum RejectReason { REJECT_REASON_UNSPECIFIED = 0; // Fewer than two syllables. REJECT_REASON_TOO_FEW_SYLLABLES = 1; // First syllable does not match the required syllable. REJECT_REASON_WRONG_LINK = 2; REJECT_REASON_NOT_IN_DICTIONARY = 3; REJECT_REASON_ALREADY_USED = 4; REJECT_REASON_NOT_YOUR_TURN = 5; // The turn deadline passed before the word arrived. REJECT_REASON_TIMEOUT = 6; // The game had already finished. REJECT_REASON_GAME_OVER = 7; } // GameEndReason says how a finished game ended. enum GameEndReason { GAME_END_REASON_UNSPECIFIED = 0; GAME_END_REASON_TIMEOUT = 1; GAME_END_REASON_NO_LEGAL_MOVE = 2; GAME_END_REASON_OPPONENT_LEFT = 3; GAME_END_REASON_RESIGNED = 4; } // --------------------------------------------------------------------------- // Client -> server // --------------------------------------------------------------------------- // Hello opens a session. nickname is a *request*: the server sanitizes it and // reports what it actually stored in Welcome.accepted_nickname. message Hello { uint32 protocol_version = 1; // Empty on a fresh session; a token from a previous Welcome to resume one. string resume_token = 2; string nickname = 3; } message StartBotGame { Difficulty difficulty = 1; } message CreateRoom {} message JoinRoom { string room_code = 1; } // SubmitWord carries the turn it was typed for. The server rejects a // turn_seq that is not the current one, which makes a double-submit or a // submission racing the timeout detectable rather than silently applied. message SubmitWord { string word = 1; uint32 turn_seq = 2; } message Resign {} // SetReady is a guest declaring themselves ready, or taking it back. // // Only guests have a readiness to set. The owner's is implied by StartGame: // asking for the game to begin is the same statement, and a second flag they // would always have to set first buys nothing. message SetReady { bool ready = 1; } // StartGame is the owner beginning the game the lobby has agreed on. It is // refused unless at least one guest is seated and every seated guest is // connected and ready. message StartGame {} // KickPlayer is the owner freeing one seat. Refused while that player is // ready: readiness is a commitment, and a player who has made it is not // something the owner gets to overrule. message KickPlayer { // Which seat, from RoomState.players. A room holds up to four people, so // "the other one" stopped being an answer. string player_id = 1; } // LeaveRoom gives up a seat without dropping the connection, which is what // makes a room outlive one game rather than one visit. Refused while the // sender is ready — unreadying first is the deliberate friction. message LeaveRoom {} // SendChat is one line of text from a seated player to the other. message SendChat { string text = 1; } // Ping echoes the client clock so Pong can expose the offset between the two. message Ping { int64 client_time_ms = 1; } message ClientMessage { // 8 was RequestRematch, retired with the rematch handshake: the lobby is // where a next game is agreed now. Reserved at message level because a // oneof cannot hold the statement itself. reserved 8; oneof payload { Hello hello = 1; StartBotGame start_bot_game = 2; CreateRoom create_room = 3; JoinRoom join_room = 4; SubmitWord submit_word = 5; Resign resign = 6; Ping ping = 7; SetReady set_ready = 9; StartGame start_game = 10; KickPlayer kick_player = 11; LeaveRoom leave_room = 12; SendChat send_chat = 13; } } // --------------------------------------------------------------------------- // Server -> client // --------------------------------------------------------------------------- message Welcome { string session_id = 1; string resume_token = 2; uint32 protocol_version = 3; // What the server stored after sanitizing Hello.nickname. The client must // display this, not the string it sent. string accepted_nickname = 4; } // PlayedWord is one accepted move. word is the canonical spelling, which can // differ from what the player typed; typed preserves the raw input so the UI // can show that a correction happened instead of silently rewriting the text. message PlayedWord { string word = 1; bool by_me = 2; uint32 points = 3; uint32 syllables = 4; string typed = 5; // Which seat played it. by_me answers "was this mine"; with four people at // the table the chain also has to say whose the other words were, and a seat // id says that without the client matching display names. string player_id = 6; // What the word means, at most five senses. Empty when the dictionary has // no definition for the word; the client then says so rather than hiding // the row's panel, so every word in the chain behaves the same. repeated Sense meanings = 7; } // Sense is one definition of a word as Wiktionary gives it: the part of // speech it sits under, in Vietnamese ("danh từ"), empty when the heading was // not one the builder knows, and the definition stripped of markup at at most // 200 characters. Plain text both: the client renders them as text, never as // markup. message Sense { string pos = 1; string gloss = 2; } // PlayerSlot is one seat in the room, rendered for one recipient. // // The recipient's own row is in the list like everybody else's, marked by // is_me. That is deliberately the only way to find yourself: a separate // i_am_owner alongside an is_owner in the list would be two encodings of one // fact, and two ways for a client to disagree with the server. message PlayerSlot { // Stable for as long as this player holds the seat. Not stable across a // seat being vacated and refilled, which is exactly when a name stops // meaning the same person too. string player_id = 1; // Always server-sanitized, as everywhere else another player's name appears. string name = 2; bool is_me = 3; bool is_owner = 4; // Always false for the owner, whose readiness is StartGame itself. bool ready = 5; // False while this player is inside their reconnect window. bool connected = 6; // How many games this seat has won since the room opened. A room outlives // its games, so the tally belongs to the seat rather than to any one of // them; it starts again when the seat is vacated, because by then the name // on it no longer means the same person. uint32 wins = 7; } // PlayerScore is one player in a running or finished game. // // Separate from PlayerSlot because they answer different questions: a slot is // about the room, a score is about the game being played in it. A player who // has been eliminated still has both — being out of the game is not being out // of the room. message PlayerScore { string player_id = 1; string name = 2; bool is_me = 3; uint32 score = 4; // True once this player has been knocked out. They keep their seat, their // score and their words; they simply no longer get a turn. bool eliminated = 5; bool connected = 6; // Final placing, 1 for the winner. Zero while the game is still running, // which is what tells the two apart without a second field. uint32 rank = 7; } // GameStarted is rendered per recipient: my_turn is true for exactly one // player. message GameStarted { string opening_word = 1; string current_syllable = 2; bool my_turn = 3; // Absolute server timestamp. The client counts down to it and never trusts // its own clock for authority. int64 deadline_unix_ms = 4; uint32 turn_seq = 5; uint32 turn_limit_ms = 6; // Everyone playing, in turn order. repeated PlayerScore players = 7; // Whose turn it is. my_turn above says whether it is yours; this says whose // it is when it is not, which a two-player game never had to. string turn_player_id = 8; // The opening word's senses, as PlayedWord.meanings for a played word. repeated Sense opening_meanings = 9; } // TurnUpdate follows every turn change and goes to every player, serialized // once per recipient so by_me and my_turn are correct for each. // // played is absent when the turn moved without a word being played, which is // what an elimination does: the syllable and the used set survive the player // who could not answer them. message TurnUpdate { // 6 was my_score and 7 was opponent_score. Both are in players below now, // where a four-way game can express them. reserved 6, 7; PlayedWord played = 1; string current_syllable = 2; bool my_turn = 3; int64 deadline_unix_ms = 4; uint32 turn_seq = 5; uint32 chain_length = 8; // Everyone playing, in turn order, with scores as they stand. repeated PlayerScore players = 9; string turn_player_id = 10; } message MoveRejected { RejectReason reason = 1; string word = 2; uint32 turn_seq = 3; } // GameOver is rendered per recipient: i_won is true for exactly one player, // the one still standing when everybody else had been eliminated. message GameOver { // 3 was my_score, which is now the standings row where is_me is true. 5 was // suggestions, which moved to PlayerEliminated: they describe the position a // player was stuck on, and by the time a game ends that is no longer the // position anyone but the last player out was looking at. reserved 3, 5; bool i_won = 1; GameEndReason reason = 2; uint32 chain_length = 4; // The final table, best first: the player left standing, then the others in // reverse order of elimination. Outlasting somebody is what beats them, so // the ranking is finishing order and each score is reported beside it rather // than deciding it. repeated PlayerScore standings = 6; } // PlayerEliminated is one player knocked out of a game that is still running. // // Rendered per recipient like everything else in a room, and the only message // whose contents differ by more than a flag: suggestions are filled in solely // for the player who went out, because they are the one who was stuck. message PlayerEliminated { string player_id = 1; string name = 2; bool is_me = 3; GameEndReason reason = 4; // A few words the position still had, for the player who just lost it. An // empty list is itself the answer: nobody could have answered that syllable. repeated string suggestions = 5; } // ServerError.message is a UI key such as "room_not_found", never prose: all // Vietnamese copy lives in the frontend so it stays in one place. message ServerError { string code = 1; string message = 2; } message Pong { int64 client_time_ms = 1; int64 server_time_ms = 2; } // RoomState is the whole room, rendered for one recipient, and it is the only // thing the lobby screen is built from. Sent on every change a player could // see — a seat filled or freed, a readiness set, an owner promoted, somebody // dropping or coming back — and again on resume, so a client that missed a // frame recovers by being told the state rather than by replaying the events // that led to it. // // It describes the room, not the game, so it is meaningful during one too: // while a game runs this is what carries presence, which is why there is no // separate message for a player disconnecting. message RoomState { // 2 was i_am_owner and 4 was i_am_ready: both are in the recipient's own // row in players now, and two encodings of one fact are two ways for a // client to disagree with the server. 5 through 8 were the opponent_* // fields, which could only ever describe a second player. reserved 2, 4, 5, 6, 7, 8; string room_code = 1; // Whether StartGame would be accepted right now. The server decides this // because it owns every condition that feeds it. bool can_start = 3; // Everyone seated, in seat order, which is also the turn order a game will // use. Always includes the recipient, marked is_me. repeated PlayerSlot players = 9; // How many seats the room has and how many players a game needs. Sent // rather than compiled in, so the lobby draws whatever the server allows and // raising the limit does not need a client deploy. uint32 max_players = 10; uint32 min_players = 11; // How long a seat is held for a player who has dropped. The client counts // down against it for anybody whose connected is false; the server still // decides when the seat is actually forfeit. uint32 grace_ms = 12; } // ChatMessage is one line as one recipient sees it. Rendered per recipient // like every other room message: from_me is the only field that differs, and // it is what lets the client style its own words without matching names. message ChatMessage { bool from_me = 1; // Server-sanitized, as everywhere a name is shown. Empty when the author has // left the room: the seat they spoke from may be somebody else's now, and a // name outlives neither. The client labels an empty author itself. string author = 2; string text = 3; // Server clock. The client renders it; it never orders by its own clock. int64 sent_unix_ms = 4; // Which seat spoke, so the client can colour a line by its author instead // of by matching display names. Cleared with the author for a line whose // seat has been vacated. string player_id = 5; } // ChatHistory is the whole panel, oldest first, sent when a player is seated in // a room or resumes into one. A snapshot rather than a replayed stream, for the // same reason RoomState is one: a client that missed frames is correct again // from the next one instead of having to catch up on events. // // Scoped to the recipient: it carries only what was said while they held their // seat, which is why it is built per seat rather than broadcast. message ChatHistory { repeated ChatMessage messages = 1; } message ServerMessage { // 2 was RoomCreated and 3 was RoomJoined: both said part of what RoomState // now says in full, and three messages describing one lobby is three ways // for a client to hold a different view of it. 11 was RematchState, retired // with the rematch handshake. 8 was OpponentLeft, retired with the second // seat: presence is per player now, and RoomState already carries it to // everyone in the room whether or not a game is running. reserved 2, 3, 8, 11; oneof payload { Welcome welcome = 1; GameStarted game_started = 4; TurnUpdate turn_update = 5; MoveRejected move_rejected = 6; GameOver game_over = 7; ServerError error = 9; Pong pong = 10; RoomState room_state = 12; ChatMessage chat_message = 13; ChatHistory chat_history = 14; PlayerEliminated player_eliminated = 15; } }