Negar Rafieedolatabadi
Published

SW0 Says

BeatMatch Nano is a rhythm game with no screen or speaker, just one LED and one button that score how well you match a beat.

BeginnerProtip30 minutes21
SW0 Says

Things used in this project

Story

Read more

Schematics

Artifact For Game

Game live UI

Code

Main.C

C#
Project file
#include <xc.h>
#include <stdint.h>

/* CONFIG1 */
#pragma config FEXTOSC  = OFF             // no external oscillator
#pragma config RSTOSC   = HFINTOSC_32MHz  // Fosc = 32 MHz internal (max for this chip)
#pragma config CLKOUTEN = OFF             // OSC2 is a normal I/O pin

/* CONFIG2 */
#pragma config WDTE  = OFF                // watchdog off
#pragma config MCLRE = EXTMCLR            // MCLR pin enabled
#pragma config LVP   = ON                 // low-voltage programming (on-board debugger)

#define _XTAL_FREQ 32000000UL             // must match Fosc -> used by __delay_us()

/* ------------------------------------------------------------------ wiring */
#define LED_ON()        (LATCbits.LATC2 = 0)   /* active low */
#define LED_OFF()       (LATCbits.LATC2 = 1)
#define BTN_IS_DOWN()   (PORTCbits.RC3 == 0)   /* active low */

/* ---------------------------------------------------------------- tunables */
/*
 * The tick body (read port, debounce, bump counter, call/return) is roughly
 * 25 instructions. At 32 MHz an instruction is 125 ns, so that is only about
 * 3 us out of every millisecond -- hence this small trim.
 *
 * Getting it slightly wrong does not affect your score: the demo beat and your
 * taps are counted with the same tick and the score is a ratio, so the error
 * cancels exactly. It only shifts the printed BPM numbers. If a metronome says
 * the beat runs fast, raise this; if slow, lower it.
 */
#define TICK_TRIM_US        3u
#define DEBOUNCE_MS         8u    /* stable samples needed to accept a change  */

/*
 * Each round picks a random LENGTH as well as a random tempo. The board plays
 * `beats` blinks and you tap back the same number -- so the demo itself tells
 * you how many taps to give, and the game stays playable with no terminal.
 *
 * Fewer beats means fewer intervals, and fewer intervals make the average
 * noisier: at 3 beats the score rests on just 2 intervals, so one twitchy tap
 * hurts much more. That is the difficulty knob -- short rounds are harder.
 */
#define MIN_TAPS            3u
#define MAX_TAPS            6u

#define BLINK_MS            60u   /* LED on-time for one demo beat            */
#define ACK_MS              35u   /* LED on-time acknowledging your tap       */
#define LEAD_IN_MS          800u  /* pause after start before beat 1          */
#define GAP_MS              700u  /* pause between the demo and your turn     */
#define FIRST_TAP_TIMEOUT   4000u /* how long we wait for your first tap      */
#define TAP_TIMEOUT         2500u /* ...and for each tap after that           */

#define TEMPO_ZERO_ERR_X10  200u  /* 20.0% tempo error scores 0               */
#define STEADY_ZERO_DEV_X10 200u  /* 20.0% wobble scores 0                    */

/* Tempos the game can pick, as milliseconds per beat: 60..160 BPM. */
static const uint16_t TEMPO_MS[] = {
    1000, 857, 750, 667, 600, 545, 500, 462, 429, 400, 375
};
#define TEMPO_COUNT (sizeof TEMPO_MS / sizeof TEMPO_MS[0])

/* ------------------------------------------------------- clock + button I/O */
static uint16_t g_ms;        /* free-running millisecond counter (wraps at 65.5 s) */
static uint8_t  btn_level;   /* debounced state: 1 = pressed                       */
static uint8_t  btn_count;   /* consecutive samples disagreeing with btn_level     */
static uint8_t  btn_edge;    /* latched "a press happened", cleared by took_press() */

