Skip to content
Back to skills

Summer Match Results

ASecurity

End a Summer match: report each player's win, loss or draw, show results on clients, then close the World.

  • 77 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 16, 2026
ai-agentsgoshelltesting

Works with

  • claude code
  • cursor
  • cli
  • mcp

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add SummerEngine/summer --skill summer-match-results --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Summer Match Results?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Summer Match Results
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/summerengine-summer-match-results/badge)](https://www.skillsdirectory.com/skills/summerengine-summer-match-results)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: summer-match-results
description: "End a Summer match: report each player's win, loss or draw, show results on clients, then close the World."
license: MIT
compatibility: [Cursor, Claude Code, Windsurf, Codex]
category: multiplayer-and-networking
user-invocable: true
allowed-tools: Read Grep Edit Write summer_read_file summer_replace_text summer_write_file summer_get_script_errors summer_project_setting summer_play summer_stop summer_get_diagnostics
paths: ["authority/**/*.gd", "client/**/*.gd", "world.json"]
---

# /summer-match-results — who won, and closing the match

## Overview

A match ends when **the authority** says so. It decides the outcome for every
player, then:

1. **Concludes** the match: `Summer.authority.match.conclude(outcomes, reason)`.
   Summer settles it once and returns a settlement, including each player's
   rating change on a rated queue.
2. Summer delivers each player **their own** result on the client
   (`Summer.client.ratings.match_result`), and later their committed rating
   (`rating_committed`). The authority never relays results or ratings.
3. **Completes** the World: `Summer.authority.world.complete()` for a
   match-scoped World. Clients are disconnected and go back to your menu.

Where it works: all of this runs under Local Play with a deterministic
settlement, so the whole flow is testable locally. Rating values are real only
when hosted.

This skill extends the Courtyard game (`multiplayer-project` and
`multiplayer-state`): the first player to collect `WIN_COINS` coins wins.
With a team queue (`summer-matchmaking`), their whole team wins.

## Steps

### 1. Track who is in the match

In `authority/main.gd`, keep every Session the engine admitted, and the
players who left. Add these variables:

```gdscript
const WIN_COINS := 1    # coins that win the match
var ended := false
var departed := {}      # session_id of players who left before the end
var sessions := {}      # session_id -> SummerSession
```

In `_ready()`, next to the other World signals (skip this line if
`summer-player-data` already connected it):

```gdscript
	world.session_joined.connect(_on_session_joined)
```

```gdscript
## Every player admitted into this match, kept as the exact Session object
## the engine handed over: conclude accepts only these.
func _on_session_joined(session: SummerSession) -> void:
	sessions[session.session_id] = session
```

If the game already has `_on_session_joined` (`summer-player-data`
adds one), put the line at its start instead of defining it twice.

At the top of `_on_session_left`:

```gdscript
	departed[session.session_id] = true
```

Keep a Session in `sessions` after it leaves: it stays on the match roster
until the match is delivered, and it still needs an outcome.

Presence signals on the authority's `SummerNetworkWorld`:

| Signal | Meaning | Typical response |
|---|---|---|
| `session_joined(session)` | First admission into this World | Nothing extra: clients send `join` when their network is ready |
| `session_disconnected(session)` | Transport lost; the seat is kept | Pause or freeze that player; don't end the match yet |
| `session_reconnected(session)` | Same Session is back | Resume them |
| `session_left(session, reason)` | The Session ended, exactly once | Count them as departed; retire their groups |

Hosted, a seat waits `lifecycle.reconnect_seconds` (`world.json`) before
Summer ends the Session. Under Local Play a quitting player only
disconnects; `session_left` arrives when the World ends.

### 2. Decide the outcome and conclude

At the end of `_collect`, after `request.accept(...)`:

```gdscript
	if wallets[session.session_id] >= WIN_COINS:
		_end_match(session)
```

Then add:

```gdscript
## Ends the match once: every player gets an outcome, Summer settles it, and
## the World closes after a short results screen.
func _end_match(winner: SummerSession) -> void:
	if ended:
		return
	ended = true
	var winner_team := Summer.authority.match.get_participant(winner).team
	var outcomes: Dictionary[SummerSession, SummerAuthorityMatch.MatchOutcome] = {}
	for id: String in sessions:
		var session: SummerSession = sessions[id]
		var participant := Summer.authority.match.get_participant(session)
		if participant == null or not participant.outcome_eligible:
			continue   # spectators get no outcome
		if departed.has(id):
			outcomes[session] = SummerAuthorityMatch.MATCH_OUTCOME_ABANDON
		elif (participant.team == winner_team) if winner_team >= 0 else (id == winner.session_id):
			outcomes[session] = SummerAuthorityMatch.MATCH_OUTCOME_WIN
		else:
			outcomes[session] = SummerAuthorityMatch.MATCH_OUTCOME_LOSS
	match_doc.winner = winner.player.display_name
	match_group.reset(Game.pack(match_doc))
	var settled := await _conclude(outcomes)
	# Let players read the results screen, then end the World.
	await get_tree().create_timer(3.0).timeout
	await Summer.authority.world.complete("match_complete").get_result_or_completed_signal()


## Commits the outcomes. A retryable failure is retried with the same
## arguments: Summer settles a match at most once.
func _conclude(outcomes: Dictionary[SummerSession, SummerAuthorityMatch.MatchOutcome]) -> SummerMatchSettlement:
	for attempt in 3:
		var concluding := Summer.authority.match.conclude(outcomes, "coins_collected")
		var concluded: SummerResult = await concluding.get_result_or_completed_signal()
		if concluded.ok:
			return concluding.settlement
		if not concluded.retryable:
			push_error("conclude: %s %s" % [concluded.code, concluded.message])
			return null
		await get_tree().create_timer(1.0 + attempt).timeout
	return null
```

Give `match_doc` a `"winner": ""` entry so clients can show the winner's name.

The rules `conclude` enforces:

- **One outcome for every outcome-eligible player admitted into the match**,
  including players who already left (`MATCH_OUTCOME_ABANDON`). A missing or
  extra player fails with `invalid_roster`; spectators are skipped.
- **Keys must be the exact `SummerSession` objects the engine delivered**
  (`session_joined`, or `request.get_session()` in a Command). Copies, such as
  the Sessions in `Summer.authority.context.participants`, fail with
  `invalid_roster`.
- Outcomes are `MATCH_OUTCOME_WIN`, `MATCH_OUTCOME_LOSS`, `MATCH_OUTCOME_DRAW`
  and `MATCH_OUTCOME_ABANDON`. On a team queue give every member of a team the
  same outcome; Summer scores the sides it formed.
- **It settles once.** Retrying with identical arguments replays the same
  settlement (`concluding.replayed` is true); different arguments are
  refused. A rejected conclusion (`invalid_request`) commits nothing, so you
  may conclude again with corrected arguments.
- `rating_deltas`, the third argument, is only for `authority_v1` queues
  (`summer-leaderboards`).

The settlement lists each player's committed rating changes:

```gdscript
	if settled != null:
		for player: SummerPlayerSettlement in settled.players:
			for change: SummerRatingChange in player.ratings:
				print("%s %s %d -> %d" % [player.session.player.display_name, change.system, change.before.value, change.after.value])
```

Use it for authority logic (logs, rewards). Players get their own numbers
directly from Summer, step 3.

### 3. Show the result on each client

In `client/main.gd`'s `_ready()`, connect the result signals right after
`Summer.initialize` succeeds and before joining (`Summer.client` is null
until then), and handle the end of the World:

```gdscript
	Summer.client.ratings.match_result.connect(_on_match_result)
	Summer.client.ratings.rating_committed.connect(_on_rating_committed)
	$World.server_disconnected.connect(_on_server_disconnected)
```

```gdscript
func _on_match_result(result: SummerMatchResult) -> void:
	var words := {
		SummerMatchResult.OUTCOME_WIN: "You win!",
		SummerMatchResult.OUTCOME_LOSS: "You lose.",
		SummerMatchResult.OUTCOME_DRAW: "Draw.",
	}
	show_status(words.get(result.outcome, "Match over."))


## Arrives later, privately, once Summer committed this player's rating.
func _on_rating_committed(result: SummerMatchResult) -> void:
	var change := result.rating
	show_status("Rating %d (%+d)" % [change.after.value, change.delta])


func _on_server_disconnected(code: String, _detail: String) -> void:
	show_status("The match has ended.")
```

`show_status` and the `Hud/Status` label come from `summer-matchmaking`.

- `match_result` arrives when the authority concludes. Its `rating_state` is
  `RATING_STATE_PENDING` on a rated queue, or `RATING_STATE_NONE`.
- `rating_committed` follows when Summer committed the rating, with
  `result.rating.before`, `after` and `delta`. Hosted only.
- `Summer.client.ratings.last_result` and `results` keep recent results, so a
  results screen opened later can read them.
- Drive the results screen from `match_result`, not from a disconnect. Give
  it a "Back to menu" button that awaits `Summer.client.leave()`, then offers
  "Play again" (`find_match` again).
- Hosted, completing the World ends every Session and Summer stops the server,
  so clients get `server_disconnected`. Under Local Play the World completes
  but clients stay connected until they leave or Local Play stops.

### 4. Close the World

- **Match-scoped World** (`"lifetime": "match_scoped"`, the project skill's
  default): conclude, then `Summer.authority.world.complete(reason)`. This
  permanently ends the World; Summer stops the server. Completing before a
  successful conclusion fails with `settlement_required`.
- **Persistent or multi-match World**: conclude, then
  `Summer.authority.match.complete_delivery()`. Match-bound Sessions end,
  World-bound Sessions stay, and the World keeps serving. Never call
  `world.complete()` on a World that should keep running.
- `draining(deadline_unix)` on the World means Summer is shutting the server
  down (maintenance). Conclude or save before the deadline
  (`summer-world-saves`).

A match that never concludes holds its players until the World is stopped;
every match needs an ending, including "everyone else left".

### 5. Test it with Local Play

```
summer_project_setting key="summer/local_play/players" value=2
summer_play
summer_get_diagnostics
summer_stop
```

Make one player win. Expect, in order: the authority's conclusion succeeds,
with a settlement of deterministic test ratings (system `deterministic-v1`);
every client receives `match_result` with its own outcome and `rating_state`
`RATING_STATE_PENDING` on a rated queue; about 3 seconds later the authority
logs `session_left` with reason `match_complete` for each player and
`world.complete()` succeeds. `rating_committed` does not fire locally, and
clients stay connected until they leave.

## Checklist

- [ ] There is exactly one place that ends the match, guarded so it runs once.
- [ ] Outcomes cover every outcome-eligible participant, including those who left (`MATCH_OUTCOME_ABANDON`).
- [ ] Team queues give a whole team the same outcome.
- [ ] A retryable conclusion failure is retried with identical arguments.
- [ ] Clients show `match_result`, and `rating_committed` when it arrives.
- [ ] Match-scoped Worlds call `world.complete()` after concluding; persistent Worlds call `match.complete_delivery()`.
- [ ] Local Play shows the result on every client, and "Back to menu" leaves cleanly.

## Common mistakes

| Don't | Do | Why |
|---|---|---|
| Decide the winner on a client | Decide on the authority and conclude there | Only the authority's outcome is settled |
| Build outcomes from players still connected | Every Session from `session_joined`, leavers as `MATCH_OUTCOME_ABANDON` | Leavers stay on the roster; a missing one fails `invalid_roster` |
| Key outcomes by `Summer.authority.context.participants` sessions or copied ids | The exact Session objects from `session_joined` | Copies are rejected with `invalid_roster` |
| Wait for `server_disconnected` to show results | Show results on `match_result` | Local Play keeps clients connected after completion |
| Send results or ratings to clients yourself | Let `Summer.client.ratings` deliver them | Each player gets their own result privately; the authority never relays account data |
| Retry a conclusion with changed outcomes | Retry with identical arguments | A match settles once; divergent retries are refused |
| `world.complete()` in a persistent World | `match.complete_delivery()` | `complete()` ends the World for good |
| Call `world.complete()` before concluding | Conclude, show results, then complete | Completion ends every Session |
| End the match when one player disconnects | Wait for `session_left` | A disconnect keeps the seat for the reconnect window |

## See also

- `summer-matchmaking` — queues and teams that the outcome refers to
- `summer-leaderboards` — ratings and `authority_v1` rating moves
- `summer-world-saves` — persistent Worlds that outlive a match
- `multiplayer-testing` — running bots through a whole match

<!-- 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@latest` 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_read_file` | `summer engine read-file` |
| `summer_replace_text` | `summer engine replace-text` |
| `summer_stop` | `summer engine stop` |
| `summer_write_file` | `summer engine write-file` |
<!-- /shell-commands -->

Files in this skill

  • SKILL.md13.6 KB
  • resource.yaml961 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.