Sam Afshari's Notes
  • GitHub
  • Twitter/X
  • All posts
  • Ed
  • NuGets
  • POIWorld
  • RedCorners

Roads in C&C Generals

Tue, Jul 7, 2026

Roads in Command & Conquer: Generals look like part of the terrain, but they are not painted into the height map. They are generated meshes built from pairs of points stored in the map.

That distinction explains most of the system. World Builder records the centerline and road type. At load time, the renderer discovers which segments touch, creates corners and intersections, conforms the result to the terrain, and groups the triangles by texture for drawing.

The other important detail is what roads do not do. A normal road is not a pathfinding graph, it does not create game objects, and I found no road-specific movement-speed rule in the logic code. It is a visual layer over the terrain. Bridges share some authoring and configuration machinery, but they become gameplay objects with damage and pathfinding state.

A road starts as a type and two points

Road styles are defined through the terrain-road INI subsystem. Each TerrainRoadType has a name, texture, road width, and a width value describing how much of the texture belongs to the road surface:

const FieldParse TerrainRoadType::m_terrainRoadFieldParseTable[] =
{
    { "Texture", INI::parseAsciiString, nullptr,
      offsetof(TerrainRoadType, m_texture) },
    { "RoadWidth", INI::parseReal, nullptr,
      offsetof(TerrainRoadType, m_roadWidth) },
    { "RoadWidthInTexture", INI::parseReal, nullptr,
      offsetof(TerrainRoadType, m_roadWidthInTexture) },
    { nullptr, nullptr, nullptr, 0 },
};

The engine loads default definitions from Data\INI\Default\Roads and then the regular override directory at Data\INI\Roads. Every type receives a numeric ID. The ID is important later because it identifies both connectivity and the texture batch.

On the map side, a segment is stored as two consecutive MapObject records. The first has FLAG_ROAD_POINT1; the second has FLAG_ROAD_POINT2. The first point's name identifies the road type. Their positions provide the centerline endpoints.

The flags also carry local authoring choices:

  • FLAG_ROAD_CORNER_ANGLED asks for a hard miter rather than an inserted curve;
  • FLAG_ROAD_CORNER_TIGHT selects the smaller curve radius;
  • FLAG_ROAD_JOIN allows an open end to blend into another road type;
  • the two road-point flags distinguish these records from ordinary map objects.

The map format therefore stores very little road geometry. It keeps intent: type, endpoints, and a few corner hints. The runtime reconstructs the rest.

Road records stay on the terrain side

Map objects normally become logic objects such as buildings, props, or units. Roads and endpoint-style bridges are explicitly skipped by that creation loop:

if (pMapObj->getFlag(FLAG_BRIDGE_FLAGS) ||
    pMapObj->getFlag(FLAG_ROAD_FLAGS))
{
    continue; // roads & bridges are special cased in the terrain side.
}

This is more than an implementation detail. A road point has no health, team, AI module, collision module, or per-frame update. Destroying nearby buildings does not destroy the road. Units do not query a road object while moving.

The road builder reads the same global MapObject list directly. It expects each point-one record to be followed by point two, resolves the point-one name through TheTerrainRoads, and creates an internal RoadSegment:

if (pMapObj->getFlag(FLAG_ROAD_POINT1)) {
    MapObject* pMapObj2 = pMapObj->getNext();
    if (!pMapObj2 || !pMapObj2->getFlag(FLAG_ROAD_POINT2))
        continue;

    curRoad.m_pt1.loc = { pMapObj->getLocation()->x,
                          pMapObj->getLocation()->y };
    curRoad.m_pt2.loc = { pMapObj2->getLocation()->x,
                          pMapObj2->getLocation()->y };
}

A zero-length segment is nudged by a quarter world unit rather than passed through as a degenerate line. Duplicate segments are discarded later.

The loader turns loose segments into chains

The geometry build is a fixed pipeline:

RoadMeshOutput RoadBuilder::build()
{
    addMapObjects();
    updateCountsAndFlags();
    insertTeeIntersections();
    insertCurveSegments();
    insertCrossTypeJoins();
    preloadRoadsInVertexAndIndexBuffers();
    return gatherBatches();
}

