Simulation (Pulse Sim)

A crafting station should finish its rounds while the player is on the other side of the world. A field should grow, a camp should rebuild, a stash should fill, even when their chunk is not loaded and their actors are gone. Pulse Sim keeps such things as plain records in one store per world and lets them live on the game clock, whether an actor shows them or not.

  • A record is data: an id, a location, a state (a struct of a kind you write) and links to other records.
  • Records run at their events (the end of a crafting round, the next growth stage) and catch up when someone looks at them. Nothing ticks per record: 10,000 records with timers cost a few hundredths of a millisecond per frame.
  • Actors show a record through the Pulse Sim View component. When the actor goes, the record keeps living.
  • Only the server computes. Pause and slow motion of the game clock reach every record without code of its own.

Pulse Sim runs in one process (the dedicated or listen server) and never hands records to other servers or to clients. Clients get records through actors per cell: the engine decides by the net cull distance of a cell actor, never by queries per player. Keep extensions inside these lines.

Terms

Term What it is
Record (FPulseSimRecord) Id (FName, the same in every session), location, cell, state, links, relevancy, the time the state is at (AdvancedTo) and the time of its next event (NextEvent, empty while it rests)
Kind A struct derived from FPulseSimState: the data and the rules of one sort of record (a timer, a stash, a station)
Event A moment the kind names in GetNextEvent; OnEvent runs exactly then
Catch up Moving a record from its time to a later one, through every event on the way (AdvanceTo)
Store (Pulse::Sim::FStore) The records by id and by cell, the heap of next events; plain C++, works without a world
View (UPulseSimViewComponent) The component that shows a record on an actor

Write a kind

A kind is a USTRUCT derived from FPulseSimState. Override what it needs:

Function Default What it does
GetNextEvent(Context, OutTime) no event The game time of the next event; false while the record rests until something changes it
Advance(Context, From, To) nothing Moves the state between two times without passing an event (growth, decay); a failure leaves the state as it was and the record rests
OnEvent(Context) nothing The event happens now (Context.Now)
ShiftTimes(Delta) nothing Moves the game times the state keeps itself (a stamp, the start of a round) when the store moves the times of its records
GetStepMode() Events Events: only at events and when someone looks. CoarseStep: also every CoarseStepSeconds. PerFrame: every frame (use rarely)
GetVersion() / Migrate(SavedVersion, Report) 1 / nothing Versioning of the saved state
Describe() empty One line for the debugger and PulseSimDump

Three rules keep records right while nobody looks:

  1. Catching up in one step is the same as step by step. Advance(From, To) must give the same state as Advance(From, Middle) followed by Advance(Middle, To). Compute from the time that passed, never per call.
  2. Check first, then apply. When something is missing (an ingredient, room in a stash), return a failure from Advance or plan no event, and change nothing.
  3. Times in the state move with the record. The store moves the times of every record when the clock is set by a load, for a catch up and when it loads a save of another session (whose clock started at another time). A kind that keeps durations (seconds left, seconds elapsed) needs nothing; a kind that keeps a game time (FPulseClockStamp, the start of a round) moves it in ShiftTimes.
USTRUCT(meta = (DisplayName = "Field"))
struct FMyFieldState : public FPulseSimState
{
	GENERATED_BODY()

	/** Game seconds per growth stage. */
	UPROPERTY(EditAnywhere, SaveGame)
	double StageSeconds = 600.0;

	UPROPERTY(SaveGame)
	int32 Stage = 0;

	UPROPERTY(SaveGame)
	double Elapsed = 0.0;

	virtual bool GetNextEvent(const FPulseSimContext& InContext, double& OutTime) const override
	{
		if (Stage >= 3)
			return false;

		OutTime = InContext.GetRecord()->AdvancedTo.Time + StageSeconds - Elapsed;
		return true;
	}

	virtual FPulseResult Advance(FPulseSimContext& InContext, double InFrom, double InTo) override
	{
		Elapsed += InTo - InFrom;
		return FPulseResult::Success();
	}

	virtual void OnEvent(FPulseSimContext& InContext) override
	{
		Stage++;
		Elapsed = 0.0;

		InContext.Notify(MyTags::Field_Grown);
	}
};

A kind may hold object properties (UPROPERTY): the subsystem reports them to the garbage collector, so the item definitions of a stash stay loaded. Saved and replicated, they become paths. The ready-made kind FPulseSimState_Timer (an event every Interval, a notice with Event, up to MaxCount) is the simplest example.

Linked records

A station that takes from a stash lists the stash’s id in Links. In its events it reads the stash with Context.FindLinked(Id) and changes it with Context.EditLinked(Id, Function): the store first moves the stash to the same time, then calls the function, then plans the stash’s events again. Records added or removed during an event are applied once the event is over.

Links also wake: when a record is changed from outside its own run (an edit, EditLinked of another record) or added, every record that links to it plans its next event again. A station that rests because an ingredient is missing has no event; when its stash gets the ingredient, GetNextEvent of the station is asked again and it starts.

Add records

  • From an actor: add the Pulse Sim View component, pick a kind in Default State. On the server it adds the record in BeginPlay when the store has none with its id (Record Id, or the save key of the component without one). Its events On Record Changed (Blueprint) and OnRecordChangedNative fire on every change; Get Pulse Sim State reads the state as it is now, Set Pulse Sim State replaces it (server).
  • Without an actor: Add Pulse Sim Record (UPulseSimSubsystem::AddRecord) on the server, with id, location and state. Remove Pulse Sim Record removes it.
  • In C++: UPulseSimSubsystem::Get(this)->EditRecord(Id, Function) moves the record to now, calls the function, plans its events again and tells its view. FindRecord(Id) gives the record moved to now.

