Room scripts
Host a RosikBall room run by your own code — like HaxBall's headless host.
Anyone can run a room with a script: it welcomes players, picks the teams, starts and stops games, answers
!commands, keeps statistics, records replays — whatever you write. The room runs where your script runs:
in a browser tab on your computer, or on a server around the clock. It is free and needs no account.
- Quick start: a room in a browser tab
- 24/7 on your computer or a server
- Creating the room
- Every command at a glance
- Players
- Events
- Actions
- Reading the room
- The field: goals, discs, objects, a scoreboard
- Bots
- Captains
- AFK players
- Admins and owners
- Replays
- Examples
- Your hosted room's script (and its limits)
- What a script cannot do
Quick start: a room in a browser tab
- Open rosikball.com/?headless=1 — a headless page: it hosts a room but has no player of its own and draws nothing. It waits for your script.
- Open the browser's console (F12 → Console).
- Paste your script and press Enter. For instance:
const room = RosikRoom.create({ roomName: 'My first room', maxPlayers: 10, public: true });
room.onRoomLink = (link, code) => console.log('Room open:', code, link);
room.onPlayerJoin = (p) => {
room.sendAnnouncement(`Welcome, ${p.name}!`, p.id, { color: '#ffd166', bold: true });
// the first two players get a team each, then the game starts
const red = room.getPlayerList().filter(x => x.team === 'red').length;
const blue = room.getPlayerList().filter(x => x.team === 'blue').length;
if (red === 0) room.setPlayerTeam(p.id, 'red');
else if (blue === 0) { room.setPlayerTeam(p.id, 'blue'); room.startGame(); }
};
The room appears on the room list (or not, with public: false: then share its code or invite link).
Keep the tab open: closing it closes the room. A headless page keeps running in a background tab.
You can also run a script on the normal game page (you keep your own player and host with it).
24/7 on your computer or a server
A small Node program keeps a scripted room running in a headless browser and restarts it if it ever stops — within two minutes the room gets its code back. It saves the replays your script records.
- Install Node.js 20 or newer.
- Download into one folder: run-room.mjs, headless-room.mjs, package.json and your script (start from standard-room.js).
- In that folder:
npm install, then on Linuxnpx playwright-core install --with-deps chromium. - Run it:
node run-room.mjs standard-room.js
| Option | What it does |
|---|---|
--browser=chromium | The browser to use: msedge (Windows' default), chrome, or chromium (Linux, after the install step). |
--replays=replays | Where room.saveReplay() writes .rbr files. |
--state=file.json | Where the room's code and reclaim key are kept (a restart gets the same code back). |
--headed | Show the browser window. |
A room uses about half a gigabyte of memory and little CPU; every player costs some upload bandwidth.
Creating the room
const room = RosikRoom.create({
roomName: 'Friday 4v4', // shown on the room list
password: '', // '' = none
maxPlayers: 8, // 2–24
public: true, // on the room list (false: code / invite link only)
field: 'classic', // 'classic' | 'easy' | 'small' | 'big' | 'rounded' | 'bigeasy' | 'bigrounded' | 'futsal' | 'sixman' | 'basketball' | 'pingpong'
map: null, // or a custom map (a .rbmap's object): played instead of the field
timeLimit: 3, // minutes, 0 = none
scoreLimit: 3, // goals, 0 = none
teamsLock: false, // true: only admins (and the script) move players
discord: 'discord.gg/abc123', // your community: a Discord invite, shown in the room with the Discord logo
admins: [] // account ids (player.account) who are admin as soon as they join
});
A scripted room starts stopped: your script starts the games (room.startGame()). One room per page.
Every command at a glance
Everything a script can call or hear, grouped; each links to where it is explained. id is a player's id,
team is 'red', 'blue' or (for players) 'spec'. The same API works in a
browser tab, with the runner and in a hosted room's sandbox (where actions are rationed).
| Group | Commands |
|---|---|
| The room | RosikRoom.create(config) |
| Reading | getPlayerList() getPlayer(id) getScores()
getBallPosition() isGameRunning() getTeamCaptain(team) getRoomCode()
getRoomLink() getMap() getScoreboard() reclaimKey |
| Players | setPlayerTeam(id, team) setPlayerAdmin(id, on)
kickPlayer(id, reason, ban) clearBans() mutePlayer(id, minutes) unmutePlayer(id)
setPlayerAfk(id, on) setTeamCaptain(team, id) setRoomAdmins(accountIds)
setTeamsLock(on) |
| Games | startGame() stopGame() pauseGame(on)
setTimeLimit(minutes) setScoreLimit(goals) setField(name) setMap(map) |
| Room settings | setRoomName(name) setPublic(on) setPassword(text)
setMaxPlayers(n) setDiscord(link) |
| Chat | sendAnnouncement(text, toId, style) |
| The field | getGoals() getZones() getPlayerDiscProperties(id)
setPlayerDiscProperties(id, props) getBallProperties() setBallProperties(props)
setObject(id, object) removeObject(id) clearObjects() getObjects()
setScoreboard(entries) setGoalMessages(on) setPlayerAvatar(id, avatar)
setPlayerColor(id, color) scoreGoal(team, scorerId) |
| Bots | addBot(team) removeBot(id) setBotTarget(id, point)
setBotAim(id, point) |
| Replays | startRecording() await stopRecording() saveReplay(bytes, name) |
| Events | onRoomLink onPlayerJoin onPlayerLeave onPlayerKicked
onPlayerChat onPlayerTeamChange onPlayerAdminChange onPlayerAfkChange
onTeamsLockChange onGameStart onGameStop onGamePause onGameUnpause
onTeamGoal onPlayerBallKick onPlayerBallTouch onTeamVictory
onPositionsReset onGameTick |
| Hosted rooms | window.__applyRoomConfig = (cfg) => { … } (the owner saved new settings) |
An action returns true when it is queued (it applies at the next tick), false when it cannot be (a
player who is not in the room, a room that has closed, a hosted room's limit reached); a wrong argument throws, saying why.
Players
Every player your script gets is a snapshot (read it again for fresh values):
{ id, name, team, // team: 'red' | 'blue' | 'spec'
avatar, admin, owner, bot,
color, // the disc's own colour ('#rrggbb', setPlayerColor), null: the team's
verified, // logged in: the name is their account's nickname
account, // their account's id (checked by our server) — the identity to trust; null for guests
country, // two letters, from their connection (never chosen by the player)
afk, // away from the keyboard (see AFK players)
captain, // 'red' | 'blue' while they pick their team, else null
roomAdmin, // one of the admins the room's owner named
position } // {x, y, z} on the pitch, null for spectators
Names can be anything a player types: show them as text, never as HTML.
Events
Set the handlers you need: room.onPlayerJoin = (player) => { … }. A handler that throws is logged and
skipped; the room keeps running.
| Handler | When |
|---|---|
onRoomLink(link, code) | the room is open (its invite link and code) |
onPlayerJoin(player) | someone joined (as a spectator) |
onPlayerLeave(player, reason) | left, timed out, kicked or banned |
onPlayerKicked(player, reason, ban, by) | before the leave |
onPlayerChat(player, text, {scope, to}) | a message — return false to drop it (for !commands). scope: 'all', 'team' (only the sender's team reads it) or 'private' (only to, a player, and the sender) |
onPlayerTeamChange(player, byPlayer) | moved to a team or the spectators; byPlayer is the captain who picked them, else null |
onPlayerAdminChange(player) | admin given or taken |
onPlayerAfkChange(player, afk, by) | away or back — by: 'self' (typed /afk), 'idle', 'back' (pressed a key), 'script' |
onTeamsLockChange(locked) | the teams were locked or unlocked |
onGameStart() / onGameStop() | a game started / was stopped |
onGamePause() / onGameUnpause() | paused / play resumed after the countdown |
onTeamGoal(team, scorer, assist, ownGoal, where) | a goal (a shot on target that a defender deflects in is the shooter's);
where says which goal it went into: {goal, zone, defendedBy, x, y, z} — goal = its index in
room.getGoals() (zone: a goal zone's, in room.getZones()), x, y, z the ball |
onPlayerBallKick(player) | a kick |
onPlayerBallTouch(player) | a disc begins touching the ball — a kick, a block or a bump (who hit it last) |
onTeamVictory({winner, red, blue}) | a game was won |
onPositionsReset() | after a goal, back to the kickoff |
onGameTick(tick) | every gameplay tick, 120 per second — keep it light |
Actions
Actions are the room host's own: they apply at the next tick, with the same rules as the room menu (the field and the limits change only while no game runs).
room.setPlayerTeam(id, 'red') room.setPlayerAdmin(id, true)
room.kickPlayer(id, 'reason', ban) room.clearBans()
room.mutePlayer(id, minutes | null) room.unmutePlayer(id)
room.startGame() room.stopGame() room.pauseGame(true | false)
room.setTimeLimit(minutes) room.setScoreLimit(goals) room.setField('big')
room.setMap(mapObjectOrText) // a custom map (.rbmap): checked at once (throws why); its limits come with it
room.setField('custom') // back to the room's custom map (the last setMap, or create's map)
room.setTeamsLock(true) room.setPassword('secret' | '')
room.setRoomName('Friday 4v4') room.setPublic(true | false) room.setMaxPlayers(10)
room.setDiscord('discord.gg/abc123' | '')
room.setTeamCaptain('red', id | null) // see Captains
room.setPlayerAfk(id, true | false) // see AFK players
room.setRoomAdmins([accountId, …]) // see Admins
room.addBot('blue') room.removeBot(id)
room.setBotTarget(id, { x, z } | null) room.setBotAim(id, { x, z } | null) // see Bots
room.sendAnnouncement(text, toId = null, { color: '#ffd166', bold: true })
room.sendAnnouncement(text, toId = null, { type: 'warning' })
room.startRecording() const bytes = await room.stopRecording(); room.saveReplay(bytes, 'name')
Announcements are system lines in the chat, to everyone (toId = null) or one player. With a
type — 'info', 'success', 'warning' or 'error' — the
announcement also pops up in the bottom-right corner for a few seconds, in that kind's colour;
line: false makes it a popup only. Admins can send the same from the chat:
/announce warning the room restarts in 5 minutes, or to one player with @name:
/announce success @Ana you are captain. A player about to be set AFK gets a warning popup
from the game itself, three seconds before.
Reading the room
room.getPlayerList() | everyone in the room (the headless page itself is not a player) |
room.getPlayer(id) | one player, or null |
room.getScores() | {red, blue, time, timeLimit, scoreLimit, overtime, phase} |
room.getBallPosition() | {x, y, z} |
room.isGameRunning() | a game is on (playing, paused or between goals) |
room.getTeamCaptain(team) | the team's captain's id, or null |
room.getRoomCode() / room.getRoomLink() | the code and the invite link |
room.getMap() | {field, name, author}: the field in use, a built-in id or 'custom' |
room.getScoreboard() | your scoreboard as setScoreboard left it, or null (the score shows) |
room.reclaimKey | the key that gets the room's code back after a restart (the runner keeps it for you; not in a hosted room) |
The field's own readings — getGoals, getZones, getPlayerDiscProperties,
getBallProperties, getObjects — are in the next section.
The field: goals, discs, objects, a scoreboard
The room plays Red against Blue, as HaxBall does. Anything else — a goal for each player, lives, rounds, more teams —
is your script's: it can read the field, move discs, put objects of its own on the pitch and show its own scoreboard.
Coordinates are metres: x along the field, z across it, y up (HaxBall's x and y
are our x and z, divided by 30).
room.getGoals() | the field's goals, in order: {index, team, a, b, middle, normal, width, height, depth}
— team defends it, a / b its posts and middle as {x, z},
normal points into the net |
room.getZones() | the field's zones (a basket's goal zone…): {index, effect, team, from, x, z, bottom, top} |
room.getPlayerDiscProperties(id) | {x, y, z, xspeed, yspeed, zspeed, radius}, or null for a spectator |
room.setPlayerDiscProperties(id, props) | moves the disc: any of x, y, z, xspeed, yspeed, zspeed
(a teleport — no goal is crossed on the way) |
room.getBallProperties() / room.setBallProperties(props) | the same for the ball |
room.setObject(id, object) | puts a 3D object of your own on the field — or changes it, by its id
(letters, digits, _ - . :). Everyone sees it, it stops what it stops, replays keep it. |
room.removeObject(id) / room.clearObjects() / room.getObjects() | (a field change removes them all; at most 64) |
room.setScoreboard(entries) | your own scoreboard in place of "Red 0 : 0 Blue", on every player's screen:
[{text, color, faded}] (a dot of the colour; faded = greyed and struck through) or plain strings; null
brings the score back. |
room.setGoalMessages(false) | turns off the game's own goal texts — the "GOAL! RED scores (Ana)" banner and the
chat's goal line — for a script that announces its goals or points its own way; true turns them back on.
They are on unless your script says so. |
room.setPlayerAvatar(id, avatar) | a player's avatar (two characters at most: letters, a number, an emoji) |
room.setPlayerColor(id, '#rrggbb') | a player's disc in a colour of its own instead of their team's, on every screen and in
replays (a free-for-all, a marked player…); null: the team's colour again |
room.scoreGoal(team, scorerId) | a goal for 'red' or 'blue' as if the ball had gone in — the score,
the kickoff, the limits and the recap as for any goal (scorerId or null): points your script decides
(ping pong, targets, a field without goals). Only while a game is on; onTeamGoal hears it with where.script. |
A field may have no goals at all (a room script scores with scoreGoal), and a field's physics may make the discs
float at a height (the editor's Physics tab, "Players float this high": no gravity, no jumping) — the Ping pong field does both.
An object is a map editor object: {shape: 'box' | 'cylinder' | 'sphere' | 'ring' | 'net', at: [x, z], y, w, d, h,
yaw, tilt, stops: 'all' | 'ball' | 'players' | 'none', bounce, opacity, color: '#rrggbb'} — y its middle's
height; a box is w along × h × d, turned by yaw degrees. A wall across a
goal's mouth, from getGoals():
const g = room.getGoals()[2];
room.setObject('shut-2', {
shape: 'box', at: [g.middle.x + g.normal.x * 0.2, g.middle.z + g.normal.z * 0.2], y: (g.height + 0.3) / 2,
w: g.width + 0.5, d: 0.3, h: g.height + 0.3, yaw: Math.atan2(-(g.b.z - g.a.z), g.b.x - g.a.x) * 180 / Math.PI,
stops: 'all', color: '#5a6270', opacity: 0.75
});
Bots
room.addBot('red') adds a bot to a team (it takes one of the room's places: a person joining a full room makes
the newest bot leave); room.removeBot(id) takes it away. A bot plays football on its own: it chases the ball,
shoots at the other team's nearest goal and covers its own when a teammate is closer to the ball. Two orders change
that, for games your script invents:
room.setBotTarget(id, {x, z}) | where the bot waits: it goes to that point and stays there, and plays the
ball only while the ball is within 2.5 m of it (a keeper in front of a goal, a defender of a zone). Whoever is closer, it
goes for those balls itself. null: it chases the ball again. |
room.setBotAim(id, {x, z}) | where the bot sends the ball, instead of the other team's goal (a player's
goal in 6man, a target, a teammate). null: the goal again. |
Orders are for bots only (a person throws), stay until changed, and end when the bot leaves. The 6man scripts give every bot its own goal to guard and the leader's goal to shoot at:
room.setBotTarget(id, { x: g.middle.x - g.normal.x * 2, z: g.middle.z - g.normal.z * 2 }); // 2 m in front of its goal
room.setBotAim(id, { x: leader.middle.x, z: leader.middle.z });
Captains
Name a team's captain with room.setTeamCaptain('red', id) (they must be on that team). A captain can
open the room menu (Esc) and drag a spectator who is not AFK onto their own team — never someone from the other team;
the room checks every pick. onPlayerTeamChange(player, byPlayer) tells you who picked whom.
room.setTeamCaptain('red', null) ends it. The standard script uses this for its picking turns (below).
AFK players
A player on a team who presses no key for 30 seconds while the ball is in play is marked AFK; so is a player who
types /afk, or one your script marks with room.setPlayerAfk(id, true). Everyone sees "💤 AFK"
on their card. Pressing a key in play, typing /afk again, or clicking the tag on their own card brings them
back. player.afk and onPlayerAfkChange let your script react (move them out, skip them…).
Admins and owners
- Your script is the host: it can do everything (
setPlayerAdmin,kickPlayer…). - Admins (made in the room) move players, start and stop games, change the field and limits, kick and ban players who are not admins.
- The room's admins named with
admins/room.setRoomAdmins([…])(account ids — theaccountof a logged-in player) are admin as soon as they join, can also make admins and clear bans, but never kick, ban or take admin from another admin. - A hosted room's owner has all of that, and nobody can act against them.
Replays
room.startRecording() records the game; await room.stopRecording() gives the file's bytes
and room.saveReplay(bytes, 'name') keeps them — a download in a browser tab, a file in the replays folder
with the runner. Anyone can watch a .rbr file with ▶ Watch replay on the room list.
Examples
Commands
room.onPlayerChat = (player, text) => {
if (!text.startsWith('!')) return true; // a normal message: shown
if (text === '!score') {
const s = room.getScores();
room.sendAnnouncement(`Red ${s.red} : ${s.blue} Blue`, player.id);
} else if (text === '!bb') {
room.kickPlayer(player.id, 'bye!', false);
}
return false; // commands are not shown in the chat
};
Goals and statistics
const goals = new Map(); // account (or name) → goals
room.onTeamGoal = (team, scorer, assist, ownGoal) => {
if (!scorer || ownGoal) return;
const key = scorer.account || scorer.name;
goals.set(key, (goals.get(key) || 0) + 1);
room.sendAnnouncement(`⚽ ${scorer.name} (${goals.get(key)} today)`);
};
6man: a goal for each player
Two complete scripts play HaxBall's 6man rules on the 6man field (or any field with two goals or more): every player
owns a goal and has 3 points, every goal let in costs one, at 0 you are out — the last player standing wins. They show
what the field functions above can do: each goal gets a floor marker in its owner's colour (setObject), each
disc an avatar of the same colour, everyone is placed in front of their own goal after every reset
(setPlayerDiscProperties), the points are on the scoreboard (setScoreboard), the game's
"RED scores" texts are off while it plays (setGoalMessages(false)), and onTeamGoal's
where.goal says whose goal it was. Each disc is in its owner's colour (setPlayerColor).
- 6man-empty-goal.js — a player who is out leaves the pitch and their goal stays open: whoever scores in an empty goal loses a point.
- 6man-closed-goal.js — a player who is out leaves the pitch and a wall shuts their goal: the ball bounces off it.
Ping pong
pingpong.js plays ping pong on the Ping pong field (a table of 3D objects in the
middle of the hall, discs floating at its height): it serves — the ball dropped over the server's half — and judges every
rally from the ball's flight (onGameTick + getBallProperties: a bounce is its vertical speed turning up
over the table) and the hits (onPlayerBallTouch, onPlayerBallKick): two bounces on one side, a ball hit onto one's own side, a ball not
returned or off the table give the point to the other side through scoreGoal — so the score panel, the kickoff and
the score limit (first to 11) work as for football.
The standard room
standard-room.js is the script every hosted room runs by default:
players wait in line, captains pick the teams (the first in line who is not AFK captains an empty team; a short
team picks one player at a time, the next in line is picked for a captain who waits 20 seconds), the game starts by
itself, winners stay and the losers go to the back of the line, idle players sit out, !afk,
!back, !bb, and every game is recorded. Change its ROOM and RULES at
the top, or use it as a starting point for your own.
Your hosted room's script
A hosted room runs the standard script until its owner gives it one of their own, for behaviour the room's settings
do not offer. In My rooms, 📜 Script → ✎ Edit opens the script the room runs (the standard one, to
start from); change it, or 📂 Load a file… you wrote elsewhere. ✓ Check compiles it (an error names its
line: click it to go there) and tries it on a simulated room — players join, chat, play and win a game. Only a script
that passes can be saved: Save and run switches the running room to it at once, and its players stay. Every save
is a version (the last five are kept: Run this one goes back to one), ↺ Standard script goes back to the
standard rules, 🗒 Log shows what the script logged (console.log) and its errors.
The script settings of the room (ROOM, RULES in the standard script) come from ⚙ Settings;
when the owner saves new settings, a script that sets window.__applyRoomConfig = (cfg) => { … } hears them.
The sandbox and its limits
A hosted room's script runs in a sandbox on our servers: the RosikRoom API and plain JavaScript, nothing else — no
network (fetch, WebSocket and the like do not exist there), no storage, no WebAssembly. Reading the room is
instant; actions go to the room. So that one room cannot hurt the others, it is rationed:
| What | Limit |
|---|---|
| All actions together | a burst of 120, then 20 a second |
sendAnnouncement | a burst of 40, then 8 a second |
setRoomName, setPublic, setPassword, setMaxPlayers,
setDiscord, setRoomAdmins | a burst of 10, then 1 a second |
setField, setMap, addBot, removeBot, startRecording,
stopRecording | a burst of 6, then 1 every 2 seconds |
setObject, removeObject, clearObjects, setScoreboard,
setPlayerAvatar, setGoalMessages, setPlayerColor, setBotTarget,
setBotAim | a burst of 60, then 8 a second |
saveReplay | a burst of 3, then 20 an hour; RosikBall replays only, 16 MB each; the room keeps the newest 40 files |
| An action's arguments | 16,000 characters (a map for setMap: 192,000) |
| Timers waiting at once | 500 |
| Script size | 64 KB |
| Processor | 35% of the room's on average over 30 seconds (📜 Script shows what it uses now) |
| Memory | the room's page within 60% of the room's memory |
An action over its limit is refused: it returns false and the log says how many were. The room replaces
the script with the standard one — without disconnecting anyone, and telling the players — when it stops answering
for 4 seconds (an endless loop; it has 15 seconds to start), throws more than 50 errors in a minute, keeps asking for more than 600 refused
actions in a minute, or goes over the processor or memory limit. 📜 Script then says why; fix it, Check it and save it
again. Scripts you run yourself (in a browser tab or with the runner) are not rationed: they use your own machine.
What a script cannot do
A script runs on the host's machine and can do exactly what the room's host can — nothing more for anyone else. It
cannot change the physics (every player's game predicts the same match), see other players' screens, or touch their
accounts. Keep handlers light, especially onGameTick: a slow script slows the room for everyone.
Please respect the terms: no impersonating RosikBall or other players, no spam on the room list.