Falling sand
Sand, water and walls that everyone in a room paints into at once, built for the web from the code below. Click it to give it the mouse and keyboard.
| Mouse and keyboard | |
|---|---|
| Paint | Left mouse button |
| Erase | Right mouse button |
| Pick sand, water, wall or the eraser | 1 to 4, or the buttons |
Paint together
Press Invite, and the room's code shows beside it, with Copy to put it on the clipboard. Whoever opens this page on another machine pastes the code next to Join (Ctrl+V, or Cmd+V) and presses it, and you're both painting into the same sand. Up to 16 players can join.
The corner shows the frame rate, the ping, and what goes over the network each second, up and down.
Only the brushes go over the network: where each one is, what it paints, and whether it's down. Every machine runs the same simulation from them, and gets the same sand, bit for bit. A player who joins gets the world as it is then, and only the parts of it that changed since the match started.
The code
The game is bench/sand/sand.tide in the repo. Its cells are a grid of a byte each, and Step moves them with a parallel loop, in 2 by 2 blocks, on every core the machine has. It's also one of the engine's benchmarks, which bots play to measure it.
// Sand: falling sand and water that every player paints into at once, the
// cellular automaton benchmark (see AGENTS.md, Benchmarks). Bots play it
// there; play.tide makes it a game people play, with the mouse.
//
// The cells are a grid, which a parallel loop steps in 2x2 blocks, every
// block at once, on threads (see docs/spec.md, Grids).
enum Material : byte
{
Empty,
Sand,
Water,
Wall
}
const int BRUSH = 4; // The brush's radius, in cells
scene Main { }
// The benchmark sets its size once the world is made, and the first tick
// makes the grid and fills it (Setup).
singleton Field
{
Grid2<Material> cells;
int size = 256;
bool ready;
}
// Where the field is on this machine's screen, which its view works out
// (play.tide), and what the player paints with.
local singleton Canvas
{
float2 middle; // The field's middle, in window pixels from the bottom left
float scale; // Pixels a cell, 0 until the field is drawn
int size; // Cells across
Material material = Material.Sand;
}
input Brush
{
int x;
int y;
Material material;
bool down;
// The left button paints, the right one erases.
Sample(Canvas canvas)
{
if (canvas.scale <= 0) return;
var mouse = Devices.mouse;
var cell = (mouse.position - canvas.middle) / canvas.scale + float2(canvas.size, canvas.size) * 0.5;
x = int(Math.Floor(cell.x));
y = int(Math.Floor(cell.y));
down = mouse.left.pressed || mouse.right.pressed;
material = mouse.right.pressed ? Material.Empty : canvas.material;
}
}
// Someone painting: their brush is their input.
component Painter { }
// A box of wall, with ledges in it and sand scattered above them, so it starts
// out falling. The box keeps sand off the grid's edges, which the blocks Step
// goes through only take in every other tick.
system Setup(mut Field field)
{
if (field.ready) return;
field.ready = true;
var size = field.size;
field.cells = Grid2(size, size);
var ledges = Math.Max(size / 4, 1);
var span = Math.Max(size / 8, 1);
for (var y = 0; y < size; y++)
{
for (var x = 0; x < size; x++)
{
if (x == 0 || y == 0 || x == size - 1 || y == size - 1) field.cells[x, y] = Material.Wall;
else if (y % ledges == ledges / 2 && (x / span) % 2 == 1) field.cells[x, y] = Material.Wall;
else if (y > size / 2 && Math.Hash(int2(x, y)) % 4 == 0) field.cells[x, y] = Material.Sand;
}
}
}
event(PlayerJoined joined) Join()
{
Spawn(Painter, Owner { player = joined.player });
}
// Painting leaves the box alone, and past the grid's edge does nothing.
system Paint(Brush brush, mut Field field, with Painter)
{
if (!brush.down) return;
var size = field.size;
for (var dy = -BRUSH; dy <= BRUSH; dy++)
{
for (var dx = -BRUSH; dx <= BRUSH; dx++)
{
var x = brush.x + dx;
var y = brush.y + dy;
if (dx * dx + dy * dy <= BRUSH * BRUSH && x > 0 && y > 0 && x < size - 1 && y < size - 1)
{
field.cells[x, y] = brush.material;
}
}
}
}
// Whether a grain of `mover` goes where `there` is: sand and water into empty
// space, and sand sinks through water.
bool Takes(Material there, Material mover)
{
if (mover == Material.Sand) return there == Material.Empty || there == Material.Water;
return mover == Material.Water && there == Material.Empty;
}
// Whether water flows between two cells side by side: one is water, the
// other empty.
bool Flows(Material left, Material right)
{
return (left == Material.Water && right == Material.Empty) || (left == Material.Empty && right == Material.Water);
}
void Swap(mut Material a, mut Material b)
{
var was = a;
a = b;
b = was;
}
// Sand and water move in 2x2 blocks, whose corners are on even cells one tick
// and odd ones the next, so blocks never overlap and each cell is in another
// block the tick after: grains fall, or else slide down to the block's other
// side, and where nothing can, water flows sideways. Every block at once.
system Step(Time time, mut Field field)
{
parallel (var at in field.cells by 2 offset time.tick % 2)
{
mut var a = field.cells[at + int2(0, 1)]; // Top left
mut var b = field.cells[at + int2(1, 1)]; // Top right
mut var c = field.cells[at]; // Bottom left
mut var d = field.cells[at + int2(1, 0)]; // Bottom right
if (a == b && b == c && c == d) continue;
if (Takes(c, a) || Takes(d, b) || Takes(d, a) || Takes(c, b))
{
if (Takes(c, a)) Swap(a, c);
if (Takes(d, b)) Swap(b, d);
if (Takes(d, a)) Swap(a, d);
if (Takes(c, b)) Swap(b, c);
}
else
{
if (Flows(c, d)) Swap(c, d);
if (Flows(a, b)) Swap(a, b);
}
field.cells[at] = c;
field.cells[at + int2(1, 0)] = d;
field.cells[at + int2(0, 1)] = a;
field.cells[at + int2(1, 1)] = b;
}
}bench/sand/play.tide draws it, a rect for each run of cells of one material along a row, and has the GUI. Views only change this machine's local state (Canvas, Lobby), never the sand.
// Sand to play: the field drawn, the brush under the mouse, and the GUI.
// sand.tide is the game; these only draw it and change this machine's own
// state (Canvas, Lobby).
// What this machine types to join someone's room.
local singleton Lobby
{
string code;
}
Color Shade(Material material)
{
if (material == Material.Sand) return Color(0.87, 0.73, 0.43);
if (material == Material.Water) return Color(0.25, 0.52, 0.92);
if (material == Material.Wall) return Color(0.42, 0.42, 0.48);
return Color(0.06, 0.06, 0.09);
}
const float TOP = 100; // Pixels along the top for the GUI
// The field is as big as fits under the GUI, a world unit a cell, and each
// row of cells goes as runs of one material, a rect each.
view DrawField(Field field, mut Canvas canvas)
{
Draw.Clear(Shade(Material.Empty));
if (!field.ready) return;
var size = field.size;
var screen = float2(float(Screen.width), float(Screen.height));
var scale = Math.Max(Math.Min((screen.x - 24) / size, (screen.y - TOP - 12) / size), 0.1);
canvas.middle = float2(screen.x * 0.5, screen.y - TOP - size * scale * 0.5);
canvas.scale = scale;
canvas.size = size;
// The camera's middle is the window's, so the field's lands on canvas.middle
Draw.Camera(float2(size, size) * 0.5 + (screen * 0.5 - canvas.middle) / scale, screen.y * 0.5 / scale);
Draw.Rect(float2(size, size) * 0.5, float2(size, size), Color(0.1, 0.1, 0.14));
for (var y = 0; y < size; y++)
{
mut var x = 0;
while (x < size)
{
var material = field.cells[x, y];
mut var end = x + 1;
while (end < size && field.cells[end, y] == material) end++;
if (material != Material.Empty) Draw.Rect(float2((x + end) * 0.5, y + 0.5), float2(end - x, 1), Shade(material));
x = end;
}
}
}
// The brush, where the left button would paint, while the mouse is over the field.
view DrawBrush(Canvas canvas)
{
if (canvas.scale <= 0) return;
var cell = (Devices.mouse.position - canvas.middle) / canvas.scale + float2(canvas.size, canvas.size) * 0.5;
if (cell.x < 0 || cell.y < 0 || cell.x >= canvas.size || cell.y >= canvas.size) return;
var at = float2(Math.Floor(cell.x), Math.Floor(cell.y)) + 0.5;
Draw.WireCircle(at, BRUSH + 0.5, canvas.material == Material.Empty ? Color.white : Shade(canvas.material));
}
// A button for a material, which shows whether it's the one picked.
bool Pick(Material picked, Material material, string name, string key)
{
return GUILayout.Button(picked == material ? "[" + name + "]" : name + " " + key);
}
view Panel(mut Canvas canvas, mut Lobby lobby, Session session)
{
var keys = Devices.keyboard;
if (keys.digit1.down) canvas.material = Material.Sand;
if (keys.digit2.down) canvas.material = Material.Water;
if (keys.digit3.down) canvas.material = Material.Wall;
if (keys.digit4.down) canvas.material = Material.Empty;
GUILayout.Area(Anchor.UpperLeft)
{
GUILayout.Horizontal()
{
if (Pick(canvas.material, Material.Sand, "Sand", "1")) canvas.material = Material.Sand;
if (Pick(canvas.material, Material.Water, "Water", "2")) canvas.material = Material.Water;
if (Pick(canvas.material, Material.Wall, "Wall", "3")) canvas.material = Material.Wall;
if (Pick(canvas.material, Material.Empty, "Eraser", "4")) canvas.material = Material.Empty;
GUILayout.Label(" Right button erases");
}
// Others join this machine's match by its room's code, or this
// machine joins theirs.
GUILayout.Horizontal()
{
if (session.server && !session.open)
{
if (GUILayout.Button("Invite")) Session.Open();
}
else if (session.room != "")
{
GUILayout.Label("Room " + session.room);
if (GUILayout.Button("Copy")) Clipboard.Copy(session.room);
}
GUILayout.TextField("Code", lobby.code);
if (GUILayout.Button("Join") && lobby.code != "") Session.Join(lobby.code);
}
}
}
// A match this machine was in ended or couldn't be joined: on to one of its own.
local event(Disconnected gone) Alone()
{
if (gone.reason != DisconnectReason.Left) Session.Start(Main);
}