Building OpenRCT2 From Scratch — A Game Development Course for Enterprise Engineers
Reading the source · v0.5.3

Building
OpenRCT2from scratch

A sixteen-lesson course in game development, taught by dismantling a real 793,000-line simulation — and rebuilding its core, system by system, in TypeScript. Written for engineers who ship CRUD apps and have never written a game loop.

16 lessons 16 interactive labs 5 units Source read at ac6030f
Rendered live with the same projection you will write in Lesson 5 tick 0
Unit 1 · Foundations — Lesson 1 of 16

Orientation: a simulation, not a game engine

Before you read a line of it, you need to know what kind of program OpenRCT2 is — because it is much closer to the systems you already build than you expect, and the parts that differ are exactly the parts worth learning.

You'll be able to: Locate any subsystem in the OpenRCT2 tree, and explain what makes it a simulation rather than a game engine.

What you are actually looking at

RollerCoaster Tycoon 2 shipped in 2002, written almost entirely in x86 assembly by Chris Sawyer. OpenRCT2 is a re-implementation of that game's engine in modern C++. It began in 2014 as a decompilation project: the original .exe was loaded into memory and its functions were replaced one at a time with C, then C++, until the new code no longer needed the old binary at all. The scars are still visible and they are useful to you — hundreds of functions carry a comment like /* rct2: 0x006B5A2A */, the address of the assembly routine they replaced.

Today it is roughly 793,000 lines across 1,469 source files, GPLv3, cross-platform, with multiplayer, a plugin API, and a headless server mode. It ships no artwork: you point it at an original RCT2 installation for the sprites and sounds. The engine is the project.

The shift from CRUD

Your enterprise app is reactive: it sleeps until a request arrives, does a unit of work inside a transaction, writes to a database, and sleeps again. State lives in Postgres. Time is whatever the clock says.

OpenRCT2 is autonomous: it wakes up 40 times a second forever and advances a world whether or not anyone touched it. State lives in one big struct in RAM. Time is an integer it increments itself. Nothing is transactional; nothing is persisted unless you ask. That single inversion — the program drives itself — is what makes game code feel alien, and it is the first thing this course fixes.

Why this is the right codebase for you

Most "learn game development" material starts with a 3D renderer, a physics middleware, and an entity-component framework, and you end up learning three vendors' APIs rather than how games work. OpenRCT2 has none of that. It is a simulation of a business with a 2D isometric renderer bolted on. It has:

  • a rich domain model (rides, guests, staff, finances, research) that you already know how to reason about;
  • hard invariants and validation — placing a track piece is closer to a bank transfer than to shooting a gun;
  • a command layer with query/execute separation that is literally CQRS;
  • deterministic replication over the network — lockstep multiplayer is an eventual-consistency problem solved the strict way;
  • a versioned, chunked, compressed persistence format with migrations.

Every one of those has a direct analogue in your day job. What's genuinely new is only four things: the loop, fixed-point determinism, the renderer, and agent AI. That's the shape of this course.

Lab 1 — Where the code actually lives
Line counts measured at commit ac6030f
rendering & drawing simulation systems & I/O legacy import / support

Hover a block. Notice the proportions: paint/ is 54% of the core library — a third of a million lines whose entire job is deciding which sprites to draw for a given track piece from four camera angles. Meanwhile peep/, which contains the guest pathfinding that defines the whole game's feel, is 3,178 lines. Line count is not importance. It is a map of where the tedium lives.

Seven shifts you will make

In your CRUD appIn OpenRCT2Lesson
Request handler fires on demandFixed 40 Hz loop runs forever2
float / decimal, IEEE 754Integers and 16.16 fixed-point only3
Rows in a table, indexed by the DBPacked 16-byte structs in one vector, hand-indexed4
The framework renders the viewYou sort every sprite yourself, back to front5–6
new / GC whenever you likeFixed-size pool, 65,535 slots, no allocation in the loop7
Service method mutates and commitsQuery() then Execute(), replicated by tick13–14
ORM migrationsChunked binary format with a min/target version pair15

Getting the real thing running

You do not strictly need to build OpenRCT2 to take this course, but reading code you can run and instrument is worth an enormous amount. On macOS with Homebrew:

terminalbuild from source, ~10 min
git clone https://github.com/OpenRCT2/OpenRCT2.git
cd OpenRCT2
brew install cmake ninja sdl2 speexdsp libpng freetype nlohmann-json zlib zstd icu4c
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DWITH_TESTS=ON
cmake --build build

# run the test suite — no game assets required for most of it
ctest --test-dir build --output-on-failure

# the headless server is the best instrument you have: no renderer,
# pure simulation, easy to attach a debugger or a profiler to
./build/openrct2-cli host my-park.park

The CMakeLists.txt exposes switches that double as a map of the optional subsystems: DISABLE_NETWORK, DISABLE_OPENGL, ENABLE_SCRIPTING (on by default), ENABLE_ASAN. If a subsystem can be compiled out, it is loosely coupled — that alone tells you a lot about the architecture.

Build it — your project, "tinypark"

Across these sixteen lessons you will build a small but genuinely correct park simulation in TypeScript, running on a <canvas>. Not a clone — a distillation. By Lesson 16 it will have a deterministic fixed-timestep loop, a packed tile world, an isometric renderer with correct depth sorting, pooled entities with a spatial index, guests that path and form opinions, a coaster with integer physics, a command layer, and a replay test that catches desyncs.

Set up now
  1. mkdir tinypark && cd tinypark && npm init -y && npm i -D typescript vite
  2. In tsconfig.json set "strict": true and "target": "ES2022".
  3. Create src/ with empty files mirroring the subsystems you'll meet: loop.ts, rng.ts, world.ts, paint.ts, entities.ts, guest.ts, path.ts, track.ts, vehicle.ts, actions.ts, save.ts.
  4. Open a second terminal on the OpenRCT2 clone. You will be reading it constantly; rg (ripgrep) is worth installing for it.

One rule, adopted from the project itself and enforced from Lesson 3 onward: no floating-point numbers anywhere in simulation code.

Check your understanding

Why do hundreds of OpenRCT2 functions carry comments like rct2: 0x006B5A2A?

Which directory is by far the largest in src/openrct2, and what does that tell you?

Unit 1 · Foundations — Lesson 2 of 16

The game loop and the fixed timestep

The single most important structural idea in the codebase: simulation time and rendering time are different clocks, and only one of them is allowed to be irregular.

You'll be able to: Separate simulation time from render time, and predict what a slow frame does to a fixed-timestep loop.

OpenRCT2 advances its world exactly 40 times per second. Not 60, not "as fast as the machine goes" — 40, because that is what RCT2 did, and every saved game, every ride rating, every guest's walking speed is calibrated to it.

src/openrct2/Game.hcondensedlines 21–32
constexpr uint32_t kGameUpdateFPS = 40;
constexpr float    kGameUpdateTimeMS = 1.0f / kGameUpdateFPS;
constexpr float    kGameUpdateMaxThreshold = kGameUpdateTimeMS * kGameMaxUpdates;
constexpr float    kNetworkUpdateTimeMS = 1.0f / kNetworkUpdateFPS;
The shift from CRUD

Think of the tick as your batch job that must run on schedule — a cron that fires every 25 ms and processes the entire domain. The frame, by contrast, is a read-only report generated from whatever the batch last produced. Reports may be skipped or duplicated; the batch may not. Once you see it that way, the accumulator pattern below is just catch-up logic for a backlogged queue.

The accumulator

Real frames arrive at unpredictable intervals. The loop banks the elapsed time and spends it in fixed 25 ms units:

src/openrct2/Context.cppcondensedUpdateTimeAccumulators / RunFixedFrame, lines 1276–1318 (condensed)
void UpdateTimeAccumulators(float deltaTime)
{
    // _timeScale is the debug/fast-forward multiplier
    float scaledDeltaTime = deltaTime * _timeScale;
    _ticksAccumulator = std::min(_ticksAccumulator + scaledDeltaTime, kGameUpdateMaxThreshold);
    //                  ^^^^^^^^ the clamp that prevents the death spiral

    _realtimeAccumulator = std::min(_realtimeAccumulator + deltaTime, kGameUpdateMaxThreshold);
    while (_realtimeAccumulator >= kGameUpdateTimeMS)
    {
        gCurrentRealTimeTicks++;          // wall-clock ticks, for UI animation
        _realtimeAccumulator -= kGameUpdateTimeMS;
    }
}

void RunFixedFrame(float deltaTime)
{
    _uiContext->ProcessMessages();

    if (_ticksAccumulator < kGameUpdateTimeMS)
    {                                     // ran early — give the CPU back
        const auto sleepTimeSec = std::min(kNetworkUpdateTimeMS, kGameUpdateTimeMS - _ticksAccumulator);
        Platform::Sleep(static_cast<uint32_t>(sleepTimeSec * 1000.f));
        return;
    }

    while (_ticksAccumulator >= kGameUpdateTimeMS)
    {
        Tick();                          // exactly one 25 ms step of the world
        _ticksAccumulator -= kGameUpdateTimeMS;
    }

    ContextHandleInput();
    WindowUpdateAll();
    if (ShouldDraw()) Draw();
}

Three details are worth more than the rest of the function:

  • The clamp is the safety valve. If a frame takes 800 ms (a stall, a breakpoint, a laptop lid closing), an unclamped accumulator would demand 32 ticks, which take longer than 800 ms to run, which grows the accumulator further — the spiral of death. Clamping to kGameUpdateMaxThreshold makes the simulation silently run slow instead of freezing. Time is allowed to be lost, never owed.
  • Sleeping when early is not optional. A busy-wait loop pins a core and melts a laptop. The sleep is capped at the network update interval so a server stays responsive between ticks.
  • Two accumulators. _ticksAccumulator is scaled by fast-forward and drives the world; _realtimeAccumulator is not, and drives UI animation. Speeding the game up must not make the window chrome flicker faster.

Interpolation: the second loop

At 40 Hz on a 120 Hz display, a naïve renderer draws the same positions three frames in a row, then jumps. OpenRCT2 fixes this with RunVariableFrame and an EntityTweener that snapshots every entity's position before and after each tick and renders somewhere between them:

src/openrct2/Context.cppcondensedRunVariableFrame, lines 1322–1357 (condensed)
while (_ticksAccumulator >= kGameUpdateTimeMS)
{
    if (shouldDraw) tweener.PreTick();     // remember where everything was
    Tick();
    _ticksAccumulator -= kGameUpdateTimeMS;
    if (shouldDraw) tweener.PostTick();    // remember where everything now is
}

if (shouldDraw)
{
    const float alpha = std::min(_ticksAccumulator / kGameUpdateTimeMS, 1.0f);
    tweener.Tween(alpha);   // lerp entity positions — VISUAL ONLY
    Draw();
}

Note carefully: alpha is a float, and that is fine, because the tweened positions are written for the renderer and restored afterwards. Floating point is allowed to touch pixels. It is never allowed to touch the simulation — Lesson 3 explains why that line matters so much.

What one tick actually does, in order

gameStateUpdateLogic() is the heart of the game. The order is not arbitrary; it encodes causality, and changing it changes the game:

src/openrct2/GameState.cppcondensedgameStateUpdateLogic, lines 251–360 (abridged)
DateUpdate(gameState);              // calendar first: everything below may ask the date
ScenarioUpdate(gameState);          // win/lose objectives
Weather::update();
MapUpdateTiles();                   // grass growth, litter decay, ride entrances

MapUpdatePathWideFlags();           // recompute "wide path" hints BEFORE peeps walk
PeepUpdateAll();                    // guests and staff decide and move
VehicleUpdateAll();                 // trains integrate physics along track
gameState.entities.UpdateAllMiscEntities();   // litter, balloons, particles
Ride::updateAll();                 // station logic, breakdowns, queues

Park::Update(park, gameState);      // park rating, guest generation, finances
ResearchUpdate();
RideRating::UpdateAll();            // amortised: a few steps of one ride's rating
RideMeasurementsUpdate();
News::UpdateCurrentItem();

gameState.entities.UpdateEntitiesSpatialIndex();
GameActions::ProcessQueue(gameState);        // player commands land at END of tick
gameState.currentTicks++;

Two design decisions to steal. First, read-then-write phases: path flags are recomputed before any guest reads them, so no guest sees a half-updated world. Second, player commands apply at the end of the tick, never in the middle — which is precisely what makes the same command produce the same result on every machine in a multiplayer game (Lesson 14).

Lab 2 — Timestep laboratory
Drag the sliders and watch the accumulator
accumulator tick executed frame drawn time discarded by clamp

Set the refresh to 12 Hz with the clamp off and a stall every 30 frames: the accumulator runs away and the tick counter lurches in bursts. Turn the clamp back on and the simulation degrades smoothly instead. Then set refresh to 144 Hz and toggle tweening to see the difference interpolation makes to the moving marker.

Build it — src/loop.ts

Write the loop before anything else; every later lesson plugs into it.

tinypark/src/loop.tsTypeScript
export const TICK_HZ = 40;
export const TICK_MS = 1000 / TICK_HZ;      // 25
const MAX_TICKS_PER_FRAME = 10;
const MAX_ACCUM = TICK_MS * MAX_TICKS_PER_FRAME;

