Make a co-op game with a leaderboard
In this tutorial you build Gem Storm, a game players win or lose together. Gems rain down on an arena in waves. The whole team has to collect enough of them before the clock runs out, and every wave asks for more. When the team falls short, the game is over: everyone’s result is saved, and a leaderboard shows the players who made it furthest.
Along the way you’ll use the pieces every multiplayer game needs:
- Server-side rules. The game server runs the waves, drops the gems and keeps score. Players’ devices only display what it decides.
- Shared state in attributes. The current wave, the clock and the team’s progress live in attributes that reach every player, including players who join late.
- A RemoteEvent with validation. Players press a key to dash. The server checks every request before it does anything.
- Ordered data stores. Everyone’s best result is saved and ranked.
- A RemoteFunction. Each player’s device asks the server for the leaderboard and draws it.
You start from the Ball Blitz template, for its arena, and write all the code yourself. You need a Rally account and a computer with a desktop browser. The other tutorials help, but aren’t required.
Create the game
Section titled “Create the game”-
Sign in at rally.place and open Games in the left rail.
-
Choose Create a game, name it
Gem Storm, leave Dimension on 3D, and under Start from, pick Ball Blitz. -
Choose Create game, then Open in Studio on the game’s page.
Ball Blitz is a battle game, but its arena is just what Gem Storm needs: a floor with low walls, a few pieces of cover, four spawn points and a HUD with a clock. You’ll keep the arena and replace the rest.
Rework the template
Section titled “Rework the template”First remove what Gem Storm doesn’t use. The template’s scripts are compiled JavaScript (templates are published without their TypeScript), so you’ll write new ones.
-
In the Explorer, click main in ServerScriptService and press Delete. Then expand StarterPlayer > StarterPlayerScripts, click controls and press Delete.
-
In Workspace, delete the Blocks folder. Select the Balls folder, press F2 and rename it
Gems. Falling gems will go in it. -
In ReplicatedStorage, rename the Launch RemoteEvent to
Dash, and delete Round and HitMarker. -
Right-click ReplicatedStorage, choose Insert Object > RemoteFunction, and rename it
GetLeaderboard. -
Right-click ReplicatedStorage again, choose Insert Object > Folder, and rename it
GameState. The server will keep the game’s public state in this folder’s attributes. -
Expand StarterGui > HUD and delete HitMarker. Select Hint and set its
textproperty toCollect gems with your team · Q or Shift to dash.
Now add a panel for the leaderboard. Put it in the top-left corner: the top-right is where Rally draws every game’s player list, on top of your UI.
-
Right-click HUD and choose Insert Object > Frame. Rename it
Boardand set these properties:Property Value positionX 16, Y16sizeX 260, Y264backgroundColor#0b132bbackgroundTransparency0.2cornerRadius14 -
Right-click Board and choose Insert Object > TextLabel. Rename it
Titleand set these properties:Property Value positionX 0, Y10sizeScaleX 1, Y0sizeX 0, Y28textMost waves clearedtextColor#fffffftextSize18fontWeightboldbackgroundTransparency1
sizeScale X 1 plus size X 0 makes the title exactly as wide as the board. The rows below it will be created by a script.
Here’s what you have now, with the scripts you’re about to write in bold:
DirectoryWorkspace
- Arena floor, walls, cover and lamps
- Spawns four SpawnLocations
- Gems empty for now
DirectoryReplicatedStorage
- Dash RemoteEvent
- GetLeaderboard RemoteFunction
- GameState Folder
- GemConfig ModuleScript
DirectoryServerScriptService
- Leaderboard ModuleScript
- GemStorm Script
DirectoryStarterGui
DirectoryHUD
- Clock with the Time and Phase labels
- Announcement
- Hint
DirectoryBoard
- Title
DirectoryStarterPlayer
DirectoryStarterPlayerScripts
- GemHud LocalScript
Plan who decides what
Section titled “Plan who decides what”In a multiplayer game, every player’s device could be modified, so it can’t be trusted with anything that matters. The game server makes every decision, and devices only show results and pass on what players want to do:
| The server (GemStorm) | Each player’s device (GemHud) |
|---|---|
| Runs the waves and the clock | Shows the clock, the wave and the team’s progress |
| Drops gems and decides who collected each one | Shows announcements |
| Speeds a character up when a dash is allowed | Asks for a dash when you press Q or Shift |
| Saves results and builds the leaderboard | Asks for the leaderboard and draws it |
Information flows in four ways:
- GameState attributes, server to everyone: the phase, wave, seconds left, goal and gems collected. Attributes replicate like properties, and a player who joins mid-game gets the current values at once. A message sent with a RemoteEvent before they joined would never reach them.
- The Dash RemoteEvent, a device to the server: “I’d like to dash.”
- The GetLeaderboard RemoteFunction, a device asking the server and waiting for the answer: “Who’s on the leaderboard?”
- leaderstats, server to everyone: each player’s gem count, shown in the player list.
Write the shared settings
Section titled “Write the shared settings”-
Right-click ReplicatedStorage and choose Insert Object > ModuleScript.
-
Rename it
GemConfig, double-click it to open it, and replace its starter code with this listing.
// Names, tuning and helpers that the server and the HUD share.
/** RemoteEvent, client → server, no arguments: "dash, please". */export const DASH = 'Dash';/** RemoteFunction, client → server: returns the leaderboard as BoardRow[]. */export const GET_LEADERBOARD = 'GetLeaderboard';
/** What the game is doing. The server keeps it in GameState's `Phase` attribute. */export type Phase = 'waiting' | 'intermission' | 'wave' | 'gameover';
export const INTERMISSION_SECONDS = 8;export const WAVE_SECONDS = 30;export const GAME_OVER_SECONDS = 8;
/** Gems the whole team needs to collect to clear a wave. */export function goalFor(wave: number, players: number): number { return (3 + 2 * wave) * Math.max(1, players);}
/** Seconds between gem drops. Each drop is one gem per player. */export const DROP_EVERY = 1.2;/** Seconds a gem lasts before it fades away. */export const GEM_LIFETIME = 10;/** Gems land at most this many studs from the arena's center, on X and Z. */export const DROP_AREA = 55;
export const DASH_SPEED = 60;export const DASH_SECONDS = 0.5;export const DASH_COOLDOWN = 4;
/** Ordered data store: user id → most waves cleared in one game. */export const BEST_STORE = 'BestWaves';/** Data store: user id → display name, so the board can show names. */export const NAMES_STORE = 'PlayerNames';/** Rows on the leaderboard. */export const BOARD_SIZE = 8;
export type BoardRow = { name: string; waves: number };
/** 75 → "1:15". */export function formatClock(seconds: number): string { const s = Math.max(0, Math.ceil(seconds)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;}Both sides import from this module, so they always agree on names and numbers. For any class or function in these scripts, see the API reference.
Phaselists the four states the game can be in.goalForsets how many gems the team needs: 5 per player in wave 1, then two more per player each wave. WithDROP_EVERYat 1.2 seconds, a 30-second wave drops about 25 gems per player, so by wave 10 or so the team needs nearly all of them.BoardRowis the shape of one leaderboard row, shared so the HUD knows what the server sends.
Every number here is a dial you can turn later.
Write the leaderboard module
Section titled “Write the leaderboard module”The leaderboard needs two data stores. An ordered data store holds numbers it can sort, so it ranks players by the most waves they’ve cleared. Its keys are user ids, which never change, so a second, plain data store remembers each player’s display name.
-
Right-click ServerScriptService and choose Insert Object > ModuleScript.
-
Rename it
Leaderboard, open it, and replace its code with this listing.
// Saved results and the top-players list. An ordered data store ranks user ids by the most waves// cleared; a second data store remembers display names, because the ranking only has ids.import { DataStoreService, type Player } from '@rally/sdk';import { BEST_STORE, type BoardRow, NAMES_STORE } from '@game/ReplicatedStorage/GemConfig';
const bestWaves = DataStoreService.getOrderedDataStore(BEST_STORE);const names = DataStoreService.getDataStore(NAMES_STORE);
/** Records `waves` for each player, keeping only their best. */export async function saveBest(players: Player[], waves: number): Promise<void> { await Promise.all( players.map(async (player) => { await names.set(player.userId, player.displayName); // `update` reads, changes and writes in one go, so a result saved by another game server // at the same moment is never lost. Returning undefined leaves the value as it is. await bestWaves.update<number>(player.userId, (old) => old === undefined || waves > old ? waves : undefined, ); }), );}
/** The best players, highest first, with their names. */export async function topPlayers(limit: number): Promise<BoardRow[]> { const top = await bestWaves.getSorted({ limit }); return Promise.all( top.map(async (entry) => ({ name: (await names.get<string>(entry.key)) ?? 'Someone', waves: entry.value, })), );}saveBestruns at the end of every game, for everyone who played. It stores each player’s name, then callsupdate, which reads the saved value, lets you return a new one, and writes it back. If two game servers save the same player at once,updateretries, so no result is lost. Returningundefinedkeeps the old value, which is how a worse result never replaces a better one.topPlayerscallsgetSorted({ limit }), which returns{ key, value }entries from highest to lowest (passascending: truefor lowest first), then looks up each name.- Data store calls are asynchronous and can fail. This module lets errors through, and the game script decides what to do with them.
The module lives in ServerScriptService because only the server uses it: data stores only work on the server.
Write the game’s rules
Section titled “Write the game’s rules”-
Right-click ServerScriptService and choose Insert Object > Script.
-
Rename it
GemStorm, open it, and replace its code with this listing.
// Gem Storm: the rules. Runs the waves, drops gems, counts what the team collects, handles dashes,// saves everyone's best result and serves the leaderboard. Clients only ask to dash and ask for// the board; everything else is decided here.import { Character, Color3, createLeaderstats, Folder, getChild, type NumberValue, Part, type Player, Players, PointLight, print, RemoteEvent, RemoteFunction, ReplicatedStorage, StarterPlayer, task, Vector3, warn, workspace,} from '@rally/sdk';import { BOARD_SIZE, type BoardRow, DASH, DASH_COOLDOWN, DASH_SECONDS, DASH_SPEED, DROP_AREA, DROP_EVERY, GAME_OVER_SECONDS, GEM_LIFETIME, GET_LEADERBOARD, goalFor, INTERMISSION_SECONDS, type Phase, WAVE_SECONDS,} from '@game/ReplicatedStorage/GemConfig';import { saveBest, topPlayers } from '@game/ServerScriptService/Leaderboard';
const state = getChild(ReplicatedStorage, 'GameState', Folder);const gems = getChild(workspace, 'Gems', Folder);const dash = getChild(ReplicatedStorage, DASH, RemoteEvent);const getLeaderboard = getChild(ReplicatedStorage, GET_LEADERBOARD, RemoteFunction);
/** Gems collected this wave, by the whole team. */let collected = 0;
// GameState's attributes are the game's public state. They replicate to every client, including// players who join later, so the HUD can always show the current phase, clock and progress.function setPhase(phase: Phase, secondsLeft: number): void { state.setAttribute('Phase', phase); state.setAttribute('SecondsLeft', secondsLeft);}
// ── Players ─────────────────────────────────────────────────────────────────────────────────
const gemCounts = new Map<Player, NumberValue>();const lastDash = new Map<Player, number>();
Players.playerAdded.connect((player) => { const stats = createLeaderstats(player, { Gems: 0 }); gemCounts.set(player, stats.Gems);});
Players.playerRemoving.connect((player) => { gemCounts.delete(player); lastDash.delete(player);});
// ── Gems ────────────────────────────────────────────────────────────────────────────────────
function dropGem(): void { const gem = new Part(); gem.name = 'Gem'; gem.size = new Vector3(1.6, 1.6, 1.6); gem.rotation = new Vector3(45, 0, 45); gem.material = 'neon'; gem.color = Color3.fromHSV(0.45 + Math.random() * 0.2, 0.8, 1); gem.anchored = false; gem.restitution = 0.4; gem.position = new Vector3( (Math.random() * 2 - 1) * DROP_AREA, 40, (Math.random() * 2 - 1) * DROP_AREA, ); const glow = new PointLight(); glow.color = gem.color; glow.range = 10; glow.parent = gem; gem.touched.connect((other) => { if (!(other instanceof Character) || other.health <= 0 || gem.destroyed) return; const player = Players.getPlayerFromCharacter(other); const count = player && gemCounts.get(player); if (!count || state.getAttribute('Phase') !== 'wave') return; gem.destroy(); count.value += 1; collected += 1; state.setAttribute('Collected', collected); }); gem.parent = gems; task.delay(GEM_LIFETIME, () => { if (!gem.destroyed) gem.destroy(); });}
/** Plays one wave. Resolves true if the team reached the goal in time. */async function playWave(wave: number): Promise<boolean> { const goal = goalFor(wave, Players.getPlayers().length); collected = 0; state.setAttribute('Goal', goal); state.setAttribute('Collected', 0); gems.clearAllChildren(); const end = task.time() + WAVE_SECONDS; let nextDrop = task.time(); while (task.time() < end && collected < goal) { if (task.time() >= nextDrop) { for (let i = 0; i < Players.getPlayers().length; i++) dropGem(); nextDrop += DROP_EVERY; } setPhase('wave', Math.ceil(end - task.time())); await task.wait(0.1); } gems.clearAllChildren(); return collected >= goal;}
// ── The leaderboard ─────────────────────────────────────────────────────────────────────────
// Clients get the board from this copy, never straight from the data stores: a hundred players// asking at once still costs one read. BoardVersion tells clients when to ask again.let board: BoardRow[] = [];
async function refreshBoard(): Promise<void> { try { board = await topPlayers(BOARD_SIZE); const version = state.getAttribute('BoardVersion'); state.setAttribute('BoardVersion', (typeof version === 'number' ? version : 0) + 1); } catch (e) { warn("Couldn't load the leaderboard:", e); }}
getLeaderboard.onServerInvoke = () => board;task.spawn(refreshBoard);
// ── The game loop ───────────────────────────────────────────────────────────────────────────
task.spawn(async () => { for (;;) { while (Players.getPlayers().length === 0) { setPhase('waiting', 0); await task.wait(1); }
// A new game: everyone back to a spawn, with no gems. for (const count of gemCounts.values()) count.value = 0; for (const player of Players.getPlayers()) player.loadCharacter(); let wave = 1; for (;;) { state.setAttribute('Wave', wave); for (let t = INTERMISSION_SECONDS; t > 0; t--) { setPhase('intermission', t); await task.wait(1); } if (!(await playWave(wave))) break; wave += 1; }
// Game over: the team cleared every wave before this one. const cleared = wave - 1; print(`Game over: cleared ${cleared} waves`); setPhase('gameover', GAME_OVER_SECONDS); if (cleared > 0) { try { await saveBest(Players.getPlayers(), cleared); await refreshBoard(); } catch (e) { warn("Couldn't save results:", e); } } for (let t = GAME_OVER_SECONDS; t > 0; t--) { setPhase('gameover', t); await task.wait(1); } }});
// ── Dashing ─────────────────────────────────────────────────────────────────────────────────
dash.onServerEvent.connect((player) => { const character = player.character; const now = task.time(); // Never trust the client: it needs a living character, and the cooldown must have passed. if (!character || character.health <= 0) return; if (now - (lastDash.get(player) ?? -Infinity) < DASH_COOLDOWN) return; lastDash.set(player, now); character.walkSpeed = DASH_SPEED; task.delay(DASH_SECONDS, () => { if (!character.destroyed) character.walkSpeed = StarterPlayer.characterWalkSpeed; });});Public state
Section titled “Public state”setPhase writes the phase and the seconds left into GameState’s attributes. The script updates the clock every tenth of a second, but setting an attribute to the value it already has does nothing, so only one change per second goes out over the network.
Players
Section titled “Players”Each player gets a Gems value in their leaderstats, which Rally shows in the player list. The script keeps a Map from each player to that value, and to the time they last dashed, and cleans both up when they leave.
dropGem creates a part in code. It’s unanchored, so physics drops it from 40 studs up and it bounces a little (restitution). A PointLight inside makes it glow.
Its touched handler is where the rules live. It checks that the toucher is a living character that belongs to a player, and that a wave is running. Then it destroys the gem, adds one to that player’s count and to the team’s total, and publishes the new total in the Collected attribute. Gems that nobody collects disappear after GEM_LIFETIME seconds.
A wave
Section titled “A wave”playWave sets the goal from the number of players, then loops until time runs out or the team reaches the goal. Each time through, it drops one gem per player if it’s time, updates the clock, and waits a tenth of a second. It returns whether the team made it.
The game loop
Section titled “The game loop”One long-running loop inside task.spawn runs the whole game:
- Wait until someone is playing.
- Start a new game: reset everyone’s gems and respawn them with
player.loadCharacter(). - Play waves with a short break before each one, until a wave fails.
- Save the result and refresh the leaderboard. A game where the team cleared no waves isn’t saved.
- Show the game-over screen for a few seconds, then start again.
await task.wait(...) pauses the loop without blocking the rest of the game. Scripts can’t use await at the top level, which is why the loop is wrapped in task.spawn.
The leaderboard
Section titled “The leaderboard”The server keeps the latest leaderboard in board, and getLeaderboard.onServerInvoke answers every request from it. Reading data stores once per game, rather than once per request, keeps the game fast no matter how many players ask.
After each refresh, the script bumps a BoardVersion attribute. Devices watch it and ask for the new board.
Dashing
Section titled “Dashing”The Dash handler shows how to treat requests from players. Anyone can fire a RemoteEvent at any time, from a modified client too, so the server checks that the player has a living character and that the cooldown has passed. Only then does it raise the character’s walkSpeed, and put it back to StarterPlayer.characterWalkSpeed half a second later.
The server never moves the character itself. Each player moves their own character, and the server checks every move against the speed it set, so this is also why a player can’t give themselves a dash: a faster character than the server allows gets put back.
Write the HUD
Section titled “Write the HUD”-
Right-click StarterPlayerScripts (inside StarterPlayer) and choose Insert Object > LocalScript.
-
Rename it
GemHud, open it, and replace its code with this listing.
// Gem Storm: the HUD. Shows the clock and the team's progress from GameState's attributes,// announces waves, sends dashes and draws the leaderboard the server hands out.import { Color3, Folder, Frame, PlayerGui, RemoteEvent, RemoteFunction, ReplicatedStorage, ScreenGui, TextLabel, TweenService, task, UserInputService, Vector2, waitForChild, waitForLocalPlayer, warn,} from '@rally/sdk';import { BOARD_SIZE, type BoardRow, DASH, DASH_COOLDOWN, formatClock, GET_LEADERBOARD,} from '@game/ReplicatedStorage/GemConfig';
const WHITE = Color3.fromHex('#ffffff');const RED = Color3.fromHex('#ff5a5f');
// Dash with Q or Shift. The server enforces the cooldown; checking it here as well only saves// sending requests the server would ignore.task.spawn(async () => { const dash = await waitForChild(ReplicatedStorage, DASH, RemoteEvent); let last = -Infinity; UserInputService.inputBegan.connect((input, gameProcessed) => { if (gameProcessed) return; if (input.key !== 'KeyQ' && input.key !== 'ShiftLeft') return; if (task.time() - last < DASH_COOLDOWN) return; last = task.time(); dash.fireServer(); });});
task.spawn(async () => { const me = await waitForLocalPlayer(); const hud = await waitForChild(await waitForChild(me, 'PlayerGui', PlayerGui), 'HUD', ScreenGui); const clock = await waitForChild(hud, 'Clock', Frame); const timeLabel = await waitForChild(clock, 'Time', TextLabel); const phaseLabel = await waitForChild(clock, 'Phase', TextLabel); const announcement = await waitForChild(hud, 'Announcement', TextLabel); const state = await waitForChild(ReplicatedStorage, 'GameState', Folder); const number = (key: string) => { const value = state.getAttribute(key); return typeof value === 'number' ? value : 0; };
// The clock and the progress line, redrawn whenever the server changes the state. const render = () => { const phase = state.getAttribute('Phase'); const wave = number('Wave'); const left = number('SecondsLeft'); timeLabel.text = phase === 'wave' || phase === 'intermission' ? formatClock(left) : '-:--'; timeLabel.textColor = phase === 'wave' && left <= 10 ? RED : WHITE; phaseLabel.text = phase === 'wave' ? `Wave ${wave}: ${number('Collected')} / ${number('Goal')} gems` : phase === 'intermission' ? `Wave ${wave} starts soon` : phase === 'gameover' ? 'Game over' : 'Waiting for players'; }; for (const key of ['Phase', 'Wave', 'SecondsLeft', 'Collected', 'Goal']) state.getAttributeChangedSignal(key).connect(render); render();
// Big announcements when the phase changes. let hideAnnouncement: (() => void) | null = null; const announce = (text: string) => { announcement.text = text; announcement.visible = true; announcement.textSize = 20; TweenService.create(announcement, { time: 0.4, easing: 'backOut' }, { textSize: 44 }).play(); hideAnnouncement?.(); hideAnnouncement = task.delay(2.5, () => { announcement.visible = false; }); }; const waves = (n: number) => `${n} ${n === 1 ? 'wave' : 'waves'}`; state.getAttributeChangedSignal('Phase').connect(() => { const phase = state.getAttribute('Phase'); const wave = number('Wave'); if (phase === 'wave') announce(`Wave ${wave}!`); else if (phase === 'intermission' && wave > 1) announce(`Wave ${wave - 1} cleared!`); else if (phase === 'gameover') announce(wave > 1 ? `Game over! Your team cleared ${waves(wave - 1)}.` : 'Game over!'); });
// The leaderboard: one row per entry, made here (UI a LocalScript creates is only on this // device) and filled with whatever the server's GetLeaderboard returns. const board = await waitForChild(hud, 'Board', Frame); const getLeaderboard = await waitForChild(ReplicatedStorage, GET_LEADERBOARD, RemoteFunction); const rows: TextLabel[] = []; for (let i = 0; i < BOARD_SIZE; i++) { const row = new TextLabel(); row.name = `Row${i + 1}`; row.position = new Vector2(16, 44 + i * 26); row.sizeScale = new Vector2(1, 0); row.size = new Vector2(-32, 24); row.text = ''; row.textAlign = 'left'; row.textSize = 15; row.textColor = Color3.fromHex('#dfe7ff'); row.backgroundTransparency = 1; row.parent = board; rows.push(row); } const showBoard = async () => { try { const result = await getLeaderboard.invokeServer(); const entries = Array.isArray(result) ? (result as BoardRow[]) : []; rows.forEach((row, i) => { const entry = entries[i]; if (entry) row.text = `${i + 1}. ${entry.name}: ${waves(entry.waves)}`; else row.text = i === 0 ? 'No results yet. Be the first!' : ''; }); } catch (e) { warn("Couldn't get the leaderboard:", e); } }; state.getAttributeChangedSignal('BoardVersion').connect(() => void showBoard()); void showBoard();});This LocalScript runs on each player’s device.
- Dash input.
UserInputService.inputBeganfires for every key and click.gameProcessedis true when the input went to the UI (for example, typing in a text box), so the script ignores those. Keys use the browser’s key codes, such asKeyQandShiftLeft. The script keeps its own cooldown too, which only saves sending requests the server would ignore. - The clock and progress.
renderreads GameState’s attributes and fills in the Clock’s two labels, turning the time red in a wave’s last 10 seconds. It runs whenever any of those attributes changes. - Announcements. When the phase changes, the script works out what happened from the attributes: a new wave, a cleared wave or the end of the game. The text pops in by tweening its size.
- The leaderboard rows. The script creates eight TextLabels inside the Board in code. UI that a LocalScript creates exists only on that device, which is all a HUD needs.
- Fetching the leaderboard.
await getLeaderboard.invokeServer()asks the server and waits for the answer. The script checks that it got a list before using it, and catches errors, because a request fails if the server doesn’t answer within a few seconds.
Test your game
Section titled “Test your game”Press Play (F5) and check that:
- After a short countdown, the clock shows Wave 1 and gems start falling.
- Each gem you touch disappears, adds to Gems in the player list and to the progress under the clock.
- Collecting enough gems announces Wave 1 cleared!, and the next wave asks for more.
- Pressing Q or Shift gives you a burst of speed, at most once every four seconds.
- Letting a wave run out ends the game. If you cleared at least one wave, your name then appears on the leaderboard.
Play runs the game in your browser with you as the only player. Saved data lives in memory, so the leaderboard starts empty each time you press Play.
To test the co-op part, open the menu next to Play and choose 2 players under Test on a real game server. Two players appear side by side, the goal doubles, and both players’ gems count toward it. Test servers don’t keep saved data either. See Testing.
If something goes wrong, the Output panel shows errors. Click one to jump to the line in your script.
Publish
Section titled “Publish”-
Choose Publish in the top-right corner of Studio.
-
Choose the back arrow at the top-left of Studio to go to the game’s page.
-
Under Settings, pick Unlisted (anyone with the link can play) or Public (listed in Games), and choose Save changes. For a co-op game, you might also raise Max players per server: Ball Blitz sets it to 16.
-
Choose Play and share the link with friends.
In the published game, results are saved for real: every server of your game shares the same data stores, so the leaderboard shows the best players from all of them.
Make it yours
Section titled “Make it yours”- Tune the difficulty. Change
goalFor,WAVE_SECONDSorDROP_EVERYin GemConfig. - Add danger. Drop red “bombs” alongside the gems and call
character.kill()on whoever touches one. A knocked-out player respawns after three seconds (the template’srespawnTime), which costs the team precious time. - Rare gems. Give some gems a different color and an attribute, and count them double in the
touchedhandler. - An all-time total. Keep a second ordered data store of total gems collected, and add to it with
increment(player.userId, count)at the end of each game.