Skip to content

Commit fa9343e

Browse files
authored
chore: auth server leaderboard (#72)
1 parent 271981d commit fa9343e

16 files changed

Lines changed: 751 additions & 0 deletions

File tree

dcl-workspace.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"folders": [
3+
{
4+
"path": "scenes/90,-9-authoritative-server-leaderboard"
5+
},
36
{
47
"path": "scenes/9,99-modifier-areas"
58
},
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.*
2+
bin/*.map
3+
package-lock.json
4+
yarn-lock.json
5+
build.json
6+
export
7+
tsconfig.json
8+
tslint.json
9+
node_modules
10+
*.ts
11+
*.tsx
12+
.vscode
13+
Dockerfile
14+
dist
15+
README.md
16+
*.blend
17+
*.fbx
18+
*.zip
19+
*.rar
20+
src
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package-lock.json
2+
*.js
3+
node_modules
4+
bin/
5+
.DS_Store
6+
**/.DS_Store
7+
npm-debug.log*
8+
yarn-debug.log*
9+
yarn-error.log*
10+
.editor
11+
.idea
12+
.vscode
13+
dclcontext
14+
.env
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Authoritative Server — Leaderboard
2+
3+
A showcase of the **Authoritative Server** leaderboard use case described in the
4+
[SDK7 authorative server docs](https://docs.decentraland.org/creator/scenes-sdk7/networking/authoritative-servers).
5+
6+
A headless, Decentraland-hosted server owns the game state. Players click an orb to
7+
score; the server validates each claim, persists per-player totals, and broadcasts a
8+
ranked leaderboard that every client renders.
9+
10+
## What it demonstrates
11+
12+
| Concept | Where |
13+
| --- | --- |
14+
| `isServer()` branching of a single codebase | `src/index.ts` |
15+
| Client→server / server→client messages via `registerMessages()` | `src/shared/messages.ts` |
16+
| Server-authoritative synced components + `validateBeforeChange()` (server-only writes) | `src/shared/schemas.ts` |
17+
| Server-only `syncEntity()` of server-created entities | `src/server/server.ts` |
18+
| Proximity anti-cheat using server-verified `PlayerIdentityData` + `Transform` | `src/server/server.ts` |
19+
| Persistence across server sleep/redeploys with `Storage` | `src/server/server.ts` |
20+
| Server-liveness **heartbeat** (distinguishing "room synced" from "server awake") | `src/shared/schemas.ts`, `src/client/state.ts` |
21+
| React-ECS UI reading synced state | `src/client/ui.tsx` |
22+
23+
## Anti-cheat model
24+
25+
The client **never reports a score** — only the *intent* to claim a point
26+
(`claimPoint`). The server:
27+
28+
1. Reads the sender's wallet from the verified message `context.from`.
29+
2. Confirms the player is genuinely within `CLAIM_RADIUS` of the orb using
30+
server-verified positions (`PlayerIdentityData` + `Transform`) — never
31+
client-reported positions.
32+
3. Increments the score itself and is the *only* writer of the `Leaderboard`
33+
component (enforced by `validateBeforeChange``AUTH_SERVER_PEER_ID`).
34+
35+
## Run it
36+
37+
> **Requires the `auth-server` SDK branch** — not the standard `@dcl/sdk`.
38+
> `package.json` already pins it; install from this scene folder:
39+
40+
```bash
41+
cd "scenes/90,-9-authoritative-server-leaderboard"
42+
npm install @dcl/sdk@auth-server @dcl/js-runtime@auth-server
43+
npm run start
44+
```
45+
46+
The preview launches a local server automatically. To test multiplayer, open a second
47+
client (Preview again in Creator Hub, or
48+
`decentraland://realm=http://127.0.0.1:8000&local-scene=true&debug=true`).
49+
50+
### Notes
51+
52+
- Add your wallet address to `logsPermissions` in `scene.json` to view production
53+
server logs (`npx sdk-commands sdk-server-logs`).
54+
- Local Storage lives at `node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json`.
55+
Inspect/clear with `npx sdk-commands storage scene get|delete leaderboard`.
56+
- The server only runs while at least one player is in the scene; a cold start takes
57+
~15 s in production, which is why the heartbeat-based liveness check matters.

scenes/90,-9-authoritative-server-leaderboard/main.crdt

Whitespace-only changes.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "authoritative-server-leaderboard",
3+
"version": "1.0.0",
4+
"description": "Authoritative Server leaderboard showcase scene",
5+
"scripts": {
6+
"start": "sdk-commands start",
7+
"deploy": "sdk-commands deploy",
8+
"build": "sdk-commands build",
9+
"server-logs": "sdk-commands sdk-server-logs"
10+
},
11+
"devDependencies": {
12+
"@dcl/js-runtime": "7.24.3-28199504206.commit-1a6c780",
13+
"@dcl/sdk": "7.24.3-28199504206.commit-1a6c780"
14+
},
15+
"engines": {
16+
"node": ">=16.0.0",
17+
"npm": ">=6.0.0"
18+
},
19+
"prettier": {
20+
"semi": false,
21+
"singleQuote": true,
22+
"printWidth": 120,
23+
"trailingComma": "none"
24+
},
25+
"dependencies": {
26+
"@dcl/sdk-commands": "7.24.3-28199504206.commit-1a6c780"
27+
}
28+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"ecs7": true,
3+
"runtimeVersion": "7",
4+
"display": {
5+
"title": "Authoritative Server — Leaderboard",
6+
"description": "Showcase of the Authoritative Server leaderboard use case: clients send score-claim actions, the headless server validates them (proximity anti-cheat + server-only component writes), persists per-player totals to Storage, and broadcasts a synced top-N ranking that every client renders in a UI panel. Includes a server-liveness heartbeat."
7+
},
8+
"owner": "",
9+
"contact": {
10+
"name": "Decentraland",
11+
"email": "developer@decentraland.org"
12+
},
13+
"main": "bin/index.js",
14+
"tags": [
15+
"multiplayer",
16+
"authoritative-server",
17+
"leaderboard",
18+
"test"
19+
],
20+
"scene": {
21+
"parcels": [
22+
"90,-9",
23+
"91,-9"
24+
],
25+
"base": "90,-9"
26+
},
27+
"spawnPoints": [
28+
{
29+
"name": "spawn1",
30+
"default": true,
31+
"position": {
32+
"x": [
33+
6,
34+
10
35+
],
36+
"y": [
37+
0,
38+
0
39+
],
40+
"z": [
41+
6,
42+
10
43+
]
44+
},
45+
"cameraTarget": {
46+
"x": 16,
47+
"y": 1.5,
48+
"z": 8
49+
}
50+
}
51+
],
52+
"requiredPermissions": [],
53+
"featureToggles": {},
54+
"worldConfiguration": {
55+
"name": "sdk7testscenes.dcl.eth"
56+
},
57+
"authoritativeMultiplayer": true
58+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
Entity,
3+
InputAction,
4+
Material,
5+
MeshCollider,
6+
MeshRenderer,
7+
TextAlignMode,
8+
TextShape,
9+
Transform,
10+
engine,
11+
pointerEventsSystem
12+
} from '@dcl/sdk/ecs'
13+
import { Color4, Quaternion, Vector3 } from '@dcl/sdk/math'
14+
import { isStateSyncronized } from '@dcl/sdk/network'
15+
import { ORB_POSITION } from '../shared/config'
16+
import { room } from '../shared/messages'
17+
import { isServerAlive, pollHeartbeat, setMyScore, showToast } from './state'
18+
19+
// A claim queued locally, waiting for the room to be synced before it is sent.
20+
let pendingClaim = false
21+
22+
export function setupClient(): void {
23+
buildScene()
24+
25+
// Server → me: my new authoritative total.
26+
room.onMessage('scoreUpdate', (data) => setMyScore(data.total))
27+
28+
// Server → me: claim was rejected (too far, etc.).
29+
room.onMessage('claimRejected', (data) => showToast(data.reason))
30+
31+
engine.addSystem(claimRetrySystem)
32+
}
33+
34+
// Build the local, non-synced visuals. These are client-only decorations — the
35+
// only synced state in this scene is owned and created by the server.
36+
function buildScene(): void {
37+
// Ground platform covering both parcels (32 × 16 m).
38+
const ground = engine.addEntity()
39+
Transform.create(ground, {
40+
position: Vector3.create(16, 0, 8),
41+
scale: Vector3.create(32, 0.1, 16)
42+
})
43+
MeshRenderer.setBox(ground)
44+
MeshCollider.setBox(ground)
45+
Material.setPbrMaterial(ground, { albedoColor: Color4.fromHexString('#1b2a4aff') })
46+
47+
// Pedestal under the orb.
48+
const pedestal = engine.addEntity()
49+
Transform.create(pedestal, {
50+
position: Vector3.create(ORB_POSITION.x, 0.5, ORB_POSITION.z),
51+
scale: Vector3.create(1.2, 1, 1.2)
52+
})
53+
MeshRenderer.setCylinder(pedestal)
54+
MeshCollider.setCylinder(pedestal)
55+
Material.setPbrMaterial(pedestal, { albedoColor: Color4.fromHexString('#3a4a6bff') })
56+
57+
// The clickable score orb (glowing emissive sphere).
58+
const orb = engine.addEntity()
59+
Transform.create(orb, { position: ORB_POSITION, scale: Vector3.create(0.9, 0.9, 0.9) })
60+
MeshRenderer.setSphere(orb)
61+
MeshCollider.setSphere(orb) // required for the pointer raycast to hit the orb
62+
Material.setPbrMaterial(orb, {
63+
albedoColor: Color4.fromHexString('#ffd34eff'),
64+
emissiveColor: Color4.fromHexString('#ffb000ff'),
65+
emissiveIntensity: 2
66+
})
67+
pointerEventsSystem.onPointerDown(
68+
{ entity: orb, opts: { button: InputAction.IA_POINTER, hoverText: 'Score a point!' } },
69+
tryClaim
70+
)
71+
72+
// Floating title sign.
73+
const sign = engine.addEntity()
74+
Transform.create(sign, {
75+
position: Vector3.create(ORB_POSITION.x, 3.4, ORB_POSITION.z),
76+
rotation: Quaternion.fromEulerDegrees(0, 180, 0)
77+
})
78+
TextShape.create(sign, {
79+
text: 'AUTHORITATIVE SERVER\nLEADERBOARD\n\nClick the orb to score',
80+
fontSize: 3,
81+
textColor: Color4.White(),
82+
textAlign: TextAlignMode.TAM_MIDDLE_CENTER
83+
})
84+
}
85+
86+
// The orb was clicked. We never send a score — only the intent to claim a point.
87+
function tryClaim(): void {
88+
// Server-not-alive is a long (cold-start) failure that may never resolve — tell
89+
// the player explicitly instead of silently buffering a click that goes nowhere.
90+
if (!isServerAlive()) {
91+
showToast('Server is waking up — try again in a moment…')
92+
return
93+
}
94+
// Room-not-synced is a brief (~1 s) load-time blip — buffer and auto-fire.
95+
pendingClaim = true
96+
}
97+
98+
function claimRetrySystem(): void {
99+
// Keep the client's view of server liveness fresh.
100+
pollHeartbeat()
101+
102+
if (!pendingClaim) return
103+
if (!isStateSyncronized()) return
104+
105+
room.send('claimPoint', { nonce: 0 })
106+
pendingClaim = false
107+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { engine } from '@dcl/sdk/ecs'
2+
import { isStateSyncronized } from '@dcl/sdk/network'
3+
import { HEARTBEAT_FRESHNESS_MS } from '../shared/config'
4+
import { ServerHeartbeat } from '../shared/schemas'
5+
6+
// Client-side, UI-facing state. Kept in its own module to avoid a circular import
7+
// between setup.ts (writes it) and ui.tsx (reads it).
8+
9+
// --- Your authoritative score (set by the server's scoreUpdate message) --------
10+
let myScore = 0
11+
export function getMyScore(): number {
12+
return myScore
13+
}
14+
export function setMyScore(value: number): void {
15+
myScore = value
16+
}
17+
18+
// --- Transient toast message (e.g. "get closer to the orb") --------------------
19+
let toastText = ''
20+
let toastUntil = 0
21+
export function showToast(message: string): void {
22+
toastText = message
23+
toastUntil = Date.now() + 3000
24+
}
25+
export function getToast(): string {
26+
return Date.now() < toastUntil ? toastText : ''
27+
}
28+
29+
// --- Server liveness ------------------------------------------------------------
30+
// isStateSyncronized() only proves the CRDT room is connected — that room can be
31+
// replaying a stale snapshot from a previous server run while the server is still
32+
// cold-booting (~15 s in production) or hasn't started at all. So we track the
33+
// CLIENT-side time at which the heartbeat value last *changed*, not the value
34+
// itself: a stale snapshot never advances, so it can't read as "alive", and
35+
// server/client clock skew is irrelevant.
36+
let lastBeatValue = 0
37+
let lastBeatSeenAt = 0
38+
39+
export function pollHeartbeat(): void {
40+
for (const [, hb] of engine.getEntitiesWith(ServerHeartbeat)) {
41+
if (hb.beatAt !== lastBeatValue) {
42+
lastBeatValue = hb.beatAt
43+
lastBeatSeenAt = Date.now()
44+
}
45+
break
46+
}
47+
}
48+
49+
export function isServerAlive(): boolean {
50+
if (!isStateSyncronized()) return false
51+
if (lastBeatSeenAt === 0) return false // never observed a tick yet
52+
return Date.now() - lastBeatSeenAt < HEARTBEAT_FRESHNESS_MS
53+
}

0 commit comments

Comments
 (0)