Quelea is the party system for the OneLiteFeather network. Players form a party and invite each other by name. When the party leader joins a game from the lobby, the members in the lobby follow onto that exact server. The state lives in a shared Dragonfly, so parties work across several Velocity proxies and any number of service replicas.
Each Velocity proxy runs the velocity plugin. The plugin reports logins, disconnects and a heartbeat to the backend service over plain HTTP, and it keeps one Server-Sent Events (SSE) stream open to receive party events. Commands from players are sent to the service, which stores all party state in Dragonfly and publishes every change on the Redis channel quelea.events. Each replica forwards these events to its open streams.
flowchart LR
subgraph Proxies
V1[Velocity + quelea plugin]
V2[Velocity + quelea plugin]
end
V1 <-->|HTTP commands, SSE stream| R1[Quelea replica]
V2 <-->|HTTP commands, SSE stream| R2[Quelea replica]
R1 <--> D[(Dragonfly<br/>state + quelea.events)]
R2 <--> D
Follow rule. Whenever the proxy sees the party leader move from one server to another, it asks the service to warp the party to the new server and names the server the leader left. Every proxy then moves its local members to that exact server. With the default follow mode lobby, a member follows if they were on the server the leader left (they were together) or if they are in a lobby (QUELEA_LOBBY_PATTERN); a member in another game stays where they are. So a round that ends brings the party back to the lobby with its leader, and a lobby hop drags the other lobby members along. The leader's first connection after login warps nothing.
Events are delivered at most once. A proxy that reconnects does not receive events it missed, so a warp that was lost can be repeated with /party warp.
Lonely parties. A party that has only one member after QUELEA_PARTY_ALONE_TIMEOUT is disbanded. The timeout counts from the creation or from the leave or kick that left that one member, and a join ends it. A leave or kick that leaves one member keeps the party open. QUELEA_PARTY_ALONE_WARNING before that, the service publishes party.expiring with the expiry time, and the proxy tells the leader how many minutes are left. Expired parties are disbanded with the reason expired-alone. Every party.disbanded event carries a reason: leader (the leader disbanded), last-member-left (no member remained) or expired-alone. Clients must treat an unknown reason like leader.
Join requests and public parties. A player outside a party may ask the party of an online player to let them in (POST /v1/players/{playerId}/party/requests). A private party records the request for its leader, who answers it with POST /v1/parties/{partyId}/join-requests/accept or .../reject; the service publishes party.join-requested and party.join-request-answered. A request expires after QUELEA_JOIN_REQUEST_TTL, unanswered and without an event. A denial or an expiry starts a cooldown (QUELEA_JOIN_REQUEST_COOLDOWN) during which the player gets 429 join-request-cooldown with Retry-After. A leader opens a party with POST /v1/parties/{partyId}/public (public: true); while it is open, a join (POST /v1/players/{playerId}/party/join) or a request admits the player at once, up to QUELEA_PARTY_PUBLIC_MAX_SIZE. Closing needs no permission and keeps the members. A change of leader closes the party. A player kicked from a party cannot join or request it until the leader invites them. Bans, open requests and cooldowns end with the party.
Party versions and conditional requests. Every party has a version: 1 when it is created, +1 with every change. It is in the party JSON and in every event. GET /v1/parties/{id} and GET /v1/players/{id}/party send it as a strong ETag (for example "4") and answer 304 Not Modified without a body when If-None-Match names the current tag. kick, promote, disband and warp accept If-Match with one or more strong tags or *. If none matches the current version, the command changes nothing and answers 412 precondition-failed. A weak tag (W/) never matches. Without the header the command runs unconditionally.
Retries. A POST may carry an Idempotency-Key header (1 to 255 visible ASCII characters). The first request with a key runs; a 2xx response is stored for QUELEA_IDEMPOTENCY_TTL, and a repeat with the same key and the same request is answered with the stored response without running again. The key is scoped to the authenticated client. The same key with another request answers 422 idempotency-key-reused; a repeat while the first request is still running answers 409 request-in-progress with Retry-After: 1. Failed responses are not stored, so a retry after an error runs again. The key is kept for QUELEA_IDEMPOTENCY_LOCK while a request runs, so a crashed request does not block its key for longer than that.
Event streams. Each stream of GET /v1/events identifies its client with clientId (proxyId is accepted as an alias). A client that falls more than QUELEA_SSE_BUFFER events behind has its stream closed and counted in quelea.sse.disconnects{reason="overflow"}; it must reconnect and take a fresh snapshot. A stream also ends when the token it was opened with expires, so it never outlives its credentials; the client reconnects with a fresh token and takes a snapshot.
| Module | Artifact / output | Purpose |
|---|---|---|
backend |
Docker image harbor.onelitefeather.dev/onelitefeather/quelea |
Micronaut HTTP service: parties, invites, presence, events, metrics. |
client |
net.onelitefeather:quelea-client |
Java client generated from the backend's OpenAPI document. |
velocity |
quelea-velocity-<version>.jar on every GitHub release |
Velocity proxy plugin: commands, follow, presence reporting. |
/party has the alias /p. Running it without arguments prints the help.
| Command | Who | Effect |
|---|---|---|
/party create |
any player | Creates a party with you as leader. Refused if you are already in one. |
/party invite <player> |
leader | Invites an online player. Creates a party if you are not in one. The invite expires after 60 s. |
/party accept [inviter] |
invited player | Joins the party. Without a name, works when exactly one invite is pending; otherwise lists the inviters. |
/party deny [inviter] |
invited player | Declines an invite. Same name rule as accept. |
/party public on |
leader with quelea.party.public |
Opens the party to everyone: anyone can join it by name with /party join. Without the permission the proxy refuses and nothing is sent to the service. |
/party public off |
leader | Closes the party again. Needs no permission, so a leader who lost it can still close. Members stay. |
/party request <player> |
player not in a party | Asks the party of an online player to let you in. A public party lets you in at once; a private one tells its leader, who answers within 60 s. After a denial or an expiry you wait 60 s before asking again. |
/party join <player> |
player not in a party | Joins the party of an online player directly. Only works for a public party; a private one names /party request. |
/party approve <player> |
leader | Accepts the join request of a player. The leader's prompt offers the same as clickable [Accept]. |
/party reject <player> |
leader | Declines the join request of a player. The leader's prompt offers the same as clickable [Deny]. |
/party leave |
member | Leaves the party. If the leader leaves, the longest-standing member becomes leader. |
/party kick <player> |
leader | Removes a member. |
/party promote <player> |
leader | Makes a member the leader. |
/party disband |
leader | Ends the party for everyone. |
/party list |
any member | Shows the members of your party. |
/party warp |
leader | Moves the party to your current server. Use it to retry a warp that did not reach the members. |
/pc <message> |
member | Sends a message to the party chat (1 to 256 characters). |
Messages are shown in English or German, following the player's client language.
Public parties and the permission. Opening a party (/party public on) needs the Velocity permission quelea.party.public; the proxy checks it before it calls the service. Closing needs no permission. A change of leader (promotion, or a leader who leaves) closes an open party: the new leader is told, and is told how to reopen it only if they hold the permission. A public party admits up to QUELEA_PARTY_PUBLIC_MAX_SIZE members, and a kicked player cannot join or request the party until the leader invites them again.
Every proxy needs the new plugin. A request reaches the leader only through the proxy the leader is on. Install the plugin version with /party request on every proxy before players use it; a proxy with an older plugin drops the prompt silently, and the request then expires.
The plugin ships English and German. A player sees the bundle for their client locale: de_DE, then de, then en. A key that is missing in the chosen bundle is shown in English, so a partial translation never shows a raw key.
To change or add messages without a new release, put a file named messages_<tag>.properties into plugins/quelea/lang/ on the proxy (for example in the proxy template, next to config.properties):
- Override single messages:
messages_de.propertieswith only the keys to change. Every other German text stays built in. - Add a language:
messages_fr.propertiestranslates the keys it wants; the rest of the messages are English until they are translated. - Regional variants:
messages_de_AT.propertiesis used for Austrian clients and falls back tode, thenen.
The files are read once at proxy start-up, so restart the proxy after a change. Each value is a MiniMessage template. Copy the keys from the built-in messages_en.properties in the plugin jar (lang/), and keep the placeholders (<name>, <count>, <server> and so on) as they are. If a template does not parse, the proxy logs a warning naming the key and language, and that message is shown in English.
The colour guide at the top of messages_en.properties explains which colour means what (commands, arguments, errors, hints, the leader's star).
Set these on the service. Defaults are in backend/src/main/resources/application.yml.
| Variable | Default | Meaning |
|---|---|---|
PORT |
8080 |
HTTP port of the service. |
REDIS_URI |
redis://localhost:6379 |
Dragonfly (Redis protocol) that holds all state. |
QUELEA_REDIS_PREFIX |
quelea: |
Prefix of every key, so several environments can share one Dragonfly. |
QUELEA_REDIS_LOCK_TTL |
3s |
Lease of a party lock. A holder that stalls longer loses the lock. |
QUELEA_REDIS_LOCK_WAIT |
1s |
How long a request waits for a busy party lock before it fails with lock-timeout. |
QUELEA_PARTY_MAX_SIZE |
8 |
Maximum members per party, counting the leader. Minimum 2. |
QUELEA_INVITE_TTL |
60s |
How long an invite stays valid. Re-inviting refreshes it. |
QUELEA_PARTY_ALONE_TIMEOUT |
30m |
A party that has only one member for this long is disbanded with reason expired-alone. |
QUELEA_PARTY_ALONE_WARNING |
5m |
party.expiring is published this long before the expiry. 0s disables the warning. |
QUELEA_PARTY_EXPIRY_INTERVAL |
15s |
How often the expiry job runs; one replica per run, under a lock. The expiry can be this late. |
QUELEA_PARTY_PUBLIC_MAX_SIZE |
20 |
Maximum members of a public party, counting the leader. Raised to QUELEA_PARTY_MAX_SIZE when lower. |
QUELEA_JOIN_REQUEST_TTL |
60s |
How long a join request to a private party stays open before it expires. |
QUELEA_JOIN_REQUEST_COOLDOWN |
60s |
How long a player waits before asking the same party again after a denial or an expiry. |
QUELEA_PROXY_TIMEOUT |
45s |
A proxy without a heartbeat for this long is treated as dead; its players are disconnected from their parties. |
QUELEA_SSE_KEEPALIVE |
15s |
Interval of the keep-alive comment on idle event streams. |
QUELEA_SSE_BUFFER |
256 |
Events one stream may hold unread. A stream that overflows is closed. |
QUELEA_IDEMPOTENCY_TTL |
24h |
How long the response of a completed POST is replayed for its Idempotency-Key. |
QUELEA_IDEMPOTENCY_LOCK |
10s |
Marker of a request in progress. A duplicate within this time answers 409 request-in-progress. |
QUELEA_AUTH_ISSUER |
http://localhost:18090/quelea |
Token issuer (iss). Outside dev/test it must be an https URL, for example https://login.microsoftonline.com/<tenant>/v2.0. |
QUELEA_AUTH_AUDIENCE |
quelea-api |
Token audience (aud). Outside dev/test the client id GUID of the Quelea API registration. |
QUELEA_AUTH_TENANT_ID |
empty | Entra tenant id; tokens of another tenant are refused. Required outside dev/test. |
QUELEA_AUTH_JWKS_URL |
http://localhost:18090/quelea/jwks |
Where the issuer publishes its signing keys (RS256 only). |
QUELEA_AUTH_CLOCK_SKEW |
60s |
Tolerated clock difference for exp and nbf. Between 0s and 60s; larger values stop the service. |
LOG_JSON |
false |
true writes logs as JSON with trace_id and span_id. |
OTEL_TRACES_EXPORTER |
none |
Set to otlp to export traces. |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4317 |
OTLP gRPC endpoint for traces. |
OTEL_SERVICE_NAME |
quelea |
Service name on traces and metrics (read from the environment by the metrics exporter too). |
QUELEA_METRICS_OTLP_ENABLED |
false |
true pushes metrics over OTLP/HTTP. |
QUELEA_METRICS_OTLP_URL |
http://localhost:4318/v1/metrics |
OTLP/HTTP metrics endpoint, for example http://alloy-receiver.grafana.svc:4318/v1/metrics. |
QUELEA_METRICS_OTLP_STEP |
60s |
Interval at which metrics are pushed. |
On the first start the plugin creates this file with commented defaults. Each value can also be set by the environment variable shown; the environment wins over the file.
| File key | Environment variable | Default | Meaning |
|---|---|---|---|
url |
QUELEA_URL |
http://localhost:8080 |
Base URL of the party service. |
proxy-id |
QUELEA_PROXY_ID |
random UUID per start | Name of this proxy in the service. Set it to keep a stable identity. |
lobby-pattern |
QUELEA_LOBBY_PATTERN |
(?i)^lobby.* |
Regex for lobby server names. Moves to other servers warp the party. |
follow-mode |
QUELEA_FOLLOW_MODE |
lobby |
Which members follow a warp: lobby (only members in a lobby) or always. |
tenant-id |
QUELEA_TENANT_ID |
none | Entra tenant (GUID or domain) of the plugin's app registration. |
client-id |
QUELEA_CLIENT_ID |
none | Client id of the plugin's app registration. |
client-secret |
QUELEA_CLIENT_SECRET |
none | Secret of that app registration. Prefer the environment for it. |
auth-scope |
QUELEA_AUTH_SCOPE |
none | Token scope, api://<client id of the Quelea API>/.default. |
sentry-dsn |
QUELEA_SENTRY_DSN |
none (off) | Sentry DSN for error reports. Blank or unset: no errors are reported and Sentry is not contacted. Never logged. |
sentry-environment |
QUELEA_SENTRY_ENVIRONMENT |
production |
Environment tag of the reported errors. |
Error reports cover unexpected exceptions, server errors (5xx), answers that are not documented problems, credentials the service still rejects after a token refresh, and an event stream that stays disconnected for 5 minutes (one report per outage). Player names, player UUIDs and request headers are never sent.
The four credential values come from the Entra app registration of the plugin, described in docs/entra-setup.md. Without all of them the plugin sends no token, logs one warning at start-up, and only a dev party service accepts its requests.
- Download
quelea-velocity-<version>.jarfrom the GitHub release and put it into the Velocity proxy template, next to the other plugins. - Set
QUELEA_URLon the proxy. In production this ishttps://quelea.apps.onelite.feather. The proxy must trust the internal certificate authority for that host. - Start the proxy. The plugin logs an error and disables itself if the configuration cannot be loaded.
The proxy keeps one SSE stream open to the service. The gateway route for the service must not apply a request timeout, or the stream is cut.
- Health:
GET /health. Reports the state of the Dragonfly connection. - Metrics: pushed over OTLP/HTTP when
QUELEA_METRICS_OTLP_ENABLED=true, toQUELEA_METRICS_OTLP_URLeveryQUELEA_METRICS_OTLP_STEP. There is no scrape endpoint;/prometheusanswers 404. Names keep their Prometheus form in Mimir, for examplequelea_party_operations_total. Each replica reports its pod name (HOSTNAME) asservice.instance.id.quelea_party_operations_total{operation,result}: service calls by operation and result.quelea_parties_active: parties currently stored.quelea_parties_created_total: parties created.quelea_parties_disbanded_total{reason}: parties disbanded, by reason (leader,last-member-left,expired-alone).quelea_party_lifetime_seconds_*{reason}: time from creation to disband, by reason (histogram).quelea_party_size_*: party size after each join, counting the leader (histogram).quelea_players_online: players online on all proxies, read from the presence store.quelea_sse_streams: open event streams on this replica.quelea_events_published_total{type}: party events published, by type.quelea_events_failed_total{type}: party events that could not be published, by type.
- Traces: OpenTelemetry over OTLP when
OTEL_TRACES_EXPORTER=otlp. The trace context is carried inside each event, so a warp can be traced from the leader's request to the fan-out. - Logs: plain text by default; JSON with
LOG_JSON=true. - Errors: every error is an RFC 9457 Problem Details document (
application/problem+json) with a stablecode, for exampleparty-fullornot-leader. Each code and what to do about it is documented in docs/problems.md. Quote thetraceIdfrom the response when you report a problem.
The plugin must be rolled out before the backend enforces authentication, because the 0.1.0 plugin cannot authenticate:
- Create the Entra app registrations (see docs/entra-setup.md).
- Release the plugin and install it on the proxies with its client credentials (
QUELEA_TENANT_ID,QUELEA_CLIENT_ID,QUELEA_CLIENT_SECRET,QUELEA_AUTH_SCOPE). The 0.1.0 backend ignores the new headers, so this step is safe. - Update the backend with the
QUELEA_AUTH_*values (QUELEA_AUTH_ISSUER,QUELEA_AUTH_AUDIENCE,QUELEA_AUTH_TENANT_ID,QUELEA_AUTH_JWKS_URL). From this point every route except/healthand the API documentation requires a token.
Requirements: JDK 25 and Docker.
# Start Dragonfly on localhost:6379
docker compose -f docker/docker-compose.yml up -d
# Run the service against it
./gradlew :backend:run
# Run a local Velocity proxy with the plugin (QUELEA_URL defaults to http://localhost:8080)
./gradlew :velocity:runVelocity
# Full build
./gradlew build
# Unit tests only (no Docker needed)
./gradlew :backend:test -PexcludeTags=integrationIntegration tests use Testcontainers and need a running Docker daemon. They are tagged integration.
Specifications and the change history live in openspec/.
Every pull request runs:
- Build: the shared OneLiteFeather Gradle workflow (
build test, JDK 25). - API compatibility:
oasdiffcompares the OpenAPI document of the base with the head and fails on breaking changes. The changelog goes to the job summary (.github/workflows/api-diff.yml). - Coverage: JaCoCo XML and HTML reports for
backendandvelocity, uploaded to Codecov and as a workflow artifact. Coverage has no threshold and never fails the build (.github/workflows/build-pr.yml).