NightCoop
SDK · Code

Examples

A shared counter three ways: a RED4ext plugin in C++, a redscript mod and a Cyber Engine Tweaks mod. Each registers the mod, keeps the count in shared state on the host and shows every change on every player's screen.

The SDK and its documentation are in English.

Shared counter, C++ (RED4ext plugin)

A RED4ext plugin that uses the NightCoop SDK directly through nightcoop_sdk.h.

What it does:

  • Registers the mod example.counter.c (protocol 1) the first time it finds NightCoop.dll (checked once a second from the game thread, in the RED4ext Running state).
  • The counter lives in the mod's shared state under the key count (decimal text). The host owns it.
  • Only the host counts here: every 30 seconds the host adds 1 and sends a ping message (channel 1) to everyone who has the mod. This keeps the example short; the host timer is not part of the SDK.
  • Every player, host and guests, gets stateChanged for count: it is written to NightCoop's log and shown at the bottom of the screen. The ping text is shown too.
  • A guest that calls stateSet("count", ...) would only send a request. The host side of that is the stateRequest hook in the code: it accepts only the key count with exactly the current value plus 1. (Two guests asking at the same moment with the same number: the second request is refused. For a real counter, send "add 1" as a channel message and let the host do the arithmetic.)

Build

You need Visual Studio 2022 (C++ workload) and a checkout of RED4ext.SDK (header only).

.\build.ps1 -Red4extSdk C:\src\RED4ext.SDK\include
# optional: -NightCoopSdk <folder with nightcoop_sdk.h>   (default: ..\..\include)
# optional: -OutDir <folder>                              (default: .\build)

The result is build\NightCoopCounterExample.dll.

Install

Copy the dll to the game folder:

Cyberpunk 2077\red4ext\plugins\NightCoopCounterExample\NightCoopCounterExample.dll