How time works

  • Every frame the server lets the due events happen in the order of their times, each record moved only to its event, so linked records see each other at the right moment. At most EventsPerFrameBudget events per frame; the rest waits for the next frames (the backlog).
  • One record handles at most MaxCatchUpStepsPerFrame events in one catch up, so a record with a short interval after a long jump does not stall a frame.
  • When the game clock jumps forward (a rest, a cheat), the records catch up over the next frames within the budget.
  • When the game clock is set while a save loads (the load barrier is held), that is no time that passed: the records only move their times along and keep the distance to their events.

Multiplayer

Only the server computes. Clients get the records they are near and show them; they never run events.

  • Relevancy of a record: None stays on the server, Always goes to every client (one replicator for the world), Spatial goes to the clients near its cell. The view component adds records with Spatial by default.
  • Cells: the server spawns one APulseSimCellReplicator per occupied cell (CellSize), in the middle of the cell, with a net cull distance of RelevancyRadius plus half a cell diagonal. The net driver (classic or Iris) decides like for any other actor; its RelevantTimeout keeps a cell a moment longer at the edge. A cell without records is destroyed.
  • What goes over the wire: the state of the kind without the properties marked NotReplicated, which stay on the server, plus location, time and next event. Mark server data (whose turn it is, secrets of an AI) NotReplicated:
UPROPERTY(SaveGame, NotReplicated)
int32 ServerOnlyCounter = 0;
  • Versions: the kind’s GetVersion goes with every record. A client with another version (another build) does not show the record and warns once per kind, instead of reading the bytes wrongly.
  • On clients a record belongs to the replicator that sent it last. When a cell leaves the relevancy of the client, its replicator goes and takes the records it still owns; a record that moved to the next cell stays. Views on clients bind by id like on the server and get On Record Changed when the record arrives, changes or goes.
  • Cost: a replicator builds the payload of a changed record once per net update (ReplicatorUpdateFrequency). The replicator of Always records goes dormant after DormantAfterSeconds without a change. Cell replicators stay awake: a client keeps a dormant actor even after it left its relevancy, so a sleeping cell would stay on clients that walked away.

Saving

The server spawns one hidden actor (APulseSimSaveActor) that saves every record through IPulseByteSaveable, save id World.Pulse.Foundation.Save.Sim. PulseSave’s byte collector finds it like any other byte saveable. Inside, the records are grouped by region (RegionSize). One actor for the whole world, because a save system finds saveable objects and an object per region could not exist before its region is loaded.

  • Each state is saved as the tagged properties of its kind (SaveGame properties), with the version of the kind.
  • When loading, the times of the records move by the difference between the saved game time and the game time now (game times inside a state through ShiftTimes of the kind).
  • A record whose kind no longer exists, or that was saved by a newer version of its kind, is kept as bytes and saved again unchanged; the import report names it (UnknownKind, TooNew).
  • An older version calls Migrate of the kind after the properties are read.
  • Offline time (OfflineCatchUp = UpToHours): the save keeps the real time; after loading, the records catch up the real time the save was on disk, at most MaxOfflineHours. Only the records catch up, the game clock (and the time of day) stays where the save left it.

Settings

Project Settings > Pulse > Foundation, block Simulation (UPulseSimSettings, DefaultGame.ini).

Setting Default Description
bEnabled true Runs Pulse Sim in game worlds.
RunMode Everywhere Which records run while no player is near. AnchorsOnly comes with the area functions; until then it runs like Everywhere.
EventsPerFrameBudget 2000 Most events per frame.
MaxCatchUpStepsPerFrame 1000 Most events of one record in one catch up.
CoarseStepSeconds 1 Game seconds between coarse steps.
CellSize 10000 Edge length of the cells of the records (cm).
RegionSize 100000 Edge length of the regions records are saved in (cm).
OfflineCatchUp None UpToHours: catch up the time the server was off after loading.
MaxOfflineHours 8 Most real hours caught up.
RelevancyRadius 15000 Clients get the records of a cell while they are this close to it (cm).
ReplicatorUpdateFrequency 5 Net updates per second of the replicators.
DormantAfterSeconds 5 Real seconds without a change after which the replicator of Always records goes dormant (0 = never).

Messages

On the server, Pulse.Message.Sim.RecordAdded, .RecordRemoved and .Event (payload FPulseSimMessage: id, kind, location, event, game time). .Event comes for every Context.Notify of a kind, also while nobody looks at the record; the event tag is in the tags of the context, the amount in its amount. See message channels.

Debugging

  • Pulse Debugger > Sim: tiles (records, events per second, budget, backlog, waiting and resting records, cost per frame), the kinds with their counts (click to filter), the records that come next and the detail of one record.
  • Snapshot category Sim (Live page, gameplay debugger), timeline events of source Foundation, category Sim.
  • Cheats PulseSimInfo (on a client also the records it shows), PulseSimAdvance <seconds>, PulseSimDump [filter], PulseSimSpawnTimers <count> <interval> [spacing] (see console).

Limits

  • Clients see records only near them and compute nothing; a client view shows the time the server sent last.
  • Kinds that need every frame (PerFrame) run on the game thread one by one; many of them belong into Mass (a bridge, planned).
  • One store per world: records of different worlds (levels opened one after the other) do not see each other.

PulseFoundation plugin documentation.

This site uses Just the Docs, a documentation theme for Jekyll.