Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

TrackMover

TrackMover rides an entity along a Track in track coordinates: a distance along the centerline, an offset across it, and a height above it. It has no physics body. Gravity along the slope adds speed, and drag and friction take it away, so every number that decides the ride is a prop. A sled, a racer, a rail shooter and a water slide ride this way.

new Track(points) builds a track from two or more points. The curve passes through every point. Each point may name a width in metres, blended to the next point, and 8 when left out. It may also name a zone, such as "ice", which a TrackSurface draws in its own material. The track answers the following lookups:

  • length: metres from the start to the end.
  • frameAt(distance): the position, the direction of travel (tangent), the level right, the surface’s up and the halfWidth at that distance.
  • pointAt(distance, lateral): the world point at those track coordinates, lateral positive to the right of travel.
  • locate(point): the distance and lateral of the track point nearest a world point.
  • zoneWeight(distance, zone): how much of zone the track is at that distance, from 0 to 1, blended between points.

A Track is data built once, so build it at module level or in a useMemo.

new Track(points, { friction }) gives the track a surface that slows a grounded mover by where it rides. friction({ track, distance, lateral }) returns a loss in 1/s at those track coordinates, and track.frictionAt(distance, lateral) reads it, 0 when the track has none. Each step adds it to the mover’s own friction prop, so every mover on the track rides the same surface. A kart game returns 1 on the grass past its racing line and 0 on the asphalt. Sled returns the depth of the snow under the rider times 1.14, which costs about 0.04/s on a snow lane, 0.4/s at a shoulder’s outer edge, and nothing on ice.

Unity, Unreal, Godot, Roblox and PlayCanvas read friction off the material of the collider under a body. A mover has no contact, so its track answers in track coordinates instead.

Its props are in TrackMoverProps.

The step reads the player’s steer and jump into every mover on an entity with authority={RunContext.Client}. A steer shifts it across, and releasing it holds the line it is on through a bend. At the track’s edge the outward shift stops. A jump leaves only from the ground. For any other mover, such as a rival racer, the game writes steer and jump on its TrackMoverTrait. The trait also holds the ride’s state, which a game reads for a speedometer or a landing: distance, lateral, height, speed and airSeconds.

At the track’s end the mover stops: distance holds at the track’s length and speed reads 0, so a speedometer reads the stop. Unity’s and Unreal’s spline followers stop at the spline’s end with their velocity cleared, and Godot’s PathFollow3D with loop off holds at progress_ratio 1. A game that wants its mover to come to rest before the end slows it itself, as Sled brakes a finished run, and gives the track a run-out to slow on. A game that wraps a mover back to the start, for a lap, sets its distance and its speed together as it wraps.

launchTrackMover(entity, speed) sets a mover going at speed metres a second from where it stands, and enables it: a sling’s release, a starting gate or a boost pad. The speed replaces the one it had, as Unreal’s LaunchCharacter replaces a character’s velocity. A mover mounted with enabled={false} holds at its start until the launch.

Gravity is the only speed the mover adds on its own, so a car on flat ground needs a throttle. A throttle is a game system that adds to speed before tracks.systems.move. Unity’s WheelCollider.motorTorque, Godot’s VehicleBody3D.engine_force and Roblox’s VehicleSeat.Throttle do the same job.

The following throttle reads the mover’s throttle, which the step copies from the character’s move, characters.inputs.move, as it copies the move’s x into steer. The value is 1 while the player holds W or pushes a stick up, and −1 while the player holds S. A stick pushed part of the way gives a value between the two. The player’s input describes the step’s input. A room’s step carries no input, so this throttle drives a car only in a game played alone. The tracks plugin needs characters() listed, whose move and jump it reads.

packages/engine/test/outside/throttle.ts
import { createQuery } from "koota";
import {
AuthorityTrait,
definePlugin,
RunContext,
type System,
TrackMoverTrait,
tracks,
updateEach,
} from "@spawnite/engine";
/** Metres a second squared that forward held all the way adds. */
const acceleration = 12;
const movers = createQuery(TrackMoverTrait, AuthorityTrait);
/** Speeds up the mover the player steers while she holds forward, W or a
* stick pushed up, and slows it while she holds back. */
const throttle: System = (world, { deltaSeconds }) => {
updateEach(world, movers, ([mover, { context }]) => {
if (context !== RunContext.Client) return;
const push = mover.throttle;
mover.speed = Math.max(
0,
mover.speed + push * acceleration * deltaSeconds,
);
});
};
export const car = definePlugin({
name: "car",
description: "The player's car: a throttle on forward and a brake on back.",
systems: {
motion: {
throttle: {
system: throttle,
before: [tracks.systems.move],
description:
"Speeds up the player's car on forward and slows it on back, before the move.",
},
},
},
});

To use the throttle, list car beside tracks() in the game’s plugins. The mover’s drag and friction still slow the car, so a held throttle settles at a top speed.

Client. The mover runs in the step’s move block and stands the entity at its track coordinates, facing down the track.

import { Entity, RunContext, Track, TrackMover } from "@spawnite/engine";
const run = new Track([
{ x: 0, y: 0, z: 0, width: 6 },
{ x: 8, y: -4, z: -20 },
{ x: -8, y: -8, z: -45 },
]);
export function Sled() {
return (
<Entity model="sled" authority={RunContext.Client}>
<TrackMover track={run} distance={6} gravity={15.7} />
</Entity>
);
}