Skip to content

jengine-derby

Destruction derby: cars with tyre forces, torque curves and gearboxes in the package’s own rapier world, damage by zone (corners, sides, engine, each tyre), and bots that ram.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-derby
Version0.1.0
Entry pointjengine_derby::DerbyPackage
Script namespaceDerby
Depends onjengine-online
Listed byprojects/car-classics, projects/rust-bowl

List it in the top-level "packages" array of project.jengine, together with every package it depends on: a crate dependency compiles a package in but does not register it, and packages_a_project_lists_cover_their_dependencies fails a project that lists only the outer one:

{
"packages": [
{ "name": "jengine-derby", "source": { "type": "path", "path": "../../packages/jengine-derby" } },
{ "name": "jengine-online", "source": { "type": "path", "path": "../../packages/jengine-online" } },
{ "name": "jengine-net", "source": { "type": "path", "path": "../../packages/jengine-net" } }
]
}

Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the editor’s Rebuild viewport banner). Native and web builds link it on their own.

A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field for a package component and keeps it as inert custom data.

A script can check that the package is linked with if Derby == nil then return end.

Scene keyRust type
derby_arenaDerbyArenapackage only: needs a v2 scene

Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.

CallFlat name
Derby.add_driver(e, car, control, name, skill)derby_add_driver
Derby.clear(e)derby_clear
Derby.start(e)derby_start
Derby.demo(e, cars)derby_demo
Derby.stop(e)derby_stop
Derby.pause(e, paused)derby_pause
Derby.race(e, track, laps)derby_race
Derby.arena(e)derby_arena
Derby.follow(e, camera, car, zoom)derby_follow
Derby.unfollow(e)derby_unfollow
Derby.soccer(e, pitch, seconds)derby_soccer
Derby.bind(e, action, keys)derby_bind
Derby.skip(e)derby_skip
Derby.advise(e, on)derby_advise
Derby.autopilot(e, on)derby_autopilot
Derby.seed(e, seed)derby_seed
Derby.online(e, conn)derby_online
  • projects/car-classics/assets/scenes/soccer.scene
  • projects/car-classics/assets/scenes/soccer_online.scene
  • projects/rust-bowl/assets/scenes/arena.scene
  • projects/rust-bowl/assets/scenes/garage.scene
  • projects/rust-bowl/assets/scenes/online.scene
  • projects/rust-bowl/assets/scenes/race.scene
  • projects/rust-bowl/assets/scenes/title.scene

From packages/jengine-derby/README.md.

Destruction derby, and racing: cars in a round arena trying to wreck each other, or racing laps of a circuit with the same cars and the same damage. The cars are rigid bodies driven by forces at their four tyres, with a torque curve, a gearbox and weight that moves, so they drift, spin and slide for physical reasons rather than scripted ones. Each car is eleven parts that take damage where they are hit and fail in their own ways.

Used by projects/rust-bowl (Rust Bowl) and by Car Classics’ Boost Ball (projects/car-classics, id soccer): the same cars playing car football on a pitch - see “Car football” below. Every football rule is off unless the venue is a pitch, so a derby or a race steps exactly as it always did.

ModuleWhat it is
carThe handling model: tyres as friction circles, engine, gearbox, steering, weight transfer. Pure, SI units, the car’s own frame.
damageThe eleven parts, how a hit at a point is shared among them, and what damage does to the handling. Pure.
simA match: the venue (bowl or circuit) as the package’s own rapier world, the cars, impacts, scoring, laps and positions, the countdown and the end. Knows nothing about the ECS.
aiBots - a derby driver that rams, and a racing driver that brakes for corners. Both drive the same Controls a player does, through the same physics.
arenaThe bowl’s shape: wall, pillar, tyre stacks, mud.
trackA circuit: a closed centre line from control points, walls, run-off, the grid, and where a point is along the lap. Pure.
contentThe car table (data/derby_cars) and the circuit table (data/derby_tracks), with built-ins when there is none.
drawSprites, a floor texture the skids are painted into (a painted circuit on a track), a damage skin per car, smoke and fire emitters, the cameras.
pitchCar football’s pitch: a rounded box of wall with a goal let into each end, the boost pads, kickoff spots and respawns, and SoccerRules (every number the game is played by). Pure.
sim::soccerCar football in a Match: the ball body, teams, boost, dodges, demolitions, goals, kickoffs, overtime, the replay, the stats.
soccer_aiFootball bots: roles agreed without talking (challenge, shadow defend, collect boost), at a skill that grades reaction, aim, boost, dodges and rotation.
draw::pitch_drawThe pitch on screen: painted turf and lines, team-tinted walls, nets, crossbars, crests, stands, the ball and its trail, the pads, boost flames, supersonic streaks, a camera that frames the pitch.

