1. Sort what your mod does
Go through the mod's features and put each one into a bucket:
| Bucket |
Examples |
Rule |
| Local: changes only your own player |
HUD, camera, controls, looks, sounds only you hear |
Nothing to do. Register with LOCAL_ONLY so partners know they need nothing |
| World: changes what everybody shares |
time, weather, NPCs, quests and facts, spawns, the population |
Only the host may do it. Guests ask |
| Entity: describes one synced thing |
"this car is flying", "this player holds a lantern" |
The entity's owner attaches the data; everyone else gets it on their mirror |
If a feature is world-changing, ask what a guest sees when the host's game and theirs disagree. If the answer is "nothing
good", route it through the host.
2. Register, with an honest passport
- Id: stable, unique, ASCII
[a-z0-9._-], 3..64 bytes. A good shape is author.modname. Never change it.
- Flags: tell the truth, they are shown to other players:
NEEDED_BY_ALL if everyone should have the mod,
LOCAL_ONLY, CHANGES_WORLD, CHANGES_QUESTS, ADDS_ITEMS, SPAWNS_ENTITIES, HOST_AUTHORITY.
- Permissions: declare what the mod uses —
USES_CHANNELS, USES_SHARED_STATE, USES_ENTITY_STATE, USES_POSE,
USES_ACTIONS (spawns need SPAWNS_ENTITIES, factKeepLocal needs CHANGES_QUESTS). An undeclared use answers
NCSDK_E_DENIED. Declare only what you use: reviewers (Nexus) read these flags.
- Partner data is untrusted: numbers out of range, texts with control characters, ids of things that do not exist.
Check every value, clean every text (
cleanText / CleanText), never spawn or run an action because a partner said
so. The checklist is in ../SECURITY.md.
- Protocol: start at
1. Raise it when your data format changes incompatibly. Players on different numbers do not talk.
- Without NightCoop: the mod must still work as a single-player mod. In C check for
NULL, in redscript use
@if(ModuleExists("NightCoop")) with a stub, in Lua use the helper (it does nothing when NightCoop is absent).
3. Route world changes through the host
The pattern:
guest presses the button --> ask the host (channel message or shared state)
host decides and applies --> host publishes the result (shared state)
everybody, guest included --> reads the result from the state event
Ask by channel when it is an action ("start the event now"). Ask by shared state when it is a value the host should
keep ("the setting is 3"). In both cases the host's game applies it; the guest's game never changes the world on its own.
Skeleton in redscript (host applies, guests ask on channel 1; IsHost() is the switch):
public func RequestEvent() -> Void {
if NightCoopSdk.IsHost() || !NightCoopSdk.InSession() {
this.StartEvent(); // we are the world: just do it
} else {
NightCoopSdk.Send("yourname.yourmod", 0, 1, "start", true); // to the host, reliable
};
}
// in the event handler
if evt.kind == EnumInt(NightCoopSdkEventKind.Message) && evt.a == 1 && NightCoopSdk.IsHost() {
this.StartEvent(); // a guest asked
};
The host should treat requests as requests: check that the sender is allowed and the value is sane. In C that is the
stateRequest hook for shared state (return 0 to refuse); for channel messages it is your own code.
4. Shared state for the world's facts
Use shared state for a fact everybody should agree on and a late joiner should learn: "the counter is 7", "the event is
running", "the difficulty is 3". Key to value (keys up to 64 bytes, values up to 4096 bytes, 256 keys).
- The host sets it; everybody gets
stateChanged. Late joiners get every key once after joining.
- A guest's set is a request. It changes only when the host accepts (scripts accept everything; in C you can refuse).
- Read the current value at any time with
stateGet / GetState.
- Put each fact under its own key, not everything into one big value: small values mean small traffic.
5. Entity state for "my car" and "my player"
Use entity state when the data belongs to one synced entity: the vehicle you drive, your own player, an NPC you lead.
- Get the entity:
vehicle.GetEntityID() in scripts, or the EntityID hash in C.
- Check you lead it:
EntityIsMine(entity) == 1. If it returns -7 (Unknown), NightCoop does not sync the entity and it
is purely local, so partners can never see it.
- Attach your value under a key you chose:
SetEntityState(id, entity, key, value, stream). Continuous values (a position,
an angle, a throttle) go as a stream: unreliable, only the newest counts. Rare values (a mode) go reliable (default).
- On the other machines,
entityState / the EntityState event arrives with their local mirror in entity. Apply the
value to that mirror. It comes again if the mirror is re-created, and for late joiners.
@if(ModuleExists("NightCoop"))
private func ShareFlightMode(vehicle: ref<VehicleObject>, mode: Int32) -> Void {
NightCoopSdk.SetEntityState("yourname.yourmod", vehicle.GetEntityID(), 1, IntToString(mode), false);
}
Never write to an entity you do not lead. Ask its owner by channel instead.
6. Channels for events
Use a channel message for something that happens and is not a state: "I fired", "start the event", "ping". Pick a number
per kind of message (your own numbering, uint16_t). Reliable for things that must arrive and keep order, unreliable (the
default) for things where the next one replaces the last. Keep messages small: 1024 bytes unreliable, 16384 reliable, and
at most 120 messages and 32 KiB a second per direction.
send returns how many peers it reached. 0 means nobody has the mod: not an error.
7. Test with two players
- Run the session with host and guest and do every feature from both seats. Many bugs show only on the guest.
- Test a partner without your mod: nothing should break, and with
NEEDED_BY_ALL both players should get the note.
- Test joining late: start the feature on the host, then let the guest join. The guest must get the current state.
- Test leaving and rejoining, and a guest going to the main menu and back (
PEER_WORLD_OUT, PEER_WORLD_IN).
- Look at NightCoop's log (lines
sdk: [yourmod.id] ...) and at NightCoopSdkStatus(): see troubleshooting.md.
8. A checklist by kind of mod
| Your mod |
What to do |
| Visual only (LUT, lighting, HUD, camera, UI) |
Nothing: it is local. Flag NCSDK_MOD_LOCAL_ONLY if you register at all |
| Clothes, cyberware looks (ArchiveXL, TweakXL) |
A partner without the mod sees a fallback NightCoop picks; name your own: fallback = Items.Mine -> Items.Game in a manifest. Flag NCSDK_MOD_ADDS_ITEMS |
| A new car (TweakXL + ArchiveXL) |
A manifest with needed_by_all, adds_items and a fallback to a game car of the same kind: a partner without the mod gets NightCoop's stand-in for a missing record, and your fallback tells it which. Handling mods are local to the driver |
| Weapons |
Your player's weapon is drawn on partners' machines by its record; for a partner without the mod name a game weapon with fallback = (NightCoop's stand-ins for missing records use it). Damage goes through the game's damage pipeline and NightCoop's claims: do not apply damage to partners' NPCs yourself |
| Quickhacks, status effects on NPCs |
An NPC is led by its owner: change only NPCs entityIsMine says are yours; ask the owner by a channel message for the rest (and see the NPC functions of the SDK for ready requests) |
| Spawns (NPCs, props, vehicles) |
Spawn on the host (or ask it), entityShare them; a purely local helper — entityKeepLocal |
| Quests, scenes, phases |
The host's world decides quests: keep your mod's per-player facts with factKeepLocal / factKeepLocalList (or fact = in a manifest); never mark facts of the main story |
| Script systems with state (needs, economy, a gig board) |
Decide whose it is: the character's (default) or the world's (scriptableScope, world_system = in a manifest) |
| Time, weather, the world's pace |
The host leads the world's time and weather: a guest's change is undone. On the host change them as in single player; a guest asks with worldAsk (time of day, weather — the host's copy of your mod or a vote decides). The pace of time cannot be asked (no setter in the game's scripts): a pace mod is data that every player needs. Mind NCSDK_EV_TIME_SKIP to count skipped hours |
| Settings of your mod that must be the same for all |
Keep them in the shared state: the host writes stateSet("settings", …) from its own settings, guests read stateGet / stateChanged and use those while in the session (their own again after NCSDK_EV_SESSION_END) |
| Menus and decisions for everybody |
Your items: menuItemSet (co-op menu «Mods…»); a question to all: voteStart on the host |
| Saves |
See below |
Saves and save locks
In a session the host's saves are the session's; a guest's saves in the host's world are NightCoop's technical saves of
the guest's character. A mod that blocks saving (a save lock of its own, GameInstance.GetSaveLocks) blocks it only on
the machine it runs on: on the host that is the session's save — the save a guest joins by waits until the game lets the
host save. Release your locks when your moment is over; write your own files of the session after NCSDK_EV_HOST_SAVE
(value 1) and read them back after value 2 (the host loaded).
Common mistakes
- A guest changes the world itself. The guest's game diverges: different weather, different NPCs, different quest state.
Route it through the host (step 3).
- Sending every frame, reliably. The rate limit answers
NCSDK_E_LIMIT and your data stops. Send on change; use a stream
or LATEST_ONLY for continuous values.
- Keeping a partner's
EntityID as a permanent handle. Mirrors are re-created under new EntityIDs and NPCs change
owners. Keep the network reference (or nothing) and ask EntityOfRef when you need the entity; let entity state push data to
the current mirror.
- Changing an entity you do not lead.
SetEntityState answers NCSDK_E_DENIED. A mirror or copy is the owner's thing:
ask the owner by channel.
- Treating "no answer" as "no".
PeerHasMod returns -2 until the mods check with that player is over. Wait for the
PEER_MOD / PEER_NO_MOD event.
- Storing peer numbers in a save. They belong to one session.
- Reusing an id or protocol after an incompatible change. Raise
protocol so old copies stay silent instead of
misreading your data.
- Copying NightCoop's
.reds files into your redscript mod. Do not; NightCoop adds them itself.
- Replacing a game function co-op relies on.
@replaceMethod, a CET Override or a detour that does not call the
original can switch a part of co-op off silently. See do-not-hook.md — and its section on what NightCoop
cannot find by itself.
This page is docs/make-your-mod-coop.md of the SDK.