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.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-derby |
| Version | 0.1.0 |
| Entry point | jengine_derby::DerbyPackage |
| Script namespace | Derby |
| Depends on | jengine-online |
| Listed by | projects/car-classics, projects/rust-bowl |
Using it
Section titled “Using it”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.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
derby_arena | DerbyArena | package only: needs a v2 scene |
Script commands
Section titled “Script commands”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.
| Call | Flat 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 |
Scenes that use it
Section titled “Scenes that use it”projects/car-classics/assets/scenes/soccer.sceneprojects/car-classics/assets/scenes/soccer_online.sceneprojects/rust-bowl/assets/scenes/arena.sceneprojects/rust-bowl/assets/scenes/garage.sceneprojects/rust-bowl/assets/scenes/online.sceneprojects/rust-bowl/assets/scenes/race.sceneprojects/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.
| Module | What it is |
|---|---|
car | The handling model: tyres as friction circles, engine, gearbox, steering, weight transfer. Pure, SI units, the car’s own frame. |
damage | The eleven parts, how a hit at a point is shared among them, and what damage does to the handling. Pure. |
sim | A 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. |
ai | Bots - 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. |
arena | The bowl’s shape: wall, pillar, tyre stacks, mud. |
track | A circuit: a closed centre line from control points, walls, run-off, the grid, and where a point is along the lap. Pure. |
content | The car table (data/derby_cars) and the circuit table (data/derby_tracks), with built-ins when there is none. |
draw | Sprites, 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. |
pitch | Car 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::soccer | Car football in a Match: the ball body, teams, boost, dodges, demolitions, goals, kickoffs, overtime, the replay, the stats. |
soccer_ai | Football bots: roles agreed without talking (challenge, shadow defend, collect boost), at a skill that grades reaction, aim, boost, dodges and rotation. |
draw::pitch_draw | The 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. |
Why the package has its own rapier world
Section titled “Why the package has its own rapier world”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.
The handling model
Section titled “The handling model”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.
The damage model
Section titled “The damage model”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.
Racing
Section titled “Racing”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::wallsreturns 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.
Online
Section titled “Online”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.
Car football
Section titled “Car football”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_scale0), 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::dodgeon 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::winneris the winning team’s best player andSoccer::winner_teamthe 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.
The component
Section titled “The component”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.
Script commands
Section titled “Script commands”| Call | Does |
|---|---|
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.
What scripts read
Section titled “What scripts read”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 raceslap(completed),position,last_lap,best_lap,finished(seconds, -1 for none) andwrong_way;mode(derby/race),laps,track(id,name,length), andtracks: 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(andadvice,advice_steerwhen advising); andsoccer:score[blue, orange],time_left,overtime,stage(kickoff/play/goal/replay/over),replay(0..1),ball(x,y,speedkm/h,shown),last_goal(team,scorer,assist,speed),winner_team,pads(x,y,big,up),pitch; eventsthis frame:countdown(n),go,impact(car,other,x,y,speedm/s,damage),scrape,part_off(zone),smoke,fire,wrecked(by),lap(lap,time),finished(position,time),reset,over(winner), andstarted/refused; football addsgoal(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.rsdrives 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.rscrashes 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.rsplays 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.