addMapObjects() first computes the two physical edges of every segment. It subtracts the endpoints to get a direction, rotates that direction by 90 degrees to get a normal, normalizes it, and scales it by half the configured road width:

Vec2 roadVector(loc2.X - loc1.X, loc2.Y - loc1.Y);
Vec2 roadNormal(-roadVector.Y, roadVector.X);
roadNormal.Normalize();
roadNormal *= cur.m_scale * cur.m_widthInTexture / 2.0f;

Those edge points become top and bottom at both ends. From then on, most joins work by moving or intersecting those edges.

The builder compares endpoint coordinates and counts how many same-type segments meet at each point. Connectivity uses exact coordinate equality and the road type's unique ID. Two visually touching segments of different types are not treated as one ordinary chain.

It also reorders and flips segments into connected runs. That makes later curve insertion possible without maintaining a separate graph structure. The data remains an array, but connected pieces become neighbors in that array.

Corners are generated, not authored as meshes

When two compatible segments meet, the road builder chooses between a miter and a curve.

A shallow turn uses a miter. An endpoint marked as angled also forces a miter. The algorithm intersects the two offset edge lines and moves both segments' top and bottom corners to those intersection points. This closes the crack without adding a special mesh.

For a curved corner, the builder shortens the original straight segments and inserts one or more synthetic CURVE segments. The turn is divided into roughly 30-degree pieces. Each inserted piece carries the original road type, width, and either the normal or tight curve radius.

This is why a long road can be authored as a simple polyline while still appearing rounded in game. The curve is not stored in the map. It is recreated from adjacent directions each time the road mesh is built.

Intersections are classified from direction vectors

Three or four segments meeting at the same coordinate take another path. The builder examines normalized direction vectors, dot products, and cross-product signs to classify the junction.

The internal corner types cover:

enum TCorner {
    SEGMENT,
    CURVE,
    TEE,
    FOUR_WAY,
    THREE_WAY_Y,
    THREE_WAY_H,
    THREE_WAY_H_FLIP,
    ALPHA_JOIN,
    NUM_JOINS
};

A three-way junction can become a normal T, a Y, or one of the offset H-shaped variants. The classification is geometric. The code identifies which pair is most nearly opposed, decides which side the third arm occupies, moves the incoming endpoints to the edge of the selected junction patch, and inserts a synthetic intersection segment.

Four-way intersections receive a corresponding four-way patch. The original straight pieces are shortened so that they terminate against the generated center geometry instead of overlapping it.

This approach does have a practical limit. The constants allow up to six links around a road point, and the generator is tuned around ordinary streets rather than an arbitrary planar-road graph. That is enough for Generals maps, where intersections are deliberately authored and visually inspected.

Different road types need an explicit blend

Same-type roads connect automatically. A transition between asphalt, dirt, damaged pavement, or another texture uses FLAG_ROAD_JOIN.

At an eligible open endpoint, insertCrossTypeJoins() looks for a nearby segment of another type. It derives the direction of that road, clips the current road edges against the join line, and inserts an ALPHA_JOIN patch. It also adjusts stacking order so the intended road type draws over the other one.

If no other road is found, the join vector is extended and the patch acts as a fading cap. This avoids the hard rectangular end that a raw textured strip would otherwise show.

The stacking order is maintained per road type. When one type must appear over another, the renderer raises its stacking level and preserves that order while batching. This is a small local solution to texture-layering problems that would otherwise require a general decal compositor.

The mesh follows the height map

After topology is resolved, every straight, curve, tee, and join is tessellated against the height map.

The core function begins with a four-corner patch. It calculates longitudinal and lateral sample counts from the patch dimensions and the terrain grid size. At each sample it asks the height-map adapter for the maximum cell height:

int uCount = int(roadLen / ROAD_MAP_XY_FACTOR) + 1;
int vCount = int(2 * halfHeight / ROAD_MAP_XY_FACTOR) + 1;

float z = m_heightMap->getMaxCellHeight(
    nextColumn.vtx[j].X,
    nextColumn.vtx[j].Y);
nextColumn.vtx[j].Z = z;

The use of the maximum is deliberate. Across each sampled column, the road surface is raised to the highest terrain point under that slice. This keeps a wide road from cutting through a ridge on one edge while following a lower sample on the other.

