g | x | w | all
Bytes Lang Time Link
075Ruby rio/console240919T200321ZJordan
423TurboWarp240921T121932ZRhaixer
088C Turbo C240919T150244Zjdt
403Rust240921T115311Zuser9403
123Nim240923T090113ZjanAkali
040x86 .COM opcode240919T071100Zl4m2
076HTML240919T082944ZKevin Cr
nan240922T173205ZA.J Serv
101Python240920T162502ZWilliam
080R + cli240921T104256ZDominic
212Java 21240921T115556ZJuuz
083QBasic240919T182446ZDLosc
087Zsh240919T132850ZGammaFun
121ZX Spectrum 48K/128K Basic240919T164810ZNeil

Ruby -rio/console, 79 75 bytes

Contains unprintable characters. (See ungolfed code).

i=0
(c=STDIN.getch
$><<c==??i>0&&(i-=1
" "):/\d/=~c&&(i+=1
?*))while i<4

Ungolfed

i = 0
(
  c = STDIN.getch
  $> << c == "\x7f" ?
    i > 0 && (
      i -= 1
      "\x08 \x08"
    ) :
    /\d/ =~ c && (
      i += 1
      "*"
    )
) while i < 4

TurboWarp, 424 423 bytes

Try it here.

Key-presses are really hard to manage well.

NOTE: This will not work in Scratch, because backspace is only detectable in TurboWarp.

NOTE: In the scratchblocks version and the picture, I used a custom block containing 1 argument. This was because I had to actually use the block rather than leave it as a function, and scratchblocks needs something in a custom block to recognize it as one. The project linked to will use an empty block, however.

edit: translating into scratchblocks is HARD

