如果你跟我一样,经常被各种赛事计时表、Excel 成绩单、手动排名搞到半夜,那你一定要试试 t3code 这套思路。t3code 最开始只是我给一个周末赛车活动写的三行脚本,后来慢慢变成了一套三段式赛事编码协议:练习、排位、正赛三段,统一用代码表达,成绩、罚时、分组、排行一次搞定。它能帮你省掉最烦的重复劳动,也适合做主赛事计时工具的补充。无论你是组织卡丁车娱乐赛的电竞馆老板,还是给校园编程赛做积分系统的开发者,都可以从这套方案里找到直接能抄的代码和配置。
1. 先从“t3code”说起:这不只是一个项目代号
1.1 三段式赛事到底在解决什么问题
先解释一下我为什么叫它“t3code”。T3 在我这里有两层意思,一是 Time Trial(计时赛),二是 Three-tier(三级流程)。计时赛大家都懂,一个人一台车,按时间说话,谁快谁赢。三级流程则是比赛最常见的结构:练习、排位、正赛。很多民间赛事、电竞比赛、校园活动,其实都是这个节奏,只不过主办方经常用三套完全不同的表格来管,数据对不上是常态。
t3code 最开始的痛点就来自这里。我给一个二十多人的小型计时赛做过一次志愿者,现场用的是秒表和纸质记录表。练习赛成绩、排位赛成绩、正赛发车顺序,全靠现场人工抄,然后再往 Excel 里敲。那天比赛结束已经晚上十点,主办方还要等我们把成绩单整理出来才能颁奖。有人在群里问“我的排位时间怎么比练习还慢”,其实是抄错了一位小数。这种问题在正式赛事里是不可能被原谅的。
所以 t3code 的设计目标非常明确:把计时数据从采集到出榜的过程全部变成可校验、可追溯、可复现的代码流程。选手在赛道上的每一圈,都能对应到一条结构化记录,练习、排位、正赛三段共用同一套数据模型,只是规则配置不同。换一种比赛,不用改代码,换配置文件就行。这正是“编码协议”比“写死逻辑”更有价值的地方。
1.2 为什么选择“编码协议”而不是直接写死逻辑
很多人拿到这类需求的第一反应是写一个函数,专门算成绩,比如calculateRanking(results)。新手阶段这么做没问题,但赛事规则一变,函数就要跟着改。今天主办方说排位赛取最快单圈,明天说取平均速度,后天说要加 3 秒维修区罚时。如果你每次都在主逻辑里硬塞条件,代码很快就会变成一坨没人敢动的意大利面条。
我的做法是把赛事规则抽象成“编码”。所谓 t3code,其实就是一套带语义的字符串:每一段成绩记录都带一个类型前缀,比如LAP表示有效圈速,PEN表示罚时,DNS表示未发车,DSQ表示取消资格。解析器只负责把字符串转成结构化数据,具体的计分逻辑由规则引擎根据配置决定。这样赛道上的设备只需要输出简单的编码流,后台系统负责解释和执行规则。
用生活类比来说,这就像快递面单。面单上写“省-市-区-街道”,快递员只需要扫编码,系统就知道往哪里派送,不需要重新理解每一张单子的含义。赛事编码也是这个思路,把“人话”翻译成“机器能稳定处理的短代码”,从源头减少歧义和录入错误。
1.3 适合谁来用
如果你属于下面几类人,t3code 的思路可以直接拿过去用。
第一类是小型赛事主办方。卡丁车场、业余赛车俱乐部、飞盘比赛、电竞馆日常赛,都需要处理计时和排名。买专业计时设备太贵,用 Excel 又容易错,t3code 可以做中间的轻量方案。
第二类是学校或社团的技术负责人。校园编程赛、机器人循迹赛、定向越野赛,这些场景的计分规则五花八门,写一套通用系统太重,每次单独写又太累。用编码协议加规则配置,一场新比赛两小时就能上线。
第三类是个人开发者。也许你只是想给跑步机上的自己记录训练数据,或者在业余游戏联赛里做一个战绩统计工具。t3code 里的时间处理、并发写入、校验容错这些经验,是可以复用到很多项目里的。
我自己在后续的版本迭代中,也把这套协议从赛车场扩展到了模拟器比赛和遥控车赛事,基本没改核心代码,只换了输入源和规则配置。这说明当初选中“编码协议”这个方向是对的。
2. 核心设计:数据模型、时间精度与规则引擎
2.1 数据模型:选手、批次、成绩、罚时
任何比赛系统,核心都离不开“人、场次、成绩、惩罚”四个要素。t3code 的数据模型也围绕这四块展开。
选手表存基础信息,包括选手ID、姓名、组别、车辆编号或战队ID。批次表存一场比赛的阶段信息,比如练习赛、排位赛、第几轮正赛,每一条记录对应一个batch_id。成绩表是最关键的表,每行记录一个计时结果,字段包括racer_id、batch_id、round_no、encoded_time、raw_time_ms、penalty_ms、status。罚时表单独存,因为罚时可能是赛后追加的,不应该直接覆盖原始成绩。
我建议用这样的字段结构:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | TEXT | 主键,UUID 或雪花ID |
| batch_id | TEXT | 批次号,例如 T3-20241221-Q1 |
| racer_id | TEXT | 选手ID |
| lap_number | INT | 第几圈 |
| raw_time_ms | INTEGER | 原始计时毫秒数 |
| penalty_ms | INTEGER | 罚时毫秒数,默认 0 |
| status | TEXT | 有效/无效/取消资格 |
| source | TEXT | 数据来源,用于追溯 |
| created_at | TEXT | 写入时间,UTC 格式 |
一开始我只存了“最终成绩”而没有存原始圈速,后来发现这是个很大的错误。因为裁判经常需要回看“这一圈是否是有效圈”,如果只存一个总分,遇到争议根本没有证据。所以我把原始圈速和罚时分开存,宁可多费一点存储空间,也要保留完整证据链。这一点在比赛现场特别重要,选手质疑成绩时,你能拿出每一圈的时间点,说服力完全不一样。
2.2 时间精度与计时格式
时间精度是计时系统里最容易出问题的地方。人用秒表习惯读“分:秒.厘秒”,也就是百分之一秒,但很多民间赛事设备实际上只能做到百分之二秒到百分之五秒的精度。为了不让自己误以为比设备更准,我在 t3code 里统一使用毫秒整数作为存储单位。
所有时间字段在数据库里都存INTEGER,单位是毫秒。显示的时候再转换成分:秒.毫秒。这样做的第一个好处是排序非常快,整数比较比字符串比较稳定得多;第二个好处是加减罚时直接做整数运算,不会出现0.1 + 0.2 !== 0.3这种浮点坑。
输入端的格式则五花八门,有的计时器输出00:12.345,有的输出12.345,有的输出0:00:12.345。所以我写了一个解析函数,先把各种文本格式统一转成毫秒:
function parseTimeToMs(input) { const str = String(input).trim(); const match = str.match(/(?:(\d+):)?(?:(\d+):)?(\d+)(?:[.:](\d{1,3}))?/); if (!match) return null; const hours = match[1] ? parseInt(match[1], 10) : 0; const minutes = match[2] ? parseInt(match[2], 10) : 0; const seconds = parseInt(match[3], 10); const msPart = match[4] ? parseInt(match[4].padEnd(3, '0'), 10) : 0; return hours * 3600000 + minutes * 60000 + seconds * 1000 + msPart; }解析完成后,逻辑层统一操作毫秒整数,只有到展示层才格式化成人类可读的字符串。这样从源头避免了“不同计时器格式不同”带来的混乱。
2.3 规则引擎:用配置代替改代码
t3code 最核心的部分不是计时,而是规则引擎。我的做法是把每场比赛的规则写成一个 JSON 文件,系统启动时加载,比赛进行中不修改。
一个典型的规则配置长这样:
{ "raceType": "sprint", "stages": ["practice", "qualifying", "race"], "qualifying": { "mode": "best_lap", "count": 1 }, "race": { "mode": "total_time", "laps": 3, "penaltyAfterRace": true }, "ranking": { "sortBy": ["totalTime", "bestLap"] } }mode字段决定成绩怎么算。best_lap表示取最快单圈,total_time表示多圈总用时。sortBy表示排序优先级,先比总用时,总用时相同再比最快单圈。规则引擎读取这段配置,自动决定应该用哪些字段、怎么排序、怎么处理 DNS 和 DSQ。
这套设计的好处非常明显。三周后主办方说“正赛改成两场积分制,积分相同看最快圈”,我只需要改配置里的raceType和ranking,重新启动服务,不需要动一行代码。对于多次复用的项目来说,省下来的时间非常可观。
3. 从零搭建 t3code 的实操全过程
3.1 环境准备与项目骨架
我建议用 Node.js + SQLite 作为第一版技术栈,原因有三个:环境容易装,适合单机/小并发场景;SQLite 不需要单独起数据库服务,比赛现场一台笔记本就能跑;JavaScript 生态里做 Web 前端也方便,可以一套语言通吃。
初始化项目很简单:
mkdir t3code cd t3code npm init -y npm install better-sqlite3 express mkdir src data为什么选better-sqlite3而不是其他 ORM?因为这个库是同步 API,写业务逻辑的时候非常直接,不需要处理一堆 Promise 回调。在数据量只有几千条的赛事场景里,同步查询的性能完全够用,而且代码可读性高,排查问题容易。
目录结构我习惯按功能拆分,而不是按文件类型拆分:
t3code/ ├── src/ │ ├── parser.js # 编码解析 │ ├── engine.js # 规则引擎 │ ├── storage.js # 数据读写 │ └── server.js # HTTP 服务 └── config/ └── race.t3.json # 赛事配置先建好数据库表:
CREATE TABLE IF NOT EXISTS racers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, group_name TEXT, vehicle_no TEXT ); CREATE TABLE IF NOT EXISTS laps ( id TEXT PRIMARY KEY, batch_id TEXT NOT NULL, racer_id TEXT NOT NULL, lap_number INTEGER, raw_time_ms INTEGER, penalty_ms INTEGER DEFAULT 0, status TEXT DEFAULT 'valid', source TEXT, created_at TEXT ); CREATE INDEX IF NOT EXISTS idx_laps_batch ON laps(batch_id); CREATE INDEX IF NOT EXISTS idx_laps_racer ON laps(racer_id);早期我偷懒没建索引,到几百条成绩的时候还不觉得慢,等练习赛、排位赛、正赛三批数据累积到两三千条,查询排行就开始卡了。加了batch_id和racer_id两个普通索引之后,速度立马上来。所以哪怕数据量小,索引也建议在第一天就建好。
3.2 核心模块实现:一行秒表数据如何变成正式成绩
现在到最核心的环节:把类似LAP|T3-001|1|00:12.345这样的原始文本,变成数据库里的一条成绩记录。
原始文本的格式我定义为:
类型|选手编号|圈数|时间比如:
LAP|T3-001|1|00:12.345解析器的核心代码如下:
const { v4: uuidv4 } = require('uuid'); const { parseTimeToMs } = require('./parser'); function ingestLap(line, source = 'manual') { const parts = line.split('|'); if (parts.length < 4) { throw new Error(`格式错误: ${line}`); } const [type, racerId, lapNumber, timeStr] = parts; const rawTimeMs = parseTimeToMs(timeStr); if (rawTimeMs === null) { throw new Error(`时间解析失败: ${timeStr}`); } return { id: uuidv4(), batch_id: currentBatchId, racer_id: racerId, lap_number: parseInt(lapNumber, 10), raw_time_ms: rawTimeMs, penalty_ms: 0, status: 'valid', source, }; }这里我加了一个currentBatchId的全局变量,用来表示当前录入的是哪个阶段的数据。比赛现场的操作流程是这样的:练习赛开始前,先创建一个batch_id = T3-20241221-PRACTICE;现场录入的所有圈速,都归属于这个批次;练习赛结束后,再创建新的批次T3-20241221-Q1。这样统计的时候按batch_id过滤就行,非常干净。
规则引擎取排位赛最快单圈时,SQL 是这样的:
SELECT racer_id, MIN(raw_time_ms + penalty_ms) AS final_time FROM laps WHERE batch_id = ? AND status = 'valid' GROUP BY racer_id ORDER BY final_time ASC;这里我把raw_time_ms和penalty_ms相加,是因为有些赛事在排位赛阶段就会因为维修区超速加罚时。如果直接用raw_time_ms排序,罚时效果就丢失了。别小看这一步,我见过好几个比赛系统在这里写错,导致被罚时的选手反而排到前面去了。
3.3 前端展示与排行榜
现场需要一个能让所有人看到的排行榜投屏。最简单的方案是用 Express 提供一个 JSON 接口,前端用一个原生 HTML 页面轮询展示。
后端接口大概这样:
app.get('/api/ranking', (req, res) => { const { batchId } = req.query; const rows = db.prepare(` SELECT racer_id, MIN(raw_time_ms + penalty_ms) AS final_time FROM laps WHERE batch_id = ? AND status = 'valid' GROUP BY racer_id ORDER BY final_time ASC `).all(batchId); res.json(rows); });前端的 HTML 里放一个<table>,每 5 秒用fetch拉一次最新排行,刷新时只更新 tbody 的内容,避免整个页面闪烁。5 秒的刷新间隔对小型赛事足够,服务器压力也小。如果现场网络不稳定,可以考虑把刷新间隔提高到 10 秒,但没有必要做 WebSocket 实时推送,因为观众对名次变化的实时性要求没那么高,偶尔多等几秒完全可以接受。
为了现场工作人员方便,我还在页面上加了一个录入表单,支持手动粘贴多条编码记录。比如计时员从秒表软件里复制出几十行,粘贴到文本框,点击“导入”,系统批量解析写入。这个功能看起来不起眼,但实际使用频率非常高,因为不是每次比赛都有自动计时设备接入。
4. 实测中踩过的坑与排查速查表
4.1 毫秒级误差:现场设备比想象中更“粗糙”
第一次实测活动我直接用手机秒表加人工按键采集数据,结果发现同一个选手同一圈,两个计时员按出的时间能差 300 毫秒左右。300 毫秒在赛车比赛里可能就是两三个车位的差距,如果直接拿去排名,争议会很大。
后面调整了流程:人工计时只作为备份,主计时尽量用手动触发红外传感器的设备,或者用视频帧数作为辅助证据。t3code 在这一层能做的事情其实有限,但我把采集来源记录到了source字段里,如果出现争议成绩,可以回溯到原始记录,知道它是人工按键还是设备触发,不会稀里糊涂地吵。
要注意,解析函数里我默认把12.3当成12.300秒处理,而不是当成 12 秒 3 毫秒。因为大多数手动计时器输出的最后一位是百分秒或千分秒的简化写法。如果你的设备输出习惯不同,一定要在解析前确认单位,否则会出现系统性偏差。
4.2 夏令时与跨时区:UTC 存储是救命稻草
这个问题我从没想过会出现在一个本地赛事系统里,直到有一次活动时间正好卡在夏令时切换附近。赛事现场的设备用的是本地时间,写数据库的脚本却用了服务器时间,两个时间相差一小时,导致成绩录入后批次的先后顺序完全错乱。
解决办法很简单:所有时间戳统一存 UTC。比如记录一个圈速的产生时刻,用2024-11-03T16:00:00.000Z而不是2024-11-03 10:00:00。显示给用户的时候,再根据现场时区转换成本地时间。
写进数据库的时间字段,我建议直接用 ISO 8601 字符串,因为 SQLite 没有原生的 DATETIME 类型,字符串反而更容易处理和排序。但注意排序时要用T和Z齐备的完整格式,比如2024-11-03T16:00:00.000Z,如果省略了毫秒或时区,按字母序排序就可能出错。
4.3 并发写入导致成绩丢失
比赛现场通常是几个计时员同时录入数据,或者一个计时软件多个进程同时写库。头几次我用的 SQLite 默认配置,连续读写时遇到了database is locked错误,有几条成绩没有写进去。
后来我把所有写操作收敛到同一个模块,在写入的地方显式开启事务:
const insert = db.transaction((rows) => { const stmt = db.prepare(`INSERT INTO laps ...`); for (const row of rows) { stmt.run(row); } }); insert(batchRows);better-sqlite3的事务默认是同步的,多个写入会排队执行,不会互相踩踏。我还在数据库连接时开启了 WAL 模式,并发性一下子就够用了。对于几千条记录的小比赛,这套方案非常稳,完全不需要上 PostgreSQL 或 MySQL。
4.4 数据校验的边界:脏数据比想象中多
现场录入时最容易出现的脏数据包括:选手编号打错、圈数写成负数、时间格式多了一个冒号、罚时字段填了负数。系统如果照单全收,排行榜上就会出现一个“-1圈”的记录,看着很尴尬。
我在解析层加了严格的边界校验:
- 选手ID必须匹配
/^T3-\d{3}$/格式; - 圈数必须大于等于 1;
- 时间解析结果必须大于 0;
- 罚时如果是负数,直接拒绝写入。
校验不通过的数据不会静默丢弃,而是写入一条错误日志,并在前端提示“某条记录格式错误,请检查”。因为现场的人最怕的是数据莫名其妙消失,而不是看到报错。报错至少知道问题在哪,静默丢失才是真正致命的。
4.5 排查速查表:常见症状一句话定位
我把实际运维中遇到的问题整理成一张速查表,每次现场出状况都先对着这张表走一遍,通常十分钟内能定位问题。
| 症状 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 排行榜缺人 | 录入时选手ID打错 | 查错误日志,按 racer_id 模糊搜索 | 严格执行格式校验 |
| 成绩整体偏慢 | 时间格式单位理解错 | 抽查几条原始文本和解析结果 | 确认秒表输出单位,统一解析规则 |
| 批次数序混乱 | 时间没有用 UTC 存储 | 查看 created_at 字段前后顺序 | 所有时间戳强制 UTC |
| 写库报 locked | 多进程并发写 SQLite | 查看应用日志 | 开启 WAL,写操作走事务 |
| 罚时没生效 | 排序时忘了加 penalty_ms | 查看 SQL 排序字段 | 使用raw_time_ms + penalty_ms |
这张表我打印了一份放在比赛现场的操作台上,比什么技术文档都有用。因为现场人一紧张,根本没有心思去读系统设计说明,只需要快速定位问题,先把活动顺利进行下去。
5. 扩展方向:从赛事统计到训练复盘
5.1 插件化:自定义赛事类型
做完基础版本之后,我开始想怎么让 t3code 适应更多比赛类型,而不只是赛车。后来我给规则引擎加了插件机制,每一种赛事类型对应一个模块,模块输出统一的成绩记录结构,这样主流程不需要改,只要按协议返回数据就能接入。
比如定向越野赛,需要使用“打卡点顺序”作为成绩依据,而不是纯粹的计时。我就写一个orienteering插件,解析打卡序列,缺少某个点就直接标为无效。又比如电竞比赛,需要算的是“队伍积分”,我就把racer_id换成team_id,规则引擎还是按同样的方式读取和排序。
插件的接口约定很简单:输入是原始编码流,输出是统一的 lap/penalty/status 结构。只要遵守这个约定,任何赛事都能挂进来。
5.2 数据可视化:从干巴巴的数字到可读的图表
排行榜只是第一步,后续可以加更多可视化能力。比如画一条时间曲线,横轴是圈数,纵轴是单圈时间,能直接看出选手在第几圈开始体力下降。还可以做分段速度热力图,把一条赛道的每个计时点数据标成色块,一眼就能看出谁的弯心速度更差。
我目前的做法是把 t3code 的成绩数据导出成 JSON,再用前端图表库渲染。图表库的选择不关键,Chart.js 画曲线够用,ECharts 做热力图更方便,看自己的熟悉程度就好。
5.3 后续优化方向:自动分组、异常检测与回放联动
第二个版本我打算加入自动分组功能。很多娱乐赛事喜欢按玩家历史水平分成 A、B、C 组,避免水平差距过大。t3code 可以把历史成绩导入,按分位数自动划分组别,主持人只需要确认结果就行。
异常检测也值得做。比如某位选手连续五圈成绩都在 11.5 秒左右,突然出现一圈 9.8 秒,大概率是计时设备漏记或走了捷径。这类极端值可以做标记,让裁判重点复查,而不是直接当成新纪录发布。
更远一点的规划是把录像回放和成绩联动起来。每一条成绩记录都带上视频时间戳,点击排行榜上某一条记录,就能跳到对应的比赛画面。这对线上电竞比赛尤其有用,可以极大降低申诉处理成本。当然,这个功能涉及视频文件的存储和管理,复杂度会比现在高不少,我会先把基础的数据联动做好,再逐步推进。
我自己用下来的体会是,t3code 的核心价值不在于代码写得多漂亮,而在于把“事后补录”变成了“现场自动”。如果你也经常组织小型比赛,我建议你从最小的场景切入:先只做计时串解析,再加罚时,再加分组,别想一步到位。最后再分享一个小技巧:日志里永远要带上比赛ID和批次号,现场排查问题的时候,这两行信息能救你的命。