The generated vertices then receive a small positive height offset. Without it, the road and terrain would occupy nearly the same plane and produce depth flicker.

The tessellator also removes unnecessary middle columns. If the neighboring columns interpolate closely enough to the current height, the current column is marked deleted. Flat stretches therefore use fewer triangles, while terrain changes retain more samples.

Roads are still an overlay. They conform to the existing terrain but do not flatten it. Good-looking roads depend on the map author preparing a sensible terrain profile under the centerline.

Texture coordinates encode a road atlas

The texture mapping is not a generic planar projection. Straight sections, curves, tees, Y intersections, H intersections, four-way patches, and alpha joins use specific normalized regions of the road texture.

For example, the straight segment starts with a vertical offset of 85 / 512, a regular curve uses 255 / 512, and a tight curve uses 425 / 512. The vertex generator projects each point onto the road direction and normal, then converts those distances into U and V coordinates using the road scale.

That arrangement lets one texture contain reusable pieces for the complete road family. The geometry generator chooses the patch; the UV rules choose the corresponding part of the texture.

It also explains why RoadWidthInTexture exists alongside RoadWidth. The physical strip and the useful painted portion of the atlas are related but not necessarily identical. The generator needs both to make edges and intersections line up.

From the original DX8 buffer to the current renderer

The original path is W3DRoadBuffer. It owns road segments, one vertex and index buffer set per road type, visibility flags, textures, and stacking information. When visibility changes, it reloads the visible geometry into the relevant buffers. Drawing walks stacking order and road type, binds the texture and road shader, then issues triangle draws.

The current Generals64 Direct3D 11 path preserves the geometry algorithm but separates it from the old renderer. RoadGeometry.cpp is a self-contained port with no DX8 or WW3D buffer dependencies. It emits RoadMeshOutput, which contains CPU-side vertices and indices grouped into batches.

TerrainRenderer::BuildRoadMesh() converts those vertices to the current Render::Vertex3D format, creates Direct3D 11 vertex and index buffers, and resolves each batch texture. The mesh is built with the terrain rather than regenerated every frame.

Rendering happens after the terrain and before bridges:

terrainRenderer.Render(camera, heightMap);
terrainRenderer.RenderRoads(camera);
terrainRenderer.RenderBridges(camera);

The road pass enables alpha blending so texture edges can fade into the ground. It binds the shroud texture, allowing fog of war to cover roads consistently with terrain, and optionally applies the macro light map. Batches are then drawn in the order produced by the road builder.

This was a useful porting boundary. The difficult part of the road system is the topology and mesh generation, not the graphics API calls. Keeping that algorithm in a renderer-independent module made it possible to replace DX8 buffers with Direct3D 11 resources without redesigning every map.

Roads, bridges, and railroads are different systems

Roads and bridges share TerrainRoadType and the paired-endpoint convention, but they diverge after loading.

A normal road remains terrain-side visual geometry. A bridge definition includes models and textures for pristine, damaged, heavily damaged, and broken states. Bridge loading creates logic-side bridge information and a GenericBridge object, registers a pathfinding layer, and changes that layer when the bridge breaks or is repaired. The current renderer builds the bridge from model pieces and chooses assets from the current damage state.

That is why destroying a bridge can stop units while driving over a road does not invoke any equivalent road state.

Railroads are separate again. Trains use RailroadBehavior and named waypoint paths. The visible track may use road-like artwork, but the locomotive follows waypoint data rather than querying the road mesh.

A small system with a clear boundary

The road system works because the authoring boundary is narrow.

World Builder supplies endpoints, a road type, and corner hints. The loader reconstructs chains. Geometry code creates curves and intersections. The tessellator follows terrain and assigns atlas coordinates. The renderer batches the result as a transparent terrain overlay.

Nothing in that pipeline needs a road to become a simulation object. Pathfinding remains based on terrain, obstacles, and explicit bridge layers. Vehicle AI remains independent. That keeps decorative road networks cheap and deterministic while allowing bridges, which really change movement and combat, to use the heavier gameplay machinery.

The interesting part is how much visual structure comes from so little map data. Two points are enough to describe a segment. Shared endpoints are enough to discover a network. A few vector tests are enough to choose a corner or intersection. The rest is generated once and handed to the renderer.

Back to Home


© Sam Afshari 2026