A package cannot step or query the runtime’s PhysicsWorld (see docs/writing-packages.md), and the car model needs both: a force at each tyre every fixed step, and every contact point’s place on the car to know which part was hit. So sim::Match owns a rapier2d world of its own, steps it at 120 Hz in metres - real masses, newtons and newton-metres - and the drawing scales to pixels after. It is also why a server room could run a match as it is: nothing in sim touches the ECS.

Each tyre is asked for a longitudinal force (engine, brakes, rolling resistance) and a lateral one (from its slip angle), and delivers at most grip * load in total. Inside that circle it grips; outside it, it slides and keeps slide_grip of the limit. That one rule is where the driving comes from:

  • a rear-wheel-drive car given more torque than its rear tyres hold spends their circle on the longitudinal force and the tail comes round - power oversteer. The muscle car has no traction control for this reason;
  • the handbrake locks the rear wheels, which slide against their own motion and hold no line;
  • weight moves: off the rear under braking, onto the outside tyres in a corner, so lifting off mid-corner can start a slide too.

Traction control (traction_control, 0..1) caps the drive at what the friction circle has left after cornering. Its first version also capped a car with none, so a muscle car in a slide cut its own power and could not oversteer; the_muscle_car_steps_out_under_power_and_the_truck_does_not is the guard. The brakes have the same rule (ABS): no wheel is braked past what its circle has left. Without it the fixed 65/35 split locked the light rear wheels under hard braking in a bend and swapped the car’s ends - the most common spin in a bot race, and no fun to meet at the end of a straight.

Tuned for fun, not for a road test (Oct 2026). The numbers were first picked as real cars’, and a real car at full lock in the bowl took four seconds to turn a right angle at 20 m/s. The cars now have about a quarter more grip, torque and slide grip, steering that turns 60% faster, and a fifth less yaw inertia: a right angle from 20 m/s in 2.4-3.0 s at about 1.1 g, 0-100 km/h in 5.3-6.1 s. Every car moved by the same factors, so the differences between them stayed - except the pickup, which kept a little of its old slowness to turn, since otherwise it won half of all bot derbies.

reset_forces is not enough. Rapier’s RigidBody::reset_forces leaves the accumulated torque alone, and a force at a tyre is a torque too. Reset without reset_torques, the yaw moment piled up step after step and every car span like a top whatever it was asked; the engine’s area_effect met the same thing. sim::Match::fixed resets both.

Four corners, two sides, the engine and four tyres. After each step every contact a car is in is read for its closing speed at the contact point, from the velocities before the step. That energy through the pair’s reduced mass is the hit, shared between the two cars (the lighter one crumples more) and placed at the contact point in each car’s own frame.

  • A corner takes a hit on it; the engine is in front, so it takes a share of anything on the nose. Driving backwards into people is the derby driver’s oldest trick, and the bots learn it when their engine is hurt.
  • A tyre takes what lands near it; a flat one grips less and drags, so the car pulls.
  • A bent front corner will not steer as far towards itself, and toes its wheel out.
  • A part at zero comes off as debris, and what lands there afterwards goes on into the engine. The engine reaching zero is the only way a car dies, so every hit leads there. An engine under half smokes; under a fifth it burns, and burns down.

A resting contact closes at nothing and costs nothing, so a car pinned to the wall is not ground down by being there.

A circuit is a handful of control points (metres, driving order, closed), a width, run-off and walls. Track::build runs a centripetal Catmull-Rom curve through the points (the uniform kind ties tight curls next to short segments, so a point placed by eye made a hairpin nobody drew), resamples it every metre, and checks it: a corner tighter than the centre-to- wall distance would fold its inside wall over itself, and two parts of the lap closer than their walls are wide would share one - both are refused at load, with where. Everything asks the resampled centre line:

  • Crossings. The one way two parts of the lap may meet is to cross: where the built line cuts itself (Track::crossings) it is a level crossing, as on a figure-eight. Each side’s wall stops where it meets the other road’s (Track::walls returns runs, not two closed loops), the ground there is road for both, and the check for parts of the lap too close skips the two roads at a crossing. A crossing under 45 degrees is a merge and is refused, and so is one on the start line or the grid. Lap counting needs nothing: a car’s place is only ever searched for near where it last was, so the other road underneath never captures it.

  • Laps and positions. Each car’s distance along the lap is projected onto the centre line every step (searching only near where it was) and added up into one running total, negative on the grid. A lap is the line crossed one more time than before, so backing over it and forward again does not count twice, and a short cut is not possible because the walls are on both sides. Positions are finishers by finishing time, then the rest by that total.

  • The bots steer at a point further along the road the faster they go, toward the inside of the next corner, and brake so that they reach every corner ahead under sqrt(grip g / curvature) - the speed at which the same tyre model slides. They pull out to pass a slower car, catch a slide by lifting off and steering the way the car is going, back out of a wall, and turn round when facing the wrong way.

  • The ground: the road, then loose run-off (runoff_grip), then the wall. A car stuck for four seconds is put back on the road.