/**
 * @brief Advance the game clock by one millisecond and sample the button.
 *
 * Everything that waits goes through this function, so the clock keeps running
 * and no press is ever missed, whatever the game is doing.
 */
static void ms_tick(void)
{
    uint8_t raw = BTN_IS_DOWN() ? 1u : 0u;

    if (raw != btn_level)
    {
        if (++btn_count >= DEBOUNCE_MS)     /* stable long enough -> believe it */
        {
            btn_level = raw;
            btn_count = 0;
            if (raw)
                btn_edge = 1;               /* rising edge = a fresh press */
        }
    }
    else
    {
        btn_count = 0;                      /* noise: restart the count */
    }

    g_ms++;
    __delay_us(1000u - TICK_TRIM_US);
}

/** @brief Consume a latched press. Returns 1 if there was one. */
static uint8_t took_press(void)
{
    if (btn_edge) { btn_edge = 0; return 1u; }
    return 0u;
}

static void wait_ms(uint16_t n)
{
    while (n--)
        ms_tick();
}

/** @brief Wait up to n ms, stopping early on a press. Returns 1 if pressed. */
static uint8_t wait_ms_or_press(uint16_t n)
{
    while (n--)
    {
        ms_tick();
        if (btn_edge)
            return 1u;
    }
    return 0u;
}

/** @brief Wait for a press. Returns 0 if it never came within timeout_ms. */
static uint8_t wait_press(uint16_t timeout_ms)
{
    while (timeout_ms--)
    {
        if (took_press())
            return 1u;
        ms_tick();
    }
    return 0u;
}

static void blink(uint16_t on_ms, uint16_t off_ms)
{
    LED_ON();  wait_ms(on_ms);
    LED_OFF(); wait_ms(off_ms);
}

static void ack_flash(void)
{
    LED_ON();  wait_ms(ACK_MS);
    LED_OFF();
}

/* -------------------------------------------------------------- EUSART1 (TX) */
/**
 * @brief EUSART1: 9600 baud, 8-N-1, transmit only, out on pin RC4.
 *
 * Baud, from datasheet Table 31-2 with SYNC=0, BRG16=1, BRGH=1:
 *     baud = Fosc / (4 * (n + 1))
 * At 32 MHz, n = 832 gives 9604 baud -- 0.04% off, comfortably inside tolerance.
 *
 * NOTE: n depends on Fosc. Changing the clock silently breaks the serial output
 * unless n is recomputed.
 */
static void uart_init(void)
{
    /* TX pin: RC4 digital output, then route EUSART1 TX onto it. */
    ANSELCbits.ANSELC4 = 0;   /* digital, not analog */
    TRISCbits.TRISC4   = 0;   /* output driver enabled (required for PPS out) */

    /* PPS output code 0x13 = TX1/CK1 (datasheet Table 18-2). PPSLOCKED is clear
       at reset on this device, so the write lands; lock it afterwards so stray
       writes cannot re-route the pin. */
    RC4PPS = 0x13;

    PPSLOCK = 0x55;           /* required sequence */
    PPSLOCK = 0xAA;
    PPSLOCKbits.PPSLOCKED = 1;

    SP1BRGL  = 0x40;          /* n = 832 = 0x0340 */
    SP1BRGH  = 0x03;
    BAUD1CON = 0x08;          /* BRG16 = 1 (16-bit baud generator) */
    TX1STA   = 0x24;          /* TXEN = 1, BRGH = 1, SYNC = 0 (asynchronous) */
    RC1STA   = 0x80;          /* SPEN = 1: serial port enabled */
}

/**
 * @brief Send one byte. Gives up after a short spin rather than blocking.
 *
 * A dead or mis-clocked UART must never be able to freeze the game -- the LED is
 * the primary display and has to keep working with no terminal attached at all.
 */
static void uart_write(char c)
{
    uint16_t guard = 20000u;

    while (!PIR4bits.TX1IF && --guard)
        ;
    TX1REG = (uint8_t)c;
}

