Sumo walkthrough
This walkthrough explains how a Box3D match drives balls with impulses, replaces floor bodies when tiles fall and keeps a large arena deterministic. It also covers the lobby, instanced rendering, the shadow map, and the cost of simulation and rollback.
You are a heavy ball, in a ring in the sky that's stacked with two thousand crates, and so are up to seven others. Plough through what's in your way, ram the others over the edge, and stay on yourself while the floor falls away under everyone. A game made with Tide to push its 3D physics in a match: every crate is in the same place on every machine, whatever their pings, with Box3D under all of it. Play it alone against bots, or with friends in a room.
Open it on a page of its own, which is the one to send to friends: see Play with friends.
Play
The game opens on its lobby, with bots at each other in the ring behind it. Play starts a game of your own, Host a room opens one for friends, and Join a room takes a friend's code.
| Action | Mouse and keyboard | Touchscreen | Gamepad |
|---|---|---|---|
| Roll | W A S D or the arrows, or drag anywhere |
Drag anywhere | The left stick |
| Charge | Hold Space, or the right button, and let go | Hold Charge with another finger, and let go | Hold the south button, and let go |
| The menu | Menu, or Escape | Menu |
Your ball is the one marked YOU, with a white ring around it that shows through whatever stands in front. A mark at the screen's edge is a ball that's out of view, on the side it's on.
- You drive your ball, and it weighs 640 kilograms. It gathers speed, and takes a moment to turn. A crate weighs 23, so a stack of them is no wall: go through it.
- A charge is how you throw someone out. Hold it to wind up, which the ring under your ball shows, and let go for a burst the way you're rolling. Two balls that meet are thrown apart, the faster they closed the harder, and the slower one loses its grip for a moment and slides. The next charge takes two and a half seconds, which the ring around the button counts.
- Over the edge and you're out. Whoever touched you last, not too long ago, has the point, and you're dropped back in two seconds later. A ball that rolls off by itself scores for nobody.
- The floor falls away from the rim in, three times in a round. The tiles that are about to go turn rose for five seconds first, and then drop one after another with everything that stands on them. The last half minute is fought on what's left in the middle.
- A white pickup makes you heavy: a ball thirteen times your weight for eight seconds, which nothing stops. A teal one is a shock: everything within nine meters is thrown outward, balls too, and whoever it throws out is yours.
- A rose crate is a keg. Hit it hard and it blows up, and so does the next keg its blast reaches.
- What's knocked about breaks up after a few seconds, so the ring is clearer the longer a round goes on.
- A round is two minutes, and it's whoever threw out the most. Then the ring is raised again, in another arrangement, and scores carry on until someone starts a new game. Whoever leads wears the crown.
The bottom right corner shows the frame rate, and your ping in someone else's room. Click or tap it, or press F8, for the rest of what the engine is doing: the tick, the entities, the draw calls and the systems that take longest. That's the game's stats setting.
In this page, click the game to give it the keyboard. On a touchscreen, a swipe on it scrolls the page until you tap the game, and again once you tap the page somewhere else. The game on a page of its own has every touch from the start.
Play with friends
A room takes eight players, on the web and on desktop alike. They meet by the room's code, and nobody waits for anybody: whoever arrives has a ball in the ring, in the round that's on.
- Host. On the lobby, press Host a room. The room's code shows at once, like
K7QF2M, with the eight seats under it and who has each. Copy puts the code on the clipboard, and Play takes you to the ring. - Share the code, or a link that has it: the game's address with
?join=and the code, likehttps://tide-engine.dev/sumo/?join=K7QF2M. Whoever opens the link is in your room, with no lobby in the way. - Join. With only the code, a friend opens the game, presses Join a room, types or pastes the code (Ctrl+V, or Cmd+V) and presses Enter, or Join. A code nobody has, a room that's full and a match that ended each say so, on the page the code is typed on, to try again from.
In a game, Menu shows the room again: its code to copy, who's in it, and Leave, which takes you back to the lobby and a game of your own. A game you started alone has Invite friends there instead, which opens a room around the game as it is. While your menu is up, your ball looks after itself, as a bot's does.
From the repo, with tide installed, the same game runs in a window, and joins the same rooms:
cd examples/sumo
tide run # The lobby
tide run --host # Straight into a room of your own: its code shows by the Menu button
tide run --join K7QF2M # Straight into a friend's- Each player has a seat and a color of their own, and a score beside the others' at the top. A ninth is turned away, and told the room is full.
- Bots have the seats nobody has, until there are four balls in the ring. A player who joins takes a bot's ball as it is, and a bot takes over from one who leaves. Bots in the menu turns them off.
- Joining in the middle works. The match's world is sent as it is, with Box3D's whole state in it: every crate where it is and as fast, the ones in the air too. That's 2.4 megabytes a few seconds into a round, which took two seconds in the game's test, over a network that's late and loses packets.
- When the host leaves, the room goes on. Another player's machine takes the match over, with the ring as it was (the game's
hostMigrationsetting). - A match that ends puts you back on the lobby, with a line that says what happened.
Only the players' inputs go over the network: which way each holds the stick, and whether they hold the charge. Every machine runs the same Box3D from them, and gets the same ring, bit for bit (see Determinism). Your own ball answers at once however far away the host is, because your machine predicts the ticks the host hasn't confirmed yet, physics and all, and puts right what it guessed wrong about the others.
The code
Sumo has seven files of Tide, and a tide.packages that says box3d.
game.tideis the match, which every machine runs the same way, andarena.tidewhat a round's ring is built of. Both are on this page, whole.play.tideis what one machine shows of it: the camera, and the ring and what's on it.light.tidehas the meshes, the frags that light them and what the shadows are made of,effects.tidethe sparks and the words that float up, andhud.tidethe scores, the clock and the controls.- The lobby and the menu are the
roomspackage's, the menu that comes with tide: which match this machine is in is its to say.
The physics
Nothing in the game moves a body. It spawns them, pushes them and reads where they are, all through components, as the physics page says.
A ball is driven by pushes. Its player's stick is an impulse every tick, which is a force, less what the floor takes of its speed, so it gathers speed, coasts and turns as something heavy does. The system that gives it is Before the package's stage, so the step that uses the push is the tick's own, and a ball answers in the tick its player pressed:
[Before(PhysicsStages.Step)]
system Drive(Controls controls, mut Body body, mut Ball ball, Brain brain, Ring ring, Time time)
{
...
var grip = GRIP * (ball.winding ? 0.4 : 1) * (dazed ? 0.15 : 1);
var rolling = float2(body.velocity.x, body.velocity.z);
var push = steer * grip - rolling * (hard > 0.05 && !dazed ? DRAG : COAST);
body.impulse += float3(push.x, 0, push.y) * (ball.mass * time.dt);
}A charge is one more impulse, given when the button is let go. The input's charge.up says so, and it's only ever true for a button a player let go of: a machine that's guessing what another player does never guesses a burst.
Two balls that meet are thrown apart. The package sends Touched to a body's entity when it starts touching another. Each ball's handler gives its own ball an impulse away from the other, on top of what Box3D made of the hit, and remembers who it was, for when the ball goes over the edge:
event(Touched touched) Bump(mut Ball ball, mut Body body, World world, Time time)
{
...
ball.last = other.seat;
ball.touched = time.tick;
var their = world.Get<Body>(touched.other).position;
var apart = Math.Normalize(float3(body.position.x - their.x, 0, body.position.z - their.z));
var closing = Math.Max(Math.Dot(other.before - ball.before, apart), 0);
body.impulse += apart * ((BUMP + BUMP_MORE * closing) * ball.mass * other.mass / (ball.mass + other.mass));
...
}A tile that falls is another body. The floor is 113 static bodies, which never move, and a body's type is read once, when it's made. So a tile that's due to fall is destroyed, and a loose one is spawned in its place, a little smaller so that it clears its neighbors. What stood on the tile wakes, and goes down with it:
this.Destroy();
var size = float3(TILE - 0.16, 1, TILE - 0.16);
Spawn(Body { position = float3(tile.at.x, -0.5, tile.at.y), velocity = float3(0, -1, 0), angularVelocity = lean },
Box { size = size, density = 400, friction = 0.8 }, Crate { kind = Kind.Slab }, Loose { mass = size.x * size.z * 400 });A heavy ball is the same: a shape is read once too, so the ball that took the pickup is destroyed and a bigger, denser one is spawned where it was, as fast, with what the first one knew. Eight seconds later it's the other way round.
A blast is an impulse for everything near. A system goes through every loose body with a query, only in a tick that has a blast to give, and writes an impulse into the ones in reach through the World. An impulse written into a body that's asleep wakes it:
foreach (var thing in things)
{
...
mut var thrown = thing.body;
thrown.impulse += way * (blast.push * (0.35 + 0.65 * near) * thing.loose.mass);
world.Set<Body>(thing.entity, thrown);
if (world.Has<Keg>(thing.entity)) thing.entity.Send(Kindled { seat = blast.seat });
else if (world.Has<Ball>(thing.entity)) thing.entity.Send(Rocked { seat = blast.seat });
}Two sensors. The pit under the ring is a sensor the size of the sky: what falls into it is destroyed, which takes its body with it. A pickup is one too, floating over the floor: the first ball that comes into it has it.
Two thousand crates
A ring is raised in one tick: arena.tide plans it, one of three arrangements, an eighth of the ring mirrored seven times so that every seat starts with the same ring around it, as high as the round's own dice say. The dice are seeded from the match's seed and the round, so every machine builds the same ring. The plan is a list of where each crate goes, and a system spawns what's on it.
- Everything stacked is asleep until something hits it. A body at rest is asleep, and a world where everything sleeps isn't stepped at all. A ring with nobody moving costs a tick 0.03 milliseconds, with 2,400 bodies standing in it.
- A ring is put to sleep as soon as it's stacked. It's stacked where it rests, and left to itself Box3D stepped all of it for a second before it let it sleep. So each crate is spawned with a tag, and a system writes
asleep = trueinto the ones that have it, a tick later, and takes the tag off. The one step in between is for Box3D to find what rests on what: a stack then sleeps as one, and wakes as one when a ball gets to it. Spawned asleep, each crate would sleep by itself, and a stack that's hit would wake a crate a tick, which costs more in the ticks where a lot wakes at once. - A column stands a hand's width from the next. Box3D keeps a contact for every two bodies whose boxes are within 14 centimeters of each other, touching or not, and every contact is in its state, which the match holds, hashes and sends to a player who joins. A crate in a column has one contact, with the crate under it. Packed closer than that, each crate had seven, and the state of the same ring was three times the size.
- What's knocked about breaks up. A crate that's hit hard enough has a few seconds left. Without that, the rubble of every tower lay in a heap in the middle of a floor that's getting smaller, most of it awake, and a tick took 2 milliseconds by the end of a round, where it takes 0.1.
- Nothing is sent while nothing can be driven. The ticks a ring is raised in are the ones a machine can least afford to run again. So the input sends nothing while a round is counted in: an input that stays as it was is one every other machine guesses right, and a tick that was guessed right isn't run again.
- How much is stacked is the match's, from its start. The match's scene has one value, how many tiles out from the middle its rings are stacked. That's every tile, unless whoever starts the match says less, as in
Session.Start(Main { stacked = 2 }), and then every player who joins gets that ring, as it's in the match. The game always starts one with all of it. Its test starts some with less: its match of three players is four machines, each keeping a ring, and it has the middle's pile and the cabins around it, a tenth of the crates.
The bots
Every ball has a Brain: where it would go by itself, and whether it would charge. A system works it out each tick, with a query over the other balls: it picks someone to go for, goes round to their inside, where a hit sends them out, winds up and lets go. It turns back from the rim by how far the rim is and how fast it's going out to it. Drive uses the brain where nobody drives: for a seat no player has, and for a player whose lobby or menu is up, which the input says with one field, away.
That field is marked [Absolute]: it says how things are, not what the player does, so what another machine guesses of it stays as it was.
The lobby
The lobby isn't the game's: it's the rooms package, the menu that comes with tide, which the game lists in its tide.packages. Main is the match's scene, so a match of this machine's own starts with the program, closed, and the menu is drawn over it: Play closes it, Host a room opens that match to others, and Join a room leaves it for someone else's. A machine that was told which match to be in, with --host or --join on its command line or ?host or ?join= in its page's address, has no menu in its way.
What the game adds is in hud.tide: the input reads the menu's Lobby.playing, and says the player is away while a page is up, so their ball plays by itself; and a view tells the room's page who has each seat and whether bots play the rest, and sends Rebuild and Bots when the page's New game and Bots buttons say so. Towers does the same.
What a machine shows
- The crates are one draw. A view runs for every crate and adds a box to a list, where the body is between its last two ticks. One
Draw.Instanceddraws the list, through a frag that paints a frame around each face and lights it. A frame of a full ring is under thirty draw calls. - The floor is a texture of 15 cells by 15. A view writes each tile into a local grid, there or about to go, and the frags sample it: the floor's paint has a line of light along an edge with no tile past it, and the rings under the balls stop where the floor does. Those rings are drawn inside
Draw.Depth(Depth.Read): what stands in front hides them, and they hide nothing. - There's no lighting built in, so everything is a mesh whose corners have a normal, made once in a local singleton, and lit by one function that every frag calls. A ball's frag gives it a white band and a dark cap, so that its roll shows.
- The camera shows the whole ring when a ball is still big enough to find at that size, and as much of it as that takes otherwise, around your ball and towards the middle, where the others are. It always looks from the same side, so up on the screen is the same way in the ring for every player, and for the stick.
- The scores are read
verified: what the server has confirmed, never a fall this machine only predicted. They're on the seats, so the view goes through them with a query. - The sparks, the blasts' rings and the camera's shake come from events. The match says what happens in it: two balls met, a ball went through crates, a charge was let go, a keg went off, a tile dropped, a ball fell. Nothing in the match handles those, so they cost it nothing, and each machine's local handlers make an effect of them, once, however often it runs the tick again. The point that floats up from where a ball fell and a round's winner wait for the server, with
verifiedhandlers. - Views see every body between its last two ticks, and gliding through a correction, because the package tags a
Body'spositionandrotationfor it (see correction smoothing).
The shadows
The engine has no shadows either. It has the pieces of a shadow map: a render target that holds depth alone, and a frag that reads a target's depth. So a tower's shadow falls across the tiles behind it, and across the crates, the balls and the towers that stand there.
What casts is drawn once more, through a camera where the lamp is. The lists the window's frame is drawn from go into a target of depth alone first, with no frag, as no color goes anywhere there: the tiles, the crates and the pickups that are boxes in one draw, and the balls in another. What's left in the target is how far the lamp sees along each of its pixels:
[Stage(Layers.Lamp)]
view Cast(Fx fx, Models models, mut Shadow shadow)
{
if (!shadow.made)
{
shadow.made = true;
shadow.map = RenderTarget(LAMP_PIXELS, LAMP_PIXELS, 1, Format.Depth);
shadow.depth = shadow.map.depth;
}
var turned = quaternion.LookRotation(-Lamp(), float3(0, 1, 0));
var lamp = float4x4.TRS(Math.Mul(turned, float3(0, LAMP_RAISED, -LAMP_AWAY)), turned, float3(1));
var sees = float4x4.Ortho(LAMP_SPAN, LAMP_SPAN, 0, LAMP_FAR);
shadow.map.Clear(Color.white);
shadow.map.SetCamera(lamp, sees);
shadow.toLamp = shadow.map.WorldToClip();
shadow.map.Instanced(models.cube, models.cubeFaces, fx.tiles);
shadow.map.Instanced(models.cube, models.cubeFaces, fx.solids);
...
}That view comes before anything is drawn to the window, the window's own clear too: everything drawn there asks the lamp's image, so the image is whole first.
Every frag that lights a pixel asks the lamp's image. One function does the asking: it works out where a place is in that image, and compares how far the place is from the lamp with what's stored there, four times around it, which blends a shadow's edge. It takes the depth to read, which each of the four frags that light something names as it calls, and gives how much of the lamp's light arrives, which the frag hands the one function that does the lighting:
float Sunlight(DepthTexture depth, float3 world, float3 normal, float4x4 toLamp)
{
float3 seen = FromLamp(world, normal, toLamp);
return (depth.Compare(seen.xy + float2(-LAMP_SOFT, -LAMP_SOFT), seen.z) + depth.Compare(seen.xy + float2(LAMP_SOFT, -LAMP_SOFT), seen.z)
+ depth.Compare(seen.xy + float2(-LAMP_SOFT, LAMP_SOFT), seen.z) + depth.Compare(seen.xy + float2(LAMP_SOFT, LAMP_SOFT), seen.z)) * 0.25;
}
frag CrateLook(Corner corner, out Color color, Solid solid, Instance instance, Room room, Shadow shadow)
{
...
float3 normal = instance.Normal(corner.normal);
float sun = Sunlight(shadow.depth, this.world, normal, shadow.toLamp);
color = Lit(paint, normal, this.world, room, 0.25, sun);
}In shadow, the lamp's light and its shine are gone, with a little of the sky's blue in their place, and the sky's own light and the cool one from the far side stay. A face that looks away from the lamp had none of the lamp's light to lose, so it's the same in a shadow and out of one.
- The lamp's image is 1,024 pixels a side, of the whole ring. It sees a box 40 meters across with no perspective, as a sun's light has none, so a pixel of it is 4 centimeters of the ring. In this page's frame the camera shows 30 meters across 375 pixels, 8 centimeters each, so a shadow's edge is finer than the screen's pixels there, and about as fine as them in a window of 1080 lines that shows the whole ring. Half as many pixels a side blur a crate's shadow. Twice as many are four times the drawing, for an edge that's only sharper where a screen's pixel is smaller than the image's.
- It never moves. The ring gets smaller through a round, and an image that followed it would be finer by the end. But the lamp's pixels would then slide over the ring as it shrank, and the edge of every shadow would crawl. Fixed to the ring as a round starts, the shadow of what stands still is still.
- A pixel looks from a little off its surface. What the lamp stored for a surface is how far it is at the middle of one of the image's pixels, which on a surface that slants away from the lamp is a way off, nearer or further. Compared as it is, such a surface is in its own shadow, in stripes. So
FromLampmoves the pixel along its normal first, by 0.4 of one of the image's pixels and up to 1.8 more as the surface slants, which is as far as the four looks reach: with none of it everything lit is striped, and with half of it a tumbling crate still shimmers. It's off the surface, not nearer the lamp, so the floor beside a crate is still behind the crate's side, and a shadow starts where its crate does. - What's outside the image is lit. Past its edges, a depth reads the pixels at the edge, so a crate thrown across one would smear its shadow over everything beyond.
FromLampgives such a pixel a depth of 0, nearer than anything stored. - What's flat on the floor isn't lit, so it's the same in a shadow: the rings under the balls, and the line of light along the floor's edge, are their own light.
What it costs
Measured on a release build, on one thread, with the world kept and hashed after every tick as a client's is, and in a browser's WebAssembly under Node, where Box3D solves four contacts at a time and a desktop eight:
| A tick, in milliseconds | Desktop | Browser |
|---|---|---|
| A fresh ring at rest | 0.02 | 0.03 |
| Four bots at a full ring | 0.4 to 0.6 | 0.5 to 1.0 |
| The worst: a shock beside the middle's pyramid | 0.9 to 1.1 | 1.2 to 1.5 |
| Keeping and hashing the world, after each | 0.06 to 0.1 | 0.08 to 0.14 |
| ...where there's one thread to hash it on | 0.12 to 0.2 | 0.14 to 0.23 |
| Raising a ring: the tick that makes its 2,460 bodies | 9 | 27 |
| ...the one after, which puts what's stacked to sleep | 3 | 6 |
| ...and each tick of the half second in which the balls come to rest | 0.09 | 0.5 |
- Box3D's state is 3.8 megabytes as a round starts, a kilobyte and a half a body, and all of it is in the match. Most ticks write only the part of it that changed: two in three while four bots bring a full ring down, and nine in ten after.
- Keeping that state costs about as much as the physics. With four bots at a full ring, Box3D's own step is 0.3 ms of a tick. Writing what changed of its state into the match is 0.2 more, and hashing the world after is 0.12 for one thread: about half of the state's 16 kilobyte blocks have a byte that changed, since what's moving is scattered through it. So the engine calls a few more threads in to hash, where the machine has them and the page lets it use them, which takes it to 0.06. The game's own systems, and the package's that read bodies in and out, are 0.03 together.
- A player who joins downloads it: 2.4 megabytes as the match packs it, with the rest of the world.
- A client that guessed wrong runs ticks again, from the last one the server confirmed: Box3D's world is made again from the state in the match, and the ticks since run again. Five ticks take 5 ms on a desktop and 7 in a browser, and ten take 7 and 8.5. Where the ticks a client is ahead by would take more than 4 ms to run again, by what its ticks took of late, the engine delays the player's input a tick at a time instead. With four bots at a full ring that's past about seven ticks ahead on a desktop, which is where a client is at a ping of about 90 milliseconds, and past four in a browser, which is still at most pings. Later in a round, with less in the ring, there's room for more.
- A frame's views take 0.2 ms with a full ring standing, for under thirty draw calls: 22 with four balls in the ring, three of them the lamp's image.
- The shadows cost a GPU next to nothing. A frame of a full ring is 0.1 ms of a desktop GPU's time in a window of 1080 lines, and 0.2 in a browser on WebGPU. In this page's frame it's 0.08 ms, of which the lamp's image is about 0.01, as it is whatever the window's size. Where a browser draws with no GPU at all, the shadows are a tenth of a frame's drawing.
That's about as much as a match takes, with the package as it is: a ring twice the size is twice the state to hold, to send and to read again for every wrong guess.
The match, whole
// Sumo: you are a heavy ball, in a ring in the sky that's stacked with towers
// of crates, and so are up to seven others. Plough through what's in your
// way, shove the others over the edge, and stay on yourself while the ring
// falls away under everyone. This file is the match, which every machine runs
// the same way. What the ring is built of each round is in arena.tide, what
// a machine shows of it in play.tide, light.tide, effects.tide and hud.tide,
// and which match it's in is the rooms package's menu's to say.
//
// Everything that moves is Box3D's (the box3d package): the game spawns
// bodies, pushes them and reads where they are, and never moves one itself.
using Box3D;
settings { title = "Sumo"; appId = "dev.tide.sumo"; maxPlayers = 8; hostMigration = true; stats = true; }
const int SEATS = 8; // The most players
const int CROWD = 4; // Balls in the ring at the least: bots have the seats players leave
// The ring, in meters: a floor of square tiles around the middle, its top at
// y = 0, and nothing under it or around it but the drop.
const float TILE = 3; // A tile's side
const int TILES = 6; // Tiles from the middle one to the rim
const float RING = 18.1; // How far out a tile's middle is at the most, as a round starts
const float PIT = -6; // The top of the pit: what falls into it is gone
const float OUT = -2.5; // A ball this low is out
const float BALL = 0.8; // A ball's radius: twice a crate's height across
const float BALL_DENSITY = 300; // 643 kilograms, to a crate's 23
const float BIG = 1.3; // ...and a heavy one's, which weighs thirteen times that
const float BIG_DENSITY = 900;
const float GRIP = 26; // How hard a ball drives, in meters a second, a second
const float DRAG = 2.0; // What the floor takes of its speed each second while it drives: 13 m/s at the most
const float COAST = 0.5; // ...and while it doesn't
const float WIND = 0.8; // Seconds to wind a charge all the way up
const float BURST = 7; // The speed a charge gives at the least, and how much more wound all the way up
const float BURST_MORE = 9;
const float BUMP = 4; // The speed two balls that meet are thrown apart with at the least, between them
const float BUMP_MORE = 0.7; // ...and how much of the speed they closed at is added to it
const int COOLDOWN = 150; // Ticks before a ball charges again
const int DASH = 36; // Ticks a burst lasts, for who it hits
const int RESPAWN = 120; // Ticks a ball that fell is gone for
const int BLAME = 360; // Ticks a touch counts for, when who it touched falls
const int HEAVY = 480; // Ticks of being heavy
const int RUBBLE = 240; // Ticks a crate lasts once it's been knocked about, at the least
const float CRATE = 0.8; // A crate's side
const float CRATE_DENSITY = 45;
// A round: the ring is set up and everyone waits, then they fight for two
// minutes while the floor falls away from the rim in, three times.
const int READY = 180;
const int ROUND = 7200;
const int RIMS = 3; // How many times the floor's rim goes
const int WARN = 300; // Ticks a rim is marked before it goes
const int OVER = 300;
const int DROP = 420; // Ticks between pickups
enum Phase { Clear, Ready, Play, Over }
enum Kind { Crate, Plank, Keg, Slab }
enum Power { Heavy, Shock }
// A seat: there are eight, from the match's start. A player has one, or a bot
// plays it, or it's empty. What it scored is here, as its ball comes and goes.
component Seat
{
int seat;
int score; // Ring-outs since the game started
int round; // ...and this round
int scored; // The tick of the last one: who got there first, in a tie
int wait; // Ticks until its ball is back
bool up; // Its ball is in the ring
}
// A ball: a player's, or a bot's.
component Ball
{
int seat;
int last = -1; // The seat whose ball touched it last...
int touched; // ...and the tick it did
float charge; // How far it's wound up, 0 to 1
bool winding;
int idle; // Ticks wound up with nobody holding
int cooldown; // Ticks until it charges again
int dash; // Ticks its burst still lasts
int dazed; // Ticks a hard hit leaves it without its grip
int heavy; // Ticks of being heavy that are left
int smashed; // The tick it last smashed into something, for whoever shows it
float radius = BALL;
float mass;
float3 before; // How fast it went into this tick's step: what a hit is judged by
float2 drive; // Where it's driven this tick, for whoever shows it
// How this machine shows it: where its trail's last spark was. Nothing of
// these is in the match.
local float3 trailed;
local bool trailing;
local float flash;
}
// What plays a ball nobody's hands are on: where it would go, and whether it
// would charge. Every ball has one, as a player's hands can come off.
component Brain
{
float2 move;
bool charge;
int held; // Ticks it has wound up for
int want; // ...and how many it means to
int prey = -1; // The seat it's after
int bored; // Ticks until it picks again
float taste; // How much sooner it goes for the next seat round than for the nearest
float2 roam; // Where it goes with nobody to go for
Random dice;
}
// Anything loose, which a blast throws and the pit takes: its weight, so that
// a blast throws a ball as far as a crate.
component Loose { float mass = 1; }
// A crate, a plank, a keg or a tile that fell: what it's drawn as, and the
// tick it breaks up at, once it's been knocked about. Rubble doesn't stay.
component Crate
{
Kind kind;
int tone; // One of three shades, so that a stack reads as crates
int until;
}
// A keg blows up when a ball hits it hard, or another blast reaches it.
component Keg { bool lit; }
// A tile of the floor: where its middle is, how far out that is, and how
// many ticks it holds on for once the floor ends short of it.
component Tile
{
float2 at;
float reach;
int fuse;
}
// Something to pick up, floating over the floor.
component Pickup
{
Power power;
float2 at;
bool taken;
int life = 1500;
}
// The pit under everything: a sensor, which hears of what falls into it.
component Pit { }
// A push outward from a place, which the next step gives everything near.
struct Blast
{
float3 at;
float reach;
float push; // The speed it gives right beside it
int seat = -1; // Whose it is: who it throws out scores for them
int due; // The tick it goes off
Entity spare; // The ball that set it off, which it leaves alone
}
singleton Ring
{
Phase phase = Phase.Clear; // The first tick sets the first round up
int round;
int timer; // Ticks left of the phase
int gone; // How many rims have gone
float radius = RING; // Where the floor ends
float next = RING; // ...and where it's about to
int manned; // The seats players have, a bit each
bool bots = true; // Bots have the seats players leave, up to the crowd
bool fresh; // The round being set up is the first of a new game
int stacked = TILES; // How many tiles out from the middle a ring is stacked: the scene's, below
int drop = DROP; // Ticks until the next pickup
Random dice;
Map<PlayerID, int> players; // Each player's seat
Map<int, PlayerID> owners; // ...and each seat's player
Map<int, Entity> chairs; // The seats' entities
List<Blast> blasts; // The blasts that haven't gone off yet
}
// The match's scene. How much is stacked on its rings is the match's from
// its start: every tile out to the rim, unless whoever started it said how
// many tiles out from the middle, as in `Session.Start(Main { stacked = 2 })`.
// With 1 it's the middle's own pile, and with 0 a bare floor. The game always
// starts a match with all of it. Its test starts some with less: the match it
// plays over a network is four machines, each with a ring to keep, and a test
// of what a ball does needs nothing in the ball's way.
scene Main
{
int stacked = TILES;
}
input Controls
{
[Clamp(-1, 1)] float2 move; // Across the ring and up it, as the screen has them
bool charge;
// The player's hands are off: their lobby or menu is up. Their ball plays
// by itself meanwhile, like a bot's. It says how things are, not what the
// player does, so what another machine guesses of it stays as it was.
[Absolute] bool away;
Sample(Devices devices, Hand hand)
{
var keys = devices.keyboard;
var pad = devices.gamepad;
mut var push = hand.stick + pad.leftStick;
if (keys.d.pressed || keys.rightArrow.pressed) push.x += 1;
if (keys.a.pressed || keys.leftArrow.pressed) push.x -= 1;
if (keys.w.pressed || keys.upArrow.pressed) push.y += 1;
if (keys.s.pressed || keys.downArrow.pressed) push.y -= 1;
// A stick is all the way over or it's let go, and points one of 32
// ways: an input that changes less often is one the other machines
// guess right more often
if (Math.Length(push) > 0.3)
{
var step = Math.TAU / 32;
var angle = Math.Round(Math.Atan2(push.y, push.x) / step) * step;
move = float2(Math.Cos(angle), Math.Sin(angle));
}
charge = hand.playing && (hand.charge || keys.space.pressed || pad.buttonSouth.pressed);
away = !hand.playing;
// Nothing is sent while nothing can be driven: the ring is being
// raised, or the player's hands are off. An input that stays as it
// was is one every other machine guesses right, and the ticks a ring
// is raised in are the ones a machine can least afford to run again
if (away || !hand.live)
{
move = float2(0);
charge = false;
}
}
}
// Sent to a seat: its ball fell, or it threw someone else's out.
event Fell { }
event Benched { } // Its ball was taken out: the round is over, or the seat is nobody's
event Scored { }
event Emptied { } // Whoever had it is gone: what it scored goes with them
// Sent to a ball: it picked something up, a blast reached it, or its fuse is lit.
event Got { Power power; }
event Rocked { int seat; }
event Kindled { int seat; }
// What happens in the match, said for whoever shows it: nothing in the match
// handles these, so they cost it nothing. Each machine's handlers of them are
// in effects.tide.
event RingOut { float3 at; int seat; int by; }
event Clash { float3 at; float hard; int a; int b; }
event Smash { float3 at; float hard; int seat; }
event Burst { float3 at; float2 way; float hard; int seat; }
event Boom { float3 at; float reach; int seat; }
event Taken { float3 at; Power power; int seat; }
event Dropped { float2 at; }
event Shrinking { float to; }
event RoundSet { int round; }
event Go { }
event RoundOver { int round; int winner; int best; }
// What the buttons ask of the match.
command Rebuild { }
command Bots { }
float SphereMass(float radius, float density)
{
return 4.18879 * radius * radius * radius * density;
}
// Whether a seat is in the match: a player has it, or it's one of the first
// seats nobody has, which bots play until the ring has its crowd.
bool Live(int manned, bool bots, int seat)
{
if ((manned >> seat & 1) == 1) return true;
if (!bots) return false;
mut var held = 0;
for (var s = 0; s < SEATS; s++) held += manned >> s & 1;
mut var free = 0;
for (var s = 0; s < seat; s++) free += 1 - (manned >> s & 1);
return free < CROWD - held;
}
// Where the floor ends once this many rims have gone: each is a ring of
// tiles' middles.
float Rim(int gone)
{
return gone <= 0 ? RING : gone == 1 ? 15.1 : gone == 2 ? 12.5 : 9.6;
}
// The tick of a round its rims go at: after 35 seconds, after 65, and with
// half a minute to go.
int Falls(int rim)
{
return rim == 1 ? 2100 : rim == 2 ? 3900 : rim == 3 ? 5400 : ROUND + 1;
}
// Where a seat's ball starts a round: on a tile that every arrangement
// leaves clear, two to a quarter of the ring, a way in from the rim.
float3 Start(int seat)
{
mut var tile = seat % 2 == 0 ? float2(1, -4) : float2(4, -1);
for (var quarter = 0; quarter < seat / 2; quarter++) tile = float2(-tile.y, tile.x);
return float3(tile.x * TILE, BALL + 0.02, tile.y * TILE);
}
// What stays for the whole match: the seats, and the pit, a sensor far under
// the floor. Gravity is the world's: stronger than the Earth's here, so that
// what falls looks its size.
event(Spawned) Build(Main main, mut Ring ring, mut Physics physics)
{
physics.gravity = float3(0, -18, 0);
ring.stacked = main.stacked;
for (var seat = 0; seat < SEATS; seat++)
ring.chairs[seat] = Spawn(Seat { seat = seat });
Spawn(Body { type = BodyType.Static, position = float3(0, PIT - 10, 0) },
Box { size = float3(160, 20, 160), sensor = true }, Pit);
}
// A player has the first seat no player has. A bot may be playing it: its
// ball is then theirs, as it is.
event(PlayerJoined joined) Arrive(mut Ring ring)
{
if (ring.players.Has(joined.player)) return;
for (var seat = 0; seat < SEATS; seat++)
{
if ((ring.manned >> seat & 1) == 1) continue;
ring.manned |= 1 << seat;
ring.players[joined.player] = seat;
ring.owners[seat] = joined.player;
if (ring.chairs.TryGet(seat, out var chair)) chair.Send(Emptied { });
return;
}
}
event(PlayerLeft left) Depart(mut Ring ring)
{
if (!ring.players.TryGet(left.player, out var seat)) return;
ring.manned &= ~(1 << seat);
ring.players.Remove(left.player);
ring.owners.Remove(seat);
if (ring.chairs.TryGet(seat, out var chair)) chair.Send(Emptied { });
}
// A ball is its seat's player's: Owner ties it to them, and Drive then reads
// their input for it. A bot's has nobody.
[Before(PhysicsStages.Step)]
system Hands(mut Owner owner, Ball ball, Ring ring)
{
var player = ring.owners[ball.seat];
if (owner.player != player) owner.player = player;
}
// Where a ball would go by itself, and whether it would charge. It picks
// someone to go for, the nearest more often than not, and sooner the next
// seat round than the one before, so that bots don't all go for the same
// ball. It goes round to their inside: from there a hit sends them out. Then it winds up and lets go at them. It turns back
// from the rim before it gets there, by how far the rim is and how fast it's
// going out to it. Every ball works this out, and only the ones nobody
// drives use it.
[Before(PhysicsStages.Step)]
system Think(mut Brain brain, Body body, Ball ball, Ring ring,
query (Body body, Ball ball) others, query (Pickup pickup) drops)
{
var at = float2(body.position.x, body.position.z);
var speed = float2(body.velocity.x, body.velocity.z);
var far = Math.Length(at);
var edge = Math.Min(ring.radius, ring.next) + 0.6;
var outward = Math.Normalize(at);
brain.bored -= 1;
mut var found = false;
mut var their = float2(0);
mut var nearest = -1;
mut var best = 1000.0;
foreach (var other in others)
{
if (other.entity == this || other.body.position.y < 0.3) continue;
var where = float2(other.body.position.x, other.body.position.z);
if (other.ball.seat == brain.prey)
{
found = true;
// Where they'll be in a moment
their = where + float2(other.body.velocity.x, other.body.velocity.z) * 0.22;
}
var gap = Math.Distance(at, where) + (other.ball.heavy > 0 ? 10 : 0)
+ ((other.ball.seat - ball.seat + SEATS) % SEATS) * brain.taste;
if (gap >= best) continue;
best = gap;
nearest = other.ball.seat;
}
if (!found || brain.bored <= 0)
{
brain.bored = 150 + brain.dice.NextInt(240);
brain.prey = nearest;
brain.taste = brain.dice.NextFloat(0, 2.5);
var around = brain.dice.NextFloat(Math.TAU);
brain.roam = float2(Math.Sin(around), Math.Cos(around)) * (edge * 0.55);
}
mut var goal = brain.roam;
mut var hunting = false;
if (found)
{
var out = Math.Normalize(their);
var lined = Math.Dot(Math.Normalize(their - at), out);
hunting = lined > 0.25 || Math.Distance(at, their) < 3;
goal = hunting ? their : their - out * 4.5;
}
foreach (var drop in drops)
{
var gap = Math.Distance(at, drop.pickup.at);
if (gap > 7 || Math.Length(drop.pickup.at) > edge - 2.5) continue;
goal = drop.pickup.at;
hunting = false;
}
var gap = Math.Distance(at, goal);
mut var way = Math.Normalize(goal - at);
var danger = far - (edge - 3.2) + Math.Max(Math.Dot(speed, outward), 0) * 0.38;
if (danger > 0) way = Math.Normalize(way - outward * Math.Min(danger * 1.3, 6));
// Knocked silly: it leans against the way it's sliding
if (ball.dazed > 0) way = Math.Normalize(way * 0.3 - Math.Normalize(speed));
brain.move = way;
if (brain.charge)
{
brain.held += 1;
if (brain.held >= brain.want || gap < 2.6 || !hunting) brain.charge = false;
}
else if (hunting && found && ball.cooldown == 0 && ball.dazed == 0 && gap < 11 && gap > 3.4 && danger < 0
&& brain.dice.NextInt(18) == 0)
{
brain.charge = true;
brain.held = 0;
brain.want = 14 + brain.dice.NextInt(34);
}
}
// A ball is driven by pushes, never put anywhere: an impulse each tick, which
// is a force, towards where its player holds the stick, less what the floor
// takes of its speed. It runs before the package's systems, so the step that
// gives the push is this tick's: a ball answers its player in the tick they
// pressed.
//
// A charge winds up while the button is held, and bursts when it's let go:
// one impulse, the way the ball is driven. `controls` is the input of the
// player who owns the ball, and `charge.up` is only ever true for a button a
// player let go of: a machine that's guessing what another player does never
// guesses that.
[Before(PhysicsStages.Step)]
system Drive(Controls controls, mut Body body, mut Ball ball, Brain brain, Ring ring, Time time)
{
ball.before = body.velocity;
if (ball.cooldown > 0) ball.cooldown -= 1;
if (ball.dash > 0) ball.dash -= 1;
if (ball.dazed > 0) ball.dazed -= 1;
var own = (ring.manned >> ball.seat & 1) == 1 && !controls.away;
mut var steer = own ? controls.move : brain.move;
mut var hold = own ? controls.charge : brain.charge;
mut var let = own ? controls.charge.up : ball.winding && !brain.charge;
// Nobody moves until a round starts, and a ball in the air has nothing to push against
var grounded = body.position.y > ball.radius - 0.35;
if (ring.phase == Phase.Ready || ring.phase == Phase.Clear)
{
steer = float2(0);
hold = false;
let = false;
}
var hard = Math.Length(steer);
if (hard > 1) steer = steer / hard;
ball.drive = steer;
if (hold && ball.cooldown == 0)
{
ball.winding = true;
ball.idle = 0;
ball.charge = Math.Min(1, ball.charge + time.dt / WIND);
}
else if (ball.winding)
{
var flat = float2(body.velocity.x, body.velocity.z);
var way = hard > 0.2 ? Math.Normalize(steer) : Math.Normalize(flat);
ball.idle += 1;
if (let && grounded && Math.LengthSq(way) > 0.5)
{
var speed = BURST + BURST_MORE * ball.charge;
body.impulse += float3(way.x, 0, way.y) * (speed * ball.mass);
ball.cooldown = COOLDOWN;
ball.dash = DASH;
Send(Burst { at = body.position, way = way, hard = ball.charge, seat = ball.seat });
}
// Wound up with nobody holding: a guess that the player let go, which
// waits for what they did, and not for long
if (let || ball.idle > 20)
{
ball.winding = false;
ball.charge = 0;
}
}
if (!grounded) return;
// A ball that was hit hard has next to no grip for a moment: it slides
var dazed = ball.dazed > 0;
var grip = GRIP * (ball.winding ? 0.4 : 1) * (dazed ? 0.15 : 1);
var rolling = float2(body.velocity.x, body.velocity.z);
var push = steer * grip - rolling * (hard > 0.05 && !dazed ? DRAG : COAST);
body.impulse += float3(push.x, 0, push.y) * (ball.mass * time.dt);
}
// Blasts: everything loose within reach is thrown outward, and a little up,
// by an impulse as heavy as it is. A keg in reach has its fuse lit, and a
// ball is told whose blast it was.
[Before(PhysicsStages.Step)]
system Blasts(mut Ring ring, mut World world, Time time, query (Body body, Loose loose) things)
{
mut var i = 0;
while (i < ring.blasts.count)
{
var blast = ring.blasts[i];
if (blast.due > time.tick)
{
i++;
continue;
}
ring.blasts.RemoveAt(i);
foreach (var thing in things)
{
if (thing.entity == blast.spare) continue;
var away = thing.body.position - blast.at;
var gap = Math.Length(away);
if (gap > blast.reach) continue;
var near = 1 - gap / blast.reach;
var way = Math.Normalize(float3(away.x, Math.Abs(away.y) * 0.5 + 0.45 * Math.Max(gap, 0.5), away.z));
mut var thrown = thing.body;
thrown.impulse += way * (blast.push * (0.35 + 0.65 * near) * thing.loose.mass);
world.Set<Body>(thing.entity, thrown);
if (world.Has<Keg>(thing.entity)) thing.entity.Send(Kindled { seat = blast.seat });
else if (world.Has<Ball>(thing.entity)) thing.entity.Send(Rocked { seat = blast.seat });
}
}
}
// A ball that's low enough has fallen off: it's gone, its seat waits for the
// next one, and whoever touched it last, not too long ago, scores. A heavy
// ball is its usual size again once its time is up: a body's shape is read
// once, so a ball of another size is another ball, where this one was and as
// fast.
[After(PhysicsStages.Step)]
system Roll(Body body, mut Ball ball, Brain brain, Owner owner, Ring ring, Time time)
{
var gone = ring.phase == Phase.Clear || !Live(ring.manned, ring.bots, ball.seat);
if (body.position.y < OUT || gone)
{
if (!gone)
{
var by = ball.last >= 0 && time.tick - ball.touched < BLAME ? ball.last : -1;
Send(RingOut { at = body.position, seat = ball.seat, by = by });
if (by >= 0 && ring.phase == Phase.Play && ring.chairs.TryGet(by, out var scorer)) scorer.Send(Scored { });
}
if (ring.chairs.TryGet(ball.seat, out var chair))
{
if (gone) chair.Send(Benched { });
else chair.Send(Fell { });
}
this.Destroy();
return;
}
if (ball.heavy == 0) return;
ball.heavy -= 1;
if (ball.heavy > 0) return;
this.Destroy();
Spawn(Body { position = body.position, rotation = body.rotation, velocity = body.velocity, angularVelocity = body.angularVelocity,
angularDamping = 0.3 },
Sphere { radius = BALL, density = BALL_DENSITY, friction = 0.8, bounce = 0.25 },
Ball { seat = ball.seat, last = ball.last, touched = ball.touched, cooldown = ball.cooldown, dazed = ball.dazed,
mass = SphereMass(BALL, BALL_DENSITY) },
Brain { dice = brain.dice }, Loose { mass = SphereMass(BALL, BALL_DENSITY) }, Owner { player = owner.player });
}
// Touched comes to a body's entity when it starts touching another. Two balls
// that meet are thrown apart: each gets an impulse away from the other, by
// how fast they closed and by how heavy the other is, on top of what Box3D
// made of the hit. It's given at the next step. A ball remembers whose ball
// touched it last, and the slower of two that meet hard loses its grip for a
// moment: that's what a charge is for.
event(Touched touched) Bump(mut Ball ball, mut Body body, World world, Time time)
{
if (!world.TryGet<Ball>(touched.other, out var other))
{
// Something it smashed into, for whoever shows it: not every crate of a wall
var fast = Math.Length(ball.before);
if (fast < 5 || time.tick - ball.smashed < 8 || !world.Has<Crate>(touched.other)) return;
ball.smashed = time.tick;
Send(Smash { at = body.position, hard = fast, seat = ball.seat });
return;
}
ball.last = other.seat;
ball.touched = time.tick;
var their = world.Get<Body>(touched.other).position;
var apart = Math.Normalize(float3(body.position.x - their.x, 0, body.position.z - their.z));
var closing = Math.Max(Math.Dot(other.before - ball.before, apart), 0);
body.impulse += apart * ((BUMP + BUMP_MORE * closing) * ball.mass * other.mass / (ball.mass + other.mass));
if (closing > 5 && ball.heavy == 0 && (Math.Length(ball.before) < Math.Length(other.before) || other.heavy > 0))
ball.dazed = Math.Min(40, int(closing * 2.5));
// Once for the two of them
if (ball.seat < other.seat) Send(Clash { at = (body.position + their) * 0.5, hard = closing, a = ball.seat, b = other.seat });
}
event(Rocked rocked) Blame(mut Ball ball, Time time)
{
if (rocked.seat < 0 || rocked.seat == ball.seat) return;
ball.last = rocked.seat;
ball.touched = time.tick;
}
// What a ball picks up. Heavy: a much bigger, much heavier ball in its place,
// for a while. Shock: a blast from where it is, which leaves it alone.
event(Got got) Gain(mut Ball ball, Body body, Brain brain, Owner owner, mut Ring ring, Time time)
{
Send(Taken { at = body.position, power = got.power, seat = ball.seat });
if (got.power == Power.Shock)
{
ring.blasts.Add(Blast { at = body.position, reach = 9, push = 15, seat = ball.seat, due = time.tick + 1, spare = this });
Send(Boom { at = body.position, reach = 9, seat = ball.seat });
return;
}
if (ball.heavy > 0)
{
ball.heavy = HEAVY;
return;
}
this.Destroy();
Spawn(Body { position = body.position + float3(0, BIG - BALL, 0), rotation = body.rotation, velocity = body.velocity,
angularVelocity = body.angularVelocity, angularDamping = 0.3 },
Sphere { radius = BIG, density = BIG_DENSITY, friction = 0.8, bounce = 0.25 },
Ball { seat = ball.seat, last = ball.last, touched = ball.touched, cooldown = ball.cooldown, heavy = HEAVY, radius = BIG,
mass = SphereMass(BIG, BIG_DENSITY) },
Brain { dice = brain.dice }, Loose { mass = SphereMass(BIG, BIG_DENSITY) }, Owner { player = owner.player });
}
// A pickup is a sensor: a ball that comes into it has it, the first one only.
event(Touched touched) Take(mut Pickup pickup, World world)
{
if (pickup.taken || !world.Has<Ball>(touched.other)) return;
pickup.taken = true;
touched.other.Send(Got { power = pickup.power });
this.Destroy();
}
// One that nobody took goes after a while, and at once when the floor under
// it does.
[After(PhysicsStages.Step)]
system Expire(mut Pickup pickup, Ring ring)
{
pickup.life -= 1;
if (pickup.life <= 0 || ring.phase == Phase.Clear || Math.Length(pickup.at) > Math.Min(ring.radius, ring.next) - 1) this.Destroy();
}
// A keg that a ball hits hard blows up, and so does one a blast reaches, a
// moment later: a row of them goes off one after another.
event(Touched touched) Fuse(mut Keg keg, Body body, World world, mut Ring ring, Time time)
{
if (keg.lit || !world.TryGet<Ball>(touched.other, out var ball) || Math.Length(ball.before) < 4.5) return;
keg.lit = true;
ring.blasts.Add(Blast { at = body.position, reach = 5.5, push = 12, seat = ball.seat, due = time.tick + 1 });
Send(Boom { at = body.position, reach = 5.5, seat = ball.seat });
this.Destroy();
}
event(Kindled lit) Burn(mut Keg keg, Body body, mut Ring ring, Time time)
{
if (keg.lit) return;
keg.lit = true;
ring.blasts.Add(Blast { at = body.position, reach = 5.5, push = 12, seat = lit.seat, due = time.tick + 7 });
Send(Boom { at = body.position, reach = 5.5, seat = lit.seat });
this.Destroy();
}
// The pit takes what falls into it: an entity that's destroyed takes its
// body with it. A ball is gone before it gets this far, and its seat is told
// (Roll).
event(Touched touched) Swallow(World world, with Pit)
{
if (world.Has<Crate>(touched.other)) touched.other.Destroy();
}
// A tile falls once the floor ends short of it: the static one goes, and a
// loose one is in its place, a little smaller so that it clears its
// neighbors, with whatever stood on it. A body's type is read once, so a tile
// that falls is another body.
[After(PhysicsStages.Step)]
system Crumble(mut Tile tile, Ring ring)
{
if (ring.phase == Phase.Clear)
{
this.Destroy();
return;
}
if (tile.reach <= ring.radius) return;
tile.fuse -= 1;
if (tile.fuse > 0) return;
this.Destroy();
var size = float3(TILE - 0.16, 1, TILE - 0.16);
var lean = float3(tile.at.y, 0, -tile.at.x) * (0.012 * (Math.Hash(int2(int(tile.at.x), int(tile.at.y))) % 7 - 3));
Spawn(Body { position = float3(tile.at.x, -0.5, tile.at.y), velocity = float3(0, -1, 0), angularVelocity = lean },
Box { size = size, density = 400, friction = 0.8 }, Crate { kind = Kind.Slab }, Loose { mass = size.x * size.z * 400 });
Send(Dropped { at = tile.at });
}
// A crate that's knocked hard enough has a few seconds left: what a ball
// brings down is in the way for a while, and then it's gone, so the ring is
// clearer the longer a round goes on. Nothing counts while a round is being
// set up, when everything that's stacked touches down.
event(Touched) Knock(mut Crate crate, Body body, Ring ring, Time time)
{
if (crate.until != 0 || crate.kind == Kind.Slab || ring.phase == Phase.Ready) return;
if (Math.LengthSq(body.velocity) < 6) return;
crate.until = time.tick + RUBBLE + Math.Hash(int2(time.tick, int(body.position.x * 64))) % 120;
}
// ...and between rounds, everything a round made goes.
[After(PhysicsStages.Step)]
system Break(Crate crate, Ring ring, Time time)
{
if (ring.phase == Phase.Clear || (crate.until != 0 && time.tick >= crate.until)) this.Destroy();
}
// A seat's ball comes back a moment after it fell: on the floor where its
// seat starts while a round is being set up, and dropped from above the ring
// otherwise, nearer the middle the less floor is left.
[After(PhysicsStages.Step)]
system Seats(mut Seat seat, Ring ring, Time time)
{
if (ring.phase == Phase.Clear)
{
seat.round = 0;
seat.wait = 0;
if (ring.fresh) seat.score = 0;
return;
}
if (seat.up || !Live(ring.manned, ring.bots, seat.seat)) return;
if (seat.wait > 0)
{
seat.wait -= 1;
return;
}
seat.up = true;
mut var at = Start(seat.seat);
if (ring.phase != Phase.Ready)
{
// Somewhere of its own each time
var around = (seat.seat + 0.5) * Math.TAU / SEATS + (Math.Hash(int2(time.tick, seat.seat)) % 100) * 0.0628;
var out = Math.Min(ring.radius, ring.next) * 0.5;
at = float3(Math.Sin(around) * out, 9, -Math.Cos(around) * out);
}
Spawn(Body { position = at, angularDamping = 0.3 },
Sphere { radius = BALL, density = BALL_DENSITY, friction = 0.8, bounce = 0.25 },
Ball { seat = seat.seat, mass = SphereMass(BALL, BALL_DENSITY) }, Brain,
Loose { mass = SphereMass(BALL, BALL_DENSITY) }, Owner { player = ring.owners[seat.seat] });
}
event(Fell) Out(mut Seat seat)
{
seat.up = false;
seat.wait = RESPAWN;
}
event(Benched) Bench(mut Seat seat)
{
seat.up = false;
seat.wait = 0;
}
event(Scored) Count(mut Seat seat, Time time)
{
seat.score += 1;
seat.round += 1;
seat.scored = time.tick;
}
event(Emptied) Empty(mut Seat seat)
{
seat.score = 0;
seat.round = 0;
}
// Pickups come one at a time, while a round is played: somewhere on the
// floor that's going to stay a while.
[After(PhysicsStages.Step)]
system Drops(mut Ring ring, query (with Pickup) drops)
{
if (ring.phase != Phase.Play) return;
ring.drop -= 1;
if (ring.drop > 0) return;
ring.drop = DROP;
mut var there = 0;
foreach (var drop in drops) there += 1;
if (there >= 2) return;
var around = ring.dice.NextFloat(Math.TAU);
var out = ring.dice.NextFloat(0.15, 0.8) * (Math.Min(ring.radius, ring.next) - 2);
var at = float2(Math.Sin(around) * out, Math.Cos(around) * out);
Spawn(Body { type = BodyType.Static, position = float3(at.x, 1.1, at.y) }, Sphere { radius = 0.75, sensor = true },
Pickup { power = ring.dice.NextInt(2) == 0 ? Power.Heavy : Power.Shock, at = at });
}
// A round. The ring is set up (arena.tide), and everyone waits while what was
// stacked comes to rest. Then they fight, and every twenty seconds the floor
// ends nearer the middle: the rim that's going is marked first, and its tiles
// fall one after another as the edge passes them. When the time is up it's
// whoever threw the most out, the one who got there first if two did.
[After(PhysicsStages.Step)]
system Rounds(mut Ring ring, query (Seat seat) seats)
{
if (ring.phase == Phase.Clear)
{
ring.round += 1;
ring.phase = Phase.Ready;
ring.timer = READY;
ring.gone = 0;
ring.radius = RING;
ring.next = RING;
ring.fresh = false;
ring.drop = DROP;
ring.blasts.Clear();
Send(RoundSet { round = ring.round });
return;
}
ring.timer -= 1;
if (ring.phase == Phase.Ready)
{
if (ring.timer > 0) return;
ring.phase = Phase.Play;
ring.timer = ROUND;
ring.gone = 0;
Send(Go { });
return;
}
if (ring.phase == Phase.Play)
{
var played = ROUND - ring.timer;
if (ring.gone < RIMS && played == Falls(ring.gone + 1) - WARN)
{
ring.next = Rim(ring.gone + 1);
Send(Shrinking { to = ring.next });
}
if (ring.gone < RIMS && played == Falls(ring.gone + 1))
{
ring.gone += 1;
ring.radius = Rim(ring.gone);
}
if (ring.timer > 0) return;
ring.phase = Phase.Over;
ring.timer = OVER;
mut var best = 0;
mut var first = 0;
mut var winner = -1;
foreach (var entry in seats)
{
var seat = entry.seat;
if (seat.round < best || seat.round == 0 || (seat.round == best && seat.scored >= first)) continue;
best = seat.round;
first = seat.scored;
winner = seat.seat;
}
Send(RoundOver { round = ring.round, winner = winner, best = best });
return;
}
if (ring.timer <= 0) ring.phase = Phase.Clear;
}
// The buttons.
command(Rebuild rebuild, PlayerID sender) Reset(mut Ring ring)
{
ring.phase = Phase.Clear;
ring.round = 0;
ring.fresh = true;
}
command(Bots bots, PlayerID sender) Idle(mut Ring ring)
{
ring.bots = !ring.bots;
}And what a round's ring is built of:
// What the ring is built of: the floor's tiles, and what's stacked on them,
// which is another arrangement every round. It's part of the match: every
// machine builds the same ring from the round's number and the match's seed.
//
// Everything stacked is a body of its own, standing still until something
// hits it. A body at rest is asleep, and a ring is put to sleep as soon as
// it's stacked (Settle), so two thousand crates cost a tick next to nothing
// until a ball gets to them.
//
// What's stacked is square to the floor, and a column of crates stands a
// hand's width from the next. That's for what the match carries: Box3D keeps
// a contact for every two bodies whose boxes are within 14 centimeters of
// each other, touching or not, and each contact is in its state, which the
// match holds, hashes, and sends to a player who joins. A crate in a column
// has one, with the crate under it. Packed closer than that, each had seven,
// and the state of the same ring was three times the size.
using Box3D;
const float PITCH = 0.96; // From a column's middle to the next one's: a crate, and the gap
// A crate that was just stacked, until it's put to sleep (Settle)
[Sparse]
component Fresh { }
// One crate, plank, beam or keg of a ring that's being planned: a box, where
// it stands and how it's turned.
struct Brick
{
float3 at;
float3 size = float3(CRATE);
bool turned; // A quarter turn: its length runs along x
Kind kind;
int tone;
}
// What stands on a tile.
enum Stand
{
Nothing,
Stack, // Crates in columns, three each way
Pile, // ...two each way
Steps, // A small pyramid of columns
Cabin, // Beams laid crosswise, two a level
Wall, // Crates laid like bricks, along the ring
Planks, // Planks on end in a row, along the ring: the first to fall takes the rest
Gate, // Two pillars and a beam across them
Kegs // Two kegs, with a crate on each
}
// Crates in columns: `wide` each way, `high` up, around `at`.
void Columns(mut List<Brick> plan, float2 at, int wide, int high)
{
for (var i = 0; i < wide; i++)
for (var j = 0; j < wide; j++)
for (var level = 0; level < high; level++)
plan.Add(Brick { at = float3(at.x + (i - (wide - 1) * 0.5) * PITCH, CRATE * 0.5 + level * CRATE,
at.y + (j - (wide - 1) * 0.5) * PITCH), tone = (i + j * 2 + level) % 3 });
}
// A pyramid of columns: `base` each way, higher each column in from its
// edge, `rise` crates a step.
void Pyramid(mut List<Brick> plan, float2 at, int base, int rise)
{
for (var i = 0; i < base; i++)
for (var j = 0; j < base; j++)
{
var high = rise * Math.Min(Math.Min(i + 1, base - i), Math.Min(j + 1, base - j));
for (var level = 0; level < high; level++)
plan.Add(Brick { at = float3(at.x + (i - (base - 1) * 0.5) * PITCH, CRATE * 0.5 + level * CRATE,
at.y + (j - (base - 1) * 0.5) * PITCH), tone = (i + j * 2 + level) % 3 });
}
}
// Beams laid crosswise, two a level: each lies on the two under it.
void Cabin(mut List<Brick> plan, float2 at, int levels)
{
for (var level = 0; level < levels; level++)
for (var side = -1; side <= 1; side += 2)
{
var across = level % 2 == 0;
plan.Add(Brick { at = float3(at.x + (across ? 0 : side * 0.9), 0.35 + level * 0.7, at.y + (across ? side * 0.9 : 0)),
size = float3(0.7, 0.7, 2.5), turned = across, kind = Kind.Plank });
}
}
// Crates laid like bricks, each on the two under it, a row one shorter than
// the row below: `long` of them at the bottom, along x or along z, `deep`
// such walls one behind the other.
void Wall(mut List<Brick> plan, float2 at, bool along, int long, int deep)
{
for (var row = 0; row < deep; row++)
for (var level = 0; level < long; level++)
for (var k = 0; k < long - level; k++)
{
var far = (k - (long - 1 - level) * 0.5) * PITCH;
var back = (row - (deep - 1) * 0.5) * PITCH;
plan.Add(Brick { at = float3(at.x + (along ? far : back), CRATE * 0.5 + level * CRATE, at.y + (along ? back : far)),
tone = (k + level * 2 + row) % 3 });
}
}
// Planks on end, one behind the other.
void Planks(mut List<Brick> plan, float2 at, bool along, int count)
{
for (var k = 0; k < count; k++)
{
var far = (k - (count - 1) * 0.5) * 1.0;
plan.Add(Brick { at = float3(at.x + (along ? far : 0), 0.9, at.y + (along ? 0 : far)), size = float3(1.2, 1.8, 0.3),
turned = along, kind = Kind.Plank });
}
}
// Two pillars and a beam across them.
void Gate(mut List<Brick> plan, float2 at, bool along)
{
for (var side = -1; side <= 1; side += 2)
plan.Add(Brick { at = float3(at.x + (along ? side * 1.0 : 0), 1.2, at.y + (along ? 0 : side * 1.0)),
size = float3(0.8, 2.4, 0.8), kind = Kind.Plank });
plan.Add(Brick { at = float3(at.x, 2.7, at.y), size = float3(0.9, 0.6, 2.9), turned = along, kind = Kind.Plank });
}
// What stands on the tile `far` tiles out along one of the floor's axes and
// `side` across it, with `far` the greater: an eighth of the ring, which the
// other seven mirror, so that every seat starts with the same ring around
// it. The balls start on the tile 4 out and 1 across, which stays clear,
// and so does a way round the middle.
//
// The floor's rim goes three times in a round, and what's on it goes with
// it, so what's tallest isn't out by the rim.
Stand Stands(int kind, int far, int side)
{
if (far <= 1 || (far == 4 && side == 1)) return Stand.Nothing; // The middle's own, and where the balls start
if (kind == 1)
{
// The citadel: a pyramid in the middle, cabins at its corners, gates
// on the way in, and stacks all around
if (far == 2) return side == 2 ? Stand.Cabin : Stand.Nothing;
if (far == 3) return side == 0 ? Stand.Gate : side == 2 ? Stand.Pile : side == 3 ? Stand.Stack : Stand.Nothing;
if (far == 4) return side == 0 ? Stand.Planks : side == 2 ? Stand.Pile : side == 4 ? Stand.Stack : Stand.Nothing;
if (far == 5) return side == 0 ? Stand.Cabin : side == 1 ? Stand.Planks : side == 2 ? Stand.Pile : Stand.Kegs;
return Stand.Wall;
}
if (kind == 2)
{
// The henge: one tall tower, a circle of gates around it, and thin
// towers further out
if (far == 2) return side <= 1 ? Stand.Gate : Stand.Pile;
if (far == 3) return side == 2 ? Stand.Pile : side == 3 ? Stand.Cabin : Stand.Nothing;
if (far == 4) return side == 0 ? Stand.Stack : side == 2 ? Stand.Kegs : side == 3 ? Stand.Pile : Stand.Stack;
if (far == 5) return side == 0 ? Stand.Planks : side == 1 ? Stand.Wall : side == 2 ? Stand.Planks : Stand.Pile;
return Stand.Gate;
}
// The pyramids: a wide one in the middle, smaller ones all around it, and
// stacks at the corners
if (far == 2) return side == 2 ? Stand.Planks : Stand.Nothing;
if (far == 3) return side == 0 ? Stand.Steps : side == 2 ? Stand.Steps : side == 3 ? Stand.Stack : Stand.Nothing;
if (far == 4) return side == 0 ? Stand.Kegs : side == 3 ? Stand.Steps : side == 4 ? Stand.Stack : Stand.Nothing;
if (far == 5) return side == 0 ? Stand.Stack : side == 1 ? Stand.Planks : side == 2 ? Stand.Pile : Stand.Nothing;
return Stand.Cabin;
}
// A round's ring: what stands on each tile, by the round's arrangement, as
// high as the round's own dice say, and the arrangement's middle. Only the
// tiles within `stacked` of the middle have anything on them: all of them, in
// a match that was started as the game starts one (see `Main`, in game.tide).
void Plan(mut List<Brick> plan, int round, int seed, int stacked)
{
mut var dice = Random(Math.Hash(int2(seed, round)));
var kind = round % 3;
if (stacked < 1) return;
if (kind == 1) Pyramid(plan, float2(0), 7, 2);
else if (kind == 2) Columns(plan, float2(0), 4, 13 + dice.NextInt(3));
else Pyramid(plan, float2(0), 9, 1);
for (var i = -TILES; i <= TILES; i++)
for (var j = -TILES; j <= TILES; j++)
{
var at = float2(i * TILE, j * TILE);
if (Math.Length(at) > RING) continue;
var far = Math.Max(Math.Abs(i), Math.Abs(j));
var side = Math.Min(Math.Abs(i), Math.Abs(j));
// Along the ring: along x where the tile is out along z
var along = Math.Abs(j) > Math.Abs(i);
var stand = far > stacked ? Stand.Nothing : Stands(kind, far, side);
var tall = dice.NextInt(4);
if (stand == Stand.Stack) Columns(plan, at, 3, 9 + tall);
else if (stand == Stand.Pile) Columns(plan, at, 2, 8 + tall * 2);
else if (stand == Stand.Steps) Pyramid(plan, at, 3, 3 + tall % 2);
else if (stand == Stand.Cabin) Cabin(plan, at, 10 + tall * 2);
else if (stand == Stand.Wall) Wall(plan, at, along, 3, 2 + tall % 2);
else if (stand == Stand.Planks) Planks(plan, at, along, 3);
else if (stand == Stand.Gate) Gate(plan, at, along);
else if (stand == Stand.Kegs)
{
// Two kegs, with a crate on each
for (var half = -1; half <= 1; half += 2)
{
var spot = float2(at.x + (along ? half * PITCH * 0.5 : 0), at.y + (along ? 0 : half * PITCH * 0.5));
plan.Add(Brick { at = float3(spot.x, CRATE * 0.5, spot.y), kind = Kind.Keg });
plan.Add(Brick { at = float3(spot.x, CRATE * 1.5, spot.y), tone = 1 });
}
}
}
}
// The ring is raised in the tick a round is cleared in: the floor, every tile
// whose middle is in the ring, and what the plan says stands on it. A tile is
// a static body, which never moves: one that's due to fall is replaced by a
// loose one (game.tide). How long each holds on once its rim goes is its own,
// so that a rim crumbles. A function can't spawn, so the plan is a list, and
// this spawns what's on it.
[After(PhysicsStages.Step), Before(Rounds)]
system Raise(Ring ring)
{
if (ring.phase != Phase.Clear) return;
for (var i = -TILES; i <= TILES; i++)
for (var j = -TILES; j <= TILES; j++)
{
var at = float2(i * TILE, j * TILE);
var reach = Math.Length(at);
if (reach > RING) continue;
Spawn(Body { type = BodyType.Static, position = float3(at.x, -0.5, at.y) },
Box { size = float3(TILE, 1, TILE), friction = 0.8 },
Tile { at = at, reach = reach, fuse = 1 + Math.Hash(int3(i, j, 7)) % 110 });
}
mut List<Brick> plan = [];
Plan(plan, ring.round + 1, Random.seed, ring.stacked);
var quarter = quaternion.AxisAngle(float3(0, 1, 0), Math.PI / 2);
foreach (var brick in plan)
{
var mass = brick.size.x * brick.size.y * brick.size.z * CRATE_DENSITY;
var turn = brick.turned ? quarter : quaternion.identity;
if (brick.kind == Kind.Keg)
Spawn(Body { position = brick.at, rotation = turn }, Box { size = brick.size, density = CRATE_DENSITY },
Crate { kind = brick.kind }, Loose { mass = mass }, Keg, Fresh);
else
Spawn(Body { position = brick.at, rotation = turn }, Box { size = brick.size, density = CRATE_DENSITY },
Crate { kind = brick.kind, tone = brick.tone }, Loose { mass = mass }, Fresh);
}
}
// What a ring is stacked of is put to sleep once it has had one step. It's
// stacked where it rests, so there's nothing for it to do until a ball gets to
// it, and left to itself Box3D would step all of it for a second before it let
// it sleep. The one step is for Box3D to find what rests on what: a stack then
// sleeps as one, and wakes as one when something hits it. Spawned asleep, each
// crate would sleep by itself, and a stack that's hit would wake a crate a
// tick, from where it was hit.
//
// It runs for the crates that were just stacked, which is none of them in
// nearly every tick, so it takes Body to read and puts the ones it changed
// back through the World: taking it `mut` would have every crate's body
// copied and compared in every tick, for the tick that writes them.
[After(PhysicsStages.Step)]
system Settle(Body body, mut World world, with Fresh)
{
if (body.id == 0) return; // Spawned this tick: its body is made in the next
mut var lulled = body;
lulled.asleep = true;
world.Set<Body>(this, lulled);
this.Remove(Fresh);
}