Installs into .claude/skills of the current project.
Are you the author of Summer World Chat?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/summerengine-summer-world-chat-summer)
---
name: summer-world-chat
description: "Add match chat to a Summer game: one World channel shared by everyone in the match. Hosted only."
license: MIT
compatibility: [Cursor, Claude Code, Windsurf, Codex]
category: multiplayer-and-networking
user-invocable: true
allowed-tools: Read Grep Edit Write summer_get_script_errors summer_project_setting summer_play summer_stop summer_get_diagnostics
paths: ["**/*.gd", "**/*.tscn"]
---
# /summer-world-chat — chat with everyone in the match
## Overview
A **World channel** is a text channel for the World (match) the player has
joined. Every player in that World can open the same channel by key, such as
`"match"`. The platform owns everything that matters about it:
- who may read it;
- the text filter;
- rate limits;
- 24-hour retention;
- blocks between players.
Your game owns only the chat UI. Messages never travel through your network
composition, and the authority neither sees nor writes them.
| Fact | Value |
| --- | --- |
| Text | Plain Unicode, 1 to 4000 characters. Always render it as plain text, never as markup |
| Channel keys | 1–64 of `A-Z a-z 0-9 _ . -`, starting with a letter or digit; at most 64 channels per World |
| History | Best effort, not a full log. A new handle shows the newest 2 messages; `load_older()` grows the window to at most 16 |
| Lifetime | A handle belongs to one World admission. After leaving or joining another World, open a new one |
| Not included | Direct messages (`summer-friends`), party chat (the Summer app) and messages written by your server |
**Hosted only.** Chat needs a joined hosted World and a Summer app whose Player
Host offers `player.channels`. A queue reservation is not enough: open the
channel after `enter()` succeeds. In Local Play and editor runs,
`check_open_readiness()` and `open_world_channel()` fail with `unavailable`.
Build the "chat hidden" path first.
This skill extends the Courtyard game from `multiplayer-project` and
`multiplayer-state`.
## Steps
### 1. Publish player names
Messages carry the author's Summer user id (`player_id`), not a name. The
authority already knows every verified name. Publish them in the shared match
document (`multiplayer-state`): in `authority/main.gd`'s `_join`, before
accepting, add
```gdscript
var players: Dictionary = match_doc.get("players", {})
players[session.player.user_id] = session.player.display_name
match_doc["players"] = players
match_group.reset(Game.pack(match_doc))
```
On the client, copy `match_doc.get("players", {})` into the chat node's
`names` whenever the match document changes.
### 2. Add the chat node
`client/chat.gd`, added as a child `Chat` of `Main` in `client/main.tscn`:
```gdscript
extends Node
## Match chat: one World channel that every player in this match shares.
signal lines_changed(lines: PackedStringArray)
signal status(text: String)
var chat: SummerWorldChannel
var names := {} # player user id -> display name, from your match state
## Call after the player has entered the match.
func open() -> void:
var channels := Summer.client.channels
var ready := channels.check_open_readiness()
if not ready.ok:
status.emit("Chat is not available here.") # hide the chat box
return
var opened := channels.open_world_channel("match")
var result: SummerResult = await opened.get_result_or_completed_signal()
if not result.ok:
status.emit("Chat is not available here.")
return
chat = opened.get_channel()
if chat == null:
return # the player already left this World
chat.history_changed.connect(_render)
chat.history_failed.connect(func(_failure: SummerResult) -> void: status.emit("Chat history is unavailable. Tap to retry."))
chat.closed.connect(func(_reason: String) -> void:
chat = null
_render())
_render() # the first history may already be here
## The Retry tap after history_failed.
func retry_history() -> void:
if chat != null:
chat.refresh()
func send(text: String) -> void:
if chat == null or text.strip_edges().is_empty():
return
var sent := chat.send_message(text)
var result: SummerResult = await sent.get_result_or_completed_signal()
if not result.ok and result.retryable and result.code != &"rate_limited":
# An ambiguous failure may have been accepted. Retry the same send,
# never send_message again: that would be a second message.
sent = sent.retry()
result = await sent.get_result_or_completed_signal()
if result.ok:
return
match result.code:
&"text_not_allowed":
status.emit("That message was not sent.")
&"rate_limited":
status.emit("You are sending messages too fast.")
_:
status.emit("Message not sent.")
## The visible window always comes from get_messages(), never from appending.
func _render() -> void:
var lines := PackedStringArray()
if chat != null and chat.is_history_current():
for message in chat.get_messages():
var author := "Game" if message.author_kind == &"creator_system" else String(names.get(message.player_id, "Player"))
lines.append("%s: %s" % [author, message.text])
lines_changed.emit(lines)
```
Call `$Chat.open()` after `enter()` returns `true`. Wire it into the UI:
- **Messages:** a `Label` or `RichTextLabel` with `bbcode_enabled = false`, refilled from `lines_changed`.
- **Input:** a `LineEdit` whose `text_submitted` calls `$Chat.send(text)` and clears itself.
- **Status:** `status` feeds a status line; hide the whole box when chat is unavailable.
### 3. Rules the code above follows
- **Render, don't append.** `history_changed` means "replace the view from `get_messages()`". It also fires when a block or expiry removes messages, which must disappear from the screen.
- **`message_received` is for sounds and badges only.** It fires for newly seen messages, not for the initial or older history. Display still comes from `get_messages()`.
- **One `send_message` call is one message.** For an ambiguous retryable failure, call `retry()` on the same operation; it reuses the original idempotency key. A second `send_message` would post twice.
- **`text_not_allowed` stored nothing.** The filter refused it, and resending the same text fails again.
- **`rate_limited`.** About 120 messages a minute per player; tell them to slow down, don't loop.
- **Sequence gaps are normal.** Blocks, other recipients and expiry hide messages; never treat a gap as an error.
- **`creator_system` messages are from the game**, never from a player or from Summer staff.
### 4. Test
Locally, check that the hidden path works and nothing errors:
```
summer_project_setting key="summer/local_play/players" value=2
summer_play
summer_get_diagnostics
summer_stop
```
Then test real chat on Summer staging with two accounts in one match.
## Checklist
- [ ] Local Play: `open()` reports chat unavailable, the chat box is hidden, and there are no script errors.
- [ ] The channel opens only after the player entered the World.
- [ ] Hosted: a message from one player appears for the other, with the right name.
- [ ] A filtered message shows "not sent" and never appears.
- [ ] After leaving and joining another match, chat reopens on a new handle.
- [ ] Text is shown as plain text: `[b]hi[/b]` appears literally.
## Common mistakes
| Don't | Do | Why |
| --- | --- | --- |
| Send chat through your own Commands or State | `Summer.client.channels` | Summer filters, rate-limits, applies blocks and expires text; your State does none of that |
| Open the channel right after `Summer.initialize` | Open after `enter()` succeeds | Only a joined World admission can open it |
| Append each message to a log | Re-render from `get_messages()` on `history_changed` | Blocked and expired text must disappear |
| Call `send_message` again after a timeout | `operation.retry()` | A new call is a second message |
| Render with BBCode enabled | Plain `Label`, or `bbcode_enabled = false` | Player text is never markup |
| Keep a handle across matches | Open a new channel per World | Handles never follow the player to another World |
| Show `player_id` | Map it to a name your authority publishes | Ids are not names |
## See also
- `multiplayer-state`: the match document that carries player names
- `summer-friends`: direct messages with friends
- `summer-parties`: party chat lives in the Summer app
- `multiplayer-testing`: what Local Play can and can't test
<!-- shell-commands: generated from the operation catalog by npm run generate:registry; do not edit by hand -->
## From a shell
No Summer MCP tools in this session? Run each one as a command; when `summer --version` is missing or below 4.0, use `npx -y summer-engine@4.3.0` in place of `summer`. Fields are flags in kebab-case (`--scene-path res://main.tscn`) or one `--input '<json>'`, and `summer ops describe <op>` prints the exact input. The `summer-cli` skill has the rest.
| In this skill | Command |
|---|---|
| `summer_get_diagnostics` | `summer engine get-diagnostics` |
| `summer_get_script_errors` | `summer engine get-script-errors` |
| `summer_play` | `summer engine play` |
| `summer_project_setting` | `summer engine project-setting` |
| `summer_stop` | `summer engine stop` |
<!-- /shell-commands -->