static void print(const char *s)
{
    while (*s)
        uart_write(*s++);
}

static void print_u16(uint16_t v)
{
    char    buf[6];
    uint8_t i = 0;

    if (v == 0u) { uart_write('0'); return; }
    while (v) { buf[i++] = (char)('0' + (v % 10u)); v /= 10u; }
    while (i) uart_write(buf[--i]);
}

/** @brief Print right-aligned in a field of `width`, so columns line up. */
static void print_u16_w(uint16_t v, uint8_t width)
{
    uint16_t t = v;
    uint8_t  digits = 1;

    while (t >= 10u) { t /= 10u; digits++; }
    while (digits < width) { uart_write(' '); digits++; }
    print_u16(v);
}

/** @brief Print a value held in tenths: 84 -> "8.4". */
static void print_tenths(uint16_t v)
{
    print_u16((uint16_t)(v / 10u));
    uart_write('.');
    uart_write((char)('0' + (v % 10u)));
}

static void print_bar(uint8_t score)
{
    uint8_t i, filled = (uint8_t)(score / 10u);   /* 0..10 */

    uart_write('[');
    for (i = 0; i < 10u; i++)
        uart_write(i < filled ? '#' : '.');
    uart_write(']');
}

static void nl(void)
{
    print("\r\n");
}

/* ------------------------------------------------------------ tempo picking */
/* 16-bit xorshift: tiny, and plenty random for choosing a tempo. */
static uint16_t rnd_state = 0xACE1u;

static uint16_t rnd_next(void)
{
    rnd_state ^= (uint16_t)(rnd_state << 7);
    rnd_state ^= (uint16_t)(rnd_state >> 9);
    rnd_state ^= (uint16_t)(rnd_state << 8);
    return rnd_state;
}

/**
 * @brief Stir in some entropy -- we use human reaction time, which is the only
 *        genuinely unpredictable thing a bare microcontroller has to hand.
 */
static void rnd_mix(uint16_t entropy)
{
    rnd_state ^= entropy;
    if (rnd_state == 0u)
        rnd_state = 0xACE1u;      /* xorshift must never sit at zero */
    (void)rnd_next();
}

/* ------------------------------------------------------------------ scoring */
/** @brief Turn an error (in 0.1% units) into 0..100, reaching 0 at `zero_at`. */
static uint8_t score_from_err(uint16_t err_x10, uint16_t zero_at)
{
    if (err_x10 >= zero_at)
        return 0u;
    return (uint8_t)(100u - (((uint32_t)err_x10 * 100u) / zero_at));
}

static const char *grade_text(uint8_t s)
{
    if (s >= 90u) return "PERFECT";
    if (s >= 75u) return "GREAT";
    if (s >= 60u) return "GOOD";
    if (s >= 40u) return "OK";
    return "OFF BEAT";
}

/**
 * @brief Blink the grade, so the game is playable with no terminal attached.
 *        5 fast blinks = PERFECT ... 1 long blink = OFF BEAT.
 */
static void celebrate(uint8_t score, uint8_t new_best)
{
    uint8_t i, n;

    if (score >= 90u)      n = 5u;
    else if (score >= 75u) n = 4u;
    else if (score >= 60u) n = 3u;
    else if (score >= 40u) n = 2u;
    else                   n = 0u;        /* 0 means "one long sad blink" */

    if (n == 0u)
    {
        blink(700u, 300u);
    }
    else
    {
        for (i = 0; i < n; i++)
            blink(70u, 110u);
    }

    if (new_best)                          /* a quick flutter = new personal best */
    {
        wait_ms(250u);
        for (i = 0; i < 6u; i++)
            blink(30u, 50u);
    }
}

/* --------------------------------------------------------------------- setup */
static void board_init(void)
{
    /* LED0 on RC2: digital output, start dark. */
    ANSELCbits.ANSELC2 = 0;
    TRISCbits.TRISC2   = 0;
    LED_OFF();

    /* SW0 on RC3: digital input with the internal pull-up. The schematic notes
       no pull-ups are fitted on the board, so without WPU the pin floats.
       ANSELC3 must be cleared too or the PORT read always returns 0. */
    ANSELCbits.ANSELC3 = 0;
    TRISCbits.TRISC3   = 1;
    WPUCbits.WPUC3     = 1;
}