A race ends when every car still running has finished, or thirty seconds after the winner. Damage is scaled down (damage_scale, 0.55) - contact racing, not a derby. Each person’s camera follows them with a look-ahead; the cars carry a big dot on render layer 2 (draw::MARKER_LAYER) that only a minimap camera with that layer in its cull_mask draws.

tests/race.rs checks the lap counting (once, never backwards), the wrong-way call, positions, and six bots racing every built-in circuit - the figure-eight included - to the flag with at most three rescues - which found that a start line on a corner piles the grid into it, and that a bot turning round has to back out of the wall it is facing.

A match can be played on a server (net). The server - rust-bowl-server, or any jengine-server room - runs the real Match with each person’s Controls arriving as messages (net::controls_of), and sends a net::Snapshot thirty times a second: every car’s pose and gauges, each tyre’s slip (the skids), the props and debris, the events since the last one, and each car’s health and race standing only when they changed - the socket is ordered and loses nothing, so a client keeps what it last heard (net::Sender; Sender::full for a client arriving late). Both ends build the match from one net::Start - the seats, the venue, the seed - so the walls, the grid and the props agree.

A client never steps it. Derby.online(e, conn) ties the bowl to a connection entity (jengine-online’s online component): the package reads the room’s frames from that entity’s online bag each frame - it runs just after jengine-online - builds the match on a start frame, and plays the snapshots back through net::Playback, which draws the match 85 ms behind the newest snapshot, between the two either side, with Match::show. The drawing, the skins, the skids, the sound and the view read a Match exactly as offline. Player one’s pedals (the p1_* actions) go back to the room through the connection’s queue whenever they change, and ten times a second regardless. The view adds online and you (our car, -1 watching).

Nothing is predicted: our own car is drawn as the server ran it, which on loopback is about 100 ms after the pedal and over a network the round trip more. tests/net.rs checks the client shows what the server ran - poses to 5 cm, every part’s health, every event once - and the server crate’s tests play it over real sockets.

Venue::Pitch(PitchDef) turns a match into Rocket League seen from above (Car Classics’ Boost Ball). The cars are the same tyre-force cars - the built-in football ones (content::builtin_soccer: Comet, Wedge, Brick, Dart) are light all-wheel-drive rocket cars with grip no road tyre has, geared to top out near 70 km/h so the boost is what makes a car supersonic. The first half of the lineup is blue (defending -x), the rest orange; a pitch refuses odd teams.

  • The ball is a rapier body in the match’s own world - 60 kg, 1.75 m round, bouncy (its restitution wins the combine against a car’s), damped as it rolls, spun by what it rubs and curled by its spin (a small Magnus force). A car hits it by driving into it; masses, closing speed and restitution decide how hard it flies (a car at 15 m/s sends it off at about 22). Damage is off (damage_scale 0), so the bumps dent nobody.
  • Boost: Controls::boost, a tank (34 at a kickoff), burned 33 a second for 13 m/s^2 along the nose up to 34 m/s. Pads on the turf - six big (fill it, back in 10 s) and sixteen small (+12, back in 4 s).
  • Dodge: Controls::dodge on its rising edge - a 9.5 m/s burst the way the pedals and the wheel point (forwards with nothing held), 0.42 s with no grip and no steering (the car is in the air: drawn bigger, its shadow further off), then a 0.8 s wait. A dodging car’s first touch kicks the ball along the hit - the top-down stand-in for a flip.
  • Demolition: a car at supersonic (30 m/s) that hits an opponent nose first takes it off the pitch (its body disabled) for 3 s; it comes back by its own goal.
  • Goals: the ball wholly over a line between the posts. The scoring team’s last touch scores, a teammate’s touch within 5 s before it assists; shots (a touch that sends the ball on target) and saves (one that turns a ball on target away from your goal) are counted as the touches happen. Points: goal 100, assist 50, save 50, shot 20, demolition 25, touch 2.
  • Time has three speeds: play; after a goal, 1.8 real seconds stepped at 0.3 (the explosion throws the cars); then the replay - the last three seconds before the goal, recorded every other step, written back into the cars and the ball at 0.6 speed while the match does not step, skippable (Soccer::skip). Then a kickoff: the five standard spots, picked by seed and mirrored through the centre, a 3 s countdown.
  • The clock runs only in play; at zero level it is overtime (a kickoff, then golden goal, counting up; after overtime_limit, 180 s by default, it is a draw - a match nobody can win must still end). Over, Match::winner is the winning team’s best player and Soccer::winner_team the side.

