/*
* CSB KZ Timer
* Copyright (C) 2026 counter-strike-boost.com
*
* A Kreedz (climb) timer. The run starts when a player presses a start button
* (targetname contains "start") and stops on the end/counter button. A live
* HUD shows the elapsed time and jump count, checkpoints let you save/teleport
* (/cp, /tp), and finish times are stored per map in nVault. /pb shows your
* personal best, /top shows the map's ten best runs.
*
* Inspired by the classic KZ Timer / xJ Timer plugins. Independent GPL
* re-implementation.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version. Distributed WITHOUT ANY WARRANTY. See the GNU General
* Public License for more details: .
*/
#include
#include
#include
#include
#include
new const PLUGIN[] = "CSB KZ Timer"
new const VERSION[] = "1.0.0"
new const AUTHOR[] = "counter-strike-boost.com"
#define TOP_MAX 10
new g_pEnabled
new bool:g_bRunning[33]
new Float:g_fStart[33]
new g_iJumps[33]
new bool:g_bHasCp[33]
new Float:g_fCp[33][3]
new Float:g_fCpAngles[33][3]
new g_iVault
new g_szMap[32]
// in-memory top table for the current map
new Float:g_fTopTime[TOP_MAX]
new g_szTopName[TOP_MAX][32]
new g_iTopCount
public plugin_init()
{
register_plugin(PLUGIN, VERSION, AUTHOR)
g_pEnabled = register_cvar("csb_kz_enabled", "1")
RegisterHam(Ham_Use, "func_button", "fwButtonUse", 1)
register_forward(FM_PlayerPreThink, "fwPreThink")
register_clcmd("say /cp", "cmdSaveCp")
register_clcmd("say /tp", "cmdTeleCp")
register_clcmd("say /start", "cmdRestart")
register_clcmd("say /pb", "cmdPersonalBest")
register_clcmd("say /top", "cmdTop")
get_mapname(g_szMap, charsmax(g_szMap))
g_iVault = nvault_open("csb_kz_timer")
loadTop()
set_task(0.1, "taskHud", _, _, _, "b")
}
public plugin_end()
{
if (g_iVault != INVALID_HANDLE)
nvault_close(g_iVault)
}
public client_putinserver(id)
{
resetRun(id)
g_bHasCp[id] = false
}
resetRun(id)
{
g_bRunning[id] = false
g_fStart[id] = 0.0
g_iJumps[id] = 0
}
public fwButtonUse(ent, idcaller, idactivator, use_type, Float:value)
{
if (!get_pcvar_num(g_pEnabled))
return HAM_IGNORED
if (idactivator < 1 || idactivator > 32 || !is_user_alive(idactivator))
return HAM_IGNORED
new tname[64]
pev(ent, pev_targetname, tname, charsmax(tname))
strtolower(tname)
if (contain(tname, "start") != -1)
{
startRun(idactivator)
}
else if (contain(tname, "end") != -1 || contain(tname, "finish") != -1 || contain(tname, "counter") != -1)
{
stopRun(idactivator)
}
return HAM_IGNORED
}
startRun(id)
{
g_bRunning[id] = true
g_fStart[id] = get_gametime()
g_iJumps[id] = 0
client_print(id, print_center, "Timer started -- go!")
}
stopRun(id)
{
if (!g_bRunning[id])
return
new Float:elapsed = get_gametime() - g_fStart[id]
g_bRunning[id] = false
new name[32]
get_user_name(id, name, charsmax(name))
new tbuf[16]
formatTime(elapsed, tbuf, charsmax(tbuf))
client_print(0, print_chat, "[KZ] %s finished %s in %s (%d jumps).", name, g_szMap, tbuf, g_iJumps[id])
savePersonal(id, elapsed)
tryInsertTop(name, elapsed)
}
public fwPreThink(id)
{
if (!is_user_alive(id))
return
new button = pev(id, pev_button)
new oldbutton = pev(id, pev_oldbuttons)
new flags = pev(id, pev_flags)
// count a take-off: jump pressed this frame, not last, while on ground
if ((button & IN_JUMP) && !(oldbutton & IN_JUMP) && (flags & FL_ONGROUND))
{
if (g_bRunning[id])
g_iJumps[id]++
}
}
public cmdSaveCp(id)
{
if (!is_user_alive(id))
return PLUGIN_HANDLED
pev(id, pev_origin, g_fCp[id])
pev(id, pev_v_angle, g_fCpAngles[id])
g_bHasCp[id] = true
client_print(id, print_center, "Checkpoint saved.")
return PLUGIN_HANDLED
}
public cmdTeleCp(id)
{
if (!is_user_alive(id))
return PLUGIN_HANDLED
if (!g_bHasCp[id])
{
client_print(id, print_chat, "[KZ] No checkpoint saved yet. Use /cp first.")
return PLUGIN_HANDLED
}
set_pev(id, pev_origin, g_fCp[id])
set_pev(id, pev_velocity, Float:{0.0, 0.0, 0.0})
set_pev(id, pev_v_angle, g_fCpAngles[id])
set_pev(id, pev_fixangle, 1)
client_print(id, print_center, "Teleported to checkpoint.")
return PLUGIN_HANDLED
}
public cmdRestart(id)
{
resetRun(id)
client_print(id, print_center, "Run reset. Hit the start button to begin.")
return PLUGIN_HANDLED
}
public cmdPersonalBest(id)
{
new authid[35], key[80], data[16], timestamp
get_user_authid(id, authid, charsmax(authid))
formatex(key, charsmax(key), "%s#%s", g_szMap, authid)
if (nvault_lookup(g_iVault, key, data, charsmax(data), timestamp))
{
new Float:best = str_to_float(data)
new tbuf[16]
formatTime(best, tbuf, charsmax(tbuf))
client_print(id, print_chat, "[KZ] Your personal best on %s: %s", g_szMap, tbuf)
}
else
{
client_print(id, print_chat, "[KZ] You have no recorded time on %s yet.", g_szMap)
}
return PLUGIN_HANDLED
}
public cmdTop(id)
{
if (g_iTopCount == 0)
{
client_print(id, print_chat, "[KZ] No records on %s yet -- be the first!", g_szMap)
return PLUGIN_HANDLED
}
client_print(id, print_chat, "[KZ] Top times on %s:", g_szMap)
for (new i = 0; i < g_iTopCount; i++)
{
new tbuf[16]
formatTime(g_fTopTime[i], tbuf, charsmax(tbuf))
client_print(id, print_chat, " %d. %s -- %s", i + 1, g_szTopName[i], tbuf)
}
return PLUGIN_HANDLED
}
savePersonal(id, Float:elapsed)
{
new authid[35], key[80], data[16], stored[16], timestamp
get_user_authid(id, authid, charsmax(authid))
formatex(key, charsmax(key), "%s#%s", g_szMap, authid)
new bool:save = true
if (nvault_lookup(g_iVault, key, stored, charsmax(stored), timestamp))
{
if (str_to_float(stored) <= elapsed)
save = false
}
if (save)
{
formatex(data, charsmax(data), "%f", elapsed)
nvault_set(g_iVault, key, data)
client_print(id, print_chat, "[KZ] New personal best saved!")
}
}
tryInsertTop(const name[], Float:elapsed)
{
// find insertion slot (ascending time)
new pos = -1
for (new i = 0; i < g_iTopCount; i++)
{
if (elapsed < g_fTopTime[i])
{
pos = i
break
}
}
if (pos == -1)
{
if (g_iTopCount >= TOP_MAX)
return
pos = g_iTopCount
}
if (g_iTopCount < TOP_MAX)
g_iTopCount++
// shift down from the end
for (new i = g_iTopCount - 1; i > pos; i--)
{
g_fTopTime[i] = g_fTopTime[i - 1]
copy(g_szTopName[i], charsmax(g_szTopName[]), g_szTopName[i - 1])
}
g_fTopTime[pos] = elapsed
copy(g_szTopName[pos], charsmax(g_szTopName[]), name)
saveTop()
}
saveTop()
{
// serialise as "time|name;time|name;..."
new blob[512], piece[80]
blob[0] = 0
for (new i = 0; i < g_iTopCount; i++)
{
formatex(piece, charsmax(piece), "%f|%s;", g_fTopTime[i], g_szTopName[i])
add(blob, charsmax(blob), piece)
}
new key[48]
formatex(key, charsmax(key), "TOP#%s", g_szMap)
nvault_set(g_iVault, key, blob)
}
loadTop()
{
g_iTopCount = 0
new key[48], blob[512], timestamp
formatex(key, charsmax(key), "TOP#%s", g_szMap)
if (!nvault_lookup(g_iVault, key, blob, charsmax(blob), timestamp))
return
new record[96], pos = 0
while (g_iTopCount < TOP_MAX && (pos = kzNext(blob, pos, ';', record, charsmax(record))))
{
if (!record[0])
continue
new bar = contain(record, "|")
if (bar < 1)
continue
new stime[24], sname[32]
new copylen = bar < charsmax(stime) ? bar : charsmax(stime)
copy(stime, copylen, record)
copy(sname, charsmax(sname), record[bar + 1])
g_fTopTime[g_iTopCount] = str_to_float(stime)
copy(g_szTopName[g_iTopCount], charsmax(g_szTopName[]), sname)
g_iTopCount++
}
}
// walk a delimited blob; returns new position after the delimiter, 0 at end
kzNext(const source[], start, delim, dest[], len)
{
new slen = strlen(source)
if (start >= slen)
return 0
new i = 0
while (start < slen && source[start] != delim)
{
if (i < len)
dest[i++] = source[start]
start++
}
dest[i] = 0
if (start < slen && source[start] == delim)
start++
return start
}
public taskHud()
{
if (!get_pcvar_num(g_pEnabled))
return
for (new id = 1; id <= 32; id++)
{
if (!is_user_alive(id) || !g_bRunning[id])
continue
new Float:elapsed = get_gametime() - g_fStart[id]
new tbuf[16]
formatTime(elapsed, tbuf, charsmax(tbuf))
set_hudmessage(0, 255, 100, 0.02, 0.25, 0, 0.0, 0.15, 0.0, 0.0, -1)
show_hudmessage(id, "Time: %s^nJumps: %d", tbuf, g_iJumps[id])
}
}
formatTime(Float:seconds, out[], len)
{
new total_ms = floatround(seconds * 1000.0)
new mins = total_ms / 60000
new secs = (total_ms / 1000) % 60
new ms = total_ms % 1000
formatex(out, len, "%02d:%02d.%03d", mins, secs, ms)
}