games: jitter-free stepping via timerfd + clearer Snake playfield

Replace the select() timeout sleep with a Linux timerfd high-resolution
timer. It fires on an absolute CLOCK_MONOTONIC deadline, so wakeups are
no longer quantized to the kernel tick (a short final wait was rounded
up to a whole tick, causing +-10-22ms jitter and lurches). The loop now
select()s on both the X connection fd and the timer fd, so input still
wakes it immediately and no busy-spin is needed -- the controls stay
responsive. A select()-timeout fallback is kept for non-timerfd setups.

Snake: draw a bright frame around the actual playfield so its boundary is
unmistakable, and move the SCORE/BEST label down into the margin between
the window frame and the playfield frame.
This commit is contained in:
Luxferre
2026-10-24 14:21:24 +03:00
parent eb78f314a3
commit 0e670d7224
6 changed files with 225 additions and 87 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+117 -45
View File
@@ -21,6 +21,10 @@
#include <X11/Xutil.h> #include <X11/Xutil.h>
#include <X11/keysym.h> #include <X11/keysym.h>
#include <X11/Xatom.h> #include <X11/Xatom.h>
#include <time.h>
#include <sys/timerfd.h>
#include <cstdint>
#include <cerrno>
static const int TOPBAR = 24; static const int TOPBAR = 24;
static const int COLS = 24; static const int COLS = 24;
@@ -111,9 +115,9 @@ static int speed = 100; // ms per step (derived from pps below)
static int pps = 90; // base speed px/sec; +1 px/sec per 5 food eaten static int pps = 90; // base speed px/sec; +1 px/sec per 5 food eaten
static bool running = true; static bool running = true;
static unsigned long now_ms() { static unsigned long now_ms() {
struct timeval tv; struct timespec ts;
gettimeofday(&tv, nullptr); clock_gettime(CLOCK_MONOTONIC, &ts);
return (unsigned long)(tv.tv_sec * 1000 + tv.tv_usec / 1000); return (unsigned long)(ts.tv_sec * 1000 + ts.tv_nsec / 1000000);
} }
// Derive the per-step delay (ms) from the target pixels/second. The snake // Derive the per-step delay (ms) from the target pixels/second. The snake
@@ -270,14 +274,20 @@ static void render() {
// head // head
XSetForeground(dpy, gc, C_HEAD); XSetForeground(dpy, gc, C_HEAD);
XFillRectangle(dpy, back, gc, gx0 + snake[snakelen - 1][0] * cell, gy0 + snake[snakelen - 1][1] * cell, cell, cell); XFillRectangle(dpy, back, gc, gx0 + snake[snakelen - 1][0] * cell, gy0 + snake[snakelen - 1][1] * cell, cell, cell);
// bright frame around the playfield so its boundaries are unmistakable
XSetForeground(dpy, gc, C_FG);
XDrawRectangle(dpy, back, gc, gx0, gy0, gw, gh);
// score (top status strip, clear of the playfield border) // score / best sit in the margin between the window frame (outer) and the
// playfield frame (inner), vertically centred there so the playfield boundary
// stays clear
int scoreY = (2 + gy0) / 2 - 3;
char buf[32]; char buf[32];
snprintf(buf, sizeof(buf), "SCORE %d", score); snprintf(buf, sizeof(buf), "SCORE %d", score);
draw_text(gx0, 4, 1, C_DIM, buf); draw_text(gx0, scoreY, 1, C_DIM, buf);
if (best > 0) { if (best > 0) {
snprintf(buf, sizeof(buf), "BEST %d", best); snprintf(buf, sizeof(buf), "BEST %d", best);
draw_text(gx0 + gw - 11 * 6, 4, 1, C_DIM, buf); draw_text(gx0 + gw - 11 * 6, scoreY, 1, C_DIM, buf);
} }
if (state == PAUSED || state == GAMEOVER) { if (state == PAUSED || state == GAMEOVER) {
@@ -402,55 +412,117 @@ int main() {
render(); render();
int xfd = ConnectionNumber(dpy); int xfd = ConnectionNumber(dpy);
const unsigned long FRAME_MS = 1000 / 30; // 30 fps rendering
unsigned long lastFrame = now_ms(); // Precise, drift-free stepping via a kernel high-resolution timer (timerfd).
unsigned long accumulator = 0; // The timer fires on an absolute CLOCK_MONOTONIC deadline, so wakeups are not
// quantized to the kernel tick the way a select() timeout is. A select()
// timeout shorter than one tick gets rounded UP to a whole tick, which made
// the final few milliseconds of each wait overshoot by up to ~20ms and showed
// up as speed jitter. Input still arrives on the X connection fd, so we
// select() on BOTH fds and react to either immediately -- there is no
// busy-spin, so the X server stays free and the controls stay responsive.
int tfd = -1;
#if defined(__linux__)
tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
#endif
unsigned long nextStep = now_ms() + speed;
State prevState = PLAYING;
auto arm_timer = [&](unsigned long deadline_ms) {
struct itimerspec its;
its.it_interval.tv_sec = 0; its.it_interval.tv_nsec = 0; // one-shot
its.it_value.tv_sec = (time_t)(deadline_ms / 1000);
its.it_value.tv_nsec = (long)((deadline_ms % 1000) * 1000000UL);
if (its.it_value.tv_nsec >= 1000000000L) {
its.it_value.tv_sec += 1; its.it_value.tv_nsec -= 1000000000L;
}
timerfd_settime(tfd, TFD_TIMER_ABSTIME, &its, nullptr);
};
auto disarm_timer = [&]() {
struct itimerspec its;
memset(&its, 0, sizeof(its));
timerfd_settime(tfd, 0, &its, nullptr);
};
if (tfd >= 0) arm_timer(nextStep);
while (running) { while (running) {
// Process all pending input immediately (non-blocking) before each frame fd_set fds;
while (XPending(dpy)) { FD_ZERO(&fds);
XEvent e; FD_SET(xfd, &fds);
XNextEvent(dpy, &e); int nfds = xfd + 1;
handle_event(e); struct timeval tvbuf, *tvp = nullptr;
if (tfd >= 0) {
// While playing, also wait on the timer fd. While paused / game over the
// timer is disarmed, so we block on input only (resume stays instant).
if (state == PLAYING) {
FD_SET(tfd, &fds);
if (tfd + 1 > nfds) nfds = tfd + 1;
}
} else {
// Fallback (timerfd unavailable): bounded select so input stays live.
long sleepMs = (state == PLAYING) ? (long)nextStep - (long)now_ms() : 30;
if (sleepMs < 0) sleepMs = 0;
tvbuf.tv_sec = sleepMs / 1000;
tvbuf.tv_usec = (sleepMs % 1000) * 1000;
tvp = &tvbuf;
}
int r = select(nfds, &fds, nullptr, nullptr, tvp);
if (r < 0) { if (errno == EINTR) continue; break; }
// 1) Drain pending input first so key presses are never delayed.
if (FD_ISSET(xfd, &fds) || XPending(dpy)) {
while (XPending(dpy)) {
XEvent e;
XNextEvent(dpy, &e);
handle_event(e);
if (!running) break;
}
if (!running) break; if (!running) break;
} }
if (!running) break;
unsigned long now = now_ms(); // 2) (Re)start play cleanly: resync the clock on resume / restart / launch,
unsigned long elapsed = now - lastFrame; // and stop the timer whenever we leave the PLAYING state.
lastFrame = now; if (state != prevState) {
if (state == PLAYING) { nextStep = now_ms() + speed; if (tfd >= 0) arm_timer(nextStep); }
// Cap elapsed time to prevent spiral of death else if (tfd >= 0) disarm_timer();
if (elapsed > 200) elapsed = 200; prevState = state;
// Accumulate time
accumulator += elapsed;
// Step game logic at fixed rate (max 2 steps per frame to prevent spiral)
int steps = 0;
while (accumulator >= (unsigned long)speed && steps < 2) {
if (state == PLAYING) step();
accumulator -= speed;
steps++;
} }
// Render every frame // 3) Advance the simulation exactly once per timer expiration (or, in the
// fallback path, whenever the deadline is reached). If we ever fell more
// than one interval behind (e.g. the process was frozen for a screenshot)
// we drop the backlog instead of racing to catch up.
if (state == PLAYING) {
bool due = false;
if (tfd >= 0) {
if (FD_ISSET(tfd, &fds)) {
uint64_t expirations = 0;
ssize_t n = read(tfd, &expirations, sizeof(expirations));
(void)n; (void)expirations;
due = true;
}
} else {
if ((long)(now_ms() - nextStep) >= 0) due = true;
}
if (due) {
step();
nextStep += speed; // advance by exactly one interval (no drift)
unsigned long now = now_ms();
if ((long)(now - nextStep) > (long)speed) nextStep = now + speed;
if (tfd >= 0 && state == PLAYING) arm_timer(nextStep);
}
}
// 4) Repaint on every loop turn (input or step) so the display never
// appears frozen and key feedback is immediate.
render(); render();
// Frame pacing: sleep until next frame, but wake up immediately on input
now = now_ms();
long sleepMs = (long)FRAME_MS - (long)(now - (lastFrame - elapsed));
if (sleepMs > 0 && sleepMs <= (long)FRAME_MS) {
struct timeval tv;
tv.tv_sec = sleepMs / 1000;
tv.tv_usec = (sleepMs % 1000) * 1000;
fd_set fds;
FD_ZERO(&fds);
FD_SET(xfd, &fds);
select(xfd + 1, &fds, nullptr, nullptr, &tv);
}
} }
if (tfd >= 0) close(tfd);
XFreeGC(dpy, gc); XFreeGC(dpy, gc);
XFreePixmap(dpy, back); XFreePixmap(dpy, back);
XDestroyWindow(dpy, win); XDestroyWindow(dpy, win);
+108 -42
View File
@@ -22,6 +22,10 @@
#include <X11/Xutil.h> #include <X11/Xutil.h>
#include <X11/keysym.h> #include <X11/keysym.h>
#include <X11/Xatom.h> #include <X11/Xatom.h>
#include <time.h>
#include <sys/timerfd.h>
#include <cstdint>
#include <cerrno>
static const int TOPBAR = 24; static const int TOPBAR = 24;
static const int PCOLS = 10; static const int PCOLS = 10;
@@ -136,9 +140,9 @@ static bool collide(int px, int py, int cells[4][2]) {
} }
static unsigned long now_ms() { static unsigned long now_ms() {
struct timeval tv; struct timespec ts;
gettimeofday(&tv, nullptr); clock_gettime(CLOCK_MONOTONIC, &ts);
return (unsigned long)(tv.tv_sec * 1000 + tv.tv_usec / 1000); return (unsigned long)(ts.tv_sec * 1000 + ts.tv_nsec / 1000000);
} }
// Derive the gravity delay (ms) from the target pixels/second. A piece drops // Derive the gravity delay (ms) from the target pixels/second. A piece drops
@@ -516,55 +520,117 @@ int main() {
render(); render();
int xfd = ConnectionNumber(dpy); int xfd = ConnectionNumber(dpy);
const unsigned long FRAME_MS = 1000 / 30; // 30 fps rendering
unsigned long lastFrame = now_ms(); // Precise, drift-free stepping via a kernel high-resolution timer (timerfd).
unsigned long accumulator = 0; // The timer fires on an absolute CLOCK_MONOTONIC deadline, so wakeups are not
// quantized to the kernel tick the way a select() timeout is. A select()
// timeout shorter than one tick gets rounded UP to a whole tick, which made
// the final few milliseconds of each wait overshoot by up to ~20ms and showed
// up as speed jitter. Input still arrives on the X connection fd, so we
// select() on BOTH fds and react to either immediately -- there is no
// busy-spin, so the X server stays free and the controls stay responsive.
int tfd = -1;
#if defined(__linux__)
tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
#endif
unsigned long nextStep = now_ms() + speed;
State prevState = PLAYING;
auto arm_timer = [&](unsigned long deadline_ms) {
struct itimerspec its;
its.it_interval.tv_sec = 0; its.it_interval.tv_nsec = 0; // one-shot
its.it_value.tv_sec = (time_t)(deadline_ms / 1000);
its.it_value.tv_nsec = (long)((deadline_ms % 1000) * 1000000UL);
if (its.it_value.tv_nsec >= 1000000000L) {
its.it_value.tv_sec += 1; its.it_value.tv_nsec -= 1000000000L;
}
timerfd_settime(tfd, TFD_TIMER_ABSTIME, &its, nullptr);
};
auto disarm_timer = [&]() {
struct itimerspec its;
memset(&its, 0, sizeof(its));
timerfd_settime(tfd, 0, &its, nullptr);
};
if (tfd >= 0) arm_timer(nextStep);
while (running) { while (running) {
// Process all pending input immediately (non-blocking) before each frame fd_set fds;
while (XPending(dpy)) { FD_ZERO(&fds);
XEvent e; FD_SET(xfd, &fds);
XNextEvent(dpy, &e); int nfds = xfd + 1;
handle_event(e); struct timeval tvbuf, *tvp = nullptr;
if (tfd >= 0) {
// While playing, also wait on the timer fd. While paused / game over the
// timer is disarmed, so we block on input only (resume stays instant).
if (state == PLAYING) {
FD_SET(tfd, &fds);
if (tfd + 1 > nfds) nfds = tfd + 1;
}
} else {
// Fallback (timerfd unavailable): bounded select so input stays live.
long sleepMs = (state == PLAYING) ? (long)nextStep - (long)now_ms() : 30;
if (sleepMs < 0) sleepMs = 0;
tvbuf.tv_sec = sleepMs / 1000;
tvbuf.tv_usec = (sleepMs % 1000) * 1000;
tvp = &tvbuf;
}
int r = select(nfds, &fds, nullptr, nullptr, tvp);
if (r < 0) { if (errno == EINTR) continue; break; }
// 1) Drain pending input first so key presses are never delayed.
if (FD_ISSET(xfd, &fds) || XPending(dpy)) {
while (XPending(dpy)) {
XEvent e;
XNextEvent(dpy, &e);
handle_event(e);
if (!running) break;
}
if (!running) break; if (!running) break;
} }
if (!running) break;
unsigned long now = now_ms(); // 2) (Re)start play cleanly: resync the clock on resume / restart / launch,
unsigned long elapsed = now - lastFrame; // and stop the timer whenever we leave the PLAYING state.
lastFrame = now; if (state != prevState) {
if (state == PLAYING) { nextStep = now_ms() + speed; if (tfd >= 0) arm_timer(nextStep); }
// Cap elapsed time to prevent spiral of death else if (tfd >= 0) disarm_timer();
if (elapsed > 200) elapsed = 200; prevState = state;
// Accumulate time
accumulator += elapsed;
// Step game logic at fixed rate (max 2 steps per frame to prevent spiral)
int steps = 0;
while (accumulator >= (unsigned long)speed && steps < 2) {
if (state == PLAYING) step();
accumulator -= speed;
steps++;
} }
// Render every frame // 3) Advance the simulation exactly once per timer expiration (or, in the
// fallback path, whenever the deadline is reached). If we ever fell more
// than one interval behind (e.g. the process was frozen for a screenshot)
// we drop the backlog instead of racing to catch up.
if (state == PLAYING) {
bool due = false;
if (tfd >= 0) {
if (FD_ISSET(tfd, &fds)) {
uint64_t expirations = 0;
ssize_t n = read(tfd, &expirations, sizeof(expirations));
(void)n; (void)expirations;
due = true;
}
} else {
if ((long)(now_ms() - nextStep) >= 0) due = true;
}
if (due) {
step();
nextStep += speed; // advance by exactly one interval (no drift)
unsigned long now = now_ms();
if ((long)(now - nextStep) > (long)speed) nextStep = now + speed;
if (tfd >= 0 && state == PLAYING) arm_timer(nextStep);
}
}
// 4) Repaint on every loop turn (input or step) so the display never
// appears frozen and key feedback is immediate.
render(); render();
// Frame pacing: sleep until next frame, but wake up immediately on input
now = now_ms();
long sleepMs = (long)FRAME_MS - (long)(now - (lastFrame - elapsed));
if (sleepMs > 0 && sleepMs <= (long)FRAME_MS) {
struct timeval tv;
tv.tv_sec = sleepMs / 1000;
tv.tv_usec = (sleepMs % 1000) * 1000;
fd_set fds;
FD_ZERO(&fds);
FD_SET(xfd, &fds);
select(xfd + 1, &fds, nullptr, nullptr, &tv);
}
} }
if (tfd >= 0) close(tfd);
XFreeGC(dpy, gc); XFreeGC(dpy, gc);
XFreePixmap(dpy, back); XFreePixmap(dpy, back);
XDestroyWindow(dpy, win); XDestroyWindow(dpy, win);