export function runLoop(tick: () => void, draw: (alpha: number) => void) {
  let accum = 0, last = performance.now(), speed = 1;

  function frame(now: number) {
    const dt = now - last; last = now;
    accum = Math.min(accum + dt * speed, MAX_ACCUM);   // clamp: lose time, never owe it

    while (accum >= TICK_MS) { tick(); accum -= TICK_MS; }

    draw(accum / TICK_MS);                            // alpha in [0,1) — VISUAL ONLY
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
  return { setSpeed(s: number) { speed = 1 << (s - 1); } };  // 1,2,4,8,16× like OpenRCT2
}

Checkpoint: log currentTick once a second. It must read exactly 40 more than the previous second, on every machine, regardless of frame rate. If it doesn't, you have a bug that will haunt you in Lesson 14.

Check your understanding

What does std::min(_ticksAccumulator + delta, kGameUpdateMaxThreshold) protect against?

Interpolation uses a floating-point alpha. Why doesn't that break determinism?

Unit 1 · Foundations — Lesson 3 of 16

Determinism, fixed-point maths and the RNG

Same inputs, same ticks, same result — on Windows, on an M4 Mac, on a Raspberry Pi, today and in five years. Three features depend on it, and one rule buys it.

You'll be able to: Explain what determinism buys OpenRCT2, and convert between 16.16 fixed point and the decimals you are used to.

OpenRCT2 makes a promise most software never has to: given an identical starting state and an identical sequence of player commands, every machine will produce a bit-identical world. That promise is what pays for:

Multiplayer

Only commands travel over the wire — a few bytes per action instead of the world state. Lesson 14.

Replays & tests

A recorded park plus a command log is a regression test for the entire simulation. Lesson 16.

Save compatibility

A park saved in 2004 must still evolve identically. Lesson 15.

The shift from CRUD

You already know this pattern — it is event sourcing. The command log is the source of truth and the world is a fold over it. The difference is severity: in your app a replay that lands one cent off is a rounding bug you fix on Monday. Here, a single differing bit at tick 40,000 forks two players into different universes and the game detects it and disconnects them. Determinism is not a nice property; it is load-bearing.

The rule: no floats in the simulation

IEEE 754 arithmetic is almost reproducible. Almost is fatal. Compilers reassociate expressions, x87 keeps 80-bit intermediates, FMA contraction fuses a multiply-add and changes the last bit, -ffast-math rewrites your algebra, and libm's sin differs between platforms. OpenRCT2 sidesteps all of it by simply not using floats where it matters. Velocity, position, money, ratings, guest happiness — all integers.

Where fractions are genuinely needed, it uses fixed-point: an integer interpreted as having an implied binary point. Vehicle speed is 16.16 — the top 16 bits are whole miles per hour, the bottom 16 are the fraction:

src/openrct2/core/Speed.hppthe whole file
// Note: Only valid for 5 decimal places.
constexpr int32_t operator""_mph(long double speedMph)
{
    uint32_t wholeNumber = speedMph;
    uint64_t fraction = (speedMph - wholeNumber) * 100000;
    return wholeNumber << 16 | ((fraction << 16) / 100000);
}

This is a beautiful piece of engineering discipline. The literal 11.0_mph is evaluated by the compiler in long double and baked into the binary as the integer 720896. Floating point does the convenient thing at compile time; the runtime never sees it. You will find comparisons like if (velocity >= 11.0_mph) throughout Vehicle.cpp — readable source, integer semantics.

Gravity is not computed either. It is a table, indexed by the pitch of the track piece the car sits on:

src/openrct2/ride/VehicleGeometry.cppcondensedkAccelerationFromPitch, line 55
constexpr auto kAccelerationFromPitch = std::to_array<int32_t>({
    0,       // Flat
    -124548, // 1  Slope Up   12.5°
    -243318, // 2  Slope Up   25°
    -416016, // 3  Slope Up   42.5°
    -546342, // 4  Slope Up   60°
    124548,  // 5  Slope Down 12.5°
    243318,  // 6  Slope Down 25°
    // ... 25 entries, up to and past vertical
});

No sin(), no g, no cos(θ). A lookup. It is exactly reproducible, it is one memory read, and it was originally a table in Chris Sawyer's assembly for the same two reasons.

The random number generator

A deterministic simulation still needs randomness — which guest is generated, whether a ride breaks down. It just needs randomness that is a pure function of a seed that lives inside the game state. OpenRCT2 uses a two-word rotate engine that reproduces RCT2's original generator:

src/openrct2/core/Random.hppRotateEngine::operator(), lines 164–170
result_type operator()()
{
    auto s0z = s0;
    s0 += ror(s1 ^ x, r1);
    s1 = ror(s0z, r2);
    return s1;
}

Two 32-bit words of state, one xor, two rotations, one add. Statistically it is nothing special — but it is fast, seeded from the save file, and above all identical everywhere. Its state s0 is the value clients and server compare every tick to detect a desync. That is why ScenarioRand() must never be called from rendering, UI, or anything that only some players execute: calling it pulls the shared sequence out of step, and the game will notice within a second.

Lab 3 — Fixed-point and the shared RNG
Two machines, one seed

32 bits · upper 16 = whole mph · lower 16 = fraction

"Desync machine B" makes B call the RNG one extra time — the sort of thing that happens when a piece of UI code accidentally calls ScenarioRand(). Both machines are still running correct code; they are simply no longer running the same code. From that tick on, every random decision diverges.

Build it — src/rng.ts and a fixed-point convention
tinypark/src/rng.tsTypeScript
const ror = (v: number, n: number) => ((v >>> n) | (v << (32 - n))) >>> 0;

export class Rng {                       // mirrors OpenRCT2's RotateEngine
  s0: number; s1: number;
  constructor(seed = 1) { this.s0 = seed >>> 0; this.s1 = seed >>> 0; }

  next(): number {
    const s0z = this.s0;
    this.s0 = (this.s0 + ror((this.s1 ^ 0x1234567F) >>> 0, 7)) >>> 0;
    this.s1 = ror(s0z, 3);
    return this.s1;
  }
  range(n: number) { return this.next() % n; }   // integer only
}

// Fixed-point: 16.16. Store as int32, never as a JS float.
export const mph = (v: number) => Math.round(v * 65536) | 0;   // authoring-time only
export const fpMul = (a: number, b: number) => ((a * b) / 65536) | 0;
export const fpToNum = (a: number) => a / 65536;                // display only
Three rules to adopt now
  1. Every simulation value is an integer. End every arithmetic expression with | 0 or Math.trunc so a stray division can't smuggle in a float.
  2. There is exactly one Rng instance and it lives in the game state, next to currentTick. It is saved and loaded with the world.
  3. Anything that runs only on some machines — hover effects, tooltips, particle sparkle — uses Math.random(), never the game RNG. Put them in a different module so the boundary is physical, not just a convention.

Checkpoint: run 10,000 ticks twice from the same seed and hash the RNG state. Identical, or you have work to do.

Check your understanding

The _mph literal takes a long double parameter. Doesn't that reintroduce floating point into the simulation?

Why is calling ScenarioRand() from UI or rendering code a serious bug?

Unit 2 · The World — Lesson 4 of 16

The tile world: packed structs and stacked elements

A million tiles, each holding an arbitrary stack of terrain, paths, track, scenery and walls — stored in a single flat vector of 16-byte structs, with no pointers and no objects.

You'll be able to: Read a 16-byte tile element, and explain why the world is a flat vector of packed structs and not a table of rows.

The coordinate system, and its three units

Almost every confusing bug a newcomer hits in this codebase is a unit mismatch. There are three coordinate spaces and the code names them precisely:

SpaceTypeUnitExample
Tile coordsTileCoordsXY1 = one tile(12, 30)
World ("big") coordsCoordsXY, CoordsXYZ32 per tile horizontally, 8 per height step vertically(384, 960, 48)
Screen coordsScreenCoordsXYpixels; a tile is 64 × 32(576, 240)
src/openrct2/world/MapLimits.hcondensedlines 14–46 (excerpt)
constexpr int32_t  kCoordsXYStep = 32;      // world units per tile
constexpr int32_t  kCoordsZStep  = 8;       // world units per height step
constexpr uint16_t kMaximumMapSizeTechnical = 1001;   // tiles per side, incl. border
constexpr uint32_t kMaxTileElements = 0x1000000 - 512;   // ~16.7 million

Why 32 and not 1? Because sub-tile precision matters: a guest standing two-thirds of the way across a footpath needs a position, and integers with 32 steps per tile give you that without floats. The number 32 is not arbitrary either — it is a power of two, so tile ↔ world conversion is >> 5 and << 5, and ToTileStart() is a mask.

One tile is a list, not a row

A tile is not "grass with a path on it". It is an ordered stack, bottom to top: a surface, then perhaps a path, then a wall, then three pieces of scenery, then a track element passing overhead at height 96. The stack is variable length, and there are up to a million tiles.

The shift from CRUD

Your instinct is a table: tile_elements(tile_x, tile_y, ordinal, type, …) with an index on (tile_x, tile_y). That is exactly the right model — and exactly the wrong layout. The renderer walks every element of every visible tile, sixty times a second. Pointer-chasing a Vec<Box<dyn Element>> per tile would spend all its time waiting on cache misses.

So OpenRCT2 keeps the relational model and throws away the object graph: one std::vector<TileElement> holding every element on the map, contiguous, elements of the same tile adjacent, with the last one flagged. Iterating a tile is a linear scan over sequential cache lines.

src/openrct2/world/tile_element/TileElementBase.hcondensedlines 19–60 (excerpt)
constexpr uint8_t kTileElementSize = 16;
constexpr uint8_t kTileElementTypeMask      = 0b00111100;
constexpr uint8_t kTileElementDirectionMask = 0b00000011;

enum {
    TILE_ELEMENT_FLAG_GHOST     = (1 << 4),
    TILE_ELEMENT_FLAG_INVISIBLE = (1 << 5),
    TILE_ELEMENT_FLAG_LAST_TILE = (1 << 7),   // <-- the list terminator
};

#pragma pack(push, 1)
struct TileElementBase
{
    uint8_t type;            // 0  bits 2-5 = element type, bits 0-1 = direction
    uint8_t flags;           // 1  upper nibble flags, lower nibble occupied quadrants
    uint8_t baseHeight;      // 2  in kCoordsZStep units
    uint8_t clearanceHeight; // 3  top of this element's collision volume
    uint8_t owner;           // 4
    // ... 11 further bytes, reinterpreted per element type
};
#pragma pack(pop)

Three techniques are packed into those five bytes, and all three are worth stealing:

  • The type tag is four bits inside a byte you were storing anyway. getType() masks and shifts. There is no vtable, no discriminated union overhead, and the object is exactly 16 bytes so indexing is a shift.
  • The list terminator is a flag, not a length or a null pointer. Iterating a tile is: get the first element, process it, ++ptr, stop when you just processed one with LAST_TILE set. Zero indirection.
  • Downcasting is a checked reinterpret. element->as<PathElement>() returns nullptr unless the type tag matches — type safety at zero runtime cost beyond the tag compare.
src/openrct2/world/tile_element/TileElementBase.hthe as<T>() template, lines 89–96
template<typename TType>
TType* as()
{
    if constexpr (std::is_same_v<TType, TileElement>)
        return reinterpret_cast<TileElement*>(this);
    else
        return getType() == TType::kElementType
                 ? reinterpret_cast<TType*>(this) : nullptr;
}

There are exactly eight element types — surface, path, track, smallScenery, entrance, wall, largeScenery, banner — and the whole game is built from them. A roller coaster is a set of track elements. A queue is path elements with a queue bit set. The park entrance is an entrance element. There is no separate "objects" layer.

Ghosts: the preview that isn't real

TILE_ELEMENT_FLAG_GHOST deserves its own paragraph, because it solves a problem every interactive builder has. When you drag a track piece around, the game inserts a real tile element marked ghost. It renders, it collides for preview purposes, and it is excluded from saves, from network replication, and from the simulation. When you release, the ghost is removed and a real game action places the actual piece.

The alternative — a parallel "preview" data structure — would need every renderer and collision routine written twice. One flag on the existing type buys the whole feature.

Lab 4 — Tile element stack inspector
Click a tile · edit its stack · watch the bytes
surface path track scenery wall

The byte view shows the real encoding: type holds the element type in bits 2–5 and the direction in bits 0–1; flags bit 7 marks the last element of the tile. Insert into the middle of the map and watch the whole vector shift — that memmove is the price this layout pays for its cache behaviour, and it is why placement is a game action that runs once, not something the loop does per frame.

Build it — src/world.ts

TypeScript has no packed structs, but it has typed arrays, which are close enough — and the discipline of writing accessors instead of touching fields is the real lesson.

tinypark/src/world.tsTypeScript
export const TILE = 32, Z_STEP = 8, ELEM_SIZE = 8;   // bytes per element
export const enum ElemType { surface, path, track, scenery, entrance, wall }

export class World {
  readonly size: number;
  data: Uint8Array;             // one flat array: every element on the map
  index: Int32Array;            // tile -> first element offset, -1 if none

  constructor(size: number) {
    this.size = size;
    this.data = new Uint8Array(size * size * ELEM_SIZE * 4);
    this.index = new Int32Array(size * size).fill(-1);
  }

  // Iterate one tile's stack. Terminates on the LAST flag, exactly like OpenRCT2.
  *elementsAt(tx: number, ty: number) {
    let off = this.index[ty * this.size + tx];
    if (off < 0) return;
    for (;;) {
      yield off;
      if (this.data[off + 1] & 0x80) return;   // LAST_TILE
      off += ELEM_SIZE;
    }
  }

  typeOf(off: number)  { return (this.data[off] >> 2) & 0x0F; }
  dirOf(off: number)   { return this.data[off] & 0x03; }
  baseZ(off: number)   { return this.data[off + 2] * Z_STEP; }
  isGhost(off: number) { return (this.data[off + 1] & 0x10) !== 0; }
}

Checkpoint: place 200,000 elements and time a full sweep of the map. Then rewrite it as Array<Array<{type, z}>> and time that. The gap you measure is the entire argument for this layout, and you should measure it once yourself rather than take my word for it.

Check your understanding

How does the code know it has reached the end of a tile's element stack?

What problem does TILE_ELEMENT_FLAG_GHOST solve?

Unit 2 · The World — Lesson 5 of 16

Isometric projection and the paint sort

Six lines of integer arithmetic turn a 3D world into a 2D image. Deciding what order to draw it in takes the other three hundred thousand.

You'll be able to: Project a 3D world coordinate to a 2D screen position by hand, and say why draw order needs a sort and not a z-buffer.

The projection

Here is the entire 3D-to-2D transform for the whole game. There is no matrix, no camera, no perspective divide:

src/openrct2/interface/Viewport.cpplines 1926–1931
ScreenCoordsXY Translate3DTo2DWithZ(int32_t rotation, const CoordsXYZ& pos)
{
    auto rotated = pos.Rotate(rotation);
    // Use right shift to avoid issues like #9301
    return ScreenCoordsXY{ rotated.y - rotated.x,
                           ((rotated.x + rotated.y) >> 1) - pos.z };
}

Read it slowly, because everything visual in the game follows from it:

  • screenX = y - x — moving one tile east and one tile north cancels horizontally.
  • screenY = (x + y) / 2 - z — the /2 is the 2:1 isometric ratio, which is why a tile is 64 px wide and 32 px tall. Height subtracts directly: one kCoordsZStep of altitude moves a sprite up exactly one pixel.
  • The comment about >> 1 instead of / 2 is a real bug fix. For negative coordinates, C++ integer division truncates toward zero while a right shift floors. Off the edge of the map, where coordinates go negative, that one-pixel difference produced visible seams. This is what "no floats" costs you: you must think about rounding at every step.

Rotation is not a matrix either. Four camera angles, four cases, all sign swaps:

src/openrct2/world/Location.hppcondensedCoordsXY::Rotate, lines 201–224
constexpr CoordsXY Rotate(int32_t direction) const
{
    CoordsXY rotatedCoords;
    switch (direction & 3) {
        case 0: rotatedCoords.x =  x; rotatedCoords.y =  y; break;
        case 1: rotatedCoords.x =  y; rotatedCoords.y = -x; break;
        case 2: rotatedCoords.x = -x; rotatedCoords.y = -y; break;
        case 3: rotatedCoords.x = -y; rotatedCoords.y =  x; break;
    }
    return rotatedCoords;
}
The shift from CRUD

There is no scene graph and no retained render tree. Each frame the renderer walks the visible tiles and emits a fresh list of draw commands — a PaintStruct per sprite — into an arena that is thrown away at the end of the frame. It is closest to building a whole response DTO from scratch on every request rather than mutating a cached view model. Immediate mode is simpler to reason about and, at this scale, faster.

The hard part: what order?

Painter's algorithm — draw far things first — is easy when everything is a point. It is not, because a coaster support column is tall and thin, a station is four tiles long, and a guest can stand under a track. Sorting by a single depth value produces sprites poking through each other.

OpenRCT2 solves it in two stages.

Stage one: bucket by quadrant

Every sprite is hashed into one of 2,002 depth buckets by its bounding-box position, using a rotation-aware hash that keeps the value positive:

src/openrct2/paint/Paint.cppcondensedRemapPositionToQuadrant + PaintSessionAddPSToQuadrant, lines 68–107
static int32_t RemapPositionToQuadrant(const PaintStruct& ps, uint8_t rotation)
{
    const auto x = ps.Bounds.x, y = ps.Bounds.y;
    switch (rotation & 3) {
        case 0: return   x + y;
        case 1: return  (y - x) + MapRangeCenter;
        case 2: return (-(y + x)) + MapRangeMax;
        case 3: return  (x - y) + MapRangeCenter;
    }
}

static void PaintSessionAddPSToQuadrant(PaintSession& session, PaintStruct* ps)
{
    const auto positionHash = RemapPositionToQuadrant(*ps, session.CurrentRotation);
    const uint32_t paintQuadrantIndex = std::clamp(positionHash / kCoordsXYStep, 0, MaxPaintQuadrants - 1);

    ps->QuadrantIndex     = paintQuadrantIndex;
    ps->NextQuadrantEntry = session.Quadrants[paintQuadrantIndex];   // intrusive singly-linked list
    session.Quadrants[paintQuadrantIndex] = ps;

    session.QuadrantBackIndex  = std::min(session.QuadrantBackIndex,  paintQuadrantIndex);
    session.QuadrantFrontIndex = std::max(session.QuadrantFrontIndex, paintQuadrantIndex);
}

This is a bucket sort, O(n), no comparisons. The back/front indices mean the later pass only visits buckets that actually received sprites.

Stage two: bounding-box comparison within neighbouring buckets

Bucketing alone is too coarse — sprites in adjacent buckets can overlap. So within each bucket, and against the next bucket along, the arranger compares 3D bounding boxes and pulls an occluder forward. The test is generated per rotation at compile time:

src/openrct2/paint/Paint.cppcondensedCheckBoundingBox, rotation 0 case, line 341
template<uint8_t TRotation>
static bool CheckBoundingBox(const PaintStructBoundBox& initialBBox,
                             const PaintStructBoundBox& currentBBox)
{
    if constexpr (TRotation == 0)
    {
        if (initialBBox.z_end >= currentBBox.z &&
            initialBBox.y_end >= currentBBox.y &&
            initialBBox.x_end >= currentBBox.x && ...)
            return true;
    }
    // ... three more rotations, each with different comparison directions
}

It is a partial topological sort over an overlap relation, not a total order — and it can't be a total order, because three sprites can overlap cyclically. Games in this genre all end up here. What OpenRCT2 gets right is bounding the work: only nearby buckets are ever compared, so the quadratic behaviour is confined to a handful of sprites.

And the allocator

A busy frame emits tens of thousands of PaintStructs. Allocating them individually would dominate the frame time, so the session owns an arena — a fixed 1,024-entry inline buffer, spilling into segmented chunks only if a column is unusually dense — reset to zero cost at the end of every frame:

src/openrct2/paint/Paint.hcondensedPaintNodeStorage, lines 205–232 (condensed)
struct PaintNodeStorage
{
    // 1024 is typically enough to cover the column; after it is full
    // it will use dynamicPaintEntries.
    sfl::static_vector<PaintEntry, 1024> fixedPaintEntries;
    std::optional<sfl::segmented_vector<PaintEntry, 256>> dynamicPaintEntries;

    PaintEntry* allocate() {
        if (!fixedPaintEntries.full()) return &fixedPaintEntries.emplace_back();
        if (!dynamicPaintEntries.has_value()) dynamicPaintEntries.emplace();
        return &dynamicPaintEntries->emplace_back();
    }
    void clear() { fixedPaintEntries.clear(); dynamicPaintEntries.reset(); }
};
Lab 5 — Isometric renderer & depth sorting
Click to place · watch what breaks without a proper sort

Place a tall tower, then place a flat pad on the tile in front of it. With "sort by screen Y" the pad draws over the tower's base because its sprite origin sits lower on screen — the classic isometric artefact. The bucket-plus-bounding-box strategy gets it right. Then rotate the camera and note that the sort order must be recomputed entirely: this is why CheckBoundingBox is templated on rotation.

Build it — src/paint.ts
tinypark/src/paint.tsTypeScript
export function rotate(x: number, y: number, dir: number): [number, number] {
  switch (dir & 3) {
    case 1: return [ y, -x];
    case 2: return [-x, -y];
    case 3: return [-y,  x];
    default: return [ x,  y];
  }
}

// The whole camera. Note >> 1, not / 2 — negative coords must floor, not truncate.
export function worldToScreen(x: number, y: number, z: number, dir: number) {
  const [rx, ry] = rotate(x, y, dir);
  return { sx: ry - rx, sy: ((rx + ry) >> 1) - z };
}

export interface PaintStruct {
  sx: number; sy: number; sprite: number;
  bx: number; by: number; bz: number;          // bounding box origin
  bxEnd: number; byEnd: number; bzEnd: number;
}

export class PaintSession {
  buckets: PaintStruct[][] = [];
  back = Infinity; front = -1;

  add(ps: PaintStruct, dir: number) {
    const [rx, ry] = rotate(ps.bx, ps.by, dir);
    const paintQuadrantIndex = ((rx + ry) / 32) | 0;                // bucket sort, O(n)
    (this.buckets[paintQuadrantIndex] ??= []).push(ps);
    if (paintQuadrantIndex < this.back) this.back = paintQuadrantIndex;
    if (paintQuadrantIndex > this.front) this.front = paintQuadrantIndex;
  }

  *arranged() {                                   // back to front
    for (let i = this.back; i <= this.front; i++) {
      const b = this.buckets[i]; if (!b) continue;
      b.sort((a, c) => (a.bz - c.bz) || (a.by - c.by) || (a.bx - c.bx));
      yield* b;
    }
  }
  reset() { this.buckets.length = 0; this.back = Infinity; this.front = -1; }
}

Checkpoint: render a 40×40 grid of terrain with a few towers, at all four rotations, with no visible seams or sprites poking through. Draw your bounding boxes in debug mode — OpenRCT2 has exactly this switch, gPaintBoundingBoxes, and you will use yours constantly.

Check your understanding

Why does the projection use >> 1 rather than / 2?

Why bucket sprites by x + y before comparing bounding boxes?

Unit 2 · The World — Lesson 6 of 16

Sprites, palettes and dirty rectangles

How a 1999 sprite format still earns its keep — and the two tricks, palette remapping and invalidation, that let a software renderer redraw a city-sized park at 60 frames a second.

You'll be able to: Explain how palette remapping recolours a sprite for free, and identify what a dirty rectangle saves you from redrawing.

The G1 format

Every sprite in the game is a G1Element. It is tiny, and every field is doing work:

src/openrct2/drawing/G1Element.hcondensedlines 24–62
enum class G1Flag : uint8_t
{
    hasTransparency,    // contains index 0xFF pixels, which are not drawn
    one,
    hasRLECompression,  // encoded with RCT2's run-length encoding
    isPalette,          // data is a sequence of R8G8B8 palette entries
    hasZoomSprite,      // a separate, smaller sprite exists for zoomed-out views
    noZoomDraw,         // only drawn at zoom level 0
};

struct G1Element
{
    uint8_t* offset   = nullptr;   // pixel data (palette indices, not colours)
    int16_t  width    = 0;
    int16_t  height   = 0;
    int16_t  xOffset  = 0;         // where to hang the sprite relative to its anchor
    int16_t  yOffset  = 0;
    G1Flags  flags    = {};
    int32_t  zoomedOffset = 0;
};

Pixels are palette indices, one byte each, not RGB. That single decision unlocks the game's whole visual system, which we'll come back to in a moment.

Run-length encoding, and why it is really about skipping

Most of a sprite's bounding box is transparent — think of a lamppost. RCT2's RLE stores each row as a series of runs: a length byte (top bit set means "last run on this line"), an x-offset byte, then the literal pixels.

src/openrct2/drawing/Drawing.Sprite.RLE.cppcondensedDrawRLESpriteMinify, lines 96–150 (condensed)
// for each line: walk runs until the end-of-line bit is seen
isEndOfLine = (dataSize & 0x80) != 0;
int32_t numPixels = dataSize & 0x7F;
// ... clip run against the render target ...
numPixels = std::min(numPixels, width - x);

if (zoom == 1) {
    if (numPixels > 0) std::memcpy(dst, src, numPixels);   // straight blit
} else {
    while (numPixels > 0) { *dst++ = *src; src += zoom; numPixels -= zoom; }
}

The compression ratio is a side benefit. The real win is that the decoder never touches a transparent pixel — no per-pixel alpha test, no branch. Zooming out is handled in the same loop by striding the source. And notice: the whole thing is memcpy of bytes. This is a software renderer, and it is fast because the inner loop is trivial.

Palette remapping: one sprite, every colour

Because pixels are indices, you can substitute the palette per draw call. This is how one guest sprite becomes a crowd in a hundred shirt colours, how a coaster's three colour schemes work, and how the ghost preview is tinted — all without a single extra byte of sprite data.

An ImageId is therefore not just a sprite index. It packs the index plus up to two palette remaps plus flags into a single 32-bit value that is passed around the paint system. When a track piece is drawn, session.TrackColours supplies the remap; the paint function only chooses which sprite.

The shift from CRUD

This is normalisation applied to pixels. Rather than store 200 recoloured copies of a guest, store one and a colour key, and resolve at read time. Same instinct as a lookup table and a foreign key — and here it saves hundreds of megabytes of VRAM that a naïve modern engine would happily burn.

Dirty rectangles: don't redraw what didn't change

The software renderer divides the window into a grid of blocks. Anything that changes marks the blocks it covers; at frame time the engine merges adjacent dirty blocks into spans and redraws only those:

src/openrct2/drawing/InvalidationGrid.hcondensedclass outline
class InvalidationGrid
{
    uint16_t _blockWidth{}, _blockHeight{};
    uint32_t _columnCount{}, _rowCount{};
    std::vector<uint8_t> _blocks;         // one byte per block: dirty or not

public:
    void reset(int32_t width, int32_t height, uint32_t blockWidth, uint32_t blockHeight);
    void invalidate(int32_t left, int32_t top, int32_t right, int32_t bottom);
    // traverse() merges horizontally adjacent dirty blocks into one span
    // before handing each span to the drawing engine.
};

Every entity that moves calls invalidate() at its old position and its new one — that is what EntityBase::moveTo does. A paused park with nothing moving costs almost nothing to render. This is the same idea as React's reconciler or a browser's repaint regions, implemented in about 150 lines.

OpenRCT2 keeps both a software engine (X8DrawingEngine, palette-indexed, with SSE4.1 and AVX2 paths) and an OpenGL one. The software path is not legacy — it is the reference implementation, and it is what makes the game run on anything.

Lab 6 — RLE encoding, palette remap, and the dirty grid
Paint on the sprite · watch the encoding and the invalidation
dirty block merged span actually redrawn

Draw a sparse shape and watch the encoded size fall well below width × height. Then push the entity count up: as the park gets busy, dirty coverage approaches 100% and the optimisation stops paying — which is precisely why OpenRCT2 also ships an OpenGL backend for large parks.

Build it — sprites without the sprite sheet

You don't have RCT2's assets and you don't need them. Generate your sprites procedurally into an OffscreenCanvas at startup — but keep the architecture: an ImageId that carries a base index plus a remap, and an invalidation grid.

tinypark/src/sprites.tsTypeScript
// ImageId: 20 bits of index, 6 bits of primary remap, 6 of secondary.
export const imageId = (idx: number, primary = 0, secondary = 0) =>
  (idx & 0xFFFFF) | (primary & 0x3F) << 20 | (secondary & 0x3F) << 26;

export class DirtyGrid {
  private cols: number; private rows: number; private blocks: Uint8Array;
  constructor(private w: number, private h: number, private bw = 64, private bh = 64) {
    this.cols = Math.ceil(w / bw); this.rows = Math.ceil(h / bh);
    this.blocks = new Uint8Array(this.cols * this.rows);
  }
  invalidate(l: number, t: number, r: number, b: number) {
    const c0 = Math.max(0, (l / this.bw) | 0), c1 = Math.min(this.cols - 1, (r / this.bw) | 0);
    const r0 = Math.max(0, (t / this.bh) | 0), r1 = Math.min(this.rows - 1, (b / this.bh) | 0);
    for (let y = r0; y <= r1; y++)
      for (let x = c0; x <= c1; x++) this.blocks[y * this.cols + x] = 1;
  }
  // yields merged horizontal spans, exactly like InvalidationGrid::traverse
  *spans() { /* … */ }
  clear() { this.blocks.fill(0); }
}

Checkpoint: make every entity call invalidate() on both its old and its new rectangle when it moves. Then add a debug key that flashes the dirty blocks — OpenRCT2 has this too, gShowDirtyVisuals. Half the rendering bugs you will write are "something moved and forgot to invalidate", and this makes them obvious instead of mysterious.

Check your understanding

Sprite pixels are palette indices rather than RGB values. What does that buy?

What is the main runtime benefit of RCT2's RLE sprite encoding?

Unit 3 · Agents — Lesson 7 of 16

Entities: pools, handles and the spatial index

Thousands of guests, trains, litter and balloons, created and destroyed constantly, with no allocation in the hot loop and no dangling pointers. Three ideas do all the work.

You'll be able to: Choose between a pointer and a handle for an entity reference, and explain what the spatial index makes cheap.

Idea one: a fixed pool, not a heap

src/openrct2/entity/EntityRegistry.hcondensedlines 23–65 (condensed)
constexpr uint16_t kMaxEntities = 65535;

union Entity_t {
    uint8_t    Pad00[0x200];      // every entity occupies exactly 512 bytes
    EntityBase base;
};

class EntityRegistry
{
    Entity_t entities[kMaxEntities]{};                              // ~33 MB, allocated once
    std::array<std::list<EntityId>, EnumValue(EntityType::count)> gEntityLists;
    std::vector<EntityId> _freeIdList;
    std::array<std::vector<EntityId>, kSpatialIndexSize> gEntitySpatialIndex;
};

A 33 MB array, reserved up front, never grown. Every entity type — a guest, a coaster car, a piece of litter, a money-effect popup — occupies the same 512-byte slot, so a slot freed by a dead guest can hold a balloon next tick. Creation is "pop an id off the free list"; deletion is "push it back". No allocator, no fragmentation, no GC pause, and the whole world lives in one contiguous region you can memcpy to compute a checksum (which is exactly what the desync detector does in Lesson 14).

The cost is honest and visible: the largest entity type dictates the slot size for all of them, and the game has a hard ceiling of 65,535 entities. Both are deliberate. A hard limit you can plan around beats an unbounded system that degrades unpredictably at 3 a.m.

The shift from CRUD

EntityId is not a pointer — it is an index, and it behaves exactly like a surrogate primary key. You pass ids around, you look up through the registry, and a lookup can legitimately return null because the row was deleted. You already write code this way against a database; the surprise is doing it in memory, for performance and for save-file stability rather than for durability.

One consequence worth internalising: never hold a raw entity pointer across a tick. Store the id and re-resolve. Same rule as never caching a detached ORM entity across a transaction boundary.

Idea two: many views over the same storage

The pool is the storage; the access patterns get their own structures. Per-type lists (gEntityLists) let PeepUpdateAll iterate only guests. And gEntitySpatialIndex — one bucket per tile, a million of them — answers "who is standing near here?".

src/openrct2/entity/EntityRegistry.hlines 26–30
constexpr const uint32_t kSpatialIndexSize =
        (kMaximumMapSizeTechnical * kMaximumMapSizeTechnical) + 1;
constexpr uint32_t kSpatialIndexNullBucket = kSpatialIndexSize - 1;
constexpr uint32_t kInvalidSpatialIndex = 0xFFFFFFFFu;
constexpr uint32_t kSpatialIndexDirtyMask = 1u << 31;

Two details reward attention. The null bucket at the end catches entities that are off-map or being carried, so no query needs a special case. And the dirty mask in the high bit means moving an entity does not immediately rewrite the index — it just marks the entry stale, and UpdateEntitiesSpatialIndex() rebuilds what changed once per tick, at a fixed point in the update order. Deferred, batched invalidation: the same trick as marking an aggregate dirty and reindexing at commit time.

Idea three: interpolate for the eye, not for the world

EntityTweener (Lesson 2) sits on top of the pool. It records positions before and after each tick, lerps them for rendering, and calls Restore() before the next tick so the simulation never sees an interpolated value. The separation is enforced structurally rather than by discipline, which is why it holds up.

Lab 7 — Spatial index vs. brute force
Real measurements, in your browser
entity query radius found buckets visited

Push the count to 20,000 and run the benchmark. Brute force scales linearly with population; the bucketed index scales with the query area instead. This is the same argument as adding an index to a table — and the same trade: the index must be maintained on every move, which is why OpenRCT2 defers that work to one batched pass per tick.

Build it — src/entities.ts
tinypark/src/entities.tsTypeScript
export const enum EntityType { guest, vehicle, litter, count }
export const MAX_ENTITIES = 16384;

export class EntityRegistry {
  // Structure of arrays: one typed array per field, indexed by EntityId.
  type = new Uint8Array(MAX_ENTITIES);
  x    = new Int32Array(MAX_ENTITIES);
  y    = new Int32Array(MAX_ENTITIES);
  z    = new Int32Array(MAX_ENTITIES);
  alive = new Uint8Array(MAX_ENTITIES);

  private free: number[] = [];
  private lists: number[][] = [[], [], []];
  private spatial: number[][] = [];
  private dirty = true;

  constructor(readonly mapSize: number) {
    for (let i = MAX_ENTITIES - 1; i >= 0; i--) this.free.push(i);
    this.spatial = Array.from({ length: mapSize * mapSize + 1 }, () => []);
  }

  create(t: EntityType): number {
    const id = this.free.pop();
    if (id === undefined) throw new Error('entity pool exhausted');  // a real limit
    this.alive[id] = 1; this.type[id] = t; this.lists[t].push(id);
    this.dirty = true;
    return id;
  }

  remove(id: number) {
    const t = this.type[id];
    this.lists[t].splice(this.lists[t].indexOf(id), 1);
    this.alive[id] = 0; this.free.push(id); this.dirty = true;
  }

  moveTo(id: number, x: number, y: number, z: number) {
    this.x[id] = x; this.y[id] = y; this.z[id] = z;
    this.dirty = true;                    // defer: rebuilt once per tick
  }

  // Called at ONE fixed point in the tick, exactly like OpenRCT2.
  rebuildSpatialIndex() {
    if (!this.dirty) return;
    for (const b of this.spatial) b.length = 0;
    const nullBucket = this.spatial.length - 1;
    for (let id = 0; id < MAX_ENTITIES; id++) {
      if (!this.alive[id]) continue;
      const tx = this.x[id] >> 5, ty = this.y[id] >> 5;
      const ok = tx >= 0 && ty >= 0 && tx < this.mapSize && ty < this.mapSize;
      this.spatial[ok ? ty * this.mapSize + tx : nullBucket].push(id);
    }
    this.dirty = false;
  }
}

Checkpoint: spawn and destroy 100,000 entities over 10,000 ticks and confirm the free list returns to its starting length. A leak here is silent until the pool is exhausted an hour into a game.

Check your understanding

Why is Entity_t a union padded to a fixed 512 bytes?

Why does moving an entity only mark the spatial index dirty rather than update it?

Unit 3 · Agents — Lesson 8 of 16

Guests: needs, state machines and amortised updates

A guest is about a hundred bytes of integers and a switch statement. Out of that come queues, complaints, vomit, and the reason people still play this game in 2026.

You'll be able to: Trace a guest through its need-driven state machine, and calculate how often any one guest actually gets updated.

A guest is a bag of counters

src/openrct2/entity/Guest.hcondensedlines 24–38, 279–291 (excerpt)
constexpr int8_t kPeepHungerWarningThreshold = 25;
constexpr int8_t kPeepThirstWarningThreshold = 25;
constexpr int8_t kPeepToiletWarningThreshold = 28;
constexpr int    kPeepMaxHappiness = 255;

struct Guest : Peep
{
    uint8_t happiness;         uint8_t happinessTarget;
    uint8_t nausea;            uint8_t nauseaTarget;
    uint8_t hunger, thirst, toilet;
    IntensityRange      intensity{ 0 };      // preferred min..max, 4 bits each
    PeepNauseaTolerance nauseaTolerance;
    money64 cashInPocket, cashSpent;
    // ...
};

Every need is a uint8_t: 0–255, no units, no floats. Note the value/target pairshappiness and happinessTarget. Events set the target; the actual value eases toward it a step at a time. That is why a guest who gets off a great ride becomes visibly happier over several seconds rather than snapping, and it costs one extra byte and one clamp per tick. It is a one-pole filter, and it is the cheapest possible way to make numbers feel like feelings.

The state machine

PeepState has 24 values, shared by guests and staff:

src/openrct2/entity/Peep.hlines 47–73
enum class PeepState : uint8_t
{
    falling = 0,     // drowning is part of falling
    one = 1,
    queuingFront = 2,  onRide = 3,      leavingRide = 4,
    walking = 5,       queuing = 6,     enteringRide = 7,
    sitting = 8,       picked = 9,      patrolling = 10,
    mowing = 11,       sweeping = 12,   enteringPark = 13,
    leavingPark = 14,   answering = 15,  fixing = 16,
    buying = 17,        watching = 18,   emptyingBin = 19,
    usingBin = 20,      watering = 21,   headingToInspection = 22,
    inspecting = 23,
};

States that need internal sequencing get a sub-state enum — PeepRideSubState alone runs from atEntrance through freeVehicleCheck, approachVehicle, and a dozen more. This is a hierarchical state machine written by hand, with no framework, and it is the single most readable way to express "a guest is doing a long thing with steps".

The shift from CRUD

You have written this before: an order that is pending → paid → picked → shipped, with a status column and a switch. Same pattern, one difference — the transition runs on a clock, not on an event. Nobody POSTs "guest becomes hungry". The tick does, forever, for four thousand guests at once. Which raises the obvious problem…

Amortisation: not everything every tick

Cheap per-guest work (moving one step) happens every tick. Expensive work (re-evaluating needs, generating thoughts, deciding to go home) happens every 128 ticks — but the guests are staggered so that roughly 1/128th of the population is re-evaluated on any given tick:

src/openrct2/entity/Peep.cppcondensedPeepUpdateAll, lines 198–235
void PeepUpdateAll()
{
    const auto currentTicks = getGameState().currentTicks;
    constexpr auto kTicks128Mask = 128u - 1u;
    const auto currentTicksMasked = currentTicks & kTicks128Mask;

    uint32_t index = 0;
    for (auto peep : EntityList<Guest>())
    {
        if ((index & kTicks128Mask) == currentTicksMasked)   // <-- the stagger
            peep->tick128UpdateGuest(index);

        peep->update();                                   // cheap part: every tick
        index++;
    }
    // ... staff, same pattern
}

Eight lines, and they are the difference between a park that runs at 40 Hz with 5,000 guests and one that doesn't. Two properties make it work: the load is flat — never a spike where every guest thinks at once — and it is deterministic, because the stagger derives from the guest's index in the list and the tick number, not from a timer or a random offset.

Inside tick128UpdateGuest there is a second layer: some work is masked with 0x1FF so it runs on only one call in four. Three frequency tiers, one integer mask each.

How a guest decides your ride is worth it

This is the economic heart of the game and it is refreshingly blunt. Value satisfaction is a four-branch comparison of price against the ride's intrinsic value, softened by how happy the guest already is:

src/openrct2/entity/Guest.cppcondensedGuestCalculateRideValueSatisfaction, lines 2767–2791
static int16_t GuestCalculateRideValueSatisfaction(Guest& guest, const Ride& ride)
{
    if (getGameState().park.flags & PARK_FLAGS_NO_MONEY) return -30;
    if (ride.value == kRideValueUndefined)            return -30;

    auto ridePrice = RideGetPrice(ride);
    if (ride.value >= ridePrice) return -5;      // a bargain

    // happier guests tolerate a higher price
    if ((ride.value + ((ride.value * guest.happiness) / 256)) >= ridePrice) return -30;

    return 0;                                    // rip-off
}

Intensity matching is the same shape: start at a penalty of 3, and subtract one for each of three progressively wider windows the ride's intensity falls into, where each window is widened by the guest's happiness. A cheerful guest will try a scarier ride. No curves, no tuning constants beyond the ones you can read — just nested integer comparisons that a designer can reason about.

Lab 8 — A guest, tick by tick
Watch needs decay, targets ease, and states switch
happiness hunger thirst nausea toilet

The "thinking load" readout shows the amortisation at work: with 5,000 guests, roughly 39 of them re-evaluate on any given tick rather than all 5,000. Push the price above twice the ride's value and watch the guest refuse to queue — that is the literal branch ridePrice > value * 2 in Guest.cpp.

Build it — src/guest.ts
tinypark/src/guest.tsTypeScript
export const enum GuestState { walking, queuing, onRide, buying, leaving }

export interface Guest {
  id: number; state: GuestState; subState: number;
  happiness: number; happinessTarget: number;    // 0..255, eased
  nausea: number;    nauseaTarget: number;
  hunger: number; thirst: number; toilet: number;
  intensityMin: number; intensityMax: number;      // ×100 like RideRating
  cash: number;                                    // pence, integer
}

const ease = (v: number, target: number, step = 4) =>
  v < target ? Math.min(target, v + step) : Math.max(target, v - step);

// Every tick: cheap only.
export function updateGuest(g: Guest) {
  g.happiness = ease(g.happiness, g.happinessTarget);
  g.nausea    = ease(g.nausea,    g.nauseaTarget);
  // … advance one step along the current state's movement
}

// Every 128 ticks, staggered by index. Expensive: needs, thoughts, decisions.
export function tick128Guest(g: Guest) {
  if (g.hunger > 0) g.hunger--;
  if (g.thirst > 0) g.thirst--;
  if (g.toilet < 255) g.toilet++;
  if (g.hunger < 25) g.happinessTarget = Math.max(0, g.happinessTarget - 4);
  if (g.toilet > 220) g.happinessTarget = Math.max(0, g.happinessTarget - 6);
}

export function updateAllGuests(guests: Guest[], tick: number) {
  const mask = 127, phase = tick & mask;
  for (let i = 0; i < guests.length; i++) {
    if ((i & mask) === phase) tick128Guest(guests[i]);   // flat, deterministic
    updateGuest(guests[i]);
  }
}

Checkpoint: plot per-tick update time with 4,000 guests. It should be a flat line. If you see a sawtooth, your expensive work is synchronised instead of staggered — the bug this pattern exists to prevent.

Check your understanding

What does (index & 127) == (currentTicks & 127) achieve?

Why does a guest have both happiness and happinessTarget?

Unit 3 · Agents — Lesson 9 of 16

Pathfinding: the algorithm that is deliberately not A*

The most-complained-about system in the game is also its most interesting design decision. Understanding why it isn't A* will change how you think about "correct" in simulation code.

You'll be able to: Explain why the pathfinder is a bounded search and not A*, and predict where a guest will get lost.

Guests in OpenRCT2 get lost. They walk past the ride they wanted. They mill about at junctions. Players have been asking for "better pathfinding" for twenty-five years. The developers wrote a 90-line comment explaining what the algorithm does and why — worth reading in full at src/openrct2/peep/GuestPathfinding.cpp:643. The core of it:

src/openrct2/peep/GuestPathfinding.cppcondensedlines 649–676, the comment abridged
/*
 * The primary heuristic used is distance from the goal; the secondary
 * heuristic used (when the primary heuristic gives equal scores) is the number
 * of steps. i.e. the search gets as close as possible to the goal in as few
 * steps as possible.
 *
 * The implementation is a depth first search of the path layout in xyz
 * according to the search limits.
 * Unlike an A* search, which tracks for each tile a heuristic score (a
 * function of the xyz distances to the goal) and cost of reaching that tile
 * (steps to the tile), a single best result "so far" (best heuristic score
 * with least cost) is tracked via the score parameter.
 * With this approach, explicit loop detection is necessary to limit the
 * search space, and each alternate route through the same tile can be
 * returned as the best result, rather than only the shortest route with A*.
 */

What it actually does

At each junction, the guest runs a bounded depth-first probe down each available edge, and picks the edge whose probe got closest to the goal. The probe is limited three ways at once:

  • _peepPathFindNumJunctions — how many junctions a single probe may pass through;
  • _peepPathFindTilesChecked — a budget for the whole search, across all edges;
  • wide paths — a probe stops as soon as it steps onto a path tile wider than one square, because plazas would otherwise explode the search space.

Crucially, the score is only recorded at the end of a probe, not at every step. A dead-end corridor that passes very close to the goal is therefore ignored, while a corridor that continues through is followed. That single rule is what stops guests from walking confidently into cul-de-sacs.

Loop detection is a small history of "thin junctions visited with the direction taken", stored per guest — not a visited set over the whole map. Bounded memory, per agent.

The shift from CRUD

Your instinct — mine too — is that this is a bug to be fixed. It is not. Three reasons, and the third is the one worth carrying into other work:

Cost. A* per guest per junction, with 5,000 guests, would dominate the tick. This search has a hard budget and cannot blow up on a pathological park layout.

Compatibility. Change the routing and every existing park behaves differently, every replay test fails, and the game desyncs against older clients.

The behaviour is the game. Guests getting lost is what makes path layout a design problem worth solving. Perfect pathfinding would delete a whole dimension of play. Sometimes the correct engineering answer is a worse algorithm — and recognising when your "obvious optimisation" would destroy the thing users actually value is a skill that transfers well beyond games.

Lab 9 — Bounded DFS vs. A*
Draw paths, place a goal, compare the two searches
footpath guest goal chosen route tiles examined

Load the maze and run both. A* finds the optimal route and examines a lot of tiles to do it. The bounded search examines a fixed budget and often picks a decent-but-wrong first step — then re-runs at the next junction, which is how a guest eventually gets there anyway. Now drop the tile budget to 30 and watch the guest genuinely lose the plot.

Build it — src/path.ts

Implement both. You need A* as a reference to know what "optimal" was, and the bounded search as the thing you actually ship.

tinypark/src/path.tsTypeScript
interface Budget { tiles: number; junctions: number; }

// Returns the best direction to step, not a full route — exactly like ChooseDirection.
export function chooseDirection(w: World, from: Vec2, goal: Vec2, b: Budget) {
  let bestScore = Infinity, bestSteps = Infinity, bestEdge = -1;
  const budget = { ...b };

  for (const edge of openEdges(w, from)) {
    const r = probe(w, step(from, edge), goal, budget, 0, new Set());
    if (r.score < bestScore || (r.score === bestScore && r.steps < bestSteps)) {
      bestScore = r.score; bestSteps = r.steps; bestEdge = edge;   // distance, then steps
    }
  }
  return bestEdge;
}

function probe(w: World, at: Vec2, goal: Vec2, budget: Budget,
               steps: number, seen: Set<number>) {
  if (budget.tiles-- <= 0) return { score: dist(at, goal), steps };   // record at END
  if (same(at, goal))       return { score: 0, steps };

  const key = tileKey(at);
  if (seen.has(key))       return { score: Infinity, steps };   // loop detection
  seen.add(key);

  const edges = openEdges(w, at);
  if (edges.length > 2 && --budget.junctions <= 0)
    return { score: dist(at, goal), steps };                    // junction limit
  if (isWidePath(w, at))
    return { score: dist(at, goal), steps };                    // stop at plazas

  let best = { score: dist(at, goal), steps };                  // dead end scores here
  for (const e of edges) {
    const r = probe(w, step(at, e), goal, budget, steps + 1, seen);
    if (r.score < best.score || (r.score === best.score && r.steps < best.steps)) best = r;
  }
  seen.delete(key);
  return best;
}

Checkpoint: the search must be a pure function of world state — no Math.random(), no iteration over a Set whose order depends on insertion history you don't control. Run the same park twice and assert every guest takes an identical route. Pathfinding is the single most common source of desyncs in games of this kind.

Check your understanding

The probe records its score only at the end of a search path, never at each step. Why?

What is the strongest argument against replacing this search with A*?

Unit 4 · Rides — Lesson 10 of 16

Track as data: descriptors instead of code

350 track piece types across 90-odd ride types. Written as classes that would be tens of thousands of lines of near-identical code. Written as data, it is a table you can read.

You'll be able to: Read a track piece descriptor, and argue when a data table beats a class hierarchy.

The track element descriptor

A track piece is defined entirely by a constexpr struct. Here are three real entries, using C++20 designated initialisers so the table reads like a spreadsheet:

src/openrct2/ride/TrackData.cppcondensedlines 6688–6712
constexpr auto kTEDFlat = TrackElementDescriptor{
    .coordinates   = { 0, 0, 0, 0, 0, 0 },      // entry/exit offsets and directions
    .pieceLength   = 32,                       // how far a car travels along it
    .curveChain    = { TrackCurve::none, TrackCurve::none },
    .alternativeType = TrackElemType::flatCovered,
    .priceModifier = 65536,                    // 1.0 in 16.16 fixed point
    .mirrorElement = TrackElemType::flat,
    .flags         = { TrackElementFlag::allowLiftHill },
    .definition    = { TrackGroup::straight, TrackPitch::none, TrackPitch::none,
                       TrackRoll::none, TrackRoll::none, 0 },
    .sequenceData  = { 1, { kFlatSeq0 } },        // occupies 1 tile
};

constexpr auto kTEDUp25 = TrackElementDescriptor{
    .coordinates   = { 0, 0, 0, 16, 0, 0 },     // exits 16 units higher
    .pieceLength   = 33,                       // longer: it's a slope
    .priceModifier = 79872,                    // 1.22× — slopes cost more
    .flags         = { TrackElementFlag::up, TrackElementFlag::startsAtHalfHeight,
                       TrackElementFlag::allowLiftHill },
    .definition    = { TrackGroup::slope, TrackPitch::up25, TrackPitch::up25, ... },
    .sequenceData  = { 1, { kUp25Seq0 } },
};

constexpr auto kTEDUp60 = TrackElementDescriptor{
    .coordinates   = { 0, 0, 0, 64, 0, 0 },     // four times the rise
    .pieceLength   = 40,
    .priceModifier = 114688,                   // 1.75×
    .flags         = { TrackElementFlag::up, TrackElementFlag::startsAtHalfHeight,
                       TrackElementFlag::isSteepUp, ... },
};

Everything downstream reads from this one table:

  • Construction validation — can piece B follow piece A? Compare A's exit pitch and roll with B's entry pitch and roll. That is the whole rule.
  • CostbasePrice × priceModifier, in 16.16 fixed point.
  • PhysicspieceLength is the distance a car covers; the pitch indexes the gravity table from Lesson 3.
  • FootprintsequenceData lists the tiles a multi-tile piece occupies, each becoming its own track tile element with a sequence index.
  • Mirroring — a track design flipped left-to-right maps each piece through mirrorElement.
The shift from CRUD

You would reach for polymorphism: abstract class TrackPiece with 350 subclasses, or a strategy per piece. The data-table approach is the same instinct you already apply when you put pricing rules in a config table instead of an if-ladder — pushed all the way. Adding a track piece is adding a row, and a row cannot contain a bug in its control flow because it has none.

Where behaviour genuinely varies, the table holds a function pointer. Look at RideTypeDescriptor: fields like UpdateRotating, StartRideMusic, SpecialElementRatingAdjustment, GetGuestWaypointLocation all default to a shared implementation and are overridden per ride type. Strategy pattern, stored as data, resolved with no virtual dispatch.

The ride type descriptor

src/openrct2/ride/RideData.hcondensedstruct RideTypeDescriptor, lines 494–554 (excerpt)
struct RideTypeDescriptor
{
    RideCategory Category{};
    OpenRCT2::TrackElemType StartTrackPiece{};
    TrackDrawerDescriptor TrackPaintFunctions{};
    RtdFlags flags{};
    uint64_t RideModes{};                // bitmask of legal operating modes
    RideOperatingSettings OperatingSettings{};
    RideHeights Heights{};
    uint8_t MaxMass{};
    OpenRCT2::RideRating::Tuple RatingsMultipliers{};
    UpkeepCostsDescriptor UpkeepCosts{};
    RideBuildCost BuildCosts{};
    RideRatingsDescriptor RatingsData{};

    // behaviour that varies, as data:
    UpdateRotatingFunction UpdateRotating = UpdateRotatingDefault;
    StartRideMusicFunction StartRideMusic = RideAudio::DefaultStartRideMusicChannel;
    SpecialElementRatingAdjustmentFunc SpecialElementRatingAdjustment
        = SpecialTrackElementRatingsAjustment_Default;
    RideLocationFunction GetGuestWaypointLocation = GetGuestWaypointLocationDefault;
};

There is one of these per ride type, in src/openrct2/ride/rtd/, split into coaster/ (44 files), gentle/, thrill/, water/, transport/ and shops/. Reading one is the fastest way to understand what a ride type is in this game: a bundle of limits, costs, ratings weights and a handful of overridden behaviours.

How pieces connect

Each placed piece becomes one or more TrackElements on tiles, carrying its type, its sequence index within a multi-tile piece, the ride id, a station index, and a HasChain() bit for the lift hill. There is no linked list of pieces: the connection is geometric. To find the next piece, take this piece's exit coordinates and direction from its descriptor, and look up what is at that tile position and height. If the geometry lines up, they are connected.

That is why a coaster can be built by placing pieces in any order, why deleting a middle piece just breaks the circuit rather than corrupting a data structure, and why the "complete circuit" check is a traversal rather than a flag.

Lab 10 — Track builder and connection rules
Chain pieces · watch pitch continuity enforced
track station lift hill illegal connection

The dropdown greys out pieces whose entry pitch doesn't match the current exit pitch — that is the entire connection rule, read straight off the descriptor table. Cost and total length also come from the table. Build a lift hill, then a drop, and carry the layout into the next lesson: the physics runs on this exact geometry.

Build it — src/track.ts
tinypark/src/track.tsTypeScript
export const enum Pitch { down60 = -4, down25 = -2, flat = 0, up25 = 2, up60 = 4 }
export const enum Curve { left = -1, none = 0, right = 1 }

export interface TrackDescriptor {
  name: string;
  dx: number; dy: number; dz: number;      // exit offset, world units
  turn: number;                            // exit direction delta, 0..3
  entryPitch: Pitch; exitPitch: Pitch;
  pieceLength: number;                     // distance a car travels
  priceModifier: number;                   // 16.16 fixed point
  allowLift: boolean;
}

// The table IS the feature. Adding a piece is adding a row.
export const TED: Record<string, TrackDescriptor> = {
  flat:     { name: 'flat',      dx: 32, dy: 0, dz:  0, turn: 0,
              entryPitch: Pitch.flat,  exitPitch: Pitch.flat,
              pieceLength: 32, priceModifier: 65536,  allowLift: true },
  up25:     { name: 'up 25°',    dx: 32, dy: 0, dz: 16, turn: 0,
              entryPitch: Pitch.up25,  exitPitch: Pitch.up25,
              pieceLength: 33, priceModifier: 79872,  allowLift: true },
  flatToUp25:{name: 'flat → up 25°', dx: 32, dy: 0, dz: 8, turn: 0,
              entryPitch: Pitch.flat,  exitPitch: Pitch.up25,
              pieceLength: 32, priceModifier: 73728,  allowLift: true },
  down60:   { name: 'down 60°',  dx: 32, dy: 0, dz: -64, turn: 0,
              entryPitch: Pitch.down60, exitPitch: Pitch.down60,
              pieceLength: 40, priceModifier: 114688, allowLift: false },
  // … left/right quarter turns, half loops, brakes, station
};

// The entire connection rule.
export const canFollow = (prev: TrackDescriptor, next: TrackDescriptor) =>
  prev.exitPitch === next.entryPitch;

Checkpoint: place a chain of pieces from a start point and walk it back to verify it forms a closed circuit — position and direction both returning to the origin. That traversal is what OpenRCT2's TrackIteration does, and you need it for the next two lessons.

Check your understanding

How does the game know which track piece follows another?

Why does RideTypeDescriptor contain function pointers such as UpdateRotating?

Unit 4 · Rides — Lesson 11 of 16

Vehicle physics in integers

A coaster train that feels right, computed with shifts and a lookup table. No floats, no solver, no timestep dependence — and it has been consistent for twenty-four years.

You'll be able to: Compute a train's speed change over one tick in integers, and explain why the physics is timestep-independent.

The integrator

Strip away the special cases and the whole thing is semi-implicit Euler on integers:

src/openrct2/ride/Vehicle.TrackMotion.cppcondensedVehicle::UpdateVelocity, lines 284–304
void Vehicle::UpdateVelocity()
{
    int32_t nextVelocity = acceleration + velocity;
    if (flags.has(VehicleFlag::stoppedBySafetyCutout)) nextVelocity = 0;
    // ... holding brake handling ...
    velocity = nextVelocity;

    _vehicleVelocityF64E08 = nextVelocity;
    _vehicleVelocityF64E0C = (nextVelocity >> 10) * 42;   // velocity → distance this tick
}

velocity is 16.16 fixed-point mph. The magic number 42 and the shift by 10 together convert speed into "track units travelled per tick" at 40 Hz. Because the timestep is fixed, this conversion is a constant. Fixed timestep is what makes integer physics possible — a variable timestep would need a multiply by dt and you would be back to floats. The two decisions support each other.

Where acceleration comes from

Three sources, combined per tick, all integer:

1. Gravity — a table lookup
src/openrct2/ride/Vehicle.TrackMotion.cppline 1398
car.acceleration = Geometry::getAccelerationFromPitch(car.pitch);
// -> kAccelerationFromPitch[pitch]: 0 flat, -243318 up 25°, +243318 down 25°

Note the sign: uphill is negative acceleration. And note what's absent — no mass term. Gravity is independent of mass, and the table encodes g·sin(θ) once, at authoring time.

2. Drag — two terms, one linear and one quadratic
src/openrct2/ride/Vehicle.cppcondensedGetAccelerationDecrease2, lines 90–105
int32_t GetAccelerationDecrease2(const int32_t velocity, const int32_t totalMass)
{
    int32_t accelerationDecrease2 = velocity >> 8;
    accelerationDecrease2 *= accelerationDecrease2;          // v², air resistance
    if (velocity < 0) accelerationDecrease2 = -accelerationDecrease2;
    accelerationDecrease2 >>= 4;
    if (totalMass != 0) return accelerationDecrease2 / totalMass;   // heavier = less affected
    return accelerationDecrease2;
}

The >> 8 before squaring is not cosmetic — it keeps inside an int32_t. Overflow discipline is a permanent tax on integer physics, and this is what paying it looks like.

3. The train, aggregated
src/openrct2/ride/Vehicle.TrackMotion.cppcondensedlines 1536–1562
int32_t totalAcceleration = 0, totalMass = 0, numVehicles = 0;
for (; vehicle != nullptr; vehicle = GetEntity<Vehicle>(vehicle->next_vehicle_on_train))
{
    numVehicles++;
    totalMass         += vehicle->mass;
    totalAcceleration += vehicle->acceleration;
}

int32_t newAcceleration = (totalAcceleration / numVehicles) * 21;
if (newAcceleration < 0) newAcceleration += 511;    // round toward zero symmetrically
newAcceleration >>= 9;                             // (× 21/512 ≈ × 0.041)

int32_t curAcceleration = newAcceleration;
curAcceleration -= vehicle->velocity / 4096;                            // rolling resistance
curAcceleration -= GetAccelerationDecrease2(vehicle->velocity, totalMass);  // air drag
vehicle->acceleration = curAcceleration;

This is the line that makes coasters feel right. Every car computes its own gravity from the pitch of the piece it is on, and the train's acceleration is the mean. A train half over the crest of a hill is genuinely pulled by its front and held by its back. The emergent behaviour — cresting slowly, then accelerating as more cars tip over — falls out of averaging, not from any code that models it.

And that += 511 before >>= 9: arithmetic right shift of a negative number rounds toward negative infinity, which would bias deceleration. Adding 2⁹ - 1 first makes negative values round toward zero, matching the positive case. Small, invisible, and the physics is asymmetric without it.

The shift from CRUD

The closest thing you have written is money arithmetic: integer pence, explicit rounding at defined points, never a double. Coaster physics is money arithmetic where the rounding mode changes the ride. Same discipline, higher stakes — and the reward is the same one you get from integer money: a number you can reproduce, test, and reason about years later.

Lab 11 — Coaster physics sandbox
Integer integrator, real constants
velocity height vertical G

The two airtime hills are fixed at 22 m and 13 m, so the lift hill has to be tall enough to carry the train over them. Start low and raise it until the train stops valleying, then add cars: the train gets heavier and longer, so more of it is on the slope at once. Finally toggle "Use floats" and dispatch again from an identical state. The difference is small — a tick or two on the circuit time, a few hundred units of raw velocity — and that is exactly the point. It is not a visible glitch; it is a silent one-bit disagreement, which in lockstep multiplayer is a disconnected player.

Build it — src/vehicle.ts
tinypark/src/vehicle.tsTypeScript
// Gravity by pitch, in the same spirit as kAccelerationFromPitch.
const ACCEL_FROM_PITCH: Record<number, number> = {
  [-4]:  546342, [-2]:  243318, [0]: 0, [2]: -243318, [4]: -546342,
};

export interface Car { pitch: number; mass: number; acceleration: number; progress: number; }
export interface Train { cars: Car[]; velocity: number; acceleration: number; }

const dragQuadratic = (velocity: number, totalMass: number) => {
  let d = velocity >> 8;
  d = d * d;                                  // keep it inside int32
  if (velocity < 0) d = -d;
  d >>= 4;
  return totalMass !== 0 ? (d / totalMass) | 0 : d;
};

export function updateTrain(t: Train) {
  let totalAcc = 0, totalMass = 0;
  for (const c of t.cars) {
    c.acceleration = ACCEL_FROM_PITCH[c.pitch] ?? 0;   // per-car pitch: the key idea
    totalAcc  += c.acceleration;
    totalMass += c.mass;
  }

  let a = ((totalAcc / t.cars.length) | 0) * 21;
  if (a < 0) a += 511;                              // symmetric rounding
  a >>= 9;

  a -= (t.velocity / 4096) | 0;                     // rolling resistance
  a -= dragQuadratic(t.velocity, totalMass);        // air drag

  t.acceleration = a;
  t.velocity = (t.velocity + a) | 0;                 // semi-implicit Euler

  const distance = ((t.velocity >> 10) * 42) | 0;     // fixed 25 ms step baked in
  for (const c of t.cars) c.progress += distance;
}

Checkpoint: a train released from a 40 m hill onto flat track must stop in the same place every run, and the same place after you save and reload mid-run. If it doesn't, a float or an unrounded division has crept in.

Check your understanding

Why is a train's acceleration the average of its cars' accelerations?

What is if (newAcceleration < 0) newAcceleration += 511; before >>= 9 for?

Unit 4 · Rides — Lesson 12 of 16

Ratings and the economy: simulation spread over time

Excitement, Intensity, Nausea — three numbers that drive the entire economy, computed by a state machine that deliberately takes several seconds to finish.

You'll be able to: Explain how excitement, intensity and nausea drive the economy, and why the calculation is spread across ticks.

Three numbers, two decimal places, stored as integers

src/openrct2/ride/RideRatings.hcondensedlines 36–52
#pragma pack(push, 1)
struct Tuple
{
    RideRating_t excitement{};
    RideRating_t intensity{};
    RideRating_t nausea{};
};
static_assert(sizeof(Tuple) == 6);
#pragma pack(pop)

A rating of 7.42 is stored as the integer 742. Six bytes for the whole reputation of a ride. Every guest decision in Lesson 8 reads these.

The calculation is a coroutine, written before coroutines

Computing a rating means walking the entire track, sampling what is near each piece — scenery, water, other track, the ground — and accumulating proximity scores. On a large coaster that is far too much work for one tick. So the calculation is a resumable state machine that keeps its position in the world between ticks:

src/openrct2/ride/RideRatings.hcondensedstruct UpdateState, lines 53–70
struct UpdateState
{
    CoordsXYZ Proximity;              // where the scan currently is
    CoordsXYZ ProximityStart;
    RideId    CurrentRide;
    uint8_t   State;                  // which step of the machine
    TrackElemType ProximityTrackType;
    uint8_t   ProximityBaseHeight;
    uint16_t  ProximityTotal;
    uint16_t  ProximityScores[26];      // 26 categories of nearby thing
    uint16_t  AmountOfBrakes, amountOfBoosters, AmountOfReversers;
    uint16_t  StationFlags;
};

static constexpr size_t kMaxUpdateStates = 4;   // four rides in flight at once
using UpdateStates = std::array<UpdateState, kMaxUpdateStates>;
src/openrct2/ride/RideRatings.cppcondensedRideRating::UpdateAll, lines 198–216
void RideRating::UpdateAll()
{
    for (auto& updateState : getGameState().rideRatingUpdateStates)
    {
        for (size_t i = 0; i < MaxRideRatingUpdateSubSteps; ++i)
        {
            ride_ratings_update_state(updateState);
            if (updateState.State == RIDE_RATINGS_STATE_FIND_NEXT_RIDE) break;
        }
    }
}

Six states — find next ride, initialise, scan, calculate, finalise — advanced a few sub-steps per tick, with a fixed budget of four concurrent calculations. This is the same amortisation idea as the guest stagger, but for a job too big to split by population. It is a coroutine with the state hoisted into a struct, and it is saved with the game: reload a park mid-calculation and it resumes exactly where it left off.

A player-visible consequence, and a nice example of a mechanic falling out of an implementation detail: modify a coaster and its rating takes a few seconds to update. Everyone reads that as the game "thinking". It is really a work budget.

The shift from CRUD

You have solved this exact problem: the report that is too slow for the request, so you queue a job, store progress in a table, and let a worker chip away at it. Same structure, three differences — the "worker" is a slot in the tick, the "queue" is a fixed array of four, and the job state is part of the durable snapshot rather than a side table. Bounded concurrency, resumable work, progress in the state: you already know this pattern.

The economic loop

The three ratings feed a cycle that is the actual game:

StepMechanismLives in
Ratings set the ride's valueexcitement weighted by the ride type's RatingsMultipliersride/RideRatings.cpp
Guests compare price to valueridePrice > value * 2 → refuse outrightentity/Guest.cpp:2219
Riding changes happiness & nauseavalue satisfaction + intensity/nausea matchentity/Guest.cpp:2718
Happiness feeds park ratingaveraged over guests, plus litter and queue penaltiespark/
Park rating gates guest generationhigher rating → more arrivals per tickPark::Update
More guests → more income → more ridesand back to the top

Every arrow is a handful of integer comparisons. There is no economic model, no equilibrium solver — just a feedback loop with enough delay in it (ratings lag, happiness eases, park rating averages) to feel organic. Delay is what makes feedback loops feel alive; remove it and the same rules produce a twitchy, gameable system.

Lab 12 — Ratings, pricing and the feedback loop
Set a price, watch the park respond over a simulated year
guests in park income / month park rating

Find the price that maximises income — then raise intensity past 10 and watch the same price become unprofitable, because most guests now refuse on the intensity check before price is even considered. Notice the lag: a change takes a simulated month to show up in attendance. That delay is the loop's damping, and it is why the game is playable.

Build it — src/ratings.ts and the loop
  1. Write ratingsStep(state) as a switch on state.step, advancing the track scan by a handful of tiles per call and returning when the budget is spent.
  2. Keep an array of four in-flight states, exactly like kMaxUpdateStates. Skip rides that are closed, already updating, or have frozen ratings.
  3. Serialise the states with the save file (Lesson 15) and confirm a mid-calculation save/load resumes rather than restarting.
  4. Wire the loop: value → guest decision → happiness → park rating → guest generation. Add delay at each stage deliberately.

Checkpoint: plot per-tick time as you add rides. It must stay flat. If ratings work scales with ride count, your budget isn't a budget.

Check your understanding

Why is ride rating calculation spread across many ticks?

What makes the park's economic feedback loop feel organic rather than twitchy?

Unit 5 · Systems — Lesson 13 of 16

Game actions: CQRS, with the stakes turned up

Every change a player can make to the world goes through one abstraction. It validates, it prices, it replicates, it records, it undoes — and you have written its cousin a dozen times.

You'll be able to: Model a player change as a game action, and explain what validate/execute buys that a direct mutation does not.

There are 179 files under src/openrct2/actions/. Placing a path, renaming a ride, hiring a mechanic, setting a price, firing a cheat — all of it is a GameAction. Nothing else is allowed to mutate the world.

src/openrct2/actions/GameAction.hppcondensedlines 29–37, 46–182 (condensed)
namespace Flags {
    constexpr uint16_t AllowWhilePaused = 1 << 0;
    constexpr uint16_t ClientOnly       = 1 << 1;   // never sent over the network
    constexpr uint16_t EditorOnly       = 1 << 2;
    constexpr uint16_t IgnoreForReplays = 1 << 3;
}

class GameAction
{
    GameCommand const _type;
    Network::PlayerId_t _playerId = { -1 };
    CommandFlags _flags = {};
    uint32_t _networkId = 0;
    Callback_t _callback;

public:
    // Serialise the action's parameters, for network and for replays.
    virtual void Serialise(DataSerialiser& stream);

    // Expose parameters by name, so plugins can build actions dynamically.
    virtual void AcceptParameters(GameActionParameterVisitor&) {}

    // Can this happen, and what would it cost? MUST NOT mutate anything.
    virtual Result Query(GameState_t& gameState, Park::ParkData& park) const = 0;

    // Do it. Called only after Query returned ok.
    virtual Result Execute(GameState_t& gameState, Park::ParkData& park) const = 0;
};
The shift from CRUD

This is CQRS with a command bus, and the mapping is close to exact: Query is your validation pass returning a price quote; Execute is the handler; Result is your typed error envelope; Serialise is your DTO; AcceptParameters is reflection for the API layer; the action queue is your message bus.

Two things are stricter than in your world, and both are worth noticing. There is no transaction to roll back — no BEGIN, no undo log — so Query must be exhaustive, because once Execute starts mutating, a failure leaves the world half-changed. And Query must be pure to the bit: it runs speculatively on the client for the cost tooltip, so a stray ScenarioRand() in it would desync the game (Lesson 3).

A complete action, start to finish

RideSetNameAction is the smallest one that shows every part of the contract:

src/openrct2/actions/ride/RideSetNameAction.cppcondensedlines 28–95 (abridged)
void RideSetNameAction::AcceptParameters(GameActionParameterVisitor& visitor)
{
    visitor.Visit("ride", _rideIndex);      // plugins can now call this by name
    visitor.Visit("name", _name);
}

uint16_t RideSetNameAction::GetActionFlags() const
{
    return GameAction::GetActionFlags() | Flags::AllowWhilePaused;   // renaming is safe paused
}

void RideSetNameAction::Serialise(DataSerialiser& stream)
{
    GameAction::Serialise(stream);
    stream << DS_TAG(_rideIndex) << DS_TAG(_name);   // one function, reads AND writes
}

Result RideSetNameAction::Query(GameState_t& gameState, Park::ParkData& park) const
{
    auto ride = GetRide(_rideIndex);
    if (ride == nullptr)
        return Result(Status::invalidParameters, STR_CANT_RENAME_RIDE_ATTRACTION,
                      STR_ERR_RIDE_NOT_FOUND);

    if (!_name.empty() && Ride::nameExists(_name, ride->id))
        return Result(Status::invalidParameters, STR_CANT_RENAME_RIDE_ATTRACTION,
                      STR_ERROR_EXISTING_NAME);

    return Result();                     // ok, cost 0
}

Result RideSetNameAction::Execute(GameState_t& gameState, Park::ParkData& park) const
{
    auto ride = GetRide(_rideIndex);
    if (ride == nullptr) return Result(Status::invalidParameters, ...);   // re-check anyway

    if (_name.empty()) ride->setNameToDefault();
    else                ride->customName = _name;

    GfxInvalidateScreen();
    windowManager->BroadcastIntent(Intent(INTENT_ACTION_REFRESH_RIDE_LIST));

    auto res = Result();
    res.position = { ride->overallView.ToTileCentre(), TileElementHeight(...) };
    return res;                        // position drives the floating "£-5" text
}

Four details worth copying:

  • Execute re-validates. Belt and braces — between query and execute, another player's action may have landed.
  • The result carries a position. That is how the game knows where to float the cost text and where to scroll the camera on error. Rich results, not booleans.
  • Errors are two string ids — a title and a message — so the UI can render "Can't rename ride… / That name already exists" without the action knowing anything about windows.
  • One Serialise for both directions. DataSerialiser is templated on mode, so read and write can never drift apart. We'll see this pattern again in the save format.

The pipeline

src/openrct2/actions/GameActionRunner.cppcondensedExecuteInternal, lines 288–345 (abridged)
Result result = QueryInternal(action, gameState, topLevel);

// plugin hook: a script may veto or alter the result
scriptEngine.RunGameActionHooks(*action, result, false);

if (result.error == Status::ok && topLevel)
{
    if (Network::GetMode() == Network::Mode::client)
    {
        // A client never applies its own action. It asks the server and waits.
        if (!(actionFlags & Flags::ClientOnly) && !flags.has(CommandFlag::networked)) {
            Network::SendGameAction(action);
            return result;
        }
    }
    else if (Network::GetMode() == Network::Mode::server || !gInUpdateCode)
    {
        // Queue it to run at a defined point in the tick, never mid-update.
        Enqueue(action, gameState.currentTicks);
        return result;
    }
}

result = action->Execute(gameState, park);
scriptEngine.RunGameActionHooks(*action, result, true);
// ... then: charge the money, log the action, notify the callback

Everything a serious command bus needs is here: speculative validation, an authority check, deferred execution at a defined point in the tick, plugin interception before and after, money as a cross-cutting concern applied centrally, and an audit log. Building this abstraction once and forcing everything through it is what makes the next lesson — multiplayer — a hundred lines rather than a rewrite.

Lab 13 — Action pipeline, step by step
Fire an action and watch it traverse the stages

Fire as a client and watch the action leave for the server rather than applying locally — the stage where a naive implementation would apply it twice. Pause the game and try each action: only the one carrying AllowWhilePaused gets through. Drop your cash below the cost and the failure happens in Query, before anything mutates.

Build it — src/actions.ts
tinypark/src/actions.tsTypeScript
export const enum Status { ok, invalidParameters, disallowed, gamePaused,
                              insufficientFunds, notOwned, noClearance }

export interface ActionResult {
  status: Status;
  cost: number;                       // pence, integer
  position?: { x: number; y: number; z: number };
  errorTitle?: string; errorMessage?: string;
}

export interface GameAction<TArgs> {
  readonly type: string;
  readonly allowWhilePaused?: boolean;
  readonly clientOnly?: boolean;
  query(s: GameState, a: TArgs): ActionResult;     // MUST be pure
  execute(s: GameState, a: TArgs): ActionResult;
}

const queue: { action: GameAction<any>; args: any; tick: number }[] = [];

export function dispatch<A>(s: GameState, action: GameAction<A>, args: A): ActionResult {
  if (s.paused && !action.allowWhilePaused)
    return { status: Status.gamePaused, cost: 0 };

  const q = action.query(s, args);                // speculative, no mutation
  if (q.status !== Status.ok) return q;
  if (q.cost > s.cash) return { status: Status.insufficientFunds, cost: q.cost };

  if (s.netMode === 'client' && !action.clientOnly) {
    sendToServer(action.type, args);              // do NOT apply locally
    return q;
  }

  queue.push({ action, args, tick: s.tick });     // run at end of tick
  return q;
}

// Called at exactly one point in the tick, after all systems have updated.
export function processQueue(s: GameState) {
  while (queue.length) {
    const { action, args } = queue.shift()!;
    const r = action.execute(s, args);
    if (r.status === Status.ok) {
      s.cash -= r.cost;                            // money handled centrally
      s.actionLog.push({ tick: s.tick, type: action.type, args });   // replay log
    }
  }
}

Checkpoint: grep your own codebase for any mutation of game state that doesn't go through dispatch, and remove it. That single rule is what makes Lessons 14 and 16 possible; every exception you allow now is a desync later.

Check your understanding

Why must Query be exhaustive rather than letting Execute fail?

A multiplayer client validates an action successfully. What happens next?

Unit 5 · Systems — Lesson 14 of 16

Lockstep multiplayer and desync detection

Sixteen players in a park with a million tiles and forty thousand entities, over a domestic connection, sending a few hundred bytes a second. The trick is to send no state at all.

You'll be able to: Explain what lockstep sends instead of state, and diagnose a desync from a tick hash mismatch.

Send commands, not state

A park is tens of megabytes. Streaming it is out of the question. So OpenRCT2 uses deterministic lockstep: every machine runs the identical simulation, and only the player commands travel. Because Lesson 3 bought bit-exact reproducibility, running the same actions at the same tick number produces the same world everywhere.

StepWhat happens
1Client validates an action locally with Query, for instant UI feedback. Does not execute it.
2Client sends the serialised action to the server.
3Server enqueues it for a specific tick and broadcasts it to everyone, itself included.
4Every machine executes it at that exact tick, in ProcessQueue.
5Every machine's world is now identical again. Verify and repeat.

Clients chase the server's tick

src/openrct2/GameState.cppcondensedgameStateTick, lines 128–140
if (Network::GetMode() == Network::Mode::client
    && Network::GetStatus() == Network::Status::connected)
{
    // Run as many ticks as needed to catch up — but never more than 10.
    numUpdates = std::clamp<uint32_t>(
        Network::GetServerTick() - getGameState().currentTicks, 0, 10);
}
else if (gGameSpeed > 1)
{
    numUpdates = 1 << (gGameSpeed - 1);         // 1, 2, 4, 8, 16×
}

A client behind the server runs extra ticks to catch up; a client that has somehow got ahead stops and waits, because running past the server means executing ticks whose actions haven't arrived yet. The clamp at 10 is the same idea as the accumulator clamp in Lesson 2: bound the catch-up, accept falling behind, never freeze.

Verification: two hashes per tick

src/openrct2/network/NetworkBase.cppcondensedCheckSRAND, lines 845–877
bool NetworkBase::CheckSRAND(uint32_t tick, uint32_t srand0)
{
    auto itTickData = _serverTickData.find(tick);
    if (itTickData == std::end(_serverTickData)) return true;   // nothing to compare yet

    const ServerTickData storedTick = itTickData->second;
    _serverTickData.erase(itTickData);

    // Cheap check: does our RNG state match the server's at this tick?
    if (storedTick.srand0 != srand0)
    {
        LOG_INFO("Srand0 mismatch, client = %08X, server = %08X", srand0, storedTick.srand0);
        return false;
    }

    // Thorough check: SHA-1 over every entity in the pool.
    if (!storedTick.spriteHash.empty())
    {
        EntitiesChecksum checksum = getGameState().entities.GetAllEntitiesChecksum();
        if (checksum.ToString() != storedTick.spriteHash) {
            LOG_INFO("Sprite hash mismatch, client = %s, server = %s", ...);
            return false;
        }
    }
    return true;
}

Two tiers, and the design is worth stealing wholesale. The RNG state s0 is a single word that changes on almost every simulation decision, so comparing it is nearly free and catches most divergence within a tick or two. The entity checksum is a full SHA-1 over the pool — expensive, only enabled when desync debugging is on, but it catches divergence that never touched the RNG.

This is exactly why Lesson 7's entity pool is one contiguous array: hashing the world is a linear walk over 33 MB, not a graph traversal.

When it goes wrong

On mismatch the client marks itself desynced and, if snapshot debugging is enabled, requests the server's full state for the offending tick so the two can be diffed field by field — GameStateSnapshots.cpp exists solely for this. That is the honest engineering answer to lockstep's one real weakness: divergence is catastrophic and invisible, so build the forensics before you need them.

The shift from CRUD

You have built the pieces: an append-only command log, ordered delivery, and replicas that fold the log into state. What is different is that there is no reconciliation. Your distributed system tolerates drift and repairs it — last-write-wins, CRDTs, a nightly job. Lockstep cannot. There is no merge function for "your park has a coaster and mine doesn't". So the system detects divergence rather than repairing it, and the entire determinism discipline of Lessons 2, 3 and 13 exists to make divergence impossible in the first place.

Lab 14 — Two clients in lockstep
Inject nondeterminism and watch the detector fire
client A client B action in flight desync

Fire actions at high latency: both clients still apply them on the same tick, just later in wall-clock time. That is the trade lockstep makes — input lag in exchange for perfect consistency and tiny bandwidth. Then inject a fault and watch s0 diverge; the detector usually catches it within one or two ticks.

Build it — determinism harness before networking
  1. Add hashState() returning a 32-bit value over your RNG state, tick, entity positions and cash. Cheap enough to call every tick in debug.
  2. Run two GameStates side by side in one process, feeding both the identical action log. Assert their hashes match every tick. This finds nearly every determinism bug without a single socket.
  3. Only then split them across a BroadcastChannel or a WebSocket. The network layer becomes almost trivial: serialise the action, tag it with a tick, deliver it.
  4. Keep a ring buffer of the last 100 state hashes so that when a mismatch appears you can report the tick it started, not the tick you noticed.

Checkpoint: deliberately introduce a bug — sort an array with a comparator that returns 0 for distinct items, or iterate a Map keyed by object identity — and confirm your harness catches it. An untested desync detector is not a detector.

Check your understanding

Why compare the RNG state s0 every tick instead of always hashing the whole world?

What does a client do if its tick counter gets ahead of the server's?

Unit 5 · Systems — Lesson 15 of 16

Persistence, objects and the plugin API

A save format with real migrations, a content system that keeps parks loadable when mods go missing, and a scripting surface that reuses everything you built in Lesson 13.

You'll be able to: Migrate a save format across a version bump, and explain how the object system keeps a park loadable when a mod is missing.

The park file

src/openrct2/park/ParkFile.hcondensedlines 22–31
constexpr uint32_t kParkFileCurrentVersion = 61;
constexpr uint32_t kParkFileMinVersion     = 57;
constexpr uint32_t kParkFileMagic          = 0x4B524150;   // "PARK"

Two version numbers, and the pair is the whole compatibility story. A writer stamps targetVersion (what it wrote) and minVersion (the oldest reader that can still make sense of it). Adding an optional chunk bumps target but not min, so older builds keep loading the file. A breaking change bumps both. Anyone who has argued about backward- versus forward-compatible schema changes will recognise this immediately — it is the same contract, made explicit in four bytes each.

src/openrct2/core/OrcaStream.hppcondensedHeader + CompressionType, lines 40–60
enum class CompressionType : uint32_t { none, gzip, zstd };

struct Header
{
    uint32_t magic{};
    uint32_t targetVersion{};
    uint32_t minVersion{};
    uint32_t numChunks{};
    uint64_t uncompressedSize{};
    CompressionType compression{};
    uint64_t compressedSize{};
    std::array<uint8_t, 8> fnv1a{};     // integrity check
    uint8_t padding[20]{};              // room for future header fields
};

The body is a sequence of tagged chunks, and the tags are sparse on purpose:

src/openrct2/park/ParkFile.cppcondensedParkFileChunkType, lines 81–106
enum class ParkFileChunkType : uint32_t
{
    authoring   = 0x01,   objects   = 0x02,   scenario = 0x03,
    general     = 0x04,   climate   = 0x05,   park     = 0x06,
//  history     = 0x07,   <-- retired; the number is never reused
    research    = 0x08,   notifications = 0x09,
    interface   = 0x20,   titles    = 0x30,   entities = 0x31,
    rides       = 0x32,   banners   = 0x33,   cheats   = 0x36,
    restrictedObjects = 0x37, pluginStorage = 0x38, preview = 0x39,
    packedObjects     = 0x80,
};

A reader skips chunks it doesn't recognise. Retired chunk numbers are commented out rather than deleted, so they can never be accidentally reused — the same reason you don't reuse a protobuf field number or a dropped column name.

One function for reading and writing

Every chunk is handled by a single ReadWriteXChunk function; the stream knows which direction it is going. There is no serialiser and separate deserialiser to drift apart, which is the most common way save formats break. It is the same DataSerialiser pattern you saw in game actions — one description of the wire format, used in both directions, everywhere in the codebase.

Objects: content as data

Rides, scenery, terrain surfaces, footpath railings, even guest names and animations are objects — 21 types, loaded from JSON .parkobj bundles or legacy binary .DAT files:

src/openrct2/object/ObjectTypes.hlines 24–50
enum class ObjectType : uint8_t
{
    ride, smallScenery, largeScenery, walls, banners, paths, pathAdditions,
    sceneryGroup, parkEntrance, water, scenarioMeta, terrainSurface,
    terrainEdge, station, music, footpathSurface, footpathRailings,
    audio, peepNames, peepAnimations, climate,
    count, none = 255
};

The critical design decision: a park stores object identifiers ("rct2.ride.wooden_rc"), not object contents. The objects chunk is the park's dependency list. The packedObjects chunk, tag 0x80, optionally embeds custom objects so a park shared with a friend still loads. Referenced-by-id with optional vendoring — the same trade-off as a lockfile versus a vendor directory.

The plugin API

Scripting is Duktape running JavaScript, with TypeScript definitions published from the repo. Its integration is almost free, because Lesson 13 did the work:

src/openrct2/scripting/HookEngine.hlines 28–49
enum class HookType
{
    actionQuery, actionExecute,          // intercept ANY game action, before or after
    intervalTick, intervalDay,
    networkChat, networkAuthenticate, networkJoin, networkLeave,
    rideRatingsCalculate, actionLocation, guestGeneration, vehicleCrash,
    mapChange, mapChanged, mapSave,
    parkCalculateGuestCap, rideBreakDown,
    count, notDefined = -1,
};

actionQuery and actionExecute alone give a plugin the ability to veto, reprice or observe every change to the world — because every change is already an action with named, visitable parameters. This is the payoff for that AcceptParameters visitor that looked like boilerplate two lessons ago.

The API is versioned: kPluginApiVersion = 116, with named constants marking the versions where behaviour changed (kApiVersionPeepDeprecation, kApiVersionCustomActionArgs) so old plugins can be given old semantics. pluginStorage, chunk 0x38, lets plugins persist their own state inside the park file. It is a genuinely well-designed extension point, and it is worth reading even if you never write a plugin.

Lab 15 — Build a chunked save file
Toggle chunks · change versions · try to load it in an old build

Add a new chunk and watch targetVersion rise while minVersion stays put — an old reader still loads the file and ignores what it doesn't know. Now change the layout of an existing chunk: minVersion must rise too, and every older reader is locked out. That asymmetry is the entire discipline of evolvable formats.

Build it — src/save.ts
tinypark/src/save.tsTypeScript
export const MAGIC = 0x4B524150;            // "PARK"
export const CURRENT_VERSION = 3, MIN_VERSION = 2;

export const enum Chunk { general = 0x01, tiles = 0x02, entities = 0x03,
                            rides = 0x04, rng = 0x05, plugin = 0x38 }

// One class, both directions — read and write can never drift apart.
export class Stream {
  constructor(public mode: 'read' | 'write', public view: DataView, public pos = 0) {}

  u32(v?: number): number {
    if (this.mode === 'write') { this.view.setUint32(this.pos, v!, true); }
    const r = this.view.getUint32(this.pos, true);
    this.pos += 4;
    return r;
  }
  // … i32, u8, str, array — same shape
}

// Symmetric chunk handler. Called for both save and load.
function readWriteGeneral(s: Stream, g: GameState) {
  g.tick = s.u32(g.tick);
  g.cash = s.u32(g.cash);
  g.rng.s0 = s.u32(g.rng.s0);      // the RNG is part of the world
  g.rng.s1 = s.u32(g.rng.s1);
}

export function canRead(header: { minVersion: number }) {
  return header.minVersion <= CURRENT_VERSION;   // forward compatibility, made explicit
}

Checkpoint: save a park, load it, run 1,000 ticks, and compare the state hash against a run that never saved. Identical, or something isn't in the file — most likely your RNG state or a mid-flight ratings calculation.

Check your understanding

What is the difference between targetVersion and minVersion?

Why can two hooks — actionQuery and actionExecute — give plugins near-total control?

Unit 5 · Systems — Lesson 16 of 16

Testing a simulation, and where to go next

How do you unit-test a world? You don't. You replay it, hash it, and compare — and then you make your first contribution.

You'll be able to: Test a simulation by replay and hash comparison, and pick a first issue to contribute to.

What OpenRCT2 actually tests

There are no mocks and very little classical unit testing of game logic, because the units aren't independently meaningful. Instead the suite in test/tests/ is built from four ideas:

TestWhat it proves
ReplayTests.cppLoad a park, feed a recorded action log, run thousands of ticks, compare final state. The whole simulation is under test at once.
RideRatings.cppGolden values: known parks must produce exactly the documented excitement/intensity/nausea. Catches accidental tuning changes.
S6ImportExportTests.cppImport an RCT2 save, export it, import again — round-trip fidelity across format generations.
MultiLaunch.cppStart the engine repeatedly in one process; catches static state that isn't reset between games.
Pathfinding.cpp, CryptTests.cpp, StringTest.cppOrdinary unit tests, where a unit genuinely is independent.
The shift from CRUD

Replay tests are golden-master / approval testing, which you may already use for report generators or pricing engines. The difference is that determinism makes it exact rather than approximate: no tolerance, no fuzzy compare, no flaky reruns. You get an extraordinarily high-coverage test for almost no authoring cost — recording one is playing the game — and the price is that when it fails it tells you that something changed, not what. Hence the snapshot diffing from Lesson 14.

The headless build is the other half. openrct2-cli runs the simulation with no renderer, so CI executes real games at hundreds of times normal speed, and a profiler shows you simulation cost without the paint system dominating every trace.

Lab 16 — Replay test harness
Record, mutate the engine, watch the test catch it
hash matches baseline divergence action applied

Notice how differently the mutations behave. The tuning change diverges immediately and loudly. The update-order swap can run for hundreds of ticks before anything observable differs. And >> 9 versus / 512 only diverges once a value goes negative — which might be tick 3,000. Long replays exist precisely to catch that third kind.

Capstone — finish tinypark

By now you should have all eleven modules. The capstone joins them into something playable:

Required
  1. A park that runs. 40 Hz fixed tick, 64×64 tile world, isometric renderer with correct sorting at all four rotations.
  2. Guests. At least 200, with needs, a state machine, staggered updates, and bounded-DFS pathing on footpaths you place.
  3. One ride. A coaster built from your descriptor table, with a lift hill, integer physics, and a station that loads and unloads.
  4. The command layer. Every mutation via dispatch. An action log you can save and replay.
  5. Persistence. Chunked binary save with a version pair, including the RNG state.
  6. The test. A replay that runs 10,000 ticks and asserts a state hash. Wire it into CI.
Stretch
  • Two browser tabs in lockstep over a BroadcastChannel, with desync detection.
  • Ride ratings as a budgeted, resumable state machine.
  • A plugin hook on actionQuery so a user script can veto placements.

If you get the first six done, you will have written — in miniature and in a different language — the architecture of a shipping simulation game. That is a genuinely unusual thing for an engineer who builds line-of-business software to have done.

Contributing for real

The best way to consolidate all of this is to fix something. The project is unusually welcoming and the process is conventional:

  • Read CONTRIBUTING.md — coding style is enforced by clang-format, so formatting is never a review conversation.
  • Filter issues by good first issue. Many are "implement this window in the new UI system" or "this label is wrong at 4× zoom": small, self-contained, real.
  • Build with -DWITH_TESTS=ON and run ctest before opening a PR. If you touched simulation code, expect replay tests to be the thing that fails.
  • The Discord (linked from the readme) has an active development channel where architectural questions get answered by people who wrote the code.

A few areas that are approachable with what you now know: the rtd/ ride descriptors are pure data and easy to reason about; the plugin API surface in scripting/bindings/ is mechanical to extend; and peep/ is small, well-commented, and the most interesting 3,000 lines in the repository.

Read next: the paint system

The one subsystem this course only sketched. paint/track/ shows how 350 track types × 4 rotations × slopes become sprite selections. Repetitive, but the pattern is clear after ten minutes.

Read next: RCT1 import

rct1/ converts a 1994 save into the modern model. A masterclass in migrating data across an ontology change — closer to your day job than anything else in the repo.

Read next: the window system

src/openrct2-ui/windows/ is an immediate-mode-ish retained UI written from scratch. Instructive if you have only ever used a framework.

Check your understanding

What is the main trade-off of replay-based golden-master testing?

Why might changing >> 9 to / 512 pass a short replay test but fail a long one?

One last thing

The reason OpenRCT2 is worth this much of your attention isn't nostalgia. It is that almost every decision in it was forced by a hard constraint — a 1999 CPU, a fixed tick, no floats, a save format that must never break — and constraints produce designs you can actually learn from. Nothing here is the way it is because a framework suggested it.

Go build the park.