/**
 * @brief Three unmistakable blinks at power-up, before anything else runs.
 *
 * Deliberately uses NOTHING but __delay_ms(): no millisecond tick, no button,
 * no serial. So if these three blinks appear, the chip is programmed, running,
 * and driving RC2 correctly, and the game can be trusted to at least start.
 *
 * This is the "flash a slow blink first" rule from the breathing-LED lesson,
 * generalised: never let a peripheral hold up the first sign of life. Their
 * SPEED also reports the clock -- three blinks inside about a second means Fosc
 * is the intended 32 MHz.
 */
static void proof_of_life(void)
{
    uint8_t i;

    for (i = 0; i < 3u; i++)
    {
        LED_ON();  __delay_ms(120);
        LED_OFF(); __delay_ms(180);
    }
    __delay_ms(400);
}

/**
 * @brief Attract mode: heartbeat the LED until SW0 is pressed.
 *
 * Stays SILENT on the serial line while waiting -- the LED heartbeat is the
 * "I'm alive" indicator, and the log only records things that actually happen.
 * Consequence to remember: opening a terminal does not reset the board, so a
 * freshly-opened terminal shows nothing until you press SW0. That is normal.
 */
static void attract_until_press(void)
{
    (void)took_press();                   /* ignore a stale press */

    for (;;)
    {
        LED_ON();
        if (wait_ms_or_press(120u)) break;   /* a clear double-thump, not a blip */
        LED_OFF();
        if (wait_ms_or_press(180u)) break;
        LED_ON();
        if (wait_ms_or_press(120u)) break;
        LED_OFF();
        if (wait_ms_or_press(900u)) break;
    }

    LED_OFF();
    (void)took_press();
}