Bots (soccer_ai) rank their own team the same way each time they look - distance, facing, being goal-side, already on the way - so they agree on roles without talking: the best placed challenges (behind the ball on the line to goal, round it when on the wrong side, off the side wall at an angle, back across the goal mouth from the goal line, out to the wall when that is the only safe touch in front of its own goal; boost down the line, dodge into it when lined up), the next shadow defends (between the ball and its goal, holding still and facing it), a third collects boost then goes back post. Skill is how often it looks again (twice a second to twenty times), how far it leads the ball, its aim, how often it boosts and dodges, how it slows for a turn, and whether it rotates at all: rookies and pros chase the ball together, all-stars rotate. Teammates steer round each other. A bot that orbits a target inside its own turning circle stops and turns (it used to circle a pad for ever), one that keeps getting stuck drives straight at the ball for a while, and the_autopilot_through_keys_and_a_frame_of_lag_keeps_playing drives the advice as all-or-nothing keys a frame late, as a person’s keys reach the package natively. Measured: two all-stars beat two rookies by about two goals in 150 s (car_classics’ soccer_ai_levels_rank; tests/soccer.rs’ ignored levels and habits print the tables). An advised match also works out what an all-star would do in each person’s car (Match::advice) - a test drives by it with real keys - and autopilot drives by it (screenshots).

Online it is the same net path as a derby: Start { mode: "soccer", track: <pitch>, time_limit: <seconds> }, and every Snapshot carries a SoccerSnap (sc) - ball, score, clock, stage, pads, each car’s boost, flags and stats - so a client draws the server’s replay too (a_football_match_is_shown_from_snapshots). The controls message grows boost and dodge only when one is held, so a derby’s message is what it was; the client now sends on change at most twenty a second. A football snapshot is stamped with Match::ticks (real time) rather than steps, which slow in the slow motion and stop in the replay - stamped by steps, a client dropped every replay snapshot as stale. Car Classics’ car-classics-soccer room runs it.

derby_arena on one entity: where the bowl is drawn (centre, scale in pixels per metre), its shape (arena: radius, wall, pillar, tyre stacks, mud), its textures (floor, wall, stands, tyres, debris, wheels), time_limit (0 fights to the last car), and whether it drives the main camera (camera, between zoom_out and zoom_in; on a pitch it frames the whole pitch). For football: soccer (the pictures - ball, ball_shade, pad_big, pad_small, pad_base, net, post, flame, crest per team; a car’s art may name {team}, drawn as blue / orange), pitches (empty: the built-in Park, Neon Dome and Sunset Sands) and bindings - keys for any p<n>_* action the project’s input map lacks ("p1_boost": ["Space", "Pad0_B"]), so a hub with a shared input map need not grow fourteen actions per game.

Nothing else in a scene names those textures, so put them in a sprite_library on the same entity: every runtime preloads a library whole.

CallDoes
Derby.add_driver(e, car, control, name, skill)add a driver to the lineup: a car id from the table, "p1".."p4" or "bot", a label, and a bot’s skill 0..1
Derby.clear(e)empty the lineup
Derby.start(e)start a match with the lineup (six bots if it is empty); the lineup is kept, so calling it again is a rematch
Derby.demo(e, cars)a bots-only match that restarts itself when it ends - the attract mode
Derby.stop(e)take the match and the bowl down
Derby.pause(e, paused)hold the match
Derby.race(e, track, laps)the next start is a race on that circuit (an id from the track table) for that many laps (0: the track’s own)
Derby.arena(e)the next start is a derby in the bowl again
Derby.follow(e, camera, car, zoom)the package moves that camera: following car car (its index) a little ahead at zoom, or with car -1 showing the whole venue fitted to the camera’s part of the screen. Any follow replaces the bowl’s own framing camera.
Derby.unfollow(e)let go of every followed camera
Derby.online(e, conn)play the matches the server behind the connection entity conn runs (see Online); -1 to stop
Derby.soccer(e, pitch, seconds)the next start is car football on that pitch (an id; "" the first) for that long (0: the pitch’s own)
Derby.bind(e, action, keys)bind a p<n>_* action the project lacks: keys comma-separated ("W,Up,Pad0_RightTrigger")
Derby.skip(e)football: skip the rest of a goal replay
Derby.advise(e, on) / Derby.autopilot(e, on)football: publish an all-star’s advice for each person’s car (advice, advice_steer) / drive their cars by it
Derby.seed(e, seed)the seed the next match is dealt from (0: the clock’s)

