Lesson 13
Cover in play, keeping it valid and choosing it
Baked cover goes stale the moment something explodes. Tiling the bake and rebuilding only dirty tiles, moving cover that carries its own template, and choosing the best spot with a utility query that knows the difference between hiding and fighting.
A bake is a snapshot. As soon as doors open, walls are destroyed, crates get pushed or carts park, it has to be kept up to date cheaply. And once cover is found, the AI still has to choose between thousands of positions in a way that holds up in a fight.
Bake in tiles, rebuild what’s dirty
Store the bake in tiles (here 16 × 16 m). When the world changes, mark the tiles the change touches as dirty, rebuild only those, and swap the results in. Click the map to blast a crater with a fireball (or switch to demolishing walls), then step through the rebuild. The same scheme keeps any baked analysis valid, from navmeshes to flow fields:
Loading…
- Mark dirty tiles: every tile the change overlaps, grown by a margin of how far the analysis looks. Cover depends only on direct neighbours, so a 1 m margin is enough.
- Invalidate the old cover in those tiles: points the change destroyed are dropped at once, and any unit that had claimed a position there must re-query. Everything outside the dirty tiles stays valid.
- Rebuild each dirty tile, as a background job spread over frames. Whatever survived of the old tile stays in use until the new one is ready.
- Swap the new tiles in. For a crater, the lip appears as new low cover.
In code, the change handler is short. The work is in making bake_cover take a tile and nothing else, so tiles can be rebuilt independently, in any order, on any thread:
void on_world_changed(Box changed) {
Box grown = expand(changed, COVER_MARGIN); // 1 m: how far cover looks
for (TileId t : tiles_overlapping(grown)) {
invalidate_claims_in(t); // units there re-query
jobs.push([t, gen = ++tile_gen[t]] {
TileCover fresh = bake_cover(t); // reads only static data + this tile
main_thread([t, gen, f = std::move(fresh)]() mutable {
if (tile_gen[t] == gen) cover[t] = std::move(f); // skip if superseded
});
});
}
}
The generation counter matters: if a second fireball dirties the same tile before the first rebuild finishes, the older result must not overwrite the newer one.
This is exactly how Recast handles changing navmeshes: Detour’s TileCache rebuilds just the tiles that temporary obstacles touch. A cover bake keyed to the same tiles can be rebuilt by the same job.
Moving cover (a wagon, a moving platform, a large creature) isn’t baked at all. Add the wagon in the widget and drag it around: it carries a template of cover points in its own frame, moved with it every frame and found through the spatial index next to the baked cover. It costs nothing to keep valid, and it blocks line of sight like any other obstacle.
Choosing cover
With cover found and kept valid, picking a spot is a scoring problem, and the utility lessons already have the tool. Every cover position within 25 m of walking is a candidate, scored on six considerations multiplied together:
Loading…
- Hidden from the threats at the chosen stance.
- Can attack from here: by standing up behind low cover, or leaning out to a neighbouring cell (the yellow dot and line).
- The cover faces the threat: it’s between the spot and the nearest threat.
- Not too close to a threat.
- A short walk.
- A safe route there.
In code it’s the same shape as any utility behaviour:
float score(const CoverPoint& c, const CoverQuery& q) {
float s = 1.f;
s *= hidden.eval(hidden_fraction(c, q.threats, q.stance));
s *= can_attack.eval(can_attack_from(c, q.threats)); // stand up, or lean out to a neighbour
s *= facing.eval(cover_between(c, q.nearest_threat));
s *= too_close.eval(distance(c.pos, q.nearest_threat));
s *= walk.eval(path_length(q.unit, c.pos));
s *= route.eval(route_exposure(q.unit, c.pos, q.threats));
return compensate(s, 6); // as in the utility lesson
}
Order the considerations cheapest first and stop as soon as the product hits zero: most candidates fail hidden or walk before anything casts a ray.
Hover any candidate to see its breakdown; drag a threat round the unit and watch the best spot move. The “can attack” consideration is what separates a hiding place from a fighting position. Without it, almost every spot behind a building scores the same, and the nearest one wins every time; with it, the best spot is usually at the end of a rail, where a peek point lets the unit hide and fight. That is why the boundary walk’s peek points matter.
Takeaways
- Bake in tiles; on a change, dirty the tiles it touches plus a margin, rebuild those in the background, and swap them in.
- Moving cover carries a template instead of being baked.
- Choose cover with a utility query, and make sure it can tell hiding from fighting.