/* ---------------------------------------------------------------------- game */
void main(void)
{
    uint16_t round_no = 0;
    uint8_t  best     = 0;

    board_init();
    proof_of_life();      /* LED first: prove we are alive before any peripheral */
    uart_init();

    nl();
    print("=== GUESS THE TEMPO ===\r\n");
    print("The LED taps out a beat -- tap SW0 back at the\r\n");
    print("same speed. Only the spacing between your taps\r\n");
    print("is scored, not when you start.\r\n");
    /* Stating the range here also fingerprints the build: seeing this line
       proves the variable-length firmware is the one actually running. */
    print("Rounds are ");
    print_u16(MIN_TAPS);
    print("-");
    print_u16(MAX_TAPS);
    print(" beats -- count the blinks, tap back the same number.\r\n");

    for (;;)
    {
        uint16_t iv[MAX_TAPS - 1u];    /* your tap-to-tap intervals, ms */
        uint16_t target, avg, err_x10, dev_x10;
        uint16_t sum = 0, worst = 0;
        uint16_t t_prev = 0, t_now;
        uint8_t  beats;                /* this round's length, MIN_TAPS..MAX_TAPS */
        uint8_t  i, tempo_score, steady_score, total;
        uint8_t  timed_out = 0;

        nl();
        print("Waiting -- press SW0.\r\n");
        attract_until_press();
        rnd_mix(g_ms);                 /* entropy: when exactly you pressed */

        round_no++;
        target = TEMPO_MS[rnd_next() % TEMPO_COUNT];
        beats  = (uint8_t)(MIN_TAPS + (rnd_next() % (MAX_TAPS - MIN_TAPS + 1u)));

        print("START    round ");
        print_u16(round_no);
        print(", ");
        print_u16(beats);
        print(" beats at a secret tempo -- count them\r\n");

        wait_ms(LEAD_IN_MS);

        for (i = 0; i < beats; i++)
            blink(BLINK_MS, (uint16_t)(target - BLINK_MS));

        /* Cue: two quick flashes = your turn. */
        wait_ms(GAP_MS);
        blink(30u, 120u);
        blink(30u, 120u);

        print("MY TURN  tap SW0 ");
        print_u16(beats);
        print(" times\r\n");
        print("         ");

        (void)took_press();            /* drop anything tapped during the demo */

        if (!wait_press(FIRST_TAP_TIMEOUT))
        {
            timed_out = 1;
        }
        else
        {
            t_prev = g_ms;             /* first tap only starts the stopwatch */
            uart_write('.');           /* live feedback: one dot per tap */
            ack_flash();

            for (i = 0; i < beats - 1u; i++)
            {
                if (!wait_press(TAP_TIMEOUT)) { timed_out = 1; break; }
                t_now  = g_ms;
                iv[i]  = (uint16_t)(t_now - t_prev);   /* wrap-safe subtraction */
                t_prev = t_now;
                uart_write('.');
                ack_flash();
            }
        }

        nl();

        if (timed_out)
        {
            print("STOP     no tap in time -- round cancelled\r\n");
            blink(600u, 400u);         /* one long blink = round cancelled */
            continue;
        }

        print("STOP     ");
        print_u16((uint16_t)(beats - 1u));
        print(" intervals captured\r\n");

        /* --- how fast were you, on average? --- */
        for (i = 0; i < beats - 1u; i++)
            sum += iv[i];
        avg = (uint16_t)(sum / (beats - 1u));

        /* --- and how steady? worst single interval vs your own average --- */
        for (i = 0; i < beats - 1u; i++)
        {
            uint16_t d = (iv[i] > avg) ? (uint16_t)(iv[i] - avg)
                                       : (uint16_t)(avg - iv[i]);
            if (d > worst)
                worst = d;
        }

        err_x10 = (uint16_t)(((uint32_t)((avg > target) ? (avg - target)
                                                        : (target - avg))
                              * 1000UL) / target);
        dev_x10 = (uint16_t)(((uint32_t)worst * 1000UL) / avg);

        tempo_score  = score_from_err(err_x10, TEMPO_ZERO_ERR_X10);
        steady_score = score_from_err(dev_x10, STEADY_ZERO_DEV_X10);
        total        = (uint8_t)(((uint16_t)tempo_score * 3u + steady_score) / 4u);

        /* --- the scorecard --- */
        print("GRADE    ");
        print_u16_w(total, 3u);
        print("/100  ");
        print(grade_text(total));
        nl();

        print("  target  ");
        print_u16_w((uint16_t)(60000UL / target), 3u);
        print(" BPM  (");
        print_u16(target);
        print(" ms)\r\n");

        print("  you     ");
        print_u16_w((uint16_t)(60000UL / avg), 3u);
        print(" BPM  (");
        print_u16(avg);
        print(" ms)   ");
        print_tenths(err_x10);
        print("% ");
        if (avg < target)      print("FASTER\r\n");
        else if (avg > target) print("SLOWER\r\n");
        else                   print("SPOT ON\r\n");

        print("  taps   ");
        for (i = 0; i < beats - 1u; i++)
        {
            uart_write(' ');
            print_u16(iv[i]);
        }
        print(" ms\r\n");

        print("  tempo   ");
        print_u16_w(tempo_score, 3u);
        print("/100  ");
        print_bar(tempo_score);
        nl();

        print("  steady  ");
        print_u16_w(steady_score, 3u);
        print("/100  ");
        print_bar(steady_score);
        nl();

        if (total > best)
        {
            best = total;
            print("  best    ");
            print_u16_w(best, 3u);
            print("/100  NEW BEST!\r\n");
            celebrate(total, 1u);
        }
        else
        {
            print("  best    ");
            print_u16_w(best, 3u);
            print("/100\r\n");
            celebrate(total, 0u);
        }
    }
}

Credits

Negar Rafieedolatabadi
7 projects • 6 followers

Comments