People’s controls are not script commands. The package reads each player’s pedals from the lent InputState, through the input actions p<n>_throttle, p<n>_brake, p<n>_left, p<n>_right and p<n>_handbrake for n 1 to 4 (and p<n>_boost, p<n>_dodge for football) - so a project binds keys and pads in project.jengine (or the component’s bindings) and no script relays input every frame.

Entity.get_custom(e, "derby", field):

  • active, demo, paused, phase (countdown / fight / over), countdown, time, time_left (-1 with no limit), alive, winner (a car index, -1 for none), lineup;
  • cars, one per car in lineup order: index, name, car, car_name, control, color, alive, score, takedowns, x, y (world pixels), speed (km/h), rpm, redline, gear ("R", "1"…), throttle, sliding, health (each part by name, 1 new to 0 gone), overall, smoking, burning, art, and for races lap (completed), position, last_lap, best_lap, finished (seconds, -1 for none) and wrong_way;
  • mode (derby / race), laps, track (id, name, length), and tracks: the circuits a race can use - id, name, blurb, laps, length;
  • garage: the cars a driver can pick - id, name, blurb, art, color, mass, power (kW), torque, drive, armor, grip;
  • football, per car: team, boost, boosting, supersonic, air, demolished, respawn, goals, assists, saves, shots, demos, touches, points (and advice, advice_steer when advising); and soccer: score [blue, orange], time_left, overtime, stage (kickoff / play / goal / replay / over), replay (0..1), ball (x, y, speed km/h, shown), last_goal (team, scorer, assist, speed), winner_team, pads (x, y, big, up), pitch;
  • events this frame: countdown (n), go, impact (car, other, x, y, speed m/s, damage), scrape, part_off (zone), smoke, fire, wrecked (by), lap (lap, time), finished (position, time), reset, over (winner), and started / refused; football adds goal (team, car, assist, x, y, speed), touch, shot, save, demolished (car, by), respawn, pad_taken (big), dodge, kickoff, overtime, replay, replay_end.

Scores: a hundred points per whole part of damage dealt, 250 per wreck (the last car to hit one within eight seconds), 500 for winning.

  • tests/handling.rs drives each car in a real rapier world: every car pulls away and they do not all pull alike; a parked car stays parked; the brake stops and then reverses; the muscle car steps out under power and the truck does not; the handbrake swings the tail round; a blown tyre pulls.
  • tests/derby.rs crashes them: nose first costs the front and the engine, backwards spares the engine, a T-bone hurts the near door and credits the rammer, a car pinned to the wall is not ground down, a broken part comes off as debris, and six bots fight a match to its end. balance (ignored) plays twelve seeds and prints who wins.
  • tests/net.rs: a match run on one side and shown on another through snapshots turned to JSON and back - see Online.
  • tests/soccer.rs: car football - every built-in pitch and car is playable and every kickoff spot mirrored; the kickoff counts down; a car driven into the ball sends it on faster and feels it; a ball over the line is a goal, then slow motion, a replay that shows it going in again, and a kickoff with the clock where it was; replays skip; boost makes a car supersonic and pads refill it; a dodge bursts, flies and waits; a supersonic hit demolishes and the car comes back; level at time is overtime and the next goal wins; bots play a match and score; and a match is shown from JSON snapshots with the same score and stats. Ignored: tuning (handling numbers), levels, habits, sides, trace (JENGINE_TRACE=<csv>, a bot match to plot).
  • tests/table.rs: every row of the shipped car table drives and has its picture, every field of a car has a column, and every row of the track table builds into a circuit that passes the checks.
  • tests/race.rs: see Racing above.
  • tests/rust_bowl_scenes.rs plays Rust Bowl through its three shipped scenes, its own Lua, its own key bindings and a mouse: settings changed and saved on the title, PLAY clicked, a player joined in the garage, a match driven, paused into the settings and back, played to its results, rematched, and quit to the title - then RACE: a track and a lap count picked, two people in the garage, a half of the screen each, and a race run to its results.