Building tracks here as an AI agent
Fast On Paper is turn-based graph-paper racing, and every track in it — the shipped roster, the ones players draw in the in-game builder — is a plain JSON document. This page is the build channel: a way to write one of those documents and get straight answers about it, without pushing a single pixel around a canvas.
Its sibling /race-as-ai-agent is the racing channel. The two have deliberately opposite shapes. Racing is a turn loop, because a board is stateful and you need to know whose move it is. Building is not a loop at all: the artefact is a file, so you edit the file and ask the game what it thinks.
Everything below is live at
fastonpaper.com — no key, no
signup, no separate endpoint. The whole channel hangs off
window.VR.agent.build.
A track in one screen
const B = VR.agent.build
let def = B.starters().oval // a legal, checked track to start from
def.name = 'Hairpin Farm'
def.theme = 'forest'
def.decor.push({ type: 'barn', x: 8, y: 8 })
def = B.fit(def, { belt: 18 }).def // sheet sized: road margin + scenery ground
const r = B.check(def) // the builder's own verdicts
console.log(r.ok, r.findings, r.notes, r.stats)
await B.probe(def, 'club') // { finished, moves, crashes, stuck }
B.render(def, { width: 640 }).png // a PNG data URL — look at your work
await B.save(def, { name: def.name, author: 'your-name', draft: true })
That is the whole protocol. The rest of this page is what the fields mean, what the verdicts are worth, and the three or four things that will otherwise surprise you.
A loop that works
The calls above can be made in any order; this is the order that wastes the least time, and it is worth following the first few tracks.
- Shape the road first, alone. Path, start, laps — nothing else. Scenery on a road you are still moving is work you will do twice.
fit(), thencheck(). Clear every finding before going further. They are cheap to fix now and expensive later, because every one of them moves geometry.probe()atpro— the real question. A track that reads beautifully and cannot be got round is worth nothing, and you want to know before you dress it. Thenrookie, to see it is not merely possible for an expert.- Now dress it. Pick
theme,surfaceandscenery; let the free scatter do the bulk; place the things that say where this is.fit(def, { belt: 18 })when you want ground beyond the road to dress. render(def, { whole: true })and LOOK at it. Every time. This is where you find the village in the lake, the label over the road and the empty quarter.practice(def)and drive a lap if you can — the bots do not tell you whether a corner is a decision.save(def, { draft: true }). Often. It costs nothing and drafts are free.
One page load carries all of it. The channel holds no state between calls and needs no reload, so an iteration is one evaluation — edit the object in hand, check, probe, render, look. Tracks are built in dozens of those, not in one.
This channel is JavaScript only
The racing channel has a second door — a DOM mirror at
#agent-feed — for agents that can read a page but not
evaluate in it. There is no equivalent here, and not by oversight:
a build has no live board to mirror, it is call and response. If
you cannot evaluate JavaScript in the page, use the in-game builder
with a pointer, the way a person does.
The def
B.schema() returns this same table as data, which
is the copy to trust if the two ever disagree.
| field | type | what it is |
|---|---|---|
w, h | number | the sheet, in grid cells |
path | [[x, y], …] | the authored centreline. The drawn road is this, smoothed — think of the points as pegs the road is bent around |
closed | boolean | true = a circuit with laps;
false = a point-to-point road |
halfWidth | number | half the road width, default 2.2. A third value on a path
point — [x, y, 3.5] — widens that stretch |
start | { x, y } | where the starting grid stands. Must be ON the road |
startDir | { x, y } | the driving direction away from the line, a unit vector |
finishAnchor | [x, y] | the cell the chequered band is painted across |
decor | [{ type, x, y, … }] | what you place: s size, a turn in
radians, text on a maplabel,
pts on a shaped road piece |
river, gorge | [[x, y], …] | a shaped line right across the sheet; both ends belong
off-page (-8 and w + 8) |
lake | [[x, y], …] | a closed ring of grips, twenty by default |
sea | { side, off, style, pts } | side n|s|e|w, style
beach|front |
theme, scenery,
surface | string | the paper, the auto-scatter, the road look |
condition | string | the weather on the surface: omit for dry,
snow for a winter road (tarmac and gravel;
composes with the snow theme, winter scenery and night) |
wear | boolean | road wear — the scattered potholes, manholes, cracks and
patches on sealed tarmac. Omit for the default (worn);
false for a clean surface (a lighter look, and the
one new drivers don't misread as obstacles). Tarmac only —
gravel and slot never wear |
assetScale | number | 2: the def is authored at the current default
asset sizes — include it (the channel stamps it if you don't).
A def without it is read as older, from before the sizes were
retuned, and its assets draw at the old sizes |
kerbs, runoff,
tyrewalls | string / boolean | corner furniture. Omit any of them for auto |
smoothIters | number | how hard the path is rounded into a curve, default 3. See below — it is the strongest knob on corner character |
riverWidth, gorgeWidth |
number | how wide the water and the ravine are, in cells. Default 2.2 and 3 |
riverMouth | string | turns the river into an estuary widening into the coast on
the named edge (n/s/e/w); pair with a sea on that
side. The river runs to the opposite edge |
opener | { x0, y0, x1, y1 } | an establishing shot the race camera may zoom out to frame, past its usual floor. See below |
marginMountains | boolean | ring the sheet with mountains beyond the scatter |
noSheep | boolean | keep the country scatter from putting sheep about |
difficulty | string | Short | Medium | Long | X-Long. Stamped from the built length on save; set it only to overrule that |
seed | number | fixes the scatter and the hand-drawn wobble: same def, same seed, same picture every time |
name | string | what the track is called once it is saved |
Leave id alone — saving mints one,
and a track's id is how every other part of the game finds it.
Point-to-point craft (closed: false): run the
road off the page at BOTH ends — first and last path
points outside w/h, with
start and finishAnchor a little way in
from them — or the corridor ends in a rounded stub. The finish is
its own gate: finishAnchor stands wherever the stage
ends, and moving the start never moves it. The starting grid stacks
backwards from start and only needs to fit the
corridor — on an open road the boxes are NOT measured against the
finish. Keep the gate downstream of the start along the road;
check() tells you if it isn't.
B.vocabulary() gives the live word lists: all 181
decor types each tagged doodad / road /
label, and every legal value for theme, scenery,
surface, kerbs, run-off, river, gorge, lake, ocean and shoreline.
Ask it rather than trusting a page that may have aged.
The paper, and what grows on it
Four knobs decide how a track LOOKS, and they are independent —
the sheet it is drawn on, what the game scatters on that sheet for
free, what the road is made of, and the furniture at its corners.
Every picture below is the same little oval, changed one word at a
time, and every one of them was drawn by
VR.agent.build.render() when you opened this page.
theme — the sheet
scenery — the free scatter
The game dresses the paper around your road for nothing, in the style you name, and it never puts anything where a car will be. It is the cheapest character a track can have: one word, and a bare oval becomes a stage through woods or a desert or a raceway. What you place yourself sits on top of it, and the scatter gives your assets a wide berth.
surface — the road
kerbs, runoff, tyrewalls — the corners
Omit all three and the surface picks for itself, which is
usually right. runoff takes '' (auto) or
'none'; tyrewalls is a boolean.
The assets
181 doodles, arranged in the families the builder's own palette
uses. The name is the type you put in a
decor entry — { type: 'church', x: 12, y: 30 }
— and the picture is what the game will draw there.
Three kinds behave differently, and
vocabulary() tags each type with which it is.
Doodads take s (size, 1 is normal) and
a (turn, in radians). Roads — the whole Roads
family — take a len and an optional pts
array of shaping points, and they are drawn UNDER everything else,
which is what makes a town. Labels take the
text you want written.
And every type has a spin, which
vocabulary() serves as spin and this
sheet marks with a small ╤
after the name: upright types are drawn standing — a house
with its roof up, a penguin on its feet — so keep their
a to a small scatter tilt (±0.25 or so), because a
half-turned house is a house lying down. Everything unmarked is
drawn flat on the paper — a helicopter from above, a pond, a
bridge — and turns the whole circle freely; that includes long
roadside furniture like the grandstand and the billboard, which
you turn to face the course.
Buildings 18
Nature 21
Boundaries 6
Animals 10
Winter 8
Desert 10
Water 26
City 15
Roads 27
Industry 4
Areas 3
Airfield 7
Race day 16
People 5
Road wear 4
Labels 1
A placed asset is more than a position
Nearly every def written by hand puts down
{ type, x, y } and stops there, and it shows: a
hundred identical trees at identical size, all facing the same
way, laid out like a font. Two more fields fix it, and both are on
every doodad in the palette.
| field | who takes it | what it does |
|---|---|---|
type | all | which doodle, from the list above |
x, y | all | where, in grid cells. Fractions are fine and welcome |
s | doodads | size. 1 is normal, and the builder's own stepper
moves in steps of 0.05 with a floor of 0.4. There is no
ceiling: a mountain at 4 is a mountain, a
tree at 0.5 is a sapling |
a | all | turn, in RADIANS. The builder taps 15° at a time, the full circle either way. Omit it for upright — and on an upright-spin type (see the assets above), never go far past a tilt |
len | roads | how long the piece is. Road pieces take this INSTEAD of
s — a road's width is its identity, so stretching
it would make it a different road |
pts | roads | shaping points along a road straight, in the piece's own local frame, so a street can bend |
text | maplabel |
the word written on the map. This is the whole of that asset — a label with no text draws nothing |
seed | some | fixes the pieces that scatter their own detail, so two gravel patches side by side are not the same patch |
Vary both, always. Size and turn are what stop a placed scene reading as a rubber stamp, and they are nearly free:
// a hedge, not a row of clones
for (let i = 0; i < 9; i++) {
def.decor.push({ type: 'bush', x: 20 + i * 1.6, y: 40 + (i % 3) * 0.4,
s: 0.8 + (i % 4) * 0.15, a: (i * 0.7) % (Math.PI * 2) })
}
// four turns of one bend make a junction
{ type: 'streetbend', x: 30, y: 20, len: 6, a: Math.PI / 2 }
// a headland worth the name
{ type: 'mountain', x: 8, y: 12, s: 3.4, a: 0.2 }
A caution on a: mind each type's spin tag
(the ╤ marks on the
contact sheet above, spin in
vocabulary()). A little turn scatters a village
nicely, but an upright type past a tilt is a thing knocked
over — and that includes some you might not guess, like the boat
and the buoy, which are drawn side-on and capsize when turned.
Turn free types as far as you like: rocks, bushes, road
pieces, bridges, anything drawn from above. One special case: the
sign's post stays planted whatever you do —
its a swivels only the arrow board, so it MEANS
"which way the arrow points".
Footprints — placing linear assets without overlap
Two helpers hang off VR.doodads and answer "how much
room does this piece take". radiusOf(dec) is the
conservative circle that bounds it — one scalar in cells, what
the game itself uses to keep the auto-scatter clear of a piece.
extentOf(dec) is the tighter, oriented reading:
{ hx, hy, a }, where hx is the half-length
along the piece's own long axis, hy the half-width
across it, and a the angle it is turned by (its
dec.a). Reach for extentOf when you place a
long linear asset by hand — a pier,
vinerow, guardrail, run of
tyres, gantry, pitlane — where
the bounding circle is far bigger than the drawing:
const D = VR.doodads
// a pier reaches from the bank OUT over the water, not centred on the
// waterline — offset the anchor by its half-length along its angle
const e = D.extentOf({ type: 'pier', len: 6, a }) // { hx: 3, hy: 0.55, a }
def.decor.push({ type: 'pier', a,
x: bankX + Math.cos(a) * e.hx, y: bankY + Math.sin(a) * e.hx, len: 6 })
// a continuous planted row: tile at pitch ~2*hx, not the wider circle
const v = D.extentOf({ type: 'vinerow' }) // hx ~1.4
for (let i = 0; i < n; i++)
def.decor.push({ type: 'vinerow', x: x0 + i * 2 * v.hx, y, a })
Every type answers extentOf; a round piece returns its
circle as a square (hx === hy, a: 0).
Two knobs almost nobody finds
smoothIters is the strongest control you have
over how a corner feels, and it is a single number. Your path
is a polyline; the game rounds it into a curve by Chaikin
corner-cutting, three passes by default. Fewer passes means
sharper, more faithful to the points you actually wrote — 1 or 2
gives hard, angular, kart-track corners; 0 races the polyline
itself. More gives long flowing sweeps that ignore your intent a
little. If a corner is not the shape you meant, reach for this
before you move points around, and probe() either
side of the change.
opener is the one way to make a dressed belt pay
off at the start of a race. The camera's zoom-out floor normally
frames the road's box and nothing wider; naming a rectangle here
lets it pull back far enough to hold that rectangle instead, for
the establishing shot. Only worth setting if what is out there is
actually dressed — an opener over bare grid is a wide shot of
nothing.
What will otherwise surprise you
- y grows DOWNWARDS. Screen-style, same as the racing
channel.
y = 0is the top of the sheet. startmust be on a WHOLE cell. Not 83.4. Everything else may sit on a tenth — path points, decor, water — but the start is where twelve cars are parked, and a car moves in whole steps from wherever it is parked. Put the grid on a fraction and the entire field spends the whole race half a cell off the lattice, which is the thing the computer drivers navigate by. It does not look like a bug in the def. It looks like the AI has lost its mind: the cars converge on one square, then pick directions at random, and some of them set off backwards round your track. Three calls keep you clear of it —fit()moves a drawing by whole cells,normalize()snaps a start that sits off one, andcheck()names it if one gets through — but the def is yours, so put the grid on a whole cell.- The start element owns three path points. The grid,
the line and the arrow are one rigid piece sitting on its own
straight, and it holds three consecutive points of
pathat fixed offsets alongstartDir. Do not hand-write them: putstartandstartDirwhere you want them and callB.normalize(def), which re-asserts the piece and absorbs whatever was in its way. It returns{ def, changed }and leaves an already-canonical def untouched, so it is safe in a loop. - Path points want elbow room — about two cells. Two points closer than that fold the road surface over itself, which is the one way to make a track that draws as a blot.
- Direction changes want room too, and this is the
failure that passes
check(). Two hard turns the opposite way with almost nothing between them collapse into a double-reversal: the road nearly folds back on itself, the corridor a car needs to thread disappears, and the geometry is still perfectly legal. It draws fine. Nothing can drive it. A hairpin followed immediately by esses is the classic way to write one by accident — give a reversal a few cells of straight before the next one, and probe after every change to the shape. - The road wants four cells of air from the paper's
edge, for its kerbs, run-off and tyre walls — a habit rather
than a law, so
check()mentions it innotesrather than failing you for it. Run the road clean off the page and it still races, buttrack.boxis clamped to the sheet, so the part outside gets cropped out of every thumbnail and out of the zoomed-out view. - Scenery has no such rule — it may stand anywhere on
the sheet, right out to the corner. Which means the scenery belt
is simply a sheet bigger than the road needs: that spare
paper is what stops a wide screen, zoomed out, finding bare grid
beside your circuit.
B.fit(def, { belt: 18 })sizes it for you — road margin first, then that much ground beyond it — and grows the sheet rather than squeezing the drawing when something already stands out there.
check(def) — does it build, and is it any good
{
"ok": true,
"error": null, // makeTrack's own message when ok is false
"findings": [], // FAULTS — the builder's status chip, verbatim
"notes": [], // HABITS — true of the track without being wrong
"stats": {
"w": 33, "h": 22, "closed": true, "halfWidth": 2.2,
"length": 66.3, // the drawn centreline, in cells
"difficulty": "Short", // Short | Medium | Long | X-Long
"gridSlots": 12, // starting boxes that fit — 12 is a full field
"crossings": 0, // places the road passes over itself
"decor": 0,
"box": { "x0": 0, "y0": 0, "w": 33, "h": 22 }
}
}
findings is the list the builder paints over its own
canvas for a human, word for word: a start point off the road, a
starting grid too short for the field, road pinched too tight for
two cars, the paper margin, a self-crossing circuit. An empty
findings list is the bar. Everything in it is something a
player would feel.
notes is the other list, and the difference
matters. A finding is something a player would feel. A note is a
habit: the road inside the paper margin, a sheet snug enough that
a zoomed-out screen finds bare grid, no scenery at all. The
shipped roster breaks every one of those somewhere on purpose, so
they are said as advice and never as failure — clear
findings, then read notes and decide.
box is the rectangle the game FRAMES — the road's
own bounding box plus a band of verge, clamped to the sheet, which
is what a thumbnail crops to and what the race camera refuses to
zoom out past. Paper outside it is the ground worth dressing.
probe(def, tier) — can it actually be driven
B.probe(def, 'club')
// { tier: "club", finished: true, stuck: false, moves: 22, crashes: 0 }
One of the game's own bot drivers takes a lap for real, on the
real engine: rookie, club,
pro or ace. This is the same check the
in-game builder offers a human, and the field that matters most is
stuck — a track no driver can get round is a track
nobody will enjoy, however good it looks. A rising move count
across the tiers means the track rewards skill; a
pro that crashes where a rookie does not
usually means a corner that punishes commitment.
Trust these verdicts over geometry you derive yourself. They come out of the engine that will run the race.
Use it to localise, not just to judge. A probe is cheap
and the top two tiers are exact, so a difference between two
variants of your def is a real difference and not noise. When a
track will not go round, do not guess which corner is at fault:
build the variants, probe each, keep the one that moved the
number. Smoothing one side of a circuit taking
pro from 71 moves to 35 while softening a chicane
moves it 71 to 68 tells you exactly which change to keep — and,
just as usefully, which piece of character you can leave sharp.
cap is how many turns the driver was given (400),
and gaveUp says it used them all. A probe that gave
up was STOPPED, not beaten: its move count is the cap, not a
measurement, so read stuck and ignore the number.
Two tiers wobble and two do not. rookie and
club carry deliberate imprecision, so their move
counts move a few either way run to run; pro and
ace are deterministic and give the same answer every
time. Judge a track by pro and ace, and
read the lower two as a range.
check() is about the drawing; probe() is about the
driving. A track can pass check() with an empty
findings list and still be impossible — a corner sequence that
collapses into a double-reversal is perfectly legal geometry and
perfectly unraceable. That is not a gap in the checker: it is why
there are two calls. Probe every draft, at more than one tier, and
treat stuck as fatal.
grid(def) — the road map
{
"w": 33, "h": 22, "closed": true,
"grid": [ "#########...", "####....####" ], // h strings of w chars,
// y=0 first; "." road, "#" off-road
"finish": { "px": 17, "py": 17, // a cell on the finish line
"nx": 1, "ny": 0, // the DRIVING direction across it
"halfSpan": 2.2 }
}
Deliberately the same shape as the racing channel's
track(), so you reason about a track you are building
in the words you already race in.
render(def, opts) — look at your own work
B.render(def, { width: 640 }) // the BOX, cropped to fill
B.render(def, { width: 640, fit: true }) // the BOX, contained
B.render(def, { width: 640, whole: true }) // the whole SHEET
// -> { png: "data:image/png;base64,…", width, height }
The track, drawn by the game's own renderer. If you can see images, use this — a grid of dots will not tell you that your village is standing in the lake or that the paper is bare on one side.
Which rectangle you get is the thing most worth
understanding here. The first two framings show the box: the road's
own bounding rectangle plus a band of verge — what a picker card
crops to, and what the race camera refuses to zoom out past.
whole: true shows the entire sheet, corner to
corner.
So: if an asset seems to have vanished, it has
almost certainly not been drawn over — it is outside the box, and
off the picture. A farm, a paddock, a lighthouse and an entire
sea can all be present in the file and absent from the default
render. Check with whole: true before you go looking
for a layering bug. And do not measure the box off landmarks:
check(def).stats.box hands you
{ x0, y0, w, h } exactly.
Both facts have a consequence for where you dress. Scenery
inside the box is scenery a player will see at any zoom. Scenery
outside it appears only when the camera's own wander reaches it
(about 18 cells past the box) — which is exactly what the belt is
for, and why fit(def, { belt: 18 }) uses that
number. Beyond that band you are decorating for the thumbnail's
sake and nobody else's.
The picture is rendered at no less than about 3.4 pixels per grid cell whatever size you ask for, because below that the renderer drops the doodads and the road wear — a smaller picture would be a picture that lies about the track.
What is drawn over what
The sheet is painted in this order, and only the first two surprise anyone:
- the paper and its squares;
- the water — sea, river, gorge, lake — which is why a river runs UNDER a road rather than over it;
- road pieces you placed (the Roads family), which is what lets streets read as a town beneath the racing line;
- the racing road itself, its kerbs, run-off and tyre walls;
- everything else you placed, and the auto-scatter.
So your assets draw over the water, not under it — a lighthouse standing in the sea is drawn, a boat on the water is drawn. If a placed asset seems to have gone missing, the box above is a likelier answer than any layer. A bridge over your river is just a bridge doodad placed on it.
The three Areas slabs (asphaltarea,
gravelarea, grassarea) are opaque ground
for the big flat spaces a scatter cannot make — a car park, a paddock
apron, a mown infield. Each is a square sized by s (no
ceiling, so one at s: 3 is a whole lot): cover an area
with one big square or a few overlapping. They are the one exception
to placement order — a slab always draws BENEATH the other things you
place, so an asphaltarea pushed into decor
after a carside still sits under the car. Put them
anywhere in the list; the cars, buildings and figures on them stay on
top.
Water: rivers, gorges, lakes and a coast
All four are shapes you author, and all four are scenery — they take no notice of the road and the road takes no notice of them, so a river crossing your circuit is a decision, not an accident.
river and gorge are polylines that
must cross the WHOLE sheet: park the first and last points off the
page (-8, and w + 8 or
h + 8) or the water will visibly start in the middle
of a field. lake is a closed ring — twenty grips make
a pool that bends; eight make a polygon.
sea is the odd one, because it is a coast rather
than a shape: everything on the far side of the line is water, out
past the edge of the paper.
sea: {
side: "e", // which edge the water lies beyond: n | s | e | w
style: "beach", // "beach" sandy band, or "front" a stone quay
pts: [[44, -8], [44, 8], [46, 16], …, [44, h + 8]],
off: 8 // ONLY used when pts is absent (see below)
}
pts is the coastline, and its ends belong off the
page along the coast's own axis, exactly like a river's.
off is a fallback: with no pts it
lays a straight coast that many cells in from the named paper
edge — from the paper's edge, not from your road, which is
the trap. Give pts and the shape wins outright.
Put the coast where you want it relative to the ROAD and let
fit() size the paper around it. Both the sheet and
the belt will make room, and the coast stays put — it belongs to
the drawing, not to the page.
practice(def) — drive it yourself
B.practice(def) // straight onto the grid, no clock
let s = VR.agent.state()
while (s.phase === 'racing') {
if (!s.yourTurn) { s = await VR.agent.wait(); continue } // sitting out a crash
const near = (c) => (c.distToGo === null ? 1e9 : c.distToGo)
const ok = s.candidates.filter((c) => c.result === 'ok')
const c = (ok.length ? ok : s.candidates)
.reduce((a, b) => (near(b) < near(a) ? b : a))
s = (await VR.agent.move(c.accel.x, c.accel.y)).state
}
A practice run on an unsaved def: nothing saved, no clock,
nobody waiting. This is where the two channels meet — build it,
drive it, read distToGo along the way, edit, repeat.
/race-as-ai-agent documents everything from
state() onwards.
B.open(def) is the other door: it opens the
in-game builder on your track, so a person can take it over and
drag it about.
Saving, and what publishing means
await B.save(def, { name: 'Hairpin Farm', author: 'your-name',
draft: true }) // -> { status: "inserted", id }
await B.listMine() // your rows
await B.peek(row) // read a def, WITHOUT arming a save
await B.load(row) // a def out, and ARM save() onto this row
await B.saveTo(id, def) // save to an EXPLICIT row, not the armed one
await B.rename(id, 'Hairpin Farm II')
await B.setDraft(id, false) // <- this is a SUBMISSION
await B.remove(id)
It saves where the in-game builder saves, and with the same identity: a track you save here is a track a person opening the builder in this browser will find under My tracks.
draft is the whole of publication policy in one
boolean. true keeps the track yours. Clearing it —
draft: false, or setDraft(id, false) — is
a submission to the community tracks, the same act as a
person unticking the box on the Save sheet. Nothing appears in
front of players on that: a human reviews every submission and
approves it, or does not.
Which row a save lands on, and how not to lose a track.
save(def) writes to the row load() last
took down — loading a track of your own arms the next save
onto it, updating it in place. That is what you want while you
iterate one draft, and exactly what you do not want on a track
that is already approved and live to players: an in-place save
would change the track under them. So two guards. To look at a track
without arming anything, use peek(row) — it returns
{ def, owner } and touches no save target. And both
save() and saveTo() refuse to
overwrite an approved row ({ blocked: 'approved' })
unless you pass force: true; to carry an approved track
forward, branch a fresh draft with save(def, { copy: true })
instead. saveTo(id, def) is the explicit door: it writes
to the row you name rather than whatever happens to be armed, with
the same approved-row guard — reach for it whenever you know the id
and want no doubt about where the save goes.
A saved row carries two more fields that you cannot
write, and it is worth knowing they exist rather than
wondering why they are missing: tags, a few editorial
words about what the track is, and length, the built
centreline in cells. Both are the moderator’s — the database
refuses them from an app client at all — so there is no point
labelling your own work. The number is not a secret, though: it is
the same one check(def).stats.length hands you, next
to the Short / Medium / Long / X-Long bucket it falls
in. Measure before you submit if you meant to build a short
one.
There is a size ceiling, and it does not measure the way you
might expect. check(def).stats now reports
bytes — the def as JSON, in UTF-8 bytes — and
maxBytes, 262144. But the server's limit is
a check on the compacted jsonb the database
stores, and for a coordinate-heavy def that binary runs
larger than the JSON text — so a save can bounce with
bytes still under maxBytes. Treat roughly
130 KB of JSON text as the practical ceiling, and
check() raises a note as you approach it. If a save is
refused for size, the fix is fewer points and fewer decor entries,
not smaller numbers.
Tracks worth studying
Five of the community tracks — made in this builder, by players, out of exactly the def you are writing. Not the ones that shipped with the game: these are what the tools in your hands have actually been used to do. Each is here because it answers a different question about how far you can push them.
decor entry somebody put there. On plain
theme: "paper", so the town reads as ink on a map
rather than a field. If you want to know the ceiling, this is
it.scenery: "forest" does the bulk
for free, 170 placed pieces do the things that had to be
somewhere in particular — the pier and its boats, the ducks, the
houses at the corners — and a river runs down the
west side. A wide gravel road around real
water.theme: "snow" and scenery: "winter".
The rest is a frozen lake in the middle of the loop, penguins on
it, and a bridge over the inlet. Proof that a completely
different place is a cheap thing to
reach.probe() open, to see what that many corners costs a
driver.Shown with their authors’ names, as the Community tab shows them. You can race any of them there in the game.
Fair play
Drafts are free — save fifty, throw them away. Submissions are
not: each one costs a person their attention. So submit work you
have actually check()ed, probe()d at
more than one tier, and render()ed and looked at. Put
a name in author so it is clear who built it, and say
that it was a machine — people rather like knowing.
And build something worth driving. The engine will tell you whether a track is legal; only you can make it interesting.