NightCoop must be installed too (red4ext\plugins\NightCoop\NightCoop.dll). Every player of a session needs the example for the counter to travel between them (the mod's passport and protocol must match).

What you should see

In NightCoop's log, lines of this mod start with sdk: [example.counter.c]. Once NightCoop is up:

registered, NightCoop build <stamp>

Host and guest in a session, both with the plugin:

  • on each side a line that the other player has the counter mod (NCSDK_EV_PEER_MOD);
  • every 30 s on the host: count = N (set by ...), the same line on the guest a moment later, and the on-screen message Shared counter: N on both;
  • a ping message <name> bumped the counter on the guest.

A partner without the plugin sees nothing, and (because of NCSDK_MOD_NEEDED_BY_ALL) both players get a short note that the mod is missing on the other side.

See ../../docs/getting-started-c.md for the connection steps this file follows.

Code

examples/c-counter/counter.cpp10.2 KBDownload
// SPDX-License-Identifier: MIT — Copyright (c) 2026 NightCoop (see sdk/LICENSE)
// NightCoop SDK example: a shared counter as a RED4ext plugin (C++).
//
// What it does
//   - Finds NightCoop.dll lazily (RED4ext "Running" state, once a second until found) and registers the mod
//     "example.counter.c" with hooks.
//   - The host keeps the counter in shared state under the key "count". Every 30 s the host adds 1 (the host is the only
//     one that counts here, to keep the example short) and sends a "ping" message (channel 1) to everyone.
//   - Every player (host and guests) sees stateChanged for "count": it is logged and shown on screen. The ping text is
//     shown on screen too.
//   - stateRequest (host side) shows how the host judges a guest's request: a guest may only add exactly 1.
//
// Build: see README.md. Needs nightcoop_sdk.h (../../include) and RED4ext.SDK (header only).

#include <RED4ext/RED4ext.hpp>

#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>

#include "nightcoop_sdk.h"

namespace
{
constexpr const char* kModId = "example.counter.c"; // ASCII [a-z0-9._-], 3..64 bytes, stable forever
constexpr const char* kStateKey = "count";
constexpr uint16_t kChannelPing = 1;
constexpr float kBumpPeriodS = 30.0f;

const NcSdkApi* g_api = nullptr; // set when NightCoop is found; cleared by hooks.shutdown
NcSdkMod g_mod = 0;
ULONGLONG g_lastLookupMs = 0;
float g_timerS = 0.0f;
RED4ext::v1::GameState g_runningState; // lives as long as the plugin

// --------------------------------------------------------------------------------------------------------- helpers

void Logf(int32_t aLevel, const char* aFormat, ...)
{
    if (!g_api || !g_mod)
    {
        return;
    }
    char buffer[256];
    va_list args;
    va_start(args, aFormat);
    vsnprintf(buffer, sizeof(buffer), aFormat, args);
    va_end(args);
    g_api->log(g_mod, aLevel, buffer);
}

void PeerName(NcSdkPeer aPeer, char* aOut, size_t aCap)
{
    aOut[0] = 0;
    if (aPeer != NCSDK_PEER_NONE)
    {
        g_api->peerName(aPeer, aOut, aCap);
    }
    if (aOut[0] == 0)
    {
        snprintf(aOut, aCap, "peer %u", static_cast<unsigned>(aPeer));
    }
}

// The counter as the shared state has it now (0 if the key is not set yet).
long long ReadCount()
{
    char buffer[32];
    const int32_t size = g_api->stateGet(g_mod, kStateKey, buffer, sizeof(buffer) - 1);
    if (size <= 0) // NCSDK_E_UNKNOWN: no such key yet
    {
        return 0;
    }
    buffer[size] = 0;
    return atoll(buffer);
}

// Host only: add 1, spread it, tell everyone.
void Bump()
{
    char text[32];
    const int length = snprintf(text, sizeof(text), "%lld", ReadCount() + 1);

    // The host stores the value, calls our stateChanged and sends it to every peer that has the mod.
    // (A guest would call exactly the same: its stateSet is a request to the host.)
    const int32_t result = g_api->stateSet(g_mod, kStateKey, text, static_cast<size_t>(length));
    if (result < 0)
    {
        Logf(NCSDK_LOG_WARN, "stateSet failed: %d", result);
        return;
    }

    // A message on our own channel to everyone who has the mod. Unreliable (flags 0): a missed ping does not matter.
    static const char kPing[] = "bumped the counter";
    g_api->send(g_mod, NCSDK_TO_ALL, kChannelPing, kPing, sizeof(kPing) - 1, 0);
}

// --------------------------------------------------------------------------------------------------------- hooks

void OnFrame(void*, float aDt)
{
    if (!g_api || !g_mod)
    {
        return;
    }
    g_timerS += aDt;
    if (g_timerS < kBumpPeriodS)
    {
        return;
    }
    g_timerS = 0.0f;

    NcSdkWhere where = {};
    where.size = sizeof(where);
    g_api->where(&where);
    if (where.inSession && where.isHost && where.inWorld) // only the host counts
    {
        Bump();
    }
}

// Hooks come from NightCoop's frame (never from inside registerMod); after hooks.shutdown g_api is NULL — check first.
void OnEvent(void*, int32_t aEvent, NcSdkPeer aPeer)
{
    if (!g_api || !g_mod)
    {
        return;
    }
    char name[64];
    switch (aEvent)
    {
    case NCSDK_EV_SESSION_START:
        Logf(NCSDK_LOG_INFO, "session started, we are peer %u", static_cast<unsigned>(aPeer));
        break;
    case NCSDK_EV_SESSION_END:
        Logf(NCSDK_LOG_INFO, "session ended");
        break;
    case NCSDK_EV_PEER_MOD:
        PeerName(aPeer, name, sizeof(name));
        Logf(NCSDK_LOG_INFO, "%s has the counter mod", name);
        break;
    case NCSDK_EV_PEER_NO_MOD:
        PeerName(aPeer, name, sizeof(name));
        Logf(NCSDK_LOG_INFO, "%s does not have the counter mod", name);
        break;
    default:
        break;
    }
}

void OnMessage(void*, NcSdkPeer aFrom, uint16_t aChannel, const uint8_t* aData, size_t aSize)
{
    if (!g_api || !g_mod || aChannel != kChannelPing)
    {
        return;
    }
    char name[64];
    PeerName(aFrom, name, sizeof(name));

    char text[160];
    snprintf(text, sizeof(text), "%s %.*s", name, static_cast<int>(aSize), reinterpret_cast<const char*>(aData));
    g_api->showMessage(g_mod, text, 3.0f, NCSDK_TONE_INFO);
}

void OnStateChanged(void*, const char* aKey, const uint8_t* aData, size_t aSize, NcSdkPeer aBy)
{
    if (!g_api || !g_mod || strcmp(aKey, kStateKey) != 0)
    {
        return;
    }
    char by[64];
    PeerName(aBy, by, sizeof(by));

    if (aSize == 0) // removed (also what a session end looks like)
    {
        Logf(NCSDK_LOG_INFO, "counter removed (by %s)", by);
        return;
    }
    char value[32];
    snprintf(value, sizeof(value), "%.*s", static_cast<int>(aSize), reinterpret_cast<const char*>(aData));
    Logf(NCSDK_LOG_INFO, "count = %s (set by %s)", value, by);

    char text[64];
    snprintf(text, sizeof(text), "Shared counter: %s", value);
    g_api->showMessage(g_mod, text, 2.0f, NCSDK_TONE_QUIET);
}

// Host only: a guest asks to set a key. Return 1 to accept, 0 to refuse.
int32_t OnStateRequest(void*, NcSdkPeer, const char* aKey, const uint8_t* aData, size_t aSize)
{
    if (!g_api || !g_mod || strcmp(aKey, kStateKey) != 0 || aSize == 0 || aSize > 18)
    {
        return 0; // only our key, and no removal
    }
    char value[32];
    snprintf(value, sizeof(value), "%.*s", static_cast<int>(aSize), reinterpret_cast<const char*>(aData));
    return atoll(value) == ReadCount() + 1 ? 1 : 0; // a guest may add exactly 1
}

void OnShutdown(void*)
{
    // NightCoop.dll is unloading: its table is about to disappear. Never call it again.
    g_api = nullptr;
    g_mod = 0;
}

// --------------------------------------------------------------------------------------------------------- connecting

void TryConnect()
{
    // Plugins load in an unknown order, so NightCoop may not be loaded when our Main runs. Look from the game thread,
    // at most once a second.
    const ULONGLONG now = GetTickCount64();
    if (now - g_lastLookupMs < 1000)
    {
        return;
    }
    g_lastLookupMs = now;

    const HMODULE nightCoop = GetModuleHandleW(L"NightCoop.dll");
    if (!nightCoop)
    {
        return; // not loaded (yet)
    }
    const auto get = reinterpret_cast<NcSdkGetFn>(GetProcAddress(nightCoop, NCSDK_EXPORT_NAME));
    if (!get)
    {
        return;
    }
    const NcSdkApi* api = get(NCSDK_VERSION);
    if (!api)
    {
        return; // no interface of this version, or the player switched the SDK off: behave as if NightCoop were absent
    }

    // Hooks we leave NULL are not an error: items of that kind wait in the mod's poll queue, which this mod never reads.
    static NcSdkHooks hooks = {};
    hooks.size = sizeof(hooks);
    hooks.frame = &OnFrame;
    hooks.event = &OnEvent;
    hooks.message = &OnMessage;
    hooks.stateChanged = &OnStateChanged;
    hooks.stateRequest = &OnStateRequest;
    hooks.shutdown = &OnShutdown;

    NcSdkModInfo info = {};
    info.size = sizeof(info);
    // Every player should have it, and the host decides the value: guests only ask.
    // What the mod uses — shared state and channels: an undeclared use answers NCSDK_E_DENIED.
    info.flags = NCSDK_MOD_NEEDED_BY_ALL | NCSDK_MOD_HOST_AUTHORITY | NCSDK_MOD_USES_SHARED_STATE | NCSDK_MOD_USES_CHANNELS;
    info.id = kModId;
    info.name = "Shared counter (C++ example)";
    info.version = "1.0.0";
    info.protocol = 1; // our own wire version: bump it when the meaning of "count" or the ping changes
    info.hooks = &hooks;

    g_api = api; // the first events (session, peers) come with NightCoop's next frame, not from inside registerMod
    const NcSdkMod mod = api->registerMod(&info);
    if (!mod)
    {
        g_api = nullptr; // refused (bad id, already registered, too many mods); NightCoop's log says why. Retry later.
        return;
    }
    g_mod = mod;
    Logf(NCSDK_LOG_INFO, "registered, NightCoop build %s", api->build ? api->build : "?");
}

bool OnRunningUpdate(RED4ext::CGameApplication*)
{
    if (!g_mod)
    {
        TryConnect();
    }
    return false; // the result does not matter in the Running state
}
} // namespace

// --------------------------------------------------------------------------------------------------------- RED4ext

RED4EXT_C_EXPORT bool RED4EXT_CALL Main(RED4ext::v1::PluginHandle aHandle, RED4ext::v1::EMainReason aReason,
                                        const RED4ext::v1::Sdk* aSdk)
{
    switch (aReason)
    {
    case RED4ext::v1::EMainReason::Load:
    {
        g_runningState.OnEnter = nullptr;
        g_runningState.OnUpdate = &OnRunningUpdate;
        g_runningState.OnExit = nullptr;
        aSdk->gameStates->Add(aHandle, RED4ext::EGameStateType::Running, &g_runningState);
        aSdk->logger->Info(aHandle, "NightCoop counter example loaded");
        break;
    }
    case RED4ext::v1::EMainReason::Unload:
    {
        if (g_api && g_mod)
        {
            g_api->unregisterMod(g_mod);
        }
        g_api = nullptr;
        g_mod = 0;
        break;
    }
    }
    return true;
}

RED4EXT_C_EXPORT void RED4EXT_CALL Query(RED4ext::v1::PluginInfo* aInfo)
{
    aInfo->name = L"NightCoop SDK example: counter";
    aInfo->author = L"NightCoop";
    aInfo->version = RED4EXT_V1_SEMVER(1, 0, 0);
    aInfo->runtime = RED4EXT_V1_RUNTIME_VERSION_INDEPENDENT; // we only use the RED4ext API, not the game's memory
    aInfo->sdk = RED4EXT_V1_SDK_VERSION_CURRENT;
}

RED4EXT_C_EXPORT uint32_t RED4EXT_CALL Supports()
{
    return RED4EXT_API_VERSION_1;
}
examples/c-counter/build.ps12.8 KBDownload
# Builds counter.cpp into build\NightCoopCounterExample.dll with MSVC (cl.exe from a Visual Studio 2022 x64 environment).
#
#   .\build.ps1 -Red4extSdk C:\src\RED4ext.SDK\include
#   .\build.ps1 -Red4extSdk C:\src\RED4ext.SDK\include -NightCoopSdk C:\src\nightcoop\sdk\include
#
# -Red4extSdk   the include folder of RED4ext.SDK (the one that contains the RED4ext\ folder). The SDK is header only.
# -NightCoopSdk the folder with nightcoop_sdk.h (default: ..\..\include, next to this example)
# -OutDir       where the dll goes (default: .\build)
param(
    [Parameter(Mandatory = $true)][string]$Red4extSdk,
    [string]$NightCoopSdk = (Join-Path $PSScriptRoot '..\..\include'),
    [string]$OutDir = (Join-Path $PSScriptRoot 'build'),
    [string]$Name = 'NightCoopCounterExample'
)

$ErrorActionPreference = 'Stop'

$Red4extSdk = (Resolve-Path $Red4extSdk).Path
$NightCoopSdk = (Resolve-Path $NightCoopSdk).Path
if (-not (Test-Path (Join-Path $Red4extSdk 'RED4ext\RED4ext.hpp'))) {
    throw "RED4ext\RED4ext.hpp not found in $Red4extSdk (pass the include folder of RED4ext.SDK)"
}
if (-not (Test-Path (Join-Path $NightCoopSdk 'nightcoop_sdk.h'))) {
    throw "nightcoop_sdk.h not found in $NightCoopSdk"
}

# Visual Studio's environment (vcvars64.bat) through vswhere.
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (-not (Test-Path $vswhere)) { throw 'vswhere.exe not found: install Visual Studio 2022 with the C++ workload' }
$vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if (-not $vsPath) { throw 'No Visual Studio with the C++ x64 tools found' }
$vcvars = Join-Path $vsPath 'VC\Auxiliary\Build\vcvars64.bat'

New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$OutDir = (Resolve-Path $OutDir).Path

# The GPU headers of RED4ext.SDK want D3D12MemAlloc, which sits next to its include folder.
$includes = @("/I`"$Red4extSdk`"", "/I`"$NightCoopSdk`"")
$vendor = Join-Path (Split-Path $Red4extSdk -Parent) 'vendor\D3D12MemAlloc'
if (Test-Path $vendor) { $includes += "/I`"$vendor`"" }

$source = Join-Path $PSScriptRoot 'counter.cpp'
$dll = Join-Path $OutDir "$Name.dll"
$cl = "cl.exe /nologo /std:c++20 /EHsc /O2 /MD /utf-8 /DWIN32_LEAN_AND_MEAN /DNOMINMAX /LD $($includes -join ' ') " +
      "`"$source`" /Fo`"$OutDir\\`" /Fe`"$dll`" /link /INCREMENTAL:NO"

# A small batch file keeps the quoting simple: set up the compiler environment, then compile.
$bat = Join-Path $OutDir 'build-temp.cmd'
Set-Content -Path $bat -Value @('@echo off', "call `"$vcvars`" >nul", $cl) -Encoding ASCII
try {
    & cmd.exe /c $bat
    if ($LASTEXITCODE -ne 0) { throw "cl.exe failed with exit code $LASTEXITCODE" }
}
finally {
    Remove-Item $bat -ErrorAction SilentlyContinue
}

Write-Host "Built $dll"

Shared counter, redscript

A redscript mod that uses the NightCoop SDK through the native class NightCoopSdk.

  • Registers the mod example.counter.redscript (protocol 1) in ScriptableSystem.OnAttach. The call is repeated on every game load, which is safe.
  • The counter is shared state under the key count, kept by the host. Bump() adds 1: on the host it sets the value, on a guest the same call is a request to the host. Then it sends a ping message (channel 1) to everyone who has the mod.
  • OnCoopEvent (registered with Listen) receives the events: the new value of count (logged and shown on screen), the ping text (shown on screen) and the "partner has / does not have the mod" session events (logged).
  • All NightCoop code is under @if(ModuleExists("NightCoop")). Without NightCoop the stub class at the bottom of the file keeps Bump() callable and does nothing.

Install

Put CoopCounter.reds where your redscript mods go:

Cyberpunk 2077\r6\scripts\CoopCounterExample\CoopCounter.reds

NightCoop provides NightCoopSdk and the NightCoop module to the redscript compiler itself when it loads. Do not copy NightCoop's .reds files into your mod: two definitions of the same class break the compilation of every script.

Every player of a session needs the example for the counter to travel between them.

Use

There is no key binding in the script. Call Bump() from the Cyber Engine Tweaks console:

Game.GetScriptableSystemsContainer():Get("CoopCounterSystem"):Bump()

(Get takes a CName; Cyber Engine Tweaks converts a plain string for it. The system is created by the game when a save is loaded.)

Each call prints count = N (set by peer P) to NightCoop's log (lines of this mod start with sdk: [example.counter.redscript]) and shows Shared counter: N on every player's screen; partners also see <name> bumped the counter.

See ../../docs/getting-started-redscript.md.

Code

examples/redscript-counter/CoopCounter.reds4.4 KBDownload
// SPDX-License-Identifier: MIT — Copyright (c) 2026 NightCoop (see sdk/LICENSE)
// NightCoop SDK example: a shared counter in redscript.
//
// - The counter is shared state of the mod "example.counter.redscript", key "count" (decimal text). The host keeps it.
// - Bump() adds 1: on the host it sets the state, on a guest it asks the host (the same call). Then it sends a "ping"
//   message (channel 1) to everyone who has the mod.
// - Everybody gets the new value as a State event and the ping as a Message event, through the callback OnCoopEvent.
//
// Call Bump() from the CET console:
//   Game.GetScriptableSystemsContainer():Get("CoopCounterSystem"):Bump()
//
// Everything that touches NightCoop is under @if(ModuleExists("NightCoop")); without NightCoop the stub at the bottom
// keeps Bump() callable and the mod compiles and runs as before. Do NOT copy NightCoop's own .reds files into your mod.

@if(ModuleExists("NightCoop"))
import NightCoop.*

@if(ModuleExists("NightCoop"))
public class CoopCounterSystem extends ScriptableSystem {

  private func ModId() -> String {
    return "example.counter.redscript"; // ASCII [a-z0-9._-], 3..64 bytes, stable forever
  }

  // Runs on every game load: registering again with the same id is safe (it updates the entry), and Listen has to be
  // called again because the target is held weakly and dies with the world.
  private func OnAttach() -> Void {
    if NightCoopSdk.Version() == 0 {
      return; // the player switched the SDK off
    }
    // Every player should have it; the host decides the value, guests only ask. Declare what the mod uses (shared state,
    // channels): an undeclared use answers Denied.
    let flags: Int32 = EnumInt(NightCoopSdkModFlag.NeededByAll) | EnumInt(NightCoopSdkModFlag.HostAuthority)
      | EnumInt(NightCoopSdkModFlag.UsesSharedState) | EnumInt(NightCoopSdkModFlag.UsesChannels);
    if !NightCoopSdk.RegisterMod(this.ModId(), "Shared counter (redscript example)", "1.0.0", 1, flags) {
      return; // refused, for instance the id is registered by a C plugin; NightCoop's log says why
    }
    NightCoopSdk.Listen(this.ModId(), this, n"OnCoopEvent");
  }

  private func OnDetach() -> Void {
    NightCoopSdk.Listen(this.ModId(), null, n""); // stop the callback; events wait in the queue until the next Listen
  }

  // Add 1 to the shared counter and tell everyone.
  public func Bump() -> Void {
    let id: String = this.ModId();
    // Out of a session the state is purely local, so this also works as a single-player counter.
    let current: String = NightCoopSdk.GetState(id, "count"); // "" if the key is not set yet
    let next: Int32 = StringToInt(current, 0) + 1;
    let result: Int32 = NightCoopSdk.SetState(id, "count", IntToString(next));
    if result < 0 {
      NightCoopSdk.Log(id, 2, "SetState failed: " + IntToString(result));
      return;
    }
    // to = -2: every peer that has the mod. reliable = false: a missed ping does not matter.
    NightCoopSdk.Send(id, -2, 1, "bumped the counter", false);
  }

  // Called by NightCoop on the game thread, once per event. The function must take exactly one ref<NightCoopSdkEvent>.
  protected cb func OnCoopEvent(evt: ref<NightCoopSdkEvent>) -> Void {
    let id: String = this.ModId();

    if evt.kind == EnumInt(NightCoopSdkEventKind.State) {
      if Equals(evt.key, "count") {
        if evt.removed {
          NightCoopSdk.Log(id, 1, "counter removed");
        } else {
          NightCoopSdk.Log(id, 1, "count = " + evt.data + " (set by peer " + IntToString(evt.peer) + ")");
          NightCoopSdk.ShowMessage(id, "Shared counter: " + evt.data, 2.0, 0);
        };
      };
      return;
    };

    if evt.kind == EnumInt(NightCoopSdkEventKind.Message) {
      if evt.a == 1 {
        NightCoopSdk.ShowMessage(id, NightCoopSdk.PeerName(evt.peer) + " " + evt.data, 3.0, 1);
      };
      return;
    };

    if evt.kind == EnumInt(NightCoopSdkEventKind.Session) {
      if evt.a == EnumInt(NightCoopSdkSessionEvent.PeerHasMod) {
        NightCoopSdk.Log(id, 1, NightCoopSdk.PeerName(evt.peer) + " has the counter mod");
      };
      if evt.a == EnumInt(NightCoopSdkSessionEvent.PeerNoMod) {
        NightCoopSdk.Log(id, 1, NightCoopSdk.PeerName(evt.peer) + " does not have the counter mod");
      };
    };
  }
}

// Without NightCoop: same class name and method, nothing happens.
@if(!ModuleExists("NightCoop"))
public class CoopCounterSystem extends ScriptableSystem {

  public func Bump() -> Void {
  }
}

Shared counter, Cyber Engine Tweaks (Lua)

A CET mod that uses the NightCoop SDK through the native class NightCoopSdk and the helper nightcoop_sdk.lua.

  • Registers the mod example.counter.lua (protocol 1) in onInit; poll() registers again by itself if NightCoop was not ready yet.
  • The counter is shared state under the key count, kept by the host. The hotkey Shared counter +1 adds 1: on the host it sets the value, on a guest the same call is a request to the host. Then it sends a ping message (channel 1) to everyone who has the mod.
  • onUpdate calls counter:poll(onEvent). The handler logs the new value, shows it on screen, shows the ping text, and logs when a partner has or lacks the mod.
  • Without NightCoop (or with its SDK switched off) every helper call does nothing, and the hotkey prints one line.

Install

Copy two files into one mod folder:

Cyberpunk 2077\bin\x64\plugins\cyber_engine_tweaks\mods\CoopCounterExample\init.lua
Cyberpunk 2077\bin\x64\plugins\cyber_engine_tweaks\mods\CoopCounterExample\nightcoop_sdk.lua   <- from sdk\cet\

require("nightcoop_sdk") loads the helper from the same folder. Every player of a session needs the example for the counter to travel between them.

Bind the hotkey in the CET overlay: Bindings, entry Shared counter +1 of this mod.

What you should see

Host and guest in a session, both with the mod: after a hotkey press on either side, Shared counter: N at the bottom of both screens, count = N (set by peer P) in NightCoop's log (lines of this mod start with sdk: [example.counter.lua]), and on the other player the ping <name> bumped the counter.

A press on a guest is a request: the value changes when the host's answer comes back. Two players pressing at the same moment can lose one increment; for a real counter send "add 1" as a channel message and let the host add.

No redscript is needed: without it the helper reads events through NightCoopSdk.PollEventText.

See ../../docs/getting-started-cet.md.

Code

examples/lua-counter/init.lua3.0 KBDownload
-- SPDX-License-Identifier: MIT — Copyright (c) 2026 NightCoop (see sdk/LICENSE)
-- NightCoop SDK example: a shared counter as a Cyber Engine Tweaks mod.
--
-- Put this file and nightcoop_sdk.lua (from sdk/cet/) in the same mod folder:
--   bin/x64/plugins/cyber_engine_tweaks/mods/CoopCounterExample/init.lua
--   bin/x64/plugins/cyber_engine_tweaks/mods/CoopCounterExample/nightcoop_sdk.lua
--
-- The counter is shared state of the mod "example.counter.lua", key "count" (decimal text), kept by the host.
-- The hotkey adds 1: on the host it sets the value, on a guest the same call is a request to the host. A "ping"
-- message (channel 1) goes to everyone who has the mod. Everybody sees the new value and the ping.

local Sdk = require("nightcoop_sdk")

local CHANNEL_PING = 1

local counter = Sdk.new(
    "example.counter.lua",            -- stable unique id: ASCII [a-z0-9._-], 3..64 bytes
    "Shared counter (Lua example)",   -- display name
    "1.0.0",                          -- your mod's version
    1,                                -- protocol: your own wire version
    Sdk.FLAG.NEEDED_BY_ALL + Sdk.FLAG.HOST_AUTHORITY + Sdk.FLAG.USES_SHARED_STATE + Sdk.FLAG.USES_CHANNELS
)

local function currentCount()
    return tonumber(counter:getState("count") or "0") or 0
end

local function bump()
    if not Sdk.available() then
        print("[counter] NightCoop is not installed (or its SDK is switched off)")
        return
    end
    local result = counter:setState("count", tostring(currentCount() + 1))
    if result < 0 then
        counter:log(Sdk.LOG.WARN, "SetState failed: " .. tostring(result))
        return
    end
    -- to = Sdk.PEER_ALL: every peer that has the mod. reliable = false: a missed ping does not matter.
    counter:send(Sdk.PEER_ALL, CHANNEL_PING, "bumped the counter", false)
end

local function onEvent(ev)
    if ev.kind == Sdk.EVENT.STATE and ev.key == "count" then
        if ev.removed then
            counter:log(Sdk.LOG.INFO, "counter removed")
        else
            counter:log(Sdk.LOG.INFO, "count = " .. ev.data .. " (set by peer " .. tostring(ev.peer) .. ")")
            counter:showMessage("Shared counter: " .. ev.data, 2.0, Sdk.TONE.QUIET)
        end

    elseif ev.kind == Sdk.EVENT.MESSAGE and ev.a == CHANNEL_PING then
        counter:showMessage(Sdk.peerName(ev.peer) .. " " .. ev.data, 3.0, Sdk.TONE.INFO)

    elseif ev.kind == Sdk.EVENT.SESSION then
        if ev.a == Sdk.SESSION.PEER_MOD then
            counter:log(Sdk.LOG.INFO, Sdk.peerName(ev.peer) .. " has the counter mod")
        elseif ev.a == Sdk.SESSION.PEER_NO_MOD then
            counter:log(Sdk.LOG.INFO, Sdk.peerName(ev.peer) .. " does not have the counter mod")
        end
    end
end

registerForEvent("onInit", function()
    counter:register() -- safe to repeat; poll() also retries if NightCoop was not ready yet
end)

registerForEvent("onUpdate", function(dt)
    counter:poll(onEvent) -- at most 32 events per frame
end)

registerHotkey("coop_counter_bump", "Shared counter +1", bump)