define(
set[i v]to(0
repeat until<(i)>(9
if<key(i)pressed?>then
set[x v]to(join(x)[*
stop[this script v
end
change[i v]by(1
end
when gf clicked
set[x v]to[
repeat until<(length of(x))=(4
if<<key(any v)pressed?>and<not<key(backspace v)pressed?>>>then
(
end
if<key(backspace v)pressed?>then
set[j v]to((length of(x))-(2
set[x v]to[
repeat until<(length of(x))>(j
set[x v]to(join(x)[*
end
end
wait until<not<key(any v)pressed?

enter image description here

C (Turbo C), 89 88 bytes

main(n,c){for(;write(1,"\r    \r****",(c-8?c>47&c<58?++n:n:n>1?--n:n)+5)<10;)c=getch();}

Interactive Demo

Rust, 484 468 458 426 403 bytes

use std::io::{stdin,stdout,Write};use termion::{clear,cursor::Left,event::Key::{Backspace,Char},input::TermRead,raw::IntoRawMode};fn main(){let(mut o,mut l)=(stdout().into_raw_mode().unwrap(),0);for k in stdin().keys(){match k.unwrap(){Char(c)=>{if c.is_digit(10){write!(o,"*").unwrap();l+=1}}Backspace=>{write!(o,"{}{}",Left(1),clear::AfterCursor).unwrap();l-=1}_=>()}o.flush().unwrap();if l>3{break}}}

This solution uses Termion, and so doesn't support Windows.

I'm new to terminal manipulation so this probably isn't the most concise way to do this.

Ungolfed:

use std::io::{stdin, stdout, Write};
use termion::{
    clear,
    cursor::Left,
    event::Key::{Backspace, Char},
    input::TermRead,
    raw::IntoRawMode,
};
fn main() {
    let (mut o, mut l) = (stdout().into_raw_mode().unwrap(), 0);
    for k in stdin().keys() {
        match k.unwrap() {
            Char(c) => {
                if c.is_digit(10) {
                    write!(o, "*").unwrap();
                    l += 1
                }
            }
            Backspace => {
                write!(o, "{}{}", Left(1), clear::AfterCursor).unwrap();
                l -= 1
            }
            _ => (),
        }
        o.flush().unwrap();
        if l > 3 {
            break;
        }
    }
}

Nim, 123 bytes

Includes non-printable chars:

import terminal;let w=writeStyled
var n=0;while n<4:n+=(case getch()
of'0'..'9':(w"*";1)
of'':(w"[D[J";-int n>0)
else:0)

Version without non-printable chars, 129 bytes:

import terminal;let w=writeStyled
var n=0;while n<4:n+=(case getch()
of'0'..'9':(w"*";1)
of'\x7F':(w "\e[D\e[J";-int n>0)
else:0)

x86 .COM opcode, 40 bytes

Not placing on left-to corner saves: https://i.sstatic.net/GsmDekFQ.png

    org 100h
    mov bx, 0AE00h
    mov ds, bx
f:  mov ah, 0
    int 16h
    cmp al, 8
    je  bksp
    sub al, '0'
    cmp al, 10
    jnb f
    mov [bx], byte '*'
t:  inc bx
    inc bx
    cmp bl, 8
    jb  f
    ret
bksp:
    sub bl, 2
    jc  t
    mov [bx], byte ' '
    jmp f

00000000: bb00 ae8e dbb4 00cd 163c 0874 112c 303c  .........<.t.,0<
00000010: 0a73 f2c6 072a 4343 80fb 0872 e8c3 80eb  .s...*CC...r....
00000020: 0272 f3c6 0720 ebdd                      .r... ..

HTML, 78 bytes

<input onkeydown=return!value[3]&&(c=event.which^48)-56&&![value+=9<c?'':'*']>

From Kevin Cruijssen's solution, doesn't handle Shift well

HTML, 150 148 134 100 94 84 80 77 76 bytes

<input type=password onkeydown=c=event.key;return!(value[3]||c[5]!='p'&!++c)

-54 bytes thanks to @Shaggy.
-10 bytes thanks to @l4m2 and @jdt.
-4 bytes thanks to @jdt again.

I'm still trying to find a browser that uses asterisk for password-fields instead of bullets (the four browsers I had installed - Chrome; Firefox; Edge; Opera - unfortunately all use bullets). But certain old browser versions defintely used asterisks for password input-fields, so it's assumed this snippet is used in one of those browsers / versions.

Explanation:

Known issues:

<input type=password onkeydown=e=event;return!(value[3]||!++e.key&e.which!=8)
https://codegolf.stackexchange.com/questions/275630/enter-a-personal-identification-number/275634#275634

Python, 101 bytes

import sys;s=0
while s<4:
 c=sys.stdin.read(1)
 s+=c.isdigit()-(c=='\x08')
 print("*"*s+'  ',end='\r')

This script only works on a terminal set to raw input mode. On UNIX, you can achieve this using stty raw.

Windows version, 102 bytes

import msvcrt;s=0
while s<4:
 c=msvcrt.getch()
 s+=c.isdigit()-(c==b'\x08')
 print("*"*s+' ',end='\r')

This second version uses msvcrt, which is only available on Windows, but it does not require raw input mode.

R + cli, 91 84 80 bytes

while(F<4){cat(c('\b \b','*')[x<-(nchar(k<-cli::keypress())>8&F)-k%in%0:9]);F=F-x}

(\b represents a single 08 byte in the source code)

The R cli (command line interface) library indicates that keypress() "currently only works at Linux/Unix and OSX terminals, and at the Windows command line", so unfortunately I don't know of an online site where this will run correctly. It runs fine in my OSX terminal.

keypress() returns the character representing the key pressed, or a string representing 'special' keys, of which "backspace" is the only one with >8 characters, so nchar()>8 saves 4 bytes compared to =="backspace" and 3 bytes compared to grepl("ba",).

Java 21, 212 bytes

A graphical solution with AWT. The output is displayed in the window title. At least on Windows 10, you need to resize the window so you can see the title, or look at the taskbar etc.

demo of the window with an on-screen keyboard

new java.awt.Frame(){{addKeyListener(new java.awt.event.KeyAdapter(){int j,c;public void keyTyped(java.awt.event.KeyEvent e){c=e.getKeyChar();setTitle("*".repeat(j+=j>3?0:47<c&&c<58?1:j>0&&c<9?-1:0));}});}}::show

The code is a Runnable (or similar) method reference.

Commented

new java.awt.Frame() {{ // Create a new anonymous Frame subclass
    addKeyListener(new java.awt.event.KeyAdapter() {
        int j, c; // j = title length, c = the current character
        public void keyTyped(java.awt.event.KeyEvent e) {
            c = e.getKeyChar();
            setTitle("*".repeat(
                j += j > 3 // if the length is 4, don't modify it
                    ? 0
                    : 47 < c && c < 58 // if the character is a digit,
                        ? 1            // add one asterisk
                        : j > 0 && c < 9 // key 8 is the backspace and other
                                         // control chars don't get this event
                            ? -1 // remove an asterisk if there are any
                            : 0
            ));
        }
    });
}}::show // show the frame when called

QBasic, 83 bytes

CLS
1c=ASC(INPUT$(1))
d=d-(d>0)*(c=8)+(c>47)*(c<58)
CLS
?STRING$(d,42)
IF d<4GOTO 1

Try it at Archive.org!

Explanation

CLS

Clear the screen.

1

Line number (used as a GOTO target later).

c = ASC(INPUT$(1))

Read one character from user input, get its ASCII code, and store that in c. Digits are 48-57; backspace is 8.

d = d - (d > 0) * (c = 8) + (c > 47) * (c < 58)

Update d, the number of digits entered so far. (Like all numeric variables in QBasic, it starts out with a value of 0.)

All other keypresses have no effect on d.

CLS
PRINT STRING$(d, 42)

Clear the screen and print a string of d asterisks (ASCII code 42).

IF d < 4 THEN GOTO 1

If the number does not have four digits yet, go back to line 1 and input another character. Otherwise, the program is over and halts.

The leading CLS is perhaps not necessary. Without it, the screen initially shows the results of the last program run, and it is only cleared once the user presses their first key. Since this includes any key, not just the number & backspace keys, I thought it probably ran afoul of "All other key presses should be ignored," so I included an initial clear-screen just in case.

Zsh, 94 87 bytes

Lost 3 bytes to fix a bug (if first key is backspace, a[1]= makes $a an array). Saved 3 bytes by not using %s in the printf statement, then 7 more by switching from while to for.

a=
for ((;$#a<4;))
{read -sk1 k
case $k {$'\C-?')a[1]=;;[0-9])a+=*;}
printf '\r\e[K'$a}

asterisks=
# $#foo: length of $foo
for ((;$#asterisks < 4;)); {
    # -s: silent/noecho, -k 1: one keypress
    read -s -k 1 key
    case $key {
        # backspace: delete first character from asterisks string
        $'\C-?') asterisks[1]= ;;
        [0-9])   asterisks+=*
    }
    # \r: start of line, \e[K: clear to end of line
    printf '\r\e[K'$asterisks
}

ZX Spectrum 48K/128K Basic, 121 bytes

   2 LET a=NOT PI
   3 IF INKEY$<>"" THEN GO TO PI
   4 LET a$=INKEY$: IF a$="" THE
N GOTO VAL "4"
   5 LET a=a+(a$>="0" AND a$<="9
")-(CODE a$=VAL "12" AND a)
   6 PRINT AT NOT PI,NOT PI;"***
*"(TO a),
   7 IF a<PI THEN GO TO PI

The above is how the program lists on screen as the ZX Spectrum's screen is 32 characters wide. The byte count was calculated by subtracting the system variables for the end and start of the program; this will be one less than the save file length as an empty program has a save file length of 1. Unfortunately I was using an online emulator which only has a save snapshot option, so you'll have to retype this yourself to test it. When typing this in 128K Basic you may notice the editor adding extra spaces but these do not take up any extra bytes, while when typing this in 48K Basic you need to ensure you use all of the tokens including <>, >= and <=. Explanation:

   2 LET a=NOT PI

Start with no digits entered. (Literal numbers cost their length plus six bytes so when golfing you need to use any means possible to avoid them.)

   3 IF INKEY$<>"" THEN GO TO PI

Wait until the current key is released. (Yes, the line number was chosen so that the GO TO could be as short as possible.)

   4 LET a$=INKEY$: IF a$="" THEN GOTO VAL "4"

Wait until a key is pressed, capturing its value in a$.

   5 LET a=a+(a$>="0" AND a$<="9")-(CODE a$=VAL "12" AND a)

Adjust the number of digits according to whether the key was a digit or the code for delete (which is what the ZX Spectrum calls backspace). Note that AND evaluates both its arguments and returns the first if the second is nonzero.

   6 PRINT AT NOT PI,NOT PI;"****"(TO a),

Output the corresponding number of *s. The , is normally used to jump to the next tab stop, but conveniently it outputs a space first, which will delete the last * if the last key was the delete key.

   7 IF a<PI THEN GO TO PI

Repeat until 4 digits have been input. (PI is doing a lot of heavy lifting in this program!)

This program could also be adapted to run on the ZX81 but you would need to split line 4 up into two lines as the ZX81 only accepts one statement per line except in IF...THEN statements. This would increase the byte count by 4.